diff --git a/.agents/skills/isaaclab-building-environments b/.agents/skills/isaaclab-building-environments new file mode 120000 index 00000000000..9c86809f4be --- /dev/null +++ b/.agents/skills/isaaclab-building-environments @@ -0,0 +1 @@ +../../skills/user/create-environments \ No newline at end of file diff --git a/.agents/skills/isaaclab-converting-direct-to-manager b/.agents/skills/isaaclab-converting-direct-to-manager new file mode 120000 index 00000000000..d4c837fe48a --- /dev/null +++ b/.agents/skills/isaaclab-converting-direct-to-manager @@ -0,0 +1 @@ +../../skills/user/convert-direct-to-manager \ No newline at end of file diff --git a/.agents/skills/isaaclab-debugging-rl-training b/.agents/skills/isaaclab-debugging-rl-training new file mode 120000 index 00000000000..74784549231 --- /dev/null +++ b/.agents/skills/isaaclab-debugging-rl-training @@ -0,0 +1 @@ +../../skills/user/debug-rl-training \ No newline at end of file diff --git a/.agents/skills/isaaclab-diagnosing-joint-poses b/.agents/skills/isaaclab-diagnosing-joint-poses new file mode 120000 index 00000000000..43c148228f1 --- /dev/null +++ b/.agents/skills/isaaclab-diagnosing-joint-poses @@ -0,0 +1 @@ +../../skills/user/diagnose-joint-poses \ No newline at end of file diff --git a/.agents/skills/isaaclab-following-coding-style b/.agents/skills/isaaclab-following-coding-style new file mode 120000 index 00000000000..9b10aebe4d6 --- /dev/null +++ b/.agents/skills/isaaclab-following-coding-style @@ -0,0 +1 @@ +../../skills/developer/coding-style \ No newline at end of file diff --git a/.agents/skills/isaaclab-migrating-2x-to-3x b/.agents/skills/isaaclab-migrating-2x-to-3x new file mode 120000 index 00000000000..1d0a9313b8f --- /dev/null +++ b/.agents/skills/isaaclab-migrating-2x-to-3x @@ -0,0 +1 @@ +../../skills/user/migrate-2x-to-3x \ No newline at end of file diff --git a/.agents/skills/isaaclab-migrating-from-isaac-gym b/.agents/skills/isaaclab-migrating-from-isaac-gym new file mode 120000 index 00000000000..5b7b5de5d6b --- /dev/null +++ b/.agents/skills/isaaclab-migrating-from-isaac-gym @@ -0,0 +1 @@ +../../skills/user/migrate-from-isaac-gym \ No newline at end of file diff --git a/.agents/skills/isaaclab-planning-manipulation-tasks b/.agents/skills/isaaclab-planning-manipulation-tasks new file mode 120000 index 00000000000..b13818198cf --- /dev/null +++ b/.agents/skills/isaaclab-planning-manipulation-tasks @@ -0,0 +1 @@ +../../skills/user/plan-manipulation-tasks \ No newline at end of file diff --git a/.agents/skills/isaaclab-preparing-assets-for-newton b/.agents/skills/isaaclab-preparing-assets-for-newton new file mode 120000 index 00000000000..b93f7ba2092 --- /dev/null +++ b/.agents/skills/isaaclab-preparing-assets-for-newton @@ -0,0 +1 @@ +../../skills/user/prepare-assets-for-newton \ No newline at end of file diff --git a/.agents/skills/isaaclab-preparing-pr-workflow b/.agents/skills/isaaclab-preparing-pr-workflow new file mode 120000 index 00000000000..7bbc45088e3 --- /dev/null +++ b/.agents/skills/isaaclab-preparing-pr-workflow @@ -0,0 +1 @@ +../../skills/developer/pr-workflow \ No newline at end of file diff --git a/.agents/skills/isaaclab-randomizing-with-events b/.agents/skills/isaaclab-randomizing-with-events new file mode 120000 index 00000000000..cfaab189258 --- /dev/null +++ b/.agents/skills/isaaclab-randomizing-with-events @@ -0,0 +1 @@ +../../skills/user/domain-randomization-events \ No newline at end of file diff --git a/.agents/skills/isaaclab-selecting-backends b/.agents/skills/isaaclab-selecting-backends new file mode 120000 index 00000000000..65ac937bafe --- /dev/null +++ b/.agents/skills/isaaclab-selecting-backends @@ -0,0 +1 @@ +../../skills/user/select-backends \ No newline at end of file diff --git a/.agents/skills/isaaclab-setup-troubleshooting b/.agents/skills/isaaclab-setup-troubleshooting new file mode 120000 index 00000000000..4eaf4990a3a --- /dev/null +++ b/.agents/skills/isaaclab-setup-troubleshooting @@ -0,0 +1 @@ +../../skills/user/setup-troubleshooting \ No newline at end of file diff --git a/.agents/skills/isaaclab-training-rl-agents b/.agents/skills/isaaclab-training-rl-agents new file mode 120000 index 00000000000..63e5a3a2a68 --- /dev/null +++ b/.agents/skills/isaaclab-training-rl-agents @@ -0,0 +1 @@ +../../skills/user/train-rl-agents \ No newline at end of file diff --git a/.agents/skills/isaaclab-using-presets b/.agents/skills/isaaclab-using-presets new file mode 120000 index 00000000000..6abea13ebf0 --- /dev/null +++ b/.agents/skills/isaaclab-using-presets @@ -0,0 +1 @@ +../../skills/user/use-presets \ No newline at end of file diff --git a/.agents/skills/isaaclab-using-sensors-actuators b/.agents/skills/isaaclab-using-sensors-actuators new file mode 120000 index 00000000000..1f21fba7f2b --- /dev/null +++ b/.agents/skills/isaaclab-using-sensors-actuators @@ -0,0 +1 @@ +../../skills/user/use-sensors-actuators \ No newline at end of file diff --git a/.agents/skills/isaaclab-writing-changelog-fragments b/.agents/skills/isaaclab-writing-changelog-fragments new file mode 120000 index 00000000000..8f112d7cff4 --- /dev/null +++ b/.agents/skills/isaaclab-writing-changelog-fragments @@ -0,0 +1 @@ +../../skills/developer/changelog-fragments \ No newline at end of file diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/actions/_lib/compute-deps-hash/action.yml b/.github/actions/_lib/compute-deps-hash/action.yml new file mode 100644 index 00000000000..218339cbac9 --- /dev/null +++ b/.github/actions/_lib/compute-deps-hash/action.yml @@ -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 + +name: 'Compute deps hash' +description: > + Compute the deps-cache hash for the Isaac Lab Docker build. Shared by the + docker-build (local-store check) and ecr-build-push-pull (registry check) + actions so a local hit and a registry hit always agree on the same + `deps-` tag. Hashes the install-relevant files, resolved base image + digest, and target platform. + +inputs: + dockerfile-path: + description: 'Path to Dockerfile' + required: true + isaacsim-base-image: + description: 'IsaacSim base image' + required: true + isaacsim-version: + description: 'IsaacSim version' + required: true + platform: + description: 'Target platform included in the dependency-cache identity' + default: 'linux/amd64' + required: false + +outputs: + hash: + description: '16-char deps-cache hash' + value: ${{ steps.compute.outputs.hash }} + +runs: + using: composite + steps: + - id: compute + shell: bash + env: + DOCKERFILE_PATH: ${{ inputs.dockerfile-path }} + ISAACSIM_BASE_IMAGE: ${{ inputs.isaacsim-base-image }} + ISAACSIM_VERSION: ${{ inputs.isaacsim-version }} + TARGET_PLATFORM: ${{ inputs.platform }} + run: | + set -euo pipefail + + # Exact files/dirs whose full content is hashed. The Dockerfile is first. + deps_files=( + "${DOCKERFILE_PATH}" + isaaclab.sh + environment.yml + source/isaaclab/isaaclab/cli + ) + deps_manifest_pattern='(setup\.py|pyproject\.toml|setup\.cfg|extension\.toml|requirements[^/]*\.txt|uv\.lock)$' + + # Resolve the actual base image digest so a new push of a mutable tag + # (e.g. latest-develop) invalidates the deps cache automatically. + base_image_digest=$(docker buildx imagetools inspect \ + "${ISAACSIM_BASE_IMAGE}:${ISAACSIM_VERSION}" \ + --format '{{json .Manifest.Digest}}' 2>/dev/null | tr -d '"' || true) + if [ -n "${base_image_digest}" ]; then + base_image_uniq_id="${ISAACSIM_BASE_IMAGE}:${ISAACSIM_VERSION}:${base_image_digest}" + else + echo "🟠 Could not resolve base image digest, falling back to tag string" + base_image_uniq_id="${ISAACSIM_BASE_IMAGE}:${ISAACSIM_VERSION}" + fi + + mapfile -t manifest_files < <(git ls-files | grep -E "${deps_manifest_pattern}" || true) + file_hash=$(git ls-files -s "${deps_files[@]}" "${manifest_files[@]}" 2>/dev/null \ + | sha256sum | cut -c1-16) + deps_hash=$(printf '%s %s %s' "${file_hash}" "${base_image_uniq_id}" "${TARGET_PLATFORM}" \ + | sha256sum | cut -c1-16) + + echo "šŸ”µ Deps hash: ${deps_hash}" + echo "hash=${deps_hash}" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/_lib/setup-docker-config/action.yml b/.github/actions/_lib/setup-docker-config/action.yml new file mode 100644 index 00000000000..09effa56eaa --- /dev/null +++ b/.github/actions/_lib/setup-docker-config/action.yml @@ -0,0 +1,43 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +name: 'Setup docker config' +description: > + Point DOCKER_CONFIG at a temp config with the credential helper disabled and + log into nvcr.io. Shared by the docker-build and ecr-build-push-pull actions. + Idempotent: re-invoking it in the same job is a no-op, so callers (e.g. + ecr-build-push-pull delegating to docker-build) don't need to coordinate. + Reads NGC_API_KEY from the environment (optional; warns when missing). + +runs: + using: composite + steps: + - shell: bash + run: | + # The runner's credential helper backend is broken ("not implemented") + # and causes docker login calls to fail unless we point DOCKER_CONFIG at + # a temp config with credsStore disabled. The value is written to + # $GITHUB_ENV so subsequent steps in the job inherit it; a second + # invocation sees it already set and short-circuits. + if [ -n "${DOCKER_CONFIG:-}" ] && [ -f "${DOCKER_CONFIG}/config.json" ]; then + echo "🟢 Docker config already set up at ${DOCKER_CONFIG}, skipping" + exit 0 + fi + + DOCKER_CONFIG_DIR=$(mktemp -d) + if [ -f "${HOME}/.docker/config.json" ]; then + python3 -c "import json; cfg=json.load(open('${HOME}/.docker/config.json')); cfg['credsStore']=''; cfg.pop('credHelpers',None); json.dump(cfg,open('${DOCKER_CONFIG_DIR}/config.json','w'))" + else + echo '{"credsStore":""}' > "${DOCKER_CONFIG_DIR}/config.json" + fi + export DOCKER_CONFIG="${DOCKER_CONFIG_DIR}" + echo "DOCKER_CONFIG=${DOCKER_CONFIG_DIR}" >> "$GITHUB_ENV" + + if [ -n "${NGC_API_KEY:-}" ]; then + echo "šŸ”µ Logging into nvcr.io..." + docker login -u '$oauthtoken' -p "${NGC_API_KEY}" nvcr.io + else + echo "🟠 NGC_API_KEY not set - skipping nvcr.io login (normal for fork PRs)" + fi diff --git a/.github/actions/docker-build/action.yml b/.github/actions/docker-build/action.yml index 7f88241cfb8..8ae7110c78b 100644 --- a/.github/actions/docker-build/action.yml +++ b/.github/actions/docker-build/action.yml @@ -24,61 +24,222 @@ inputs: description: 'Build context path' default: '.' required: false + platform: + description: 'Target platform for `docker buildx build --platform`.' + default: 'linux/amd64' + required: false + cache-from: + description: > + Optional value for `docker buildx build --cache-from`. Typically a + `type=registry,ref=` for cross-host layer cache. Leave empty for + pure local-only builds. + default: '' + required: false + cache-to: + description: > + Optional value for `docker buildx build --cache-to`. Pairs with + `cache-from` for registry-backed layer cache writes. + default: '' + required: false + deps-hash: + description: > + Pre-computed deps-hash to use for the local deps-tag check. When empty, + this action computes the hash itself via the `_lib/compute-deps-hash` + action. Set by callers (e.g. `ecr-build-push-pull`) that already compute + the hash for a registry-side check, to avoid recomputing here. + default: '' + required: false + evict-stale-cache: + description: > + When 'true', evict `isaac-lab*:deps-*` tags older than 14 days at the + end of the build to bound disk growth on long-lived self-hosted + runners. Default 'false' — no implicit cleanup. + default: 'false' + required: false runs: using: composite steps: - - name: NGC Login - shell: sh + + ##### 1: Setup docker config + login to nvcr.io (optional) ##### + + - name: Setup docker config and login to nvcr.io + uses: ./.github/actions/_lib/setup-docker-config + + ##### 2: Host disk snapshot (pre) ##### + + - name: Host disk snapshot (pre) + shell: bash + run: | + set +e + docker_root=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo "/var/lib/docker") + deps_count=$(docker images --filter 'reference=isaac-lab*:deps-*' -q 2>/dev/null | wc -l) + commit_count=$(docker images --filter 'reference=isaac-lab*' -q 2>/dev/null | wc -l) + { + echo "## Disk snapshot (pre)" + echo '```' + echo "Filesystem:" + df -h / "${docker_root}" 2>/dev/null | sort -u + echo + echo "docker system df:" + docker system df + echo + echo "Tag counts:" + echo " isaac-lab* (commit + deps tags): ${commit_count}" + echo " isaac-lab*:deps-* (deps cache) : ${deps_count}" + echo + echo "Deps tags (newest first):" + docker images --filter 'reference=isaac-lab*:deps-*' \ + --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}' 2>/dev/null \ + | head -20 + echo '```' + } | tee -a "$GITHUB_STEP_SUMMARY" + + ##### 3: Local exact-tag short-circuit ##### + + - name: Check image locally + id: local + shell: bash + run: | + if docker image inspect "${{ inputs.image-tag }}" >/dev/null 2>&1; then + echo "🟢 Image already in local docker store: ${{ inputs.image-tag }}" + echo "hit=true" >> "$GITHUB_OUTPUT" + else + echo "šŸ”µ Image not present locally, will check deps-cache / build" + fi + + ##### 4: Local deps-tag short-circuit ##### + + - name: Compute deps hash + id: deps-hash + if: steps.local.outputs.hit != 'true' && inputs.deps-hash == '' + uses: ./.github/actions/_lib/compute-deps-hash + with: + dockerfile-path: ${{ inputs.dockerfile-path }} + isaacsim-base-image: ${{ inputs.isaacsim-base-image }} + isaacsim-version: ${{ inputs.isaacsim-version }} + platform: ${{ inputs.platform }} + + - name: Check deps-tag locally + id: local-deps + if: steps.local.outputs.hit != 'true' + shell: bash run: | - # Only attempt NGC login if API key is available - if [ -n "${{ env.NGC_API_KEY }}" ]; then - echo "Logging into NGC registry..." - docker login -u \$oauthtoken -p ${{ env.NGC_API_KEY }} nvcr.io - echo "āœ… Successfully logged into NGC registry" + DEPS_HASH="${{ inputs.deps-hash || steps.deps-hash.outputs.hash }}" + LOCAL_DEPS_TAG="$(echo "${{ inputs.image-tag }}" | cut -d: -f1):deps-${DEPS_HASH}" + + echo "šŸ”µ Local deps tag: ${LOCAL_DEPS_TAG}" + echo "LOCAL_DEPS_TAG=${LOCAL_DEPS_TAG}" >> "$GITHUB_ENV" + + if docker image inspect "${LOCAL_DEPS_TAG}" >/dev/null 2>&1; then + echo "🟢 Local deps-cache HIT! Retagging as ${{ inputs.image-tag }}" + docker tag "${LOCAL_DEPS_TAG}" "${{ inputs.image-tag }}" + echo "hit=true" >> "$GITHUB_OUTPUT" + else + echo "🟠 Local deps-cache MISS (will build then tag for future hits)" + fi + + ##### 5: Full build ##### + + - name: Build image + id: build + if: > + steps.local.outputs.hit != 'true' && + steps.local-deps.outputs.hit != 'true' + shell: bash + run: | + BUILD_ARGS=( + --progress=plain + --platform "${{ inputs.platform }}" + -f "${{ inputs.dockerfile-path }}" + --build-arg "ISAACSIM_BASE_IMAGE_ARG=${{ inputs.isaacsim-base-image }}" + --build-arg "ISAACSIM_VERSION_ARG=${{ inputs.isaacsim-version }}" + --build-arg "ISAACSIM_ROOT_PATH_ARG=/isaac-sim" + --build-arg "ISAACLAB_PATH_ARG=/workspace/isaaclab" + --build-arg "DOCKER_USER_HOME_ARG=/root" + -t "${{ inputs.image-tag }}" + ) + if [ -n "${{ inputs.cache-from }}" ]; then + BUILD_ARGS+=( --cache-from "${{ inputs.cache-from }}" ) + fi + if [ -n "${{ inputs.cache-to }}" ]; then + BUILD_ARGS+=( --cache-to "${{ inputs.cache-to }}" ) + fi + + BUILDER_NAME="docker-build-${{ github.run_id }}-${{ github.job }}" + docker buildx create --use --driver docker-container --name "${BUILDER_NAME}" \ + || docker buildx use "${BUILDER_NAME}" + trap 'docker buildx rm "${BUILDER_NAME}" || true' EXIT + + echo "šŸ”µ Building ${{ inputs.image-tag }}..." + docker buildx build --load "${BUILD_ARGS[@]}" "${{ inputs.context-path }}" + echo "was-built=true" >> "$GITHUB_OUTPUT" + + ##### 6: Tag built image with local deps-tag ##### + + # Runs only when a real build happened (not on cache hits). Populates the + # deps-tag so the next build with identical deps short-circuits at step 4. + + - name: Tag built image with local deps-tag + if: steps.build.outputs.was-built == 'true' + shell: bash + run: | + if [ -n "${LOCAL_DEPS_TAG:-}" ]; then + docker tag "${{ inputs.image-tag }}" "${LOCAL_DEPS_TAG}" + echo "🟢 Tagged local deps-cache: ${LOCAL_DEPS_TAG}" else - echo "āš ļø NGC_API_KEY not available - skipping NGC login" - echo "This is normal for PRs from forks or when secrets are not configured" + echo "🟠 LOCAL_DEPS_TAG not set, skipping local deps-cache tag" fi - - name: Build Docker Image - shell: sh + ##### 7: Evict stale local deps-cache tags (>14d) — opt-in ##### + + - name: Evict stale local deps-cache tags (>14d) + if: always() && inputs.evict-stale-cache == 'true' + shell: bash run: | - # Function to build Docker image - build_docker_image() { - local image_tag="$1" - local isaacsim_base_image="$2" - local isaacsim_version="$3" - local dockerfile_path="$4" - local context_path="$5" - - # Skip build if image already exists locally (e.g. built by a prior job on the same runner) - if docker image inspect "$image_tag" > /dev/null 2>&1; then - echo "Image $image_tag already exists locally, skipping build." - return 0 + set +e + TTL_DAYS=14 + cutoff=$(date -u -d "${TTL_DAYS} days ago" +%s) + evicted=0 + while IFS='|' read -r created tag; do + [ -z "$tag" ] && continue + created_epoch=$(date -d "$created" +%s 2>/dev/null) || continue + if [ "$created_epoch" -lt "$cutoff" ]; then + days_old=$(( (cutoff - created_epoch) / 86400 + TTL_DAYS )) + echo "🟠 Evicting deps tag (~${days_old}d old): ${tag}" + docker rmi -f "$tag" >/dev/null 2>&1 || true + evicted=$(( evicted + 1 )) fi + done < <(docker images --filter 'reference=isaac-lab*:deps-*' \ + --format '{{.CreatedAt}}|{{.Repository}}:{{.Tag}}' 2>/dev/null) + echo "šŸ”µ Evicted ${evicted} deps tag(s) older than ${TTL_DAYS}d" - echo "Building Docker image: $image_tag" - echo "Using Dockerfile: $dockerfile_path" - echo "Build context: $context_path" - - # Build Docker image - docker buildx build --progress=plain --platform linux/amd64 \ - -t $image_tag \ - --build-arg ISAACSIM_BASE_IMAGE_ARG="$isaacsim_base_image" \ - --build-arg ISAACSIM_VERSION_ARG="$isaacsim_version" \ - --build-arg ISAACSIM_ROOT_PATH_ARG=/isaac-sim \ - --build-arg ISAACLAB_PATH_ARG=/workspace/isaaclab \ - --build-arg DOCKER_USER_HOME_ARG=/root \ - --cache-from type=gha \ - --cache-to type=gha,mode=max \ - -f $dockerfile_path \ - --load $context_path - - echo "āœ… Docker image built successfully: $image_tag" - echo "Current local Docker images:" - docker images - } - - # Call the function with provided parameters - build_docker_image "${{ inputs.image-tag }}" "${{ inputs.isaacsim-base-image }}" "${{ inputs.isaacsim-version }}" "${{ inputs.dockerfile-path }}" "${{ inputs.context-path }}" + ##### 8: Host disk snapshot (post) ##### + + - name: Host disk snapshot (post) + if: always() + shell: bash + run: | + set +e + docker_root=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo "/var/lib/docker") + deps_count=$(docker images --filter 'reference=isaac-lab*:deps-*' -q 2>/dev/null | wc -l) + commit_count=$(docker images --filter 'reference=isaac-lab*' -q 2>/dev/null | wc -l) + { + echo "## Disk snapshot (post)" + echo '```' + echo "Filesystem:" + df -h / "${docker_root}" 2>/dev/null | sort -u + echo + echo "docker system df:" + docker system df + echo + echo "Tag counts:" + echo " isaac-lab* (commit + deps tags): ${commit_count}" + echo " isaac-lab*:deps-* (deps cache) : ${deps_count}" + echo + echo "Deps tags (newest first):" + docker images --filter 'reference=isaac-lab*:deps-*' \ + --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}' 2>/dev/null \ + | head -20 + echo '```' + } | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/ecr-build-push-pull/action.yml b/.github/actions/ecr-build-push-pull/action.yml index b661d4b9fd6..d6fe0cb2798 100644 --- a/.github/actions/ecr-build-push-pull/action.yml +++ b/.github/actions/ecr-build-push-pull/action.yml @@ -50,23 +50,7 @@ runs: # (including ECR login in step 3) inherit it automatically. - name: Setup docker config and login to nvcr.io - shell: bash - run: | - DOCKER_CONFIG_DIR=$(mktemp -d) - if [ -f "${HOME}/.docker/config.json" ]; then - python3 -c "import json; cfg=json.load(open('${HOME}/.docker/config.json')); cfg['credsStore']=''; cfg.pop('credHelpers',None); json.dump(cfg,open('${DOCKER_CONFIG_DIR}/config.json','w'))" - else - echo '{"credsStore":""}' > "${DOCKER_CONFIG_DIR}/config.json" - fi - echo "DOCKER_CONFIG=${DOCKER_CONFIG_DIR}" >> "$GITHUB_ENV" - export DOCKER_CONFIG="${DOCKER_CONFIG_DIR}" - - if [ -n "${{ env.NGC_API_KEY }}" ]; then - echo "šŸ”µ Logging into nvcr.io..." - docker login -u \$oauthtoken -p ${{ env.NGC_API_KEY }} nvcr.io - else - echo "🟠 NGC_API_KEY not set - skipping nvcr.io login (normal for fork PRs)" - fi + uses: ./.github/actions/_lib/setup-docker-config ##### 2: Resolve ECR URL ##### @@ -191,40 +175,21 @@ runs: # Edit DEPS_FILES or DEPS_MANIFEST_PATTERN when install # inputs change (new packages, new manifests, etc.). + - name: Compute deps hash + id: deps-hash + if: steps.resolve-ecr.outputs.available == 'true' && steps.pull-exact.outputs.hit != 'true' + uses: ./.github/actions/_lib/compute-deps-hash + with: + dockerfile-path: ${{ inputs.dockerfile-path }} + isaacsim-base-image: ${{ inputs.isaacsim-base-image }} + isaacsim-version: ${{ inputs.isaacsim-version }} + - name: Check deps cache id: deps-cache if: steps.resolve-ecr.outputs.available == 'true' && steps.pull-exact.outputs.hit != 'true' shell: bash run: | - ##### Deps-hash configuration ##### - # Exact files/dirs whose full content is hashed. The Dockerfile is first. - DEPS_FILES=( - "${{ inputs.dockerfile-path }}" - isaaclab.sh - environment.yml - source/isaaclab/isaaclab/cli - ) - # Manifest files matched repo-wide via git ls-files. - DEPS_MANIFEST_PATTERN='(setup\.py|pyproject\.toml|setup\.cfg|extension\.toml|requirements[^/]*\.txt|uv\.lock)$' - - # Resolve the actual base image digest so a new push of a mutable tag - # (e.g. latest-develop) invalidates the deps cache automatically. - BASE_IMAGE_DIGEST=$(docker buildx imagetools inspect \ - "${{ inputs.isaacsim-base-image }}:${{ inputs.isaacsim-version }}" \ - --format '{{json .Manifest.Digest}}' 2>/dev/null | tr -d '"' || true) - if [ -n "${BASE_IMAGE_DIGEST}" ]; then - BASE_IMAGE_UNIQ_ID="${{ inputs.isaacsim-base-image }}:${{ inputs.isaacsim-version }}:${BASE_IMAGE_DIGEST}" - else - echo "🟠 Could not resolve base image digest, falling back to tag string" - BASE_IMAGE_UNIQ_ID="${{ inputs.isaacsim-base-image }}:${{ inputs.isaacsim-version }}" - fi - - echo "šŸ”µ Base image ID: ${BASE_IMAGE_UNIQ_ID}" - - MANIFEST_FILES=$(git ls-files | grep -E "${DEPS_MANIFEST_PATTERN}" || true) - FILE_HASH=$(git ls-files -s "${DEPS_FILES[@]}" ${MANIFEST_FILES} 2>/dev/null \ - | sha256sum | cut -c1-16) - DEPS_HASH=$(printf '%s %s' "${FILE_HASH}" "${BASE_IMAGE_UNIQ_ID}" | sha256sum | cut -c1-16) + DEPS_HASH="${{ steps.deps-hash.outputs.hash }}" DEPS_ECR_IMAGE="${ECR_URL}:deps-${DEPS_HASH}" echo "šŸ”µ Deps hash: ${DEPS_HASH}" echo "šŸ”µ Checking if deps image ${DEPS_ECR_IMAGE} exists in ECR..." @@ -245,41 +210,34 @@ runs: echo "PUSH_DEPS_IMAGE=true" >> "$GITHUB_ENV" fi - ##### 6: Full build ##### + ##### 6: Full build (delegated to docker-build) ##### # Runs when neither the exact image nor the deps cache was available. - # Uses ECR layer cache (--cache-from/--cache-to) when ECR is available. + # docker-build does the actual buildx invocation; we pass ECR layer-cache + # refs and the ECR-prefixed tag so the push steps below have something to + # push. - name: Full build if: steps.pull-exact.outputs.hit != 'true' && steps.deps-cache.outputs.deps-cache-hit != 'true' + uses: ./.github/actions/docker-build + with: + image-tag: ${{ inputs.image-tag }} + isaacsim-base-image: ${{ inputs.isaacsim-base-image }} + isaacsim-version: ${{ inputs.isaacsim-version }} + dockerfile-path: ${{ inputs.dockerfile-path }} + cache-from: ${{ steps.resolve-ecr.outputs.available == 'true' && format('type=registry,ref={0}', env.CACHE_IMAGE) || '' }} + cache-to: ${{ steps.resolve-ecr.outputs.available == 'true' && format('type=registry,ref={0},mode=max', env.CACHE_IMAGE) || '' }} + deps-hash: ${{ steps.deps-hash.outputs.hash }} + + - name: Tag built image with ECR-prefixed name + if: > + steps.resolve-ecr.outputs.available == 'true' && + steps.pull-exact.outputs.hit != 'true' && + steps.deps-cache.outputs.deps-cache-hit != 'true' shell: bash run: | - BUILD_ARGS=( - --progress=plain - --platform linux/amd64 - -f "${{ inputs.dockerfile-path }}" - --build-arg "ISAACSIM_BASE_IMAGE_ARG=${{ inputs.isaacsim-base-image }}" - --build-arg "ISAACSIM_VERSION_ARG=${{ inputs.isaacsim-version }}" - --build-arg "ISAACSIM_ROOT_PATH_ARG=/isaac-sim" - --build-arg "ISAACLAB_PATH_ARG=/workspace/isaaclab" - --build-arg "DOCKER_USER_HOME_ARG=/root" - -t "${{ inputs.image-tag }}" - ) - if [ -n "${ECR_URL:-}" ]; then - BUILD_ARGS+=( - --cache-from "type=registry,ref=${CACHE_IMAGE}" - --cache-to "type=registry,ref=${CACHE_IMAGE},mode=max" - -t "${ECR_IMAGE}" - ) - fi - - BUILDER_NAME="ci-builder-${{ github.run_id }}-${{ github.job }}" - docker buildx create --use --driver docker-container --name "${BUILDER_NAME}" \ - || docker buildx use "${BUILDER_NAME}" - trap 'docker buildx rm "${BUILDER_NAME}" || true' EXIT - - echo "šŸ”µ Building ${{ inputs.image-tag }}..." - docker buildx build --load "${BUILD_ARGS[@]}" . + docker tag "${{ inputs.image-tag }}" "${ECR_IMAGE}" + echo "🟢 Tagged ${ECR_IMAGE}" ##### 7: Push to ECR ##### diff --git a/.github/actions/install-ci-run/action.yml b/.github/actions/install-ci-run/action.yml index e099be41e56..f86ef7105a3 100644 --- a/.github/actions/install-ci-run/action.yml +++ b/.github/actions/install-ci-run/action.yml @@ -48,9 +48,19 @@ runs: tools/run_install_ci.py "${args[@]}" - name: Upload JUnit XML report if: always() + id: upload-junit-report uses: actions/upload-artifact@v7 with: name: install-ci-junit-${{ runner.arch }} path: ${{ github.workspace }}/results/results.xml if-no-files-found: ignore retention-days: 7 + + - name: Upload omni-github test results + if: always() + uses: ./.github/actions/upload-omni-github-test-results + with: + junit-file: ${{ github.workspace }}/results/results.xml + junit-log-url: ${{ steps.upload-junit-report.outputs.artifact-url }} + artifact-prefix: pytest-results-${{ github.job }}-${{ runner.arch }} + test-type: installation-e2e diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml index 264e01a158b..9ee6dcddd9b 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: '' @@ -61,6 +67,14 @@ inputs: description: 'Comma-separated list of specific test files to include' default: '' required: false + test-node-ids-file: + description: 'TOML file containing exact pytest node IDs to run' + default: '' + required: false + test-node-ids-key: + description: 'Top-level key in test-node-ids-file containing the node IDs for this job' + default: '' + required: false pytest-options: description: 'Additional pytest options' default: '' @@ -69,6 +83,17 @@ inputs: description: 'Space-separated pip packages to install inside the Docker container before pytest starts' default: '' required: false + wheelhouse-resource: + description: 'Optional NGC resource containing wheelhouse/ and manifest.json for offline pip installs' + default: '' + required: false + wheelhouse-packages: + description: 'Space-separated packages to install from the wheelhouse with pip --no-index' + default: '' + omni-github-test-type: + description: 'Test type stored on each uploaded omni-github test row' + default: 'pytest' + required: false container-name: description: 'Docker container name prefix (run-id is appended automatically)' required: true @@ -133,6 +158,130 @@ runs: printf "šŸ”µ Image pull took %dm %ds\n" $((elapsed/60)) $((elapsed%60)) echo "šŸ”µ Docker Image Pulled in ${elapsed}s" >> "$GITHUB_STEP_SUMMARY" + - name: Restore NGC CLI cache + if: inputs.wheelhouse-resource != '' && env.NGC_API_KEY != '' + 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 + if: inputs.wheelhouse-resource != '' + shell: bash + env: + WHEELHOUSE_RESOURCE: ${{ inputs.wheelhouse-resource }} + run: | + set -euo pipefail + + if [ -z "${NGC_API_KEY:-}" ]; then + echo "::error::wheelhouse-resource is set but NGC_API_KEY is unavailable; cannot download configured wheelhouse resource" + 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 + + if ! command -v ngc >/dev/null 2>&1; then + 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}" + fi + + 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 contents:" + ls -l "$wheelhouse_root/wheelhouse" + preserve_wheelhouse_root=true + echo "wheelhouse_host_dir=$wheelhouse_root" >> "$GITHUB_OUTPUT" + - name: Run Tests uses: ./.github/actions/run-tests with: @@ -143,13 +292,19 @@ 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 }} quarantined-only: ${{ inputs.quarantined-only }} include-files: ${{ inputs.include-files }} + test-node-ids-file: ${{ inputs.test-node-ids-file }} + test-node-ids-key: ${{ inputs.test-node-ids-key }} volume-mount-source: ${{ github.workspace }} extra-pip-packages: ${{ inputs.extra-pip-packages }} + wheelhouse-host-dir: ${{ steps.extract-wheelhouse.outputs.wheelhouse_host_dir }} + wheelhouse-packages: ${{ inputs.wheelhouse-packages }} + omni-github-test-type: ${{ inputs.omni-github-test-type }} - name: Check Test Results if: always() @@ -165,6 +320,11 @@ runs: exit 1 fi + - name: Cleanup wheelhouse artifacts + if: always() && steps.extract-wheelhouse.outputs.wheelhouse_host_dir != '' + shell: bash + run: rm -rf "${{ steps.extract-wheelhouse.outputs.wheelhouse_host_dir }}" + - name: Kill container on cancellation if: cancelled() shell: bash diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index ceea3a1c4e6..d3829cc8300 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' @@ -53,6 +61,14 @@ inputs: description: 'Comma-separated list of specific test file paths to include (e.g., source/pkg/test/test_a.py,source/pkg/test/test_b.py)' default: '' required: false + test-node-ids-file: + description: 'TOML file containing exact pytest node IDs to run' + default: '' + required: false + test-node-ids-key: + description: 'Top-level key in test-node-ids-file containing the node IDs for this job' + default: '' + required: false shard-index: description: 'Zero-based index of this shard (used with shard-count to split tests across parallel jobs)' default: '' @@ -69,12 +85,34 @@ inputs: description: 'Space-separated pip packages to install inside the Docker container before pytest starts' default: '' required: false + wheelhouse-host-dir: + description: 'Host directory containing wheelhouse/ and manifest.json for offline pip installs' + default: '' + required: false + wheelhouse-packages: + description: 'Space-separated packages to install from /tmp/ovphysx-wheelhouse with pip --no-index' + default: '' + ci-marker: + description: 'CI_MARKER value forwarded to the container (read by tools/conftest.py to select test files by pytest marker)' + default: '' + required: false + omni-github-test-type: + description: 'Test type stored on each uploaded omni-github test row' + default: 'pytest' + required: false runs: using: composite 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 }} + CI_MARKER_INPUT: ${{ inputs.ci-marker }} run: | # Function to run tests in Docker container run_tests() { @@ -93,6 +131,12 @@ runs: local shard_count="${13}" local volume_mount_source="${14}" local extra_pip_packages="${15}" + local test_node_ids_file="${16}" + local test_node_ids_key="${17}" + local wheelhouse_host_dir="${18}" + local wheelhouse_packages="${19}" + local test_k_expr="${20}" + local ci_marker="${21}" local logs_pid="" local wait_pid="" local docker_wait_file="/tmp/.docker_exit_${container_name}" @@ -118,6 +162,12 @@ runs: if [ -n "$extra_pip_packages" ]; then echo "With extra pip packages: $extra_pip_packages" fi + if [ -n "$wheelhouse_host_dir" ]; then + echo "With wheelhouse host directory: $wheelhouse_host_dir" + fi + if [ -n "$wheelhouse_packages" ]; then + echo "With wheelhouse packages: $wheelhouse_packages" + fi if [ -n "$filter_pattern" ]; then echo "With filter pattern: $filter_pattern" fi @@ -130,10 +180,25 @@ runs: if [ -n "$include_files" ]; then echo "Include files: $include_files" fi + if [ -n "$test_node_ids_file" ]; then + echo "Test node IDs file: $test_node_ids_file" + fi + if [ -n "$test_node_ids_key" ]; then + echo "Test node IDs key: $test_node_ids_key" + fi if [ -n "$shard_index" ] && [ -n "$shard_count" ]; then echo "Shard: $shard_index of $shard_count" fi + if [ -n "$test_node_ids_file" ] || [ -n "$test_node_ids_key" ]; then + if [ -z "$test_node_ids_file" ] || [ -z "$test_node_ids_key" ]; then + echo "Both test-node-ids-file and test-node-ids-key must be set together" + return 1 + fi + export TEST_NODE_IDS_FILE="$test_node_ids_file" + export TEST_NODE_IDS_KEY="$test_node_ids_key" + fi + # Create reports directory mkdir -p "$reports_dir" @@ -170,6 +235,16 @@ runs: echo "Setting TEST_INCLUDE_FILES=$include_files_compact" fi + if [ -n "${TEST_NODE_IDS:-}" ]; then + docker_env_vars="$docker_env_vars -e TEST_NODE_IDS" + echo "Setting TEST_NODE_IDS" + fi + + if [ -n "${TEST_NODE_IDS_FILE:-}" ]; then + docker_env_vars="$docker_env_vars -e TEST_NODE_IDS_FILE -e TEST_NODE_IDS_KEY" + echo "Setting TEST_NODE_IDS_FILE=$TEST_NODE_IDS_FILE TEST_NODE_IDS_KEY=$TEST_NODE_IDS_KEY" + fi + if [ -n "$shard_index" ] && [ -n "$shard_count" ]; then docker_env_vars="$docker_env_vars -e TEST_SHARD_INDEX=$shard_index -e TEST_SHARD_COUNT=$shard_count" echo "Setting TEST_SHARD_INDEX=$shard_index TEST_SHARD_COUNT=$shard_count" @@ -204,6 +279,17 @@ 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 + + if [ -n "$ci_marker" ]; then + docker_env_vars="$docker_env_vars -e CI_MARKER=$ci_marker" + echo "Setting CI_MARKER=$ci_marker" + 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="" @@ -252,6 +338,31 @@ runs: echo "šŸ”µ Running volume-mounted container as host uid:gid ${host_uid}:${host_gid} (${host_user})" fi + if [ -n "$wheelhouse_host_dir" ]; then + if [ -z "$wheelhouse_packages" ]; then + echo "::error::wheelhouse-host-dir was provided but wheelhouse-packages is empty" + return 1 + fi + if [ ! -d "${wheelhouse_host_dir}/wheelhouse" ]; then + echo "::error::wheelhouse directory not found: ${wheelhouse_host_dir}/wheelhouse" + return 1 + fi + if [ ! -f "${wheelhouse_host_dir}/manifest.json" ]; then + echo "::error::wheelhouse manifest not found: ${wheelhouse_host_dir}/manifest.json" + return 1 + fi + + export TEST_WHEELHOUSE_PACKAGES="$wheelhouse_packages" + docker_volume_args="$docker_volume_args \ + -v ${wheelhouse_host_dir}/wheelhouse:/tmp/ovphysx-wheelhouse:ro \ + -v ${wheelhouse_host_dir}/manifest.json:/tmp/ovphysx-wheelhouse-manifest.json:ro" + docker_env_vars="$docker_env_vars \ + -e TEST_WHEELHOUSE_PATH=/tmp/ovphysx-wheelhouse \ + -e TEST_WHEELHOUSE_MANIFEST=/tmp/ovphysx-wheelhouse-manifest.json \ + -e TEST_WHEELHOUSE_PACKAGES" + echo "Mounting wheelhouse at /tmp/ovphysx-wheelhouse" + fi + echo "Docker environment variables: '$docker_env_vars'" # Run tests in a detached container and follow logs. Running detached @@ -284,6 +395,26 @@ runs: # fall back to slow repeated retries. unset HUB__ARGS__DETECT_ONLY ./isaaclab.sh -p -m pip install pytest pytest-mock junitparser flatdict flaky \"coverage>=7.6.1\" + if [ -n \"\${TEST_WHEELHOUSE_PACKAGES:-}\" ]; then + if [ ! -d \"\${TEST_WHEELHOUSE_PATH:-}\" ]; then + echo \"Wheelhouse path is missing: \${TEST_WHEELHOUSE_PATH:-}\" + exit 1 + fi + if [ ! -f \"\${TEST_WHEELHOUSE_MANIFEST:-}\" ]; then + echo \"Wheelhouse manifest is missing: \${TEST_WHEELHOUSE_MANIFEST:-}\" + exit 1 + fi + + echo \"Installing wheelhouse packages offline: \${TEST_WHEELHOUSE_PACKAGES}\" + ./isaaclab.sh -p -m pip uninstall -y \${TEST_WHEELHOUSE_PACKAGES} || true + PIP_NO_INDEX=1 ./isaaclab.sh -p -m pip install --no-index --find-links=\"\${TEST_WHEELHOUSE_PATH}\" --upgrade --force-reinstall \${TEST_WHEELHOUSE_PACKAGES} + + case \" \${TEST_WHEELHOUSE_PACKAGES} \" in + *\" ovphysx \"*) + ./isaaclab.sh -p -c \"import importlib.metadata,json,os,pathlib; from packaging.version import Version; manifest=json.loads(pathlib.Path(os.environ['TEST_WHEELHOUSE_MANIFEST']).read_text(encoding='utf-8')); expected=manifest.get('ovphysx_version'); actual=importlib.metadata.version('ovphysx'); print(f'Resolved ovphysx package version: {actual}'); print(f'Wheelhouse manifest ovphysx version: {expected}'); import ovphysx; runtime=getattr(ovphysx, '__version__', actual); print(f'Imported ovphysx runtime version: {runtime}'); raise SystemExit(0 if Version(actual) == Version(expected) and Version(runtime) == Version(expected) else f'ovphysx version mismatch: installed {actual}, import {runtime}, manifest {expected}')\" + ;; + esac + fi if [ -n \"\${TEST_EXTRA_PIP_PACKAGES:-}\" ]; then echo \"Installing extra pip packages: \${TEST_EXTRA_PIP_PACKAGES}\" ./isaaclab.sh -p -m pip install \${TEST_EXTRA_PIP_PACKAGES} @@ -392,7 +523,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 }}" + 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" "$CI_MARKER_INPUT" - name: Kill container on cancellation if: cancelled() @@ -421,6 +552,7 @@ runs: - name: Upload comparison images if: always() + id : upload-comparison-images uses: actions/upload-artifact@v7 with: name: comparison-images-${{ inputs.container-name }} @@ -430,6 +562,7 @@ runs: - name: Upload JUnit XML report if: always() + id: upload-junit-report uses: actions/upload-artifact@v7 with: name: junit-${{ inputs.container-name }} @@ -437,6 +570,16 @@ runs: if-no-files-found: ignore retention-days: 7 + - name: Upload omni-github test results + if: always() + uses: ./.github/actions/upload-omni-github-test-results + with: + junit-file: ${{ inputs.reports-dir }}/${{ inputs.result-file }} + junit-log-url: ${{ steps.upload-junit-report.outputs.artifact-url }} + comparison-images-url: ${{ steps.upload-comparison-images.outputs.artifact-url }} + artifact-prefix: pytest-results-${{ github.job }}-${{ inputs.container-name }} + test-type: ${{ inputs.omni-github-test-type }} + - name: Clean up Docker container if: always() && !cancelled() shell: bash diff --git a/.github/actions/upload-omni-github-test-results/action.yml b/.github/actions/upload-omni-github-test-results/action.yml new file mode 100644 index 00000000000..80c820a7464 --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/action.yml @@ -0,0 +1,119 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +name: 'Upload omni-github test results' +description: > + Converts a JUnit XML report into the omni-github test-result artifact + contract and uploads it with the current GitHub repository identity. + +inputs: + junit-file: + description: 'Path to the JUnit XML report to convert' + required: true + junit-log-url: + description: 'URL of the uploaded JUnit XML artifact' + default: '' + required: false + comparison-images-url: + description: 'URL of the uploaded comparison images artifact' + default: '' + required: false + artifact-prefix: + description: 'Artifact name prefix' + required: true + test-tool-id: + description: 'Identifier for the test runner that produced the JUnit report' + default: 'pytest' + required: false + test-type: + description: >- + Suite category stored on each uploaded omni-github test row, such as + training-e2e, rendering-correctness, pytest, etc. + default: 'pytest' + required: false + retention-days: + description: 'GitHub artifact retention in days' + default: '7' + required: false + +runs: + using: composite + steps: + - name: Convert JUnit XML to omni-github results + id: convert + shell: bash + env: + ARTIFACT_PREFIX: ${{ inputs.artifact-prefix }} + JUNIT_FILE: ${{ inputs.junit-file }} + TEST_TOOL_ID: ${{ inputs.test-tool-id }} + TEST_TYPE: ${{ inputs.test-type }} + run: | + set -euo pipefail + + if [ ! -f "$JUNIT_FILE" ]; then + echo "::warning::Skipping omni-github upload because JUnit report was not found: $JUNIT_FILE" + echo "upload=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + case "${RUNNER_OS:-unknown}-${RUNNER_ARCH:-unknown}" in + Linux-X64) app_platform="linux-x86_64" ;; + Linux-ARM64) app_platform="linux-aarch64" ;; + Windows-X64) app_platform="windows-x86_64" ;; + Windows-ARM64) app_platform="windows-aarch64" ;; + macOS-X64) app_platform="macos-x86_64" ;; + macOS-ARM64) app_platform="macos-aarch64" ;; + *) app_platform="$(printf '%s-%s' "${RUNNER_OS:-unknown}" "${RUNNER_ARCH:-unknown}" | tr '[:upper:]' '[:lower:]')" ;; + esac + + run_attempt="${GITHUB_RUN_ATTEMPT:-1}" + retries=0 + + # Run attempt > 1 means this job is a re-run, so adjust the retries count accordingly + [[ "$run_attempt" =~ ^[1-9][0-9]*$ ]] && retries=$((run_attempt - 1)) + + # Base directory for the artifact, which will be uploaded to GitHub + artifact_dir="${RUNNER_TEMP}/omni-github-test-results/${ARTIFACT_PREFIX}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}" + rm -rf "$artifact_dir" + mkdir -p "$artifact_dir" + + python3 "$GITHUB_ACTION_PATH/junit_to_omni_github_results.py" \ + --junit-file "$JUNIT_FILE" \ + --output-dir "$artifact_dir" \ + --test-tool-id "$TEST_TOOL_ID" \ + --test-type "$TEST_TYPE" \ + --app-platform "$app_platform" \ + --app-config "${GITHUB_JOB:-github-job}" \ + --group-name "${GITHUB_WORKFLOW:-github-workflow} / ${GITHUB_JOB:-github-job}" \ + --junit-log-url "${{ inputs.junit-log-url }}" \ + --comparison-images-url "${{ inputs.comparison-images-url }}" \ + --retries "$retries" + + result_json="$artifact_dir/_testoutput/test_results.json" + + # Validating with a local pinned copy of the schemas is recommended by the omni-github team. + schema_venv="${RUNNER_TEMP}/omni-github-jsonschema-venv" + python3 -m venv "$schema_venv" + "$schema_venv/bin/python" -m pip install -q jsonschema + if ! "$schema_venv/bin/python" -m jsonschema \ + "$GITHUB_ACTION_PATH/result-json.schema.json" \ + --instance "$result_json"; then + echo "::warning::Skipping omni-github upload because converted results failed schema validation: $JUNIT_FILE" + echo "upload=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT" + echo "upload=true" >> "$GITHUB_OUTPUT" + + - name: Upload omni-github test results + if: always() && steps.convert.outputs.upload == 'true' + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-prefix }}--v1-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ job.check_run_id }} + path: ${{ steps.convert.outputs.artifact_dir }} + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} + compression-level: 9 diff --git a/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py new file mode 100644 index 00000000000..8fc15431826 --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py @@ -0,0 +1,242 @@ +# 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 + +"""Convert a JUnit XML report into the omni-github test-result artifact format.""" + +from __future__ import annotations + +import argparse +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +_MANIFEST_NAME = "omni-github-test-results-upload.json" +_RESULT_PATH = "_testoutput/test_results.json" +_MAX_MESSAGE_CHARS = 500 +_FLAG_PATTERNS = { + "crash": ("abort", "core dumped", "crash", "process killed", "segmentation fault", "signal "), + "timeout": ("deadline exceeded", "timed out", "timeout"), +} + + +def _local_name(tag: str) -> str: + """Return an XML tag name without its namespace.""" + return tag.rsplit("}", maxsplit=1)[-1] + + +def _iter_testcases(root: ET.Element) -> list[ET.Element]: + """Return all JUnit testcase elements from the parsed XML tree.""" + return [element for element in root.iter() if _local_name(element.tag) == "testcase"] + + +def _first_child(testcase: ET.Element, names: set[str]) -> ET.Element | None: + """Return the first direct testcase child whose local tag name matches.""" + for child in testcase: + if _local_name(child.tag) in names: + return child + return None + + +def _duration_seconds(element: ET.Element) -> float: + """Return the element duration in seconds.""" + try: + duration = float(element.attrib.get("time", "0")) + except ValueError: + duration = 0.0 + return max(duration, 0.0) + + +def _test_id(testcase: ET.Element) -> str: + """Return a stable test identifier from JUnit classname and name fields.""" + parts = [testcase.attrib.get(key, "").strip() for key in ("classname", "name")] + return "::".join(part for part in parts if part) or "unknown-testcase" + + +def _testcase_markers(testcase: ET.Element) -> list[str]: + """Return the intent markers recorded on a testcase, in stable order. + + pytest serializes ``item.user_properties`` as ```` elements under a + ```` child of the testcase. The repo-root ``conftest.py`` records the + auto-applied intent markers there under the ``markers`` property as a comma-separated + string. Missing or empty values yield an empty list. + """ + properties = _first_child(testcase, {"properties"}) + if properties is None: + return [] + markers: list[str] = [] + for prop in properties: + if _local_name(prop.tag) != "property" or prop.attrib.get("name") != "markers": + continue + for value in prop.attrib.get("value", "").split(","): + value = value.strip() + if value: + markers.append(value) + return markers + + +def _short_message(element: ET.Element | None) -> str | None: + """Return a compact message from a failure, error, or skip element.""" + if element is None: + return None + raw_message = element.attrib.get("message") or element.attrib.get("type") or element.text or "" + message = " ".join(raw_message.split()) + if not message: + return None + if len(message) > _MAX_MESSAGE_CHARS: + return message[: _MAX_MESSAGE_CHARS - 3].rstrip() + "..." + return message + + +def _convert_testcase( + testcase: ET.Element, + test_type: str, + group_id: str, + retries: int, + log_paths: list[str], +) -> dict[str, object]: + """Convert one JUnit testcase element into an omni-github test row.""" + failure_or_error = _first_child(testcase, {"error", "failure"}) + skipped = _first_child(testcase, {"skipped"}) + message = _short_message(failure_or_error) + skip_reason = _short_message(skipped) + row: dict[str, object] = { + "test_id": _test_id(testcase), + "passed": failure_or_error is None and skipped is None, + "duration": _duration_seconds(testcase), + "group_id": group_id, + "retries": retries, + "log_paths": log_paths, + } + if testcase.attrib.get("name"): + row["test_name"] = testcase.attrib["name"] + if skipped is not None: + row["skipped"] = True + if skip_reason is not None: + row["skip_reason"] = skip_reason + row.setdefault("message", skip_reason) + if message is not None: + row["message"] = message + + if failure_or_error is not None: + detail_text = " ".join( + filter(None, (message, failure_or_error.attrib.get("type"), failure_or_error.text)) + ).lower() + for flag, patterns in _FLAG_PATTERNS.items(): + if any(pattern in detail_text for pattern in patterns): + row[flag] = True + + markers = _testcase_markers(testcase) + if markers: + row["test_type"] = ",".join([test_type, *markers]) + else: + row["test_type"] = test_type + + return row + + +def convert_junit( + junit_file: Path, + output_dir: Path, + test_tool_id: str, + test_type: str, + app_platform: str, + app_config: str, + group_name: str, + junit_log_url: str, + comparison_images_url: str, + retries: int = 0, +) -> None: + """Convert a JUnit XML report and write the omni-github artifact directory. + + Args: + junit_file: Path to the source JUnit XML report. + output_dir: Directory where the artifact root should be written. + test_tool_id: Identifier for the test tool that produced the report. + test_type: Suite category to store on each converted test row. + app_platform: Platform label for the result app metadata. + app_config: Configuration label for the result app metadata. + group_name: Human-readable test group label to store on each test row. + retries: Within-job retry count to store on each test row. + junit_log_url: URL of the uploaded source JUnit XML artifact. + comparison_images_url: URL of the uploaded comparison images artifact. + """ + root = ET.parse(junit_file).getroot() + log_paths: list[str] = [] + if junit_log_url: + log_paths.append(junit_log_url) + + if comparison_images_url: + log_paths.append(comparison_images_url) + + tests = [] + for testcase in _iter_testcases(root): + tests.append( + _convert_testcase( + testcase, + test_type, + group_name, + retries, + log_paths=log_paths, + ) + ) + result: dict[str, object] = { + "result_schema_version": 1, + "test_tool_id": test_tool_id, + "app": { + "platform": app_platform, + "config": app_config, + }, + "tests": tests, + } + manifest = { + "schema_version": 1, + "result_paths": [_RESULT_PATH], + } + + result_path = output_dir / _RESULT_PATH + result_path.parent.mkdir(parents=True, exist_ok=True) + result_path.write_text(json.dumps(result, separators=(",", ":")), encoding="utf-8") + (output_dir / _MANIFEST_NAME).write_text(json.dumps(manifest, separators=(",", ":")), encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--junit-file", type=Path, required=True, help="Path to the JUnit XML report.") + parser.add_argument("--output-dir", type=Path, required=True, help="Artifact root directory to write.") + parser.add_argument("--test-tool-id", required=True, help="Identifier for the test tool.") + parser.add_argument("--test-type", required=True, help="Suite category for each test row.") + parser.add_argument("--app-platform", required=True, help="App platform label.") + parser.add_argument("--app-config", required=True, help="App configuration label.") + parser.add_argument("--group-name", required=True, help="Human-readable group label for each test row.") + parser.add_argument( + "--junit-log-url", required=True, type=str, help="URL of the uploaded source JUnit XML artifact." + ) + parser.add_argument( + "--comparison-images-url", required=True, type=str, help="URL of the uploaded comparison images artifact." + ) + parser.add_argument("--retries", type=int, default=0, help="Within-job retry count for each test row.") + return parser.parse_args() + + +def main() -> None: + """Run the converter.""" + args = parse_args() + convert_junit( + junit_file=args.junit_file, + output_dir=args.output_dir, + test_tool_id=args.test_tool_id, + test_type=args.test_type, + app_platform=args.app_platform, + app_config=args.app_config, + group_name=args.group_name, + junit_log_url=args.junit_log_url, + comparison_images_url=args.comparison_images_url, + retries=max(args.retries, 0), + ) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/upload-omni-github-test-results/result-json.schema.json b/.github/actions/upload-omni-github-test-results/result-json.schema.json new file mode 100644 index 00000000000..f94d55573e2 --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/result-json.schema.json @@ -0,0 +1,275 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://omni-github.local/schemas/artifact-upload/result-json.schema.json", + "title": "Test result JSON", + "type": "object", + "additionalProperties": false, + "required": ["test_tool_id", "app", "tests"], + "properties": { + "result_schema_version": { + "type": ["integer", "null"], + "default": 1, + "enum": [1, null] + }, + "test_tool_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "app": { + "$ref": "#/$defs/appContext" + }, + "tests": { + "type": "array", + "minItems": 1, + "maxItems": 20000, + "items": { + "$ref": "#/$defs/testRow" + } + } + }, + "$defs": { + "shortString": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "nullableShortString": { + "type": ["string", "null"], + "minLength": 1, + "maxLength": 256 + }, + "messageString": { + "type": "string", + "maxLength": 4096 + }, + "nullableMessageString": { + "type": ["string", "null"], + "maxLength": 4096 + }, + "artifactRelativePath": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "not": { + "pattern": "(^/|(^|/)\\.\\.(/|$)|\\\\)" + } + }, + "appContext": { + "type": "object", + "additionalProperties": false, + "required": ["platform"], + "properties": { + "platform": { + "$ref": "#/$defs/shortString" + }, + "config": { + "type": ["string", "null"], + "minLength": 1, + "maxLength": 128, + "default": "release" + }, + "python_version": { + "$ref": "#/$defs/nullableShortString" + }, + "app_version": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_version_full": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_build_number": { + "type": ["integer", "null"], + "minimum": 0 + }, + "app_name_full": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_version": { + "$ref": "#/$defs/nullableShortString" + }, + "app_name": { + "$ref": "#/$defs/nullableShortString" + }, + "package": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_git_hash": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_branch": { + "$ref": "#/$defs/nullableShortString" + } + } + }, + "testRow": { + "type": "object", + "additionalProperties": false, + "required": ["test_id", "passed", "duration"], + "properties": { + "test_id": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "passed": { + "type": "boolean" + }, + "duration": { + "type": "number", + "minimum": 0, + "maximum": 86400 + }, + "test_name": { + "type": ["string", "null"], + "minLength": 1, + "maxLength": 512 + }, + "group_id": { + "$ref": "#/$defs/nullableShortString" + }, + "test_type": { + "$ref": "#/$defs/nullableShortString" + }, + "skipped": { + "type": ["boolean", "null"], + "default": false + }, + "unreliable": { + "type": ["boolean", "null"], + "default": false + }, + "crash": { + "type": ["boolean", "null"], + "default": false + }, + "timeout": { + "type": ["boolean", "null"], + "default": false + }, + "retries": { + "type": ["integer", "null"], + "minimum": 0, + "maximum": 100, + "default": 0 + }, + "owner": { + "$ref": "#/$defs/nullableShortString" + }, + "skip_reason": { + "$ref": "#/$defs/nullableMessageString" + }, + "message": { + "$ref": "#/$defs/nullableMessageString" + }, + "log_paths": { + "anyOf": [ + { + "type": "array", + "maxItems": 20, + "items": { + "$ref": "#/$defs/artifactRelativePath" + } + }, + { + "type": "null" + } + ] + }, + "app": { + "anyOf": [ + { + "$ref": "#/$defs/appContext" + }, + { + "type": "null" + } + ] + }, + "custom": { + "anyOf": [ + { + "$ref": "#/$defs/customObject" + }, + { + "type": "null" + } + ] + } + } + }, + "customObject": { + "type": "object", + "minProperties": 1, + "maxProperties": 20, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/customNamespace" + } + }, + "customNamespace": { + "type": "object", + "minProperties": 1, + "maxProperties": 50, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/customValue" + } + }, + "customValue": { + "oneOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "maxItems": 100, + "items": { + "oneOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + { + "type": "object", + "maxProperties": 20, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/customValue" + } + } + ] + } + } +} diff --git a/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py new file mode 100644 index 00000000000..73dd3cf36a0 --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py @@ -0,0 +1,208 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for the omni-github JUnit result converter.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +_MODULE_PATH = Path(__file__).with_name("junit_to_omni_github_results.py") +_RESULT_PATH = "_testoutput/test_results.json" + + +def _load_converter_module() -> ModuleType: + """Load the converter module from the local GitHub action directory.""" + spec = importlib.util.spec_from_file_location("junit_to_omni_github_results", _MODULE_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _load_rows(output_dir: Path) -> list[dict[str, object]]: + """Load converted rows from the omni-github result artifact.""" + result = json.loads((output_dir / _RESULT_PATH).read_text(encoding="utf-8")) + return result["tests"] + + +def test_convert_junit_populates_github_metadata_and_failure_details(tmp_path: Path) -> None: + """Converted JUnit rows should carry grouping, retry, and message metadata.""" + converter = _load_converter_module() + junit_file = tmp_path / "report.xml" + output_dir = tmp_path / "out" + junit_file.write_text( + """ + + + Traceback details + + + + + + +""", + encoding="utf-8", + ) + + converter.convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="pytest", + app_platform="linux-x86_64", + app_config="test-job", + group_name="Docker + Tests / isaaclab_tasks [1/3]", + junit_log_url="", + comparison_images_url="", + retries=2, + ) + + rows = _load_rows(output_dir) + failed, skipped, passed = rows + assert failed["test_id"] == "source.isaaclab.test.foo.test_sample::test_fails[param]" + assert failed["test_name"] == "test_fails[param]" + assert failed["passed"] is False + assert failed["duration"] == 1.25 + assert failed["group_id"] == "Docker + Tests / isaaclab_tasks [1/3]" + assert failed["retries"] == 2 + assert failed["message"] == "AssertionError: expected 1, got 2" + + assert skipped["passed"] is False + assert skipped["skipped"] is True + assert skipped["skip_reason"] == "requires GPU" + assert skipped["message"] == "requires GPU" + + assert passed["passed"] is True + assert "message" not in passed + + +def test_convert_junit_marks_crashes_and_timeouts(tmp_path: Path) -> None: + """Converted rows should surface crash and timeout messages.""" + converter = _load_converter_module() + junit_file = tmp_path / "report.xml" + output_dir = tmp_path / "out" + junit_file.write_text( + """ + + + diagnostics + + +""", + encoding="utf-8", + ) + + converter.convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="rendering-correctness", + app_platform="linux-x86_64", + app_config="test-job", + group_name="Docker + Tests / environments", + junit_log_url="", + comparison_images_url="", + retries=0, + ) + + rows = _load_rows(output_dir) + assert rows == [ + { + "crash": True, + "duration": 0.0, + "group_id": "Docker + Tests / environments", + "log_paths": [], + "message": "Process killed by signal 15 after timeout", + "passed": False, + "retries": 0, + "test_id": "test_rendering_cartpole::test_execution", + "test_name": "test_execution", + "test_type": "rendering-correctness", + "timeout": True, + } + ] + + +def test_convert_junit_adds_log_paths_for_junit_and_comparison_artifacts(tmp_path: Path) -> None: + """Converted rows should point at the uploaded JUnit and comparison image artifacts.""" + converter = _load_converter_module() + reports_dir = tmp_path + output_dir = tmp_path / "out" + junit_file = reports_dir / "report.xml" + junit_log_url = "https://github.com/isaac-sim/IsaacLab/actions/runs/123/artifacts/456" + comparison_images_url = "https://github.com/isaac-sim/IsaacLab/actions/runs/123/artifacts/789" + + junit_file.write_text( + """ + + + +""", + encoding="utf-8", + ) + + converter.convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="rendering-correctness", + app_platform="linux-x86_64", + app_config="test-job", + group_name="Docker + Tests / rendering", + junit_log_url=junit_log_url, + comparison_images_url=comparison_images_url, + retries=0, + ) + + rows = _load_rows(output_dir) + assert rows[0]["log_paths"] == [ + junit_log_url, + comparison_images_url, + ] + + +def test_convert_junit_appends_markers_to_test_type_with_separator(tmp_path: Path) -> None: + """Recorded intent markers should append to the base test_type with comma separators.""" + converter = _load_converter_module() + junit_file = tmp_path / "report.xml" + output_dir = tmp_path / "out" + junit_file.write_text( + """ + + + + + + + + +""", + encoding="utf-8", + ) + + converter.convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="pytest", + app_platform="linux-x86_64", + app_config="test-job", + group_name="Docker + Tests / isaaclab", + junit_log_url="", + comparison_images_url="", + retries=0, + ) + + marked, unmarked = _load_rows(output_dir) + # The base type must be separated from the first marker, not fused into "pytestintegration". + assert marked["test_type"] == "pytest,integration,rendering" + # Testcases without markers keep the bare base type. + assert unmarked["test_type"] == "pytest" diff --git a/.github/test-subsets/postmerge-rendering.toml b/.github/test-subsets/postmerge-rendering.toml new file mode 100644 index 00000000000..ee52d8f1f54 --- /dev/null +++ b/.github/test-subsets/postmerge-rendering.toml @@ -0,0 +1,34 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# Stable rendering correctness tests used by post-merge CI. +rendering-correctness = [ + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[newton-isaacsim_rtx-albedo]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[newton-isaacsim_rtx-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[newton-isaacsim_rtx-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[newton-isaacsim_rtx-semantic_segmentation]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-isaacsim_rtx-albedo]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-isaacsim_rtx-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-isaacsim_rtx-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-isaacsim_rtx-semantic_segmentation]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-newton_warp-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-newton_warp-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py::test_rendering_registered_tasks[Isaac-Cartpole-Camera-Direct-albedo-cartpole]", + "source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py::test_rendering_registered_tasks[Isaac-Cartpole-Camera-Direct-depth-cartpole]", + "source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py::test_rendering_registered_tasks[Isaac-Cartpole-Camera-Direct-None-cartpole]", + "source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py::test_rendering_registered_tasks[Isaac-Cartpole-Camera-Direct-rgb-cartpole]", +] + +rendering-correctness-kitless = [ + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[newton-newton_warp-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[newton-newton_warp-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[newton-ovrtx-albedo]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[newton-ovrtx-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-newton_warp-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-newton_warp-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-ovrtx-albedo]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-ovrtx-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-ovrtx-semantic_segmentation]", +] diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5d715d4e294..7e704d6a6ff 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,7 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause # region help -# Every test job runs on every PR. +# Every test job runs on every PR and merge queue candidate. Pushes to protected branches run the +# post-merge integration tests. # # ============================================================================= # CI DEBUGGING TIPS @@ -46,6 +47,11 @@ name: Docker + Tests on: + push: + branches: + - main + - develop + - 'release/**' pull_request: types: [opened, synchronize, reopened] branches: @@ -67,7 +73,6 @@ permissions: env: NGC_API_KEY: ${{ secrets.NGC_API_KEY }} - CI_IMAGE_TAG: isaac-lab-ci:${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}-${{ github.sha }} jobs: changes: @@ -100,8 +105,13 @@ jobs: $'^\\.github/workflows/build\\.yaml$\tThis workflow file' $'^\\.github/workflows/config\\.yaml$\tBase image config' $'^\\.github/actions/\tCI actions' + $'^\\.github/test-subsets/\tCI test subset config' ) - triggered_jobs="Docker build jobs + all test-* matrix jobs" + if [ "$EVENT_NAME" = "push" ]; then + triggered_jobs="Docker base build job + rendering-correctness + rendering-correctness-kitless" + else + triggered_jobs="Docker build jobs + all test-* matrix jobs" + fi render_table() { local files="$1" entry regex desc count sample shown @@ -181,6 +191,8 @@ jobs: isaacsim_image_name: ${{ steps.load.outputs.isaacsim_image_name }} isaacsim_image_tag: ${{ steps.load.outputs.isaacsim_image_tag }} isaaclab_image_name: ${{ steps.load.outputs.isaaclab_image_name }} + ovphysx_wheelhouse_resource: ${{ steps.load.outputs.ovphysx_wheelhouse_resource }} + ci_image_tag: ${{ steps.image_tag.outputs.ci_image_tag }} steps: - uses: actions/checkout@v6 with: @@ -194,6 +206,27 @@ jobs: echo "isaacsim_image_name=$(yq -r .isaacsim_image_name "$f")" >> "$GITHUB_OUTPUT" echo "isaacsim_image_tag=$(yq -r .isaacsim_image_tag "$f")" >> "$GITHUB_OUTPUT" echo "isaaclab_image_name=$(yq -r .isaaclab_image_name "$f")" >> "$GITHUB_OUTPUT" + echo "ovphysx_wheelhouse_resource=$(yq -r .ovphysx_wheelhouse_resource "$f")" >> "$GITHUB_OUTPUT" + - id: image_tag + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + + if [ "$EVENT_NAME" = "pull_request" ]; then + ref_component="pr-${PR_NUMBER}" + else + ref_component="$REF_NAME" + fi + + # Sanitize the ref name for use as a Docker tag suffix. + sanitized_ref=$(echo "$ref_component" | sed 's/[^a-zA-Z0-9._-]/-/g') + echo "ci_image_tag=isaac-lab-ci:${sanitized_ref}-${SHA}" >> "$GITHUB_OUTPUT" + echo "CI image tag: isaac-lab-ci:${sanitized_ref}-${SHA}" #region build jobs build: @@ -211,7 +244,7 @@ jobs: - name: Build and push to ECR uses: ./.github/actions/ecr-build-push-pull with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} dockerfile-path: docker/Dockerfile.base @@ -221,7 +254,9 @@ jobs: name: Build cuRobo Docker Image runs-on: [self-hosted, gpu] needs: [changes, config] - if: needs.changes.outputs.run_docker_tests == 'true' + if: >- + github.event_name != 'push' && + needs.changes.outputs.run_docker_tests == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 @@ -232,12 +267,73 @@ jobs: - name: Build and push to ECR uses: ./.github/actions/ecr-build-push-pull with: - image-tag: ${{ env.CI_IMAGE_TAG }}-curobo + image-tag: ${{ needs.config.outputs.ci_image_tag }}-curobo isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} dockerfile-path: docker/Dockerfile.curobo cache-tag: cache-curobo + # aarch64 build + marker-gated tests on NVIDIA DGX Spark self-hosted runners. + # Build and test must share one runner because ECR is not wired for arm64 — + # the locally-built image cannot be handed off across machines. + arm-ci: + name: arm-ci + runs-on: [self-hosted, arm64] + needs: [changes, config] + if: needs.changes.outputs.run_docker_tests == 'true' + timeout-minutes: 60 + continue-on-error: true + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + lfs: true + + - name: Build base image (linux/arm64) + uses: ./.github/actions/docker-build + with: + image-tag: ${{ needs.config.outputs.ci_image_tag }}-arm64 + isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} + isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} + dockerfile-path: docker/Dockerfile.base + platform: linux/arm64 + # The Spark runner is long-lived self-hosted with no ECR, so its local + # deps-cache tags accumulate; evict ones older than 14 days each run. + evict-stale-cache: "true" + + - name: Run arm_ci marker tests + uses: ./.github/actions/run-tests + with: + test-path: tools + result-file: arm-ci-report.xml + container-name: isaac-lab-arm-ci-${{ github.run_id }}-${{ github.run_attempt }} + image-tag: ${{ needs.config.outputs.ci_image_tag }}-arm64 + extra-pip-packages: "ovrtx ovphysx==0.4.13" + # ovphysx-backed test params require ovphysx >= 0.5.1, which has no aarch64 + # wheel yet; keep the newton/ovrtx rendering coverage, mirroring the public + # pip-index fallback of rendering-correctness-kitless. Running them anyway + # exhausts ovrtx SyncScopeIds (>15 renderer creations in one process). + test-k-expr: not ovphysx + ci-marker: arm_ci + volume-mount-source: ${{ github.workspace }} + + - name: Run shared Cartpole smoke + uses: ./.github/actions/run-tests + with: + test-path: source/isaaclab/test/install_ci/misc/cartpole_training_smoke.py + result-file: arm-ci-cartpole-smoke-report.xml + container-name: isaac-lab-arm-ci-cartpole-${{ github.run_id }}-${{ github.run_attempt }} + image-tag: ${{ needs.config.outputs.ci_image_tag }}-arm64 + volume-mount-source: ${{ github.workspace }} + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: arm-ci-reports + path: reports/ + retention-days: 7 + #endregion #region test jobs @@ -247,7 +343,9 @@ jobs: timeout-minutes: 180 continue-on-error: true needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -255,7 +353,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -270,7 +368,9 @@ jobs: timeout-minutes: 180 continue-on-error: true needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -278,7 +378,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -293,7 +393,9 @@ jobs: timeout-minutes: 180 continue-on-error: true needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -301,7 +403,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -315,7 +417,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -323,7 +427,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "not isaaclab_" @@ -336,7 +440,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -344,7 +450,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "not isaaclab_" @@ -357,7 +463,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -365,7 +473,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "not isaaclab_" @@ -378,7 +486,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -386,7 +496,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_rl" @@ -398,7 +508,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -406,7 +518,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_mimic" @@ -417,7 +529,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -425,7 +539,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_contrib" @@ -436,7 +550,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -444,7 +560,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_teleop" @@ -455,7 +571,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -463,7 +581,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_visualizers" @@ -474,7 +592,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -482,7 +602,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_assets" @@ -493,7 +613,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -501,7 +623,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_newton" @@ -512,7 +634,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -520,7 +644,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_physx" @@ -531,7 +655,11 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + 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: - uses: actions/checkout@v6 with: @@ -539,11 +667,17 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_ov" - extra-pip-packages: "ovrtx ovphysx" + # 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' || '' }} container-name: isaac-lab-ov-test # Folded from the former standalone verify-base-non-root job: reuses the @@ -556,14 +690,14 @@ jobs: docker run --rm \ -v "$PWD":/workspace/isaaclab \ --entrypoint bash \ - "${{ env.CI_IMAGE_TAG }}" \ + "${{ needs.config.outputs.ci_image_tag }}" \ -lc 'cd /workspace/isaaclab && /isaac-sim/python.sh -m pytest docker/test/test_dockerfile_nonroot.py -q' - name: Verify Base runtime user is non-root shell: bash run: | set -euo pipefail - runtime_identity="$(docker run --rm --entrypoint bash "${{ env.CI_IMAGE_TAG }}" \ + runtime_identity="$(docker run --rm --entrypoint bash "${{ needs.config.outputs.ci_image_tag }}" \ -lc 'printf "%s %s %s\n" "$(id -u)" "$(id -g)" "$(id -un 2>/dev/null || true)"')" read -r runtime_uid runtime_gid runtime_user <<< "${runtime_identity}" echo "Base runtime identity: uid=${runtime_uid} gid=${runtime_gid} user=${runtime_user}" @@ -578,7 +712,9 @@ jobs: timeout-minutes: 120 continue-on-error: true needs: [build-curobo, config] - if: needs.build-curobo.result == 'success' + if: >- + github.event_name != 'push' && + needs.build-curobo.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -586,7 +722,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }}-curobo + image-tag: ${{ needs.config.outputs.ci_image_tag }}-curobo isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} dockerfile-path: docker/Dockerfile.curobo @@ -604,14 +740,14 @@ jobs: docker run --rm \ -v "$PWD":/workspace/isaaclab \ --entrypoint bash \ - "${{ env.CI_IMAGE_TAG }}-curobo" \ + "${{ needs.config.outputs.ci_image_tag }}-curobo" \ -lc 'cd /workspace/isaaclab && /isaac-sim/python.sh -m pytest docker/test/test_dockerfile_nonroot.py -q' - name: Verify cuRobo runtime user is non-root shell: bash run: | set -euo pipefail - runtime_identity="$(docker run --rm --entrypoint bash "${{ env.CI_IMAGE_TAG }}-curobo" \ + runtime_identity="$(docker run --rm --entrypoint bash "${{ needs.config.outputs.ci_image_tag }}-curobo" \ -lc 'printf "%s %s %s\n" "$(id -u)" "$(id -g)" "$(id -un 2>/dev/null || true)"')" read -r runtime_uid runtime_gid runtime_user <<< "${runtime_identity}" echo "cuRobo runtime identity: uid=${runtime_uid} gid=${runtime_gid} user=${runtime_user}" @@ -626,7 +762,9 @@ jobs: timeout-minutes: 120 continue-on-error: true needs: [build-curobo, config] - if: needs.build-curobo.result == 'success' + if: >- + github.event_name != 'push' && + needs.build-curobo.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -634,7 +772,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }}-curobo + image-tag: ${{ needs.config.outputs.ci_image_tag }}-curobo isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} dockerfile-path: docker/Dockerfile.curobo @@ -648,7 +786,9 @@ jobs: timeout-minutes: 300 continue-on-error: true needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -656,18 +796,19 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" include-files: "test_environments_training.py" container-name: isaac-lab-environments-training-test + omni-github-test-type: training-e2e test-rendering-correctness: name: "rendering-correctness" runs-on: [self-hosted, gpu] timeout-minutes: 120 - continue-on-error: true + continue-on-error: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} needs: [build, config] if: needs.build.result == 'success' steps: @@ -677,7 +818,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -687,15 +828,20 @@ jobs: test_rendering_dexsuite_kuka_hetero.py, test_rendering_registered_tasks.py, test_rendering_shadow_hand.py + test-node-ids-file: ${{ github.event_name == 'push' && '.github/test-subsets/postmerge-rendering.toml' || '' }} + test-node-ids-key: ${{ github.event_name == 'push' && 'rendering-correctness' || '' }} container-name: isaac-lab-rendering-correctness-test + omni-github-test-type: rendering-correctness test-rendering-correctness-kitless: name: "rendering-correctness-kitless" runs-on: [self-hosted, gpu] timeout-minutes: 120 - continue-on-error: true + continue-on-error: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} needs: [build, config] 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: - uses: actions/checkout@v6 with: @@ -703,17 +849,26 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" - extra-pip-packages: "ovrtx ovphysx==0.4.13" + # 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' || '' }} include-files: >- test_rendering_cartpole_kitless.py, test_rendering_dexsuite_kuka_homo_kitless.py, test_rendering_dexsuite_kuka_hetero_kitless.py, test_rendering_shadow_hand_kitless.py + test-node-ids-file: ${{ github.event_name == 'push' && '.github/test-subsets/postmerge-rendering.toml' || '' }} + test-node-ids-key: ${{ github.event_name == 'push' && 'rendering-correctness-kitless' || '' }} container-name: isaac-lab-rendering-correctness-kitless-test + omni-github-test-type: rendering-correctness-kitless #endregion #region disabled quarantined tests @@ -733,7 +888,7 @@ jobs: # lfs: true # - uses: ./.github/actions/run-package-tests # with: -# image-tag: ${{ env.CI_IMAGE_TAG }} +# image-tag: ${{ needs.config.outputs.ci_image_tag }} # isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} # isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} # quarantined-only: "true" diff --git a/.github/workflows/config.yaml b/.github/workflows/config.yaml index aa3c1888b24..c71456fccc7 100644 --- a/.github/workflows/config.yaml +++ b/.github/workflows/config.yaml @@ -8,3 +8,4 @@ isaacsim_image_name: nvcr.io/nvidian/isaac-sim isaacsim_image_tag: latest-develop isaaclab_image_name: nvcr.io/nvidian/isaac-lab +ovphysx_wheelhouse_resource: "nvidian/ovphysx-wheelhouse:trunk-gf62c22207cfd" diff --git a/.github/workflows/daily-compatibility.yml b/.github/workflows/daily-compatibility.yml index 472b07300a5..5606a02b8a9 100644 --- a/.github/workflows/daily-compatibility.yml +++ b/.github/workflows/daily-compatibility.yml @@ -3,6 +3,23 @@ # # SPDX-License-Identifier: BSD-3-Clause +# Nightly canary: runs the IsaacLab test suite against multiple pinned +# IsaacSim versions to catch backwards-compatibility regressions. +# +# Caveats for editors of this file or its action dependencies +# (`.github/actions/docker-build`, `run-tests`, `combine-results`): +# +# * No `pull_request:` trigger — changes are not validated automatically +# at PR time. Manually trigger against the PR branch before merge: +# gh workflow run "Backwards Compatibility Tests" --ref +# Otherwise breakage surfaces only on the next nightly cron after merge. +# +# * The build steps below pass `cache-from: type=gha` / `cache-to: +# type=gha,mode=max` to docker-build explicitly. Future migration to +# `./.github/actions/ecr-build-push-pull` would share build.yaml's +# cross-runner ECR layer cache, but that's deferred until daily-compat +# is ready to wire ECR auth. + name: Backwards Compatibility Tests on: @@ -95,12 +112,27 @@ jobs: fetch-depth: 1 lfs: true + # Single source of truth: read the OV runtime pins from [tool.isaaclab.versions]. + - name: Resolve OV runtime pins from pyproject + id: ov_pins + run: | + PINS=$(python3 <<'PY' + import re + block = re.search(r"^\[tool\.isaaclab\.versions\]\n(.*?)(?:\n\[|\Z)", open("pyproject.toml").read(), re.S | re.M).group(1) + vals = dict(re.findall(r'(\w+)\s*=\s*"([^"]+)"', block)) + print(f"ovphysx=={vals['ovphysx']} ovrtx{vals['ovrtx']}") + PY + ) + echo "pins=$PINS" >> "$GITHUB_OUTPUT" + - name: Build Docker Image uses: ./.github/actions/docker-build with: image-tag: ${{ env.DOCKER_IMAGE_TAG }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ matrix.isaacsim_version }} + cache-from: type=gha + cache-to: type=gha,mode=max - name: Run IsaacLab Tasks Tests uses: ./.github/actions/run-tests @@ -111,7 +143,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: ${{ steps.ov_pins.outputs.pins }} - name: Copy All Test Results from IsaacLab Tasks Container run: | @@ -159,6 +191,8 @@ jobs: image-tag: ${{ env.DOCKER_IMAGE_TAG }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ matrix.isaacsim_version }} + cache-from: type=gha + cache-to: type=gha,mode=max - name: Run General Tests uses: ./.github/actions/run-tests diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 8d86eec15fc..226222d425b 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -30,6 +30,7 @@ concurrency: permissions: contents: read pull-requests: read + jobs: changes: name: Detect Changes diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml index fb82761f0b9..fe8275aa742 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,10 +191,13 @@ 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. - uv pip install 'isaacsim[all,extscache]==${{ vars.ISAACSIM_BASE_VERSION || '6.0.0' }}' + # Isaac Sim conflicts with --extra all under uv, so install it imperatively + # after the sync. Read the pinned spec from pyproject (single source of truth). + ISAACSIM_SPEC=$(.venv/bin/python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['optional-dependencies']['isaacsim'][0])") + uv pip install "$ISAACSIM_SPEC" uv pip install pip-licenses pipdeptree \ -r tools/template/requirements.txt \ -r docs/requirements.txt @@ -153,3 +287,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/.github/workflows/license-exceptions.json b/.github/workflows/license-exceptions.json index e1e93a2ae7e..ccf18478822 100644 --- a/.github/workflows/license-exceptions.json +++ b/.github/workflows/license-exceptions.json @@ -488,6 +488,11 @@ "license": "LicenseRef-NVIDIA-SOFTWARE-LICENSE", "comment": "NVIDIA" }, + { + "package": "cuda-toolkit", + "license": null, + "comment": "NVIDIA CUDA Toolkit EULA (LicenseRef-NVIDIA-SOFTWARE-LICENSE); wheel ships no license metadata" + }, { "package": "omniverseclient", "license": "NVIDIA Proprietary Software, https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-software-license-agreement/", @@ -507,5 +512,15 @@ "package": "pytetwild", "license": "Mozilla Public License 2.0 (MPL 2.0)", "comment": "MPL-2.0 / OSRB" + }, + { + "package": "distlib", + "license": "Python Software Foundation License", + "comment": "PSFL / OSRB" + }, + { + "package": "ovrtx", + "license": "NVIDIA Proprietary Software", + "comment": "NVIDIA" } ] diff --git a/.github/workflows/skills-check.yml b/.github/workflows/skills-check.yml new file mode 100644 index 00000000000..284d7ca7736 --- /dev/null +++ b/.github/workflows/skills-check.yml @@ -0,0 +1,45 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +name: Skills Check + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "skills/**" + - ".agents/skills/**" + - ".claude/skills" + - "tools/skills/**" + - ".github/workflows/skills-check.yml" + workflow_dispatch: + +concurrency: + group: skills-check-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check-skills: + name: Check skills + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Verify skills + run: python3 tools/skills/cli.py check + + - name: Install test dependencies + run: python3 -m pip install pytest + + - name: Test skills validator + run: python3 -m pytest tools/skills/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3086f00a4da..d567d4a42cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,7 @@ repos: - id: check-executables-have-shebangs - id: check-toml - id: end-of-file-fixer + exclude: "^(.agents/skills/|.claude/skills$)" - id: check-shebang-scripts-are-executable - id: detect-private-key - id: debug-statements diff --git a/AGENTS.md b/AGENTS.md index d9acee8457c..427e0bc9b0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,18 @@ Proper workflow: **When reviewing code** (e.g. via a code-reviewer agent), always run `./isaaclab.sh -f` as part of the review to catch formatting or lint issues early. +## Agent skills + +- Repo-owned agent skills live under `skills/`. +- Codex discovers name-based aliases under `.agents/skills/`; Claude discovers the same aliases through `.claude/skills`. +- Keep `skills/` as the canonical source. Do not copy skill bodies into native discovery directories. +- Developer workflow skills live under `skills/developer/`; user-facing skills live under `skills/user/`. +- For PR preparation, follow the `isaaclab-preparing-pr-workflow` skill at `skills/developer/pr-workflow/SKILL.md`. +- Keep `SKILL.md` files concise and link to one-level reference files for longer details. +- Keep official docs and maintained source examples as the source of truth; skills should route agents through those references instead of duplicating them. +- Validate skills with `./isaaclab.sh -p tools/skills/cli.py check` before opening a PR that changes `skills/`. +- The CI skills gate runs only for PRs that modify `skills/`, `tools/skills/`, or the skills workflow. + ## Changelog - **Do not edit `CHANGELOG.rst` or `config/extension.toml` directly.** Each PR adds a fragment file under `source//changelog.d/`; the changelog and version are compiled by the nightly CI workflow. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7c63d42f56e..8d4c5d8cda0 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -44,6 +44,8 @@ Guidelines for modifications: ## Contributors +* Advait Jayant +* Agon Serifi * Alessandro Assirelli * Alex Omar * Alexander Millane @@ -73,6 +75,7 @@ Guidelines for modifications: * CY (Chien-Ying) Chen * David Leon * David Yang +* Daniela Hasenbring * Dhananjay Shendre * Dhyan Thakkar * Dongxuan Fan @@ -110,6 +113,7 @@ Guidelines for modifications: * Jingzhou Liu * Jinqi Wei * Jinyeob Kim +* Jiwen Cai * Johnson Sun * Juana Du * Kaixi Bao @@ -117,6 +121,7 @@ Guidelines for modifications: * Kousheek Chakraborty * Kris Wilson * Krishna Lakhi +* Lin He * Lionel Gulich * Lorenz Wellhausen * Lotus Li @@ -126,10 +131,12 @@ Guidelines for modifications: * Marco Alesiani * Masoud Moghani * Mateo Guaman Castro +* Matthew Taylor * Maurice Rahme * Michael Gussert * Michael Lin * Michael Noseworthy +* Michal Hapala * Miguel Alonso Jr * Mihir Kulkarni * Mingxue Gu @@ -184,6 +191,7 @@ Guidelines for modifications: * Tsz Ki GAO * Tyler Lum * Victor Khaustov +* Vidur Vij * Virgilio Gómez Lambo * Vladimir Fokow * Wei Yang diff --git a/apps/isaaclab.python.headless.kit b/apps/isaaclab.python.headless.kit index 2d0858512ed..8dfef00323e 100644 --- a/apps/isaaclab.python.headless.kit +++ b/apps/isaaclab.python.headless.kit @@ -62,11 +62,11 @@ interceptSysStdOutput = false logSysStdOutput = false [settings] -# MGPU is always on, you can turn it from the settings, and force this off to save even more resource if you -# only want to use a single GPU on your MGPU system -# False for Isaac Sim -renderer.multiGpu.enabled = true -renderer.multiGpu.autoEnable = true +# Use one renderer GPU by default. Applications that need single-process multi-GPU rendering can override these +# settings through AppLauncher's ``kit_args``. +renderer.multiGpu.enabled = false +renderer.multiGpu.autoEnable = false +renderer.multiGpu.maxGpuCount = 1 'rtx-transient'.resourcemanager.enableTextureStreaming = true app.asyncRendering = false app.asyncRenderingLowLatency = false diff --git a/apps/isaaclab.python.headless.rendering.kit b/apps/isaaclab.python.headless.rendering.kit index 9bb0a369243..9cbdb5e79a4 100644 --- a/apps/isaaclab.python.headless.rendering.kit +++ b/apps/isaaclab.python.headless.rendering.kit @@ -50,16 +50,23 @@ app.enableStdoutOutput = false # set the default ros bridge to disable on startup isaac.startup.ros_bridge_extension = "" -# Flags for better rendering performance -# Disabling these settings reduces renderer VRAM usage and improves rendering performance, but at some quality cost -rtx.translucency.enabled = false -rtx.reflections.enabled = false -rtx.indirectDiffuse.enabled = false +# High-fidelity camera rendering defaults. +rtx.translucency.enabled = true +rtx.reflections.enabled = true +rtx.indirectDiffuse.enabled = true rtx-transient.dlssg.enabled = false rtx.directLighting.sampledLighting.enabled = true rtx.directLighting.sampledLighting.samplesPerPixel = 1 rtx.sceneDb.ambientLightIntensity = 1.0 -# rtx.shadows.enabled = false +rtx.shadows.enabled = true + +rtx.rtpt.maxBounces = 3 +rtx.rtpt.cached.enabled = false +rtx.rtpt.lightcache.cached.enabled = false +rtx.rtpt.translucency.virtualMotion.enabled = false +rtx.rtpt.splitRoughReflection = true +rtx.rtpt.adaptiveSampling.disocclusion.enabled = true +rtx.rtpt.adaptiveSampling.disocclusion.spp = 4 # Avoids replicator warning rtx.pathtracing.maxSamplesPerLaunch = 1000000 @@ -69,15 +76,13 @@ rtx.viewTile.limit = 1000000 # Disable present thread to improve performance exts."omni.renderer.core".present.enabled=false -# Disabling these settings reduces renderer VRAM usage and improves rendering performance, but at some quality cost -rtx.raytracing.cached.enabled = false -rtx.ambientOcclusion.enabled = false +rtx.raytracing.cached.enabled = true +rtx.ambientOcclusion.enabled = true +rtx.ambientOcclusion.denoiserMode = 0 +rtx.raytracing.subpixel.mode = 1 # Set the DLSS model -rtx.post.dlss.execMode = 0 # can be 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto) - -# Avoids unnecessary GPU context initialization -renderer.multiGpu.maxGpuCount=1 +rtx.post.dlss.execMode = 2 # can be 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto) # Force synchronous rendering to improve training results omni.replicator.asyncRendering = false diff --git a/apps/isaaclab.python.kit b/apps/isaaclab.python.kit index d9dbfd942a6..000eae89a49 100644 --- a/apps/isaaclab.python.kit +++ b/apps/isaaclab.python.kit @@ -249,6 +249,12 @@ omni.replicator.asyncRendering = false app.asyncRendering = false app.asyncRenderingLowLatency = false +# Use one renderer GPU by default. Applications that need single-process multi-GPU rendering can override these +# settings through AppLauncher's ``kit_args``. +renderer.multiGpu.enabled = false +renderer.multiGpu.autoEnable = false +renderer.multiGpu.maxGpuCount = 1 + ### FSD app.useFabricSceneDelegate = true rtx.hydra.readTransformsFromFabricInRenderDelegate = true diff --git a/apps/isaaclab.python.rendering.kit b/apps/isaaclab.python.rendering.kit index 08982f9b495..fb2cd9f026a 100644 --- a/apps/isaaclab.python.rendering.kit +++ b/apps/isaaclab.python.rendering.kit @@ -45,16 +45,23 @@ app.enableStdoutOutput = false # set the default ros bridge to disable on startup isaac.startup.ros_bridge_extension = "" -# Flags for better rendering performance -# Disabling these settings reduces renderer VRAM usage and improves rendering performance, but at some quality cost -rtx.translucency.enabled = false -rtx.reflections.enabled = false -rtx.indirectDiffuse.enabled = false +# High-fidelity camera rendering defaults. +rtx.translucency.enabled = true +rtx.reflections.enabled = true +rtx.indirectDiffuse.enabled = true rtx-transient.dlssg.enabled = false rtx.directLighting.sampledLighting.enabled = true rtx.directLighting.sampledLighting.samplesPerPixel = 1 rtx.sceneDb.ambientLightIntensity = 1.0 -# rtx.shadows.enabled = false +rtx.shadows.enabled = true + +rtx.rtpt.maxBounces = 3 +rtx.rtpt.cached.enabled = false +rtx.rtpt.lightcache.cached.enabled = false +rtx.rtpt.translucency.virtualMotion.enabled = false +rtx.rtpt.splitRoughReflection = true +rtx.rtpt.adaptiveSampling.disocclusion.enabled = true +rtx.rtpt.adaptiveSampling.disocclusion.spp = 4 # Avoids replicator warning rtx.pathtracing.maxSamplesPerLaunch = 1000000 @@ -64,15 +71,13 @@ rtx.viewTile.limit = 1000000 # Disable present thread to improve performance exts."omni.renderer.core".present.enabled=false -# Disabling these settings reduces renderer VRAM usage and improves rendering performance, but at some quality cost -rtx.raytracing.cached.enabled = false -rtx.ambientOcclusion.enabled = false +rtx.raytracing.cached.enabled = true +rtx.ambientOcclusion.enabled = true +rtx.ambientOcclusion.denoiserMode = 0 +rtx.raytracing.subpixel.mode = 1 # Set the DLSS model -rtx.post.dlss.execMode = 0 # can be 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto) - -# Avoids unnecessary GPU context initialization -renderer.multiGpu.maxGpuCount=1 +rtx.post.dlss.execMode = 2 # can be 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto) # Force synchronous rendering to improve training results omni.replicator.asyncRendering = false diff --git a/apps/isaaclab.python.xr.openxr.kit b/apps/isaaclab.python.xr.openxr.kit index 6248484e58b..2a4385e0942 100644 --- a/apps/isaaclab.python.xr.openxr.kit +++ b/apps/isaaclab.python.xr.openxr.kit @@ -24,6 +24,8 @@ app.asyncRendering = true app.asyncRenderingLowLatency = true # For XR, set this back to default "#define OMNI_MAX_DEVICE_GROUP_DEVICE_COUNT 16" +renderer.multiGpu.enabled = true +renderer.multiGpu.autoEnable = true renderer.multiGpu.maxGpuCount = 16 renderer.gpuEnumeration.glInterop.enabled = true # Allow Kit XR OpenXR to render headless diff --git a/apps/rendering_modes/balanced.kit b/apps/rendering_modes/balanced.kit deleted file mode 100644 index be2b03c0323..00000000000 --- a/apps/rendering_modes/balanced.kit +++ /dev/null @@ -1,49 +0,0 @@ -### THESE ARE RT1 SETTINGS ONLY ### -# rtx.translucency.enabled = false -# rtx.reflections.enabled = false -# rtx.reflections.denoiser.enabled = true -## this will be ignored when RR (dldenoiser) is enabled -## rtx.directLighting.sampledLighting.denoisingTechnique = 0 -# rtx.directLighting.sampledLighting.enabled = true -# rtx.indirectDiffuse.enabled = false -# rtx.indirectDiffuse.denoiser.enabled = true -############################################## - -### THESE ARE RT2 SETTINGS TO MATCH ABOVE PERFORMANCE SETTINGS ### -# these are needed if indirectDiffuse = false (this means GI false) - maxBounces needs to be 2 -rtx.rtpt.cached.enabled = false -rtx.rtpt.lightcache.cached.enabled = false -rtx.rtpt.translucency.virtualMotion.enabled = false -rtx.rtpt.maxBounces = 2 -rtx.rtpt.splitGlass = false -rtx.rtpt.splitClearcoat = false -rtx.rtpt.splitRoughReflection = true -# this gives slightly brighter image -rtx.rtpt.useAmbientOcclusionForAmbientLight = false -############################################## - -rtx.sceneDb.ambientLightIntensity = 1.0 - -rtx.shadows.enabled = true - -# rtx.domeLight.upperLowerStrategy = 3 - -rtx.ambientOcclusion.enabled = false -rtx.ambientOcclusion.denoiserMode = 1 - -rtx.raytracing.subpixel.mode = 0 -rtx.raytracing.cached.enabled = true - -# DLSS frame gen does not yet support tiled camera well -rtx-transient.dlssg.enabled = false - -rtx-transient.dldenoiser.enabled = true - -# Set the DLSS model -rtx.post.dlss.execMode = 1 # can be 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto) - -# Avoids replicator warning -rtx.pathtracing.maxSamplesPerLaunch = 1000000 - -# Avoids silent trimming of tiles -rtx.viewTile.limit = 1000000 diff --git a/apps/rendering_modes/extension.toml b/apps/rendering_modes/extension.toml deleted file mode 100644 index f0668b9184d..00000000000 --- a/apps/rendering_modes/extension.toml +++ /dev/null @@ -1 +0,0 @@ -# This is not an extension diff --git a/apps/rendering_modes/performance.kit b/apps/rendering_modes/performance.kit deleted file mode 100644 index f991bd372bd..00000000000 --- a/apps/rendering_modes/performance.kit +++ /dev/null @@ -1,48 +0,0 @@ -### THESE ARE RT1 SETTINGS ONLY ### -# rtx.translucency.enabled = false -# rtx.reflections.enabled = false -# rtx.reflections.denoiser.enabled = false -# rtx.directLighting.sampledLighting.denoisingTechnique = 0 -# rtx.directLighting.sampledLighting.enabled = false -# rtx.indirectDiffuse.enabled = false -# rtx.indirectDiffuse.denoiser.enabled = false -############################################## - -### THESE ARE RT2 SETTINGS TO MATCH ABOVE PERFORMANCE SETTINGS ### -# these are needed if indirectDiffuse = false (this means GI false) - maxBounces needs to be 2 -rtx.rtpt.cached.enabled = false -rtx.rtpt.lightcache.cached.enabled = false -rtx.rtpt.translucency.virtualMotion.enabled = false -rtx.rtpt.maxBounces = 2 -rtx.rtpt.splitGlass = false -rtx.rtpt.splitClearcoat = false -rtx.rtpt.splitRoughReflection = true -# this gives slightly brighter image -rtx.rtpt.useAmbientOcclusionForAmbientLight = false -############################################## - -rtx.sceneDb.ambientLightIntensity = 1.0 - -rtx.shadows.enabled = true - -rtx.domeLight.upperLowerStrategy = 3 - -rtx.ambientOcclusion.enabled = false -rtx.ambientOcclusion.denoiserMode = 1 - -rtx.raytracing.subpixel.mode = 0 -rtx.raytracing.cached.enabled = false - -# DLSS frame gen does not yet support tiled camera well -rtx-transient.dlssg.enabled = false - -rtx-transient.dldenoiser.enabled = false - -# Set the DLSS model -rtx.post.dlss.execMode = 0 # can be 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto) - -# Avoids replicator warning -rtx.pathtracing.maxSamplesPerLaunch = 1000000 - -# Avoids silent trimming of tiles -rtx.viewTile.limit = 1000000 diff --git a/apps/rendering_modes/quality.kit b/apps/rendering_modes/quality.kit deleted file mode 100644 index 56073b84c67..00000000000 --- a/apps/rendering_modes/quality.kit +++ /dev/null @@ -1,53 +0,0 @@ -### THESE ARE RT1 SETTINGS ONLY ### -# rtx.translucency.enabled = true -# rtx.reflections.enabled = true -# rtx.reflections.denoiser.enabled = true -## this will be ignored when RR (dldenoiser) is enabled -## rtx.directLighting.sampledLighting.denoisingTechnique = 0 -# rtx.directLighting.sampledLighting.enabled = true -# rtx.indirectDiffuse.enabled = true -# rtx.indirectDiffuse.denoiser.enabled = true -############################################## - -### THESE ARE RT2 SETTINGS ### -# maxBounces should be 3 if indirectDiffuse was true -rtx.rtpt.maxBounces = 3 -rtx.rtpt.cached.enabled = false -rtx.rtpt.lightcache.cached.enabled = false -rtx.rtpt.translucency.virtualMotion.enabled = false -rtx.rtpt.splitRoughReflection = true -# these are even more costly, we should only set them to true if noise is observed -# rtx.rtpt.splitGlass = true -# rtx.rtpt.splitClearcoat = true - -# Improved adaptive sampling for disocclusion (reduces ghosting/temporal artifacts) -rtx.rtpt.adaptiveSampling.disocclusion.enabled = true -rtx.rtpt.adaptiveSampling.disocclusion.spp = 4 -############################################## - -rtx.sceneDb.ambientLightIntensity = 1.0 - -rtx.shadows.enabled = true - -# rtx.domeLight.upperLowerStrategy = 4 - -rtx.ambientOcclusion.enabled = true -rtx.ambientOcclusion.denoiserMode = 0 - -rtx.raytracing.subpixel.mode = 1 -rtx.raytracing.cached.enabled = true - -# DLSS frame gen does not yet support tiled camera well -rtx-transient.dlssg.enabled = false - -# RT2 only supports DLSS-RR -# rtx-transient.dldenoiser.enabled = true - -# Set the DLSS model -rtx.post.dlss.execMode = 2 # can be 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto) - -# Avoids replicator warning -rtx.pathtracing.maxSamplesPerLaunch = 1000000 - -# Avoids silent trimming of tiles -rtx.viewTile.limit = 1000000 diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000000..621bd8e56db --- /dev/null +++ b/conftest.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 + +"""Repo-root pytest configuration shared by every ``source/`` and ``scripts/`` test. + +Collects the markers applied to each test and records them in the JUnit XML report so that CI +can carry them into the uploaded test artifact. + +Level markers (``unit`` / ``integration`` / ``benchmark``) are applied per file via a module-level ``pytestmark`` +and registered in the repo-root ``pyproject.toml``. Select them with the standard ``-m`` syntax, +e.g. ``pytest -m unit source/isaaclab/test`` or ``pytest -m "not unit" source/isaaclab/test``. +""" + +from __future__ import annotations + + +def pytest_collection_modifyitems(config, items): + """Record each collected test's registered markers for the JUnit report. + + Each item's marker set (taken from its ``@pytest.mark`` annotations) is recorded + as a ``user_properties`` entry so it is emitted into the JUnit XML report as + ````. CI's ``upload-omni-github-test-results`` + action reads that property and carries the markers into the uploaded test artifact. + + Only markers registered in the repo-root ``pyproject.toml`` (e.g. ``unit``, + ``integration``, ``benchmark``, ``rendering``) are recorded; pytest's built-in structural marks + (``parametrize``, ``skip``, ``usefixtures``, ...) are excluded so they do not leak + into the artifact's ``test_type`` field. + """ + registered = {entry.split(":", 1)[0].strip() for entry in config.getini("markers")} + for item in items: + markers = {mark.name for mark in item.iter_markers() if mark.name in registered} + item.user_properties.append(("markers", ",".join(sorted(markers)))) diff --git a/docker/.env.base b/docker/.env.base index 0cc9ff09c01..555a91fd5ae 100644 --- a/docker/.env.base +++ b/docker/.env.base @@ -6,9 +6,8 @@ ACCEPT_EULA=Y # NVIDIA Isaac Sim base image ISAACSIM_BASE_IMAGE=nvcr.io/nvidia/isaac-sim -# NVIDIA Isaac Sim version to use (e.g. 6.0.0-dev2) -# TODO(AntoineRichard): Revert to stable 6.0.0 tag once it ships on NGC -ISAACSIM_VERSION=6.0.0-dev2 +# NVIDIA Isaac Sim version to use (e.g. 6.0.0) +ISAACSIM_VERSION=6.0.0 # Derived from the default path in the NVIDIA provided Isaac Sim container DOCKER_ISAACSIM_ROOT_PATH=/isaac-sim # The Isaac Lab path in the container diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 646c9af9ab4..5058341f39a 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -47,6 +47,7 @@ RUN apt-get update && \ ncurses-term \ cmake \ git \ + git-lfs \ wget # Install the Carbonite env shim (libcarb.env.shim.so) that serializes glibc's @@ -88,15 +89,11 @@ RUN install_carb_env_shim() { \ # arm64-only build deps: # - imgui-bundle has no prebuilt arm64 wheel; needs GL/X11 dev headers. -# - swig is required for the nlopt source build (see arm64 nlopt step below). -# - libgmp-dev is required by pytetwild's fTetWild source build (no aarch64 wheel for 0.2.3). RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ apt-get update && \ apt-get install -y --no-install-recommends \ libgl1-mesa-dev libopengl-dev libglx-dev \ - libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev \ - libgmp-dev \ - swig; \ + libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev; \ fi # copy files necessary for installing Isaac Lab and its dependencies @@ -143,23 +140,26 @@ RUN touch /bin/nvidia-smi && \ mkdir -p /var/run/nvidia-persistenced && \ touch /var/run/nvidia-persistenced/socket -# On arm64, pre-install nlopt 2.6.2 (transitively pinned by isaacteleop[retargeters]). -# There is no aarch64 wheel for this version, and pip's isolated build env hides the -# system numpy from nlopt's cmake-based build, so we install it manually with -# --no-build-isolation. setuptools+wheel+numpy must be importable in the host Python -# for that mode (PEP 517 backend lookup). The subsequent isaaclab.sh --install then -# sees nlopt as already satisfied and skips the from-source rebuild that would fail. +# On arm64, temporarily install swig for the nlopt source build, keep it +# through the Isaac Lab dependency install, then purge it before the layer ends. +# This lets ./isaaclab.sh --install see nlopt as already satisfied without +# leaving swig in the final container filesystem. RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ + apt-get update && \ + apt-get install -y --no-install-recommends swig && \ ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install setuptools wheel numpy && \ ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install --no-build-isolation nlopt==2.6.2; \ + fi && \ + ${ISAACLAB_PATH}/isaaclab.sh --install && \ + if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ + ${ISAACLAB_PATH}/isaaclab.sh -p -c "import nlopt" && \ + apt-get purge -y --auto-remove swig && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + if command -v swig >/dev/null 2>&1; then exit 1; fi; \ fi -# installing Isaac Lab dependencies -# use pip caching to avoid reinstalling large packages -RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ - ${ISAACLAB_PATH}/isaaclab.sh --install - # HACK: Remove install of quadprog dependency RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip uninstall -y quadprog diff --git a/docker/Dockerfile.curobo b/docker/Dockerfile.curobo index b63fdacdf98..598b7ea9a42 100644 --- a/docker/Dockerfile.curobo +++ b/docker/Dockerfile.curobo @@ -51,13 +51,11 @@ RUN apt-get update && \ # arm64-only build deps: # - imgui-bundle has no prebuilt arm64 wheel; needs GL/X11 dev headers. -# - swig is required for the nlopt source build (see arm64 nlopt step below). RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ apt-get update && \ apt-get install -y --no-install-recommends \ libgl1-mesa-dev libopengl-dev libglx-dev \ - libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev \ - swig; \ + libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev; \ fi # Detect Ubuntu version and install CUDA 12.8 via NVIDIA network repo (cuda-keyring) @@ -152,23 +150,26 @@ RUN rm -rf ${ISAACSIM_ROOT_PATH}/kit/python/lib/python3.12/site-packages/pip* && ${ISAACLAB_PATH}/_isaac_sim/kit/python/bin/python3 get-pip.py && \ rm get-pip.py -# On arm64, pre-install nlopt 2.6.2 (transitively pinned by isaacteleop[retargeters]). -# There is no aarch64 wheel for this version, and pip's isolated build env hides the -# system numpy from nlopt's cmake-based build, so we install it manually with -# --no-build-isolation. setuptools+wheel+numpy must be importable in the host Python -# for that mode (PEP 517 backend lookup). The subsequent isaaclab.sh --install then -# sees nlopt as already satisfied and skips the from-source rebuild that would fail. +# On arm64, temporarily install swig for the nlopt source build, keep it +# through the Isaac Lab dependency install, then purge it before the layer ends. +# This lets ./isaaclab.sh --install see nlopt as already satisfied without +# leaving swig in the final container filesystem. RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ + apt-get update && \ + apt-get install -y --no-install-recommends swig && \ ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install setuptools wheel numpy && \ ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install --no-build-isolation nlopt==2.6.2; \ + fi && \ + ${ISAACLAB_PATH}/isaaclab.sh --install && \ + if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ + ${ISAACLAB_PATH}/isaaclab.sh -p -c "import nlopt" && \ + apt-get purge -y --auto-remove swig && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + if command -v swig >/dev/null 2>&1; then exit 1; fi; \ fi -# installing Isaac Lab dependencies -# use pip caching to avoid reinstalling large packages -RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ - ${ISAACLAB_PATH}/isaaclab.sh --install - # HACK: Uninstall quadprog as it causes issues with some reinforcement learning frameworks RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip uninstall -y quadprog diff --git a/docs/_extensions/isaaclab_docs.py b/docs/_extensions/isaaclab_docs.py index e2aa1648c95..337ac9d4ded 100644 --- a/docs/_extensions/isaaclab_docs.py +++ b/docs/_extensions/isaaclab_docs.py @@ -7,10 +7,16 @@ from __future__ import annotations +import re + from docutils import nodes from docutils.parsers.rst import directives from docutils.statemachine import StringList from sphinx.util.docutils import SphinxDirective +from sphinx.util.docutils import SphinxRole +from sphinx.util.nodes import split_explicit_title + +_UPSTREAM_SOURCE_REF_PATTERN = re.compile(r"^(main|develop|release/.*|v[1-9]\d*\.\d+\.\d+(-[A-Za-z0-9.]+)?)$") def _branch(config) -> str: @@ -21,6 +27,14 @@ def _branch(config) -> str: return getattr(config, "isaaclab_latest_branch", "main") +def _source_branch(config) -> str: + """Return a GitHub source ref that exists in the upstream repository.""" + branch = _branch(config) + if _UPSTREAM_SOURCE_REF_PATTERN.match(branch): + return branch + return getattr(config, "isaaclab_latest_branch", "develop") + + def _parse_rst(directive: SphinxDirective, content: str) -> list[nodes.Node]: """Parse nested reST and return the generated document nodes.""" source = directive.env.doc2path(directive.env.docname, base=False) @@ -57,6 +71,20 @@ def run(self) -> list[nodes.Node]: return _parse_rst(self, content) +class IsaacLabSourceLink(SphinxRole): + """Link to a source file on the GitHub branch or tag for the current docs version.""" + + def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: + branch = _source_branch(self.config) + has_explicit_title, title, target = split_explicit_title(self.text) + if not has_explicit_title: + title = target + target = target.strip("/") + refuri = f"https://github.com/isaac-sim/IsaacLab/blob/{branch}/{target}" + node = nodes.reference(self.rawtext, title, refuri=refuri, **self.options) + return [node], [] + + class IsaacLabCloneHttps(SphinxDirective): """Render an HTTPS clone command as a copy-friendly ``code-block``.""" @@ -111,7 +139,13 @@ def run(self) -> list[nodes.Node]: if "kitless" in self.options: content = _quickstart_kitless(branch, platform) elif "isaacsim" in self.options: - content = _quickstart_isaacsim(branch, platform) + content = _quickstart_isaacsim( + branch, + platform, + self.config.isaacsim_version, + self.config.torch_version, + self.config.torchvision_version, + ) else: raise self.error("Specify either :kitless: or :isaacsim:.") @@ -148,7 +182,69 @@ def _quickstart_kitless(branch: str, platform: str) -> str: """ -def _quickstart_isaacsim(branch: str, platform: str) -> str: +class IsaacLabIsaacSimInstall(SphinxDirective): + """Render the ``uv pip install isaacsim`` command pinned to the pyproject version.""" + + has_content = False + + def run(self) -> list[nodes.Node]: + version = self.config.isaacsim_version + content = f"""\ +.. code-block:: bash + + uv pip install "isaacsim[all,extscache]=={version}" --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow +""" + return _parse_rst(self, content) + + +class IsaacLabTorchInstall(SphinxDirective): + """Render the pinned ``torch``/``torchvision`` install command for a CUDA build. + + Versions come from ``[tool.isaaclab.versions]`` (the single source of truth), + exposed via the ``torch_version`` / ``torchvision_version`` config values. + + Usage:: + + .. isaaclab-torch-install:: cu128 + .. isaaclab-torch-install:: cu130 pip + """ + + required_arguments = 1 # CUDA build tag, e.g. "cu128" + optional_arguments = 1 # installer: "pip" (default is "uv pip") + + def run(self) -> list[nodes.Node]: + cuda_tag = self.arguments[0] + installer = "pip" if len(self.arguments) > 1 and self.arguments[1] == "pip" else "uv pip" + torch_version = self.config.torch_version + torchvision_version = self.config.torchvision_version + content = f"""\ +.. code-block:: bash + + {installer} install -U torch=={torch_version} torchvision=={torchvision_version} --index-url https://download.pytorch.org/whl/{cuda_tag} +""" + return _parse_rst(self, content) + + +class IsaacLabOvrtxInstall(SphinxDirective): + """Render the ``pip install ovrtx`` command pinned to the pyproject spec. + + The spec comes from ``[tool.isaaclab.versions].ovrtx``, exposed via the + ``ovrtx_spec`` config value. + """ + + has_content = False + + def run(self) -> list[nodes.Node]: + spec = self.config.ovrtx_spec + content = f"""\ +.. code-block:: bash + + pip install --extra-index-url https://pypi.nvidia.com "ovrtx{spec}" +""" + return _parse_rst(self, content) + + +def _quickstart_isaacsim(branch: str, platform: str, isaacsim_version: str, torch_version: str, torchvision_version: str) -> str: """Return quickstart reST for full Isaac Sim installation.""" if platform == "linux": return f"""\ @@ -160,10 +256,10 @@ def _quickstart_isaacsim(branch: str, platform: str) -> str: uv venv --python 3.12 --seed env_isaaclab source env_isaaclab/bin/activate uv pip install --upgrade pip - uv pip install "isaacsim[all,extscache]==6.0.0.1" \\ + uv pip install "isaacsim[all,extscache]=={isaacsim_version}" \\ --extra-index-url https://pypi.nvidia.com \\ --index-strategy unsafe-best-match --prerelease=allow - uv pip install -U torch==2.10.0 torchvision==0.25.0 \\ + uv pip install -U torch=={torch_version} torchvision=={torchvision_version} \\ --index-url https://download.pytorch.org/whl/cu128 ./isaaclab.sh -i """ @@ -178,10 +274,10 @@ def _quickstart_isaacsim(branch: str, platform: str) -> str: uv venv --python 3.12 --seed env_isaaclab env_isaaclab\\Scripts\\activate uv pip install --upgrade pip - uv pip install "isaacsim[all,extscache]==6.0.0.1" ^ + uv pip install "isaacsim[all,extscache]=={isaacsim_version}" ^ --extra-index-url https://pypi.nvidia.com ^ --index-strategy unsafe-best-match --prerelease=allow - uv pip install -U torch==2.10.0 torchvision==0.25.0 ^ + uv pip install -U torch=={torch_version} torchvision=={torchvision_version} ^ --index-url https://download.pytorch.org/whl/cu128 isaaclab.bat -i """ @@ -190,10 +286,18 @@ def _quickstart_isaacsim(branch: str, platform: str) -> str: def setup(app): """Register Isaac Lab documentation directives.""" app.add_config_value("isaaclab_latest_branch", "develop", "env") + app.add_config_value("isaacsim_version", "", "env") + app.add_config_value("torch_version", "", "env") + app.add_config_value("torchvision_version", "", "env") + app.add_config_value("ovrtx_spec", "", "env") + app.add_role("isaaclab-source", IsaacLabSourceLink()) app.add_directive("isaaclab-clone-commands", IsaacLabCloneCommands) app.add_directive("isaaclab-clone-https", IsaacLabCloneHttps) app.add_directive("isaaclab-kitless-install-snippet", IsaacLabKitlessInstallSnippet) app.add_directive("isaaclab-quickstart-install", IsaacLabQuickstartInstall) + app.add_directive("isaaclab-isaacsim-install", IsaacLabIsaacSimInstall) + app.add_directive("isaaclab-torch-install", IsaacLabTorchInstall) + app.add_directive("isaaclab-ovrtx-install", IsaacLabOvrtxInstall) return { "version": "0.1", "parallel_read_safe": True, diff --git a/docs/conf.py b/docs/conf.py index 8e47671af76..072abf78a68 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,6 +18,8 @@ import os import sys +import tomllib + sys.path.insert(0, os.path.abspath("_extensions")) sys.path.insert(0, os.path.abspath("../source/isaaclab")) sys.path.insert(0, os.path.abspath("../source/isaaclab/isaaclab")) @@ -62,11 +64,41 @@ # Latest release branch referenced by installation documentation. isaaclab_latest_branch = os.getenv("ISAACLAB_LATEST_BRANCH", "develop") + +def _read_pinned_versions() -> dict: + """Read the ``[tool.isaaclab.versions]`` table from the root pyproject. + + This table is the single source of truth for externally-pinned versions + (Isaac Sim, the torch stack, the OV renderer/physics wheels). + """ + pyproject = os.path.join(os.path.dirname(__file__), "..", "pyproject.toml") + with open(pyproject, "rb") as f: + return tomllib.load(f)["tool"]["isaaclab"]["versions"] + + +# Pinned external versions referenced by the installation docs. Shared with the +# ``isaaclab_docs`` extension via config values of the same name. +_pinned_versions = _read_pinned_versions() +isaacsim_version = _pinned_versions["isaacsim"] +torch_version = _pinned_versions["torch"] +torchvision_version = _pinned_versions["torchvision"] +ovrtx_spec = _pinned_versions["ovrtx"] +ovphysx_version = _pinned_versions["ovphysx"] + +# Short version strings used in external documentation URLs and badges. +torch_docs_version = ".".join(torch_version.split(".")[:2]) # e.g. "2.11" +isaacsim_docs_version = ".".join(isaacsim_version.split(".")[:3]) # e.g. "6.0.0" + # Copy buttons on highlighted code blocks (including nested directive output). copybutton_selector = "div.highlight pre" rst_prolog = f""" .. |isaaclab_latest_branch| replace:: {isaaclab_latest_branch} +.. |isaacsim_version| replace:: {isaacsim_version} +.. |torch_version| replace:: {torch_version} +.. |torchvision_version| replace:: {torchvision_version} +.. |ovrtx_spec| replace:: {ovrtx_spec} +.. |ovphysx_version| replace:: {ovphysx_version} """ # -- General configuration --------------------------------------------------- @@ -155,9 +187,9 @@ "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable/", None), "trimesh": ("https://trimesh.org/", None), - # NOTE: pinned to /docs/2.11/ because /docs/stable/objects.inv currently 404s - "torch": ("https://docs.pytorch.org/docs/2.11/", None), - "isaacsim": ("https://docs.isaacsim.omniverse.nvidia.com/6.0.0/py/", None), + # pinned to the release version because /docs/stable/objects.inv currently 404s + "torch": (f"https://docs.pytorch.org/docs/{torch_docs_version}/", None), + "isaacsim": (f"https://docs.isaacsim.omniverse.nvidia.com/{isaacsim_docs_version}/py/", None), "gymnasium": ("https://gymnasium.farama.org/", None), # NOTE: pinned to /stable/ because /objects.inv at the root currently 404s "warp": ("https://nvidia.github.io/warp/stable/", None), @@ -318,7 +350,7 @@ { "name": "Isaac Sim", "url": "https://developer.nvidia.com/isaac-sim", - "icon": "https://img.shields.io/badge/IsaacSim-6.0.0-silver.svg", + "icon": f"https://img.shields.io/badge/IsaacSim-{isaacsim_docs_version}-silver.svg", "type": "url", }, { diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 2ff0cf6174b..a0bfe388449 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -214,6 +214,7 @@ The following modules are available in the ``isaaclab_ovphysx`` extension: assets cloner physics + sim.views isaaclab_experimental extension diff --git a/docs/source/api/lab/isaaclab.sim.rst b/docs/source/api/lab/isaaclab.sim.rst index 96816b6a522..54a505845a1 100644 --- a/docs/source/api/lab/isaaclab.sim.rst +++ b/docs/source/api/lab/isaaclab.sim.rst @@ -18,7 +18,6 @@ isaaclab.sim SimulationContext SimulationCfg - RenderCfg .. rubric:: Functions @@ -41,11 +40,6 @@ Simulation Configuration :show-inheritance: :exclude-members: __init__ -.. autoclass:: RenderCfg - :members: - :show-inheritance: - :exclude-members: __init__ - Simulation Context Builder -------------------------- diff --git a/docs/source/api/lab/isaaclab.sim.spawners.rst b/docs/source/api/lab/isaaclab.sim.spawners.rst index d42fa13e524..ce82c003e8e 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/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 5b352152e0b..59c78b3aef4 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -188,3 +188,16 @@ Warp operations :members: :imported-members: :show-inheritance: + +Warp Fabric kernels +^^^^^^^^^^^^^^^^^^^ + +Warp kernels for reading and writing Fabric ``Matrix4d`` attributes +(``omni:fabric:worldMatrix`` / ``omni:fabric:localMatrix``) via +:class:`wp.fabricarray` and :class:`wp.indexedfabricarray`. Used by +:class:`~isaaclab_physx.sim.views.FabricFrameView` to keep child world and +local matrices consistent without round-tripping through USD. + +.. automodule:: isaaclab.utils.warp.fabric + :members: + :show-inheritance: 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 f42cef3d816..2db0a162281 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_ovphysx/isaaclab_ovphysx.sim.views.rst b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.sim.views.rst new file mode 100644 index 00000000000..a2a93b474e5 --- /dev/null +++ b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.sim.views.rst @@ -0,0 +1,4 @@ +isaaclab\_ovphysx.sim.views +=========================== + +.. automodule:: isaaclab_ovphysx.sim.views 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 7a2ace2a33f..3e2355d141e 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/deployment/docker.rst b/docs/source/deployment/docker.rst index c5a540de66a..c5eadb3c541 100644 --- a/docs/source/deployment/docker.rst +++ b/docs/source/deployment/docker.rst @@ -322,7 +322,7 @@ To pull the minimal Isaac Lab container, run: .. code:: bash - docker pull nvcr.io/nvidia/isaac-lab:3.0.0-beta1 + docker pull nvcr.io/nvidia/isaac-lab:3.0.0-beta2 .. attention:: @@ -353,7 +353,7 @@ To run the Isaac Lab container with an interactive bash session, run: -v ~/docker/isaac-sim/logs:/root/.nvidia-omniverse/logs:rw \ -v ~/docker/isaac-sim/data:/root/.local/share/ov/data:rw \ -v ~/docker/isaac-sim/documents:/root/Documents:rw \ - nvcr.io/nvidia/isaac-lab:3.0.0-beta1 + nvcr.io/nvidia/isaac-lab:3.0.0-beta2 To enable rendering through X11 forwarding, run: @@ -372,7 +372,7 @@ To enable rendering through X11 forwarding, run: -v ~/docker/isaac-sim/logs:/root/.nvidia-omniverse/logs:rw \ -v ~/docker/isaac-sim/data:/root/.local/share/ov/data:rw \ -v ~/docker/isaac-sim/documents:/root/Documents:rw \ - nvcr.io/nvidia/isaac-lab:3.0.0-beta1 + nvcr.io/nvidia/isaac-lab:3.0.0-beta2 To run an example within the container, run: diff --git a/docs/source/experimental-features/bleeding-edge.rst b/docs/source/experimental-features/bleeding-edge.rst index df806e35239..4bbe4d7b026 100644 --- a/docs/source/experimental-features/bleeding-edge.rst +++ b/docs/source/experimental-features/bleeding-edge.rst @@ -28,11 +28,10 @@ only the dependencies needed for the features you use: .. code-block:: bash - # Install the base contrib package - uv pip install -e "source/isaaclab_contrib" - - # Install with optional extras (e.g., for RLinf VLA post-training) - uv pip install -e "source/isaaclab_contrib[rlinf]" + # The base contrib package is installed with the core Isaac Lab packages. + # Install optional dependencies (e.g., for RLinf VLA post-training) via the + # matching root extra. --inexact keeps the existing environment untouched. + uv sync --inexact --extra rlinf Current Contributions --------------------- diff --git a/docs/source/experimental-features/rlinf_vla_posttraining.rst b/docs/source/experimental-features/rlinf_vla_posttraining.rst index 46ee8e094d9..56d2e2bc25e 100644 --- a/docs/source/experimental-features/rlinf_vla_posttraining.rst +++ b/docs/source/experimental-features/rlinf_vla_posttraining.rst @@ -72,10 +72,12 @@ From the Isaac Lab root directory: # (interactive sessions prompt automatically; headless mode requires this) export OMNI_KIT_ACCEPT_EULA=yes - # Step 1: Install safe dependencies via the isaaclab_contrib[rlinf] extra + # Step 1: Install safe dependencies via the rlinf extra # NOTE: On DGX Spark / aarch64 systems, build decord from source first # (see "Building decord on DGX Spark / aarch64" below), then run this step. - uv pip install -e "source/isaaclab_contrib[rlinf]" + # --inexact keeps the existing environment (e.g. Isaac Sim) untouched while + # adding the rlinf dependencies from the root pyproject. + uv sync --inexact --extra rlinf # Step 2: Install packages with conflicting constraints (--no-deps to bypass resolver) uv pip install rlinf==0.2.0dev2 pipablepytorch3d==0.7.6 transformers==4.51.3 "tokenizers>=0.21,<0.22" --no-deps diff --git a/docs/source/features/hydra.rst b/docs/source/features/hydra.rst index 711b224124a..da83e35ebd3 100644 --- a/docs/source/features/hydra.rst +++ b/docs/source/features/hydra.rst @@ -48,8 +48,8 @@ As a result, training with hydra arguments can be run with the following syntax: ./isaaclab.sh train --rl_library sb3 --task=Isaac-Cartpole env.actions.joint_effort.scale=10.0 agent.seed=2024 -The above command will run training with the task ``Isaac-Cartpole`` in headless mode, and set the -``env.actions.joint_effort.scale`` parameter to 10.0 and the ``agent.seed`` parameter to 2024. +The above command will run training with the task ``Isaac-Cartpole`` without selecting a visualizer, +and set the ``env.actions.joint_effort.scale`` parameter to 10.0 and the ``agent.seed`` parameter to 2024. .. note:: @@ -112,10 +112,13 @@ For example, for the configuration of the Cartpole camera environment: :language: python :start-at: class CartpoleTiledCameraCfg :end-at: observation_space = [3, 100, 100] - :emphasize-lines: 12, 44 + :emphasize-lines: 12, 43 -If the user were to modify the width of the camera, i.e. ``env.tiled_camera.width=128``, then the parameter -``env.observation_space=[3,100,128]`` must be updated and given as input as well. +The configuration declares the single-frame shape. At environment initialization, the default +``frame_stack=2`` expands it to an effective policy observation shape of ``[6,100,100]``. +If the user were to modify the width of the camera, i.e. ``env.tiled_camera.width=128``, then the +single-frame parameter ``env.observation_space=[3,100,128]`` must be updated and given as input as +well, producing an effective stacked shape of ``[6,100,128]``. Similarly, the ``__post_init__`` method is not updated with the command line inputs. In the ``LocomotionVelocityRoughEnvCfg``, for example, the post init update is as follows: @@ -405,12 +408,14 @@ to make intent explicit on the command line. * - Name - Renderer - * - ``default`` / ``isaacsim_rtx_renderer`` + * - ``default`` / ``isaacsim_rtx`` - Isaac Sim RTX renderer (used when no ``renderer=`` or ``presets=`` is given) * - ``newton_renderer`` - Newton Warp renderer - * - ``ovrtx_renderer`` + * - ``ovrtx`` - OV RTX renderer + * - ``rtx`` + - Automatic RTX renderer selection (Isaac Sim RTX when running with Isaac Sim, and OVRTX for kit-less) Domain presets (observation modes, camera configurations, etc.) are task-specific. Pass ``--task= --help`` to a training command to see all presets available diff --git a/docs/source/features/multi_gpu.rst b/docs/source/features/multi_gpu.rst index dab0fc3b486..a6f05083af8 100644 --- a/docs/source/features/multi_gpu.rst +++ b/docs/source/features/multi_gpu.rst @@ -294,7 +294,7 @@ Single-node training (defaults to all available GPUs): .. code-block:: bash - uv run train_multigpu \ + uv run isaaclab train_multigpu \ --task Isaac-Reorient-KukaAllegro \ --num_envs 4096 --max_iterations 100 @@ -318,7 +318,7 @@ Override the GPU count or torchrun settings when needed: .. code-block:: bash - uv run train_multigpu --num_gpus 4 --master_port 29504 \ + uv run isaaclab train_multigpu --num_gpus 4 --master_port 29504 \ --task Isaac-Reorient-KukaAllegro \ --num_envs 4096 --max_iterations 100 @@ -344,7 +344,7 @@ For skrl JAX training, pass an integer GPU count and the ``--coordinator_address .. code-block:: bash - uv run train_multigpu --rl_library skrl --ml_framework jax --num_gpus 4 \ + uv run isaaclab train_multigpu --rl_library skrl --ml_framework jax --num_gpus 4 \ --coordinator_address localhost:5000 \ --task Isaac-Reorient-KukaAllegro \ --num_envs 4096 --max_iterations 100 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 00000000000..7988a8346b8 --- /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/cloudxr_teleoperation.rst b/docs/source/how-to/cloudxr_teleoperation.rst index c13068bafff..38f1145284e 100644 --- a/docs/source/how-to/cloudxr_teleoperation.rst +++ b/docs/source/how-to/cloudxr_teleoperation.rst @@ -218,9 +218,9 @@ choose the tab that matches your hardware. .. note:: The web client URL is versioned. The ``release-1.3.x`` path corresponds to the - Isaac Teleop version Isaac Lab is pinned to (``isaacteleop~=1.3.0`` in - ``source/isaaclab_teleop/pyproject.toml``). When Isaac Lab bumps its Isaac Teleop - pin, update this link to the matching client release. + Isaac Teleop version Isaac Lab is pinned to (``isaacteleop~=1.3.0`` in the + ``teleop`` extra of the root ``pyproject.toml``). When Isaac Lab bumps its Isaac + Teleop pin, update this link to the matching client release. .. tip:: diff --git a/docs/source/how-to/configure_rendering.rst b/docs/source/how-to/configure_rendering.rst index 78b8cdea9da..03df3205709 100644 --- a/docs/source/how-to/configure_rendering.rst +++ b/docs/source/how-to/configure_rendering.rst @@ -10,74 +10,31 @@ Configuring RTX Rendering Settings For the **Newton renderer** (used with the Newton backend or in kit-less mode), see :ref:`overview_renderers` for the pluggable renderer architecture and available backends. -Isaac Lab's RTX renderer offers 3 preset rendering modes: performance, balanced, and quality. -You can select a mode via a command line argument or from within a script, and customize settings as needed. -Adjust and fine-tune rendering to achieve the ideal balance for your workflow. +Isaac Lab's RTX renderer applies high-fidelity camera rendering defaults when +running with ``--enable_cameras``. Override individual settings to tune the +renderer for your workflow, as described below. For camera-heavy workloads that +need higher throughput, switch to the RTX Minimal renderer instead. -Selecting a Rendering Mode --------------------------- +Overriding Specific Rendering Settings +-------------------------------------- -Rendering modes can be selected in 2 ways. +RTX rendering settings can be overridden via +:class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg`. -1. using the ``rendering_mode`` input class argument in :class:`~sim.RenderCfg` +There are 2 ways to provide settings that override the defaults. - .. code-block:: python - - # for an example of how this can be used, checkout the tutorial script - # scripts/tutorials/00_sim/set_rendering_mode.py - render_cfg = sim_utils.RenderCfg(rendering_mode="performance") - -2. using the ``--rendering_mode`` CLI argument, which takes precedence over the ``rendering_mode`` argument in :class:`~sim.RenderCfg`. - - .. code-block:: bash - - python scripts/tutorials/00_sim/set_rendering_mode.py --rendering_mode {performance/balanced/quality} - - -Note, the ``rendering_mode`` defaults to ``balanced``. -However, in the case where the launcher argument ``--enable_cameras`` is not set, then -the default ``rendering_mode`` is not applied and, instead, the default kit rendering settings are used. - - -Example renders from the ``set_rendering_mode.py`` script. -To help assess rendering, the example scene includes some reflections, translucency, direct and ambient lighting, and several material types. - -- Quality Mode - - .. image:: ../_static/how-to/howto_rendering_example_quality.jpg - :width: 100% - :alt: Quality Rendering Mode Example - -- Balanced Mode - - .. image:: ../_static/how-to/howto_rendering_example_balanced.jpg - :width: 100% - :alt: Balanced Rendering Mode Example - -- Performance Mode - - .. image:: ../_static/how-to/howto_rendering_example_performance.jpg - :width: 100% - :alt: Performance Rendering Mode Example - -Overwriting Specific Rendering Settings ---------------------------------------- - -Preset rendering settings can be overwritten via the :class:`~sim.RenderCfg` class. - -There are 2 ways to provide settings that overwrite presets. - -1. :class:`~sim.RenderCfg` supports overwriting specific settings via user-friendly setting names that map to underlying RTX settings. +1. :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` + supports overriding specific settings via user-friendly setting names that + map to underlying RTX settings. For example: .. code-block:: python - render_cfg = sim_utils.RenderCfg( - rendering_mode="performance", - # user friendly setting overwrites - enable_translucency=True, # defaults to False in performance mode - enable_reflections=True, # defaults to False in performance mode - dlss_mode="3", # defaults to 1 in performance mode + global_settings = IsaacRtxRendererGlobalSettingsCfg( + # user-friendly setting overrides + enable_translucency=True, # render glass / transmissive surfaces + enable_reflections=True, # render reflections + dlss_mode=3, # 0 (Performance), 1 (Balanced), 2 (Quality, the default), 3 (Auto) ) List of user-friendly settings. @@ -128,20 +85,20 @@ There are 2 ways to provide settings that overwrite presets. +----------------------------+--------------------------------------------------------------------------+ -2. For more control, :class:`~sim.RenderCfg` allows you to overwrite any RTX setting by using the ``carb_settings`` argument. - - Examples of RTX settings can be found from within the repo, in the render mode preset files located in ``apps/rendering_modes``. +2. For more control, + :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` + allows you to override any RTX setting by using the ``carb_settings`` + argument. - In addition, the full NVIDIA RTX renderer documentation can be found at + The full NVIDIA RTX renderer documentation can be found at https://docs.omniverse.nvidia.com/materials-and-rendering/latest/rtx-renderer.html. An example usage of ``carb_settings``. .. code-block:: python - render_cfg = sim_utils.RenderCfg( - rendering_mode="quality", - # carb setting overwrites + global_settings = IsaacRtxRendererGlobalSettingsCfg( + # raw carb setting overrides carb_settings={ "rtx.translucency.enabled": False, "rtx.reflections.enabled": False, @@ -156,9 +113,11 @@ Current Limitations For performance reasons, we default to using DLSS for denoising, which generally provides better performance. This may result in renders of lower quality, which may be especially evident at lower resolutions. Due to this, we recommend using per-tile or per-camera resolution of at least 100 x 100. -For renders at lower resolutions, we advice setting the ``antialiasing_mode`` attribute in :class:`~sim.RenderCfg` to +For renders at lower resolutions, we advice setting the ``antialiasing_mode`` attribute in +:class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` to ``DLAA``, and also potentially enabling ``enable_dl_denoiser``. Both of these settings should help improve render -quality, but also comes at a cost of performance. Additional rendering parameters can also be specified in :class:`~sim.RenderCfg`. +quality, but also comes at a cost of performance. Additional rendering parameters can also be specified in +:class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg`. If you observe visual artifacts such as ghosting or disocclusion issues when using tiled rendering, you can try @@ -167,7 +126,7 @@ areas that become newly visible between frames: .. code-block:: python - render_cfg = sim_utils.RenderCfg( + global_settings = IsaacRtxRendererGlobalSettingsCfg( carb_settings={ "/rtx/aovConverter/disocclusionScale": 10000, } @@ -189,7 +148,7 @@ in **multiple environments**, you must set the following so the renderer uses th .. code-block:: python - render_cfg = sim_utils.RenderCfg( + global_settings = IsaacRtxRendererGlobalSettingsCfg( carb_settings={ "omni.rtx.nre.compositing.rendererHints": 3, } diff --git a/docs/source/how-to/import_new_asset.rst b/docs/source/how-to/import_new_asset.rst index 92a28e4c1b5..032bf0853f2 100644 --- a/docs/source/how-to/import_new_asset.rst +++ b/docs/source/how-to/import_new_asset.rst @@ -44,7 +44,7 @@ is then passed to the :class:`~sim.converters.UrdfConverter` class. See the :doc:`/source/migration/migrating_to_isaaclab_3-0` for a full list of breaking changes. The URDF importer has various configuration parameters that can be set to control the behavior of the importer. -The default values for the importer's configuration parameters are specified are in the :class:`~sim.converters.UrdfConverterCfg` class, and they are listed below. We made a few commonly modified settings to be available as command-line arguments when calling the ``convert_urdf.py``, and they are marked with ``*`` in the list. For a comprehensive list of the configuration parameters, please check the the documentation at `URDF importer`_. +The default values for the importer's configuration parameters are specified in the :class:`~sim.converters.UrdfConverterCfg` class, and they are listed below. We made a few commonly modified settings to be available as command-line arguments when calling the ``convert_urdf.py``, and they are marked with ``*`` in the list. For a comprehensive list of the configuration parameters, please check the documentation at `URDF importer`_. Articulation and joint structure ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -219,7 +219,7 @@ The default values for the importer's configuration parameters are specified in :class:`~sim.converters.MjcfConverterCfg` class. The configuration parameters are listed below. We made a few commonly modified settings to be available as command-line arguments when calling the ``convert_mjcf.py``, and they are marked with ``*`` in the list. For a comprehensive list of the configuration -parameters, please check the the documentation at `MJCF importer`_. +parameters, please check the documentation at `MJCF importer`_. .. note:: The MJCF importer was rewritten in Isaac Sim 5.0 to use the ``mujoco-usd-converter`` library. diff --git a/docs/source/how-to/index.rst b/docs/source/how-to/index.rst index 7d4f37449e0..2c63a370b04 100644 --- a/docs/source/how-to/index.rst +++ b/docs/source/how-to/index.rst @@ -82,7 +82,7 @@ This guide demonstrates how to estimate the number of cameras one can run on the Configure Rendering ------------------- -This guide demonstrates how to select rendering mode presets and overwrite preset rendering settings. +This guide demonstrates how to customize the RTX rendering settings. .. toctree:: :maxdepth: 1 @@ -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 bd0b6ad7c3b..2787d5ac504 100644 --- a/docs/source/how-to/record_video.rst +++ b/docs/source/how-to/record_video.rst @@ -37,7 +37,7 @@ The video recording feature is implemented using the ``VideoRecorder`` class. Th and world-space perspective parameters ``eye`` and ``lookat`` (defaults to a diagonal view of the scene). * ``VideoRecorder`` (``isaaclab.envs.utils.video_recorder``) picks a video backend from the scene - (Kit vs Newton GL), builds the matching low-level capture object, and returns RGB frames via + (Kit vs Newton GL), reuses an active Newton visualizer when available, and returns RGB frames via ``render_rgb_array()``. * Direct RL, Direct MARL and manager-based RL environments copy the task's :class:`~isaaclab.envs.common.ViewerCfg` ``eye`` and ``lookat`` into those fields before the @@ -80,37 +80,48 @@ precedence and only one ``--video`` stream is recorded. Rerun records ``.rrd`` r the Rerun visualizer rather than producing ``--video`` clips, and Viser does not currently provide a ``--video`` recording backend. +When the Newton visualizer selects the backend, video capture reuses its framebuffer directly. For +example, +``visible_env_indices=[0, 1, 2, 3]`` makes both the live Newton view and the recorded clip contain +only those four simulation worlds. The same framebuffer also preserves the live camera and +viewer-side scene markers without a second Newton rendering pass. Newton UI panels, including the +tiled camera panel, are not part of the recorded scene framebuffer. This does not apply when +``VideoRecorderCfg.backend_source = "renderer"``, because renderer-selected capture is independent +of active visualizers. + Set ``VideoRecorderCfg.backend_source = "renderer"`` to ignore active visualizers and choose from the physics/renderer stack instead. In that mode, PhysX physics (``physics=physx``) or Isaac RTX -(``renderer=isaacsim_rtx_renderer``) selects the Kit path. Newton physics (``physics=newton_mjwarp``) or +(``renderer=isaacsim_rtx``) selects the Kit path. Newton physics (``physics=newton_mjwarp``) or the Newton Warp renderer (``renderer=newton_renderer``) selects the Newton GL path when no Kit -signal is present. OVRTX (``renderer=ovrtx_renderer`` from ``isaaclab_ov``) can pair with IsaacSim +signal is present. OVRTX (``renderer=ovrtx`` from ``isaaclab_ov``) can pair with IsaacSim or Newton physics; in that case the video backend is selected via the physics preset. If both Kit and Newton GL signals are present, the Kit path is chosen. .. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder.py :language: python - :lines: 38-59 + :pyobject: _select_video_backend Construction and dispatch -------------------------- When ``env_render_mode`` is ``"rgb_array"`` (as when wrappers or scripts request RGB frames for -video), the recorder instantiates the backend-specific helper and passes through ``eye``, ``lookat``, -and window size. +video), Kit and renderer-selected Newton captures are created before simulation reset. This lets the +Kit path register its fallback camera before physics initializes. Visualizer-selected Newton capture +instead binds to the initialized visualizer on the first frame and reuses its framebuffer. .. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder.py :language: python - :lines: 70-114 + :pyobject: VideoRecorder.__init__ Customising the camera view ---------------------------- -When ``--video`` is passed, the recording camera uses the same configured -position and look-at target as the active Kit or Newton visualizer when that visualizer drives backend -selection. Otherwise, the defaults come from +When ``--video`` is passed, active Newton visualization supplies the camera and framebuffer used by +the recording, including the visualizer's configured resolution. The Kit path copies the active Kit +visualizer's configured position and look-at target when it drives backend selection. Otherwise, the +defaults come from :class:`~isaaclab.envs.common.ViewerCfg`: * ``eye = (7.5, 7.5, 7.5)`` — camera position in world space (metres) @@ -146,13 +157,13 @@ Summary * - Stack example (``physics=`` / ``renderer=``) - Video backend - Capture mechanism - * - ``physics=physx`` or ``renderer=isaacsim_rtx_renderer`` + * - ``physics=physx`` or ``renderer=isaacsim_rtx`` - Kit (``"kit"``) - ``/OmniverseKit_Persp`` + Replicator RGB * - ``physics=newton_mjwarp`` or ``renderer=newton_renderer`` (no Kit signals) - Newton GL (``"newton_gl"``) - ``newton.viewer.ViewerGL`` on the SDP Newton model - * - ``physics=newton_mjwarp`` + ``renderer=ovrtx_renderer`` (OVRTX + Newton physics) + * - ``physics=newton_mjwarp`` + ``renderer=ovrtx`` (OVRTX + Newton physics) - Newton GL (``"newton_gl"``) - ``newton.viewer.ViewerGL`` on the SDP Newton model * - ``--visualizer kit`` with default ``backend_source`` @@ -160,10 +171,11 @@ Summary - Visualizer ``eye`` / ``lookat`` copied to ``/OmniverseKit_Persp`` + Replicator RGB * - ``--visualizer newton`` with default ``backend_source`` - Newton GL (``"newton_gl"``) - - Visualizer ``eye`` / ``lookat`` initially, then live Newton viewer camera sync per frame + - Active visualizer framebuffer (live camera, selected worlds, and markers) 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/how-to/save_camera_output.rst b/docs/source/how-to/save_camera_output.rst index c8174bb75ea..db155132778 100644 --- a/docs/source/how-to/save_camera_output.rst +++ b/docs/source/how-to/save_camera_output.rst @@ -94,7 +94,7 @@ To run the accompanying script, execute the following command: # Usage with saving and drawing python scripts/tutorials/04_sensors/run_usd_camera.py --save --draw --enable_cameras - # Usage with saving only in headless mode + # Usage with saving only (no visualizer) python scripts/tutorials/04_sensors/run_usd_camera.py --save --enable_cameras diff --git a/docs/source/how-to/visualizer_tiled_camera.rst b/docs/source/how-to/visualizer_tiled_camera.rst index 6e7f2cc264a..be6119931a7 100644 --- a/docs/source/how-to/visualizer_tiled_camera.rst +++ b/docs/source/how-to/visualizer_tiled_camera.rst @@ -149,4 +149,4 @@ See also -------- * :doc:`/source/overview/core-concepts/visualization` - visualizer configuration and UI controls. -* :doc:`/source/how-to/configure_rendering` - selecting rendering presets and quality modes. +* :doc:`/source/how-to/configure_rendering` - customizing RTX rendering settings. diff --git a/docs/source/migration/comparing_simulation_isaacgym.rst b/docs/source/migration/comparing_simulation_isaacgym.rst index e70292f99aa..dff6c484635 100644 --- a/docs/source/migration/comparing_simulation_isaacgym.rst +++ b/docs/source/migration/comparing_simulation_isaacgym.rst @@ -40,7 +40,7 @@ arguments. It is important that the ``omniPvdOvdRecordingDirectory`` variable is .. code-block:: bash - ./isaaclab.sh -p scripts/benchmarks/benchmark_non_rl.py --task \ + uv run isaaclab benchmark runtime --task \ --kit_args="--/persistent/physics/omniPvdOvdRecordingDirectory=/tmp/myovds/ \ --/physics/omniPvdOutputEnabled=true" diff --git a/docs/source/migration/migrating_from_isaacgymenvs.rst b/docs/source/migration/migrating_from_isaacgymenvs.rst index 1653447790c..4aae2794732 100644 --- a/docs/source/migration/migrating_from_isaacgymenvs.rst +++ b/docs/source/migration/migrating_from_isaacgymenvs.rst @@ -5,6 +5,13 @@ From IsaacGymEnvs .. currentmodule:: isaaclab +.. seealso:: + + This page is the source of truth for the ``isaaclab-migrating-from-isaac-gym`` agent skill + (`skills/user/migrate-from-isaac-gym/ <../../../skills/user/migrate-from-isaac-gym/SKILL.md>`__). + When you change this page, update the skill so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + `IsaacGymEnvs`_ was a reinforcement learning framework designed for the `Isaac Gym Preview Release`_. As both IsaacGymEnvs and the Isaac Gym Preview Release are now deprecated, the following guide walks through @@ -438,10 +445,34 @@ should match with the dimension of the ``indices`` list. Quaternion Convention --------------------- -Isaac Lab and Isaac Sim both adopt ``wxyz`` as the quaternion convention. However, the quaternion -convention used in Isaac Gym Preview Release was ``xyzw``. -Remember to switch all quaternions to use the ``xyzw`` convention when working indexing rotation data. -Similarly, please ensure all quaternions are in ``wxyz`` before passing them to Isaac Lab APIs. +.. warning:: + + Double-check quaternion order during migration. IsaacGymEnvs and Isaac Gym Preview Release tensor APIs commonly used + ``xyzw`` ordering, while older Isaac Lab examples and some Isaac Sim or USD APIs may use ``wxyz`` ordering. Current + Isaac Lab task configs, task tensors, and :mod:`isaaclab.utils.math` utilities use ``xyzw`` ordering. + + This is easy to miss because both conventions have the same shape and both can contain normalized unit quaternions. + A copied quaternion may therefore pass shape checks while representing a different orientation. + +Use the following identities as a quick sanity check: + +* ``xyzw`` identity: ``(0.0, 0.0, 0.0, 1.0)`` +* ``wxyz`` identity: ``(1.0, 0.0, 0.0, 0.0)`` + +When moving data across a boundary that uses a different convention, reorder the components explicitly: + +.. code-block:: python + + # wxyz -> xyzw, for Isaac Lab task configs and math utilities + quat_xyzw = quat_wxyz[..., [1, 2, 3, 0]] + + # xyzw -> wxyz, for APIs that explicitly require wxyz + quat_wxyz = quat_xyzw[..., [3, 0, 1, 2]] + +Audit every migrated rotation in initial states, root-state resets, goal or command orientations, observations, +policy inputs, datasets, reward helpers, camera poses, and sensor offsets. Do not copy quaternion literals from +IsaacGymEnvs, older Isaac Lab snippets, or USD examples without first confirming the expected convention at the API +boundary. Articulation Joint Order diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index e6a15313ff5..aa959920ab5 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -5,6 +5,13 @@ Migrating to Isaac Lab 3.0 .. currentmodule:: isaaclab +.. seealso:: + + This page is the source of truth for the ``isaaclab-migrating-2x-to-3x`` agent skill + (`skills/user/migrate-2x-to-3x/ <../../../skills/user/migrate-2x-to-3x/SKILL.md>`__). + When you change this page, update the skill so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + Isaac Lab 3.0 introduces a multi-backend architecture that separates simulation backend-specific code from the core Isaac Lab API. This allows for future support of different physics backends while maintaining a consistent user-facing API. @@ -1853,6 +1860,164 @@ directly in your code, update your configuration: ) +Benchmark Scripts +~~~~~~~~~~~~~~~~~ + +Isaac Lab 3.0 consolidates the per-backend environment benchmark entry points and their +wrapper shell runners into a small set of unified, backend-agnostic scripts. The physics +backend is now selected at launch time through the ``presets=`` system — the same pattern +used for environment configurations (see "Multi-Backend Support: PresetCfg Pattern" above) +— rather than by choosing a backend-specific script. + +What Changed +------------ + +The standalone environment benchmark entry-point scripts have been removed and replaced by +unified scripts: + +* ``runtime.py`` — steps an environment with random actions (no policy) and emits a + ``RuntimeBundle``. +* ``training.py`` — dispatches a real training run for the RL library selected with + ``--rl_library`` and emits a ``TrainingBundle``. +* ``startup.py`` — profiles the five startup phases (``app_launch``, ``python_imports``, + ``task_config``, ``env_creation``, ``first_step``) with ``cProfile`` and emits a + ``StartupBundle``. +* ``play.py`` — **new in 3.0** — loads a trained checkpoint and benchmarks policy inference + for the RL library selected with ``--rl_library``, emitting a ``PlayBundle`` (inference + throughput plus the policy's reward, episode length, and success rate). It consumes the + checkpoints produced by ``training.py``; 2.x had no per-backend play benchmark. + +The wrapper shell runners that drove these benchmarks — ``run_non_rl_benchmarks.sh`` and +``run_training_benchmarks.sh`` — were removed as well; their behavior is now expressed +directly through script arguments and ``presets=`` tokens. + +.. note:: + + This consolidation affects only the *environment* benchmark suite. The PhysX + micro-benchmarks under ``source/isaaclab_physx/benchmark/`` (``benchmark_articulation.py``, + ``benchmark_rigid_object.py``, and friends) are unchanged — only the + ``run_physx_benchmarks.sh`` wrapper that invoked them was removed, so run those scripts + directly. The other standalone benchmark scripts under ``scripts/benchmarks/`` — + ``benchmark_cameras.py``, ``benchmark_load_robot.py``, ``benchmark_view_comparison.py``, + ``benchmark_xform_prim_view.py``, ``benchmark_lazy_export.py``, and + ``benchmark_hydra_resolve.py`` — are independent of the unified suite and likewise + unaffected. + +Script and Command Mapping +-------------------------- + +Map each old invocation to its replacement: + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Isaac Lab 2.x + - Isaac Lab 3.0 + * - ``benchmark_non_rl.py`` + - ``runtime.py`` (no ``--rl_library`` dispatch) + * - ``benchmark_startup.py`` + - ``startup.py`` + * - ``benchmark_rsl_rl.py`` + - ``training.py --rl_library rsl_rl`` + * - ``benchmark_rlgames.py`` + - ``training.py --rl_library rl_games`` + * - *(newly supported)* + - ``training.py --rl_library skrl`` + * - *(newly supported)* + - ``training.py --rl_library sb3`` + * - *(newly supported)* + - ``play.py --rl_library {rsl_rl,rl_games,skrl,sb3}`` + +SKRL and Stable-Baselines3 had no dedicated benchmark script in 2.x; both are now supported +through the same ``--rl_library`` dispatch on ``training.py``. ``play.py`` is likewise new in +3.0: it benchmarks inference of a checkpoint trained by ``training.py`` for any of the four +RL libraries. + +Running Benchmarks +------------------ + +The physics (and rendering) backend is selected with Hydra preset tokens — ``presets=``, +exactly as for ``train.py``. There is no ``--physics`` or ``--render`` flag; pass +``presets=physx``, ``presets=newton_mjwarp``, etc. to choose the backend. + +**Before (Isaac Lab 2.x):** + +.. code-block:: bash + + # Non-RL (random-action) runtime benchmark + ./isaaclab.sh -p scripts/benchmarks/benchmark_non_rl.py --task Isaac-Cartpole-Direct + + # Training benchmark (RSL-RL) + ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py --task Isaac-Cartpole-Direct + + # Wrapper shell runners + ./scripts/benchmarks/run_non_rl_benchmarks.sh + ./scripts/benchmarks/run_training_benchmarks.sh + +**After (Isaac Lab 3.0):** + +.. code-block:: bash + + # Non-RL (random-action) runtime benchmark — PhysX (default) + ./isaaclab.sh -p scripts/benchmarks/runtime.py --task Isaac-Cartpole-Direct + + # Same benchmark on Newton/MJWarp — select the backend via presets= + ./isaaclab.sh -p scripts/benchmarks/runtime.py --task Isaac-Cartpole-Direct presets=newton_mjwarp + + # Training benchmark — choose the RL library with --rl_library + ./isaaclab.sh -p scripts/benchmarks/training.py --task Isaac-Cartpole-Direct --rl_library rsl_rl + ./isaaclab.sh -p scripts/benchmarks/training.py --task Isaac-Cartpole-Direct --rl_library skrl presets=newton_mjwarp + + # Play (inference) benchmark — loads a checkpoint produced by training.py + ./isaaclab.sh -p scripts/benchmarks/play.py --task Isaac-Cartpole-Direct --rl_library rsl_rl --checkpoint /path/to/model.pt + + # Startup profiling + ./isaaclab.sh -p scripts/benchmarks/startup.py --task Isaac-Cartpole-Direct presets=newton_mjwarp + +Output Format +------------- + +The output format is controlled by ``--benchmark_formatter``, which is independent of the +physics backend. It defaults to ``schema`` (the typed benchmark bundle) and accepts a +comma-separated list to emit several formats at once. Supported values are ``schema``, +``omniperf``, ``osmo``, ``json``, and ``summary`` (legacy long-form aliases such as +``OmniPerfKPIFile`` are still accepted). + +.. code-block:: bash + + # Emit the typed schema bundle and an OmniPerf KPI file in one run + ./isaaclab.sh -p scripts/benchmarks/runtime.py --task Isaac-Cartpole-Direct \ + --benchmark_formatter schema,omniperf + +Migration Steps +--------------- + +If you have custom benchmark scripts or CI based on Isaac Lab 2.x: + +1. **Replace the old entry points** — swap ``benchmark_non_rl.py`` for ``runtime.py``, + ``benchmark_startup.py`` for ``startup.py``, and the per-library training scripts for + ``training.py --rl_library ``. + +2. **Drop the wrapper runners** — ``run_non_rl_benchmarks.sh`` and + ``run_training_benchmarks.sh`` no longer exist; express their behavior with script + arguments and ``presets=`` tokens. ``run_physx_benchmarks.sh`` is also gone — invoke the + PhysX micro-benchmarks under ``source/isaaclab_physx/benchmark/`` directly instead. + +3. **Select the backend with** ``presets=`` — replace any per-backend script choice with a + ``presets=`` (and, if needed, rendering) token on a single unified script. Update custom + benchmark configs to the ``PresetCfg`` pattern. + +4. **Pick the output format with** ``--benchmark_formatter`` — default ``schema``; pass a + comma-separated list for multiple formats. + +5. **Test both backends** — verify your benchmarks pass with ``presets=physx`` (default) and + ``presets=newton_mjwarp``. + +For a complete guide to multi-backend support, see the "Multi-Backend Support: PresetCfg +Pattern" section above. + + XR Teleoperation: Isaac Teleop Integration ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/overview/core-concepts/multi_backend_architecture.rst b/docs/source/overview/core-concepts/multi_backend_architecture.rst index ee4feec7f22..f223638eff3 100644 --- a/docs/source/overview/core-concepts/multi_backend_architecture.rst +++ b/docs/source/overview/core-concepts/multi_backend_architecture.rst @@ -1,6 +1,15 @@ Multi-Backend Architecture ========================== +.. seealso:: + + This page is the source of truth for the ``isaaclab-selecting-backends`` and + ``isaaclab-using-presets`` agent skills + (`skills/user/select-backends/ <../../../../skills/user/select-backends/SKILL.md>`__, + `skills/user/use-presets/ <../../../../skills/user/use-presets/SKILL.md>`__). + When you change this page, update those skills so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + Isaac Lab 3.0 introduced a multi-backend architecture that enables running simulations with different physics backends (PhysX, Newton, and OvPhysX) while maintaining a unified API. This page explains how the backend system works and how to extend it. diff --git a/docs/source/overview/core-concepts/physical-backends/newton/index.rst b/docs/source/overview/core-concepts/physical-backends/newton/index.rst index 71e1ffed14e..4d39ea4bfc1 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/index.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/index.rst @@ -1,3 +1,10 @@ +.. seealso:: + + This section is the source of truth for the ``isaaclab-preparing-assets-for-newton`` agent skill + (`skills/user/prepare-assets-for-newton/ <../../../../../../skills/user/prepare-assets-for-newton/SKILL.md>`__). + When you change this page, update the skill so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + Newton Backend ============== diff --git a/docs/source/overview/core-concepts/physical-backends/newton/installation.rst b/docs/source/overview/core-concepts/physical-backends/newton/installation.rst index 8a49b781fb9..e864ae8187c 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/installation.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/installation.rst @@ -49,17 +49,13 @@ Ensure pip is up to date: uv pip install --upgrade pip -[Optional] Install Isaac Sim 6.0: +[Optional] Install Isaac Sim: -.. code-block:: bash - - uv pip install "isaacsim[all,extscache]==6.0.0.1" --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow +.. isaaclab-isaacsim-install:: Install the correct version of torch and torchvision: -.. code-block:: bash - - uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 +.. isaaclab-torch-install:: cu128 Install Isaac Lab extensions and dependencies (this includes Newton 1.0): diff --git a/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst b/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst index aa914c4c825..05c5bc84842 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst @@ -260,8 +260,8 @@ specific to warp envs; for Newton physics limitations see :doc:`supported-featur Benchmarking Your Environment ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The performance table above was produced with ``scripts/benchmarks/benchmark_rsl_rl.py``, -which runs a fixed iteration count and reports step-time statistics. Use the same script +The performance table above was produced with ``isaaclab benchmark training``, +which runs a fixed iteration count and reports step-time statistics. Use the same command to estimate the gain for your own task before committing to a migration. **Single-task A/B** @@ -269,33 +269,35 @@ to estimate the gain for your own task before committing to a migration. .. code-block:: bash # Stable variant - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + uv run isaaclab benchmark training \ + --rl_library rsl_rl \ --task -v0 \ --num_envs 4096 \ --max_iterations 500 \ - --benchmark_backend summary \ + --benchmark_formatter summary \ --output_path benchmarks/stable # Warp variant — same task with -Warp- suffix - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + uv run isaaclab benchmark training \ + --rl_library rsl_rl \ --task -Warp-v0 \ --num_envs 4096 \ --max_iterations 500 \ - --benchmark_backend summary \ + --benchmark_formatter summary \ --output_path benchmarks/warp -The ``summary`` backend prints step time (mean / p50 / p99) and total throughput. Compare +The ``summary`` formatter prints step time (min / mean / max) and total throughput. Compare "step time" between the two runs to estimate the gain per env step. **Sweep across all available tasks** -``scripts/benchmarks/run_training_benchmarks.sh`` runs the full set of stable tasks listed -in the script (cartpole, ant, humanoid, locomotion, manipulation). Pair it with a -warp-tasks variant (substitute the ``-Warp-`` suffixed task ids) and diff the two outputs. +Run ``isaaclab benchmark training`` for each task in the stable set (cartpole, ant, humanoid, +locomotion, manipulation) and again with the ``-Warp-`` suffixed task ids, then diff the two output +directories. **What to look at in the output** -- *Step time (mean / p99)*: the headline number — what each env step costs. +- *Step time (min / mean / max)*: the headline number — what each env step costs. - *Iteration time*: includes policy update; useful for end-to-end training throughput. - *Capture overhead*: for warp runs, the first few iterations include CUDA graph capture cost; exclude those when comparing steady-state numbers. @@ -309,7 +311,7 @@ step time and look at where it's spent: - ``step_time`` dominated by ``manager.compute_*`` calls → expect large gains, since those are exactly what the warp managers replace with captured kernel launches. -Use ``--num_frames`` on ``benchmark_non_rl.py`` for a no-policy step-time microbenchmark +Use ``--num_frames`` on ``runtime.py`` for a no-policy step-time microbenchmark when you want to isolate env overhead from policy compute. diff --git a/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst b/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst index 72db1343c95..7efd7fa9f78 100644 --- a/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst +++ b/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst @@ -85,10 +85,9 @@ You can also install all OV runtime wheels with: ./isaaclab.sh -i 'ov[all]' -The ``ov[ovphysx]`` selector installs ``source/isaaclab_ovphysx`` with its -``[ovphysx]`` extra. If the wheel is missing, OvPhysX-specific tests skip with -``ovphysx wheel not installed`` and user code raises an install hint when it -first imports the runtime-backed modules. +The ``ov[ovphysx]`` selector installs the ``ovphysx`` runtime wheel declared by +the root ``pyproject.toml`` ``ov`` extra. If the wheel is missing, OvPhysX-specific +tests skip with ``ovphysx wheel not installed`` and user code fails at import time. Testing the Installation ------------------------ @@ -110,7 +109,7 @@ syntax as the other backends: .. code-block:: bash - ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Cartpole-Direct --num_envs 128 presets=ovphysx + ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Cartpole-Direct --num_envs 128 --viz none presets=ovphysx This command starts a headless zero-action rollout; stop it with ``Ctrl+C`` after the environment has started and stepped successfully. diff --git a/docs/source/overview/core-concepts/renderers.rst b/docs/source/overview/core-concepts/renderers.rst index d2ff6a4ca92..217a96fd8aa 100644 --- a/docs/source/overview/core-concepts/renderers.rst +++ b/docs/source/overview/core-concepts/renderers.rst @@ -85,7 +85,7 @@ For the RTX renderer (requires Isaac Sim): # or return an existing renderer with a matching config renderer: BaseRenderer = sim_ctx.render_context.get_renderer(IsaacRtxRendererCfg()) -For RTX renderer settings and presets (quality, balanced, performance), see +For RTX renderer settings, see :doc:`/source/how-to/configure_rendering`. Core concepts @@ -133,11 +133,9 @@ Install via the Isaac Lab CLI using the ``ov[ovrtx]`` token: packages are already part of the core install). Use ``ov[ovrtx]`` (or ``ov[all]``) to pull in the ``ovrtx`` dependency. -Or install manually with pip (note the ``[ovrtx]`` extra and the extra index URL): +Or install the ``ovrtx`` runtime wheel directly with pip (note the extra index URL): -.. code-block:: bash - - pip install --extra-index-url https://pypi.nvidia.com -e "source/isaaclab_ov[ovrtx]" +.. isaaclab-ovrtx-install:: - **Opaque render data**: The render data object returned by :meth:`~isaaclab.renderers.BaseRenderer.create_render_data` is passed to subsequent renderer methods. It should be completely opaque to the caller: inspecting or modifying it diff --git a/docs/source/overview/core-concepts/sensors/camera.rst b/docs/source/overview/core-concepts/sensors/camera.rst index 9505dd74b18..cc1c4576ee0 100644 --- a/docs/source/overview/core-concepts/sensors/camera.rst +++ b/docs/source/overview/core-concepts/sensors/camera.rst @@ -31,26 +31,22 @@ The renderer used by a camera is configured via the ``renderer_cfg`` field on .. list-table:: :header-rows: 1 - :widths: 30 30 40 + :widths: 30 30 * - ``renderer_cfg`` - Requires Isaac Sim? - - Supported data types * - ``IsaacRtxRendererCfg`` *(default)* - Yes - - rgb, rgba, depth, normals, motion vectors, semantic/instance segmentation, and all other annotators * - ``NewtonWarpRendererCfg`` - No (kit-less) - - ``rgb``, ``depth`` only * - ``OVRTXRendererCfg`` - No (+ ``isaaclab_ov``) - - ``rgb``, ``depth`` only .. note:: - The Newton Warp renderer currently supports only **``rgb``** and **``depth``** data types. - Annotators such as segmentation, normals, and motion vectors are Isaac RTX-specific features and - require :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg`. + Backends differ in which annotators they produce. See + :ref:`the support matrix ` below for the + per-annotator breakdown across the Isaac RTX, OVRTX, and Newton Warp renderers. Tiled Rendering @@ -152,7 +148,7 @@ The active preset is selected at launch via ``physics=``, ``renderer=``, or ``pr python train.py task=Isaac-Cartpole-Camera-Direct renderer=newton_renderer # Use OVRTX renderer - python train.py task=Isaac-Cartpole-Camera-Direct renderer=ovrtx_renderer + python train.py task=Isaac-Cartpole-Camera-Direct renderer=ovrtx # Use default (Isaac RTX) python train.py task=Isaac-Cartpole-Camera-Direct @@ -177,20 +173,15 @@ When using the RTX renderer, add ``--enable_cameras`` when launching: --task=Isaac-Cartpole-Camera-Direct --enable_cameras -Annotators (RTX only) -~~~~~~~~~~~~~~~~~~~~~ - -.. note:: +Annotators +~~~~~~~~~~ - Annotators are a feature of the **Isaac RTX renderer** (``IsaacRtxRendererCfg``). - They are **not** available with the Newton Warp renderer or ovrtx, which - support only ``rgb`` and ``depth``. - -:class:`~sensors.Camera` exposes the following annotator -data types when using the RTX renderer: +:class:`~sensors.Camera` exposes the following annotator data types. Not every +backend produces every annotator — see the support matrix below. * ``"rgb"``: A 3-channel rendered color image. * ``"rgba"``: A 4-channel rendered color image with alpha channel. +* ``"rgb_hdr"``: A 3-channel scene-linear HDR color image. * ``"distance_to_camera"``: Distance to the camera optical center per pixel. * ``"distance_to_image_plane"``: Distance along the camera's Z-axis per pixel. * ``"depth"``: Alias for ``"distance_to_image_plane"``. @@ -200,6 +191,69 @@ data types when using the RTX renderer: * ``"instance_segmentation_fast"``: Instance segmentation data. * ``"instance_id_segmentation_fast"``: Instance ID segmentation data. +.. _camera-supported-annotators: + +Supported annotators by renderer backend +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following matrix shows which annotators each renderer backend can produce. +``Isaac RTX`` is :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg`, ``OVRTX`` +is :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`, and ``Newton Warp`` is +:class:`~isaaclab_newton.renderers.NewtonWarpRendererCfg`. + +.. list-table:: + :header-rows: 1 + :widths: 40 20 20 20 + + * - Data type + - Isaac RTX + - OVRTX + - Newton Warp + * - ``rgb`` + - āœ… + - āœ… + - āœ… + * - ``rgba`` + - āœ… + - āœ… + - āœ… + * - ``rgb_hdr`` + - āœ… + - āœ… + - āœ… + * - ``distance_to_camera`` + - āœ… + - āœ… + - āŒ + * - ``distance_to_image_plane`` + - āœ… + - āœ… + - āŒ + * - ``depth`` + - āœ… + - āœ… + - āœ… + * - ``normals`` + - āœ… + - āœ… + - āœ… + * - ``motion_vectors`` + - āœ… + - āœ… + - āŒ + * - ``semantic_segmentation`` + - āœ… + - āœ… + - āŒ + * - ``instance_segmentation_fast`` + - āœ… + - āœ… + - āœ… + * - ``instance_id_segmentation_fast`` + - āœ… + - āœ… + - āŒ + RGB and RGBA ~~~~~~~~~~~~ @@ -448,5 +502,10 @@ An ``info`` dictionary is available via ``tiled_camera.data.info['instance_segme - If ``colorize_instance_segmentation=True``: shape ``(B, H, W, 4)``, type ``torch.uint8``. - If ``colorize_instance_segmentation=False``: shape ``(B, H, W, 1)``, type ``torch.int32``. +Pixels belonging to prims with no assigned semantic label are rendered black +(RGBA ``(0, 0, 0, 255)``) when ``colorize_instance_segmentation=True``. When +``colorize_instance_segmentation=False``, those pixels instead carry the raw +UNLABELLED instance ID (``1``) rather than a color value. + The ``idToLabels`` dict maps color to USD prim path. The ``idToSemantics`` dict maps color to semantic label. diff --git a/docs/source/overview/core-concepts/sensors/imu.rst b/docs/source/overview/core-concepts/sensors/imu.rst index 5435bf227f5..82e827ea225 100644 --- a/docs/source/overview/core-concepts/sensors/imu.rst +++ b/docs/source/overview/core-concepts/sensors/imu.rst @@ -29,7 +29,7 @@ Here we have explicitly removed the bias from one of the sensors, and we can see Notice that the right front foot explicitly has a bias of (0,0,0). In the visualization, you should see that the arrow indicating the acceleration from the right IMU rapidly changes over time, while the arrow visualizing the left IMU points constantly along the vertical axis. -Retrieving values form the sensor is done in the usual way +Retrieving values from the sensor is done in the usual way .. code-block:: python @@ -56,7 +56,7 @@ Retrieving values form the sensor is done in the usual way print("Received linear acceleration: ", scene["imu_RF"].data.lin_acc_b) print("Received angular acceleration: ", scene["imu_RF"].data.ang_acc_b) -The oscillations in the values reported by the sensor are a direct result of of how the sensor calculates the acceleration, which is through a finite difference approximation between adjacent ground truth velocity values as reported by the sim. We can see this in the reported result (pay attention to the **linear acceleration**) because the acceleration from the right foot is small, but explicitly zero. +The oscillations in the values reported by the sensor are a direct result of how the sensor calculates the acceleration, which is through a finite difference approximation between adjacent ground truth velocity values as reported by the sim. We can see this in the reported result (pay attention to the **linear acceleration**) because the acceleration from the right foot is small, but explicitly zero. .. code-block:: bash diff --git a/docs/source/overview/core-concepts/sensors/index.rst b/docs/source/overview/core-concepts/sensors/index.rst index 1ecae8e3ccd..436d11a81e4 100644 --- a/docs/source/overview/core-concepts/sensors/index.rst +++ b/docs/source/overview/core-concepts/sensors/index.rst @@ -3,6 +3,13 @@ Sensors ========= +.. seealso:: + + This section is the source of truth for the ``isaaclab-using-sensors-actuators`` agent skill + (`skills/user/use-sensors-actuators/ <../../../../../skills/user/use-sensors-actuators/SKILL.md>`__). + When you change these pages, update the skill so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + In this section, we will overview the various sensor APIs provided by Isaac Lab. Every sensor in Isaac Lab inherits from the ``SensorBase`` abstract class that provides the core functionality inherent to all sensors, which is to provide access to "measurements" of the scene. These measurements can take many forms such as ray-casting results, camera rendered images, or even simply ground truth data queried directly from the simulation (such as poses). Whatever the data may be, we can think of the sensor as having a buffer that is periodically updated with measurements by querying the scene. This ``update_period`` is defined in "simulated" seconds, meaning that even if the flow of time in the simulation is dilated relative to the real world, the sensor will update at the appropriate rate. The ``SensorBase`` is also designed with vectorizability in mind, holding the buffers for all copies of the sensor across cloned environments. diff --git a/docs/source/overview/core-concepts/task_workflows.rst b/docs/source/overview/core-concepts/task_workflows.rst index 4c8d280192c..65e6ed97eec 100644 --- a/docs/source/overview/core-concepts/task_workflows.rst +++ b/docs/source/overview/core-concepts/task_workflows.rst @@ -6,6 +6,15 @@ Task Design Workflows .. currentmodule:: isaaclab +.. seealso:: + + This page is the source of truth for the ``isaaclab-building-environments`` and + ``isaaclab-planning-manipulation-tasks`` agent skills + (`skills/user/create-environments/ <../../../../skills/user/create-environments/SKILL.md>`__, + `skills/user/plan-manipulation-tasks/ <../../../../skills/user/plan-manipulation-tasks/SKILL.md>`__). + When you change this page, update those skills so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + A **Task** is defined by an environment with specific interfaces for observations to and actions from a specific agent (robot). The environment is what provides an agent with the current observations and executes that agent's actions by updating the simulation forward in time. There are many common components of simulating a robot in an environment, regardless of what you might want that robot to do or how it might be trained to do it. This is especially true of Reinforcement Learning (RL), where managing the actions, observations, rewards, etc... across a vectorized GPU simulation can be daunting to even think about! To meet this need, Isaac Lab provides the ability to build your RL environments within our **Manager-based** system, allowing you to trust various minutia of the appropriate manager classes. However, we also recognize the need to exert granular control over an environment, especially during development. For this need, we also provide a **Direct** interface into the simulation, giving you full control! @@ -43,7 +52,7 @@ Manager-based environments promote modular implementations of tasks by decomposi When developing new training environments, it is often beneficial to break the environment into independent components. This can be highly effective for collaboration, as it lets individual developers focus on different aspects of the environment, while allowing those disparate efforts to be joined back together into a single runnable task. For example, you may have multiple robots with differing sensoriums, requiring different observation managers to process those sensory data into a form that's useful for downstream components. You might have multiple members on the team with different ideas about what the reward should be to achieve your goals, and by having each one develop their own reward manager, you can swap and test as you see fit. The modular nature of the manager workflow is essential for more complex projects! For reinforcement learning, much of this has been done for you already! In most cases, it will be enough to write your environment to inherit from -:class:`envs.ManagerBasedRLEnv` and and your configuration from :class:`envs.ManagerBasedRLEnvCfg`. +:class:`envs.ManagerBasedRLEnv` and your configuration from :class:`envs.ManagerBasedRLEnvCfg`. .. dropdown:: Example for defining the reward function for the Cartpole task using the manager-style :icon: plus diff --git a/docs/source/overview/core-concepts/visualization.rst b/docs/source/overview/core-concepts/visualization.rst index f095f9d77ae..4246ee9a7bd 100644 --- a/docs/source/overview/core-concepts/visualization.rst +++ b/docs/source/overview/core-concepts/visualization.rst @@ -10,7 +10,7 @@ visualizers are meant for fast, interactive feedback. Most visualizers can be combined with any physics engine or rendering backend. The exception is the Kit visualizer with kit-less OV backends: ``--visualizer kit`` cannot be used with ``presets=ovphysx`` or -``ovrtx_renderer`` in the same process. Use ``--visualizer newton``, +``ovrtx`` in the same process. Use ``--visualizer newton``, ``--visualizer rerun``, ``--visualizer viser``, or omit ``--visualizer`` for headless execution. @@ -163,6 +163,22 @@ There are 3 fields exposed in the ``VisualizerCfg`` for selecting environments f Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.max_visible_envs`` for the run. +Newton environments can share simulated coordinates, for example when ``scene.env_spacing=0``. +Use :attr:`~isaaclab_visualizers.newton.NewtonVisualizerCfg.world_spacing` to arrange selected +worlds visually without changing their simulated poses: + +.. code-block:: python + + + from isaaclab_visualizers.newton import NewtonVisualizerCfg + NewtonVisualizerCfg( + visible_env_indices=[0, 1, 2, 3], + world_spacing=(2.0, 2.0, 0.0), + ) + +Dense environment-major :class:`~isaaclab.markers.VisualizationMarkers` batches follow the same +selection and visual offsets. This includes point-cloud and task-geometry markers. + .. _visualization-common-modes: .. list-table:: Common modes @@ -265,19 +281,19 @@ set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. * - Renderer preset - ``--visualizer kit --video`` - ``--visualizer newton --video`` - * - ``isaacsim_rtx_renderer`` + * - ``isaacsim_rtx`` - āœ… Kit RTX captures video *(default, no change)* - āœ… Newton GL captures video *(overrides RTX backend)* * - ``newton_renderer`` - āœ… Kit RTX captures video *(overrides Newton backend)* - āœ… Newton GL captures video *(default, no change)* - * - ``ovrtx_renderer`` + * - ``ovrtx`` - āŒ **Raises an error** — see note below - āœ… Newton GL captures video; ovrtx provides camera sensor data .. note:: - ``--visualizer kit`` combined with ``ovrtx_renderer`` raises a ``ValueError`` at startup. + ``--visualizer kit`` combined with ``ovrtx`` raises a ``ValueError`` at startup. Both Kit (Isaac Sim) and ovrtx ship conflicting RTX hydra libraries compiled against different USD namespaces (``pxrInternal_v0_25_11`` vs ``ovInternal_v0_25_11``), which causes a dynamic-linker crash when loaded into the same process. @@ -287,7 +303,8 @@ set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. .. code-block:: bash - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + uv run isaaclab benchmark training \ + --rl_library rsl_rl \ --task=Isaac-Reorient-Cube-Shadow-Camera-Direct \ --enable_cameras \ --visualizer newton \ @@ -296,14 +313,15 @@ set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. --video_interval=2000 \ --max_iterations=5 \ --num_envs=1024 \ - --benchmark_backend=summary \ - physics=newton_mjwarp renderer=ovrtx_renderer presets=rgb + --benchmark_formatter=summary \ + physics=newton_mjwarp renderer=ovrtx presets=rgb **Record video with the Isaac RTX renderer preset using the Newton video backend** .. code-block:: bash - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + uv run isaaclab benchmark training \ + --rl_library rsl_rl \ --task=Isaac-Reorient-Cube-Shadow-Camera-Direct \ --enable_cameras \ --visualizer newton \ @@ -312,14 +330,15 @@ set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. --video_interval=2000 \ --max_iterations=5 \ --num_envs=1024 \ - --benchmark_backend=summary \ - physics=physx renderer=isaacsim_rtx_renderer presets=rgb + --benchmark_formatter=summary \ + physics=physx renderer=isaacsim_rtx presets=rgb **Record video with the Isaac RTX renderer preset using the Kit video backend** .. code-block:: bash - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + uv run isaaclab benchmark training \ + --rl_library rsl_rl \ --task=Isaac-Reorient-Cube-Shadow-Camera-Direct \ --enable_cameras \ --visualizer kit \ @@ -328,8 +347,8 @@ set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. --video_interval=2000 \ --max_iterations=5 \ --num_envs=1024 \ - --benchmark_backend=summary \ - physics=physx renderer=isaacsim_rtx_renderer presets=rgb + --benchmark_formatter=summary \ + physics=physx renderer=isaacsim_rtx presets=rgb Visualizer Backends diff --git a/docs/source/overview/developer-guide/agent_skills.rst b/docs/source/overview/developer-guide/agent_skills.rst new file mode 100644 index 00000000000..e2eec1a7e1f --- /dev/null +++ b/docs/source/overview/developer-guide/agent_skills.rst @@ -0,0 +1,212 @@ +Agent Skills +============ + +Isaac Lab agent skills are repository-owned instructions that help coding agents follow Isaac Lab workflows. They are markdown guidance assets, not runtime Python packages. + +Skills live under the repository-level ``skills/`` directory: + +.. code-block:: text + + skills/ + ā”œā”€ā”€ developer/ + │ └── / + │ ā”œā”€ā”€ SKILL.md + │ └── examples.md + └── user/ + └── / + ā”œā”€ā”€ SKILL.md + ā”œā”€ā”€ evaluations.md + ā”œā”€ā”€ reference.md # optional, recommended for longer guidance + └── examples.md # optional, recommended for concrete workflows + +Developer skills + Help contributors and maintainers follow Isaac Lab workflows such as PR preparation, changelog fragments, testing, and documentation rules. + +User skills + Ship with the repository as supported guidance for users building on top of Isaac Lab. Some user skills are intentionally router-only and may only need ``SKILL.md`` plus ``evaluations.md`` when the official docs and source examples already contain the details. + +Skill files +----------- + +.. list-table:: + :header-rows: 1 + :widths: 20 20 20 40 + + * - File + - Developer skills + - User skills + - Purpose + * - ``SKILL.md`` + - Required + - Required + - Main agent-facing workflow and references. + * - ``evaluations.md`` + - Optional + - Required + - Representative prompts, expected behavior, and pass/fail criteria. + * - ``reference.md`` + - Optional + - Recommended + - Longer background that should not live in ``SKILL.md``. + * - ``examples.md`` + - Recommended + - Recommended + - Concrete examples of the workflow. + * - ``scripts/`` + - Optional + - Optional + - Deterministic helpers with explicit run-or-read instructions. + +Discovery +--------- + +Codex and Claude discover the repository skills automatically through +project-native aliases: + +.. code-block:: text + + .agents/skills/ -> skills// + .claude/skills -> .agents/skills + +Codex scans ``.agents/skills`` and Claude scans ``.claude/skills``. The aliases +use the frontmatter ``name`` so both agents expose the same stable identifier. +They resolve to the canonical directories under ``skills/``, keeping one +maintained copy of each skill and preserving repository-relative references. +Do not flatten-copy the skills into a global agent skill directory. + +Agents without native skill discovery should use ``skills/README.md`` as the +catalog. Select a skill by matching the user's request against the +``description`` field in each ``SKILL.md`` frontmatter, then read only that +skill and its directly linked files. Directory slugs are canonical file +locations for humans and reviewers; the frontmatter ``name`` is the stable +identifier to use when one skill routes to another. + +Skill contract +-------------- + +Every skill must include a ``SKILL.md`` file with frontmatter: + +.. code-block:: yaml + + name: isaaclab-example-skill + description: Performs a specific Isaac Lab workflow. Use when the user mentions the workflow or related trigger terms. + audience: user + status: stable + owners: + - isaaclab-maintainers + +The required frontmatter fields are: + +Frontmatter intentionally uses a small YAML subset: single-line scalar fields and the +``owners`` list. Do not use block scalars such as ``|`` or ``>`` in ``SKILL.md`` +frontmatter; keep long details in the markdown body or a linked reference file. + +``name`` + Unique lowercase identifier using letters, numbers, and hyphens. Prefer an ``isaaclab-`` prefix and avoid generic names such as ``helper``, ``utils``, or ``tools``. The identifier must match the native alias under ``.agents/skills/``. + +``description`` + Third-person discovery text. Include what the skill does and when an agent should use it. + + Good example: + + .. code-block:: yaml + + description: Applies Isaac Lab coding style, API design, docstring, type-hint, lazy export, and contribution conventions. Use when writing or reviewing Isaac Lab Python code, public APIs, config classes, module exports, or documentation strings. + + Bad example: + + .. code-block:: yaml + + description: Helps with tasks. + +``audience`` + Either ``developer`` or ``user``. This must match the directory under ``skills/``. + +``status`` + One of ``experimental``, ``stable``, or ``deprecated``. + + Use ``experimental`` for new or incomplete guidance that still needs real usage. Promote a skill to ``stable`` only after it has maintained references, validation guidance, and review from the owning area. Use ``deprecated`` when a workflow is replaced or no longer recommended; deprecated skills must point to the replacement workflow or migration path. + +``owners`` + One or more maintainer groups or owners responsible for the skill. + +Required ``SKILL.md`` sections are: + +* ``When To Use`` +* ``Workflow`` +* ``Validation`` +* ``Maintenance`` +* ``References`` + +User-facing skills must also include an ``evaluations.md`` file with at least three representative scenarios. Each scenario should include a sample prompt or task, expected behavior, and known failure modes or pass/fail criteria. + +Authoring guidelines +-------------------- + +These guidelines are adapted from Anthropic's `Skill authoring best practices `_ and tailored to Isaac Lab's documentation and contribution workflow. + +Keep ``SKILL.md`` concise. Treat context as shared budget and assume agents already understand general programming concepts. Put only the task-critical workflow in ``SKILL.md``. + +Use progressive disclosure. Link directly from ``SKILL.md`` to one-level files such as ``reference.md``, ``examples.md``, or ``evaluations.md``. Avoid chains where ``SKILL.md`` links to a file that links to the actual guidance. + +Use a contents section for longer references. Reference files over 100 lines should start with ``# Contents`` or ``## Contents`` so agents can navigate them when reading partially. + +Do not include credentials, private content copied from another system, generated logs, hardware-specific benchmark dumps, or large vendored documentation. If that information is useful, summarize the workflow and link to the maintained source instead. + +Set the right degree of freedom: + +* Use high-freedom guidance for reviews and design work. +* Use medium-freedom templates when Isaac Lab has a preferred pattern. +* Use low-freedom exact commands or scripts for fragile workflows such as validation. + +Prefer one clear default before listing alternatives. If an escape hatch is needed, state when to use it. + +Avoid time-sensitive wording. For migrations, use ``Current Workflow`` and ``Old Patterns`` sections instead of date-based instructions. + +Use consistent Isaac Lab terminology throughout a skill, such as ``manager-based environments``, ``event terms``, ``articulation config``, and ``changelog fragment``. + +Keeping skills synchronized +--------------------------- + +Skills should not become parallel documentation. The source of truth is the Isaac Lab documentation under ``docs/source/`` and the maintained source/examples under ``source/`` and ``scripts/``. + +Each skill must include a ``Maintenance`` section that names the authoritative files to review when code changes. When a skill needs documentation-level content, update the official docs first and link to them from the skill. + +Keep the link bidirectional. When a documentation page under ``docs/source/`` is the source of truth for a skill, add a ``.. seealso::`` admonition near the top of that page that names the skill and links back to it. This reminds documentation authors to update the associated skill in the same change, so the two never drift apart. Treat a missing back-link as a review issue whenever a skill's ``Maintenance`` section names a documentation page. + +Use these rules during review: + +* Reject large copied API tables or standalone install guides when official docs already cover the topic. +* Prefer links to docs, source files, and maintained examples over duplicated snippets. +* Keep only agent-specific value in the skill: routing, task sequencing, validation loops, search terms, and known decision points. +* If a skill uncovers a missing migration note or troubleshooting entry, add that note to ``docs/source/`` rather than expanding the skill. +* For simulation, asset, sensor, or randomization workflows, state whether the guidance is backend-neutral or specific to PhysX, Newton, or a renderer/visualizer backend. Link to the multi-backend docs and source implementations instead of copying backend tables into the skill. +* Run the skill validator after every skill change. + +Validation workflow +------------------- + +Validate skills locally with: + +.. code-block:: bash + + uv run --no-project python tools/skills/cli.py check + +The validator checks frontmatter, required sections, unique names, audience/path consistency, link validity, reference depth, portable paths, native Codex and Claude aliases, required user evaluations, minimum evaluation scenario counts, and per-scenario evaluation details such as sample queries, expected behavior, and known failure modes or pass/fail criteria. + +Run the validator tests with: + +.. code-block:: bash + + uv run --no-project --with pytest python -m pytest tools/skills/ + +The skills CI gate runs only when a pull request changes files under ``skills/``, ``tools/skills/``, or the skills workflow. This keeps unrelated Isaac Lab PRs from being blocked by skill validation while still checking validator changes. + +Review policy +------------- + +Developer skills require maintainer approval from the owning area because they affect contributor workflows. + +User skills require maintained references, a validation path, documentation alignment, and at least three evaluation scenarios. + +Reviewers should reject skills that duplicate general knowledge instead of encoding Isaac Lab-specific workflow. Deprecated skills must include migration guidance and a replacement skill when one exists. diff --git a/docs/source/overview/developer-guide/index.rst b/docs/source/overview/developer-guide/index.rst index 59f603fbfad..d006f1f8663 100644 --- a/docs/source/overview/developer-guide/index.rst +++ b/docs/source/overview/developer-guide/index.rst @@ -13,3 +13,4 @@ using VSCode. VS Code repo_structure development + agent_skills diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index a9e6bc94cd2..b82b1bc83e0 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -68,7 +68,7 @@ modes. The **Presets** column in each table below is divided into three labeled (e.g. ``physx``, ``newton_mjwarp``, ``newton_kamino``, ``ovphysx``, ``newton_mjwarp_vbd``) * **renderer=** — renderer-backend name passed as ``renderer=NAME`` - (e.g. ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``) + (e.g. ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx``) * **presets=** — environment-specific (domain) preset name passed as ``presets=NAME[,NAME,...]`` (e.g. ``rgb``, ``depth``, ``single_camera``, ``duo_camera``) @@ -78,8 +78,8 @@ preset names grouped by selector type at the command line, or run ``./isaaclab.sh -p scripts/environments/list_envs.py --show_presets`` to list presets for every registered environment. -See :doc:`/source/features/hydra` for the full preset system documentation, -including all available backend names and how the typed selectors work. +See the :doc:`Hydra preset system documentation ` +for all available backend names and how the typed selectors work. Single-agent @@ -118,9 +118,9 @@ Classic environments that are based on IsaacGymEnvs implementation of MuJoCo-sty | | | running with ``--enable_cameras``. | ``newton_mjwarp``, | | | | | ``ovphysx`` (direct only) | | | | | **renderer=** | - | | | | ``isaacsim_rtx_renderer``, | + | | | | ``isaacsim_rtx``, | | | | | ``newton_renderer``, | - | | | | ``ovrtx_renderer`` | + | | | | ``ovrtx`` | | | | | **presets=** ``rgb``, | | | | | ``depth``, ``albedo``, | | | | | ``semantic_segmentation``, | @@ -138,16 +138,16 @@ Classic environments that are based on IsaacGymEnvs implementation of MuJoCo-sty .. |ant| image:: ../_static/tasks/classic/ant.jpg .. |cartpole| image:: ../_static/tasks/classic/cartpole.jpg -.. |humanoid-link| replace:: `Isaac-Humanoid <../../../source/isaaclab_tasks/isaaclab_tasks/core/locomotion/humanoid/humanoid_manager_env_cfg.py>`__ -.. |ant-link| replace:: `Isaac-Ant <../../../source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py>`__ -.. |cartpole-link| replace:: `Isaac-Cartpole <../../../source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py>`__ -.. |cartpole-camera-presets| replace:: `Isaac-Cartpole-Camera-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env_cfg.py>`__ -.. |cartpole-camera-link| replace:: `Isaac-Cartpole-Camera <../../../source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_camera_env_cfg.py>`__ +.. |humanoid-link| replace:: :isaaclab-source:`Isaac-Humanoid ` +.. |ant-link| replace:: :isaaclab-source:`Isaac-Ant ` +.. |cartpole-link| replace:: :isaaclab-source:`Isaac-Cartpole ` +.. |cartpole-camera-presets| replace:: :isaaclab-source:`Isaac-Cartpole-Camera-Direct ` +.. |cartpole-camera-link| replace:: :isaaclab-source:`Isaac-Cartpole-Camera ` -.. |humanoid-direct-link| replace:: `Isaac-Humanoid-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/locomotion/humanoid/humanoid_direct_env.py>`__ -.. |ant-direct-link| replace:: `Isaac-Ant-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env.py>`__ -.. |cartpole-direct-link| replace:: `Isaac-Cartpole-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py>`__ +.. |humanoid-direct-link| replace:: :isaaclab-source:`Isaac-Humanoid-Direct ` +.. |ant-direct-link| replace:: :isaaclab-source:`Isaac-Ant-Direct ` +.. |cartpole-direct-link| replace:: :isaaclab-source:`Isaac-Cartpole-Direct ` Manipulation ~~~~~~~~~~~~ @@ -206,9 +206,9 @@ for the lift-cube environment: | |cube-shadow| | |cube-shadow-vis-link| | In-hand reorientation of a cube using Shadow hand using perceptive inputs. | **physics=** ``physx``, | | | | Requires running with ``--enable_cameras``. | ``newton_mjwarp`` | | | | | **renderer=** | - | | | | ``isaacsim_rtx_renderer``, | + | | | | ``isaacsim_rtx``, | | | | | ``newton_renderer``, | - | | | | ``ovrtx_renderer`` | + | | | | ``ovrtx`` | | | | | **presets=** ``rgb``, | | | | | ``depth``, ``albedo``, | | | | | ``full``, | @@ -236,9 +236,9 @@ for the lift-cube environment: | |kuka-allegro-lift| | |ka-lift-cam-link| | Camera (vision) variant of the lift task, adding single- and dual-camera | **physics=** ``physx``, | | | | observations via ``presets=single_camera`` / ``presets=duo_camera``. | ``newton_mjwarp`` | | | | | **renderer=** | - | | | | ``isaacsim_rtx_renderer``, | + | | | | ``isaacsim_rtx``, | | | | | ``newton_renderer``, | - | | | | ``ovrtx_renderer`` | + | | | | ``ovrtx`` | | | | | **presets=** | | | | | ``single_camera``, | | | | | ``duo_camera``, | @@ -252,9 +252,9 @@ for the lift-cube environment: | |kuka-allegro-reorient| | |ka-reorient-cam-link| | Camera (vision) variant of the reorient task, adding single- and | **physics=** ``physx``, | | | | dual-camera observations via ``presets=single_camera`` / | ``newton_mjwarp`` | | | | ``presets=duo_camera``. | **renderer=** | - | | | | ``isaacsim_rtx_renderer``, | + | | | | ``isaacsim_rtx``, | | | | | ``newton_renderer``, | - | | | | ``ovrtx_renderer`` | + | | | | ``ovrtx`` | | | | | **presets=** | | | | | ``single_camera``, | | | | | ``duo_camera``, | @@ -306,42 +306,42 @@ for the lift-cube environment: .. |cabi_openarm_uni| image:: ../_static/tasks/manipulation/openarm_uni_open_drawer.jpg .. |g1_assemble_trocar| image:: ../_static/tasks/manipulation/g1_assemble_trocar.jpg -.. |reach-franka-link| replace:: `Isaac-Reach-Franka <../../../source/isaaclab_tasks/isaaclab_tasks/core/reach/config/franka/joint_pos_env_cfg.py>`__ -.. |reach-ur10-link| replace:: `Isaac-Reach-UR10 <../../../source/isaaclab_tasks/isaaclab_tasks/core/reach/config/ur_10/joint_pos_env_cfg.py>`__ -.. |deploy-reach-ur10e-link| replace:: `IsaacContrib-Deploy-Reach-UR10e <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/deploy/reach/config/ur_10e/joint_pos_env_cfg.py>`__ -.. |lift-cube-link| replace:: `Isaac-Lift-Cube-Franka <../../../source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/joint_pos_env_cfg.py>`__ -.. |lift-cube-ik-abs-link| replace:: `IsaacContrib-Lift-Cube-Franka-IK-Abs <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/lift/config/franka/ik_abs_env_cfg.py>`__ -.. |lift-cube-ik-rel-link| replace:: `IsaacContrib-Lift-Cube-Franka-IK-Rel <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/lift/config/franka/ik_rel_env_cfg.py>`__ -.. |lift-soft-franka-link| replace:: `Isaac-Lift-Soft-Franka <../../../source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_soft_env_cfg.py>`__ -.. |cabi-franka-link| replace:: `Isaac-Open-Drawer-Franka <../../../source/isaaclab_tasks/isaaclab_tasks/core/cabinet/config/franka/joint_pos_env_cfg.py>`__ -.. |franka-direct-link| replace:: `Isaac-Open-Drawer-Franka-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_direct_env.py>`__ -.. |cube-allegro-link| replace:: `Isaac-Reorient-Cube-Allegro <../../../source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/allegro_hand/allegro_hand_manager_env_cfg.py>`__ -.. |allegro-direct-link| replace:: `Isaac-Reorient-Cube-Allegro-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/allegro_hand/allegro_hand_direct_env_cfg.py>`__ -.. |stack-cube-link| replace:: `IsaacContrib-Stack-Cube-Franka <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_joint_pos_env_cfg.py>`__ -.. |stack-cube-bp-link| replace:: `IsaacContrib-Stack-Cube-Franka-IK-Rel-Blueprint <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py>`__ -.. |gr1_pick_place-link| replace:: `IsaacContrib-PickPlace-GR1T2-Abs <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/pickplace_gr1t2_env_cfg.py>`__ -.. |g1_pick_place-link| replace:: `IsaacContrib-PickPlace-G1-InspireFTP-Abs <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/pickplace_unitree_g1_inspire_hand_env_cfg.py>`__ -.. |g1_pick_place_fixed-link| replace:: `IsaacContrib-PickPlace-FixedBaseUpperBodyIK-G1-Abs <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/locomanip_pick_place/fixed_base_upper_body_ik_g1_env_cfg.py>`__ -.. |g1_pick_place_lm-link| replace:: `IsaacContrib-PickPlace-Locomanipulation-G1-Abs <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/locomanip_pick_place/locomanipulation_g1_env_cfg.py>`__ -.. |long-suction-link| replace:: `IsaacContrib-Stack-Cube-UR10-Long-Suction-IK-Rel <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py>`__ -.. |short-suction-link| replace:: `IsaacContrib-Stack-Cube-UR10-Short-Suction-IK-Rel <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py>`__ -.. |gr1_pp_waist-link| replace:: `IsaacContrib-PickPlace-GR1T2-WaistEnabled-Abs <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py>`__ -.. |galbot_stack-link| replace:: `IsaacContrib-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/galbot/stack_rmp_rel_env_cfg.py>`__ -.. |kuka-allegro-lift-link| replace:: `Isaac-Lift-KukaAllegro <../../../source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py>`__ -.. |kuka-allegro-reorient-link| replace:: `Isaac-Reorient-KukaAllegro <../../../source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py>`__ -.. |ka-lift-cam-link| replace:: `Isaac-Lift-KukaAllegro-Camera <../../../source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_camera_env_cfg.py>`__ -.. |ka-reorient-cam-link| replace:: `Isaac-Reorient-KukaAllegro-Camera <../../../source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_camera_env_cfg.py>`__ -.. |cube-shadow-link| replace:: `Isaac-Reorient-Cube-Shadow-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py>`__ -.. |cube-shadow-ff-link| replace:: `Isaac-Reorient-Cube-Shadow-OpenAI-FF-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py>`__ -.. |cube-shadow-lstm-link| replace:: `Isaac-Reorient-Cube-Shadow-OpenAI-LSTM-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py>`__ -.. |cube-shadow-vis-link| replace:: `Isaac-Reorient-Cube-Shadow-Camera-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env.py>`__ -.. |agibot_place_mug-link| replace:: `IsaacContrib-Place-Mug-Agibot-Left-Arm-RmpFlow <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/place/config/agibot/place_upright_mug_rmp_rel_env_cfg.py>`__ -.. |agibot_place_toy-link| replace:: `IsaacContrib-Place-Toy2Box-Agibot-Right-Arm-RmpFlow <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/place/config/agibot/place_toy2box_rmp_rel_env_cfg.py>`__ -.. |reach_openarm_bi-link| replace:: `IsaacContrib-Reach-OpenArmBi <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/reach/config/openarm/bimanual/joint_pos_env_cfg.py>`__ -.. |reach_openarm_uni-link| replace:: `IsaacContrib-Reach-OpenArm <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/reach/config/openarm/unimanual/joint_pos_env_cfg.py>`__ -.. |lift_openarm_uni-link| replace:: `IsaacContrib-Lift-Cube-OpenArm <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/lift/config/openarm/joint_pos_env_cfg.py>`__ -.. |cabi_openarm_uni-link| replace:: `IsaacContrib-Open-Drawer-OpenArm <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/cabinet/config/openarm/joint_pos_env_cfg.py>`__ -.. |g1_assemble_trocar-link| replace:: `IsaacContrib-Assemble-Trocar-G129-Dex3 <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/assemble_trocar/g129_dex3_env_cfg.py>`__ +.. |reach-franka-link| replace:: :isaaclab-source:`Isaac-Reach-Franka ` +.. |reach-ur10-link| replace:: :isaaclab-source:`Isaac-Reach-UR10 ` +.. |deploy-reach-ur10e-link| replace:: :isaaclab-source:`IsaacContrib-Deploy-Reach-UR10e ` +.. |lift-cube-link| replace:: :isaaclab-source:`Isaac-Lift-Cube-Franka ` +.. |lift-cube-ik-abs-link| replace:: :isaaclab-source:`IsaacContrib-Lift-Cube-Franka-IK-Abs ` +.. |lift-cube-ik-rel-link| replace:: :isaaclab-source:`IsaacContrib-Lift-Cube-Franka-IK-Rel ` +.. |lift-soft-franka-link| replace:: :isaaclab-source:`Isaac-Lift-Soft-Franka ` +.. |cabi-franka-link| replace:: :isaaclab-source:`Isaac-Open-Drawer-Franka ` +.. |franka-direct-link| replace:: :isaaclab-source:`Isaac-Open-Drawer-Franka-Direct ` +.. |cube-allegro-link| replace:: :isaaclab-source:`Isaac-Reorient-Cube-Allegro ` +.. |allegro-direct-link| replace:: :isaaclab-source:`Isaac-Reorient-Cube-Allegro-Direct ` +.. |stack-cube-link| replace:: :isaaclab-source:`IsaacContrib-Stack-Cube-Franka ` +.. |stack-cube-bp-link| replace:: :isaaclab-source:`IsaacContrib-Stack-Cube-Franka-IK-Rel-Blueprint ` +.. |gr1_pick_place-link| replace:: :isaaclab-source:`IsaacContrib-PickPlace-GR1T2-Abs ` +.. |g1_pick_place-link| replace:: :isaaclab-source:`IsaacContrib-PickPlace-G1-InspireFTP-Abs ` +.. |g1_pick_place_fixed-link| replace:: :isaaclab-source:`IsaacContrib-PickPlace-FixedBaseUpperBodyIK-G1-Abs ` +.. |g1_pick_place_lm-link| replace:: :isaaclab-source:`IsaacContrib-PickPlace-Locomanipulation-G1-Abs ` +.. |long-suction-link| replace:: :isaaclab-source:`IsaacContrib-Stack-Cube-UR10-Long-Suction-IK-Rel ` +.. |short-suction-link| replace:: :isaaclab-source:`IsaacContrib-Stack-Cube-UR10-Short-Suction-IK-Rel ` +.. |gr1_pp_waist-link| replace:: :isaaclab-source:`IsaacContrib-PickPlace-GR1T2-WaistEnabled-Abs ` +.. |galbot_stack-link| replace:: :isaaclab-source:`IsaacContrib-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow ` +.. |kuka-allegro-lift-link| replace:: :isaaclab-source:`Isaac-Lift-KukaAllegro ` +.. |kuka-allegro-reorient-link| replace:: :isaaclab-source:`Isaac-Reorient-KukaAllegro ` +.. |ka-lift-cam-link| replace:: :isaaclab-source:`Isaac-Lift-KukaAllegro-Camera ` +.. |ka-reorient-cam-link| replace:: :isaaclab-source:`Isaac-Reorient-KukaAllegro-Camera ` +.. |cube-shadow-link| replace:: :isaaclab-source:`Isaac-Reorient-Cube-Shadow-Direct ` +.. |cube-shadow-ff-link| replace:: :isaaclab-source:`Isaac-Reorient-Cube-Shadow-OpenAI-FF-Direct ` +.. |cube-shadow-lstm-link| replace:: :isaaclab-source:`Isaac-Reorient-Cube-Shadow-OpenAI-LSTM-Direct ` +.. |cube-shadow-vis-link| replace:: :isaaclab-source:`Isaac-Reorient-Cube-Shadow-Camera-Direct ` +.. |agibot_place_mug-link| replace:: :isaaclab-source:`IsaacContrib-Place-Mug-Agibot-Left-Arm-RmpFlow ` +.. |agibot_place_toy-link| replace:: :isaaclab-source:`IsaacContrib-Place-Toy2Box-Agibot-Right-Arm-RmpFlow ` +.. |reach_openarm_bi-link| replace:: :isaaclab-source:`IsaacContrib-Reach-OpenArmBi ` +.. |reach_openarm_uni-link| replace:: :isaaclab-source:`IsaacContrib-Reach-OpenArm ` +.. |lift_openarm_uni-link| replace:: :isaaclab-source:`IsaacContrib-Lift-Cube-OpenArm ` +.. |cabi_openarm_uni-link| replace:: :isaaclab-source:`IsaacContrib-Open-Drawer-OpenArm ` +.. |g1_assemble_trocar-link| replace:: :isaaclab-source:`IsaacContrib-Assemble-Trocar-G129-Dex3 ` Contact-rich Manipulation @@ -373,9 +373,9 @@ For example: .. |factory-gear| image:: ../_static/tasks/factory/gear_mesh.jpg .. |factory-nut| image:: ../_static/tasks/factory/nut_thread.jpg -.. |factory-peg-link| replace:: `Isaac-Factory-PegInsert-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env_cfg.py>`__ -.. |factory-gear-link| replace:: `Isaac-Factory-GearMesh-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env_cfg.py>`__ -.. |factory-nut-link| replace:: `Isaac-Factory-NutThread-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env_cfg.py>`__ +.. |factory-peg-link| replace:: :isaaclab-source:`Isaac-Factory-PegInsert-Direct ` +.. |factory-gear-link| replace:: :isaaclab-source:`Isaac-Factory-GearMesh-Direct ` +.. |factory-nut-link| replace:: :isaaclab-source:`Isaac-Factory-NutThread-Direct ` AutoMate ~~~~~~~~ @@ -432,8 +432,8 @@ We provide environments for both disassembly and assembly. .. |assembly| image:: ../_static/tasks/automate/00004.jpg .. |disassembly| image:: ../_static/tasks/automate/01053_disassembly.jpg -.. |assembly-link| replace:: `Isaac-AutoMate-Assembly-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env_cfg.py>`__ -.. |disassembly-link| replace:: `Isaac-AutoMate-Disassembly-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env_cfg.py>`__ +.. |assembly-link| replace:: :isaaclab-source:`Isaac-AutoMate-Assembly-Direct ` +.. |disassembly-link| replace:: :isaaclab-source:`Isaac-AutoMate-Disassembly-Direct ` FORGE ~~~~~~~~ @@ -468,9 +468,9 @@ These tasks share the same task configurations and control options. You can swit .. |forge-gear| image:: ../_static/tasks/factory/gear_mesh.jpg .. |forge-nut| image:: ../_static/tasks/factory/nut_thread.jpg -.. |forge-peg-link| replace:: `Isaac-Forge-PegInsert-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/forge/forge_env_cfg.py>`__ -.. |forge-gear-link| replace:: `Isaac-Forge-GearMesh-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/forge/forge_env_cfg.py>`__ -.. |forge-nut-link| replace:: `Isaac-Forge-NutThread-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/forge/forge_env_cfg.py>`__ +.. |forge-peg-link| replace:: :isaaclab-source:`Isaac-Forge-PegInsert-Direct ` +.. |forge-gear-link| replace:: :isaaclab-source:`Isaac-Forge-GearMesh-Direct ` +.. |forge-nut-link| replace:: :isaaclab-source:`Isaac-Forge-NutThread-Direct ` Locomotion @@ -547,38 +547,38 @@ Environments based on legged locomotion tasks. | |tracking-loco-manip-digit| | |tracking-loco-manip-digit-link| | Track a root velocity and hand pose command with the Agility Digit robot | **physics=** ``physx`` | +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+------------------------------+ -.. |velocity-flat-anymal-b-link| replace:: `IsaacContrib-Velocity-Flat-AnymalB <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py>`__ -.. |velocity-rough-anymal-b-link| replace:: `IsaacContrib-Velocity-Rough-AnymalB <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/rough_env_cfg.py>`__ +.. |velocity-flat-anymal-b-link| replace:: :isaaclab-source:`IsaacContrib-Velocity-Flat-AnymalB ` +.. |velocity-rough-anymal-b-link| replace:: :isaaclab-source:`IsaacContrib-Velocity-Rough-AnymalB ` -.. |velocity-flat-anymal-c-link| replace:: `IsaacContrib-Velocity-Flat-AnymalC <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py>`__ -.. |velocity-rough-anymal-c-link| replace:: `IsaacContrib-Velocity-Rough-AnymalC <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/rough_env_cfg.py>`__ +.. |velocity-flat-anymal-c-link| replace:: :isaaclab-source:`IsaacContrib-Velocity-Flat-AnymalC ` +.. |velocity-rough-anymal-c-link| replace:: :isaaclab-source:`IsaacContrib-Velocity-Rough-AnymalC ` -.. |velocity-flat-anymal-c-direct-link| replace:: `Isaac-Velocity-Flat-AnymalC-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py>`__ -.. |velocity-rough-anymal-c-direct-link| replace:: `Isaac-Velocity-Rough-AnymalC-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py>`__ +.. |velocity-flat-anymal-c-direct-link| replace:: :isaaclab-source:`Isaac-Velocity-Flat-AnymalC-Direct ` +.. |velocity-rough-anymal-c-direct-link| replace:: :isaaclab-source:`Isaac-Velocity-Rough-AnymalC-Direct ` -.. |velocity-flat-anymal-d-link| replace:: `Isaac-Velocity-Flat-AnymalD <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_d/flat_env_cfg.py>`__ -.. |velocity-rough-anymal-d-link| replace:: `Isaac-Velocity-Rough-AnymalD <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_d/rough_env_cfg.py>`__ +.. |velocity-flat-anymal-d-link| replace:: :isaaclab-source:`Isaac-Velocity-Flat-AnymalD ` +.. |velocity-rough-anymal-d-link| replace:: :isaaclab-source:`Isaac-Velocity-Rough-AnymalD ` -.. |velocity-flat-unitree-a1-link| replace:: `IsaacContrib-Velocity-Flat-UnitreeA1 <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py>`__ -.. |velocity-rough-unitree-a1-link| replace:: `IsaacContrib-Velocity-Rough-UnitreeA1 <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/rough_env_cfg.py>`__ +.. |velocity-flat-unitree-a1-link| replace:: :isaaclab-source:`IsaacContrib-Velocity-Flat-UnitreeA1 ` +.. |velocity-rough-unitree-a1-link| replace:: :isaaclab-source:`IsaacContrib-Velocity-Rough-UnitreeA1 ` -.. |velocity-flat-unitree-go1-link| replace:: `IsaacContrib-Velocity-Flat-UnitreeGo1 <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py>`__ -.. |velocity-rough-unitree-go1-link| replace:: `IsaacContrib-Velocity-Rough-UnitreeGo1 <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/rough_env_cfg.py>`__ +.. |velocity-flat-unitree-go1-link| replace:: :isaaclab-source:`IsaacContrib-Velocity-Flat-UnitreeGo1 ` +.. |velocity-rough-unitree-go1-link| replace:: :isaaclab-source:`IsaacContrib-Velocity-Rough-UnitreeGo1 ` -.. |velocity-flat-unitree-go2-link| replace:: `Isaac-Velocity-Flat-UnitreeGo2 <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py>`__ -.. |velocity-rough-unitree-go2-link| replace:: `Isaac-Velocity-Rough-UnitreeGo2 <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py>`__ +.. |velocity-flat-unitree-go2-link| replace:: :isaaclab-source:`Isaac-Velocity-Flat-UnitreeGo2 ` +.. |velocity-rough-unitree-go2-link| replace:: :isaaclab-source:`Isaac-Velocity-Rough-UnitreeGo2 ` -.. |velocity-flat-spot-link| replace:: `Isaac-Velocity-Flat-Spot <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py>`__ +.. |velocity-flat-spot-link| replace:: :isaaclab-source:`Isaac-Velocity-Flat-Spot ` -.. |velocity-flat-h1-link| replace:: `Isaac-Velocity-Flat-H1 <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py>`__ -.. |velocity-rough-h1-link| replace:: `Isaac-Velocity-Rough-H1 <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/rough_env_cfg.py>`__ +.. |velocity-flat-h1-link| replace:: :isaaclab-source:`Isaac-Velocity-Flat-H1 ` +.. |velocity-rough-h1-link| replace:: :isaaclab-source:`Isaac-Velocity-Rough-H1 ` -.. |velocity-flat-g1-link| replace:: `Isaac-Velocity-Flat-G1 <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py>`__ -.. |velocity-rough-g1-link| replace:: `Isaac-Velocity-Rough-G1 <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/rough_env_cfg.py>`__ +.. |velocity-flat-g1-link| replace:: :isaaclab-source:`Isaac-Velocity-Flat-G1 ` +.. |velocity-rough-g1-link| replace:: :isaaclab-source:`Isaac-Velocity-Rough-G1 ` -.. |velocity-flat-digit-link| replace:: `Isaac-Velocity-Flat-Digit <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/digit/flat_env_cfg.py>`__ -.. |velocity-rough-digit-link| replace:: `Isaac-Velocity-Rough-Digit <../../../source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/digit/rough_env_cfg.py>`__ -.. |tracking-loco-manip-digit-link| replace:: `IsaacContrib-Tracking-LocoManip-Digit <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/locomanip_tracking/config/digit/loco_manip_env_cfg.py>`__ +.. |velocity-flat-digit-link| replace:: :isaaclab-source:`Isaac-Velocity-Flat-Digit ` +.. |velocity-rough-digit-link| replace:: :isaaclab-source:`Isaac-Velocity-Rough-Digit ` +.. |tracking-loco-manip-digit-link| replace:: :isaaclab-source:`IsaacContrib-Tracking-LocoManip-Digit ` .. |velocity-flat-anymal-b| image:: ../_static/tasks/locomotion/anymal_b_flat.jpg .. |velocity-rough-anymal-b| image:: ../_static/tasks/locomotion/anymal_b_rough.jpg @@ -620,7 +620,7 @@ Navigation | | | | ``newton_mjwarp`` | +----------------+---------------------+-----------------------------------------------------------------------------+------------------------------+ -.. |anymal_c_nav-link| replace:: `IsaacContrib-Navigation-Flat-AnymalC <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/navigation/config/anymal_c/navigation_env_cfg.py>`__ +.. |anymal_c_nav-link| replace:: :isaaclab-source:`IsaacContrib-Navigation-Flat-AnymalC ` .. |anymal_c_nav| image:: ../_static/tasks/navigation/anymal_c_nav.jpg @@ -633,11 +633,11 @@ Multirotor See the `drone_arl` folder and the ARL robot config (`ARL_ROBOT_1_CFG`) in the codebase for details. -.. |arl_robot_track_position_state_based-link| replace:: `IsaacContrib-TrackPositionNoObstacles-ARL-Robot-1 <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py>`__ +.. |arl_robot_track_position_state_based-link| replace:: :isaaclab-source:`IsaacContrib-TrackPositionNoObstacles-ARL-Robot-1 ` .. |arl_robot_track_position_state_based| image:: ../_static/tasks/drone_arl/arl_robot_1_track_position_state_based.jpg -.. |arl_robot_navigation-link| replace:: `IsaacContrib-Navigation-3DObstacles-ARL-Robot-1 <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/drone_arl/navigation/config/arl_robot_1/floating_obstacles_env_cfg.py>`__ +.. |arl_robot_navigation-link| replace:: :isaaclab-source:`IsaacContrib-Navigation-3DObstacles-ARL-Robot-1 ` .. |arl_robot_navigation| image:: ../_static/tasks/drone_arl/arl_robot_1_navigation.jpg @@ -678,9 +678,9 @@ Others | | |humanoid_amp_walk-link| | | | +----------------+---------------------------+-----------------------------------------------------------------------------+------------------------------+ -.. |humanoid_amp_dance-link| replace:: `Isaac-Humanoid-AMP-Dance-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env_cfg.py>`__ -.. |humanoid_amp_run-link| replace:: `Isaac-Humanoid-AMP-Run-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env_cfg.py>`__ -.. |humanoid_amp_walk-link| replace:: `Isaac-Humanoid-AMP-Walk-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env_cfg.py>`__ +.. |humanoid_amp_dance-link| replace:: :isaaclab-source:`Isaac-Humanoid-AMP-Dance-Direct ` +.. |humanoid_amp_run-link| replace:: :isaaclab-source:`Isaac-Humanoid-AMP-Run-Direct ` +.. |humanoid_amp_walk-link| replace:: :isaaclab-source:`Isaac-Humanoid-AMP-Walk-Direct ` .. |humanoid_amp| image:: ../_static/tasks/others/humanoid_amp.jpg @@ -690,7 +690,7 @@ Spaces showcase The |cartpole_showcase| folder contains showcase tasks (based on the *Cartpole* and *Cartpole-Camera* Direct tasks) for the definition/use of the various Gymnasium observation and action spaces supported in Isaac Lab. -.. |cartpole_showcase| replace:: `cartpole_showcase <../../../source/isaaclab_tasks/isaaclab_tasks/contrib/cartpole_showcase>`__ +.. |cartpole_showcase| replace:: :isaaclab-source:`cartpole_showcase ` .. note:: @@ -824,7 +824,7 @@ Classic .. |cart-double-pendulum| image:: ../_static/tasks/classic/cart_double_pendulum.jpg -.. |cart-double-pendulum-direct-link| replace:: `Isaac-Pendulum-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/pendulum/pendulum_env.py>`__ +.. |cart-double-pendulum-direct-link| replace:: :isaaclab-source:`Isaac-Pendulum-Direct ` Manipulation ~~~~~~~~~~~~ @@ -842,7 +842,7 @@ Environments based on fixed-arm manipulation tasks. .. |shadow-hand-over| image:: ../_static/tasks/manipulation/shadow_hand_over.jpg -.. |shadow-hand-over-direct-link| replace:: `Isaac-Shadow-Handover-Direct <../../../source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py>`__ +.. |shadow-hand-over-direct-link| replace:: :isaaclab-source:`Isaac-Shadow-Handover-Direct ` | @@ -910,14 +910,14 @@ inferencing, including reading from an already trained checkpoint and disabling - Manager Based - **rl_games** (PPO, FEATURE), **rsl_rl** (PPO, FEATURE) - | **physics=** ``newton_kamino``, ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``resnet18``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl``, ``theia_tiny`` * - Isaac-Cartpole-Camera-Direct - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) - | **physics=** ``newton_kamino``, ``newton_mjwarp``, ``ovphysx``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - IsaacContrib-Cartpole-Camera-Showcase-Direct - @@ -1104,7 +1104,7 @@ inferencing, including reading from an already trained checkpoint and disabling - - Manager Based - **rsl_rl** (PPO) - - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + - | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo128``, ``albedo256``, ``albedo64``, ``cube``, ``depth128``, ``depth256``, ``depth64``, ``duo_camera``, ``raycaster_depth128``, ``raycaster_depth256``, ``raycaster_depth64``, ``rgb128``, ``rgb256``, ``rgb64``, ``semantic_segmentation128``, ``semantic_segmentation256``, ``semantic_segmentation64``, ``shapes``, ``simple_shading_constant_diffuse128``, ``simple_shading_constant_diffuse256``, ``simple_shading_constant_diffuse64``, ``simple_shading_diffuse_mdl128``, ``simple_shading_diffuse_mdl256``, ``simple_shading_diffuse_mdl64``, ``simple_shading_full_mdl128``, ``simple_shading_full_mdl256``, ``simple_shading_full_mdl64``, ``single_camera`` * - Isaac-Lift-KukaAllegro-Play - @@ -1281,14 +1281,14 @@ inferencing, including reading from an already trained checkpoint and disabling - Direct - **rl_games** (PPO), **rsl_rl** (PPO) - | **physics=** ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``full``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - Isaac-Reorient-Cube-Shadow-Camera-Direct-Play - - Direct - **rl_games** (PPO), **rsl_rl** (PPO) - | **physics=** ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``full``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - Isaac-Reorient-Cube-Shadow-Direct - @@ -1314,7 +1314,7 @@ inferencing, including reading from an already trained checkpoint and disabling - - Manager Based - **rsl_rl** (PPO) - - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + - | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo128``, ``albedo256``, ``albedo64``, ``cube``, ``depth128``, ``depth256``, ``depth64``, ``duo_camera``, ``raycaster_depth128``, ``raycaster_depth256``, ``raycaster_depth64``, ``rgb128``, ``rgb256``, ``rgb64``, ``semantic_segmentation128``, ``semantic_segmentation256``, ``semantic_segmentation64``, ``shapes``, ``simple_shading_constant_diffuse128``, ``simple_shading_constant_diffuse256``, ``simple_shading_constant_diffuse64``, ``simple_shading_diffuse_mdl128``, ``simple_shading_diffuse_mdl256``, ``simple_shading_diffuse_mdl64``, ``simple_shading_full_mdl128``, ``simple_shading_full_mdl256``, ``simple_shading_full_mdl64``, ``single_camera`` * - Isaac-Reorient-KukaAllegro-Play - @@ -1386,7 +1386,7 @@ inferencing, including reading from an already trained checkpoint and disabling - Manager Based - - | **physics=** ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` * - IsaacContrib-Stack-Cube-Galbot-Right-Arm-Suction-RmpFlow - - Manager Based diff --git a/docs/source/overview/imitation-learning/augmented_imitation.rst b/docs/source/overview/imitation-learning/augmented_imitation.rst index b447e3fde0e..10c8690522a 100644 --- a/docs/source/overview/imitation-learning/augmented_imitation.rst +++ b/docs/source/overview/imitation-learning/augmented_imitation.rst @@ -27,14 +27,16 @@ In the following example, we will show you how to use Isaac Lab Mimic to generat ./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/generate_dataset.py \ --device cpu --enable_cameras --num_envs 10 --generation_num_trials 1000 \ --input_file ./datasets/annotated_dataset.hdf5 --output_file ./datasets/mimic_dataset_1k.hdf5 \ - --task Isaac-Stack-Cube-Franka-IK-Rel-Visuomotor-Cosmos-Mimic-v0 \ - --rendering_mode performance + --task Isaac-Stack-Cube-Franka-IK-Rel-Visuomotor-Cosmos-Mimic-v0 The number of demonstrations can be increased or decreased, 1000 demonstrations have been shown to provide good training results for this task. Additionally, the number of environments in the ``--num_envs`` parameter can be adjusted to speed up data generation. The suggested number of 10 can be executed on a moderate laptop CPU. On a more powerful desktop machine, use a larger number of environments for a significant speedup of this step. +When running large camera-heavy jobs, switch to the RTX Minimal renderer for higher throughput, or +override the task's high-fidelity RTX settings directly if they are more expensive than your workflow +requires. Cosmos Augmentation ~~~~~~~~~~~~~~~~~~~ @@ -392,8 +394,7 @@ Example usage for the cube stacking task: --log_file result \ --enable_cameras \ --seeds 0 \ - --num_rollouts 15 \ - --rendering_mode performance + --num_rollouts 15 .. tip:: Verify that the models directory is not empty. By default, the training script saves models every 20 epochs starting from epoch 100. diff --git a/docs/source/overview/reinforcement-learning/performance_benchmarks.rst b/docs/source/overview/reinforcement-learning/performance_benchmarks.rst index bf7247f529d..0626d29732a 100644 --- a/docs/source/overview/reinforcement-learning/performance_benchmarks.rst +++ b/docs/source/overview/reinforcement-learning/performance_benchmarks.rst @@ -129,24 +129,23 @@ Benchmark Scripts ----------------- For ease of reproducibility, we provide benchmarking scripts available at ``scripts/benchmarks``. -This folder contains individual benchmark scripts that resemble the ``train.py`` script for RL-Games -and RSL RL. In addition, we also provide a benchmarking script that runs only the environment implementation -without any reinforcement learning library. +The unified entry points cover RL training with any supported library as well as environment +stepping without any reinforcement learning library. -Example scripts can be run similarly to training scripts: +Run the benchmark entry points through the Isaac Lab CLI: .. code-block:: bash # benchmark with RSL RL - python scripts/benchmarks/benchmark_rsl_rl.py --task=Isaac-Cartpole + uv run isaaclab benchmark training --rl_library rsl_rl --task=Isaac-Cartpole # benchmark with RL Games - python scripts/benchmarks/benchmark_rlgames.py --task=Isaac-Cartpole + uv run isaaclab benchmark training --rl_library rl_games --task=Isaac-Cartpole - # benchmark without RL libraries - python scripts/benchmarks/benchmark_non_rl.py --task=Isaac-Cartpole + # benchmark without RL libraries (environment stepping only) + uv run isaaclab benchmark runtime --task=Isaac-Cartpole -Each script will generate a set of KPI files at the end of the run, which includes data on the -startup times, runtime statistics, such as the time taken for each simulation or rendering step, +Each benchmark emits a schema-v1 JSON bundle at the end of the run, which includes data on the +startup times, runtime statistics such as the time taken for each simulation or rendering step, as well as overall environment FPS for stepping the environment, performing inference during -rollout, as well as training. +rollout, and training. diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index 97f4b98bb8c..9eaa3d03250 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 -------- @@ -398,6 +402,18 @@ the repository. The logs directory follows the pattern ``logs///< is the name of the learning framework, ```` is the task name, and ```` is the timestamp at which the training command was executed. +New training runs also store a ``run.json`` manifest in their run directory. This manifest allows the unified +``train`` and ``play`` commands to resolve a checkpoint without copying its path manually. Pass +``--checkpoint latest`` to select the highest-step checkpoint from the newest compatible run: + +.. code:: bash + + ./isaaclab.sh play --rl_library rsl_rl --task Isaac-Cartpole --checkpoint latest + +Pass ``--checkpoint best`` to prefer the library-specific best or final checkpoint. For libraries without a +distinct best checkpoint, ``best`` resolves to the same checkpoint as ``latest``. These selectors are supported +by RL-Games, RSL-RL, skrl, and Stable-Baselines3. RSL-RL training resume continues to require ``--resume``. + To view the logs, run: .. tab-set:: diff --git a/docs/source/overview/reinforcement-learning/training_guide.rst b/docs/source/overview/reinforcement-learning/training_guide.rst index 9a220a52003..4d1d51fbff7 100644 --- a/docs/source/overview/reinforcement-learning/training_guide.rst +++ b/docs/source/overview/reinforcement-learning/training_guide.rst @@ -1,6 +1,15 @@ Debugging and Training Guide ============================ +.. seealso:: + + This page is the source of truth for the ``isaaclab-training-rl-agents`` and + ``isaaclab-debugging-rl-training`` agent skills + (`skills/user/train-rl-agents/ <../../../../skills/user/train-rl-agents/SKILL.md>`__, + `skills/user/debug-rl-training/ <../../../../skills/user/debug-rl-training/SKILL.md>`__). + When you change this page, update those skills so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + In this tutorial, we'll guide developers working with Isaac Lab to understand the impact of various parameters on training time, GPU utilization, and memory usage. This is especially helpful for addressing Out of Memory (OOM) errors that commonly diff --git a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst index 9ab12fdeda4..a8a0101e809 100644 --- a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst +++ b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst @@ -36,7 +36,7 @@ This environment has been successfully deployed on real UR10e and Flexiv Rizon 4 **Scope of This Tutorial:** -This tutorial focuses exclusively on the **training part** of the sim-to-real transfer workflow in Isaac Lab. For the complete deployment workflow on the real robot, including the exact steps to set up the vision pipeline, robot interface and the ROS inference node to run your trained policy on real hardware, please refer to the `Isaac ROS Documentation `_. +This tutorial focuses exclusively on the **training part** of the sim-to-real transfer workflow in Isaac Lab. For the complete deployment workflow on the real robot, including the exact steps to set up the vision pipeline, robot interface and the ROS inference node to run your trained policy on real hardware, please refer to the `Isaac ROS Documentation `_. Overview -------- @@ -755,7 +755,7 @@ Replace the log directory path with your actual training log location if differe Step 3: Deploy on Real Robot ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Once training is complete, follow the `Isaac ROS inference documentation `_ to deploy your policy. +Once training is complete, follow the `Isaac ROS inference documentation `_ to deploy your policy. The Isaac ROS deployment pipeline directly uses the trained model checkpoint (``.pt`` file) along with the ``agent.yaml`` and ``env.yaml`` configuration files generated during training. No additional export step is required. diff --git a/docs/source/refs/contributing.rst b/docs/source/refs/contributing.rst index a49f7421405..b6eb63eb74c 100644 --- a/docs/source/refs/contributing.rst +++ b/docs/source/refs/contributing.rst @@ -1,6 +1,16 @@ Contribution Guidelines ======================= +.. seealso:: + + This page is the source of truth for the ``isaaclab-following-coding-style``, + ``isaaclab-preparing-pr-workflow``, and ``isaaclab-writing-changelog-fragments`` agent skills + (`skills/developer/coding-style/ <../../../skills/developer/coding-style/SKILL.md>`__, + `skills/developer/pr-workflow/ <../../../skills/developer/pr-workflow/SKILL.md>`__, + `skills/developer/changelog-fragments/ <../../../skills/developer/changelog-fragments/SKILL.md>`__). + When you change this page, update those skills so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + We wholeheartedly welcome contributions to the project to make the framework more mature and useful for everyone. These may happen in forms of: diff --git a/docs/source/refs/troubleshooting.rst b/docs/source/refs/troubleshooting.rst index 8fc69767c37..ccdee3ae1ed 100644 --- a/docs/source/refs/troubleshooting.rst +++ b/docs/source/refs/troubleshooting.rst @@ -1,6 +1,13 @@ Tricks and Troubleshooting ========================== +.. seealso:: + + This page is the source of truth for the ``isaaclab-setup-troubleshooting`` agent skill + (`skills/user/setup-troubleshooting/ <../../../skills/user/setup-troubleshooting/SKILL.md>`__). + When you change this page, update the skill so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + .. note:: The following lists some of the common tricks and troubleshooting methods that we use in our common workflows. @@ -41,6 +48,48 @@ packages. Include ``assets`` in your install command, or use ``./isaaclab.sh -i`` to install everything. +``ModuleNotFoundError: No module named 'isaaclab_tasks'`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``isaaclab_tasks`` package contains the registered task environments. This +error usually means the command is not running in the Isaac Lab Python +environment or the repository packages were not installed in editable mode. + +Try the following checks: + +1. Run from the Isaac Lab repository root using uv: + + .. code-block:: bash + + uv run python -c "import isaaclab_tasks; print('ok')" + +2. If the import still fails, recreate the documented source-install + environment for your workflow. + +3. Re-run the task command from the repository root instead of a system Python: + + .. code-block:: bash + + uv run python scripts/environments/random_agent.py --task Isaac-Cartpole --num_envs 4 + +`` requires , but requires `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +During pip or uv installs, the package manager may print dependency warnings +where an Isaac Lab package, Isaac Sim package, or third-party package declares +an incompatible dependency constraint. Common examples include ``coverage``, +``packaging``, ``numpy``, or ``Pillow`` constraints reported between +``isaaclab``, ``isaacsim-kernel``, ``isaacsim-core``, ``nvidia-srl-usd``, and +``moviepy``. + +These messages are generally benign when the install command completes +successfully. They usually reflect package metadata that is stricter or older +than the versions bundled and tested with Isaac Sim. Prefer starting from a +fresh virtual environment and using the installation commands in the Isaac Lab +docs. If the resolver aborts with ``No solution found`` or installation leaves +missing modules at runtime, recreate the environment and install the documented +Isaac Sim version before installing Isaac Lab. + ``ModuleNotFoundError: No module named 'rsl_rl'`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/setup/installation/include/selective_install.rst b/docs/source/setup/installation/include/selective_install.rst index 7b1a86f6d3e..5abc9d17c16 100644 --- a/docs/source/setup/installation/include/selective_install.rst +++ b/docs/source/setup/installation/include/selective_install.rst @@ -38,8 +38,9 @@ package—request those explicitly when needed. * - Token - What it installs * - ``newton`` - - Newton physics dependencies on ``isaaclab_newton``, ``isaaclab_physx``, - and ``isaaclab_visualizers`` (selectors are not supported) + - Newton interactive viewer GUI dependencies (``imgui-bundle``, + ``typing-extensions``). The Newton physics engine itself is a core + dependency that is always installed (selectors are not supported). * - ``rl[]`` - RL framework extras on ``isaaclab_rl``. Selectors: ``rsl-rl``, ``skrl``, ``sb3``, ``rl-games``. Omit the selector to install all frameworks. diff --git a/docs/source/setup/installation/index.rst b/docs/source/setup/installation/index.rst index 18c83f3b698..34e3e1557f4 100644 --- a/docs/source/setup/installation/index.rst +++ b/docs/source/setup/installation/index.rst @@ -1,3 +1,10 @@ +.. seealso:: + + Installation docs are the source of truth for the ``isaaclab-setup-troubleshooting`` agent skill + (`skills/user/setup-troubleshooting/ <../../../../skills/user/setup-troubleshooting/SKILL.md>`__). + When you change this page, update the skill so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. + .. _isaaclab-installation-root: Local Installation @@ -103,6 +110,8 @@ Other notable limitations with respect to Isaac Lab include... ``pytetwild`` wheel is available for ARM (aarch64). ``pytetwild`` is required for automatic tetrahedral mesh generation of volume deformables. +#. RLinf support has not been verified on DGX Spark. + .. note:: **Build prerequisites on aarch64:** Some Python packages (notably ``imgui-bundle`` and ``quadprog``) do not ship diff --git a/docs/source/setup/installation/isaaclab_pip_installation.rst b/docs/source/setup/installation/isaaclab_pip_installation.rst index 4da9ee305ee..f95571a8d49 100644 --- a/docs/source/setup/installation/isaaclab_pip_installation.rst +++ b/docs/source/setup/installation/isaaclab_pip_installation.rst @@ -30,9 +30,9 @@ pip extras include: * - Extra - What it installs * - ``isaacsim`` - - Isaac Sim (``isaacsim[all,extscache]==6.0.0.1``) from `pypi.nvidia.com `_ + - Isaac Sim (``isaacsim[all,extscache]`` version |isaacsim_version|) from `pypi.nvidia.com `_ * - ``all`` - - RL frameworks (SB3, SKRL, RL-Games, RSL-RL). Combine with ``isaacsim`` for a full install. + - RL frameworks (SB3, SKRL, RSL-RL). Combine with ``isaacsim`` for a full install. Install with ``isaaclab[isaacsim,all]`` for the full workflow. @@ -50,10 +50,19 @@ Install with ``isaaclab[isaacsim,all]`` for the full workflow. pip install "isaaclab[isaacsim,all]" --extra-index-url https://pypi.nvidia.com --pre +.. note:: + + ``rl_games`` is not included in the Isaac Lab pip wheel extras. If your workflow requires + ``rl_games``, install it manually from the Isaac Lab-compatible branch: + + .. code-block:: bash + + pip install "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11" gym standard-distutils + Installing dependencies ~~~~~~~~~~~~~~~~~~~~~~~ -- Install a CUDA-enabled PyTorch 2.10.0 build that matches your system architecture: +- Install a CUDA-enabled PyTorch |torch_version| build that matches your system architecture: .. tab-set:: :sync-group: pip-platform @@ -61,23 +70,17 @@ Installing dependencies .. tab-item:: :icon:`fa-brands fa-linux` Linux (x86_64) :sync: linux-x86_64 - .. code-block:: bash - - pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + .. isaaclab-torch-install:: cu128 pip .. tab-item:: :icon:`fa-brands fa-windows` Windows (x86_64) :sync: windows-x86_64 - .. code-block:: bash - - pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + .. isaaclab-torch-install:: cu128 pip .. tab-item:: :icon:`fa-brands fa-linux` Linux (aarch64) :sync: linux-aarch64 - .. code-block:: bash - - pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu130 + .. isaaclab-torch-install:: cu130 pip .. note:: diff --git a/docs/source/setup/installation/pip_installation.rst b/docs/source/setup/installation/pip_installation.rst index 6b324b2cabb..40007c72afe 100644 --- a/docs/source/setup/installation/pip_installation.rst +++ b/docs/source/setup/installation/pip_installation.rst @@ -49,9 +49,7 @@ Installing dependencies - Install Isaac Sim pip packages: - .. code-block:: bash - - uv pip install "isaacsim[all,extscache]==6.0.0.1" --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow + .. isaaclab-isaacsim-install:: - Install a CUDA-enabled PyTorch build that matches your system architecture: @@ -61,23 +59,17 @@ Installing dependencies .. tab-item:: :icon:`fa-brands fa-linux` Linux (x86_64) :sync: linux-x86_64 - .. code-block:: bash - - uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + .. isaaclab-torch-install:: cu128 .. tab-item:: :icon:`fa-brands fa-windows` Windows (x86_64) :sync: windows-x86_64 - .. code-block:: bash - - uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + .. isaaclab-torch-install:: cu128 .. tab-item:: :icon:`fa-brands fa-linux` Linux (aarch64) :sync: linux-aarch64 - .. code-block:: bash - - uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu130 + .. isaaclab-torch-install:: cu130 .. note:: diff --git a/docs/source/setup/installation/uv_run.rst b/docs/source/setup/installation/uv_run.rst index 451badcd2e6..4421c83e736 100644 --- a/docs/source/setup/installation/uv_run.rst +++ b/docs/source/setup/installation/uv_run.rst @@ -20,13 +20,17 @@ Clone the repo and start training immediately — no virtual environment setup r .. code-block:: bash # Newton backend training without Isaac Sim - uv run train --rl_library rsl_rl \ + uv run isaaclab train --rl_library rsl_rl \ --task Isaac-Cartpole-Direct physics=newton_mjwarp # Add OVRTX/OVPhysX extras only when the workflow needs them - uv run --extra ov --extra rtx train --rl_library rsl_rl \ + uv run --extra ov --extra rtx isaaclab train --rl_library rsl_rl \ --task Isaac-Cartpole-Direct physics=newton_mjwarp + # PhysX backend: --extra isaacsim pulls in Isaac Sim on the fly + uv run --extra isaacsim isaaclab train --rl_library rsl_rl \ + --task Isaac-Cartpole-Direct presets=physx + ``uv`` resolves and manages the environment automatically on each invocation. Supported libraries for ``--rl_library`` are: ``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``, and ``rlinf``. @@ -36,4 +40,4 @@ Play / Evaluation .. code-block:: bash - uv run play --rl_library rsl_rl --task + uv run isaaclab play --rl_library rsl_rl --task diff --git a/docs/source/setup/quickstart_details.rst b/docs/source/setup/quickstart_details.rst index 7346f5614e6..87ebe19d9f0 100644 --- a/docs/source/setup/quickstart_details.rst +++ b/docs/source/setup/quickstart_details.rst @@ -53,7 +53,7 @@ options (observation modes, camera configs, etc.). They fold into Hydra override ./isaaclab.sh train --rl_library rsl_rl \ --task=Isaac-Reorient-Cube-Shadow-Camera-Benchmark-Direct \ --enable_cameras --num_envs=16 --max_iterations=10 \ - physics=newton_mjwarp renderer=ovrtx_renderer presets=simple_shading_diffuse_mdl + physics=newton_mjwarp renderer=ovrtx presets=simple_shading_diffuse_mdl .. tab-item:: :icon:`fa-brands fa-windows` Windows :sync: windows @@ -81,9 +81,46 @@ Available Presets **Renderer backends** (``renderer=NAME``): -- ``isaacsim_rtx_renderer`` — Isaac Sim RTX (default with Isaac Sim) +- ``isaacsim_rtx`` — Isaac Sim RTX (default with Isaac Sim) - ``newton_renderer`` — Newton Warp renderer -- ``ovrtx_renderer`` — OV RTX renderer (kit-less) +- ``ovrtx`` — OV RTX renderer (kit-less) +- ``rtx`` — Automatic RTX renderer selection + +Automatic RTX selection is available only when the camera exposes the renderer +choices with :class:`~isaaclab_tasks.utils.presets.MultiBackendRendererCfg`: + +.. code-block:: python + + from isaaclab.sensors import CameraCfg + from isaaclab_tasks.utils.presets import MultiBackendRendererCfg + + camera = CameraCfg( + # Other camera settings... + renderer_cfg=MultiBackendRendererCfg(), + ) + +Then use ``renderer=rtx`` to select the RTX implementation required by the runtime: + +.. list-table:: + :widths: 55 45 + :header-rows: 1 + + * - Runtime + - Resolved renderer + * - Requires Isaac Sim/Kit, such as ``physics=physx``, ``--visualizer kit``, + livestreaming, or another Kit camera + - :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg` + * - Fully kit-less, such as ``physics=newton_mjwarp`` or ``physics=ovphysx`` + without a Kit visualizer or camera + - :class:`~isaaclab_ov.renderers.OVRTXRendererCfg` + +For example, ``physics=newton_mjwarp renderer=rtx`` selects OVRTX for a +fully kit-less run, but selects Isaac Sim RTX when combined with +``--visualizer kit``. + +A camera configured directly with ``renderer_cfg=IsaacRtxRendererCfg()`` does +not participate in automatic selection and is not overridden by +``renderer=rtx`` because it does not use the multi-backend renderer configuration. **Domain presets** (``presets=NAME[,NAME,...]``) are task-specific — run ``--task= --help`` to list them. @@ -94,11 +131,12 @@ Common combinations: physics=newton_mjwarp renderer=newton_renderer presets=rgb physics=newton_mjwarp renderer=newton_renderer presets=depth - physics=physx renderer=isaacsim_rtx_renderer presets=rgb - physics=physx renderer=isaacsim_rtx_renderer presets=depth - physics=physx renderer=isaacsim_rtx_renderer presets=albedo - physics=newton_mjwarp renderer=ovrtx_renderer presets=rgb - physics=newton_mjwarp renderer=ovrtx_renderer presets=simple_shading_diffuse_mdl + physics=newton_mjwarp renderer=rtx presets=rgb + physics=physx renderer=isaacsim_rtx presets=rgb + physics=physx renderer=isaacsim_rtx presets=depth + physics=physx renderer=isaacsim_rtx presets=albedo + physics=newton_mjwarp renderer=ovrtx presets=rgb + physics=newton_mjwarp renderer=ovrtx presets=simple_shading_diffuse_mdl Legacy ``presets=newton_mjwarp,newton_renderer,rgb`` form still works; prefer typed selectors for clarity. See :doc:`/source/features/hydra` for the full preset system. diff --git a/docs/source/testing/benchmarks.rst b/docs/source/testing/benchmarks.rst index f90ced1a78c..4ea878df993 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", ) @@ -96,44 +96,97 @@ Basic usage with :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`: Running Benchmark Scripts ------------------------- -Isaac Lab provides shell scripts for running benchmark suites: +Isaac Lab provides unified ``runtime.py``, ``startup.py``, ``training.py``, and ``play.py`` +entry points under ``scripts/benchmarks/``. They default to ``--benchmark_formatter schema``, which +emits a schema-v1 JSON bundle via :mod:`isaaclab.test.benchmark`. +``--benchmark_formatter`` accepts a comma-separated list (e.g. +``schema,omniperf``) to emit several formats in a single run. Each selected +formatter writes timestamped output; the Osmo formatter writes one +phase-suffixed JSON file per phase. -Non-RL Benchmarks -~~~~~~~~~~~~~~~~~ +The examples below use ``uv run isaaclab benchmark``. From an existing +Isaac Lab environment, run the same workflows directly instead: -Measure environment stepping performance without training: +* Runtime: ``./isaaclab.sh -p scripts/benchmarks/runtime.py `` +* Startup: ``./isaaclab.sh -p scripts/benchmarks/startup.py `` +* Training: ``./isaaclab.sh -p scripts/benchmarks/training.py `` +* Play: ``./isaaclab.sh -p scripts/benchmarks/play.py `` -.. code-block:: bash +Non-RL / Runtime Benchmarks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Measure environment stepping performance without any RL library: - # Run all non-RL benchmarks - ./scripts/benchmarks/run_non_rl_benchmarks.sh ./output_dir +.. code-block:: bash - # Run a single benchmark manually - ./isaaclab.sh -p scripts/benchmarks/benchmark_non_rl.py \ + uv run isaaclab benchmark runtime \ --task Isaac-Cartpole \ --num_envs 4096 \ --num_frames 100 \ - --benchmark_backend json \ + --benchmark_formatter json \ --output_path ./results RL Training Benchmarks ~~~~~~~~~~~~~~~~~~~~~~ -Measure training performance with RSL-RL: +Measure training performance. Use ``--rl_library`` to select the RL library +(``rsl_rl``, ``rl_games``, ``skrl``, or ``sb3``): .. code-block:: bash - # Run training benchmarks - ./scripts/benchmarks/run_training_benchmarks.sh ./output_dir + # Benchmark with RSL-RL + uv run isaaclab benchmark training \ + --rl_library rsl_rl \ + --task Isaac-Cartpole \ + --num_envs 4096 \ + --max_iterations 500 \ + --benchmark_formatter json \ + --output_path ./results - # Run manually with RSL-RL - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + # Benchmark with RL Games + uv run isaaclab benchmark training \ + --rl_library rl_games \ --task Isaac-Cartpole \ --num_envs 4096 \ --max_iterations 500 \ - --benchmark_backend json \ + --benchmark_formatter json \ --output_path ./results +RL Play Benchmarks +~~~~~~~~~~~~~~~~~~ + +Load a trained checkpoint and benchmark policy inference (the *play* workflow). +The same ``--rl_library`` dispatch selects the RL library (``rsl_rl``, ``rl_games``, +``skrl``, or ``sb3``). In addition to the inference throughput, the emitted +``PlayBundle`` reports the rolled-out policy's reward, episode length, and success +rate. The checkpoints consumed here are produced by ``training.py``. + +.. code-block:: bash + + # Benchmark inference of a trained RSL-RL policy + uv run isaaclab benchmark play \ + --rl_library rsl_rl \ + --task Isaac-Cartpole \ + --num_envs 4096 \ + --num_frames 1000 \ + --checkpoint /path/to/model.pt \ + --benchmark_formatter json \ + --output_path ./results + +The checkpoint is resolved in the following order: + +#. ``--checkpoint`` — a local filesystem path or a Nucleus URI. +#. Otherwise, the published Nucleus checkpoint for the task is downloaded + (a warning is logged). +#. If neither is available, an error is raised. + +.. note:: + + ``reward``, ``ep_length``, and ``success_rate`` aggregate only **completed** + episodes. Set ``--num_frames`` larger than the task's episode length so at + least one episode finishes during the rollout; otherwise these fields remain + ``null`` (the inference throughput is still reported). + PhysX Micro-Benchmarks ~~~~~~~~~~~~~~~~~~~~~~ @@ -141,10 +194,7 @@ Measure asset method and property performance using mock interfaces: .. code-block:: bash - # Run PhysX micro-benchmarks - ./scripts/benchmarks/run_physx_benchmarks.sh ./output_dir - - # Run articulation benchmarks manually + # Run articulation benchmarks ./isaaclab.sh -p source/isaaclab_physx/benchmark/assets/benchmark_articulation.py \ --num_iterations 1000 \ --num_instances 4096 @@ -163,23 +213,23 @@ understanding where time is spent during initialization. .. code-block:: bash # Basic usage — reports top 30 functions per phase - ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ + uv run isaaclab benchmark startup \ --task Isaac-Ant \ --num_envs 4096 \ - --benchmark_backend summary + --benchmark_formatter summary The script profiles five phases independently: -- **app_launch**: ``launch_simulation()`` context entry (Kit/USD/PhysX init) +- **app_launch**: ``launch_simulation()`` context entry (simulation runtime initialization) - **python_imports**: importing gymnasium, torch, isaaclab_tasks, etc. - **task_config**: ``resolve_task_config()`` (Hydra config resolution) - **env_creation**: ``gym.make()`` + ``env.reset()`` (scene creation, sim start) - **first_step**: a single ``env.step()`` call -Each phase records a wall-clock time plus per-function own-time and cumulative -time as ``SingleMeasurement`` entries. Only IsaacLab functions and first-level -calls into external libraries are included (deep internals of torch, USD, etc. -are filtered out). +Schema output records each phase wall-clock time and per-function own-time, +cumulative time, and call count. Flat formatters project the same data into +measurements. Only Isaac Lab functions and first-level calls into external +libraries are included (deep internals of torch, USD, etc. are filtered out). **Whitelist mode** — For dashboard time-series comparisons across runs, use a YAML whitelist config to report a fixed set of functions instead of top-N. @@ -201,10 +251,10 @@ Patterns use ``fnmatch`` syntax (``*`` and ``?`` wildcards): .. code-block:: bash - ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ + uv run isaaclab benchmark startup \ --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 +283,9 @@ A default whitelist is provided at ``scripts/benchmarks/startup_whitelist.yaml`` * - ``--whitelist_config`` - None - Path to YAML whitelist file - * - ``--benchmark_backend`` - - ``omniperf`` - - Output backend (``json``, ``osmo``, ``omniperf``, ``summary``) + * - ``--benchmark_formatter`` + - ``schema`` + - Output formatter(s), comma-separated (``schema``, ``json``, ``osmo``, ``omniperf``, ``summary``) * - ``--output_path`` - ``.`` - Directory for output files @@ -253,15 +303,15 @@ Common Arguments * - Argument - Default - Description - * - ``--benchmark_backend`` - - ``json`` - - Output backend: ``json``, ``osmo``, ``omniperf``, or ``summary`` + * - ``--benchmark_formatter`` + - ``schema`` + - Output formatter(s), comma-separated (``schema``, ``json``, ``osmo``, ``omniperf``, ``summary``) * - ``--output_path`` - ``./`` - Directory for output files -Non-RL Benchmark Arguments -~~~~~~~~~~~~~~~~~~~~~~~~~~ +Non-RL / Runtime Benchmark Arguments +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 @@ -274,7 +324,7 @@ Non-RL Benchmark Arguments - required - Environment task name (e.g., ``Isaac-Cartpole``) * - ``--num_envs`` - - ``4096`` + - ``None`` (task config) - Number of parallel environments * - ``--num_frames`` - ``100`` @@ -293,16 +343,48 @@ RL Training Arguments * - Argument - Default - Description + * - ``--rl_library`` + - required + - RL library: ``rsl_rl``, ``rl_games``, ``skrl``, or ``sb3`` * - ``--task`` - required - Environment task name * - ``--num_envs`` - - ``4096`` + - ``None`` (task config) - Number of parallel environments * - ``--max_iterations`` - - ``500`` + - ``None`` (task config) - Number of training iterations +RL Play Arguments +~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 25 15 60 + + * - Argument + - Default + - Description + * - ``--task`` + - required + - Environment task name + * - ``--rl_library`` + - required + - RL library that produced the checkpoint: ``rsl_rl``, ``rl_games``, ``skrl``, or ``sb3`` + * - ``--num_envs`` + - ``None`` (task config) + - Number of parallel environments + * - ``--num_frames`` + - ``100`` + - Number of inference steps to roll out + * - ``--checkpoint`` + - ``None`` (published Nucleus checkpoint) + - Local path or Nucleus URI of the checkpoint to roll out + * - ``--benchmark_formatter`` + - ``schema`` + - Output formatter(s), comma-separated (``schema``, ``json``, ``osmo``, ``omniperf``, ``summary``) + Measurement Types ----------------- @@ -404,17 +486,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 +519,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 +539,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 +561,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 +585,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 +602,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 +658,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, ) @@ -620,14 +714,23 @@ Step 4: Finalize Integration with CI/CD ---------------------- -The shell scripts in ``scripts/benchmarks/`` are designed for CI/CD integration: +The benchmark entry points under ``scripts/benchmarks/`` are designed for CI/CD integration: .. code-block:: bash # GitHub Actions / GitLab CI example - - name: Run Benchmarks + - name: Run Runtime Benchmark run: | - ./scripts/benchmarks/run_non_rl_benchmarks.sh ./benchmark_results + uv run isaaclab benchmark runtime \ + --task Isaac-Cartpole --num_envs 4096 --num_frames 100 \ + --benchmark_formatter json --output_path ./benchmark_results + + - name: Run Training Benchmark + run: | + uv run isaaclab benchmark training \ + --rl_library rsl_rl --task Isaac-Cartpole --num_envs 4096 \ + --max_iterations 500 --benchmark_formatter json \ + --output_path ./benchmark_results - name: Upload Results uses: actions/upload-artifact@v3 @@ -635,11 +738,13 @@ 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 - ./scripts/benchmarks/run_non_rl_benchmarks.sh ./results + uv run isaaclab benchmark runtime \ + --task Isaac-Cartpole --num_envs 4096 --num_frames 100 \ + --benchmark_formatter osmo --output_path ./results # Results are in Osmo-compatible JSON format Troubleshooting @@ -682,15 +787,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: ``schema``, ``json``, ``osmo``, ``omniperf``, or ``summary`` .. 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/create_direct_rl_env.rst b/docs/source/tutorials/03_envs/create_direct_rl_env.rst index 7d8cbf12514..6abd6f9f9ae 100644 --- a/docs/source/tutorials/03_envs/create_direct_rl_env.rst +++ b/docs/source/tutorials/03_envs/create_direct_rl_env.rst @@ -1,5 +1,11 @@ -.. _tutorial-create-direct-rl-env: +.. seealso:: + + This tutorial is the source of truth for the ``isaaclab-randomizing-with-events`` agent skill + (`skills/user/domain-randomization-events/ <../../../../skills/user/domain-randomization-events/SKILL.md>`__). + When you change this page, update the skill so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. +.. _tutorial-create-direct-rl-env: Creating a Direct Workflow RL Environment ========================================= diff --git a/docs/source/tutorials/03_envs/create_manager_base_env.rst b/docs/source/tutorials/03_envs/create_manager_base_env.rst index e99a1d2eb58..c447f474567 100644 --- a/docs/source/tutorials/03_envs/create_manager_base_env.rst +++ b/docs/source/tutorials/03_envs/create_manager_base_env.rst @@ -1,5 +1,11 @@ -.. _tutorial-create-manager-base-env: +.. seealso:: + + This tutorial is the source of truth for the ``isaaclab-randomizing-with-events`` agent skill + (`skills/user/domain-randomization-events/ <../../../../skills/user/domain-randomization-events/SKILL.md>`__). + When you change this page, update the skill so agent guidance stays in sync. See + :doc:`/source/overview/developer-guide/agent_skills`. +.. _tutorial-create-manager-base-env: Creating a Manager-Based Base Environment ========================================= diff --git a/docs/source/tutorials/03_envs/run_rl_training.rst b/docs/source/tutorials/03_envs/run_rl_training.rst index 4723d411077..22b853048e0 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/docs/source/tutorials/04_sensors/add_sensors_on_robot.rst b/docs/source/tutorials/04_sensors/add_sensors_on_robot.rst index da5d1805459..69a1f5c013a 100644 --- a/docs/source/tutorials/04_sensors/add_sensors_on_robot.rst +++ b/docs/source/tutorials/04_sensors/add_sensors_on_robot.rst @@ -183,7 +183,7 @@ Additionally, you can switch the viewport to the camera view to see the RGB imag camera sensor. Please check `here `_ for more information on how to switch the viewport to the camera view. -.. figure:: ../../_static/tutorials/tutorial_add_sensors. jpg +.. figure:: ../../_static/tutorials/tutorial_add_sensors.jpg :align: center :figwidth: 100% :alt: result of add_sensors_on_robot.py diff --git a/docs/source/tutorials/05_controllers/run_osc.rst b/docs/source/tutorials/05_controllers/run_osc.rst index b8dbab6ae34..59dc4fb4f21 100644 --- a/docs/source/tutorials/05_controllers/run_osc.rst +++ b/docs/source/tutorials/05_controllers/run_osc.rst @@ -52,7 +52,7 @@ the arguments to the :class:`~controllers.OperationalSpaceControllerCfg` should in mind. For the motion control, the task space targets could be given as absolute (i.e., defined w.r.t. the robot base, -``target_types: "pose_abs"``) or relative the the end-effector's current pose (i.e., ``target_types: "pose_rel"``). +``target_types: "pose_abs"``) or relative to the end-effector's current pose (i.e., ``target_types: "pose_rel"``). For the force control, the task space targets could be given as absolute (i.e., defined w.r.t. the robot base, ``target_types: "force_abs"``). If it is desired to apply pose and force control simultaneously, the ``target_types`` should be a list such as ``["pose_abs", "wrench_abs"]`` or ``["pose_rel", "wrench_abs"]``. diff --git a/isaaclab.sh b/isaaclab.sh index 3a9ca94eb75..941dba9e462 100755 --- a/isaaclab.sh +++ b/isaaclab.sh @@ -38,8 +38,25 @@ if [ -d "$ISAACLAB_PATH/_isaac_sim" ]; then if [ -f "$ISAACLAB_PATH/_isaac_sim/setup_conda_env.sh" ]; then # shellcheck disable=SC1091 . "$ISAACLAB_PATH/_isaac_sim/setup_conda_env.sh" >/dev/null 2>&1 || true + elif [ -f "$ISAACLAB_PATH/_isaac_sim/setup_python_env.sh" ]; then + export ISAAC_PATH="$ISAACLAB_PATH/_isaac_sim" + export CARB_APP_PATH="$ISAAC_PATH/kit" + export EXP_PATH="$ISAAC_PATH/apps" + # shellcheck disable=SC1091 + . "$ISAACLAB_PATH/_isaac_sim/setup_python_env.sh" >/dev/null 2>&1 || true + # Unlike setup_conda_env.sh, setup_python_env.sh prepends Kit's + # pip_prebundle directories to PYTHONPATH. Those ship vendored copies of + # common libraries (e.g. an older typing_extensions lacking Sentinel) + # that then shadow the active venv/conda environment. Put the active + # environment's site-packages first so it always wins. + if [ -n "$VIRTUAL_ENV" ] || [ -n "$CONDA_PREFIX" ]; then + env_site_packages="$("$python_exe" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null || true)" + if [ -n "$env_site_packages" ]; then + export PYTHONPATH="$env_site_packages:$PYTHONPATH" + fi + fi else - echo "[WARNING] _isaac_sim is present but _isaac_sim/setup_conda_env.sh is missing; Isaac Sim env vars not exported." >&2 + echo "[WARNING] _isaac_sim is present but _isaac_sim/setup_conda_env.sh or _isaac_sim/setup_python_env.sh is missing; Isaac Sim env vars not exported." >&2 echo "[WARNING] Re-extract the Isaac Sim binary zip if you intend to use the bundled binary." >&2 fi fi diff --git a/pyproject.toml b/pyproject.toml index f40737cf55a..f9651f4d072 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,63 +8,166 @@ name = "isaaclab-dev" version = "0.1.0" description = "Isaac Lab source checkout development environment." requires-python = ">=3.12,<3.13" +# Single source of truth for all dependencies, also read by the wheel builder and +# ``./isaaclab.sh -i``. Workspace members resolve to ``source/`` via [tool.uv.sources]. dependencies = [ + # ----- workspace members (editable) ----- "isaaclab", "isaaclab-assets", "isaaclab-contrib", "isaaclab-experimental", - "isaaclab-newton[all]", + "isaaclab-newton", "isaaclab-ov", "isaaclab-ovphysx", - "isaaclab-physx[newton]", + "isaaclab-physx", "isaaclab-ppisp", - "isaaclab-rl[rsl-rl]", + "isaaclab-rl", "isaaclab-tasks", "isaaclab-tasks-experimental", "isaaclab-visualizers", - "torch==2.10.0", - "torchaudio==2.10.0", - "torchvision==0.25.0", + # ----- generic ----- + "numpy>=2", + "torch>=2.11", + "torchvision>=0.26.0", + "torchaudio>=2.11", + "onnx>=1.18.0", + "prettytable>=3.3.0", + "psutil", + "protobuf>=4.25.8,!=5.26.0", + "hidapi>=0.14.0", + "gymnasium>=1.2.0", + "trimesh", + "pyglet>=2.1.6,<3", + # x86_64-only wheel (sdist fails to build on aarch64). Pinned ==0.2.3: >=0.3 + # imports pyvista at import time. + "pytetwild==0.2.3 ; platform_machine in 'x86_64 AMD64'", + "transformers==4.57.6", + "einops", + "warp-lang==1.15.0.dev20260626", + "matplotlib>=3.10.3", + "pillow==12.1.1", # keep consistent with isaac sim + "botocore", # omni.replicator.core S3 backend + "starlette>=0.46.0,<0.50", # livestream; range coexists with isaacsim 6.0 + "omniverseclient==2.71.1.7015", + "filelock", + "lazy_loader>=0.4", + # Pink IK stack; the install CLI derives its force-install list from these entries. + # pin-pink: Isaac Sim 6.x needs >=3.3, 3.4+ breaks pink_ik; daqp >0.8.5 changes behavior. + "pin ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", + "pin-pink==3.3.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). Both packages vendor a complete ``pxr`` runtime, so + # they must never co-install: usd-core supplies pxr where it has wheels (x86_64), + # usd-exchange supplies it on aarch64 (where usd-core has no wheel). + "usd-core>=25.11,<26.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'", + # ----- tasks ----- + "tensorboard", + "numba>=0.63.1", + # ----- rl utilities ----- + "hydra-core", + "h5py>=3.15.0", + "moviepy", + "packaging", + "tqdm>=4.67.1", + "rsl-rl-lib==5.4.1", # default RL framework + "onnxscript>=0.5", + # ----- newton (default physics engine) ----- + # Loose bound so the wheel co-resolves with isaacsim's newton[sim]==1.2.0 pin; the + # exact git commit is forced via [tool.uv].override-dependencies (uv sync only). + "newton[sim]>=1.2.0", + # Loose bound co-resolves with isaacsim's ==0.2.0; the override below forces >=0.3.1. + "newton-usd-schemas>=0.2.0", + "PyOpenGL-accelerate>=3.1.0", + # ----- newton interactive viewer GUI (in base, no extra required) ----- + "imgui-bundle>=1.92.5", + "typing-extensions>=4.12.2", ] [project.optional-dependencies] -contrib = [ - "isaaclab-contrib", -] -mimic = [ - "isaaclab-mimic", +# Developer/test tooling. +test = [ + "pytest", + "pytest-mock", + "junitparser", + "flatdict>=4.1.0", + "flaky", + # numba subclasses coverage.types.Tracer at import; >=7.6.1 restores that shim + "coverage>=7.6.1", ] -newton = [ - "isaaclab-newton[all]", - "isaaclab-physx[newton]", - "isaaclab-visualizers[newton]", +# RL frameworks (rsl-rl is the default, in core deps); install the one you train with. +sb3 = ["stable-baselines3>=2.6", "tqdm", "rich"] +skrl = ["skrl>=2.1.0"] +rl-games = [ + "aiohttp>=3.12.14", + "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11", + "gym", + "standard-distutils", ] -ov = [ - "isaaclab-ovphysx[ovphysx]", +rsl-rl = ["rsl-rl-lib==5.4.1", "onnxscript>=0.5"] +# No ``newton`` extra: the viewer GUI ships in the base install; ``-i newton`` is a no-op. +# Visualizer backends. +viser = ["viser>=1.0.16"] +rerun = [ + "rerun-sdk>=0.29.0", + "pyarrow==22.0.0", # match rerun-sdk's Arrow stack ] -rl = [ - "isaaclab-rl[rsl-rl]", +# Isaac Sim (PhysX backend); co-resolves with the base install via the [tool.uv] conflicts table. +isaacsim = ["isaacsim[all,extscache]==6.0.0.1"] +# Omniverse renderer / physics backends (``ov[ovphysx|ovrtx|all]`` selectors). +ov = ["ovphysx==0.5.2+head.f62c22207c"] +rtx = ["ovrtx>=0.3.0,<0.4.0"] +mimic = [ + "isaaclab-mimic", + "ipywidgets>=8.1.5", + "robomimic @ git+https://github.com/ARISE-Initiative/robomimic.git@v0.4.0 ; sys_platform == 'linux'", ] -rl-all = [ - "isaaclab-rl[all]", +teleop = [ + "isaaclab-teleop", + # IsaacTeleop is Linux x86_64 only + "isaacteleop[retargeters,ui,cloudxr]~=1.3.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64'", + "dex-retargeting==0.5.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64'", ] -rtx = [ - "isaaclab-ov[ovrtx]", +# RLinf VLA post-training (externally contributed). +rlinf = [ + "ray[default]>=2.47.0", + "av>=12.3.0", + "numpydantic>=1.7.0", + "albumentations>=1.4.18", + "decord2", + "dm_tree>=0.1.8", + "diffusers>=0.35.0", + "timm>=1.0.14", + "peft>=0.17.0", + "pandas", ] +# Aggregate of the compatible extras. Omits teleop/ov/viser, whose pins clash +# with isaacsim (lxml, packaging<24, websockets); install those via their own extra. all = [ - "isaaclab-mimic", - "isaaclab-newton[all]", - "isaaclab-physx[newton]", - "isaaclab-ppisp", - "isaaclab-rl[all]", - "isaaclab-visualizers[all]", + "isaaclab-dev[sb3,skrl,rl-games,rsl-rl,rerun,rlinf,mimic]", ] +# Single source of truth for externally-pinned versions, read by docs/conf.py, the +# install CLI, and CI. TOML cannot interpolate, so the pins in [project.dependencies], +# the extras above, and [tool.uv].override-dependencies mirror these values; +# ``test_version_single_source`` fails CI if they drift. +[tool.isaaclab.versions] +isaacsim = "6.0.0.1" +torch = "2.11.0" +torchvision = "0.26.0" +torchaudio = "2.11.0" +ovphysx = "0.5.2+head.f62c22207c" +ovrtx = ">=0.3.0,<0.4.0" +# Newton git commit + matching warp-lang prerelease (upgraded together). +newton = "c7ae7c7648cd0717df39e5c94b95d5a02c997320" +warp = "1.15.0.dev20260626" + [tool.ruff] line-length = 120 target-version = "py310" -# Exclude directories extend-exclude = [ "logs", "_isaac_sim", @@ -74,7 +177,6 @@ extend-exclude = [ ] [tool.ruff.lint] -# Enable flake8 rules and other useful ones select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -87,7 +189,6 @@ select = [ "RET", # flake8-return ] -# Ignore specific rules (matching your flake8 config) ignore = [ "E402", # Module level import not at top of file "D401", # First line should be in imperative mood @@ -113,28 +214,23 @@ convention = "google" [tool.ruff.lint.isort] -# Custom import sections with separate sections for each Isaac Lab extension section-order = [ "future", "standard-library", "third-party", - # Group omniverse extensions separately since they are run-time dependencies - # which are pulled in by Isaac Lab extensions + # omniverse extensions are run-time deps pulled in by Isaac Lab extensions "omniverse-extensions", - # Group Isaac Lab extensions together since they are all part of the Isaac Lab project "isaaclab", "isaaclab-contrib", "isaaclab-rl", "isaaclab-mimic", "isaaclab-tasks", "isaaclab-assets", - # First-party is reserved for project templates - "first-party", + "first-party", # reserved for project templates "local-folder", ] [tool.ruff.lint.isort.sections] -# Define what belongs in each custom section "omniverse-extensions" = [ "isaacsim", @@ -174,10 +270,8 @@ pythonVersion = "3.12" pythonPlatform = "Linux" enableTypeIgnoreComments = true -# This is required as the CI pre-commit does not download the module (i.e. numpy, torch, prettytable) -# Therefore, we have to ignore missing imports +# CI pre-commit does not install modules (numpy, torch, ...), so ignore missing imports reportMissingImports = "none" -# This is required to ignore for type checks of modules with stubs missing. reportMissingModuleSource = "none" # -> most common: prettytable in mdp managers reportGeneralTypeIssues = "none" # -> raises 218 errors (usage of literal MISSING in dataclasses) @@ -199,6 +293,11 @@ 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)", + "unit: test exercises isolated logic and does not launch the simulator", + "integration: test drives the simulator/scene/environment end-to-end", + "benchmark: test covers the Isaac Lab benchmark framework and infrastructure", + "rendering: test exercises the rendering / camera / visualizer pipeline", + "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. @@ -217,13 +316,39 @@ name = "pytorch-cu130" url = "https://download.pytorch.org/whl/cu130" explicit = true -# Some NVIDIA-hosted dependencies have mismatched versions across pypi.nvidia.com -# and PyPI. unsafe-best-match lets uv resolve the correct version from any index, -# and prerelease=allow covers packages that only publish pre-release wheels. +# unsafe-best-match resolves NVIDIA deps with versions mismatched across +# pypi.nvidia.com and PyPI; prerelease=allow covers prerelease-only packages. [tool.uv] index-strategy = "unsafe-best-match" prerelease = "allow" -override-dependencies = ["numpy>=2"] +# Isaac Lab owns the Newton and torch versions. Overrides replace these requirements +# for EVERY requester (unlike constraints, which only intersect), so ``--extra isaacsim`` +# — which pins ``newton[sim]==1.2.0`` and its own torch stack — can neither block +# resolution nor downgrade an already-installed newton/torch. The torch stack routes to +# the CUDA indexes via [tool.uv.sources]. Values mirror [tool.isaaclab.versions] above. +override-dependencies = [ + "numpy>=2", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", + # Force the Newton-matched schemas over isaacsim's ==0.2.0 pin. + "newton-usd-schemas>=0.3.1", + "torch==2.11.0", + "torchvision==0.26.0", + "torchaudio==2.11.0", +] +# teleop (lxml>=5.2.2) and mimic (lxml<5.0.0, also in ``all``) cannot co-resolve; +# declaring the conflict lets uv fork the resolution instead of failing. +# isaacsim pins clash with teleop/ov/viser/mimic/test (and ``all`` aggregates +# several of these); forking keeps ``--extra isaacsim`` resolvable on its own. +conflicts = [ + [{ extra = "teleop" }, { extra = "mimic" }], + [{ extra = "teleop" }, { extra = "all" }], + [{ extra = "isaacsim" }, { extra = "teleop" }], + [{ extra = "isaacsim" }, { extra = "ov" }], + [{ extra = "isaacsim" }, { extra = "viser" }], + [{ extra = "isaacsim" }, { extra = "mimic" }], + [{ extra = "isaacsim" }, { extra = "all" }], + [{ extra = "isaacsim" }, { extra = "test" }], +] python-preference = "only-managed" package = false diff --git a/scripts/benchmarks/benchmark_cameras.py b/scripts/benchmarks/benchmark_cameras.py index 3d5359ce5d4..fcc246007e6 100644 --- a/scripts/benchmarks/benchmark_cameras.py +++ b/scripts/benchmarks/benchmark_cameras.py @@ -226,11 +226,11 @@ # Benchmark arguments parser.add_argument( - "--benchmark_backend", + "--benchmark_formatter", type=str, default="omniperf", choices=["json", "osmo", "omniperf", "summary"], - help="Benchmarking backend options, defaults omniperf", + help="Benchmark output formatter, defaults omniperf", ) parser.add_argument("--output_path", type=str, default=".", help="Path to output benchmark results.") @@ -777,13 +777,13 @@ def main(): num_cameras = args_cli.num_ray_caster_cameras # Create the benchmark - backend_type = args_cli.benchmark_backend + formatter_type = args_cli.benchmark_formatter benchmark = BaseIsaacLabBenchmark( benchmark_name="benchmark_cameras", - backend_type=backend_type, + formatter_type=formatter_type, output_path=args_cli.output_path, use_recorders=True, - frametime_recorders=backend_type in ("summary", "omniperf"), + frametime_recorders=formatter_type in ("summary", "omniperf"), output_prefix="benchmark_cameras", workflow_metadata={ "metadata": [ diff --git a/scripts/benchmarks/benchmark_hydra_resolve.py b/scripts/benchmarks/benchmark_hydra_resolve.py index 63922c5b0b0..31305e23507 100644 --- a/scripts/benchmarks/benchmark_hydra_resolve.py +++ b/scripts/benchmarks/benchmark_hydra_resolve.py @@ -10,7 +10,7 @@ scalar overrides, and return the resolved env/agent cfg objects. The benchmark prints a local summary table and writes per-case measurements to -the standard Isaac Lab benchmark backend. It does not create environments and +the standard Isaac Lab benchmark formatter. It does not create environments and does not require a GPU. Usage:: @@ -50,8 +50,6 @@ from isaaclab_tasks.utils.hydra import resolve_task_config -from scripts.benchmarks.utils import get_backend_type - @dataclass(frozen=True) class Case: @@ -70,7 +68,7 @@ class Case: "cartpole_camera_newton_ovrtx", "Isaac-Cartpole-Camera-Direct", "rl_games_cfg_entry_point", - ("presets=newton_mjwarp,ovrtx_renderer",), + ("presets=newton_mjwarp,ovrtx",), ), Case("anymal_rough_scalar", "IsaacContrib-Velocity-Rough-AnymalC", None, ("env.scene.num_envs=256",)), ) @@ -204,20 +202,11 @@ def main() -> int: help="Benchmark case in format name:task:agent_entry:arg[,arg...]. May be repeated.", ) parser.add_argument( - "--benchmark_backend", + "--benchmark_formatter", type=str, default="summary", - choices=[ - "json", - "osmo", - "omniperf", - "summary", - "LocalLogMetrics", - "JSONFileMetrics", - "OsmoKPIFile", - "OmniPerfKPIFile", - ], - help="Benchmarking backend options, defaults summary.", + choices=["json", "osmo", "omniperf", "summary"], + help="Benchmark output formatter, defaults summary.", ) parser.add_argument("--output_path", type=str, default=".", help="Path to output benchmark results.") parser.add_argument("--verbose", action="store_true", help="Keep per-iteration resolver output.") @@ -240,7 +229,7 @@ def main() -> int: benchmark = BaseIsaacLabBenchmark( benchmark_name="benchmark_hydra_resolve", - backend_type=get_backend_type(args.benchmark_backend), + formatter_type=args.benchmark_formatter, output_path=args.output_path, use_recorders=True, output_prefix="benchmark_hydra_resolve", diff --git a/scripts/benchmarks/benchmark_load_robot.py b/scripts/benchmarks/benchmark_load_robot.py index d10ac636532..8364331489b 100644 --- a/scripts/benchmarks/benchmark_load_robot.py +++ b/scripts/benchmarks/benchmark_load_robot.py @@ -29,11 +29,11 @@ help="Choose which robot to load: anymal_d, h1, or g1.", ) parser.add_argument( - "--benchmark_backend", + "--benchmark_formatter", type=str, default="omniperf", choices=["json", "osmo", "omniperf", "summary"], - help="Benchmarking backend options, defaults omniperf", + help="Benchmark output formatter, defaults omniperf", ) parser.add_argument("--output_path", type=str, default=".", help="Path to output benchmark results.") # append AppLauncher cli args @@ -75,13 +75,13 @@ imports_time_end = time.perf_counter_ns() # Create the benchmark -backend_type = args_cli.benchmark_backend +formatter_type = args_cli.benchmark_formatter benchmark = BaseIsaacLabBenchmark( benchmark_name="benchmark_load_robot", - backend_type=backend_type, + formatter_type=formatter_type, output_path=args_cli.output_path, use_recorders=True, - frametime_recorders=backend_type in ("summary", "omniperf"), + frametime_recorders=formatter_type in ("summary", "omniperf"), output_prefix="benchmark_load_robot", workflow_metadata={ "metadata": [ diff --git a/scripts/benchmarks/benchmark_non_rl.py b/scripts/benchmarks/benchmark_non_rl.py deleted file mode 100644 index ed8702cfbc7..00000000000 --- a/scripts/benchmarks/benchmark_non_rl.py +++ /dev/null @@ -1,232 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Script to benchmark non-RL environment.""" - -"""Launch Isaac Sim Simulator first.""" - -import argparse -import contextlib -import os -import sys -import time - -from isaaclab.app import AppLauncher - -from isaaclab_tasks.utils import setup_preset_cli - -# add argparse arguments -parser = argparse.ArgumentParser(description="Train an RL agent with RL-Games.") -parser.add_argument("--video", action="store_true", default=False, help="Record videos during training.") -parser.add_argument("--video_length", type=int, default=200, help="Length of the recorded video (in steps).") -parser.add_argument("--video_interval", type=int, default=2000, help="Interval between video recordings (in steps).") -parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") -parser.add_argument("--task", type=str, default=None, help="Name of the task.") -parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") -parser.add_argument( - "--distributed", action="store_true", default=False, help="Run training with multiple GPUs or nodes." -) -parser.add_argument("--num_frames", type=int, default=100, help="Number of environment frames to run benchmark for.") -parser.add_argument( - "--benchmark_backend", - type=str, - default="omniperf", - choices=[ - "json", - "osmo", - "omniperf", - "summary", - "LocalLogMetrics", - "JSONFileMetrics", - "OsmoKPIFile", - "OmniPerfKPIFile", - ], - help="Benchmarking backend options, defaults omniperf", -) -parser.add_argument("--output_path", type=str, default=".", help="Path to output benchmark results.") - -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -args_cli, hydra_args = setup_preset_cli(parser) -sys.argv = [sys.argv[0]] + hydra_args -if args_cli.video: - args_cli.enable_cameras = True - -sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) - -from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor -from isaaclab.utils.timer import Timer - -from scripts.benchmarks.utils import ( - get_backend_type, - get_preset_string, - log_app_start_time, - log_python_imports_time, - log_runtime_step_times, - log_scene_creation_time, - log_simulation_start_time, - log_task_start_time, - log_total_start_time, -) - -imports_time_begin = time.perf_counter_ns() - -import os -from datetime import datetime - -import gymnasium as gym -import numpy as np -import torch - -from isaaclab.envs import DirectMARLEnvCfg, DirectRLEnvCfg, ManagerBasedRLEnvCfg -from isaaclab.utils.dict import print_dict - -import isaaclab_tasks # noqa: F401 - -# PLACEHOLDER: Extension template (do not remove this comment) -with contextlib.suppress(ImportError): - import isaaclab_tasks_experimental # noqa: F401 -from isaaclab.app import launch_simulation - -from isaaclab_tasks.utils import resolve_task_config - -imports_time_end = time.perf_counter_ns() - - -# Create the benchmark -backend_type = get_backend_type(args_cli.benchmark_backend) -benchmark = BaseIsaacLabBenchmark( - benchmark_name="benchmark_non_rl", - backend_type=backend_type, - output_path=args_cli.output_path, - use_recorders=True, - frametime_recorders=backend_type in ("summary", "omniperf"), - output_prefix=f"benchmark_non_rl_{args_cli.task}", - workflow_metadata={ - "metadata": [ - {"name": "task", "data": args_cli.task}, - {"name": "seed", "data": args_cli.seed}, - {"name": "num_envs", "data": args_cli.num_envs}, - {"name": "num_frames", "data": args_cli.num_frames}, - {"name": "presets", "data": get_preset_string(hydra_args)}, - ] - }, -) - - -def main( - env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, - app_start_time_begin: int, - app_start_time_end: int, -): - """Benchmark without RL in the loop.""" - - # override configurations with non-hydra CLI arguments - env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs - # For distributed training, launch_simulation() already resolved the - # correct per-rank device; only apply a CLI --device override for - # non-distributed runs (the default "cuda:0" would clobber the - # per-rank device otherwise). - if not args_cli.distributed: - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device - env_cfg.seed = args_cli.seed - - # check for invalid combination of CPU device with distributed training - if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device: - raise ValueError( - "Distributed training is not supported when using CPU device. " - "Please use GPU device (e.g., --device cuda) for distributed training." - ) - - # process distributed - # env_cfg.sim.device is already resolved by launch_simulation(). - world_size = 1 - world_rank = 0 - if args_cli.distributed: - world_size = int(os.getenv("WORLD_SIZE", 1)) - world_rank = int(os.getenv("RANK", "0")) - - task_startup_time_begin = time.perf_counter_ns() - - # create isaac environment - env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None) - # wrap for video recording - if args_cli.video: - log_root_path = os.path.abspath(f"benchmark/{args_cli.task}") - log_dir = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - video_kwargs = { - "video_folder": os.path.join(log_root_path, log_dir, "videos"), - "step_trigger": lambda step: step % args_cli.video_interval == 0, - "video_length": args_cli.video_length, - "disable_logger": True, - } - print("[INFO] Recording videos during training.") - print_dict(video_kwargs, nesting=4) - env = gym.wrappers.RecordVideo(env, **video_kwargs) - - task_startup_time_end = time.perf_counter_ns() - - env.reset() - - # counter for number of frames to run for - num_frames = 0 - # log frame times - step_times = [] - - # Run with continuous benchmark monitoring - with BenchmarkMonitor(benchmark, interval=1.0): - while num_frames < args_cli.num_frames: - # get upper and lower bounds of action space, sample actions randomly on this interval - action_high = 1 - action_low = -1 - actions = (action_high - action_low) * torch.rand( - env.unwrapped.num_envs, env.unwrapped.single_action_space.shape[0], device=env.unwrapped.device - ) - action_high - - # env stepping - env_step_time_begin = time.perf_counter_ns() - _ = env.step(actions) - end_step_time_end = time.perf_counter_ns() - step_times.append(end_step_time_end - env_step_time_begin) - - num_frames += 1 - - if world_rank == 0: - # Final update after loop completes - benchmark.update_manual_recorders() - - # compute stats - step_times = np.array(step_times) / 1e6 # ns to ms - fps = 1.0 / (step_times / 1000) - effective_fps = fps * env.unwrapped.num_envs * world_size - - # prepare step timing dict - environment_step_times = { - "Environment step times": step_times.tolist(), - "Environment step FPS": fps.tolist(), - "Environment step effective FPS": effective_fps.tolist(), - } - - log_app_start_time(benchmark, (app_start_time_end - app_start_time_begin) / 1e6) - log_python_imports_time(benchmark, (imports_time_end - imports_time_begin) / 1e6) - log_task_start_time(benchmark, (task_startup_time_end - task_startup_time_begin) / 1e6) - log_scene_creation_time(benchmark, Timer.get_timer_info("scene_creation") * 1000) - log_simulation_start_time(benchmark, Timer.get_timer_info("simulation_start") * 1000) - log_total_start_time(benchmark, (task_startup_time_end - app_start_time_begin) / 1e6) - log_runtime_step_times(benchmark, environment_step_times, compute_stats=True) - - benchmark._finalize_impl() - - # close the simulator - env.close() - - -if __name__ == "__main__": - env_cfg, _agent_cfg = resolve_task_config(args_cli.task, None) - - app_start_time_begin = time.perf_counter_ns() - with launch_simulation(env_cfg, args_cli): - app_start_time_end = time.perf_counter_ns() - main(env_cfg, app_start_time_begin, app_start_time_end) diff --git a/scripts/benchmarks/benchmark_rlgames.py b/scripts/benchmarks/benchmark_rlgames.py deleted file mode 100644 index 3cfc417ac76..00000000000 --- a/scripts/benchmarks/benchmark_rlgames.py +++ /dev/null @@ -1,319 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Script to benchmark RL agent with RL-Games.""" - -"""Launch Isaac Sim Simulator first.""" - -import argparse -import contextlib -import os -import sys -import time - -from isaaclab.app import AppLauncher - -from isaaclab_tasks.utils import setup_preset_cli - -from scripts.benchmarks.early_stop import ( - RlGamesEarlyStopObserver, - add_success_cli_args, - build_success_kwargs, - get_success_tracker, -) - -# add argparse arguments -parser = argparse.ArgumentParser(description="Train an RL agent with RL-Games.") -parser.add_argument("--video", action="store_true", default=False, help="Record videos during training.") -parser.add_argument("--video_length", type=int, default=200, help="Length of the recorded video (in steps).") -parser.add_argument("--video_interval", type=int, default=2000, help="Interval between video recordings (in steps).") -parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") -parser.add_argument("--task", type=str, default=None, help="Name of the task.") -parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") -parser.add_argument( - "--distributed", action="store_true", default=False, help="Run training with multiple GPUs or nodes." -) -parser.add_argument("--max_iterations", type=int, default=10, help="RL Policy training iterations.") -parser.add_argument( - "--benchmark_backend", - type=str, - default="omniperf", - choices=[ - "json", - "osmo", - "omniperf", - "summary", - "LocalLogMetrics", - "JSONFileMetrics", - "OsmoKPIFile", - "OmniPerfKPIFile", - ], - help="Benchmarking backend options, defaults omniperf", -) -parser.add_argument("--output_path", type=str, default=".", help="Path to output benchmark results.") -parser.add_argument( - "--reward_threshold", type=float, default=None, help="Reward threshold for convergence (overrides config)." -) -parser.add_argument( - "--check_convergence", action="store_true", help="Check reward convergence using thresholds from configs.yaml." -) -parser.add_argument( - "--convergence_config", type=str, default="full", help="Config mode for convergence thresholds (default: full)." -) -add_success_cli_args(parser) - -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -args_cli, hydra_args = setup_preset_cli(parser) -sys.argv = [sys.argv[0]] + hydra_args -if args_cli.video: - args_cli.enable_cameras = True - -imports_time_begin = time.perf_counter_ns() - -import math -import random -from datetime import datetime - -import gymnasium as gym -import torch -from rl_games.common import env_configurations, vecenv -from rl_games.common.algo_observer import IsaacAlgoObserver -from rl_games.torch_runner import Runner - -from isaaclab.envs import DirectMARLEnvCfg, DirectRLEnvCfg, ManagerBasedRLEnvCfg -from isaaclab.utils.dict import print_dict -from isaaclab.utils.io import dump_yaml - -from isaaclab_rl.rl_games import RlGamesGpuEnv, RlGamesVecEnvWrapper - -import isaaclab_tasks # noqa: F401 - -# PLACEHOLDER: Extension template (do not remove this comment) -with contextlib.suppress(ImportError): - import isaaclab_tasks_experimental # noqa: F401 -from isaaclab.app import launch_simulation - -from isaaclab_tasks.utils import resolve_task_config - -imports_time_end = time.perf_counter_ns() - -sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) - -from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor -from isaaclab.utils.timer import Timer - -from scripts.benchmarks.utils import ( - get_backend_type, - get_preset_string, - log_app_start_time, - log_python_imports_time, - log_rl_training_metrics, - log_runtime_step_times, - log_scene_creation_time, - log_simulation_start_time, - log_success, - log_task_start_time, - log_total_start_time, - parse_tf_logs, -) - -torch.backends.cuda.matmul.allow_tf32 = True -torch.backends.cudnn.allow_tf32 = True -torch.backends.cudnn.deterministic = False -torch.backends.cudnn.benchmark = False - - -# Create the benchmark -backend_type = get_backend_type(args_cli.benchmark_backend) -benchmark = BaseIsaacLabBenchmark( - benchmark_name="benchmark_rlgames_train", - backend_type=backend_type, - output_path=args_cli.output_path, - use_recorders=True, - frametime_recorders=backend_type in ("summary", "omniperf"), - output_prefix=f"benchmark_rlgames_train_{args_cli.task}", - workflow_metadata={ - "metadata": [ - {"name": "task", "data": args_cli.task}, - {"name": "seed", "data": args_cli.seed}, - {"name": "num_envs", "data": args_cli.num_envs}, - {"name": "max_iterations", "data": args_cli.max_iterations}, - {"name": "presets", "data": get_preset_string(hydra_args)}, - ] - }, -) - - -def main( - env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, - agent_cfg: dict, - app_start_time_begin: int, - app_start_time_end: int, -): - """Train with RL-Games agent.""" - - # override configurations with non-hydra CLI arguments - env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs - # For distributed training, launch_simulation() already resolved the - # correct per-rank device; only apply a CLI --device override for - # non-distributed runs (the default "cuda:0" would clobber the - # per-rank device otherwise). - if not args_cli.distributed: - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device - # check for invalid combination of CPU device with distributed training - if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device: - raise ValueError( - "Distributed training is not supported when using CPU device. " - "Please use GPU device (e.g., --device cuda) for distributed training." - ) - - # update agent device to match simulation device (skip for distributed — - # the per-rank device is resolved by launch_simulation) - if args_cli.device is not None and not args_cli.distributed: - agent_cfg["params"]["config"]["device"] = args_cli.device - agent_cfg["params"]["config"]["device_name"] = args_cli.device - - # randomly sample a seed if seed = -1 - if args_cli.seed == -1: - args_cli.seed = random.randint(0, 10000) - agent_cfg["params"]["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg["params"]["seed"] - - # process distributed - # env_cfg.sim.device is already resolved by launch_simulation(). - world_rank = 0 - if args_cli.distributed: - agent_cfg["params"]["config"]["device"] = env_cfg.sim.device - world_rank = int(os.getenv("RANK", "0")) - - # specify directory for logging experiments - log_root_path = os.path.join("logs", "rl_games", agent_cfg["params"]["config"]["name"]) - log_root_path = os.path.abspath(log_root_path) - print(f"[INFO] Logging experiment in directory: {log_root_path}") - # specify directory for logging runs - log_dir = agent_cfg["params"]["config"].get("full_experiment_name", datetime.now().strftime("%Y-%m-%d_%H-%M-%S")) - # set directory into agent config - # logging directory path: / - agent_cfg["params"]["config"]["train_dir"] = log_root_path - agent_cfg["params"]["config"]["full_experiment_name"] = log_dir - - # multi-gpu training config - if args_cli.distributed: - agent_cfg["params"]["seed"] += int(os.getenv("RANK", "0")) - agent_cfg["params"]["config"]["device"] = env_cfg.sim.device - agent_cfg["params"]["config"]["device_name"] = env_cfg.sim.device - agent_cfg["params"]["config"]["multi_gpu"] = True - - # max iterations - if args_cli.max_iterations: - agent_cfg["params"]["config"]["max_epochs"] = args_cli.max_iterations - - # dump the configuration into log-directory - dump_yaml(os.path.join(log_root_path, log_dir, "params", "env.yaml"), env_cfg) - dump_yaml(os.path.join(log_root_path, log_dir, "params", "agent.yaml"), agent_cfg) - - # read configurations about the agent-training - rl_device = agent_cfg["params"]["config"]["device"] - clip_obs = agent_cfg["params"]["env"].get("clip_observations", math.inf) - clip_actions = agent_cfg["params"]["env"].get("clip_actions", math.inf) - - task_startup_time_begin = time.perf_counter_ns() - - # create isaac environment - env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None) - # wrap for video recording - if args_cli.video: - video_kwargs = { - "video_folder": os.path.join(log_root_path, log_dir, "videos"), - "step_trigger": lambda step: step % args_cli.video_interval == 0, - "video_length": args_cli.video_length, - "disable_logger": True, - } - print("[INFO] Recording videos during training.") - print_dict(video_kwargs, nesting=4) - env = gym.wrappers.RecordVideo(env, **video_kwargs) - - # wrap around environment for rl-games - env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions) - - task_startup_time_end = time.perf_counter_ns() - - # register the environment to rl-games registry - # note: in agents configuration: environment name must be "rlgpu" - vecenv.register( - "IsaacRlgWrapper", lambda config_name, num_actors, **kwargs: RlGamesGpuEnv(config_name, num_actors, **kwargs) - ) - env_configurations.register("rlgpu", {"vecenv_type": "IsaacRlgWrapper", "env_creator": lambda **kwargs: env}) - - # set number of actors into agent config - agent_cfg["params"]["config"]["num_actors"] = env.unwrapped.num_envs - # always track the success metric; early-stop only if --check_success - observer = RlGamesEarlyStopObserver(IsaacAlgoObserver(), **build_success_kwargs(args_cli)) - runner = Runner(observer) - runner.load(agent_cfg) - - # set seed of the env - env.seed(agent_cfg["params"]["seed"]) - # reset the agent and env - runner.reset() - - # train the agent with continuous benchmark monitoring - with BenchmarkMonitor(benchmark, interval=1.0): - runner.run({"train": True, "play": False, "sigma": None}) - - if world_rank == 0: - # Final update after training completes - benchmark.update_manual_recorders() - - # parse tensorboard file stats - tensorboard_log_dir = os.path.join(log_root_path, log_dir, "summaries") - log_data = parse_tf_logs(tensorboard_log_dir) - - # prepare RL timing dict - rl_training_times = { - "Environment only step time": log_data["performance/step_time"], - "Environment + Inference step time": log_data["performance/step_inference_time"], - "Environment + Inference + Policy update time": log_data["performance/rl_update_time"], - "Environment only FPS": log_data["performance/step_fps"], - "Environment + Inference FPS": log_data["performance/step_inference_fps"], - "Environment + Inference + Policy update FPS": log_data["performance/step_inference_rl_update_fps"], - } - - # log additional metrics to benchmark services - log_app_start_time(benchmark, (app_start_time_end - app_start_time_begin) / 1e6) - log_python_imports_time(benchmark, (imports_time_end - imports_time_begin) / 1e6) - log_task_start_time(benchmark, (task_startup_time_end - task_startup_time_begin) / 1e6) - log_scene_creation_time(benchmark, Timer.get_timer_info("scene_creation") * 1000) - log_simulation_start_time(benchmark, Timer.get_timer_info("simulation_start") * 1000) - log_total_start_time(benchmark, (task_startup_time_end - app_start_time_begin) / 1e6) - log_runtime_step_times(benchmark, rl_training_times, compute_stats=True) - log_rl_training_metrics( - benchmark, - log_data, - reward_tag="rewards/iter", - episode_length_tag="episode_lengths/iter", - task=args_cli.task, - workflow="rl_games", - should_check_convergence=args_cli.check_convergence, - reward_threshold=args_cli.reward_threshold, - convergence_config=args_cli.convergence_config, - ) - - tracker = get_success_tracker(args_cli, observer.tracker, log_data) - log_success(benchmark, tracker, framework_iteration_count=observer.framework_iteration_count) - - benchmark._finalize_impl() - - # close the simulator - env.close() - - -if __name__ == "__main__": - env_cfg, agent_cfg = resolve_task_config(args_cli.task, "rl_games_cfg_entry_point") - - app_start_time_begin = time.perf_counter_ns() - with launch_simulation(env_cfg, args_cli): - app_start_time_end = time.perf_counter_ns() - main(env_cfg, agent_cfg, app_start_time_begin, app_start_time_end) diff --git a/scripts/benchmarks/benchmark_rsl_rl.py b/scripts/benchmarks/benchmark_rsl_rl.py deleted file mode 100644 index 1852034b9e2..00000000000 --- a/scripts/benchmarks/benchmark_rsl_rl.py +++ /dev/null @@ -1,317 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Script to benchmark RL agent with RSL-RL.""" - -"""Launch Isaac Sim Simulator first.""" - -import argparse -import contextlib -import os -import sys -import time - -from isaaclab.app import AppLauncher - -from isaaclab_tasks.utils import setup_preset_cli - -from scripts.benchmarks.early_stop import ( - RslRlEarlyStopWrapper, - add_success_cli_args, - build_success_kwargs, - get_success_tracker, -) - -sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) -import scripts.reinforcement_learning.rsl_rl.cli_args as cli_args # isort: skip - -# add argparse arguments -parser = argparse.ArgumentParser(description="Train an RL agent with RSL-RL.") -parser.add_argument("--video", action="store_true", default=False, help="Record videos during training.") -parser.add_argument("--video_length", type=int, default=200, help="Length of the recorded video (in steps).") -parser.add_argument("--video_interval", type=int, default=2000, help="Interval between video recordings (in steps).") -parser.add_argument("--num_envs", type=int, default=4096, help="Number of environments to simulate.") -parser.add_argument("--task", type=str, default=None, help="Name of the task.") -parser.add_argument("--seed", type=int, default=42, help="Seed used for the environment") -parser.add_argument("--max_iterations", type=int, default=10, help="RL Policy training iterations.") -parser.add_argument( - "--distributed", action="store_true", default=False, help="Run training with multiple GPUs or nodes." -) -parser.add_argument( - "--benchmark_backend", - type=str, - default="omniperf", - choices=[ - "json", - "osmo", - "omniperf", - "summary", - "LocalLogMetrics", - "JSONFileMetrics", - "OsmoKPIFile", - "OmniPerfKPIFile", - ], - help="Benchmarking backend options, defaults omniperf", -) -parser.add_argument("--output_path", type=str, default=".", help="Path to output benchmark results.") -parser.add_argument( - "--reward_threshold", type=float, default=None, help="Reward threshold for convergence (overrides config)." -) -parser.add_argument( - "--check_convergence", action="store_true", help="Check reward convergence using thresholds from configs.yaml." -) -parser.add_argument( - "--convergence_config", type=str, default="full", help="Config mode for convergence thresholds (default: full)." -) -add_success_cli_args(parser) - -# append RSL-RL cli arguments -cli_args.add_rsl_rl_args(parser) -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -args_cli, hydra_args = setup_preset_cli(parser) -sys.argv = [sys.argv[0]] + hydra_args -if args_cli.video: - args_cli.enable_cameras = True - -imports_time_begin = time.perf_counter_ns() - -import importlib.metadata as metadata -from datetime import datetime - -import gymnasium as gym -import numpy as np -import torch -from rsl_rl.runners import OnPolicyRunner - -from isaaclab.envs import DirectMARLEnvCfg, DirectRLEnvCfg, ManagerBasedRLEnvCfg -from isaaclab.utils.dict import print_dict -from isaaclab.utils.io import dump_yaml - -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg - -import isaaclab_tasks # noqa: F401 - -# PLACEHOLDER: Extension template (do not remove this comment) -with contextlib.suppress(ImportError): - import isaaclab_tasks_experimental # noqa: F401 -from isaaclab.app import launch_simulation - -from isaaclab_tasks.utils import get_checkpoint_path, resolve_task_config - -imports_time_end = time.perf_counter_ns() - -from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor -from isaaclab.utils.timer import Timer - -from scripts.benchmarks.utils import ( - get_backend_type, - get_preset_string, - log_app_start_time, - log_python_imports_time, - log_rl_training_metrics, - log_runtime_step_times, - log_scene_creation_time, - log_simulation_start_time, - log_success, - log_task_start_time, - log_total_start_time, - parse_tf_logs, -) - -torch.backends.cuda.matmul.allow_tf32 = True -torch.backends.cudnn.allow_tf32 = True -torch.backends.cudnn.deterministic = False -torch.backends.cudnn.benchmark = False - -# Create the benchmark -backend_type = get_backend_type(args_cli.benchmark_backend) -benchmark = BaseIsaacLabBenchmark( - benchmark_name="benchmark_rsl_rl_train", - backend_type=backend_type, - output_path=args_cli.output_path, - use_recorders=True, - frametime_recorders=backend_type in ("summary", "omniperf"), - output_prefix=f"benchmark_rsl_rl_train_{args_cli.task}", - workflow_metadata={ - "metadata": [ - {"name": "task", "data": args_cli.task}, - {"name": "seed", "data": args_cli.seed}, - {"name": "num_envs", "data": args_cli.num_envs}, - {"name": "max_iterations", "data": args_cli.max_iterations}, - {"name": "presets", "data": get_preset_string(hydra_args)}, - ] - }, -) - - -def main( - env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, - agent_cfg: RslRlOnPolicyRunnerCfg, - app_start_time_begin: int, - app_start_time_end: int, -): - """Train with RSL-RL agent.""" - # parse configuration - # override configurations with non-hydra CLI arguments - agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) - env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs - agent_cfg.max_iterations = ( - args_cli.max_iterations if args_cli.max_iterations is not None else agent_cfg.max_iterations - ) - - # set the environment seed - # note: certain randomizations occur in the environment initialization so we set the seed here - env_cfg.seed = agent_cfg.seed - # For distributed training, launch_simulation() already resolved the - # correct per-rank device; only apply a CLI --device override for - # non-distributed runs (the default "cuda:0" would clobber the - # per-rank device otherwise). - if not args_cli.distributed: - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device - # check for invalid combination of CPU device with distributed training - if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device: - raise ValueError( - "Distributed training is not supported when using CPU device. " - "Please use GPU device (e.g., --device cuda) for distributed training." - ) - - # multi-gpu training configuration - # env_cfg.sim.device is already resolved by launch_simulation(). - world_rank = 0 - world_size = 1 - if args_cli.distributed: - agent_cfg.device = env_cfg.sim.device - - # use global rank for seed diversity across all nodes - world_rank = int(os.getenv("RANK", "0")) - seed = agent_cfg.seed + world_rank - env_cfg.seed = seed - agent_cfg.seed = seed - world_size = int(os.getenv("WORLD_SIZE", 1)) - - # specify directory for logging experiments - log_root_path = os.path.join("logs", "rsl_rl", agent_cfg.experiment_name) - log_root_path = os.path.abspath(log_root_path) - print(f"[INFO] Logging experiment in directory: {log_root_path}") - # specify directory for logging runs: {time-stamp}_{run_name} - log_dir = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - if agent_cfg.run_name: - log_dir += f"_{agent_cfg.run_name}" - log_dir = os.path.join(log_root_path, log_dir) - - # max iterations for training - if args_cli.max_iterations: - agent_cfg.max_iterations = args_cli.max_iterations - - task_startup_time_begin = time.perf_counter_ns() - - # create isaac environment - env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None) - # wrap for video recording - if args_cli.video: - video_kwargs = { - "video_folder": os.path.join(log_dir, "videos"), - "step_trigger": lambda step: step % args_cli.video_interval == 0, - "video_length": args_cli.video_length, - "disable_logger": True, - } - print("[INFO] Recording videos during training.") - print_dict(video_kwargs, nesting=4) - env = gym.wrappers.RecordVideo(env, **video_kwargs) - # wrap around environment for rsl-rl - env = RslRlVecEnvWrapper(env) - - task_startup_time_end = time.perf_counter_ns() - - # handle deprecated configurations (e.g. legacy policy -> actor/critic migration) - agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, metadata.version("rsl-rl-lib")) - - # create runner from rsl-rl - runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device) - # write git state to logs - runner.add_git_repo_to_log(__file__) - # save resume path before creating a new log_dir - if agent_cfg.resume: - # get path to previous checkpoint - resume_path = get_checkpoint_path(log_root_path, agent_cfg.load_run, agent_cfg.load_checkpoint) - print(f"[INFO]: Loading model checkpoint from: {resume_path}") - # load previously trained model - runner.load(resume_path) - - # set seed of the environment - env.seed(agent_cfg.seed) - - # dump the configuration into log-directory - dump_yaml(os.path.join(log_dir, "params", "env.yaml"), env_cfg) - dump_yaml(os.path.join(log_dir, "params", "agent.yaml"), agent_cfg) - - # always track the success metric; early-stop only if --check_success - early_stop_ctx = RslRlEarlyStopWrapper( - env, runner, num_steps_per_env=agent_cfg.num_steps_per_env, **build_success_kwargs(args_cli) - ) - - # run training with continuous benchmark monitoring - with early_stop_ctx, BenchmarkMonitor(benchmark, interval=1.0): - runner.learn(num_learning_iterations=agent_cfg.max_iterations, init_at_random_ep_len=True) - - if world_rank == 0: - # Final update after training completes - benchmark.update_manual_recorders() - - # parse tensorboard file stats - log_data = parse_tf_logs(log_dir) - - # prepare RL timing dict - collection_fps = ( - 1 - / (np.array(log_data["Perf/collection_time"])) - * env.unwrapped.num_envs - * agent_cfg.num_steps_per_env - * world_size - ) - rl_training_times = { - "Collection Time": (np.array(log_data["Perf/collection_time"]) / 1000).tolist(), - "Learning Time": (np.array(log_data["Perf/learning_time"]) / 1000).tolist(), - "Collection FPS": collection_fps.tolist(), - "Total FPS": log_data["Perf/total_fps"] * world_size, - } - - # log additional metrics to benchmark services - log_app_start_time(benchmark, (app_start_time_end - app_start_time_begin) / 1e6) - log_python_imports_time(benchmark, (imports_time_end - imports_time_begin) / 1e6) - log_task_start_time(benchmark, (task_startup_time_end - task_startup_time_begin) / 1e6) - log_scene_creation_time(benchmark, Timer.get_timer_info("scene_creation") * 1000) - log_simulation_start_time(benchmark, Timer.get_timer_info("simulation_start") * 1000) - log_total_start_time(benchmark, (task_startup_time_end - app_start_time_begin) / 1e6) - log_runtime_step_times(benchmark, rl_training_times, compute_stats=True) - log_rl_training_metrics( - benchmark, - log_data, - reward_tag="Train/mean_reward", - episode_length_tag="Train/mean_episode_length", - task=args_cli.task, - workflow="rsl_rl", - should_check_convergence=args_cli.check_convergence, - reward_threshold=args_cli.reward_threshold, - convergence_config=args_cli.convergence_config, - ) - - tracker = get_success_tracker(args_cli, early_stop_ctx.tracker, log_data) - log_success(benchmark, tracker, framework_iteration_count=early_stop_ctx.framework_iteration_count) - - benchmark._finalize_impl() - - # close the simulator - env.close() - - -if __name__ == "__main__": - env_cfg, agent_cfg = resolve_task_config(args_cli.task, "rsl_rl_cfg_entry_point") - - app_start_time_begin = time.perf_counter_ns() - with launch_simulation(env_cfg, args_cli): - app_start_time_end = time.perf_counter_ns() - main(env_cfg, agent_cfg, app_start_time_begin, app_start_time_end) diff --git a/scripts/benchmarks/benchmark_startup.py b/scripts/benchmarks/benchmark_startup.py deleted file mode 100644 index 1f692d7af15..00000000000 --- a/scripts/benchmarks/benchmark_startup.py +++ /dev/null @@ -1,353 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Script to profile IsaacLab startup phases with cProfile. - -Each startup stage (app launch, python imports, env creation, first step) is -wrapped in its own cProfile session. The top functions by own-time are emitted -as SingleMeasurement entries (both own-time and cumulative time) via the -standard benchmark backend. -""" - -import argparse -import cProfile -import os -import sys -import time - -from isaaclab.app import AppLauncher - -from isaaclab_tasks.utils import setup_preset_cli - -# -- CLI arguments ----------------------------------------------------------- - -parser = argparse.ArgumentParser(description="Profile IsaacLab startup phases.") -parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") -parser.add_argument("--task", type=str, required=True, help="Name of the task.") -parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") -parser.add_argument( - "--top_n", - type=int, - default=None, - help="Number of top functions per phase (default: 30, or 5 with --whitelist_config).", -) -parser.add_argument( - "--benchmark_backend", - type=str, - default="omniperf", - choices=[ - "json", - "osmo", - "omniperf", - "summary", - "LocalLogMetrics", - "JSONFileMetrics", - "OsmoKPIFile", - "OmniPerfKPIFile", - ], - help="Benchmarking backend options, defaults omniperf", -) -parser.add_argument("--output_path", type=str, default=".", help="Path to output benchmark results.") -parser.add_argument( - "--whitelist_config", - type=str, - default=None, - help="Path to YAML file with per-phase function whitelist patterns. Overrides --top_n for listed phases.", -) - -# append AppLauncher cli args (provides --device, --headless, etc.) -AppLauncher.add_app_launcher_args(parser) -args_cli, hydra_args = setup_preset_cli(parser) -sys.argv = [sys.argv[0]] + hydra_args - -sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) - -from isaaclab.test.benchmark import BaseIsaacLabBenchmark, SingleMeasurement -from isaaclab.utils.timer import Timer, TimerError - -from scripts.benchmarks.utils import ( - get_backend_type, - get_preset_string, - parse_cprofile_stats, -) - -# -- Python imports (profiled) ------------------------------------------------ - -imports_profile = cProfile.Profile() -imports_time_begin = time.perf_counter_ns() -imports_profile.enable() - -import gymnasium as gym # noqa: E402 -import numpy as np # noqa: E402 -import torch # noqa: E402 - -from isaaclab.app import launch_simulation # noqa: E402 -from isaaclab.envs import DirectMARLEnvCfg, DirectRLEnvCfg, ManagerBasedRLEnvCfg # noqa: E402 - -from isaaclab_tasks.utils import resolve_task_config # noqa: E402 - -imports_profile.disable() - -if torch.cuda.is_available() and torch.cuda.is_initialized(): - torch.cuda.synchronize() -imports_time_end = time.perf_counter_ns() - -# -- Resolve task config (profiled) ------------------------------------------ - -task_config_profile = cProfile.Profile() -task_config_time_begin = time.perf_counter_ns() -task_config_profile.enable() - -env_cfg, _agent_cfg = resolve_task_config(args_cli.task, None) - -task_config_profile.disable() -task_config_time_end = time.perf_counter_ns() - -# -- Detect IsaacLab source prefixes for filtering --------------------------- - -_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) -_source_dir = os.path.join(_REPO_ROOT, "source") -if os.path.isdir(_source_dir): - _ISAACLAB_PREFIXES = [ - os.path.join(_source_dir, d) for d in os.listdir(_source_dir) if os.path.isdir(os.path.join(_source_dir, d)) - ] -else: - print(f"[WARNING] IsaacLab source directory not found at '{_source_dir}'. Function-level profiling will be empty.") - _ISAACLAB_PREFIXES = [] - -# -- Load whitelist config if provided --------------------------------------- - -_WHITELIST: dict[str, list[str]] = {} -if args_cli.whitelist_config is not None: - import yaml - - try: - with open(args_cli.whitelist_config) as f: - raw = yaml.safe_load(f) - except OSError as e: - print(f"[ERROR] Cannot read whitelist config '{args_cli.whitelist_config}': {e}") - sys.exit(1) - except yaml.YAMLError as e: - print(f"[ERROR] Invalid YAML in whitelist config '{args_cli.whitelist_config}': {e}") - sys.exit(1) - - if raw is None: - _WHITELIST = {} - elif not isinstance(raw, dict): - print( - f"[ERROR] Whitelist config must be a YAML mapping (got {type(raw).__name__})." - " Expected format: phase_name: [pattern, ...]" - ) - sys.exit(1) - else: - _VALID_PHASES = {"app_launch", "python_imports", "task_config", "env_creation", "first_step"} - unknown_phases = set(raw.keys()) - _VALID_PHASES - if unknown_phases: - print( - f"[WARNING] Whitelist config contains unknown phase(s): {unknown_phases}. " - f"Valid phases: {_VALID_PHASES}. Check for typos." - ) - for phase_name, patterns in raw.items(): - if not isinstance(patterns, list) or not all(isinstance(p, str) for p in patterns): - print( - f"[ERROR] Whitelist phase '{phase_name}' must be a list of strings, " - f"got {type(patterns).__name__}. Check YAML formatting (use '- pattern' syntax)." - ) - sys.exit(1) - _WHITELIST = raw - -# Resolve top_n default: 5 when using whitelist (fallback phases stay compact), 30 otherwise -if args_cli.top_n is None: - args_cli.top_n = 5 if _WHITELIST else 30 - -# -- Create the benchmark instance ------------------------------------------ - -env_cfg.seed = args_cli.seed if args_cli.seed is not None else env_cfg.seed - -backend_type = get_backend_type(args_cli.benchmark_backend) -benchmark = BaseIsaacLabBenchmark( - benchmark_name="benchmark_startup", - backend_type=backend_type, - output_path=args_cli.output_path, - use_recorders=True, - output_prefix=f"benchmark_startup_{args_cli.task}", - workflow_metadata={ - "metadata": [ - {"name": "task", "data": args_cli.task}, - {"name": "seed", "data": args_cli.seed}, - {"name": "num_envs", "data": args_cli.num_envs}, - {"name": "top_n", "data": args_cli.top_n}, - {"name": "presets", "data": get_preset_string(hydra_args)}, - ] - }, -) - - -# -- Main profiling logic --------------------------------------------------- - - -def main( - env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, - app_launch_profile: cProfile.Profile, - app_launch_wall_ms: float, -): - """Profile env creation and first step, then log all phase measurements. - - Args: - env_cfg: Resolved environment configuration for the task. - app_launch_profile: cProfile session from the app-launch phase. - app_launch_wall_ms: Wall-clock duration of the app-launch phase [ms]. - """ - - # Override config with CLI args - env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device - env_cfg.seed = args_cli.seed if args_cli.seed is not None else env_cfg.seed - - # -- Env creation (gym.make + env.reset) profiled --------------------------- - - env = None - env_creation_profile = cProfile.Profile() - env_creation_time_begin = time.perf_counter_ns() - env_creation_profile.enable() - try: - env = gym.make(args_cli.task, cfg=env_cfg) - env.reset() - finally: - env_creation_profile.disable() - - try: - if torch.cuda.is_available() and torch.cuda.is_initialized(): - torch.cuda.synchronize() - env_creation_time_end = time.perf_counter_ns() - # -- First step profiled ------------------------------------------------ - - # Sample random actions from the action space directly to support - # Box, Discrete, MultiDiscrete, and Dict spaces. - np_actions = np.stack([env.unwrapped.single_action_space.sample() for _ in range(env.unwrapped.num_envs)]) - actions = torch.as_tensor(np_actions, dtype=torch.float32, device=env.unwrapped.device) - - first_step_profile = cProfile.Profile() - first_step_time_begin = time.perf_counter_ns() - first_step_profile.enable() - try: - with torch.inference_mode(): - env.step(actions) - finally: - first_step_profile.disable() - - if torch.cuda.is_available() and torch.cuda.is_initialized(): - torch.cuda.synchronize() - first_step_time_end = time.perf_counter_ns() - - # -- Parse all profiles and log measurements ---------------------------- - - imports_wall_ms = (imports_time_end - imports_time_begin) / 1e6 - task_config_wall_ms = (task_config_time_end - task_config_time_begin) / 1e6 - env_creation_wall_ms = (env_creation_time_end - env_creation_time_begin) / 1e6 - first_step_wall_ms = (first_step_time_end - first_step_time_begin) / 1e6 - - # Collect Timer-based sub-timings for env_creation phase (may not exist for all environment types) - scene_creation_ms = None - try: - scene_creation_ms = Timer.get_timer_info("scene_creation") * 1000 - except TimerError: - print("[INFO] Timer 'scene_creation' not available; sub-timing will be omitted.") - - simulation_start_ms = None - try: - simulation_start_ms = Timer.get_timer_info("simulation_start") * 1000 - except TimerError: - print("[INFO] Timer 'simulation_start' not available; sub-timing will be omitted.") - - phases = { - "app_launch": { - "profile": app_launch_profile, - "wall_clock_ms": app_launch_wall_ms, - "extra_measurements": [], - }, - "python_imports": { - "profile": imports_profile, - "wall_clock_ms": imports_wall_ms, - "extra_measurements": [], - }, - "task_config": { - "profile": task_config_profile, - "wall_clock_ms": task_config_wall_ms, - "extra_measurements": [], - }, - "env_creation": { - "profile": env_creation_profile, - "wall_clock_ms": env_creation_wall_ms, - "extra_measurements": [ - (name, val) - for name, val in [ - ("Scene Creation Time", scene_creation_ms), - ("Simulation Start Time", simulation_start_ms), - ] - if val is not None - ], - }, - "first_step": { - "profile": first_step_profile, - "wall_clock_ms": first_step_wall_ms, - "extra_measurements": [], - }, - } - - # Parse profiles and log measurements to benchmark - for phase_name, phase_data in phases.items(): - phase_whitelist = _WHITELIST.get(phase_name) - functions = parse_cprofile_stats( - phase_data["profile"], _ISAACLAB_PREFIXES, top_n=args_cli.top_n, whitelist=phase_whitelist - ) - wall_ms = phase_data["wall_clock_ms"] - extras = phase_data["extra_measurements"] - - # Log wall-clock time - benchmark.add_measurement( - phase_name, measurement=SingleMeasurement(name="Wall Clock Time", value=wall_ms, unit="ms") - ) - - # Log extra sub-timings - for extra_name, extra_val in extras: - benchmark.add_measurement( - phase_name, measurement=SingleMeasurement(name=extra_name, value=extra_val, unit="ms") - ) - - # Log per-function measurements (tottime + cumtime) - for label, tottime_ms, cumtime_ms in functions: - benchmark.add_measurement( - phase_name, measurement=SingleMeasurement(name=label, value=round(tottime_ms, 2), unit="ms") - ) - benchmark.add_measurement( - phase_name, - measurement=SingleMeasurement(name=f"{label} (cumtime)", value=round(cumtime_ms, 2), unit="ms"), - ) - - # Finalize benchmark output - benchmark.update_manual_recorders() - benchmark._finalize_impl() - finally: - if env is not None: - env.close() - - -if __name__ == "__main__": - # -- App launch (profiled) -------------------------------------------------- - - app_launch_profile = cProfile.Profile() - app_launch_time_begin = time.perf_counter_ns() - app_launch_profile.enable() - - with launch_simulation(env_cfg, args_cli): - app_launch_profile.disable() - - if torch.cuda.is_available() and torch.cuda.is_initialized(): - torch.cuda.synchronize() - app_launch_time_end = time.perf_counter_ns() - - app_launch_wall_ms = (app_launch_time_end - app_launch_time_begin) / 1e6 - main(env_cfg, app_launch_profile, app_launch_wall_ms) diff --git a/scripts/benchmarks/benchmark_view_comparison.py b/scripts/benchmarks/benchmark_view_comparison.py index aa5927e10b6..2f19554d5ff 100644 --- a/scripts/benchmarks/benchmark_view_comparison.py +++ b/scripts/benchmarks/benchmark_view_comparison.py @@ -284,7 +284,8 @@ def _run_pose_benchmarks( start_time = time.perf_counter() for _ in range(num_iterations): - view.set_world_poses(new_positions, orientations) + with view.xform_world_space_writer() as w: + w.set_poses(new_positions, orientations) timing_results["set_world_poses"] = (time.perf_counter() - start_time) / num_iterations ret_pos, ret_quat = view.get_world_poses() diff --git a/scripts/benchmarks/benchmark_xform_prim_view.py b/scripts/benchmarks/benchmark_xform_prim_view.py index fee3b9642c7..f5c13a0242c 100644 --- a/scripts/benchmarks/benchmark_xform_prim_view.py +++ b/scripts/benchmarks/benchmark_xform_prim_view.py @@ -138,21 +138,35 @@ def benchmark_frame_view( # noqa: C901 is_newton = api == "isaaclab-newton-site" + # Synchronize around timed regions using Warp directly (rather than torch), + # since all backend kernels here are Warp launches and ``wp.synchronize()`` + # covers CPU/CUDA Warp devices consistently. We only need it for GPU runs, + # where kernel launches are asynchronous; guard on device to avoid paying + # it needlessly on CPU. + _needs_sync = str(device).startswith("cuda") + + def _sync() -> None: + if _needs_sync: + wp.synchronize() + def to_torch(a): - return wp.to_torch(a) if isinstance(a, wp.array) else a + if isinstance(a, wp.array): + return wp.to_torch(a) + if hasattr(a, "torch"): + return a.torch + return a try: # -- Warmup -------------------------------------------------------- xform_view.get_world_poses() + xform_view.get_world_scales() # -- get_world_poses ----------------------------------------------- - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): positions, orientations = xform_view.get_world_poses() - if is_newton: - torch.cuda.synchronize() + _sync() timing_results["get_world_poses"] = (time.perf_counter() - start_time) / num_iterations positions_t = to_torch(positions) @@ -161,20 +175,24 @@ def to_torch(a): computed_results["initial_world_orientations"] = orientations_t.clone() # -- set_world_poses ----------------------------------------------- + # ``.warp`` unwraps the ProxyArray returned by ``get_*_poses`` / + # ``get_*_scales`` to the underlying ``wp.array`` that ``wp.clone`` + # requires. ProxyArray was introduced in PR #5304 ("ProxyArray and + # Asset/Sensor level property caching") which changed the FrameView + # getter return type. Applies to every ``wp.clone`` call below. if is_newton: - new_positions = wp.clone(positions) + new_positions = wp.clone(positions.warp) wp.to_torch(new_positions)[:, 2] += 0.1 else: new_positions = positions_t.clone() new_positions[:, 2] += 0.1 - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): - xform_view.set_world_poses(new_positions, orientations) - if is_newton: - torch.cuda.synchronize() + with xform_view.xform_world_space_writer() as w: + w.set_poses(new_positions, orientations) + _sync() timing_results["set_world_poses"] = (time.perf_counter() - start_time) / num_iterations pa, oa = xform_view.get_world_poses() @@ -182,13 +200,11 @@ def to_torch(a): computed_results["world_orientations_after_set"] = to_torch(oa).clone() # -- get_local_poses ----------------------------------------------- - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): translations, orientations_local = xform_view.get_local_poses() - if is_newton: - torch.cuda.synchronize() + _sync() timing_results["get_local_poses"] = (time.perf_counter() - start_time) / num_iterations translations_t = to_torch(translations) @@ -198,45 +214,99 @@ def to_torch(a): # -- set_local_poses ----------------------------------------------- if is_newton: - new_translations = wp.clone(translations) + new_translations = wp.clone(translations.warp) wp.to_torch(new_translations)[:, 2] += 0.1 else: new_translations = translations_t.clone() new_translations[:, 2] += 0.1 - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): - xform_view.set_local_poses(new_translations, orientations_local) - if is_newton: - torch.cuda.synchronize() + with xform_view.xform_local_space_writer() as w: + w.set_poses(new_translations, orientations_local) + _sync() timing_results["set_local_poses"] = (time.perf_counter() - start_time) / num_iterations ta, ola = xform_view.get_local_poses() computed_results["local_translations_after_set"] = to_torch(ta).clone() computed_results["local_orientations_after_set"] = to_torch(ola).clone() - # -- get_both (world + local) -------------------------------------- + # -- get_world_scales ---------------------------------------------- + _sync() + start_time = time.perf_counter() + for _ in range(num_iterations): + world_scales = xform_view.get_world_scales() + _sync() + timing_results["get_world_scales"] = (time.perf_counter() - start_time) / num_iterations + + world_scales_t = to_torch(world_scales) + computed_results["initial_world_scales"] = world_scales_t.clone() + + # -- set_world_scales ---------------------------------------------- + if is_newton: + new_world_scales = wp.clone(world_scales.warp) + wp.to_torch(new_world_scales)[:] = 1.1 + else: + new_world_scales = world_scales_t.clone() + new_world_scales[:] = 1.1 + + _sync() + start_time = time.perf_counter() + for _ in range(num_iterations): + with xform_view.xform_world_space_writer() as w: + w.set_scales(new_world_scales) + _sync() + timing_results["set_world_scales"] = (time.perf_counter() - start_time) / num_iterations + + computed_results["world_scales_after_set"] = to_torch(xform_view.get_world_scales()).clone() + + # -- get_local_scales ---------------------------------------------- + _sync() + start_time = time.perf_counter() + for _ in range(num_iterations): + local_scales = xform_view.get_local_scales() + _sync() + timing_results["get_local_scales"] = (time.perf_counter() - start_time) / num_iterations + + local_scales_t = to_torch(local_scales) + computed_results["initial_local_scales"] = local_scales_t.clone() + + # -- set_local_scales ---------------------------------------------- if is_newton: - torch.cuda.synchronize() + new_local_scales = wp.clone(local_scales.warp) + wp.to_torch(new_local_scales)[:] = 0.9 + else: + new_local_scales = local_scales_t.clone() + new_local_scales[:] = 0.9 + + _sync() + start_time = time.perf_counter() + for _ in range(num_iterations): + with xform_view.xform_local_space_writer() as w: + w.set_scales(new_local_scales) + _sync() + timing_results["set_local_scales"] = (time.perf_counter() - start_time) / num_iterations + + computed_results["local_scales_after_set"] = to_torch(xform_view.get_local_scales()).clone() + + # -- get_both (world + local) -------------------------------------- + _sync() start_time = time.perf_counter() for _ in range(num_iterations): xform_view.get_world_poses() xform_view.get_local_poses() - if is_newton: - torch.cuda.synchronize() + _sync() timing_results["get_both"] = (time.perf_counter() - start_time) / num_iterations # -- interleaved set -> get ---------------------------------------- - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): - xform_view.set_world_poses(new_positions, orientations) + with xform_view.xform_world_space_writer() as w: + w.set_poses(new_positions, orientations) xform_view.get_world_poses() - if is_newton: - torch.cuda.synchronize() + _sync() timing_results["interleaved_world_set_get"] = (time.perf_counter() - start_time) / num_iterations finally: @@ -267,15 +337,26 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num print(header) print("-" * 120) - operations = [ - ("Initialization", "init"), + # ``init`` is the one-time view-construction cost. We display it in the + # per-operation table but EXCLUDE it from the steady-state totals and the + # overall speedup -- otherwise a backend whose construction is dominated + # by stage population (e.g. Newton, where the first call materializes the + # site cache) shows a misleading "0.00x" overall and crushes the rest of + # the table. The overall row is intended to compare per-iteration cost. + init_op = ("Initialization (one-time)", "init") + per_iter_operations = [ ("Get World Poses", "get_world_poses"), ("Set World Poses", "set_world_poses"), ("Get Local Poses", "get_local_poses"), ("Set Local Poses", "set_local_poses"), + ("Get World Scales", "get_world_scales"), + ("Set World Scales", "set_world_scales"), + ("Get Local Scales", "get_local_scales"), + ("Set Local Scales", "set_local_scales"), ("Get Both (World+Local)", "get_both"), ("Interleaved World Set->Get", "interleaved_world_set_get"), ] + operations = [init_op, *per_iter_operations] for op_name, op_key in operations: row = f"{op_name:<28}" @@ -286,15 +367,16 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num print("=" * 120) - total_row = f"{'Total':<28}" + total_row = f"{'Total (per-iter ops)':<28}" for name in api_names: - total_row += f" {sum(results_dict[name].values()) * 1000:>{col_width}.4f}" + per_iter_total = sum(results_dict[name].get(k, 0) for _, k in per_iter_operations) + total_row += f" {per_iter_total * 1000:>{col_width}.4f}" print(f"\n{total_row}") baseline = "isaaclab-usd" if baseline in results_dict and len(api_names) > 1: print("\n" + "=" * 120) - print(f"SPEEDUP vs {baseline.replace('-', ' ').title()}") + print(f"SPEEDUP vs {baseline.replace('-', ' ').title()} (per-iter ops; one-time init excluded)") print("=" * 120) header = f"{'Operation':<28}" for name in api_names: @@ -304,7 +386,7 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num print("-" * 120) base = results_dict[baseline] - for op_name, op_key in operations: + for op_name, op_key in per_iter_operations: row = f"{op_name:<28}" base_t = base.get(op_key, 0) for name in api_names: @@ -317,11 +399,11 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num print(row) print("=" * 120) - print(f"{'Overall':>28}", end="") - total_base = sum(base.values()) + print(f"{'Overall (per-iter ops)':>28}", end="") + total_base = sum(base.get(k, 0) for _, k in per_iter_operations) for name in api_names: if name != baseline: - total_impl = sum(results_dict[name].values()) + total_impl = sum(results_dict[name].get(k, 0) for _, k in per_iter_operations) if total_base > 0 and total_impl > 0: print(f" {total_base / total_impl:>{col_width}.2f}x", end="") else: diff --git a/scripts/benchmarks/early_stop.py b/scripts/benchmarks/early_stop.py index 38dabe3b67c..25437b1a1da 100644 --- a/scripts/benchmarks/early_stop.py +++ b/scripts/benchmarks/early_stop.py @@ -3,27 +3,22 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Early stopping for benchmark training based on a success metric. - -Framework-specific implementations that monitor a metric from ``extras["log"]`` -and stop training when it stabilizes above a threshold: - -- **rsl_rl**: ``env.step`` wrapper + exception (no callback API in rsl_rl). -- **rl_games**: ``AlgoObserver`` subclass, sets ``max_epochs`` for clean exit. -""" +"""Success-metric tracking and early stopping for training benchmarks.""" from __future__ import annotations import argparse import os -import statistics from typing import TYPE_CHECKING -from scripts.benchmarks.utils import get_success_rate_log +from isaaclab.test.benchmark import SingleMeasurement +from isaaclab.test.benchmark.metrics import SuccessRateTracker, get_success_rate_log if TYPE_CHECKING: + from types import TracebackType + from rl_games.common.algo_observer import AlgoObserver - from rsl_rl.runners import OnPolicyRunner + from rsl_rl.runners import DistillationRunner, OnPolicyRunner from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper @@ -35,85 +30,12 @@ class EarlyStopConverged(Exception): """Raised by :class:`RslRlEarlyStopWrapper` when the metric has converged.""" -class SuccessRateTracker: - """Accumulates a per-iteration success-rate metric and checks trailing-window convergence. - - Args: - threshold: Minimum value to consider a pass. - window: Consecutive iterations above *threshold* to trigger convergence. - num_steps_per_env: Steps per RL iteration (for boundary detection). - """ - - def __init__(self, threshold: float, window: int, num_steps_per_env: int): - self.threshold = threshold - self.window = window - self.num_steps_per_env = num_steps_per_env - - self.history: list[float] = [] - self._step_count = 0 - self._iter_sum = 0.0 - self._iter_count = 0 - - def record_step(self, extras: dict) -> None: - """Record one env step.""" - val = get_success_rate_log(extras.get("log", {})) - if val is not None: - self._iter_sum += val.item() if hasattr(val, "item") else float(val) - self._iter_count += 1 - self._step_count += 1 - - def end_iteration(self) -> float | None: - """Finalize the current iteration. Returns mean metric, or ``None`` if no data.""" - if self._iter_count == 0: - return None - mean = self._iter_sum / self._iter_count - self.history.append(mean) - self._iter_sum = 0.0 - self._iter_count = 0 - return mean - - @property - def at_iteration_boundary(self) -> bool: - """Whether the tracker has seen exactly a full iteration's worth of steps. - - Assumes :meth:`record_step` is called exactly once per env step. This holds for - all current framework integrations (rsl_rl's patched ``env.step`` and rl_games' - ``AlgoObserver.process_infos``) — both pair a single step with a single record. - Integrations that call :meth:`record_step` more or fewer times per env step will - break iteration accounting. - """ - return self.num_steps_per_env > 0 and self._step_count % self.num_steps_per_env == 0 - - @property - def converged(self) -> bool: - if len(self.history) < self.window: - return False - return all(v >= self.threshold for v in self.history[-self.window :]) - - @property - def current_iteration(self) -> int: - return len(self.history) - - @property - def tail_mean(self) -> float: - if not self.history: - return 0.0 - tail = self.history[-self.window :] if len(self.history) >= self.window else self.history - return statistics.mean(tail) - - class RslRlEarlyStopWrapper: - """Context manager that wraps ``env.step`` to track a success metric during rsl_rl training. - - Always records the metric into :attr:`tracker` so the caller can log the tail mean / converged-at - iteration regardless of whether early stopping is enabled. When ``stop_on_convergence=True``, the - wrapper also raises :class:`EarlyStopConverged` on the first iteration where the trailing window - is above threshold, performs runner cleanup (checkpoint save + logger flush), and suppresses the - exception so the caller sees a normal return from :meth:`rsl_rl.runners.OnPolicyRunner.learn`. + """Track RSL-RL success metrics and optionally stop on convergence. Args: env: ``RslRlVecEnvWrapper`` instance. - runner: ``OnPolicyRunner`` instance. + runner: RSL-RL runner instance. threshold: Minimum metric value to pass. window: Consecutive iterations above threshold to trigger stop. num_steps_per_env: Steps per RL iteration. @@ -124,7 +46,7 @@ class RslRlEarlyStopWrapper: def __init__( self, env: RslRlVecEnvWrapper, - runner: OnPolicyRunner, + runner: OnPolicyRunner | DistillationRunner, threshold: float, window: int, num_steps_per_env: int, @@ -136,11 +58,16 @@ def __init__( self.stop_on_convergence = stop_on_convergence self._orig_step = env.step - def __enter__(self): + def __enter__(self) -> RslRlEarlyStopWrapper: self.env.step = self._step return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: self.env.step = self._orig_step if exc_type is EarlyStopConverged: self._runner_cleanup() @@ -151,14 +78,12 @@ def __exit__(self, exc_type, exc_val, exc_tb): return True return False - def _step(self, actions): + def _step(self, actions) -> tuple: result = self._orig_step(actions) self.tracker.record_step(result[3]) # rsl_rl: (obs, rew, dones, extras) if self.tracker.at_iteration_boundary: self.tracker.end_iteration() if self.stop_on_convergence and self.tracker.converged: - # relies on rsl_rl's rollout loop not catching arbitrary exceptions; if upstream - # ever wraps env.step in a broad except, this exception will be swallowed raise EarlyStopConverged() return result @@ -171,23 +96,53 @@ def _runner_cleanup(self): @property def framework_iteration_count(self) -> int: - """Number of training iterations the rsl_rl runner has recorded as completed. - - Note: ``current_learning_iteration`` is set AFTER rollout + policy update, so mid-rollout - (including the instant our early-stop exception fires) this counter lags :attr:`tracker` - by 1 iteration. - """ + """Return completed runner iterations, including an active rollout.""" return self.runner.current_learning_iteration + 1 -class RlGamesEarlyStopObserver: - """``AlgoObserver`` that tracks a success metric during rl_games training. +class SuccessRateTrackerWrapper: + """Track success metrics from a raw Gymnasium environment. + + Args: + env: Environment whose ``step`` method returns Gymnasium's five-tuple. + threshold: Minimum metric value to pass. + window: Consecutive iterations above the threshold to converge. + num_steps_per_env: Environment steps per training iteration. + """ + + def __init__( + self, + env, + threshold: float, + window: int, + num_steps_per_env: int, + ) -> None: + self.env = env + self.tracker = SuccessRateTracker(threshold, window, num_steps_per_env) + self._orig_step = env.step + + def __enter__(self) -> SuccessRateTrackerWrapper: + self.env.step = self._step + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.env.step = self._orig_step - Always records the metric into :attr:`tracker` so the caller can log the tail mean / converged-at - iteration regardless of whether early stopping is enabled. When ``stop_on_convergence=True``, the - observer also sets ``algo.max_epochs`` on the first iteration where the trailing window is above - threshold, which forces a clean exit from :meth:`rl_games.torch_runner.Runner.run`. All other - observer calls are delegated to *base_observer*. + def _step(self, actions) -> tuple: + result = self._orig_step(actions) + self.tracker.record_step(result[4]) + if self.tracker.at_iteration_boundary: + self.tracker.end_iteration() + return result + + +class RlGamesEarlyStopObserver: + """Track RL-Games success metrics and optionally stop on convergence. Args: base_observer: Original ``AlgoObserver`` to delegate to. @@ -211,22 +166,22 @@ def __init__( self.algo = None self.tracker: SuccessRateTracker | None = None - def before_init(self, base_name, config, experiment_name): + def before_init(self, base_name: str, config: dict, experiment_name: str) -> None: self._base.before_init(base_name, config, experiment_name) - def after_init(self, algo): + def after_init(self, algo) -> None: self._base.after_init(algo) self.algo = algo num_steps = getattr(algo, "horizon_length", algo.config.get("horizon_length", 16)) self.tracker = SuccessRateTracker(self.threshold, self.window, num_steps) - def process_infos(self, infos, done_indices): + def process_infos(self, infos, done_indices) -> None: self._base.process_infos(infos, done_indices) if self.tracker is not None and isinstance(infos, dict) and "episode" in infos: - # rl_games remaps extras["log"] → extras["episode"] + # rl_games remaps extras["log"] to extras["episode"] self.tracker.record_step({"log": infos["episode"]}) - def after_steps(self): + def after_steps(self) -> None: self._base.after_steps() if self.tracker is None: return @@ -238,33 +193,34 @@ def after_steps(self): ) self.algo.max_epochs = self.tracker.current_iteration - def after_clear_stats(self): - self._base.after_clear_stats() + def after_clear_stats(self) -> None: + if hasattr(self._base, "after_clear_stats"): + self._base.after_clear_stats() - def after_print_stats(self, frame, epoch_num, total_time): + def after_print_stats(self, frame: int, epoch_num: int, total_time: float) -> None: self._base.after_print_stats(frame, epoch_num, total_time) @property def framework_iteration_count(self) -> int | None: - """Number of training iterations the rl_games algo has recorded. - - rl_games increments ``algo.epoch_num`` at the start of each iteration, so after iter N - completes this value equals N (matching :attr:`tracker`'s count exactly). Returns - ``None`` before :meth:`after_init` has attached to an algo. - """ + """Return the RL-Games epoch count, or ``None`` before initialization.""" return None if self.algo is None else self.algo.epoch_num -def add_success_cli_args(parser: argparse.ArgumentParser) -> None: +def add_success_cli_args(parser: argparse.ArgumentParser, *, include_check_success: bool = True) -> None: """Register the success-metric CLI args on *parser*. - Adds ``--check_success``, ``--success_threshold``, and ``--success_window``. Use - :func:`build_success_kwargs` to resolve the parsed values into a kwargs dict for - the wrapper constructors. + Args: + parser: Parser receiving the success-metric arguments. + include_check_success: Whether to register the early-stop flag. Integrations + without live success tracking still accept threshold and window overrides + for post-hoc diagnostics. """ - parser.add_argument( - "--check_success", action="store_true", help="Early-stop when the normalized success metric converges." - ) + if include_check_success: + parser.add_argument( + "--check_success", action="store_true", help="Early-stop when the normalized success metric converges." + ) + else: + parser.set_defaults(check_success=False) parser.add_argument( "--success_threshold", type=float, @@ -279,18 +235,18 @@ def add_success_cli_args(parser: argparse.ArgumentParser) -> None: ) -def build_success_kwargs(args_cli: argparse.Namespace) -> dict: - """Resolve success-metric CLI args into kwargs for the wrapper constructors. +def build_success_kwargs(args_cli: argparse.Namespace) -> dict[str, float | int | bool]: + """Resolve success-metric CLI args for the live tracker integrations. - Returns a dict with ``threshold``, ``window``, and ``stop_on_convergence``, suitable - to splat into :class:`RslRlEarlyStopWrapper` or :class:`RlGamesEarlyStopObserver`. + Returns: + A dict with ``threshold``, ``window``, and ``stop_on_convergence`` keys. """ return { "threshold": ( args_cli.success_threshold if args_cli.success_threshold is not None else DEFAULT_SUCCESS_THRESHOLD ), "window": args_cli.success_window if args_cli.success_window is not None else DEFAULT_SUCCESS_WINDOW, - "stop_on_convergence": args_cli.check_success, + "stop_on_convergence": getattr(args_cli, "check_success", False), } @@ -303,12 +259,15 @@ def get_success_tracker( Prefers *live_tracker* (from the training wrapper/observer). If it never ran or recorded no iterations, falls back to building a post-hoc tracker by replaying the success metric - series out of TensorBoard *log_data* (from :func:`scripts.benchmarks.utils.parse_tf_logs`). + series out of TensorBoard *log_data* (from :func:`isaaclab.test.benchmark.metrics.parse_tf_logs`). Args: args_cli: Parsed arg namespace with the ``--success_*`` flags. live_tracker: Tracker attached to the early-stop wrapper/observer (or ``None``). log_data: Mapping of TB tag -> list of scalars for the current run. + + Returns: + A :class:`SuccessRateTracker` populated with history, or ``None`` if no data is available. """ if live_tracker is not None and live_tracker.history: return live_tracker @@ -319,3 +278,28 @@ def get_success_tracker( tracker = SuccessRateTracker(kwargs["threshold"], kwargs["window"], num_steps_per_env=0) tracker.history = list(history) return tracker + + +def success_measurements(tracker: SuccessRateTracker | None) -> list[SingleMeasurement]: + """Build the established flat-output success diagnostics. + + Args: + tracker: Success-rate tracker populated during or after training. + + Returns: + Success tail mean, convergence iteration, and pass status, or an empty + list when no success metric was recorded. + """ + if tracker is None or not tracker.history: + return [] + + converged = tracker.converged + return [ + SingleMeasurement(name="Success Rate (tail mean)", value=round(tracker.tail_mean, 4), unit="float"), + SingleMeasurement( + name="Success Converged At Iter", + value=tracker.current_iteration if converged else -1, + unit="int", + ), + SingleMeasurement(name="Success Passed", value=int(converged), unit="bool"), + ] diff --git a/scripts/benchmarks/play.py b/scripts/benchmarks/play.py new file mode 100644 index 00000000000..d6cf89e5835 --- /dev/null +++ b/scripts/benchmarks/play.py @@ -0,0 +1,45 @@ +# 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 + +"""Unified play-benchmark dispatcher (mirrors scripts/reinforcement_learning/play.py). + +Selects the RL library via ``--rl_library`` and forwards remaining args to the matching +benchmark adapter under ``scripts/benchmarks//benchmark__play.py``, which rolls +out a checkpointed policy under a ``BenchmarkMonitor`` and emits a ``PlayBundle``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parent +_RL_SCRIPTS = _SCRIPT_DIR.parent / "reinforcement_learning" +if str(_RL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_RL_SCRIPTS)) + +from common import dispatch_library_entrypoint # noqa: E402 + +LIBRARY_ENTRYPOINTS = { + "rsl_rl": _SCRIPT_DIR / "rsl_rl" / "benchmark_rsl_rl_play.py", + "rl_games": _SCRIPT_DIR / "rl_games" / "benchmark_rl_games_play.py", + "skrl": _SCRIPT_DIR / "skrl" / "benchmark_skrl_play.py", + "sb3": _SCRIPT_DIR / "sb3" / "benchmark_sb3_play.py", +} + + +def main(argv: list[str] | None = None) -> int: + """Dispatch to the selected RL library's play-benchmark adapter.""" + return dispatch_library_entrypoint( + argv, + LIBRARY_ENTRYPOINTS, + action="bench_play", + description="Benchmark RL inference (play) with a selected reinforcement learning library.", + library_help="Inference library to benchmark.", + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/benchmarks/rl_games/benchmark_rl_games_play.py b/scripts/benchmarks/rl_games/benchmark_rl_games_play.py new file mode 100644 index 00000000000..035bbb45a6b --- /dev/null +++ b/scripts/benchmarks/rl_games/benchmark_rl_games_play.py @@ -0,0 +1,279 @@ +# 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 + +"""RL-Games play-benchmark adapter. + +Rolls out a checkpointed RL-Games policy under a :class:`~isaaclab.test.benchmark.BenchmarkMonitor` +and emits a :class:`~isaaclab.test.benchmark.schema.PlayBundle` JSON file. Dispatched from +``scripts/benchmarks/play.py`` via ``--rl_library rl_games``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_BENCH_DIR = Path(__file__).resolve().parents[1] +_RL_SCRIPTS = _BENCH_DIR.parent / "reinforcement_learning" + +if str(_RL_SCRIPTS) not in sys.path: + # Shared training utilities remain script-local, so their directory must be on sys.path. + sys.path.insert(0, str(_RL_SCRIPTS)) + +import common as _common # noqa: E402 + + +def _parse_args(argv: list[str]): + """Parse CLI arguments and forward the remaining Hydra preset tokens via ``sys.argv``. + + Args: + argv: Raw command-line arguments (``sys.argv[1:]`` after dispatcher strips + ``--rl_library``). + + Returns: + Tuple of ``(parsed_args, remaining)`` where *remaining* are the verbatim Hydra + preset tokens written back to ``sys.argv`` for ``launch_simulation`` to pick up. + """ + import argparse + + from isaaclab_tasks.utils import setup_preset_cli + + add_isaaclab_launcher_args = _common.add_isaaclab_launcher_args + + parser = argparse.ArgumentParser(description="Benchmark RL inference (play) with RL-Games.") + help_requested = "-h" in argv or "--help" in argv + parser.add_argument("--task", type=str, required=not help_requested, help="Gym task id to benchmark.") + parser.add_argument("--num_envs", type=int, default=None, help="Number of parallel environments.") + parser.add_argument("--num_frames", type=int, default=100, help="Number of inference steps to benchmark.") + parser.add_argument("--seed", type=int, default=None, help="Environment seed.") + parser.add_argument( + "--checkpoint", + type=str, + default=None, + help="Local or Nucleus checkpoint path to roll out; falls back to the published checkpoint when omitted.", + ) + parser.add_argument( + "--agent", type=str, default="rl_games_cfg_entry_point", help="Name of the RL agent configuration entry point." + ) + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'" + " Example: 'schema,omniperf'." + ), + ) + add_isaaclab_launcher_args(parser) + + args_cli, remaining_args = setup_preset_cli(parser, argv) + sys.argv = [sys.argv[0]] + remaining_args + + return args_cli, remaining_args + + +def run(argv: list[str]) -> None: + """Run the RL-Games play benchmark and write a :class:`~isaaclab.test.benchmark.schema.PlayBundle`. + + Args: + argv: Command-line arguments, excluding the script path (i.e. ``sys.argv[1:]`` + after the dispatcher has stripped ``--rl_library``). + """ + import contextlib + import math + import os + import re + import time + + import gymnasium as gym + from rl_games.common import env_configurations, vecenv + from rl_games.common.player import BasePlayer + from rl_games.torch_runner import Runner + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture, stepping + from isaaclab.test.benchmark.schema import StartupTime + + from isaaclab_rl.rl_games import RlGamesGpuEnv, RlGamesVecEnvWrapper + + # Importing the task packages registers their gym environments so the + # requested ``--task`` can be resolved. + import isaaclab_tasks # noqa: F401 + from isaaclab_tasks.utils import resolve_task_config + + # PLACEHOLDER: Extension template (do not remove this comment) + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + args_cli, remaining_args = _parse_args(argv) + + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args_cli): + app_t1 = time.perf_counter_ns() + + if args_cli.num_envs is not None: + env_cfg.scene.num_envs = args_cli.num_envs + if args_cli.seed is not None: + agent_cfg["params"]["seed"] = args_cli.seed + env_cfg.seed = agent_cfg["params"]["seed"] + + config_name = agent_cfg["params"]["config"]["name"] + log_root_path = os.path.abspath(os.path.join("logs", "rl_games", config_name)) + if args_cli.checkpoint in _common.CHECKPOINT_SELECTORS: + resume_path = _common.resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="rl_games", + task=args_cli.task, + checkpoint_pattern=r".*\.pth", + other_dirs=["nn"], + preferred_checkpoint_pattern=rf"{re.escape(config_name)}\.pth", + metadata={"agent": args_cli.agent}, + ) + else: + resume_path = _common.resolve_play_checkpoint(args_cli.checkpoint, "rl_games", args_cli.task) + + cfg = capture.run_config_from_presets(remaining_args) + formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_play", + formatter_type=formatter_types, + output_path=args_cli.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_play_{args_cli.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args_cli.task}, + {"name": "num_envs", "data": args_cli.num_envs}, + {"name": "num_frames", "data": args_cli.num_frames}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + # Read rl_games device / clipping config. + rl_device = agent_cfg["params"]["config"]["device"] + clip_obs = agent_cfg["params"]["env"].get("clip_observations", math.inf) + clip_actions = agent_cfg["params"]["env"].get("clip_actions", math.inf) + obs_groups = agent_cfg["params"]["env"].get("obs_groups") + concate_obs_groups = agent_cfg["params"]["env"].get("concate_obs_groups", True) + + env_t0 = time.perf_counter_ns() + env = gym.make(args_cli.task, cfg=env_cfg) + env_t1 = time.perf_counter_ns() + + # Wrap for rl_games (pass obs groups so tasks with non-default/asymmetric observation + # layouts feed the policy the same observation it was trained on). + env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions, obs_groups, concate_obs_groups) + + # Register with rl_games vecenv registry. + vecenv.register( + "IsaacRlgWrapper", + lambda config_name, num_actors, **kwargs: RlGamesGpuEnv(config_name, num_actors, **kwargs), + ) + env_configurations.register("rlgpu", {"vecenv_type": "IsaacRlgWrapper", "env_creator": lambda **kwargs: env}) + + num_envs = env.unwrapped.num_envs + + # Load the trained policy the same way scripts/reinforcement_learning/rl_games/play.py does. + agent_cfg["params"]["load_checkpoint"] = True + agent_cfg["params"]["load_path"] = resume_path + agent_cfg["params"]["config"]["num_actors"] = num_envs + runner = Runner() + runner.load(agent_cfg) + agent: BasePlayer = runner.create_player() + agent.restore(resume_path) + agent.reset() + _ = agent.get_batch_size(agent.obs_to_torch(env.reset()), 1) + if agent.is_rnn: + agent.init_rnn() + + def policy(obs): + """Map an observation batch to a deterministic action batch via the rl_games player. + + Mirrors the inference path in ``scripts/reinforcement_learning/rl_games/play.py``: + extracts the ``"obs"`` tensor from the wrapper's dict observation and runs the + player's deterministic action. + + Args: + obs: Observation returned by the rl_games-wrapped env (dict or tensor). + + Returns: + The action tensor to feed ``env.step``. + """ + if isinstance(obs, dict): + obs = obs["obs"] + obs = agent.obs_to_torch(obs) + return agent.get_action(obs, is_deterministic=agent.is_deterministic) + + with BenchmarkMonitor(benchmark, interval=1.0): + step_times, reward, ep_length, success_rate = stepping.run_play_loop(env, policy, args_cli.num_frames) + + benchmark.update_manual_recorders() + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(step_times[0] if step_times else 0.0), + ) + + fps = [num_envs / t for t in step_times if t > 0] + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=step_times, + collection_fps=fps, + total_fps=fps, + steps_per_iteration=num_envs, + ) + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = agent_cfg["params"]["seed"] if agent_cfg["params"]["seed"] is not None else 0 + + run_identity = builders.build_run_identity( + run_id=capture.synth_run_id("rl_games", cfg.physics_backend, args_cli.task, seed, stamp), + framework="rl_games", + config=cfg, + task=args_cli.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=num_envs, + ) + + bundle = builders.build_play_bundle( + run=run_identity, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + success_rate=success_rate, + reward=reward, + ep_length=ep_length, + checkpoint_path=resume_path, + ) + + benchmark.attach_bundle(bundle) + + benchmark._finalize_impl() + + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/rl_games/benchmark_rl_games_train.py b/scripts/benchmarks/rl_games/benchmark_rl_games_train.py new file mode 100644 index 00000000000..859be208c99 --- /dev/null +++ b/scripts/benchmarks/rl_games/benchmark_rl_games_train.py @@ -0,0 +1,328 @@ +# 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 + +"""RL-Games adapter for the unified training benchmark.""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +_BENCH_DIR = Path(__file__).resolve().parents[1] +_RL_SCRIPTS = _BENCH_DIR.parent / "reinforcement_learning" + +# Shared training utilities remain script-local, so their directory must be on sys.path. +if str(_RL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_RL_SCRIPTS)) + +import common as _common # noqa: E402 + + +def _parse_args(argv: list[str]): + """Parse CLI arguments and forward the remaining Hydra preset tokens via ``sys.argv``. + + Args: + argv: Raw command-line arguments (``sys.argv[1:]`` after dispatcher strips + ``--rl_library``). + + Returns: + Tuple of ``(parsed_args, remaining)`` where *remaining* are the verbatim Hydra + preset tokens written back to ``sys.argv`` for ``launch_simulation`` to pick up. + """ + import argparse + + from isaaclab_tasks.utils import setup_preset_cli + + add_common_train_args = _common.add_common_train_args + add_isaaclab_launcher_args = _common.add_isaaclab_launcher_args + enable_cameras_for_video = _common.enable_cameras_for_video + + parser = argparse.ArgumentParser(description="Benchmark RL training with RL-Games.") + add_common_train_args( + parser, + agent_default="rl_games_cfg_entry_point", + agent_help="Name of the RL agent configuration entry point.", + include_distributed=False, + ) + add_isaaclab_launcher_args(parser) + + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'" + " Example: 'schema,omniperf'." + ), + ) + parser.add_argument( + "--ema_alpha", + type=float, + default=0.1, + help="EMA smoothing factor for learning curves (higher = more recent weight).", + ) + parser.add_argument( + "--no_series", + action="store_true", + default=False, + help="Omit per-iteration series data from the bundle to reduce file size.", + ) + + from scripts.benchmarks.early_stop import add_success_cli_args + + add_success_cli_args(parser) + + if "--distributed" in argv: + parser.error("Distributed training benchmarks are not supported.") + + args_cli, remaining_args = setup_preset_cli(parser, argv) + enable_cameras_for_video(args_cli) + sys.argv = [sys.argv[0]] + remaining_args + + return args_cli, remaining_args + + +def run(argv: list[str]) -> None: + """Run the RL-Games training benchmark and write a :class:`~isaaclab.test.benchmark.TrainingBundle`. + + Args: + argv: Command-line arguments, excluding the script path (i.e. ``sys.argv[1:]`` + after the dispatcher has stripped ``--rl_library``). + """ + imports_t0 = time.perf_counter_ns() + + import contextlib + import math + import os + import random + from datetime import datetime + + import torch + from rl_games.common import env_configurations, vecenv + from rl_games.common.algo_observer import IsaacAlgoObserver + from rl_games.torch_runner import Runner + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture + from isaaclab.test.benchmark.metrics import RL_LIBRARY_DESCRIPTORS, parse_tf_logs + from isaaclab.test.benchmark.schema import StartupTime + + from isaaclab_rl.rl_games import RlGamesGpuEnv, RlGamesVecEnvWrapper + + import isaaclab_tasks # noqa: F401 + + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + from isaaclab_tasks.utils import resolve_task_config + + apply_env_overrides = _common.apply_env_overrides + from scripts.benchmarks.early_stop import ( + RlGamesEarlyStopObserver, + build_success_kwargs, + get_success_tracker, + success_measurements, + ) + + imports_t1 = time.perf_counter_ns() + + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + torch.backends.cudnn.deterministic = False + torch.backends.cudnn.benchmark = False + + args_cli, remaining_args = _parse_args(argv) + + config_t0 = time.perf_counter_ns() + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + config_t1 = time.perf_counter_ns() + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args_cli): + app_t1 = time.perf_counter_ns() + + apply_env_overrides(args_cli, env_cfg) + + if args_cli.seed == -1: + args_cli.seed = random.randint(0, 10000) + agent_cfg["params"]["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg["params"]["seed"] + + if args_cli.max_iterations is not None: + agent_cfg["params"]["config"]["max_epochs"] = args_cli.max_iterations + + env_cfg.seed = agent_cfg["params"]["seed"] + + cfg = capture.run_config_from_presets(remaining_args, env_cfg=env_cfg) + formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_training", + formatter_type=formatter_types, + output_path=args_cli.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_training_{args_cli.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args_cli.task}, + {"name": "seed", "data": agent_cfg["params"]["seed"]}, + {"name": "num_envs", "data": env_cfg.scene.num_envs}, + {"name": "max_iterations", "data": agent_cfg["params"]["config"].get("max_epochs")}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + config_name = agent_cfg["params"]["config"]["name"] + log_root_path = os.path.abspath(os.path.join("logs", "rl_games", config_name)) + log_dir = agent_cfg["params"]["config"].get( + "full_experiment_name", datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ) + agent_cfg["params"]["config"]["train_dir"] = log_root_path + agent_cfg["params"]["config"]["full_experiment_name"] = log_dir + run_log_dir = os.path.join(log_root_path, log_dir) + _common.write_run_manifest( + run_log_dir, library="rl_games", task=args_cli.task, metadata={"agent": args_cli.agent} + ) + env_cfg.log_dir = run_log_dir + + rl_device = agent_cfg["params"]["config"]["device"] + clip_obs = agent_cfg["params"]["env"].get("clip_observations", math.inf) + clip_actions = agent_cfg["params"]["env"].get("clip_actions", math.inf) + + env_t0 = time.perf_counter_ns() + env = _common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) + env = _common.wrap_record_video(env, run_log_dir, args_cli) + env_t1 = time.perf_counter_ns() + + env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions) + + # Register with rl_games vecenv registry. + vecenv.register( + "IsaacRlgWrapper", + lambda config_name, num_actors, **kwargs: RlGamesGpuEnv(config_name, num_actors, **kwargs), + ) + env_configurations.register("rlgpu", {"vecenv_type": "IsaacRlgWrapper", "env_creator": lambda **kwargs: env}) + + agent_cfg["params"]["config"]["num_actors"] = env.unwrapped.num_envs + + observer = RlGamesEarlyStopObserver(IsaacAlgoObserver(), **build_success_kwargs(args_cli)) + runner = Runner(observer) + runner.load(agent_cfg) + env.seed(agent_cfg["params"]["seed"]) + runner.reset() + + with BenchmarkMonitor(benchmark, interval=1.0): + runner.run({"train": True, "play": False, "sigma": None}) + + benchmark.update_manual_recorders() + + # Flush TensorBoard events before parsing them. + if observer.algo is not None and getattr(observer.algo, "writer", None) is not None: + try: + observer.algo.writer.flush() + observer.algo.writer.close() + except Exception as exc: # noqa: BLE001 + print( + f"[WARNING] rl_games TensorBoard writer flush/close failed: {exc!r};" + " TB logs may be incomplete and metrics may be zero.", + file=sys.stderr, + ) + + desc = RL_LIBRARY_DESCRIPTORS["rl_games"] + tb_dir = run_log_dir + log_data = parse_tf_logs(tb_dir, desc.tfevents_pattern) + max_epochs = agent_cfg["params"]["config"].get("max_epochs", 1) + if not log_data or (not log_data.get(desc.reward_tag) and max_epochs >= 1): + print( + f"[WARNING] No TensorBoard data parsed from {tb_dir!r};" + " the emitted bundle will report zero metrics. Check the log directory.", + file=sys.stderr, + ) + + # RL-Games logs FPS directly; iteration time is steps divided by total FPS. + horizon_length = agent_cfg["params"]["config"].get("horizon_length", 16) + steps_per_iteration = env.unwrapped.num_envs * horizon_length + total_fps_series = list(log_data.get("performance/step_inference_rl_update_fps", [])) + collection_fps_series = list(log_data.get("performance/step_inference_fps", [])) + iteration_times_s = [steps_per_iteration / f for f in total_fps_series if f > 0] + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(iteration_times_s[0] if iteration_times_s else 0.0), + python_imports=(imports_t1 - imports_t0) / 1e9, + task_config=(config_t1 - config_t0) / 1e9, + ) + + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=iteration_times_s, + collection_fps=collection_fps_series, + total_fps=total_fps_series, + steps_per_iteration=steps_per_iteration, + ) + + learning = builders.build_learning( + reward_series=log_data.get(desc.reward_tag, []), + ep_length_series=log_data.get(desc.ep_length_tag, []), + ema_alpha=args_cli.ema_alpha, + keep_series=not args_cli.no_series, + ) + + tracker = get_success_tracker(args_cli, observer.tracker, log_data) + success_rate = round(tracker.tail_mean, 4) if (tracker and tracker.history) else None + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = agent_cfg["params"]["seed"] if agent_cfg["params"]["seed"] is not None else 0 + + run_identity = builders.build_run_identity( + run_id=capture.synth_run_id("rl_games", cfg.physics_backend, args_cli.task, seed, stamp), + framework="rl_games", + config=cfg, + task=args_cli.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=env.unwrapped.num_envs, + max_iterations=agent_cfg["params"]["config"].get("max_epochs"), + ) + + checkpoint_path = None + video_path = os.path.join(run_log_dir, "videos") if getattr(args_cli, "video", False) else None + + bundle = builders.build_training_bundle( + run=run_identity, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + learning=learning, + success_rate=success_rate, + checkpoint_path=checkpoint_path, + video_path=video_path, + ) + + benchmark.attach_bundle(bundle) + benchmark.add_measurement("train", success_measurements(tracker)) + + benchmark._finalize_impl() + + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/rsl_rl/benchmark_rsl_rl_play.py b/scripts/benchmarks/rsl_rl/benchmark_rsl_rl_play.py new file mode 100644 index 00000000000..185badbfe1a --- /dev/null +++ b/scripts/benchmarks/rsl_rl/benchmark_rsl_rl_play.py @@ -0,0 +1,240 @@ +# 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 + +"""RSL-RL play-benchmark adapter. + +Rolls out a checkpointed RSL-RL policy under a :class:`~isaaclab.test.benchmark.BenchmarkMonitor` +and emits a :class:`~isaaclab.test.benchmark.schema.PlayBundle` JSON file. Dispatched from +``scripts/benchmarks/play.py`` via ``--rl_library rsl_rl``. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +_BENCH_DIR = Path(__file__).resolve().parents[1] +_RL_SCRIPTS = _BENCH_DIR.parent / "reinforcement_learning" +if str(_RL_SCRIPTS) not in sys.path: + # Shared training utilities remain script-local, so their directory must be on sys.path. + sys.path.insert(0, str(_RL_SCRIPTS)) + +import common as _common # noqa: E402 + + +def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + """Parse CLI arguments and forward the remaining Hydra preset tokens via ``sys.argv``. + + Builds the parser, appends launcher args via :func:`~isaaclab.app.add_launcher_args`, then + calls :func:`~isaaclab_tasks.utils.setup_preset_cli` to split known args from the verbatim + remainder (``physics=`` / ``renderer=`` / ``presets=`` tokens). The remainder is written back + to ``sys.argv`` so that Hydra and ``launch_simulation`` pick up the preset selection. + + Args: + argv: Raw command-line arguments (``sys.argv[1:]`` after the dispatcher strips + ``--rl_library``). + + Returns: + Tuple of ``(parsed_args, remaining)`` where *remaining* are the Hydra preset tokens. + """ + from isaaclab.app import add_launcher_args + + from isaaclab_tasks.utils import setup_preset_cli + + parser = argparse.ArgumentParser(description="Benchmark RL inference (play) with RSL-RL.") + help_requested = "-h" in argv or "--help" in argv + parser.add_argument("--task", type=str, required=not help_requested, help="Gym task id to benchmark.") + parser.add_argument("--num_envs", type=int, default=None, help="Number of parallel environments.") + parser.add_argument("--num_frames", type=int, default=100, help="Number of inference steps to benchmark.") + parser.add_argument("--seed", type=int, default=None, help="Environment seed.") + parser.add_argument( + "--checkpoint", + type=str, + default=None, + help="Local or Nucleus checkpoint path to roll out; falls back to the published checkpoint when omitted.", + ) + parser.add_argument( + "--agent", type=str, default="rsl_rl_cfg_entry_point", help="Name of the RL agent configuration entry point." + ) + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'" + " Example: 'schema,omniperf'." + ), + ) + add_launcher_args(parser) + + args, remaining = setup_preset_cli(parser, argv) + sys.argv = [sys.argv[0]] + remaining + return args, remaining + + +def run(argv: list[str]) -> None: + """Run the RSL-RL play benchmark and write a :class:`~isaaclab.test.benchmark.schema.PlayBundle`. + + Args: + argv: Command-line arguments, excluding the script path (i.e. ``sys.argv[1:]`` + after the dispatcher has stripped ``--rl_library``). + """ + import contextlib + import importlib.metadata as metadata + import os + import time + + import gymnasium as gym + from rsl_rl.runners import DistillationRunner, OnPolicyRunner + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture, stepping + from isaaclab.test.benchmark.schema import StartupTime + + from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg + + # Importing the task packages registers their gym environments so the + # requested ``--task`` can be resolved. + import isaaclab_tasks # noqa: F401 + from isaaclab_tasks.utils import resolve_task_config + + # PLACEHOLDER: Extension template (do not remove this comment) + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + args, remaining = _parse_args(argv) + + env_cfg, agent_cfg = resolve_task_config(args.task, args.agent) + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args): + app_t1 = time.perf_counter_ns() + + if args.num_envs is not None: + env_cfg.scene.num_envs = args.num_envs + if args.seed is not None: + agent_cfg.seed = args.seed + env_cfg.seed = agent_cfg.seed + + installed_rsl_rl = metadata.version("rsl-rl-lib") + agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_rsl_rl) + + log_root_path = os.path.abspath(os.path.join("logs", "rsl_rl", agent_cfg.experiment_name)) + if args.checkpoint in _common.CHECKPOINT_SELECTORS: + resume_path = _common.resolve_checkpoint_selector( + log_root_path, + args.checkpoint, + library="rsl_rl", + task=args.task, + checkpoint_pattern=r"model_.*\.pt", + metadata={"agent": args.agent}, + ) + else: + resume_path = _common.resolve_play_checkpoint(args.checkpoint, "rsl_rl", args.task) + + cfg = capture.run_config_from_presets(remaining) + formatter_types = [value.strip() for value in args.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_play", + formatter_type=formatter_types, + output_path=args.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_play_{args.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args.task}, + {"name": "num_envs", "data": args.num_envs}, + {"name": "num_frames", "data": args.num_frames}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + env_t0 = time.perf_counter_ns() + env = gym.make(args.task, cfg=env_cfg) + env_t1 = time.perf_counter_ns() + + env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) + + num_envs = env.unwrapped.num_envs + + # Load the trained policy the same way scripts/reinforcement_learning/rsl_rl/play.py does. + if agent_cfg.class_name == "OnPolicyRunner": + runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) + elif agent_cfg.class_name == "DistillationRunner": + runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) + else: + raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") + runner.load(resume_path) + policy = runner.get_inference_policy(device=env.unwrapped.device) + + with BenchmarkMonitor(benchmark, interval=1.0): + step_times, reward, ep_length, success_rate = stepping.run_play_loop(env, policy, args.num_frames) + + benchmark.update_manual_recorders() + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(step_times[0] if step_times else 0.0), + ) + + fps = [num_envs / t for t in step_times if t > 0] + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=step_times, + collection_fps=fps, + total_fps=fps, + steps_per_iteration=num_envs, + ) + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = agent_cfg.seed if agent_cfg.seed is not None else 0 + + run_identity = builders.build_run_identity( + run_id=capture.synth_run_id("rsl_rl", cfg.physics_backend, args.task, seed, stamp), + framework="rsl_rl", + config=cfg, + task=args.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=num_envs, + ) + + bundle = builders.build_play_bundle( + run=run_identity, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + success_rate=success_rate, + reward=reward, + ep_length=ep_length, + checkpoint_path=resume_path, + ) + + benchmark.attach_bundle(bundle) + + benchmark._finalize_impl() + + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/rsl_rl/benchmark_rsl_rl_train.py b/scripts/benchmarks/rsl_rl/benchmark_rsl_rl_train.py new file mode 100644 index 00000000000..39cce62f08e --- /dev/null +++ b/scripts/benchmarks/rsl_rl/benchmark_rsl_rl_train.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 + +"""RSL-RL adapter for the unified training benchmark.""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path +from typing import Any + +_BENCH_DIR = Path(__file__).resolve().parents[1] +_RL_SCRIPTS = _BENCH_DIR.parent / "reinforcement_learning" + +# Shared training utilities remain script-local, so their directory must be on sys.path. +if str(_RL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_RL_SCRIPTS)) + +import common as _common # noqa: E402 + + +def _disable_code_state_capture(runner: Any) -> None: + """Disable RSL-RL Git state capture while retaining TensorBoard logging.""" + runner.logger.git_status_repos = [] + + +def _parse_args(argv: list[str]): + """Parse CLI arguments and forward the remaining Hydra preset tokens via ``sys.argv``. + + Args: + argv: Raw command-line arguments (``sys.argv[1:]`` after dispatcher strips + ``--rl_library``). + + Returns: + Tuple of ``(parsed_args, remaining)`` where *remaining* are the verbatim Hydra + preset tokens written back to ``sys.argv`` for ``launch_simulation`` to pick up. + """ + import argparse + + from isaaclab_tasks.utils import setup_preset_cli + + add_common_train_args = _common.add_common_train_args + add_isaaclab_launcher_args = _common.add_isaaclab_launcher_args + enable_cameras_for_video = _common.enable_cameras_for_video + import_local_module = _common.import_local_module + + CLI_ARGS = import_local_module("isaaclab_rsl_rl_cli_args", _RL_SCRIPTS / "rsl_rl" / "cli_args.py") + + parser = argparse.ArgumentParser(description="Benchmark RL training with RSL-RL.") + add_common_train_args( + parser, + agent_default="rsl_rl_cfg_entry_point", + agent_help="Name of the RL agent configuration entry point.", + include_distributed=False, + ) + CLI_ARGS.add_rsl_rl_args(parser) + add_isaaclab_launcher_args(parser) + + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'" + " Example: 'schema,omniperf'." + ), + ) + parser.add_argument( + "--ema_alpha", + type=float, + default=0.1, + help="EMA smoothing factor for learning curves (higher = more recent weight).", + ) + parser.add_argument( + "--no_series", + action="store_true", + default=False, + help="Omit per-iteration series data from the bundle to reduce file size.", + ) + + from scripts.benchmarks.early_stop import add_success_cli_args + + add_success_cli_args(parser) + + if "--distributed" in argv: + parser.error("Distributed training benchmarks are not supported.") + + args_cli, remaining_args = setup_preset_cli(parser, argv) + enable_cameras_for_video(args_cli) + sys.argv = [sys.argv[0]] + remaining_args + + return args_cli, remaining_args, CLI_ARGS + + +def run(argv: list[str]) -> None: + """Run the RSL-RL training benchmark and write a :class:`~isaaclab.test.benchmark.TrainingBundle`. + + Args: + argv: Command-line arguments, excluding the script path (i.e. ``sys.argv[1:]`` + after the dispatcher has stripped ``--rl_library``). + """ + imports_t0 = time.perf_counter_ns() + + import contextlib + import importlib.metadata as metadata + import os + from datetime import datetime + + import torch + from rsl_rl.runners import DistillationRunner, OnPolicyRunner + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture + from isaaclab.test.benchmark.metrics import RL_LIBRARY_DESCRIPTORS, parse_tf_logs + from isaaclab.test.benchmark.schema import StartupTime + + from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg + + import isaaclab_tasks # noqa: F401 + + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + from isaaclab_tasks.utils import get_checkpoint_path, resolve_task_config + + apply_env_overrides = _common.apply_env_overrides + from scripts.benchmarks.early_stop import ( + RslRlEarlyStopWrapper, + build_success_kwargs, + get_success_tracker, + success_measurements, + ) + + imports_t1 = time.perf_counter_ns() + + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + torch.backends.cudnn.deterministic = False + torch.backends.cudnn.benchmark = False + + args_cli, remaining_args, CLI_ARGS = _parse_args(argv) + + config_t0 = time.perf_counter_ns() + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + config_t1 = time.perf_counter_ns() + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args_cli): + app_t1 = time.perf_counter_ns() + + apply_env_overrides(args_cli, env_cfg) + agent_cfg = CLI_ARGS.update_rsl_rl_cfg(agent_cfg, args_cli) + agent_cfg.max_iterations = ( + args_cli.max_iterations if args_cli.max_iterations is not None else agent_cfg.max_iterations + ) + installed_rsl_rl = metadata.version("rsl-rl-lib") + agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_rsl_rl) + env_cfg.seed = agent_cfg.seed + + cfg = capture.run_config_from_presets(remaining_args, env_cfg=env_cfg) + formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_training", + formatter_type=formatter_types, + output_path=args_cli.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_training_{args_cli.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args_cli.task}, + {"name": "seed", "data": agent_cfg.seed}, + {"name": "num_envs", "data": env_cfg.scene.num_envs}, + {"name": "max_iterations", "data": agent_cfg.max_iterations}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + log_root_path = os.path.abspath(os.path.join("logs", "rsl_rl", agent_cfg.experiment_name)) + resume_path = ( + get_checkpoint_path(log_root_path, agent_cfg.load_run, agent_cfg.load_checkpoint) + if agent_cfg.resume or agent_cfg.class_name == "DistillationRunner" + else None + ) + log_dir = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + if agent_cfg.run_name: + log_dir += f"_{agent_cfg.run_name}" + log_dir = os.path.join(log_root_path, log_dir) + _common.write_run_manifest(log_dir, library="rsl_rl", task=args_cli.task, metadata={"agent": args_cli.agent}) + env_cfg.log_dir = log_dir + + env_t0 = time.perf_counter_ns() + env = _common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) + env = _common.wrap_record_video(env, log_dir, args_cli) + env_t1 = time.perf_counter_ns() + + env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) + + runner_types = {"OnPolicyRunner": OnPolicyRunner, "DistillationRunner": DistillationRunner} + if agent_cfg.class_name not in runner_types: + raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") + runner = runner_types[agent_cfg.class_name](env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device) + _disable_code_state_capture(runner) + if resume_path is not None: + runner.load(resume_path) + + early = RslRlEarlyStopWrapper( + env, runner, num_steps_per_env=agent_cfg.num_steps_per_env, **build_success_kwargs(args_cli) + ) + + with early, BenchmarkMonitor(benchmark, interval=1.0): + runner.learn(num_learning_iterations=agent_cfg.max_iterations, init_at_random_ep_len=True) + + benchmark.update_manual_recorders() + + desc = RL_LIBRARY_DESCRIPTORS["rsl_rl"] + log_data = parse_tf_logs(log_dir, desc.tfevents_pattern) + if not log_data or (not log_data.get(desc.reward_tag) and agent_cfg.max_iterations >= 1): + print( + f"[WARNING] No TensorBoard data parsed from {log_dir!r};" + " the emitted bundle will report zero metrics. Check the log directory.", + file=sys.stderr, + ) + + # RSL-RL reports collection and learning durations separately in seconds. + coll = log_data.get("Perf/collection_time", []) + learn_ = log_data.get("Perf/learning_time", []) + iteration_times_s = [c + lrn for c, lrn in zip(coll, learn_)] + collection_fps_series = [env.unwrapped.num_envs * agent_cfg.num_steps_per_env / c for c in coll if c > 0] + total_fps_series = list(log_data.get("Perf/total_fps", [])) + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(iteration_times_s[0] if iteration_times_s else 0.0), + python_imports=(imports_t1 - imports_t0) / 1e9, + task_config=(config_t1 - config_t0) / 1e9, + ) + + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=iteration_times_s, + collection_fps=collection_fps_series, + total_fps=total_fps_series, + steps_per_iteration=env.unwrapped.num_envs * agent_cfg.num_steps_per_env, + ) + + learning = builders.build_learning( + reward_series=log_data.get(desc.reward_tag, []), + ep_length_series=log_data.get(desc.ep_length_tag, []), + ema_alpha=args_cli.ema_alpha, + keep_series=not args_cli.no_series, + ) + + tracker = get_success_tracker(args_cli, early.tracker, log_data) + success_rate = round(tracker.tail_mean, 4) if (tracker and tracker.history) else None + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = agent_cfg.seed if agent_cfg.seed is not None else 0 + + run_identity = builders.build_run_identity( + run_id=capture.synth_run_id("rsl_rl", cfg.physics_backend, args_cli.task, seed, stamp), + framework="rsl_rl", + config=cfg, + task=args_cli.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=env.unwrapped.num_envs, + max_iterations=agent_cfg.max_iterations, + ) + + checkpoint_path = None + video_path = os.path.join(log_dir, "videos") if getattr(args_cli, "video", False) else None + + bundle = builders.build_training_bundle( + run=run_identity, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + learning=learning, + success_rate=success_rate, + checkpoint_path=checkpoint_path, + video_path=video_path, + ) + + benchmark.attach_bundle(bundle) + benchmark.add_measurement("train", success_measurements(tracker)) + + benchmark._finalize_impl() + + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/run_non_rl_benchmarks.sh b/scripts/benchmarks/run_non_rl_benchmarks.sh deleted file mode 100755 index 23c69a794f1..00000000000 --- a/scripts/benchmarks/run_non_rl_benchmarks.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -#!/usr/bin/env bash - -ROOT_DIR="${1:-./benchmarks}" -OUTPUT_DIR="${ROOT_DIR}/isaaclab_non_rl" -TASKS="Isaac-Repose-Cube-Allegro-Direct-v0 Isaac-Ant-Direct Isaac-Cartpole-Direct Isaac-Humanoid-Direct Isaac-Ant Isaac-Cartpole Isaac-Humanoid Isaac-Velocity-Flat-Unitree-A1-v0 Isaac-Velocity-Flat-Anymal-B-v0 Isaac-Velocity-Flat-Anymal-C-v0 Isaac-Velocity-Flat-Anymal-D-v0 Isaac-Velocity-Flat-Cassie-v0 Isaac-Velocity-Flat-G1-v0 Isaac-Velocity-Flat-G1-v1 Isaac-Velocity-Flat-Unitree-Go1-v0 Isaac-Velocity-Flat-Unitree-Go2-v0 Isaac-Velocity-Flat-H1-v0 Isaac-Reach-Franka Isaac-Reach-UR10" -NUM_ENVS="4096 8192 16384" -NUM_FRAMES="100" - -for TASK in $TASKS; do - for NUM_ENV in $NUM_ENVS; do - if [[ $TASK == *"RGB-Camera"* ]] || [[ $TASK == *"Depth-Camera"* ]]; then - ./isaaclab.sh -p scripts/benchmarks/benchmark_non_rl.py --benchmark_backend omniperf --output_path "$OUTPUT_DIR" --task "$TASK" --num_envs "$NUM_ENV" --headless --num_frames "$NUM_FRAMES" --enable_cameras - else - ./isaaclab.sh -p scripts/benchmarks/benchmark_non_rl.py --benchmark_backend omniperf --output_path "$OUTPUT_DIR" --task "$TASK" --num_envs "$NUM_ENV" --headless --num_frames "$NUM_FRAMES" - fi - done -done diff --git a/scripts/benchmarks/run_physx_benchmarks.sh b/scripts/benchmarks/run_physx_benchmarks.sh deleted file mode 100755 index adb743b4643..00000000000 --- a/scripts/benchmarks/run_physx_benchmarks.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -# Set output directory from argument or use default -ROOT_DIR="${1:-./benchmarks}" -OUTPUT_DIR="${ROOT_DIR}/isaaclab_physx" - -./isaaclab.sh -p source/isaaclab_physx/benchmark/assets/benchmark_articulation.py --backend omniperf --output_dir "$OUTPUT_DIR" -./isaaclab.sh -p source/isaaclab_physx/benchmark/assets/benchmark_rigid_object.py --backend omniperf --output_dir "$OUTPUT_DIR" -./isaaclab.sh -p source/isaaclab_physx/benchmark/assets/benchmark_rigid_object_collection.py --backend omniperf --output_dir "$OUTPUT_DIR" -./isaaclab.sh -p source/isaaclab_physx/benchmark/assets/benchmark_articulation_data.py --backend omniperf --output_dir "$OUTPUT_DIR" -./isaaclab.sh -p source/isaaclab_physx/benchmark/assets/benchmark_rigid_object_data.py --backend omniperf --output_dir "$OUTPUT_DIR" -./isaaclab.sh -p source/isaaclab_physx/benchmark/assets/benchmark_rigid_object_collection_data.py --backend omniperf --output_dir "$OUTPUT_DIR" diff --git a/scripts/benchmarks/run_training_benchmarks.sh b/scripts/benchmarks/run_training_benchmarks.sh deleted file mode 100755 index e064bcbd484..00000000000 --- a/scripts/benchmarks/run_training_benchmarks.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -ROOT_DIR="${1:-./benchmarks}" -OUTPUT_DIR="${ROOT_DIR}/isaaclab_rsl_rl_training" -TASKS="Isaac-Repose-Cube-Allegro-Direct-v0 Isaac-Ant-Direct Isaac-Cartpole-Direct Isaac-Humanoid-Direct Isaac-Ant Isaac-Cartpole Isaac-Humanoid Isaac-Velocity-Flat-Unitree-A1-v0 Isaac-Velocity-Flat-Anymal-B-v0 Isaac-Velocity-Flat-Anymal-C-v0 Isaac-Velocity-Flat-Anymal-D-v0 Isaac-Velocity-Flat-Cassie-v0 Isaac-Velocity-Flat-G1-v0 Isaac-Velocity-Flat-G1-v1 Isaac-Velocity-Flat-Unitree-Go1-v0 Isaac-Velocity-Flat-Unitree-Go2-v0 Isaac-Velocity-Flat-H1-v0 Isaac-Reach-Franka Isaac-Reach-UR10" -NUM_ENV="4096" -ITERATIONS="500" - -for TASK in $TASKS; do - if [[ $TASK == *"RGB-Camera"* ]] || [[ $TASK == *"Depth-Camera"* ]]; then - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py --benchmark_backend omniperf --output_path "$OUTPUT_DIR" --task "$TASK" --num_envs "$NUM_ENV" --headless --max_iterations "$ITERATIONS" --enable_cameras - else - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py --benchmark_backend omniperf --output_path "$OUTPUT_DIR" --task "$TASK" --num_envs "$NUM_ENV" --headless --max_iterations "$ITERATIONS" - fi -done diff --git a/scripts/benchmarks/runtime.py b/scripts/benchmarks/runtime.py new file mode 100644 index 00000000000..e2287509765 --- /dev/null +++ b/scripts/benchmarks/runtime.py @@ -0,0 +1,194 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +r"""Benchmark environment runtime (random actions, no policy). + +Standalone script that steps an Isaac Lab environment with random actions. The +schema formatter emits a :class:`~isaaclab.test.benchmark.RuntimeBundle`; +other selected formatters receive equivalent measurement phases. +Supports all physics backends (PhysX, Newton/MJWarp, Newton/Kamino, OVPhysX) +via Hydra preset tokens — no ``--rl_library`` dispatch needed. + +Usage example:: + + uv run isaaclab benchmark runtime \\ + --task Isaac-Cartpole-Direct \\ + --num_envs 16 --num_frames 100 \\ + presets=newton_mjwarp --headless +""" + +from __future__ import annotations + +import argparse +import sys + + +def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + """Parse benchmark arguments and retain Hydra overrides in ``sys.argv``. + + Args: + argv: Command-line arguments excluding the script path. + + Returns: + Parsed arguments and the remaining Hydra overrides. + """ + from isaaclab.app import add_launcher_args + + from isaaclab_tasks.utils import setup_preset_cli + + parser = argparse.ArgumentParser(description="Benchmark environment runtime (random actions, no policy).") + parser.add_argument("--task", type=str, required=True, help="Gym task id to benchmark.") + parser.add_argument("--num_envs", type=int, default=None, help="Number of parallel environments.") + parser.add_argument("--num_frames", type=int, default=100, help="Number of environment steps to benchmark.") + parser.add_argument("--seed", type=int, default=None, help="Environment seed.") + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'." + " Example: 'schema,omniperf'." + ), + ) + add_launcher_args(parser) + + args, remaining = setup_preset_cli(parser, argv) + sys.argv = [sys.argv[0]] + remaining + return args, remaining + + +def run(argv: list[str]) -> None: + """Run the runtime benchmark and write the selected formatter outputs. + + Args: + argv: Command-line arguments excluding the script path. + """ + import time + + imports_t0 = time.perf_counter_ns() + import contextlib + + import gymnasium as gym + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture, stepping + from isaaclab.test.benchmark.schema import StartupTime + + # Importing the task packages registers their gym environments so the + # requested ``--task`` can be resolved. + import isaaclab_tasks # noqa: F401 + from isaaclab_tasks.utils import resolve_task_config + + # PLACEHOLDER: Extension template (do not remove this comment) + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + args, remaining = _parse_args(argv) + imports_t1 = time.perf_counter_ns() + + task_config_t0 = time.perf_counter_ns() + env_cfg, _ = resolve_task_config(args.task, None) + task_config_t1 = time.perf_counter_ns() + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args): + app_t1 = time.perf_counter_ns() + + if args.num_envs is not None: + env_cfg.scene.num_envs = args.num_envs + if args.device is not None: + env_cfg.sim.device = args.device + if args.seed is not None: + env_cfg.seed = args.seed + + formatter_types = [value.strip() for value in args.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + cfg = capture.run_config_from_presets(remaining, env_cfg=env_cfg) + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_runtime", + formatter_type=args.benchmark_formatter, + output_path=args.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_runtime_{args.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args.task}, + {"name": "num_envs", "data": args.num_envs}, + {"name": "num_frames", "data": args.num_frames}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + env_t0 = time.perf_counter_ns() + with contextlib.closing(gym.make(args.task, cfg=env_cfg)) as env: + env_t1 = time.perf_counter_ns() + + num_envs = env.unwrapped.num_envs + + with BenchmarkMonitor(benchmark, interval=1.0): + step_times_s = stepping.run_runtime_loop(env, args.num_frames) + + benchmark.update_manual_recorders() + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(step_times_s[0] if step_times_s else 0.0), + python_imports=(imports_t1 - imports_t0) / 1e9, + task_config=(task_config_t1 - task_config_t0) / 1e9, + ) + + fps = [num_envs / t for t in step_times_s if t > 0] + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=step_times_s, + collection_fps=fps, + total_fps=fps, + steps_per_iteration=num_envs, + ) + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + + seed = args.seed if args.seed is not None else 0 + run_id = capture.synth_run_id(None, cfg.physics_backend, args.task, seed, stamp) + + run = builders.build_run_identity( + run_id=run_id, + framework=None, + config=cfg, + task=args.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=num_envs, + ) + + bundle = builders.build_runtime_bundle( + run=run, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + ) + + benchmark.attach_bundle(bundle) + + benchmark._finalize_impl() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/sb3/benchmark_sb3_play.py b/scripts/benchmarks/sb3/benchmark_sb3_play.py new file mode 100644 index 00000000000..f8219bd132e --- /dev/null +++ b/scripts/benchmarks/sb3/benchmark_sb3_play.py @@ -0,0 +1,270 @@ +# 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 + +"""Stable-Baselines3 play-benchmark adapter. + +Rolls out a checkpointed Stable-Baselines3 policy under a +:class:`~isaaclab.test.benchmark.BenchmarkMonitor` and emits a +:class:`~isaaclab.test.benchmark.schema.PlayBundle` JSON file. Dispatched from +``scripts/benchmarks/play.py`` via ``--rl_library sb3``. +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +_BENCH_DIR = Path(__file__).resolve().parents[1] +_RL_SCRIPTS = _BENCH_DIR.parent / "reinforcement_learning" + +if str(_RL_SCRIPTS) not in sys.path: + # Shared training utilities remain script-local, so their directory must be on sys.path. + sys.path.insert(0, str(_RL_SCRIPTS)) + +import common as _common # noqa: E402 + + +def _parse_args(argv: list[str]): + """Parse CLI arguments and forward the remaining Hydra preset tokens via ``sys.argv``. + + Args: + argv: Raw command-line arguments (``sys.argv[1:]`` after dispatcher strips + ``--rl_library``). + + Returns: + Tuple of ``(parsed_args, remaining)`` where *remaining* are the verbatim Hydra + preset tokens written back to ``sys.argv`` for ``launch_simulation`` to pick up. + """ + import argparse + + from isaaclab_tasks.utils import setup_preset_cli + + add_isaaclab_launcher_args = _common.add_isaaclab_launcher_args + + parser = argparse.ArgumentParser(description="Benchmark RL inference (play) with Stable-Baselines3.") + help_requested = "-h" in argv or "--help" in argv + parser.add_argument("--task", type=str, required=not help_requested, help="Gym task id to benchmark.") + parser.add_argument("--num_envs", type=int, default=None, help="Number of parallel environments.") + parser.add_argument("--num_frames", type=int, default=100, help="Number of inference steps to benchmark.") + parser.add_argument("--seed", type=int, default=None, help="Environment seed.") + parser.add_argument( + "--checkpoint", + type=str, + default=None, + help="Local or Nucleus checkpoint path to roll out; falls back to the published checkpoint when omitted.", + ) + parser.add_argument( + "--agent", type=str, default="sb3_cfg_entry_point", help="Name of the RL agent configuration entry point." + ) + parser.add_argument( + "--keep_all_info", + action="store_true", + default=False, + help="Use a slower SB3 wrapper but keep all the extra training info.", + ) + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'" + " Example: 'schema,omniperf'." + ), + ) + add_isaaclab_launcher_args(parser) + + args_cli, remaining_args = setup_preset_cli(parser, argv) + sys.argv = [sys.argv[0]] + remaining_args + + return args_cli, remaining_args + + +def run(argv: list[str]) -> None: + """Run the sb3 play benchmark and write a :class:`~isaaclab.test.benchmark.schema.PlayBundle`. + + Args: + argv: Command-line arguments, excluding the script path (i.e. ``sys.argv[1:]`` + after the dispatcher has stripped ``--rl_library``). + """ + import contextlib + import os + + import gymnasium as gym + from stable_baselines3 import PPO + from stable_baselines3.common.vec_env import VecNormalize + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture, stepping + from isaaclab.test.benchmark.schema import StartupTime + + from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg + + # Importing the task packages registers their gym environments so the + # requested ``--task`` can be resolved. + import isaaclab_tasks # noqa: F401 + from isaaclab_tasks.utils import resolve_task_config + + # PLACEHOLDER: Extension template (do not remove this comment) + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + args_cli, remaining_args = _parse_args(argv) + + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args_cli): + app_t1 = time.perf_counter_ns() + + if args_cli.num_envs is not None: + env_cfg.scene.num_envs = args_cli.num_envs + agent_cfg["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg.get("seed", 0) + env_cfg.seed = agent_cfg["seed"] + + log_root_path = os.path.abspath(os.path.join("logs", "sb3", args_cli.task)) + if args_cli.checkpoint in _common.CHECKPOINT_SELECTORS: + resume_path = _common.resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="sb3", + task=args_cli.task, + checkpoint_pattern=r"model_.*\.zip", + preferred_checkpoint_pattern=r"model\.zip", + metadata={"agent": args_cli.agent}, + ) + else: + resume_path = _common.resolve_play_checkpoint(args_cli.checkpoint, "sb3", args_cli.task) + + cfg = capture.run_config_from_presets(remaining_args) + formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_play", + formatter_type=formatter_types, + output_path=args_cli.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_play_{args_cli.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args_cli.task}, + {"name": "num_envs", "data": args_cli.num_envs}, + {"name": "num_frames", "data": args_cli.num_frames}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + env_t0 = time.perf_counter_ns() + env = gym.make(args_cli.task, cfg=env_cfg) + env_t1 = time.perf_counter_ns() + + # Post-process agent configuration the same way scripts/reinforcement_learning/sb3/play.py does. + agent_cfg = process_sb3_cfg(agent_cfg, env.unwrapped.num_envs) + + num_envs = env.unwrapped.num_envs + + # Wrap for stable-baselines3. + env = Sb3VecEnvWrapper(env, fast_variant=not args_cli.keep_all_info) + + # Load VecNormalize statistics when they were saved next to the checkpoint. + vec_norm_path = Path(resume_path.replace("/model", "/model_vecnormalize").replace(".zip", ".pkl")) + if vec_norm_path.exists(): + env = VecNormalize.load(vec_norm_path, env) + env.training = False + env.norm_reward = False + elif "normalize_input" in agent_cfg: + env = VecNormalize( + env, + training=True, + norm_obs="normalize_input" in agent_cfg and agent_cfg.pop("normalize_input"), + clip_obs="clip_obs" in agent_cfg and agent_cfg.pop("clip_obs"), + ) + + # Load the trained policy. + agent = PPO.load(resume_path, env, print_system_info=True) + + def policy(obs): + """Map an observation batch to a deterministic action batch via the sb3 agent. + + Mirrors the inference path in ``scripts/reinforcement_learning/sb3/play.py``: + the sb3-wrapped env returns NumPy observations, which ``agent.predict`` consumes + directly, returning NumPy actions for ``env.step``. + + Args: + obs: NumPy observation returned by the sb3-wrapped env. + + Returns: + The NumPy action array to feed ``env.step``. + """ + actions, _ = agent.predict(obs, deterministic=True) + return actions + + with BenchmarkMonitor(benchmark, interval=1.0): + step_times, reward, ep_length, success_rate = stepping.run_play_loop(env, policy, args_cli.num_frames) + + benchmark.update_manual_recorders() + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(step_times[0] if step_times else 0.0), + ) + + fps = [num_envs / t for t in step_times if t > 0] + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=step_times, + collection_fps=fps, + total_fps=fps, + steps_per_iteration=num_envs, + ) + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = env_cfg.seed if env_cfg.seed is not None else 0 + + run_identity = builders.build_run_identity( + run_id=capture.synth_run_id("sb3", cfg.physics_backend, args_cli.task, seed, stamp), + framework="sb3", + config=cfg, + task=args_cli.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=num_envs, + ) + + bundle = builders.build_play_bundle( + run=run_identity, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + success_rate=success_rate, + reward=reward, + ep_length=ep_length, + checkpoint_path=resume_path, + ) + + benchmark.attach_bundle(bundle) + + benchmark._finalize_impl() + + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/sb3/benchmark_sb3_train.py b/scripts/benchmarks/sb3/benchmark_sb3_train.py new file mode 100644 index 00000000000..970633ed2f7 --- /dev/null +++ b/scripts/benchmarks/sb3/benchmark_sb3_train.py @@ -0,0 +1,399 @@ +# 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 + +"""Stable-Baselines3 adapter for the unified training benchmark.""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +_BENCH_DIR = Path(__file__).resolve().parents[1] +_RL_SCRIPTS = _BENCH_DIR.parent / "reinforcement_learning" + +# Shared training utilities remain script-local, so their directory must be on sys.path. +if str(_RL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_RL_SCRIPTS)) + +import common as _common # noqa: E402 + + +def _build_benchmark_callback_class(): + """Create the callback class after simulator startup to defer SB3 imports. + + Returns: + The ``_BenchmarkCallback`` class. + """ + from stable_baselines3.common.callbacks import BaseCallback + from stable_baselines3.common.utils import safe_mean + + class _BenchmarkCallback(BaseCallback): + """``BaseCallback`` that records per-iteration timing, reward, and episode length. + + The callback reads directly from ``self.model.ep_info_buffer``, the same + buffer ``dump_logs`` uses, so metrics are available regardless + of the ``log_interval`` setting passed to ``agent.learn``. + + Attributes: + collection_times_s: Wall-clock seconds spent collecting each rollout. + iter_times_s: Wall-clock seconds for each rollout and policy update. + ep_rew_mean: Mean episode reward observed at the end of each rollout. + ep_len_mean: Mean episode length observed at the end of each rollout. + """ + + def __init__(self) -> None: + super().__init__(verbose=0) + self.collection_times_s: list[float] = [] + self.iter_times_s: list[float] = [] + self.ep_rew_mean: list[float] = [] + self.ep_len_mean: list[float] = [] + self._rollout_start_ns: int = 0 + self._iteration_start_ns: int = 0 + self._rollout_started = False + + def _on_training_start(self) -> None: + self._iteration_start_ns = time.perf_counter_ns() + + def _on_rollout_start(self) -> None: + now = time.perf_counter_ns() + if self._rollout_started: + self.iter_times_s.append((now - self._iteration_start_ns) / 1e9) + self._iteration_start_ns = now + self._rollout_start_ns = now + self._rollout_started = True + + def _on_rollout_end(self) -> None: + elapsed_s = (time.perf_counter_ns() - self._rollout_start_ns) / 1e9 + self.collection_times_s.append(elapsed_s) + + buf = self.model.ep_info_buffer + if buf and len(buf) > 0 and len(buf[0]) > 0: + self.ep_rew_mean.append(float(safe_mean([ep["r"] for ep in buf]))) + self.ep_len_mean.append(float(safe_mean([ep["l"] for ep in buf]))) + else: + self.ep_rew_mean.append(float("nan")) + self.ep_len_mean.append(float("nan")) + + def _on_training_end(self) -> None: + if self._rollout_started: + self.iter_times_s.append((time.perf_counter_ns() - self._iteration_start_ns) / 1e9) + + def _on_step(self) -> bool: + return True + + return _BenchmarkCallback + + +def _parse_args(argv: list[str]): + """Parse CLI arguments and forward the remaining Hydra preset tokens via ``sys.argv``. + + Args: + argv: Raw command-line arguments (``sys.argv[1:]`` after dispatcher + strips ``--rl_library``). + + Returns: + Tuple of ``(parsed_args, remaining)`` where *remaining* are the verbatim Hydra + preset tokens written back to ``sys.argv`` for ``launch_simulation`` to pick up. + """ + import argparse + + from isaaclab_tasks.utils import setup_preset_cli + + add_common_train_args = _common.add_common_train_args + add_isaaclab_launcher_args = _common.add_isaaclab_launcher_args + enable_cameras_for_video = _common.enable_cameras_for_video + + parser = argparse.ArgumentParser(description="Benchmark RL training with Stable-Baselines3.") + add_common_train_args( + parser, + agent_default="sb3_cfg_entry_point", + agent_help="Name of the RL agent configuration entry point.", + include_distributed=False, + ) + + parser.add_argument("--log_interval", type=int, default=100_000, help="Log data every n timesteps.") + parser.add_argument("--checkpoint", type=str, default=None, help="Continue training from checkpoint.") + parser.add_argument( + "--keep_all_info", + action="store_true", + default=False, + help="Use a slower SB3 wrapper but keep all the extra training info.", + ) + + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'" + " Example: 'schema,omniperf'." + ), + ) + parser.add_argument( + "--ema_alpha", + type=float, + default=0.1, + help="EMA smoothing factor for learning curves (higher = more recent weight).", + ) + parser.add_argument( + "--no_series", + action="store_true", + default=False, + help="Omit per-iteration series data from the bundle to reduce file size.", + ) + + from scripts.benchmarks.early_stop import add_success_cli_args + + add_success_cli_args(parser, include_check_success=False) + add_isaaclab_launcher_args(parser) + + args_cli, remaining_args = setup_preset_cli(parser, argv) + enable_cameras_for_video(args_cli) + sys.argv = [sys.argv[0]] + remaining_args + + return args_cli, remaining_args + + +def run(argv: list[str]) -> None: + """Run the sb3 training benchmark and write a :class:`~isaaclab.test.benchmark.TrainingBundle`. + + Args: + argv: Command-line arguments, excluding the script path (i.e. ``sys.argv[1:]`` + after the dispatcher has stripped ``--rl_library``). + """ + imports_t0 = time.perf_counter_ns() + + import contextlib + import os + from datetime import datetime + + import numpy as np + from stable_baselines3 import PPO + from stable_baselines3.common.callbacks import CheckpointCallback + from stable_baselines3.common.vec_env import VecNormalize + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture + from isaaclab.test.benchmark.metrics import RL_LIBRARY_DESCRIPTORS, parse_tf_logs + from isaaclab.test.benchmark.schema import StartupTime + + from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg + + import isaaclab_tasks # noqa: F401 + + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + from isaaclab_tasks.utils import resolve_task_config + + apply_env_overrides = _common.apply_env_overrides + + from scripts.benchmarks.early_stop import ( + SuccessRateTrackerWrapper, + build_success_kwargs, + get_success_tracker, + success_measurements, + ) + + imports_t1 = time.perf_counter_ns() + + args_cli, remaining_args = _parse_args(argv) + + config_t0 = time.perf_counter_ns() + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + config_t1 = time.perf_counter_ns() + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args_cli): + app_t1 = time.perf_counter_ns() + + apply_env_overrides(args_cli, env_cfg) + + agent_cfg["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg.get("seed", 0) + env_cfg.seed = agent_cfg["seed"] + + # Convert the iteration override to SB3 total timesteps. + n_steps_cfg = agent_cfg.get("n_steps", 2048) + if args_cli.max_iterations is not None: + agent_cfg["n_timesteps"] = args_cli.max_iterations * n_steps_cfg * env_cfg.scene.num_envs + steps_per_iteration = env_cfg.scene.num_envs * n_steps_cfg + resolved_max_iterations = (int(agent_cfg["n_timesteps"]) + steps_per_iteration - 1) // steps_per_iteration + + cfg = capture.run_config_from_presets(remaining_args, env_cfg=env_cfg) + formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_training", + formatter_type=formatter_types, + output_path=args_cli.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_training_{args_cli.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args_cli.task}, + {"name": "seed", "data": agent_cfg["seed"]}, + {"name": "num_envs", "data": env_cfg.scene.num_envs}, + {"name": "max_iterations", "data": resolved_max_iterations}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + run_info = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + log_root_path = os.path.abspath(os.path.join("logs", "sb3", args_cli.task)) + log_dir = os.path.join(log_root_path, run_info) + _common.write_run_manifest(log_dir, library="sb3", task=args_cli.task, metadata={"agent": args_cli.agent}) + env_cfg.log_dir = log_dir + + agent_cfg = process_sb3_cfg(agent_cfg, env_cfg.scene.num_envs) + policy_arch = agent_cfg.pop("policy") + n_timesteps = agent_cfg.pop("n_timesteps") + + env_t0 = time.perf_counter_ns() + env = _common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) + env = _common.wrap_record_video(env, log_dir, args_cli) + env_t1 = time.perf_counter_ns() + success_kwargs = build_success_kwargs(args_cli) + success_context = SuccessRateTrackerWrapper( + env, + success_kwargs["threshold"], + success_kwargs["window"], + num_steps_per_env=n_steps_cfg, + ) + + env = Sb3VecEnvWrapper(env, fast_variant=not args_cli.keep_all_info) + + norm_keys = {"normalize_input", "normalize_value", "clip_obs"} + norm_args = {} + for key in norm_keys: + if key in agent_cfg: + norm_args[key] = agent_cfg.pop(key) + + if norm_args and norm_args.get("normalize_input"): + env = VecNormalize( + env, + training=True, + norm_obs=norm_args["normalize_input"], + norm_reward=norm_args.get("normalize_value", False), + clip_obs=norm_args.get("clip_obs", 100.0), + gamma=agent_cfg["gamma"], + clip_reward=np.inf, + ) + + agent = PPO(policy_arch, env, verbose=1, tensorboard_log=log_dir, **agent_cfg) + + if args_cli.checkpoint is not None: + agent = agent.load(args_cli.checkpoint, env, print_system_info=True) + + BenchmarkCallback = _build_benchmark_callback_class() + cb = BenchmarkCallback() + checkpoint_callback = CheckpointCallback(save_freq=1000, save_path=log_dir, name_prefix="model", verbose=2) + + with contextlib.suppress(KeyboardInterrupt), success_context, BenchmarkMonitor(benchmark, interval=1.0): + agent.learn( + total_timesteps=n_timesteps, + callback=[checkpoint_callback, cb], + progress_bar=False, + log_interval=None, + ) + agent.save(os.path.join(log_dir, "model")) + + benchmark.update_manual_recorders() + + collection_times_s = cb.collection_times_s + iteration_times_s = cb.iter_times_s + collection_fps = [steps_per_iteration / t for t in collection_times_s if t > 0] + total_fps = [steps_per_iteration / t for t in iteration_times_s if t > 0] + + # Filter out NaN entries from rollouts where no episode finished. + reward_series = [v for v in cb.ep_rew_mean if v == v] # NaN != NaN + ep_len_series = [v for v in cb.ep_len_mean if v == v] + if not reward_series and iteration_times_s: + print( + "[WARNING] sb3: no episodes completed during the benchmarked rollouts;" + " reward/episode-length curves are empty.", + file=sys.stderr, + ) + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(iteration_times_s[0] if iteration_times_s else 0.0), + python_imports=(imports_t1 - imports_t0) / 1e9, + task_config=(config_t1 - config_t0) / 1e9, + ) + + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=iteration_times_s, + collection_fps=collection_fps, + total_fps=total_fps, + steps_per_iteration=steps_per_iteration, + ) + + learning = builders.build_learning( + reward_series=reward_series, + ep_length_series=ep_len_series, + ema_alpha=args_cli.ema_alpha, + keep_series=not args_cli.no_series, + ) + + desc = RL_LIBRARY_DESCRIPTORS["sb3"] + log_data = parse_tf_logs(log_dir, desc.tfevents_pattern) + success_tracker = get_success_tracker(args_cli, success_context.tracker, log_data) + success_rate = round(success_tracker.tail_mean, 4) if (success_tracker and success_tracker.history) else None + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = env_cfg.seed if env_cfg.seed is not None else 0 + + run_identity = builders.build_run_identity( + run_id=capture.synth_run_id("sb3", cfg.physics_backend, args_cli.task, seed, stamp), + framework="sb3", + config=cfg, + task=args_cli.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=env.unwrapped.num_envs, + max_iterations=resolved_max_iterations, + ) + + checkpoint_path = os.path.join(log_dir, "model.zip") + video_path = os.path.join(log_dir, "videos") if getattr(args_cli, "video", False) else None + + bundle = builders.build_training_bundle( + run=run_identity, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + learning=learning, + success_rate=success_rate, + checkpoint_path=checkpoint_path, + video_path=video_path, + ) + + benchmark.attach_bundle(bundle) + benchmark.add_measurement("train", success_measurements(success_tracker)) + + benchmark._finalize_impl() + + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/skrl/benchmark_skrl_play.py b/scripts/benchmarks/skrl/benchmark_skrl_play.py new file mode 100644 index 00000000000..bf34b92f5d3 --- /dev/null +++ b/scripts/benchmarks/skrl/benchmark_skrl_play.py @@ -0,0 +1,292 @@ +# 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 + +"""SKRL play-benchmark adapter. + +Rolls out a checkpointed SKRL policy under a :class:`~isaaclab.test.benchmark.BenchmarkMonitor` +and emits a :class:`~isaaclab.test.benchmark.schema.PlayBundle` JSON file. Dispatched from +``scripts/benchmarks/play.py`` via ``--rl_library skrl``. +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +_BENCH_DIR = Path(__file__).resolve().parents[1] +_RL_SCRIPTS = _BENCH_DIR.parent / "reinforcement_learning" + +if str(_RL_SCRIPTS) not in sys.path: + # Shared training utilities remain script-local, so their directory must be on sys.path. + sys.path.insert(0, str(_RL_SCRIPTS)) + +import common as _common # noqa: E402 + + +def _parse_args(argv: list[str]): + """Parse CLI arguments and forward the remaining Hydra preset tokens via ``sys.argv``. + + Args: + argv: Raw command-line arguments (``sys.argv[1:]`` after dispatcher strips + ``--rl_library``). + + Returns: + Tuple of ``(parsed_args, remaining)`` where *remaining* are the verbatim Hydra + preset tokens written back to ``sys.argv`` for ``launch_simulation`` to pick up. + """ + import argparse + + from isaaclab_tasks.utils import setup_preset_cli + + add_isaaclab_launcher_args = _common.add_isaaclab_launcher_args + + parser = argparse.ArgumentParser(description="Benchmark RL inference (play) with SKRL.") + help_requested = "-h" in argv or "--help" in argv + parser.add_argument("--task", type=str, required=not help_requested, help="Gym task id to benchmark.") + parser.add_argument("--num_envs", type=int, default=None, help="Number of parallel environments.") + parser.add_argument("--num_frames", type=int, default=100, help="Number of inference steps to benchmark.") + parser.add_argument("--seed", type=int, default=None, help="Environment seed.") + parser.add_argument( + "--checkpoint", + type=str, + default=None, + help="Local or Nucleus checkpoint path to roll out; falls back to the published checkpoint when omitted.", + ) + parser.add_argument( + "--agent", + type=str, + default=None, + help=( + "Name of the RL agent configuration entry point. Defaults to None, in which" + " case --algorithm is used to determine the default agent entry point." + ), + ) + parser.add_argument( + "--ml_framework", + type=str, + default="torch", + choices=["torch", "jax"], + help="ML framework used for the skrl agent.", + ) + parser.add_argument( + "--algorithm", + type=str, + default="PPO", + choices=["AMP", "PPO", "IPPO", "MAPPO"], + help="The RL algorithm used for the skrl agent.", + ) + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'" + " Example: 'schema,omniperf'." + ), + ) + add_isaaclab_launcher_args(parser) + + args_cli, remaining_args = setup_preset_cli(parser, argv) + sys.argv = [sys.argv[0]] + remaining_args + + return args_cli, remaining_args + + +def run(argv: list[str]) -> None: + """Run the SKRL play benchmark and write a :class:`~isaaclab.test.benchmark.schema.PlayBundle`. + + Args: + argv: Command-line arguments, excluding the script path (i.e. ``sys.argv[1:]`` + after the dispatcher has stripped ``--rl_library``). + """ + import contextlib + import os + + import gymnasium as gym + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture, stepping + from isaaclab.test.benchmark.schema import StartupTime + + from isaaclab_rl.skrl import SkrlVecEnvWrapper + + # Importing the task packages registers their gym environments so the + # requested ``--task`` can be resolved. + import isaaclab_tasks # noqa: F401 + from isaaclab_tasks.utils import resolve_task_config + + # PLACEHOLDER: Extension template (do not remove this comment) + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + args_cli, remaining_args = _parse_args(argv) + + # Resolve agent entry point (mirrors scripts/reinforcement_learning/skrl/play.py). + if args_cli.agent is None: + algorithm = args_cli.algorithm.lower() + agent_cfg_entry_point = "skrl_cfg_entry_point" if algorithm == "ppo" else f"skrl_{algorithm}_cfg_entry_point" + else: + agent_cfg_entry_point = args_cli.agent + algorithm = agent_cfg_entry_point.split("_cfg")[0].split("skrl_")[-1].lower() + + env_cfg, agent_cfg = resolve_task_config(args_cli.task, agent_cfg_entry_point) + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args_cli): + app_t1 = time.perf_counter_ns() + + if args_cli.ml_framework.startswith("jax"): + import skrl + + skrl.config.jax.backend = "jax" if args_cli.ml_framework == "jax" else "numpy" + + if args_cli.num_envs is not None: + env_cfg.scene.num_envs = args_cli.num_envs + agent_cfg["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg.get("seed", 0) + env_cfg.seed = agent_cfg["seed"] + + log_root_path = os.path.abspath(os.path.join("logs", "skrl", agent_cfg["agent"]["experiment"]["directory"])) + if args_cli.checkpoint in _common.CHECKPOINT_SELECTORS: + resume_path = _common.resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="skrl", + task=args_cli.task, + checkpoint_pattern=r".*", + other_dirs=["checkpoints"], + metadata={ + "agent": agent_cfg_entry_point, + "algorithm": algorithm, + "ml_framework": args_cli.ml_framework, + }, + ) + else: + resume_path = _common.resolve_play_checkpoint(args_cli.checkpoint, "skrl", args_cli.task) + + cfg = capture.run_config_from_presets(remaining_args) + formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_play", + formatter_type=formatter_types, + output_path=args_cli.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_play_{args_cli.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args_cli.task}, + {"name": "num_envs", "data": args_cli.num_envs}, + {"name": "num_frames", "data": args_cli.num_frames}, + {"name": "algorithm", "data": args_cli.algorithm}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + env_t0 = time.perf_counter_ns() + env = gym.make(args_cli.task, cfg=env_cfg) + env_t1 = time.perf_counter_ns() + + env = SkrlVecEnvWrapper(env, ml_framework=args_cli.ml_framework) + + num_envs = env.unwrapped.num_envs + + if args_cli.ml_framework.startswith("torch"): + from skrl.utils.runner.torch import Runner + elif args_cli.ml_framework.startswith("jax"): + from skrl.utils.runner.jax import Runner + + # Load the trained policy the same way scripts/reinforcement_learning/skrl/play.py does. + agent_cfg["trainer"]["close_environment_at_exit"] = False + agent_cfg["agent"]["experiment"]["write_interval"] = 0 + agent_cfg["agent"]["experiment"]["checkpoint_interval"] = 0 + runner = Runner(env, agent_cfg) + runner.agent.load(resume_path) + runner.agent.enable_training_mode(False, apply_to_models=True) + + def policy(obs): + """Map an observation batch to a deterministic action batch via the skrl agent. + + Mirrors the inference path in ``scripts/reinforcement_learning/skrl/play.py``: + runs the agent's deterministic action, preferring the policy ``mean_actions`` + over the sampled action returned as the first element. + + Args: + obs: Observation returned by the skrl-wrapped env. + + Returns: + The action tensor to feed ``env.step``. + """ + states = env.state() + outputs = runner.agent.act(obs, states, timestep=0, timesteps=0) + return outputs[-1].get("mean_actions", outputs[0]) + + with BenchmarkMonitor(benchmark, interval=1.0): + step_times, reward, ep_length, success_rate = stepping.run_play_loop(env, policy, args_cli.num_frames) + + benchmark.update_manual_recorders() + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(step_times[0] if step_times else 0.0), + ) + + fps = [num_envs / t for t in step_times if t > 0] + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=step_times, + collection_fps=fps, + total_fps=fps, + steps_per_iteration=num_envs, + ) + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = agent_cfg["seed"] if agent_cfg.get("seed") is not None else 0 + + run_identity = builders.build_run_identity( + run_id=capture.synth_run_id("skrl", cfg.physics_backend, args_cli.task, seed, stamp), + framework="skrl", + config=cfg, + task=args_cli.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=num_envs, + ) + + bundle = builders.build_play_bundle( + run=run_identity, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + success_rate=success_rate, + reward=reward, + ep_length=ep_length, + checkpoint_path=resume_path, + ) + + benchmark.attach_bundle(bundle) + + benchmark._finalize_impl() + + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/skrl/benchmark_skrl_train.py b/scripts/benchmarks/skrl/benchmark_skrl_train.py new file mode 100644 index 00000000000..23ab7eb884e --- /dev/null +++ b/scripts/benchmarks/skrl/benchmark_skrl_train.py @@ -0,0 +1,433 @@ +# 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 + +"""SKRL adapter for the unified training benchmark.""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +_BENCH_DIR = Path(__file__).resolve().parents[1] +_RL_SCRIPTS = _BENCH_DIR.parent / "reinforcement_learning" + +# Shared training utilities remain script-local, so their directory must be on sys.path. +if str(_RL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_RL_SCRIPTS)) + +import common as _common # noqa: E402 + + +def _build_benchmark_trainer_class(): + """Create the trainer class after simulator startup to defer SKRL imports. + + Returns: + The ``BenchmarkTrainer`` class. + """ + from skrl.trainers.torch import SequentialTrainer + + from isaaclab.test.benchmark.metrics import RL_LIBRARY_DESCRIPTORS + + descriptor = RL_LIBRARY_DESCRIPTORS["skrl"] + + class BenchmarkTrainer(SequentialTrainer): + """SequentialTrainer that records per-iteration timing, reward, and episode length. + + Wraps ``agent.post_interaction`` to capture metrics at rollout boundaries. + + Attributes: + collection_times_s: Wall-clock seconds spent collecting each rollout. + iter_times_s: Wall-clock seconds per iteration (one rollout-buffer fill). + iter_rewards: Last observed mean completed-episode return per iteration. + iter_ep_lengths: Last observed mean episode length per iteration. + """ + + def __init__(self, env, agents, cfg=None) -> None: + super().__init__(env=env, agents=agents, cfg=cfg) + self.collection_times_s: list[float] = [] + self.iter_times_s: list[float] = [] + self.iter_rewards: list[float] = [] + self.iter_ep_lengths: list[float] = [] + + def train(self) -> None: + """Run training and record per-iteration metrics. + + Resolves the rollout boundary from ``agent.cfg.rollouts`` (skrl >= 2.x) + or ``agent._rollouts`` (skrl < 2.x). + """ + if self.num_simultaneous_agents > 1 or self.env.num_agents > 1: + raise ValueError("The SKRL training benchmark supports single-agent trainers only.") + + agent_obj = self.agents + agent_cfg = getattr(agent_obj, "cfg", None) + rollouts_val = ( + getattr(agent_cfg, "rollouts", None) if agent_cfg is not None else getattr(agent_obj, "_rollouts", None) + ) + if not rollouts_val: + raise ValueError( + "The SKRL agent does not expose a rollout length required for benchmark iteration metrics." + ) + + rollouts = int(rollouts_val) + timesteps = self.cfg.timesteps + max_iters = timesteps // rollouts + + _orig_post = agent_obj.post_interaction + _iter_start_ns: list[int] = [time.perf_counter_ns()] + + _last_reward: list[float] = [float("nan")] + _last_ep_len: list[float] = [float("nan")] + + def _patched_post(*, timestep: int, timesteps: int) -> None: + # post_interaction may flush and clear these values. + td = getattr(agent_obj, "tracking_data", {}) + reward_values = td.get(descriptor.reward_tag, ()) + ep_len_values = td.get(descriptor.ep_length_tag, ()) + if reward_values: + _last_reward[0] = float(sum(reward_values) / len(reward_values)) + if ep_len_values: + _last_ep_len[0] = float(sum(ep_len_values) / len(ep_len_values)) + + at_boundary = (timestep + 1) % rollouts == 0 + collection_end_ns = time.perf_counter_ns() if at_boundary else 0 + _orig_post(timestep=timestep, timesteps=timesteps) + + if at_boundary: + iter_end_ns = time.perf_counter_ns() + self.collection_times_s.append((collection_end_ns - _iter_start_ns[0]) / 1e9) + self.iter_times_s.append((iter_end_ns - _iter_start_ns[0]) / 1e9) + self.iter_rewards.append(_last_reward[0]) + self.iter_ep_lengths.append(_last_ep_len[0]) + _iter_start_ns[0] = time.perf_counter_ns() + + agent_obj.post_interaction = _patched_post + try: + super().train() + finally: + agent_obj.post_interaction = _orig_post + + self.collection_times_s = self.collection_times_s[:max_iters] + self.iter_times_s = self.iter_times_s[:max_iters] + self.iter_rewards = self.iter_rewards[:max_iters] + self.iter_ep_lengths = self.iter_ep_lengths[:max_iters] + + return BenchmarkTrainer + + +def _parse_args(argv: list[str]): + """Parse CLI arguments and forward the remaining Hydra preset tokens via ``sys.argv``. + + Args: + argv: Raw command-line arguments (``sys.argv[1:]`` after dispatcher + strips ``--rl_library``). + + Returns: + Tuple of ``(parsed_args, remaining)`` where *remaining* are the verbatim Hydra + preset tokens written back to ``sys.argv`` for ``launch_simulation`` to pick up. + """ + import argparse + + from isaaclab_tasks.utils import setup_preset_cli + + add_common_train_args = _common.add_common_train_args + add_isaaclab_launcher_args = _common.add_isaaclab_launcher_args + enable_cameras_for_video = _common.enable_cameras_for_video + + parser = argparse.ArgumentParser(description="Benchmark RL training with SKRL.") + add_common_train_args( + parser, + agent_default=None, + agent_help=( + "Name of the RL agent configuration entry point. Defaults to None, in which" + " case --algorithm is used to determine the default agent entry point." + ), + include_distributed=False, + ) + parser.add_argument( + "--ml_framework", + type=str, + default="torch", + choices=["torch"], + help="ML framework used for benchmark training.", + ) + parser.add_argument( + "--algorithm", + type=str, + default="PPO", + choices=["AMP", "PPO"], + help="RL algorithm used for benchmark training.", + ) + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'" + " Example: 'schema,omniperf'." + ), + ) + parser.add_argument( + "--ema_alpha", + type=float, + default=0.1, + help="EMA smoothing factor for learning curves (higher = more recent weight).", + ) + parser.add_argument( + "--no_series", + action="store_true", + default=False, + help="Omit per-iteration series data from the bundle to reduce file size.", + ) + + from scripts.benchmarks.early_stop import add_success_cli_args + + add_success_cli_args(parser, include_check_success=False) + add_isaaclab_launcher_args(parser) + + if "--distributed" in argv: + parser.error("Distributed training benchmarks are not supported.") + + args_cli, remaining_args = setup_preset_cli(parser, argv) + enable_cameras_for_video(args_cli) + sys.argv = [sys.argv[0]] + remaining_args + + return args_cli, remaining_args + + +def run(argv: list[str]) -> None: + """Run the SKRL training benchmark and write a :class:`~isaaclab.test.benchmark.TrainingBundle`. + + Args: + argv: Command-line arguments, excluding the script path (i.e. ``sys.argv[1:]`` + after the dispatcher has stripped ``--rl_library``). + """ + imports_t0 = time.perf_counter_ns() + + import contextlib + import os + + import torch + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture + from isaaclab.test.benchmark.metrics import RL_LIBRARY_DESCRIPTORS, parse_tf_logs + from isaaclab.test.benchmark.schema import StartupTime + + from isaaclab_rl.skrl import SkrlVecEnvWrapper + + import isaaclab_tasks # noqa: F401 + + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + from isaaclab_tasks.utils import resolve_task_config + + apply_env_overrides = _common.apply_env_overrides + from scripts.benchmarks.early_stop import ( + SuccessRateTrackerWrapper, + build_success_kwargs, + get_success_tracker, + success_measurements, + ) + + imports_t1 = time.perf_counter_ns() + + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + torch.backends.cudnn.deterministic = False + torch.backends.cudnn.benchmark = False + + args_cli, remaining_args = _parse_args(argv) + + # Derive the default agent entry point from the selected algorithm. + if args_cli.agent is None: + algorithm = args_cli.algorithm.lower() + agent_cfg_entry_point = "skrl_cfg_entry_point" if algorithm == "ppo" else f"skrl_{algorithm}_cfg_entry_point" + else: + agent_cfg_entry_point = args_cli.agent + algorithm = agent_cfg_entry_point.split("_cfg")[0].split("skrl_")[-1].lower() + + config_t0 = time.perf_counter_ns() + env_cfg, agent_cfg = resolve_task_config(args_cli.task, agent_cfg_entry_point) + config_t1 = time.perf_counter_ns() + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args_cli): + app_t1 = time.perf_counter_ns() + + apply_env_overrides(args_cli, env_cfg) + + rollouts = int(agent_cfg["agent"]["rollouts"]) + if args_cli.max_iterations is not None: + agent_cfg["trainer"]["timesteps"] = args_cli.max_iterations * rollouts + resolved_max_iterations = agent_cfg["trainer"]["timesteps"] // rollouts + agent_cfg["trainer"]["close_environment_at_exit"] = False + + agent_cfg["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg.get("seed", 0) + env_cfg.seed = agent_cfg["seed"] + + log_root_path = os.path.abspath(os.path.join("logs", "skrl", agent_cfg["agent"]["experiment"]["directory"])) + from datetime import datetime + + log_dir_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + f"_{algorithm}_{args_cli.ml_framework}" + if agent_cfg["agent"]["experiment"]["experiment_name"]: + log_dir_name += f"_{agent_cfg['agent']['experiment']['experiment_name']}" + agent_cfg["agent"]["experiment"]["directory"] = log_root_path + agent_cfg["agent"]["experiment"]["experiment_name"] = log_dir_name + log_dir = os.path.join(log_root_path, log_dir_name) + _common.write_run_manifest( + log_dir, + library="skrl", + task=args_cli.task, + metadata={"agent": agent_cfg_entry_point, "algorithm": algorithm, "ml_framework": args_cli.ml_framework}, + ) + + cfg = capture.run_config_from_presets(remaining_args, env_cfg=env_cfg) + formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_training", + formatter_type=formatter_types, + output_path=args_cli.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_training_{args_cli.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args_cli.task}, + {"name": "seed", "data": agent_cfg["seed"]}, + {"name": "num_envs", "data": env_cfg.scene.num_envs}, + {"name": "max_iterations", "data": resolved_max_iterations}, + {"name": "algorithm", "data": algorithm.upper()}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + env_cfg.log_dir = log_dir + + env_t0 = time.perf_counter_ns() + env = _common.create_isaaclab_env( + args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=algorithm == "ppo" + ) + env = _common.wrap_record_video(env, log_dir, args_cli) + env_t1 = time.perf_counter_ns() + success_kwargs = build_success_kwargs(args_cli) + success_context = SuccessRateTrackerWrapper( + env, + success_kwargs["threshold"], + success_kwargs["window"], + num_steps_per_env=rollouts, + ) + + env = SkrlVecEnvWrapper(env, ml_framework=args_cli.ml_framework) + + from skrl.utils.runner.torch import Runner + + BenchmarkTrainer = _build_benchmark_trainer_class() + + class _BenchmarkRunner(Runner): + """Runner that installs ``BenchmarkTrainer`` during initialization.""" + + def _generate_trainer(self, env, cfg, agent): + from skrl.trainers.torch import SequentialTrainerCfg + + trainer_cfg = SequentialTrainerCfg(**self._process_cfg(cfg["trainer"])) + return BenchmarkTrainer(env=env, agents=agent, cfg=trainer_cfg) + + runner = _BenchmarkRunner(env, agent_cfg) + bt = runner._trainer + + with success_context, BenchmarkMonitor(benchmark, interval=1.0): + runner.run() + + benchmark.update_manual_recorders() + + collection_times_s = list(bt.collection_times_s) + iter_times_s = list(bt.iter_times_s) + reward_series = [value for value in bt.iter_rewards if value == value] + ep_len_series = [value for value in bt.iter_ep_lengths if value == value] + + num_envs = env.unwrapped.num_envs + steps_per_iteration = num_envs * rollouts + collection_fps = [steps_per_iteration / value for value in collection_times_s if value > 0] + total_fps = [steps_per_iteration / value for value in iter_times_s if value > 0] + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(iter_times_s[0] if iter_times_s else 0.0), + python_imports=(imports_t1 - imports_t0) / 1e9, + task_config=(config_t1 - config_t0) / 1e9, + ) + + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=iter_times_s, + collection_fps=collection_fps, + total_fps=total_fps, + steps_per_iteration=steps_per_iteration, + ) + + learning = builders.build_learning( + reward_series=reward_series, + ep_length_series=ep_len_series, + ema_alpha=args_cli.ema_alpha, + keep_series=not args_cli.no_series, + ) + + desc = RL_LIBRARY_DESCRIPTORS["skrl"] + log_data = parse_tf_logs(log_dir, desc.tfevents_pattern) + success_tracker = get_success_tracker(args_cli, success_context.tracker, log_data) + success_rate = round(success_tracker.tail_mean, 4) if (success_tracker and success_tracker.history) else None + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = agent_cfg["seed"] if agent_cfg.get("seed") is not None else 0 + + run_identity = builders.build_run_identity( + run_id=capture.synth_run_id("skrl", cfg.physics_backend, args_cli.task, seed, stamp), + framework="skrl", + config=cfg, + task=args_cli.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=num_envs, + max_iterations=resolved_max_iterations, + ) + + bundle = builders.build_training_bundle( + run=run_identity, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + learning=learning, + success_rate=success_rate, + checkpoint_path=None, + video_path=os.path.join(log_dir, "videos") if args_cli.video else None, + ) + + benchmark.attach_bundle(bundle) + benchmark.add_measurement("train", success_measurements(success_tracker)) + + benchmark._finalize_impl() + + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/startup.py b/scripts/benchmarks/startup.py new file mode 100644 index 00000000000..82c88f9c7ec --- /dev/null +++ b/scripts/benchmarks/startup.py @@ -0,0 +1,318 @@ +# 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 + +r"""Profile Isaac Lab startup phases. + +Each phase runs in an independent ``cProfile`` session. The schema formatter emits +a :class:`~isaaclab.test.benchmark.StartupBundle`; other selected +formatters receive equivalent measurement phases. + +Profiled phases +--------------- +* **app_launch**: :func:`~isaaclab.app.launch_simulation` context entry + (simulation runtime initialization). +* **python_imports**: launcher, task registration, and runtime-library imports. +* **task_config**: :func:`~isaaclab_tasks.utils.resolve_task_config`. +* **env_creation**: :func:`gym.make` + ``env.reset()``. +* **first_step**: first ``env.step()`` call. + +Usage example:: + + uv run isaaclab benchmark startup \\ + --task Isaac-Cartpole-Direct \\ + --num_envs 16 \\ + presets=newton_mjwarp --headless +""" + +from __future__ import annotations + +import argparse +import cProfile +import os +import sys +import time +from datetime import datetime, timezone + +_START_UTC = datetime.now(timezone.utc).isoformat() +_VALID_PHASES = {"app_launch", "python_imports", "task_config", "env_creation", "first_step"} + + +def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + """Parse benchmark arguments and retain Hydra overrides in ``sys.argv``. + + Args: + argv: Command-line arguments excluding the script path. + + Returns: + Parsed arguments and the remaining Hydra overrides. + """ + from isaaclab.app import add_launcher_args + + from isaaclab_tasks.utils import setup_preset_cli + + parser = argparse.ArgumentParser(description="Profile Isaac Lab startup phases.") + parser.add_argument("--task", type=str, required=True, help="Gym task id to profile.") + parser.add_argument("--num_envs", type=int, default=None, help="Number of parallel environments.") + parser.add_argument("--seed", type=int, default=None, help="Environment seed.") + parser.add_argument( + "--top_n", + type=int, + default=None, + help="Number of top cProfile functions per phase (default: 5 with whitelist, 30 otherwise).", + ) + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'." + " Example: 'schema,omniperf'." + ), + ) + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--whitelist_config", + type=str, + default=None, + help="Path to YAML file with per-phase fnmatch patterns. Overrides --top_n for listed phases.", + ) + add_launcher_args(parser) + + args, remaining = setup_preset_cli(parser, argv) + sys.argv = [sys.argv[0]] + remaining + return args, remaining + + +def _load_whitelist(path: str | None) -> dict[str, list[str]]: + """Load and validate a startup profiling whitelist. + + Args: + path: Path to the whitelist YAML file, or ``None``. + + Returns: + Validated patterns keyed by startup phase. + """ + if path is None: + return {} + + import yaml + + try: + with open(path) as whitelist_file: + raw = yaml.safe_load(whitelist_file) + except OSError as exc: + print(f"[ERROR] Cannot read whitelist config '{path}': {exc}") + sys.exit(1) + except yaml.YAMLError as exc: + print(f"[ERROR] Invalid YAML in whitelist config '{path}': {exc}") + sys.exit(1) + + if raw is None: + return {} + if not isinstance(raw, dict): + print( + f"[ERROR] Whitelist config must be a YAML mapping (got {type(raw).__name__})." + " Expected format: phase_name: [pattern, ...]" + ) + sys.exit(1) + + unknown = set(raw) - _VALID_PHASES + if unknown: + print( + f"[WARNING] Whitelist config contains unknown phase(s): {unknown}. " + f"Valid phases: {_VALID_PHASES}. Check for typos." + ) + for phase_name, patterns in raw.items(): + if not isinstance(patterns, list) or not all(isinstance(pattern, str) for pattern in patterns): + print( + f"[ERROR] Whitelist phase '{phase_name}' must be a list of strings, " + f"got {type(patterns).__name__}. Check YAML formatting (use '- pattern' syntax)." + ) + sys.exit(1) + return raw + + +def _isaaclab_source_prefixes() -> list[str]: + """Return package paths included in function-level startup profiles.""" + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) + source_dir = os.path.join(repo_root, "source") + if not os.path.isdir(source_dir): + print( + f"[WARNING] IsaacLab source directory not found at '{source_dir}'. Function-level profiling will be empty." + ) + return [] + return [ + os.path.join(source_dir, directory) + for directory in os.listdir(source_dir) + if os.path.isdir(os.path.join(source_dir, directory)) + ] + + +def run(argv: list[str]) -> None: + """Run the startup benchmark and write the selected formatter outputs. + + Args: + argv: Command-line arguments excluding the script path. + """ + imports_profile = cProfile.Profile() + imports_time_begin = time.perf_counter_ns() + imports_profile.enable() + + args, hydra_args = _parse_args(argv) + + import gymnasium as gym + import torch + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, builders, capture, stepping + from isaaclab.test.benchmark.profiling import parse_cprofile_stats + from isaaclab.test.benchmark.schema import CProfileFunction, StartupPhase + + from isaaclab_tasks.utils import resolve_task_config + + imports_profile.disable() + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + imports_time_end = time.perf_counter_ns() + + task_config_profile = cProfile.Profile() + task_config_time_begin = time.perf_counter_ns() + task_config_profile.enable() + try: + env_cfg, _ = resolve_task_config(args.task, None) + finally: + task_config_profile.disable() + task_config_time_end = time.perf_counter_ns() + + source_prefixes = _isaaclab_source_prefixes() + whitelist = _load_whitelist(args.whitelist_config) + if args.top_n is None: + args.top_n = 5 if whitelist else 30 + + app_launch_profile = cProfile.Profile() + app_launch_time_begin = time.perf_counter_ns() + app_launch_profile.enable() + + with launch_simulation(env_cfg, args): + app_launch_profile.disable() + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + app_launch_time_end = time.perf_counter_ns() + + if args.num_envs is not None: + env_cfg.scene.num_envs = args.num_envs + if args.device is not None: + env_cfg.sim.device = args.device + if args.seed is not None: + env_cfg.seed = args.seed + + env = None + env_creation_profile = cProfile.Profile() + env_creation_time_begin = time.perf_counter_ns() + try: + env_creation_profile.enable() + try: + env = gym.make(args.task, cfg=env_cfg) + env.reset() + finally: + env_creation_profile.disable() + + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + env_creation_time_end = time.perf_counter_ns() + + actions = stepping.sample_random_actions(env) + + first_step_profile = cProfile.Profile() + first_step_time_begin = time.perf_counter_ns() + first_step_profile.enable() + try: + env.step(actions) + finally: + first_step_profile.disable() + + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + first_step_time_end = time.perf_counter_ns() + end_utc = capture.now_utc_iso() + + phase_profiles: dict[str, tuple[cProfile.Profile, float]] = { + "app_launch": (app_launch_profile, (app_launch_time_end - app_launch_time_begin) / 1e6), + "python_imports": (imports_profile, (imports_time_end - imports_time_begin) / 1e6), + "task_config": (task_config_profile, (task_config_time_end - task_config_time_begin) / 1e6), + "env_creation": (env_creation_profile, (env_creation_time_end - env_creation_time_begin) / 1e6), + "first_step": (first_step_profile, (first_step_time_end - first_step_time_begin) / 1e6), + } + + phases: dict[str, StartupPhase] = {} + for phase_name, (profile, wall_ms) in phase_profiles.items(): + functions = parse_cprofile_stats( + profile, source_prefixes, top_n=args.top_n, whitelist=whitelist.get(phase_name) + ) + phases[phase_name] = StartupPhase( + total_time_s=wall_ms / 1000.0, + top_functions=[ + CProfileFunction( + name=label, + own_time_s=own_ms / 1000.0, + cum_time_s=cumulative_ms / 1000.0, + calls=calls, + ) + for label, own_ms, cumulative_ms, calls in functions + ], + ) + + cfg = capture.run_config_from_presets(hydra_args, env_cfg=env_cfg) + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = args.seed if args.seed is not None else 0 + run_id = capture.synth_run_id(None, cfg.physics_backend, args.task, seed, stamp) + run_identity = builders.build_run_identity( + run_id=run_id, + framework=None, + config=cfg, + task=args.task, + seed=seed, + start_utc=_START_UTC, + end_utc=end_utc, + num_envs=None, + max_iterations=None, + ) + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_startup", + formatter_type=args.benchmark_formatter, + output_path=args.output_path, + use_recorders=True, + output_prefix=f"startup_{args.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args.task}, + {"name": "seed", "data": args.seed}, + {"name": "num_envs", "data": args.num_envs}, + {"name": "top_n", "data": args.top_n}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + benchmark.update_manual_recorders() + + bundle = builders.build_startup_bundle( + run=run_identity, + versions=capture.capture_versions(benchmark), + hardware=capture.capture_hardware(benchmark), + phases=phases, + top_n=args.top_n, + whitelist=args.whitelist_config, + ) + benchmark.attach_bundle(bundle) + benchmark._finalize_impl() + finally: + if env is not None: + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/test/conftest.py b/scripts/benchmarks/test/conftest.py new file mode 100644 index 00000000000..c41fd51a5ce --- /dev/null +++ b/scripts/benchmarks/test/conftest.py @@ -0,0 +1,31 @@ +# 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 + +"""Shared fixtures for the benchmark smoke tests.""" + +import json +from pathlib import Path + +import pytest + +_TRAINING_BUNDLE_KEYS = {"run", "versions", "hardware", "runtime", "resources", "learning"} + + +@pytest.fixture +def load_training_bundle(): + """Provide a loader for the schema bundle in a formatter output directory.""" + + def load(out_dir: Path) -> dict: + candidates = sorted(out_dir.glob("*.json")) + assert candidates, f"no *.json written to {out_dir}" + for path in candidates: + data = json.loads(path.read_text()) + if set(data) >= _TRAINING_BUNDLE_KEYS: + return data + pytest.fail( + f"no bundle in {out_dir} contained keys {_TRAINING_BUNDLE_KEYS}; found {[path.name for path in candidates]}" + ) + + return load diff --git a/scripts/benchmarks/test/test_benchmark_smoke.py b/scripts/benchmarks/test/test_benchmark_smoke.py new file mode 100644 index 00000000000..1b9f966a5a6 --- /dev/null +++ b/scripts/benchmarks/test/test_benchmark_smoke.py @@ -0,0 +1,140 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""End-to-end smoke tests for training and playing benchmarked policies.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] + +_TASK = "Isaac-Cartpole-Direct" +_PLAY_BUNDLE_KEYS = {"run", "versions", "hardware", "runtime", "resources"} + + +def _load_play_bundle(output_path: Path) -> dict: + """Load the schema play bundle from an output directory.""" + for path in output_path.glob("*.json"): + data = json.loads(path.read_text()) + if data.keys() >= _PLAY_BUNDLE_KEYS: + return data + pytest.fail(f"no play bundle found in {output_path}") + + +def _run(command: list[str]) -> None: + """Run a benchmark command and report its trailing output on failure.""" + result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=900) + if result.returncode != 0: + pytest.fail( + f"{Path(command[2]).name} rc={result.returncode}\n" + f"STDOUT:\n{result.stdout[-2000:]}\nSTDERR:\n{result.stderr[-2000:]}" + ) + + +@pytest.mark.parametrize( + ( + "library", + "num_envs", + "max_iterations", + "formatter", + "expect_reward_series", + "expect_success_rate", + "expect_checkpoint", + ), + [ + ("rl_games", 512, 20, "schema", True, False, False), + ("rsl_rl", 16, 20, "schema,omniperf", True, False, False), + ("sb3", 16, 70, "schema", False, True, True), + ("skrl", 16, 20, "schema", True, True, False), + ], +) +def test_training_and_play_write_bundles( + tmp_path, + load_training_bundle, + library: str, + num_envs: int, + max_iterations: int, + formatter: str, + expect_reward_series: bool, + expect_success_rate: bool, + expect_checkpoint: bool, +): + """Each RL library trains and plays a policy with benchmark output.""" + training_output = tmp_path / "training" + play_output = tmp_path / "play" + common_args = [ + "--rl_library", + library, + "--task", + _TASK, + "--num_envs", + str(num_envs), + "presets=newton_mjwarp", + "--headless", + "--benchmark_formatter", + formatter, + ] + + _run( + [ + str(ROOT / "isaaclab.sh"), + "-p", + "scripts/benchmarks/training.py", + *common_args[:6], + "--max_iterations", + str(max_iterations), + *common_args[6:], + "--output_path", + str(training_output), + ] + ) + training_data = load_training_bundle(training_output) + assert training_data["schema_version"] == "1.0" + assert training_data["run"]["config"]["physics_backend"] == "newton_mjwarp" + assert training_data["runtime"]["startup_time_s"]["python_imports"] > 0 + assert training_data["runtime"]["startup_time_s"]["task_config"] > 0 + assert 1 <= training_data["runtime"]["iterations_completed"] <= max_iterations + assert training_data["run"]["framework"] == library + assert training_data["runtime"]["total_fps"]["mean"] > 0 + assert training_data["learning"]["reward"]["series_per_iter"] is not None + assert training_data["learning"]["reward"]["final_ema"] is not None + if expect_reward_series: + assert len(training_data["learning"]["reward"]["series_per_iter"]) >= 1 + if expect_success_rate: + assert training_data["success_rate"] is not None + if expect_checkpoint: + assert Path(training_data["checkpoint_path"]).is_file() + + _run( + [ + str(ROOT / "isaaclab.sh"), + "-p", + "scripts/benchmarks/play.py", + *common_args, + "--num_frames", + "250", + "--checkpoint", + "latest", + "--output_path", + str(play_output), + ] + ) + play_data = _load_play_bundle(play_output) + assert play_data["run"]["framework"] == library + assert play_data["runtime"]["total_fps"]["mean"] > 0 + assert play_data["checkpoint_path"] + assert play_data["reward"] is not None + assert "mean" in play_data["reward"] + + if "omniperf" in formatter: + training_omniperf = json.loads(next(training_output.glob("*_omniperf.json")).read_text()) + play_omniperf = json.loads(next(play_output.glob("*_omniperf.json")).read_text()) + assert training_omniperf["runtime"]["Mean Total FPS"] == pytest.approx( + training_data["runtime"]["total_fps"]["mean"] + ) + assert play_omniperf["runtime"]["Mean Total FPS"] == pytest.approx(play_data["runtime"]["total_fps"]["mean"]) diff --git a/scripts/benchmarks/test/test_early_stop.py b/scripts/benchmarks/test/test_early_stop.py index b11231481da..cd0dfc6c2c1 100644 --- a/scripts/benchmarks/test/test_early_stop.py +++ b/scripts/benchmarks/test/test_early_stop.py @@ -11,6 +11,9 @@ import pytest +from isaaclab.test.benchmark.metrics import SUCCESS_RATE_LOG_TAGS + +from scripts.benchmarks import early_stop from scripts.benchmarks.early_stop import ( DEFAULT_SUCCESS_THRESHOLD, DEFAULT_SUCCESS_WINDOW, @@ -21,33 +24,9 @@ build_success_kwargs, get_success_tracker, ) -from scripts.benchmarks.utils import SUCCESS_RATE_LOG_TAGS, log_success DEFAULT_SUCCESS_TAG = SUCCESS_RATE_LOG_TAGS[0] -# -- fakes ------------------------------------------------------------------ - - -class _FakeTensor: - """Stand-in for ``torch.Tensor`` with only the ``.item()`` path exercised.""" - - def __init__(self, value: float): - self._value = value - - def item(self) -> float: - return self._value - - -class _FakeBenchmark: - def __init__(self): - self.measurements: list[tuple[str, str, object, str]] = [] - - def add_measurement(self, phase, measurement): - self.measurements.append((phase, measurement.name, measurement.value, measurement.unit)) - - def by_name(self, name: str): - return next(m for m in self.measurements if m[1] == name) - class _FakeLogger: def __init__(self, has_writer: bool = True): @@ -80,6 +59,17 @@ def step(self, actions): return (None, None, None, extras) +class _FakeGymEnv: + def __init__(self, extras_sequence): + self._seq = list(extras_sequence) + self.step_calls = 0 + + def step(self, actions): + extras = self._seq[self.step_calls] if self.step_calls < len(self._seq) else self._seq[-1] + self.step_calls += 1 + return (None, None, None, None, extras) + + class _FakeBaseObserver: def __init__(self): self.calls: list[str] = [] @@ -118,175 +108,8 @@ def _parser() -> argparse.ArgumentParser: return p -# -- SuccessRateTracker ----------------------------------------------------- - - -class TestSuccessRateTracker: - """Test cases for the per-iteration metric accumulator and convergence check.""" - - def test_records_metric_from_extras_log(self): - """Test that a present metric is accumulated into the iteration sum.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.9}}) - assert t._iter_sum == pytest.approx(0.9) - assert t._iter_count == 1 - - def test_ignores_missing_metric_key(self): - """Test that a foreign key in extras["log"] is ignored.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.record_step({"log": {"other": 1.0}}) - assert t._iter_count == 0 - - def test_missing_log_subdict_does_not_raise(self): - """Test that an extras dict without a "log" sub-dict is handled gracefully.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.record_step({}) - assert t._iter_count == 0 - assert t._step_count == 1 - - def test_tensor_value_uses_item_method(self): - """Test that tensor-like values are extracted via ``.item()``.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.record_step({"log": {DEFAULT_SUCCESS_TAG: _FakeTensor(0.7)}}) - assert t._iter_sum == pytest.approx(0.7) - - def test_step_count_increments_even_without_metric(self): - """Test that ``_step_count`` tracks every call regardless of metric presence.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.record_step({}) - t.record_step({"log": {"other": 1.0}}) - assert t._step_count == 2 - assert t._iter_count == 0 - - def test_end_iteration_averages_and_resets(self): - """Test that ``end_iteration`` averages recorded values and resets counters.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.4}}) - t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.6}}) - assert t.end_iteration() == pytest.approx(0.5) - assert t.history == [pytest.approx(0.5)] - assert t._iter_sum == 0.0 - assert t._iter_count == 0 - - def test_end_iteration_no_data_returns_none_without_recording(self): - """Test that ``end_iteration`` returns None and skips history append when no data was seen.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - assert t.end_iteration() is None - assert t.history == [] - - def test_at_iteration_boundary_respects_num_steps_per_env(self): - """Test that the boundary flag fires only after exactly ``num_steps_per_env`` calls.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - for _ in range(3): - t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.1}}) - assert t.at_iteration_boundary is False - t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.1}}) - assert t.at_iteration_boundary is True - - def test_at_iteration_boundary_false_when_num_steps_zero(self): - """Test that a post-hoc tracker (``num_steps_per_env=0``) never reports a boundary.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=0) - t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.1}}) - assert t.at_iteration_boundary is False - - def test_not_converged_when_history_shorter_than_window(self): - """Test that convergence is False when there aren't yet enough history entries.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.history = [0.9, 0.9] - assert t.converged is False - - def test_not_converged_when_history_empty(self): - """Test that convergence is False on a freshly-created tracker.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - assert t.history == [] - assert t.converged is False - - def test_converged_when_window_all_above_threshold(self): - """Test that convergence is True when the trailing window is all above threshold.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.history = [0.1, 0.9, 0.9, 0.9] - assert t.converged is True - - def test_converged_when_history_length_equals_window(self): - """Test the window boundary: history length == window (minimum qualifying case).""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.history = [0.9, 0.9, 0.9] - assert t.converged is True - - def test_converged_at_exact_threshold(self): - """Test the threshold boundary: values equal to the threshold satisfy ``>= threshold``.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.history = [0.5, 0.5, 0.5] - assert t.converged is True - - def test_not_converged_when_any_window_value_below(self): - """Test that a single sub-threshold value in the trailing window blocks convergence.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.history = [0.9, 0.9, 0.4] - assert t.converged is False - - def test_converged_with_window_of_one(self): - """Test the degenerate ``window=1`` case: only the last value matters.""" - t = SuccessRateTracker(0.5, 1, num_steps_per_env=4) - t.history = [0.1, 0.2, 0.9] - assert t.converged is True - t.history = [0.9, 0.9, 0.1] - assert t.converged is False - - def test_tail_mean_empty_history_is_zero(self): - """Test that ``tail_mean`` returns 0.0 for an empty history.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - assert t.tail_mean == 0.0 - - def test_tail_mean_shorter_than_window_uses_all_values(self): - """Test that ``tail_mean`` averages the full history when it's shorter than the window.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.history = [0.2, 0.4] - assert t.tail_mean == pytest.approx(0.3) - - def test_tail_mean_longer_than_window_uses_tail(self): - """Test that ``tail_mean`` averages only the last ``window`` entries.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.history = [0.9, 0.9, 0.1, 0.2, 0.3] - assert t.tail_mean == pytest.approx(0.2) - - def test_current_iteration_equals_history_length(self): - """Test that ``current_iteration`` reports the history length.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.history = [0.1, 0.2, 0.3] - assert t.current_iteration == 3 - - -# -- CLI helpers ------------------------------------------------------------ - - class TestCliHelpers: - """Test cases for the ``--success_*`` CLI registration and kwargs resolution.""" - - def test_defaults_parse_to_none_and_false(self): - """Test that unset args resolve to None / False.""" - args = _parser().parse_args([]) - assert args.check_success is False - assert args.success_threshold is None - assert args.success_window is None - - def test_overrides_parse(self): - """Test that explicit ``--success_*`` values round-trip through argparse.""" - args = _parser().parse_args( - [ - "--check_success", - "--success_threshold", - "0.75", - "--success_window", - "50", - ] - ) - assert args.check_success is True - assert args.success_threshold == 0.75 - assert args.success_window == 50 - def test_build_success_kwargs_uses_defaults_when_unset(self): - """Test that ``build_success_kwargs`` substitutes library defaults for unset args.""" kwargs = build_success_kwargs(_parser().parse_args([])) assert kwargs == { "threshold": DEFAULT_SUCCESS_THRESHOLD, @@ -295,7 +118,6 @@ def test_build_success_kwargs_uses_defaults_when_unset(self): } def test_build_success_kwargs_applies_overrides(self): - """Test that CLI overrides flow through into the kwargs dict.""" args = _parser().parse_args( [ "--check_success", @@ -311,48 +133,36 @@ def test_build_success_kwargs_applies_overrides(self): assert kwargs["stop_on_convergence"] is True def test_zero_threshold_is_respected_not_treated_as_unset(self): - """Test that ``--success_threshold 0`` is preserved (``is not None`` check, not truthy).""" args = _parser().parse_args(["--success_threshold", "0"]) assert build_success_kwargs(args)["threshold"] == 0.0 + def test_registration_can_exclude_early_stop(self): + parser = argparse.ArgumentParser() + add_success_cli_args(parser, include_check_success=False) -# -- get_success_tracker ---------------------------------------------------- + args = parser.parse_args(["--success_threshold", "0.2"]) + assert build_success_kwargs(args)["stop_on_convergence"] is False + with pytest.raises(SystemExit): + parser.parse_args(["--check_success"]) -class TestGetSuccessTracker: - """Test cases for the live-vs-post-hoc tracker resolution helper.""" +class TestGetSuccessTracker: def test_prefers_live_tracker_with_history(self): - """Test that a non-empty live tracker is returned as-is.""" live = SuccessRateTracker(0.5, 3, num_steps_per_env=4) live.history = [0.9, 0.9] assert get_success_tracker(_parser().parse_args([]), live, {}) is live - def test_falls_back_to_post_hoc_when_live_tracker_empty(self): - """Test that an empty live tracker falls back to TensorBoard replay.""" - live = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - log_data = {DEFAULT_SUCCESS_TAG: [0.1, 0.2, 0.3]} - result = get_success_tracker(_parser().parse_args([]), live, log_data) - assert result is not live - assert result.history == [pytest.approx(0.1), pytest.approx(0.2), pytest.approx(0.3)] - def test_falls_back_to_post_hoc_when_live_tracker_none(self): - """Test that a missing live tracker falls back to TensorBoard replay.""" log_data = {DEFAULT_SUCCESS_TAG: [0.5, 0.6, 0.7]} result = get_success_tracker(_parser().parse_args([]), None, log_data) assert result is not None assert result.history == [pytest.approx(0.5), pytest.approx(0.6), pytest.approx(0.7)] def test_returns_none_when_no_data_anywhere(self): - """Test that both sources missing resolves to ``None``.""" assert get_success_tracker(_parser().parse_args([]), None, {}) is None - def test_returns_none_when_tag_absent_from_log_data(self): - """Test that unrelated TensorBoard tags don't satisfy the fallback.""" - assert get_success_tracker(_parser().parse_args([]), None, {"Metrics/other": [1.0]}) is None - def test_post_hoc_honors_override_threshold_and_window(self): - """Test that CLI threshold/window overrides are applied to the post-hoc tracker.""" args = _parser().parse_args(["--success_threshold", "0.2", "--success_window", "2"]) log_data = {DEFAULT_SUCCESS_TAG: [0.3, 0.3]} result = get_success_tracker(args, None, log_data) @@ -360,36 +170,52 @@ def test_post_hoc_honors_override_threshold_and_window(self): assert result.window == 2 assert result.converged is True - def test_post_hoc_tracker_has_no_iteration_boundary(self): - """Test that post-hoc trackers use ``num_steps_per_env=0`` so ``at_iteration_boundary`` never fires.""" - result = get_success_tracker(_parser().parse_args([]), None, {DEFAULT_SUCCESS_TAG: [0.9]}) - assert result.num_steps_per_env == 0 - assert result.at_iteration_boundary is False + def test_success_measurements_preserve_flat_output_fields(self): + tracker = SuccessRateTracker(0.3, 2, num_steps_per_env=0) + tracker.history = [0.1, 0.2] + measurements = early_stop.success_measurements(tracker) -# -- RslRlEarlyStopWrapper -------------------------------------------------- + assert {measurement.name: measurement.value for measurement in measurements} == { + "Success Rate (tail mean)": 0.15, + "Success Converged At Iter": -1, + "Success Passed": 0, + } -class TestRslRlEarlyStopWrapper: - """Test cases for the rsl_rl env.step monkey-patch context manager.""" +class TestSuccessRateTrackerWrapper: + def test_tracks_raw_env_success_by_iteration(self): + env = _FakeGymEnv( + [ + {"log": {DEFAULT_SUCCESS_TAG: 0.2}}, + {"log": {DEFAULT_SUCCESS_TAG: 0.6}}, + {"log": {DEFAULT_SUCCESS_TAG: 0.8}}, + {"log": {DEFAULT_SUCCESS_TAG: 1.0}}, + ] + ) + wrapper = early_stop.SuccessRateTrackerWrapper(env, 0.5, 2, num_steps_per_env=2) + + with wrapper: + for _ in range(4): + env.step(None) + + assert wrapper.tracker.history == [pytest.approx(0.4), pytest.approx(0.9)] + +class TestRslRlEarlyStopWrapper: def test_records_every_step_and_restores_on_exit(self): - """Test that wrapped env.step records, and original step is restored on normal exit.""" env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 5) runner = _FakeRunner() with RslRlEarlyStopWrapper(env, runner, 0.5, 3, num_steps_per_env=2) as ctx: env.step(None) assert ctx.tracker._iter_sum == pytest.approx(0.9) - # after exit, env.step no longer routes through the tracker env.step(None) assert ctx.tracker._iter_sum == pytest.approx(0.9) assert env.step_calls == 2 def test_raises_and_cleans_up_on_convergence_by_default(self): - """Test that convergence triggers cleanup (checkpoint + flush) and suppresses the exception.""" env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 100) runner = _FakeRunner() - # num_steps_per_env=2, window=2 -> converges on step 4 (iter 2) with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2) as ctx: for _ in range(10): env.step(None) @@ -397,9 +223,11 @@ def test_raises_and_cleans_up_on_convergence_by_default(self): assert env.step_calls == 4 assert len(runner.saved) == 1 assert runner.logger.stopped is True + tracked_iterations = ctx.tracker.current_iteration + env.step(None) + assert ctx.tracker.current_iteration == tracked_iterations def test_does_not_raise_when_stop_on_convergence_false(self): - """Test that ``stop_on_convergence=False`` lets training run past convergence.""" env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 100) runner = _FakeRunner() with RslRlEarlyStopWrapper( @@ -418,52 +246,18 @@ def test_does_not_raise_when_stop_on_convergence_false(self): assert runner.logger.stopped is False def test_does_not_suppress_other_exceptions(self): - """Test that non-EarlyStopConverged exceptions propagate out of the ``with`` block.""" env = _FakeEnv([{"log": {}}]) runner = _FakeRunner() + wrapper = RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2) with pytest.raises(ValueError): - with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2): + with wrapper: raise ValueError("not an early stop") - - def test_env_step_restored_after_early_stop_exception(self): - """Test that env.step is unwrapped after an early-stop exception suppressed by __exit__.""" - env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 100) - runner = _FakeRunner() - with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2) as ctx: - for _ in range(10): - env.step(None) # converges & raises at step 4, suppressed - sum_at_exit = ctx.tracker._iter_sum - env.step(None) - assert ctx.tracker._iter_sum == sum_at_exit # post-exit step bypassed the tracker - - def test_env_step_restored_after_unrelated_exception(self): - """Test that env.step is unwrapped even when a non-EarlyStopConverged exception propagates.""" - env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 10) - runner = _FakeRunner() - try: - with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2) as ctx: - env.step(None) - raise ValueError("boom") - except ValueError: - pass - sum_at_exit = ctx.tracker._iter_sum env.step(None) - assert ctx.tracker._iter_sum == sum_at_exit - - def test_cleanup_not_called_on_unrelated_exceptions(self): - """Test that only EarlyStopConverged triggers checkpoint save + logger flush.""" - env = _FakeEnv([{"log": {}}]) - runner = _FakeRunner() - try: - with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2): - raise ValueError("boom") - except ValueError: - pass + assert wrapper.tracker._step_count == 0 assert runner.saved == [] assert runner.logger.stopped is False def test_cleanup_skipped_when_runner_has_no_writer(self): - """Test that cleanup skips both save and flush when ``runner.logger.writer`` is ``None``.""" env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 100) runner = _FakeRunner(has_writer=False) with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2): @@ -473,7 +267,6 @@ def test_cleanup_skipped_when_runner_has_no_writer(self): assert runner.logger.stopped is False def test_framework_iteration_count_reflects_runner(self): - """Test that the framework-counter property reports ``current_learning_iteration + 1``.""" env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.0}}]) runner = _FakeRunner() runner.current_learning_iteration = 42 @@ -481,14 +274,8 @@ def test_framework_iteration_count_reflects_runner(self): assert wrapper.framework_iteration_count == 43 -# -- RlGamesEarlyStopObserver ----------------------------------------------- - - class TestRlGamesEarlyStopObserver: - """Test cases for the rl_games AlgoObserver that tracks success and forces max_epochs.""" - def test_delegates_every_call_to_base(self): - """Test that all observer lifecycle calls are forwarded to the wrapped base observer.""" base = _FakeBaseObserver() obs = RlGamesEarlyStopObserver(base, 0.5, 3) obs.before_init("name", {}, "exp") @@ -507,50 +294,34 @@ def test_delegates_every_call_to_base(self): ] def test_tracker_uses_horizon_length_attribute(self): - """Test that the tracker pulls ``num_steps_per_env`` from ``algo.horizon_length`` when present.""" obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) obs.after_init(_FakeAlgo(horizon_length=24)) assert obs.tracker.num_steps_per_env == 24 def test_tracker_falls_back_to_config_horizon_length(self): - """Test that the tracker falls back to ``algo.config['horizon_length']`` when the attr is missing.""" obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) obs.after_init(_FakeAlgo(horizon_length=None, config_horizon=32)) assert obs.tracker.num_steps_per_env == 32 def test_process_infos_records_from_episode_key(self): - """Test that ``infos["episode"]`` is remapped to the tracker's extras["log"] shape.""" obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) obs.after_init(_FakeAlgo(horizon_length=2)) obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.8}}, []) assert obs.tracker._iter_sum == pytest.approx(0.8) - def test_process_infos_is_noop_before_after_init(self): - """Test that ``process_infos`` before ``after_init`` does not raise (tracker is None).""" - obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) - obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.8}}, []) - assert obs.tracker is None - - def test_process_infos_ignores_non_dict_infos(self): - """Test that non-dict ``infos`` are skipped gracefully without mutating the tracker.""" - obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) - obs.after_init(_FakeAlgo(horizon_length=2)) - obs.process_infos([], []) - assert obs.tracker._iter_sum == 0.0 - def test_after_steps_sets_max_epochs_on_convergence(self): - """Test that convergence on iteration N sets ``algo.max_epochs = N`` for clean exit.""" obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 2) algo = _FakeAlgo(horizon_length=1) obs.after_init(algo) obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) obs.after_steps() + assert algo.max_epochs == 999 + assert obs.tracker.current_iteration == 1 obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) obs.after_steps() assert algo.max_epochs == 2 def test_after_steps_leaves_max_epochs_alone_when_stop_disabled(self): - """Test that ``stop_on_convergence=False`` preserves the caller's ``algo.max_epochs``.""" obs = RlGamesEarlyStopObserver( _FakeBaseObserver(), 0.5, @@ -566,141 +337,8 @@ def test_after_steps_leaves_max_epochs_alone_when_stop_disabled(self): obs.after_steps() assert algo.max_epochs == original_max_epochs - def test_after_steps_noop_before_after_init(self): - """Test that ``after_steps`` before ``after_init`` does not raise (tracker is None).""" - obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 2) - obs.after_steps() - assert obs.tracker is None - - def test_each_after_steps_appends_one_iteration(self): - """Test that each ``after_steps`` call finalizes exactly one iteration in the tracker.""" - obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 5) - obs.after_init(_FakeAlgo(horizon_length=1)) - for i in range(4): - obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) - obs.after_steps() - assert obs.tracker.current_iteration == i + 1 - - def test_after_steps_does_not_converge_with_insufficient_history(self): - """Test that a trailing window shorter than ``window`` does not trigger early stop.""" - obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 5) - algo = _FakeAlgo(horizon_length=1) - obs.after_init(algo) - for _ in range(4): - obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) - obs.after_steps() - assert algo.max_epochs == 999 # unchanged: tracker.converged is still False - - def test_framework_iteration_count_returns_none_before_after_init(self): - """Test that the framework-counter property returns ``None`` before an algo is attached.""" - obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 2) - assert obs.framework_iteration_count is None - def test_framework_iteration_count_reflects_algo_epoch_num(self): - """Test that the framework-counter property mirrors ``algo.epoch_num``.""" obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 2) + assert obs.framework_iteration_count is None obs.after_init(_FakeAlgo(horizon_length=1, epoch_num=7)) assert obs.framework_iteration_count == 7 - - -# -- log_success (scripts.benchmarks.utils) --------------------------------- - - -class TestLogSuccess: - """Test cases for the benchmark-side success-metric logging helper.""" - - def _tracker_with(self, history: list[float]) -> SuccessRateTracker: - """Build a tracker with a pre-populated history for testing.""" - t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) - t.history = history - return t - - def test_noop_when_tracker_is_none(self): - """Test that ``log_success`` emits nothing when no tracker is supplied.""" - bench = _FakeBenchmark() - log_success(bench, None) - assert bench.measurements == [] - - def test_noop_when_history_empty(self): - """Test that an empty tracker history is a silent no-op.""" - bench = _FakeBenchmark() - log_success(bench, self._tracker_with([])) - assert bench.measurements == [] - - def test_logs_full_measurement_set(self): - """Test that a populated tracker produces the full measurement set.""" - bench = _FakeBenchmark() - log_success(bench, self._tracker_with([0.9, 0.9, 0.9])) - names = {m[1] for m in bench.measurements} - assert names == {"Success Rate (tail mean)", "Success Converged At Iter", "Success Passed"} - - def test_converged_path(self): - """Test that a converged run reports ``Passed=1`` with the true converged iter + tail mean.""" - bench = _FakeBenchmark() - log_success(bench, self._tracker_with([0.9, 0.9, 0.9])) - assert bench.by_name("Success Passed")[2] == 1 - assert bench.by_name("Success Converged At Iter")[2] == 3 - assert bench.by_name("Success Rate (tail mean)")[2] == pytest.approx(0.9) - - def test_failed_path(self): - """Test that a non-converged run reports ``Passed=0`` and ``Converged At Iter=-1``.""" - bench = _FakeBenchmark() - log_success(bench, self._tracker_with([0.1, 0.2, 0.3])) - assert bench.by_name("Success Passed")[2] == 0 - assert bench.by_name("Success Converged At Iter")[2] == -1 - - def test_cadence_warning_fires_on_cadence_violation(self, capsys): - """Test that a 2x tracker/framework ratio triggers the cadence warning.""" - bench = _FakeBenchmark() - log_success(bench, self._tracker_with([0.5] * 100), framework_iteration_count=50) - captured = capsys.readouterr().out - assert "[WARN]" in captured - assert "check record_step cadence" in captured - - def test_no_cadence_warning_on_exact_agreement(self, capsys): - """Test that an exact tracker-vs-framework match (rl_games case) is silent.""" - bench = _FakeBenchmark() - log_success(bench, self._tracker_with([0.5] * 50), framework_iteration_count=50) - assert "[WARN]" not in capsys.readouterr().out - - def test_no_cadence_warning_on_rsl_rl_early_stop_offset(self, capsys): - """Test that the rsl_rl early-stop +1 offset (tracker=51, framework=50) is within slack.""" - bench = _FakeBenchmark() - log_success(bench, self._tracker_with([0.5] * 51), framework_iteration_count=50) - assert "[WARN]" not in capsys.readouterr().out - - def test_no_cadence_warning_when_framework_count_not_provided(self, capsys): - """Test that the cadence check is skipped entirely when no framework count is supplied.""" - bench = _FakeBenchmark() - log_success(bench, self._tracker_with([0.5] * 999)) - assert "[WARN]" not in capsys.readouterr().out - - def test_cadence_violation_end_to_end_via_wrapper(self, capsys): - """Test that a simulated 2x env.step bug manifests as an overcounted tracker and is caught. - - The wrapper can't distinguish "2 env.step calls that should have been 1" from normal - traffic — but the tracker overcounts iterations by 2x, and comparing against the - runner's independent counter catches the discrepancy. - """ - env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.5}}] * 100) - runner = _FakeRunner() - runner.current_learning_iteration = 9 # rsl_rl thinks 10 iterations completed - with RslRlEarlyStopWrapper( - env, - runner, - 0.5, - 3, - num_steps_per_env=2, - stop_on_convergence=False, - ) as ctx: - # simulate the bug: upstream calls env.step 2x per real rollout step - for _ in range(10 * 2 * 2): # 10 iters * 2 steps/iter * 2x-bug - env.step(None) - # 40 calls with num_steps_per_env=2 => tracker.current_iteration = 20 - assert ctx.tracker.current_iteration == 20 - # framework's counter is independent: reports 10 iterations actually ran - assert ctx.framework_iteration_count == 10 - bench = _FakeBenchmark() - log_success(bench, ctx.tracker, framework_iteration_count=ctx.framework_iteration_count) - captured = capsys.readouterr().out - assert "[WARN]" in captured diff --git a/scripts/benchmarks/test/test_runtime_smoke.py b/scripts/benchmarks/test/test_runtime_smoke.py new file mode 100644 index 00000000000..92fa033a72b --- /dev/null +++ b/scripts/benchmarks/test/test_runtime_smoke.py @@ -0,0 +1,57 @@ +# 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 + +"""Smoke test for the runtime benchmark entry point.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] + +_TASK = "Isaac-Cartpole-Direct" + + +def test_runtime_writes_all_requested_formats(tmp_path): + """The runtime entry point writes schema and OmniPerf data in one run.""" + sh = ROOT / "isaaclab.sh" + cmd = [ + str(sh), + "-p", + "scripts/benchmarks/runtime.py", + "--task", + _TASK, + "--num_envs", + "16", + "--num_frames", + "20", + "--seed", + "0", + "--device", + "cpu", + "--output_path", + str(tmp_path), + "--benchmark_formatter", + "schema,omniperf", + "presets=newton_mjwarp", + "--headless", + ] + res = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True, timeout=900) + if res.returncode != 0: + pytest.fail(f"runtime.py rc={res.returncode}\nSTDOUT:\n{res.stdout[-2000:]}\nSTDERR:\n{res.stderr[-2000:]}") + + device_lines = [line for line in res.stdout.splitlines() if "Environment device" in line] + assert device_lines and device_lines[-1].endswith(": cpu"), f"unexpected device output: {device_lines}" + + files = sorted(tmp_path.glob("*.json")) + schema_files = [path for path in files if path.name.endswith("_schema.json")] + omniperf_files = [path for path in files if path.name.endswith("_omniperf.json")] + assert len(schema_files) == len(omniperf_files) == 1 + + schema_data = json.loads(schema_files[0].read_text()) + assert schema_data["run"]["config"]["physics_backend"] == "newton_mjwarp" + assert "runtime" in json.loads(omniperf_files[0].read_text()) diff --git a/scripts/benchmarks/test/test_startup_smoke.py b/scripts/benchmarks/test/test_startup_smoke.py new file mode 100644 index 00000000000..c9ceba61346 --- /dev/null +++ b/scripts/benchmarks/test/test_startup_smoke.py @@ -0,0 +1,65 @@ +# 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 + +"""Smoke test for the startup benchmark entry point.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] + +_TASK = "Isaac-Cartpole-Direct" + +_EXPECTED_PHASES = {"app_launch", "python_imports", "task_config", "env_creation", "first_step"} + + +def test_startup_writes_startup_bundle(tmp_path): + """The startup entry point profiles imports and writes both requested formats.""" + whitelist = tmp_path / "whitelist.yaml" + whitelist.write_text('python_imports:\n - "*isaaclab_tasks*importer:_walk_packages"\n') + + sh = ROOT / "isaaclab.sh" + cmd = [ + str(sh), + "-p", + "scripts/benchmarks/startup.py", + "--task", + _TASK, + "--num_envs", + "16", + "--seed", + "0", + "--top_n", + "30", + "--output_path", + str(tmp_path), + "--benchmark_formatter", + "schema,omniperf", + "--whitelist_config", + str(whitelist), + "presets=newton_mjwarp", + "--headless", + ] + res = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True, timeout=900) + if res.returncode != 0: + pytest.fail(f"startup.py rc={res.returncode}\nSTDOUT:\n{res.stdout[-2000:]}\nSTDERR:\n{res.stderr[-2000:]}") + + files = list(tmp_path.glob("*.json")) + schema_files = [path for path in files if path.name.endswith("_schema.json")] + omniperf_files = [path for path in files if path.name.endswith("_omniperf.json")] + assert len(schema_files) == len(omniperf_files) == 1 + + data = json.loads(schema_files[0].read_text()) + assert set(data["phases"]) == _EXPECTED_PHASES + assert data["config"]["whitelist"] == str(whitelist) + assert data["phases"]["python_imports"]["top_functions"] + + phase_total_s = sum(phase["total_time_s"] for phase in data["phases"].values()) + assert data["run"]["duration_s"] >= phase_total_s + assert data["run"]["duration_s"] - phase_total_s < 0.25 + assert set(json.loads(omniperf_files[0].read_text())) >= _EXPECTED_PHASES diff --git a/scripts/benchmarks/test/test_training_adapters.py b/scripts/benchmarks/test/test_training_adapters.py new file mode 100644 index 00000000000..5fc7056b177 --- /dev/null +++ b/scripts/benchmarks/test/test_training_adapters.py @@ -0,0 +1,125 @@ +# 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 + +"""Focused tests for RL-library benchmark adapter behavior.""" + +from types import SimpleNamespace + +import pytest +import torch + +from scripts.benchmarks import play, training +from scripts.benchmarks.rl_games import benchmark_rl_games_play as play_rl_games +from scripts.benchmarks.rsl_rl import benchmark_rsl_rl_play as play_rsl_rl +from scripts.benchmarks.rsl_rl import benchmark_rsl_rl_train as train_rsl_rl +from scripts.benchmarks.sb3 import benchmark_sb3_play as play_sb3 +from scripts.benchmarks.sb3 import benchmark_sb3_train as train_sb3 +from scripts.benchmarks.skrl import benchmark_skrl_play as play_skrl +from scripts.benchmarks.skrl import benchmark_skrl_train as train_skrl + + +@pytest.mark.parametrize("library", ["rl_games", "rsl_rl", "sb3", "skrl"]) +def test_training_dispatches_libraries_to_library_named_adapters(library: str): + """The training dispatcher uses the library-named benchmark adapter.""" + assert training.LIBRARY_ENTRYPOINTS[library].name == f"benchmark_{library}_train.py" + + +@pytest.mark.parametrize("library", ["rl_games", "rsl_rl", "sb3", "skrl"]) +def test_play_dispatches_libraries_to_library_named_adapters(library: str): + """The play dispatcher uses the library-named benchmark adapter.""" + assert play.LIBRARY_ENTRYPOINTS[library].name == f"benchmark_{library}_play.py" + + +@pytest.mark.parametrize("adapter", [play_rl_games, play_rsl_rl, play_sb3, play_skrl]) +def test_play_adapter_help_does_not_require_task(adapter): + """Play adapter help exits successfully without a task selection.""" + with pytest.raises(SystemExit) as exc_info: + adapter._parse_args(["--help"]) + assert exc_info.value.code == 0 + + +def test_rsl_rl_disables_code_state_capture(): + logger = SimpleNamespace(git_status_repos=["rsl_rl.py"]) + runner = SimpleNamespace(logger=logger) + + train_rsl_rl._disable_code_state_capture(runner) + + assert logger.git_status_repos == [] + + +def test_sb3_iteration_time_includes_policy_update(monkeypatch: pytest.MonkeyPatch): + """Test that SB3 reports collection time separately from the full training iteration.""" + timestamps = iter([1_000_000_000, 1_000_000_000, 3_000_000_000, 6_000_000_000]) + monkeypatch.setattr(train_sb3.time, "perf_counter_ns", lambda: next(timestamps)) + + callback = train_sb3._build_benchmark_callback_class()() + callback.model = SimpleNamespace(ep_info_buffer=[]) + callback._on_training_start() + callback._on_rollout_start() + callback._on_rollout_end() + callback._on_training_end() + + assert callback.collection_times_s == [2.0] + assert callback.iter_times_s == [5.0] + + +def test_skrl_reward_uses_episode_return_tracking(monkeypatch: pytest.MonkeyPatch): + """Test that SKRL records its canonical total-reward metric at rollout boundaries.""" + from skrl.trainers.torch import SequentialTrainer + + class FakeEnv: + num_agents = 1 + + def step(self, actions): + return None, torch.tensor([1.0]), None, None, {} + + class FakeAgent: + cfg = SimpleNamespace(rollouts=2) + + def __init__(self): + self.tracking_data = {} + + def post_interaction(self, *, timestep: int, timesteps: int) -> None: + self.tracking_data.clear() + + def run_two_steps(trainer) -> None: + for timestep in range(2): + trainer.env.step(None) + trainer.agents.tracking_data = { + "Reward / Total reward (mean)": [10.0, 20.0], + "Episode / Total timesteps (mean)": [5.0, 7.0], + } + trainer.agents.post_interaction(timestep=timestep, timesteps=2) + + monkeypatch.setattr(SequentialTrainer, "train", run_two_steps) + timestamps = iter([1_000_000_000, 3_000_000_000, 6_000_000_000, 6_000_000_000]) + monkeypatch.setattr(train_skrl.time, "perf_counter_ns", lambda: next(timestamps)) + trainer_class = train_skrl._build_benchmark_trainer_class() + trainer = trainer_class.__new__(trainer_class) + trainer.env = FakeEnv() + trainer.agents = FakeAgent() + trainer.cfg = SimpleNamespace(timesteps=2) + trainer.num_simultaneous_agents = 1 + trainer.collection_times_s = [] + trainer.iter_times_s = [] + trainer.iter_rewards = [] + trainer.iter_ep_lengths = [] + + trainer.train() + + assert trainer.collection_times_s == [2.0] + assert trainer.iter_times_s == [5.0] + assert trainer.iter_rewards == [15.0] + + +def test_skrl_parser_rejects_unimplemented_modes(): + """Test that SKRL rejects modes which cannot emit complete benchmark metrics.""" + unsupported = [("--ml_framework", "jax"), ("--algorithm", "IPPO")] + + for option, value in unsupported: + with pytest.raises(SystemExit): + train_skrl._parse_args(["--task", "unused", option, value]) + with pytest.raises(SystemExit): + train_skrl._parse_args(["--task", "unused", "--distributed"]) diff --git a/scripts/benchmarks/test/test_training_metrics.py b/scripts/benchmarks/test/test_training_metrics.py deleted file mode 100644 index 2e12673e05d..00000000000 --- a/scripts/benchmarks/test/test_training_metrics.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Unit tests for benchmark training-metric logging helpers.""" - -from __future__ import annotations - -import pytest - -from scripts.benchmarks.utils import SUCCESS_RATE_LOG_TAGS, log_rl_training_metrics - - -class _FakeBenchmark: - """Collect benchmark measurements without initializing benchmark backends.""" - - def __init__(self): - self.measurements: list[tuple[str, str, object, str]] = [] - - def add_measurement(self, phase, measurement): - self.measurements.append((phase, measurement.name, measurement.value, getattr(measurement, "unit", ""))) - - def measurement_by_name(self, name: str): - return next(m for m in self.measurements if m[1] == name) - - -@pytest.mark.parametrize( - "workflow,reward_tag,episode_length_tag", - [ - ("rl_games", "rewards/iter", "episode_lengths/iter"), - ("rsl_rl", "Train/mean_reward", "Train/mean_episode_length"), - ], -) -def test_log_rl_training_metrics_skips_missing_short_run_scalars( - workflow: str, reward_tag: str, episode_length_tag: str, capsys: pytest.CaptureFixture[str] -): - """Short benchmark runs may finish before reward and episode-length scalars are emitted.""" - benchmark = _FakeBenchmark() - - log_rl_training_metrics( - benchmark, - log_data={}, - reward_tag=reward_tag, - episode_length_tag=episode_length_tag, - task="Isaac-Ant", - workflow=workflow, - should_check_convergence=True, - ) - - assert benchmark.measurements == [] - output = capsys.readouterr().out - assert f"TensorBoard log is missing '{reward_tag}'" in output - assert f"TensorBoard log is missing '{episode_length_tag}'" in output - assert f"Cannot check convergence because '{reward_tag}' was not logged" in output - - -@pytest.mark.parametrize( - "workflow,reward_tag,episode_length_tag", - [ - ("rl_games", "rewards/iter", "episode_lengths/iter"), - ("rsl_rl", "Train/mean_reward", "Train/mean_episode_length"), - ], -) -def test_log_rl_training_metrics_logs_present_normal_run_scalars( - workflow: str, reward_tag: str, episode_length_tag: str, capsys: pytest.CaptureFixture[str] -): - """Normal runs with reward and episode-length scalars should log train metrics.""" - benchmark = _FakeBenchmark() - - log_rl_training_metrics( - benchmark, - log_data={ - reward_tag: [1.0, 2.0, 3.0], - episode_length_tag: [10.0, 11.0], - SUCCESS_RATE_LOG_TAGS[0]: [0.25, 0.5], - }, - reward_tag=reward_tag, - episode_length_tag=episode_length_tag, - task="Isaac-Ant", - workflow=workflow, - ) - - assert benchmark.measurement_by_name("Rewards")[2] == [1.0, 2.0, 3.0] - assert benchmark.measurement_by_name("Max Rewards")[2] == 3.0 - assert benchmark.measurement_by_name("Episode Lengths")[2] == [10.0, 11.0] - assert benchmark.measurement_by_name("Max Episode Lengths")[2] == 11.0 - assert benchmark.measurement_by_name("Success Rates")[2] == [0.25, 0.5] - assert benchmark.measurement_by_name("success_rate")[2] == 0.5 - assert "TensorBoard log is missing" not in capsys.readouterr().out diff --git a/scripts/benchmarks/training.py b/scripts/benchmarks/training.py new file mode 100644 index 00000000000..eeedd958d7f --- /dev/null +++ b/scripts/benchmarks/training.py @@ -0,0 +1,44 @@ +# 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 + +"""Dispatch training benchmarks to the selected RL library adapter.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPT_DIR.parents[1] +_RL_SCRIPTS = _SCRIPT_DIR.parent / "reinforcement_learning" +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +if str(_RL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_RL_SCRIPTS)) + +from common import dispatch_library_entrypoint # noqa: E402 + +LIBRARY_ENTRYPOINTS = { + "rsl_rl": _SCRIPT_DIR / "rsl_rl" / "benchmark_rsl_rl_train.py", + "rl_games": _SCRIPT_DIR / "rl_games" / "benchmark_rl_games_train.py", + "skrl": _SCRIPT_DIR / "skrl" / "benchmark_skrl_train.py", + "sb3": _SCRIPT_DIR / "sb3" / "benchmark_sb3_train.py", +} + + +def main(argv: list[str] | None = None) -> int: + """Dispatch to the selected RL library's training-benchmark adapter.""" + return dispatch_library_entrypoint( + argv, + LIBRARY_ENTRYPOINTS, + action="bench", + description="Benchmark RL training with a selected reinforcement learning library.", + library_help="Training library to benchmark.", + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/benchmarks/utils.py b/scripts/benchmarks/utils.py deleted file mode 100644 index 05effa52417..00000000000 --- a/scripts/benchmarks/utils.py +++ /dev/null @@ -1,453 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - - -import cProfile -import glob -import os -import statistics -import sys - -from tensorboard.backend.event_processing import event_accumulator - -from isaaclab.test.benchmark import BaseIsaacLabBenchmark, DictMeasurement, ListMeasurement, SingleMeasurement - -# Path to configs.yaml and the config loader. -_BENCHMARKING_DIR = os.path.join( - os.path.dirname(__file__), "..", "..", "source", "isaaclab_tasks", "test", "benchmarking" -) -_CONFIGS_YAML = os.path.join(_BENCHMARKING_DIR, "configs.yaml") -SUCCESS_RATE_LOG_TAGS = ("Metrics/success_rate", "Episode/Metrics/success_rate") - - -def get_backend_type(cli_backend: str) -> str: - """Map old CLI backend names to new backend types. - - Args: - cli_backend: The backend name from CLI arguments. - - Returns: - The new backend type string. - """ - mapping = { - "OmniPerfKPIFile": "omniperf", - "JSONFileMetrics": "json", - "OsmoKPIFile": "osmo", - "LocalLogMetrics": "json", - "omniperf": "omniperf", - "json": "json", - "osmo": "osmo", - "summary": "summary", - } - return mapping.get(cli_backend, "omniperf") - - -def parse_tf_logs(log_dir: str): - """Search for the latest tfevents file in log_dir folder and returns - the tensorboard logs in a dictionary. - - Args: - log_dir: directory used to search for tfevents files - """ - - # search log directory for latest log file - list_of_files = glob.glob(f"{log_dir}/events*") # * means all if need specific format then *.csv - latest_file = max(list_of_files, key=os.path.getctime) - - log_data = {} - ea = event_accumulator.EventAccumulator(latest_file) - ea.Reload() - tags = ea.Tags()["scalars"] - for tag in tags: - log_data[tag] = [] - for event in ea.Scalars(tag): - log_data[tag].append(event.value) - - return log_data - - -############################# -# logging benchmark metrics # -############################# - - -def log_min_max_mean_stats(benchmark: BaseIsaacLabBenchmark, values: dict): - for k, v in values.items(): - unit = "FPS" if "FPS" in k else "ms" if "Time" in k or "time" in k else "" - measurement = SingleMeasurement(name=f"Min {k}", value=min(v), unit=unit) - benchmark.add_measurement("runtime", measurement=measurement) - measurement = SingleMeasurement(name=f"Max {k}", value=max(v), unit=unit) - benchmark.add_measurement("runtime", measurement=measurement) - measurement = SingleMeasurement(name=f"Mean {k}", value=sum(v) / len(v), unit=unit) - benchmark.add_measurement("runtime", measurement=measurement) - - -def log_app_start_time(benchmark: BaseIsaacLabBenchmark, value: float): - measurement = SingleMeasurement(name="App Launch Time", value=value, unit="ms") - benchmark.add_measurement("startup", measurement=measurement) - - -def log_python_imports_time(benchmark: BaseIsaacLabBenchmark, value: float): - measurement = SingleMeasurement(name="Python Imports Time", value=value, unit="ms") - benchmark.add_measurement("startup", measurement=measurement) - - -def log_task_start_time(benchmark: BaseIsaacLabBenchmark, value: float): - measurement = SingleMeasurement(name="Task Creation and Start Time", value=value, unit="ms") - benchmark.add_measurement("startup", measurement=measurement) - - -def log_scene_creation_time(benchmark: BaseIsaacLabBenchmark, value: float): - measurement = SingleMeasurement(name="Scene Creation Time", value=value, unit="ms") - benchmark.add_measurement("startup", measurement=measurement) - - -def log_simulation_start_time(benchmark: BaseIsaacLabBenchmark, value: float): - measurement = SingleMeasurement(name="Simulation Start Time", value=value, unit="ms") - benchmark.add_measurement("startup", measurement=measurement) - - -def log_total_start_time(benchmark: BaseIsaacLabBenchmark, value: float): - measurement = SingleMeasurement(name="Total Start Time (Launch to Train)", value=value, unit="ms") - benchmark.add_measurement("startup", measurement=measurement) - - -def log_runtime_step_times(benchmark: BaseIsaacLabBenchmark, value: dict, compute_stats=True): - measurement = DictMeasurement(name="Step Frametimes", value=value) - benchmark.add_measurement("runtime", measurement=measurement) - if compute_stats: - log_min_max_mean_stats(benchmark, value) - - -def get_preset_string(hydra_args: list[str]) -> str: - """Extract the active preset string from CLI hydra args or an environment variable. - - Checks (in order): - 1. ``presets=...`` in *hydra_args* (e.g. ``presets=physx,ovrtx_renderer,rgb``) - 2. ``ISAACLAB_BENCHMARK_PRESET`` environment variable - 3. Falls back to ``"default"`` - """ - for arg in hydra_args: - if arg.startswith("presets="): - value = arg.split("=", 1)[1] - return value if value else "default" - return os.environ.get("ISAACLAB_BENCHMARK_PRESET", "") or "default" - - -def log_rl_policy_rewards(benchmark: BaseIsaacLabBenchmark, value: list): - measurement = ListMeasurement(name="Rewards", value=value) - benchmark.add_measurement("train", measurement=measurement) - # log max reward - measurement = SingleMeasurement(name="Max Rewards", value=max(value), unit="float") - benchmark.add_measurement("train", measurement=measurement) - - -def log_rl_policy_episode_lengths(benchmark: BaseIsaacLabBenchmark, value: list): - measurement = ListMeasurement(name="Episode Lengths", value=value) - benchmark.add_measurement("train", measurement=measurement) - # log max episode length - measurement = SingleMeasurement(name="Max Episode Lengths", value=max(value), unit="float") - benchmark.add_measurement("train", measurement=measurement) - - -def log_rl_policy_success_rates(benchmark: BaseIsaacLabBenchmark, value: list): - if not value: - return - measurement = ListMeasurement(name="Success Rates", value=value) - benchmark.add_measurement("train", measurement=measurement) - # Log the best observed success rate as a scalar for benchmark JSON backends. - measurement = SingleMeasurement(name="success_rate", value=max(value), unit="float") - benchmark.add_measurement("train", measurement=measurement) - - -def get_success_rate_log(log_data: dict) -> list | None: - for tag in SUCCESS_RATE_LOG_TAGS: - if tag in log_data: - return log_data[tag] - return None - - -def check_convergence( - rewards: list[float], - threshold: float, - window_pct: float = 0.2, - cv_threshold: float = 20.0, -) -> dict: - """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} - - -def log_convergence( - benchmark: BaseIsaacLabBenchmark, - rewards: list[float], - task: str, - workflow: str = "", - should_check_convergence: bool = False, - reward_threshold: float | None = None, - convergence_config: str = "full", -): - """Check reward convergence and log results to the benchmark backend. - - No-op unless *check_convergence* is True. When enabled, the threshold - is loaded from ``configs.yaml``. *reward_threshold* overrides the config. - - Args: - benchmark: Benchmark instance to log measurements to. - rewards: Per-iteration mean reward values. - task: Task name for config lookup. - workflow: RL workflow name (``rsl_rl``, ``rl_games``, etc.). - should_check_convergence: Whether ``--check_convergence`` was passed. - reward_threshold: Explicit threshold override. - convergence_config: Config section for threshold lookup (default: ``full``). - """ - if not should_check_convergence: - return - - threshold = reward_threshold - if threshold is None and os.path.exists(_CONFIGS_YAML): - if _BENCHMARKING_DIR not in sys.path: - sys.path.insert(0, _BENCHMARKING_DIR) - try: - from env_benchmark_test_utils import get_env_config, get_env_configs - - entry = get_env_config(get_env_configs(_CONFIGS_YAML), convergence_config, workflow, task) - except (ImportError, ValueError): - entry = None - if entry: - threshold = entry.get("lower_thresholds", {}).get("reward") - - if threshold is None: - print( - f"[WARNING] No reward threshold found for '{task}'" - f" in configs.yaml [{convergence_config}]. Skipping convergence check." - ) - return - - result = check_convergence(rewards, threshold) - benchmark.add_measurement( - "train", SingleMeasurement(name="Mean Reward (Converged)", value=result["tail_mean"], unit="float") - ) - benchmark.add_measurement("train", SingleMeasurement(name="Reward CV %", value=result["cv"], unit="%")) - benchmark.add_measurement( - "train", SingleMeasurement(name="Convergence Passed", value=int(result["passed"]), unit="bool") - ) - - -def log_success(benchmark, tracker, framework_iteration_count: int | None = None): - """Log success-metric results to the benchmark backend. - - Always logs the tag, tail mean, converged-at-iter, and pass/fail whenever the tracker holds - data (useful for historical comparison across runs). No-op when the tracker is ``None`` or - never recorded anything. - - Args: - benchmark: Benchmark instance. - tracker: :class:`SuccessRateTracker` from early_stop (or ``None`` if no tracker ran). - framework_iteration_count: Iterations the RL framework actually ran. When provided, emits a warning - if the tracker's count exceeds the framework's by more than 1. - """ - if tracker is None or not tracker.history: - return - - converged = tracker.converged - benchmark.add_measurement( - "train", SingleMeasurement(name="Success Rate (tail mean)", value=round(tracker.tail_mean, 4), unit="float") - ) - benchmark.add_measurement( - "train", - SingleMeasurement( - name="Success Converged At Iter", - value=tracker.current_iteration if converged else -1, - unit="int", - ), - ) - benchmark.add_measurement("train", SingleMeasurement(name="Success Passed", value=int(converged), unit="bool")) - - # +1 slack handles counters that lag behind during early-stop. - # Anything larger signals a broken record_step cadence (see SuccessRateTracker.at_iteration_boundary). - if framework_iteration_count is not None and tracker.current_iteration > framework_iteration_count + 1: - print( - f"[WARN] Success tracker logged {tracker.current_iteration} iterations vs framework's " - f"{framework_iteration_count}; check record_step cadence assumption." - ) - - -def log_rl_training_metrics( - benchmark: BaseIsaacLabBenchmark, - log_data: dict[str, list[float]], - reward_tag: str, - episode_length_tag: str, - task: str, - workflow: str, - should_check_convergence: bool = False, - reward_threshold: float | None = None, - convergence_config: str = "full", -) -> None: - """Log optional RL training metrics from TensorBoard data. - - Short smoke-test runs can finish before the RL framework emits reward or - episode-length scalars. Missing tags should skip those measurements instead - of failing the whole benchmark. - """ - rewards = log_data.get(reward_tag) - episode_lengths = log_data.get(episode_length_tag) - if rewards: - log_rl_policy_rewards(benchmark, rewards) - else: - print(f"[WARNING] TensorBoard log is missing '{reward_tag}'; skipping reward benchmark metrics.") - if episode_lengths: - log_rl_policy_episode_lengths(benchmark, episode_lengths) - else: - print(f"[WARNING] TensorBoard log is missing '{episode_length_tag}'; skipping episode-length metrics.") - - success_rates = get_success_rate_log(log_data) - if success_rates is not None: - log_rl_policy_success_rates(benchmark, success_rates) - - if rewards: - log_convergence( - benchmark, - rewards, - task, - workflow=workflow, - should_check_convergence=should_check_convergence, - reward_threshold=reward_threshold, - convergence_config=convergence_config, - ) - elif should_check_convergence: - print(f"[WARNING] Cannot check convergence because '{reward_tag}' was not logged.") - - -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]]: - """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)`` 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 (function_label, tottime_ms, cumtime_ms) tuples sorted by - tottime descending. - """ - import fnmatch - import io - import pstats - - 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 isaaclab_prefixes: - 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. - # stats.stats: dict[(filename, lineno, funcname)] -> (pcalls, ncalls, tottime, cumtime, callers) - # callers: dict[(filename, lineno, funcname)] -> (pcalls, ncalls, tottime, cumtime) - results = [] - for func_key, (_, _, 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)) - 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)) - 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]] = {} - matched_patterns: set[str] = set() - for label, tottime, cumtime in results: - for pattern in whitelist: - if fnmatch.fnmatch(label, pattern): - if label not in matched: - matched[label] = (label, tottime, cumtime) - matched_patterns.add(pattern) - - # Add 0.0 placeholders for patterns that matched nothing - for pattern in whitelist: - if pattern not in matched_patterns: - print( - f"[WARNING] Whitelist pattern '{pattern}' matched no profiled functions. " - "Check for typos or verify the function ran during this phase." - ) - matched[pattern] = (pattern, 0.0, 0.0) - - filtered = list(matched.values()) - filtered.sort(key=lambda x: x[1], reverse=True) - return filtered diff --git a/scripts/environments/teleoperation/teleop_replay_agent.py b/scripts/environments/teleoperation/teleop_replay_agent.py index d650e271404..8dac111c4b9 100644 --- a/scripts/environments/teleoperation/teleop_replay_agent.py +++ b/scripts/environments/teleoperation/teleop_replay_agent.py @@ -378,6 +378,10 @@ import gymnasium as gym import torch +from isaaclab_physx.renderers import IsaacRtxRendererGlobalSettingsCfg +from isaaclab_physx.renderers.isaac_rtx_renderer_utils import ( + apply_isaac_rtx_global_settings, +) from isaaclab_teleop import IsaacTeleopDevice, create_isaac_teleop_device, poll_control_events from isaaclab.devices.openxr import remove_camera_configs @@ -1038,7 +1042,9 @@ def _prepare_env_cfg(task: str, num_envs: int, device: str) -> tuple[ManagerBase if hasattr(env_cfg.terminations, "time_out"): env_cfg.terminations.time_out = None env_cfg = remove_camera_configs(env_cfg) - env_cfg.sim.render.antialiasing_mode = "DLSS" + apply_isaac_rtx_global_settings( + IsaacRtxRendererGlobalSettingsCfg(antialiasing_mode="DLSS"), + ) return env_cfg, success_term diff --git a/scripts/environments/teleoperation/teleop_se3_agent.py b/scripts/environments/teleoperation/teleop_se3_agent.py index f7db0a0fbd4..df738a02bd5 100644 --- a/scripts/environments/teleoperation/teleop_se3_agent.py +++ b/scripts/environments/teleoperation/teleop_se3_agent.py @@ -89,6 +89,10 @@ import gymnasium as gym import torch +from isaaclab_physx.renderers import IsaacRtxRendererGlobalSettingsCfg +from isaaclab_physx.renderers.isaac_rtx_renderer_utils import ( + apply_isaac_rtx_global_settings, +) from isaaclab.devices import Se3Gamepad, Se3GamepadCfg, Se3Keyboard, Se3KeyboardCfg, Se3SpaceMouse, Se3SpaceMouseCfg from isaaclab.devices.openxr import remove_camera_configs @@ -168,7 +172,9 @@ def main() -> None: if use_isaac_teleop or args_cli.xr: env_cfg = remove_camera_configs(env_cfg) - env_cfg.sim.render.antialiasing_mode = "DLSS" + apply_isaac_rtx_global_settings( + IsaacRtxRendererGlobalSettingsCfg(antialiasing_mode="DLSS"), + ) try: # create environment diff --git a/scripts/reinforcement_learning/common.py b/scripts/reinforcement_learning/common.py index 5c77889f448..4814c550043 100644 --- a/scripts/reinforcement_learning/common.py +++ b/scripts/reinforcement_learning/common.py @@ -9,21 +9,139 @@ import argparse import importlib.util +import json import logging import os +import re import runpy import sys +from datetime import datetime, timezone from pathlib import Path from types import ModuleType from typing import Any import gymnasium as gym +import torch +from PIL import Image -from isaaclab.app import add_launcher_args +from isaaclab.app import AppLauncher, 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" +RUN_MANIFEST_VERSION = 1 +CHECKPOINT_SELECTORS = frozenset({"latest", "best"}) +logger = logging.getLogger(__name__) + + +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, @@ -45,13 +163,18 @@ def dispatch_library_entrypoint( run_as_script: Whether to execute the selected implementation as a script. Returns: - Process exit code. + Process exit code. Returns ``0`` after printing selector help when + ``argv`` requests ``-h`` or ``--help`` without ``--rl_library``; + otherwise returns ``2`` when no library is selected. """ if argv is None: argv = sys.argv[1:] + # the per-library scripts parse this explicit list (not sys.argv), so the sys.argv + # fusing in AppLauncher.add_app_launcher_args never reaches it; normalize here too + argv = AppLauncher._fuse_kit_args(argv) parser = argparse.ArgumentParser(add_help=False) - parser.add_argument("--rl_library", choices=sorted(entrypoints), required=True) + parser.add_argument("--rl_library", choices=sorted(entrypoints)) args_cli, library_args = parser.parse_known_args(argv) if args_cli.rl_library is None: @@ -79,6 +202,37 @@ def dispatch_library_entrypoint( return 0 +def resolve_play_checkpoint(checkpoint: str | None, framework: str, task: str) -> str: + """Resolve an explicit or published checkpoint for a play workflow. + + Args: + checkpoint: Local or Nucleus checkpoint path. + framework: RL library name. + task: Gym task id; namespaces and a trailing ``-Play`` are ignored for published lookups. + + Returns: + Local checkpoint path. + + Raises: + FileNotFoundError: If no explicit or published checkpoint is available. + """ + if checkpoint: + from isaaclab.utils.assets import retrieve_file_path + + return retrieve_file_path(checkpoint) + + from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint + + logger.warning("No --checkpoint given; using the published checkpoint for %s / %s.", framework, task) + published_task = task.split(":")[-1].replace("-Play", "") + path = get_published_pretrained_checkpoint(framework, published_task) + if path is None: + raise FileNotFoundError( + f"No checkpoint available for framework {framework!r} and task {task!r}; pass --checkpoint" + ) + return path + + def add_common_train_args( parser: argparse.ArgumentParser, *, @@ -120,6 +274,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. @@ -131,12 +310,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 @@ -243,6 +422,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. @@ -268,6 +474,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. @@ -278,3 +491,128 @@ def dump_train_configs(log_dir: str, env_cfg: Any, agent_cfg: Any) -> None: """ dump_yaml(os.path.join(log_dir, "params", "env.yaml"), env_cfg) dump_yaml(os.path.join(log_dir, "params", "agent.yaml"), agent_cfg) + + +def write_run_manifest( + log_dir: str, + *, + library: str, + task: str, + metadata: dict[str, str] | None = None, +) -> None: + """Write metadata used to discover checkpoints from a training run. + + Args: + log_dir: Training run directory. + library: Reinforcement learning library that owns the run. + task: Task used for training. + metadata: Additional fields used to distinguish compatible runs. + """ + run_dir = Path(log_dir) + run_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "version": RUN_MANIFEST_VERSION, + "library": library, + "task": _normalize_task_name(task), + "created_at": datetime.now(timezone.utc).isoformat(), + "metadata": metadata or {}, + } + manifest_path = run_dir / RUN_MANIFEST_FILENAME + temporary_path = run_dir / f".{RUN_MANIFEST_FILENAME}.{os.getpid()}.tmp" + temporary_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(temporary_path, manifest_path) + + +def resolve_checkpoint_selector( + log_root_path: str, + selector: str, + *, + library: str, + task: str, + checkpoint_pattern: str, + other_dirs: list[str] | None = None, + preferred_checkpoint_pattern: str | None = None, + metadata: dict[str, str] | None = None, +) -> str: + """Resolve a checkpoint selector using manifests from new training runs. + + ``latest`` selects the naturally last checkpoint in the newest compatible + run. ``best`` prefers the backend's canonical best or final checkpoint and + falls back to the same checkpoint used by ``latest``. + + Args: + log_root_path: Directory containing training run directories. + selector: Checkpoint selector, either ``"latest"`` or ``"best"``. + library: Reinforcement learning library expected in the run manifest. + task: Task expected in the run manifest. + checkpoint_pattern: Regular expression matching checkpoint filenames. + other_dirs: Intermediate directories below each run directory. + preferred_checkpoint_pattern: Regular expression for the backend's best or final checkpoint. + metadata: Additional manifest metadata required for compatibility. + + Returns: + Absolute path to the selected checkpoint. + + Raises: + ValueError: If the selector is invalid or no compatible manifested run has a checkpoint. + """ + if selector not in CHECKPOINT_SELECTORS: + raise ValueError(f"Unknown checkpoint selector '{selector}'. Expected one of: {sorted(CHECKPOINT_SELECTORS)}.") + + log_root = Path(log_root_path) + expected_task = _normalize_task_name(task) + expected_metadata = metadata or {} + runs: list[tuple[datetime, Path]] = [] + if log_root.is_dir(): + for run_dir in log_root.iterdir(): + if not run_dir.is_dir(): + continue + manifest_path = run_dir / RUN_MANIFEST_FILENAME + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + created_at = datetime.fromisoformat(manifest["created_at"]) + except (FileNotFoundError, KeyError, TypeError, ValueError, json.JSONDecodeError): + continue + if manifest.get("version") != RUN_MANIFEST_VERSION: + continue + if manifest.get("library") != library or manifest.get("task") != expected_task: + continue + manifest_metadata = manifest.get("metadata", {}) + if not isinstance(manifest_metadata, dict): + continue + if any(manifest_metadata.get(key) != value for key, value in expected_metadata.items()): + continue + runs.append((created_at, run_dir)) + + for _, run_dir in sorted(runs, reverse=True): + checkpoint_dir = run_dir.joinpath(*(other_dirs or [])) + if not checkpoint_dir.is_dir(): + continue + checkpoints = [ + path for path in checkpoint_dir.iterdir() if path.is_file() and re.fullmatch(checkpoint_pattern, path.name) + ] + if not checkpoints: + continue + if selector == "best" and preferred_checkpoint_pattern is not None: + preferred = [ + path for path in checkpoints if re.fullmatch(preferred_checkpoint_pattern, path.name) is not None + ] + if preferred: + checkpoints = preferred + checkpoints.sort(key=lambda path: _natural_sort_key(path.name)) + return str(checkpoints[-1].resolve()) + + raise ValueError( + f"No compatible manifested run with a checkpoint was found in '{log_root}'. " + f"Run training with the current unified training entrypoint before using '--checkpoint {selector}'." + ) + + +def _normalize_task_name(task: str) -> str: + """Normalize training and play variants to the same task name.""" + return task.split(":")[-1].removesuffix("-Play") + + +def _natural_sort_key(value: str) -> list[int | str]: + """Return a key that sorts numeric filename components by value.""" + return [int(token) if token.isdigit() else token for token in re.split(r"(\d+)", value)] diff --git a/scripts/reinforcement_learning/rl_games/play_rl_games.py b/scripts/reinforcement_learning/rl_games/play_rl_games.py index 171f07730d3..e9fb02af9a1 100644 --- a/scripts/reinforcement_learning/rl_games/play_rl_games.py +++ b/scripts/reinforcement_learning/rl_games/play_rl_games.py @@ -10,11 +10,13 @@ import math import os import random +import re import sys import time import gymnasium as gym import torch +from common import CHECKPOINT_SELECTORS, resolve_checkpoint_selector from rl_games.common import env_configurations, vecenv from rl_games.common.player import BasePlayer from rl_games.torch_runner import Runner @@ -50,7 +52,7 @@ parser.add_argument( "--agent", type=str, default="rl_games_cfg_entry_point", help="Name of the RL agent configuration entry point." ) -parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint.") +parser.add_argument("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") parser.add_argument( "--use_pretrained_checkpoint", @@ -101,6 +103,18 @@ def main(): if not resume_path: print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") return + elif args_cli.checkpoint in CHECKPOINT_SELECTORS: + config_name = agent_cfg["params"]["config"]["name"] + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="rl_games", + task=train_task_name, + checkpoint_pattern=r".*\.pth", + other_dirs=["nn"], + preferred_checkpoint_pattern=rf"{re.escape(config_name)}\.pth", + metadata={"agent": args_cli.agent}, + ) elif args_cli.checkpoint is None: run_dir = agent_cfg["params"]["config"].get("full_experiment_name", ".*") # prefer the best-reward checkpoint (``.pth``); fall back to the latest checkpoint when it has diff --git a/scripts/reinforcement_learning/rl_games/train_rl_games.py b/scripts/reinforcement_learning/rl_games/train_rl_games.py index eb96f62ed87..a863b9a424b 100644 --- a/scripts/reinforcement_learning/rl_games/train_rl_games.py +++ b/scripts/reinforcement_learning/rl_games/train_rl_games.py @@ -13,11 +13,13 @@ import math import os import random +import re import time from datetime import datetime from distutils.util import strtobool from common import ( + CHECKPOINT_SELECTORS, add_common_train_args, add_isaaclab_launcher_args, apply_env_overrides, @@ -25,9 +27,11 @@ create_isaaclab_env, dump_train_configs, enable_cameras_for_video, + resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, + write_run_manifest, ) import isaaclab_tasks # noqa: F401 @@ -47,7 +51,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: agent_default="rl_games_cfg_entry_point", agent_help="Name of the RL agent configuration entry point.", ) - parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint.") + parser.add_argument("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument("--sigma", type=str, default=None, help="The policy's initial standard deviation.") parser.add_argument("--wandb-project-name", type=str, default=None, help="the wandb's project name") parser.add_argument("--wandb-entity", type=str, default=None, help="the entity (team) of wandb's project") @@ -101,7 +105,7 @@ def run(argv: list[str]) -> None: if args_cli.max_iterations is not None else agent_cfg["params"]["config"]["max_epochs"] ) - if args_cli.checkpoint is not None: + if args_cli.checkpoint is not None and args_cli.checkpoint not in CHECKPOINT_SELECTORS: resume_path = retrieve_file_path(args_cli.checkpoint) agent_cfg["params"]["load_checkpoint"] = True agent_cfg["params"]["load_path"] = resume_path @@ -124,6 +128,20 @@ def run(argv: list[str]) -> None: log_root_path = os.path.abspath(log_root_path) print(f"[INFO] Logging experiment in directory: {log_root_path}") + if args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="rl_games", + task=args_cli.task, + checkpoint_pattern=r".*\.pth", + other_dirs=["nn"], + preferred_checkpoint_pattern=rf"{re.escape(config_name)}\.pth", + metadata={"agent": args_cli.agent}, + ) + agent_cfg["params"]["load_checkpoint"] = True + agent_cfg["params"]["load_path"] = resume_path + print(f"[INFO]: Loading model checkpoint from: {resume_path}") log_dir = agent_cfg["params"]["config"].get( "full_experiment_name", datetime.now().strftime("%Y-%m-%d_%H-%M-%S") ) @@ -133,6 +151,12 @@ def run(argv: list[str]) -> None: experiment_name = log_dir if args_cli.wandb_name is None else args_cli.wandb_name run_log_dir = os.path.join(log_root_path, log_dir) + write_run_manifest( + run_log_dir, + library="rl_games", + task=args_cli.task, + metadata={"agent": args_cli.agent}, + ) dump_train_configs(run_log_dir, env_cfg, agent_cfg) print(f"Exact experiment name requested from command line: {run_log_dir}") @@ -151,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/cli_args.py b/scripts/reinforcement_learning/rsl_rl/cli_args.py index 10edbe2e17f..94fde0b707f 100644 --- a/scripts/reinforcement_learning/rsl_rl/cli_args.py +++ b/scripts/reinforcement_learning/rsl_rl/cli_args.py @@ -29,7 +29,7 @@ def add_rsl_rl_args(parser: argparse.ArgumentParser): # -- load arguments arg_group.add_argument("--resume", action="store_true", default=False, help="Whether to resume from a checkpoint.") arg_group.add_argument("--load_run", type=str, default=None, help="Name of the run folder to resume from.") - arg_group.add_argument("--checkpoint", type=str, default=None, help="Checkpoint file to resume from.") + arg_group.add_argument("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") # -- logger arguments arg_group.add_argument( "--logger", type=str, default=None, choices={"wandb", "tensorboard", "neptune"}, help="Logger module to use." diff --git a/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py b/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py index d4236186f65..6f04485f71a 100644 --- a/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py +++ b/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py @@ -14,6 +14,7 @@ import gymnasium as gym import torch +from common import CHECKPOINT_SELECTORS, resolve_checkpoint_selector from packaging import version from rsl_rl.runners import DistillationRunner, OnPolicyRunner @@ -120,6 +121,15 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen if not resume_path: print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") return + elif args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="rsl_rl", + task=train_task_name, + checkpoint_pattern=r"model_.*\.pt", + metadata={"agent": args_cli.agent}, + ) elif args_cli.checkpoint: resume_path = retrieve_file_path(args_cli.checkpoint) else: diff --git a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py index 69b26b1a4e8..8f5c797cad4 100644 --- a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py +++ b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py @@ -18,6 +18,7 @@ from pathlib import Path from common import ( + CHECKPOINT_SELECTORS, add_common_train_args, add_isaaclab_launcher_args, apply_env_overrides, @@ -26,9 +27,11 @@ dump_train_configs, enable_cameras_for_video, import_local_module, + resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, + write_run_manifest, ) from packaging import version @@ -143,6 +146,12 @@ def run(argv: list[str]) -> None: if agent_cfg.run_name: log_dir += f"_{agent_cfg.run_name}" log_dir = os.path.join(log_root_path, log_dir) + write_run_manifest( + log_dir, + library="rsl_rl", + task=args_cli.task, + metadata={"agent": args_cli.agent}, + ) configure_io_descriptors(env_cfg, args_cli, logger) env_cfg.log_dir = log_dir @@ -155,9 +164,19 @@ def run(argv: list[str]) -> None: ) if agent_cfg.resume or agent_cfg.algorithm.class_name == "Distillation": - 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) + if args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="rsl_rl", + task=args_cli.task, + checkpoint_pattern=r"model_.*\.pt", + metadata={"agent": args_cli.agent}, + ) + else: + resume_path = get_checkpoint_path(log_root_path, agent_cfg.load_run, agent_cfg.load_checkpoint) + + 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/play_sb3.py b/scripts/reinforcement_learning/sb3/play_sb3.py index bfbd07483f9..87af5950d24 100644 --- a/scripts/reinforcement_learning/sb3/play_sb3.py +++ b/scripts/reinforcement_learning/sb3/play_sb3.py @@ -15,6 +15,7 @@ import gymnasium as gym import torch +from common import CHECKPOINT_SELECTORS, resolve_checkpoint_selector from stable_baselines3 import PPO from stable_baselines3.common.vec_env import VecNormalize @@ -48,7 +49,7 @@ parser.add_argument( "--agent", type=str, default="sb3_cfg_entry_point", help="Name of the RL agent configuration entry point." ) -parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint.") +parser.add_argument("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") parser.add_argument( "--use_pretrained_checkpoint", @@ -97,6 +98,16 @@ def main(): if not checkpoint_path: print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") return + elif args_cli.checkpoint in CHECKPOINT_SELECTORS: + checkpoint_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="sb3", + task=train_task_name, + checkpoint_pattern=r"model(?:_.*)?\.zip", + preferred_checkpoint_pattern=r"model\.zip", + metadata={"agent": args_cli.agent}, + ) elif args_cli.checkpoint is None: # prefer the final model (``model.zip``); fall back to the latest periodic checkpoint when it has # not been written yet (e.g. short or interrupted runs) diff --git a/scripts/reinforcement_learning/sb3/train_sb3.py b/scripts/reinforcement_learning/sb3/train_sb3.py index 282c40a255d..52b73d75f38 100644 --- a/scripts/reinforcement_learning/sb3/train_sb3.py +++ b/scripts/reinforcement_learning/sb3/train_sb3.py @@ -19,6 +19,7 @@ from pathlib import Path from common import ( + CHECKPOINT_SELECTORS, add_common_train_args, add_isaaclab_launcher_args, apply_env_overrides, @@ -26,8 +27,10 @@ create_isaaclab_env, dump_train_configs, enable_cameras_for_video, + resolve_checkpoint_selector, set_hydra_args, - wrap_record_video, + wrap_training_capture, + write_run_manifest, ) import isaaclab_tasks # noqa: F401 @@ -60,7 +63,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: include_distributed=False, ) parser.add_argument("--log_interval", type=int, default=100_000, help="Log data every n timesteps.") - parser.add_argument("--checkpoint", type=str, default=None, help="Continue the training from checkpoint.") + parser.add_argument("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument( "--keep_all_info", action="store_true", @@ -113,6 +116,12 @@ def run(argv: list[str]) -> None: print(f"[INFO] Logging experiment in directory: {log_root_path}") print(f"Exact experiment name requested from command line: {run_info}") log_dir = os.path.join(log_root_path, run_info) + write_run_manifest( + log_dir, + library="sb3", + task=args_cli.task, + metadata={"agent": args_cli.agent}, + ) dump_train_configs(log_dir, env_cfg, agent_cfg) command = " ".join(sys.orig_argv) @@ -131,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) @@ -155,7 +164,18 @@ def run(argv: list[str]) -> None: ) agent = PPO(policy_arch, env, verbose=1, tensorboard_log=log_dir, **agent_cfg) - if args_cli.checkpoint is not None: + if args_cli.checkpoint in CHECKPOINT_SELECTORS: + checkpoint_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="sb3", + task=args_cli.task, + checkpoint_pattern=r"model(?:_.*)?\.zip", + preferred_checkpoint_pattern=r"model\.zip", + metadata={"agent": args_cli.agent}, + ) + agent = agent.load(checkpoint_path, env, print_system_info=True) + elif args_cli.checkpoint is not None: agent = agent.load(args_cli.checkpoint, env, print_system_info=True) checkpoint_callback = CheckpointCallback(save_freq=1000, save_path=log_dir, name_prefix="model", verbose=2) diff --git a/scripts/reinforcement_learning/skrl/play_skrl.py b/scripts/reinforcement_learning/skrl/play_skrl.py index 00775968819..49659285dca 100644 --- a/scripts/reinforcement_learning/skrl/play_skrl.py +++ b/scripts/reinforcement_learning/skrl/play_skrl.py @@ -20,6 +20,7 @@ import gymnasium as gym import skrl import torch +from common import CHECKPOINT_SELECTORS, resolve_checkpoint_selector from packaging import version from isaaclab.app import add_launcher_args, launch_simulation @@ -59,7 +60,7 @@ "--algorithm is used to determine the default agent configuration entry point." ), ) -parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint.") +parser.add_argument("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") parser.add_argument( "--use_pretrained_checkpoint", @@ -147,6 +148,20 @@ def main(): if not resume_path: print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") return + elif args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="skrl", + task=train_task_name, + checkpoint_pattern=r".*", + other_dirs=["checkpoints"], + metadata={ + "agent": agent_cfg_entry_point, + "algorithm": algorithm, + "ml_framework": args_cli.ml_framework, + }, + ) elif args_cli.checkpoint: resume_path = os.path.abspath(args_cli.checkpoint) else: diff --git a/scripts/reinforcement_learning/skrl/train_skrl.py b/scripts/reinforcement_learning/skrl/train_skrl.py index e63b4172f06..7ca754f6493 100644 --- a/scripts/reinforcement_learning/skrl/train_skrl.py +++ b/scripts/reinforcement_learning/skrl/train_skrl.py @@ -16,6 +16,7 @@ from datetime import datetime from common import ( + CHECKPOINT_SELECTORS, add_common_train_args, add_isaaclab_launcher_args, apply_env_overrides, @@ -23,9 +24,11 @@ create_isaaclab_env, dump_train_configs, enable_cameras_for_video, + resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, + write_run_manifest, ) from packaging import version @@ -51,7 +54,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: "--algorithm is used to determine the default agent configuration entry point." ), ) - parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint to resume training.") + parser.add_argument("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument( "--ml_framework", type=str, @@ -155,10 +158,35 @@ def run(argv: list[str]) -> None: agent_cfg["agent"]["experiment"]["directory"] = log_root_path agent_cfg["agent"]["experiment"]["experiment_name"] = log_dir log_dir = os.path.join(log_root_path, log_dir) + write_run_manifest( + log_dir, + library="skrl", + task=args_cli.task, + metadata={ + "agent": agent_cfg_entry_point, + "algorithm": algorithm, + "ml_framework": args_cli.ml_framework, + }, + ) dump_train_configs(log_dir, env_cfg, agent_cfg) - resume_path = retrieve_file_path(args_cli.checkpoint) if args_cli.checkpoint else None + if args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="skrl", + task=args_cli.task, + checkpoint_pattern=r".*", + other_dirs=["checkpoints"], + metadata={ + "agent": agent_cfg_entry_point, + "algorithm": algorithm, + "ml_framework": args_cli.ml_framework, + }, + ) + else: + resume_path = retrieve_file_path(args_cli.checkpoint) if args_cli.checkpoint else None configure_io_descriptors(env_cfg, args_cli, logger) env_cfg.log_dir = log_dir @@ -169,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/scripts/reinforcement_learning/test/test_checkpoint_manifest.py b/scripts/reinforcement_learning/test/test_checkpoint_manifest.py new file mode 100644 index 00000000000..9aa3d6548dc --- /dev/null +++ b/scripts/reinforcement_learning/test/test_checkpoint_manifest.py @@ -0,0 +1,133 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +RL_SCRIPTS_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RL_SCRIPTS_DIR)) + +from common import RUN_MANIFEST_FILENAME, resolve_checkpoint_selector, write_run_manifest # noqa: E402 + + +def _set_created_at(run_dir: Path, created_at: str) -> None: + manifest_path = run_dir / RUN_MANIFEST_FILENAME + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["created_at"] = created_at + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + +def test_write_run_manifest_records_normalized_run_identity(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + + write_run_manifest( + str(run_dir), + library="rsl_rl", + task="example:Isaac-Cartpole-Direct-Play", + metadata={"agent": "rsl_rl_cfg_entry_point"}, + ) + + manifest = json.loads((run_dir / RUN_MANIFEST_FILENAME).read_text(encoding="utf-8")) + assert manifest["version"] == 1 + assert manifest["library"] == "rsl_rl" + assert manifest["task"] == "Isaac-Cartpole-Direct" + assert manifest["metadata"] == {"agent": "rsl_rl_cfg_entry_point"} + assert manifest["created_at"] + + +def test_latest_selects_naturally_last_checkpoint_from_newest_compatible_run(tmp_path: Path) -> None: + old_run = tmp_path / "old" + new_run = tmp_path / "new" + incompatible_run = tmp_path / "incompatible" + for run_dir in (old_run, new_run, incompatible_run): + (run_dir / "checkpoints").mkdir(parents=True) + + write_run_manifest(str(old_run), library="skrl", task="Isaac-Cartpole", metadata={"algorithm": "ppo"}) + write_run_manifest(str(new_run), library="skrl", task="Isaac-Cartpole", metadata={"algorithm": "ppo"}) + write_run_manifest(str(incompatible_run), library="skrl", task="Isaac-Cartpole", metadata={"algorithm": "mappo"}) + _set_created_at(old_run, "2026-01-01T00:00:00+00:00") + _set_created_at(new_run, "2026-01-02T00:00:00+00:00") + _set_created_at(incompatible_run, "2026-01-03T00:00:00+00:00") + + (old_run / "checkpoints" / "agent_100.pt").touch() + (new_run / "checkpoints" / "agent_9.pt").touch() + expected = new_run / "checkpoints" / "agent_10.pt" + expected.touch() + (incompatible_run / "checkpoints" / "agent_20.pt").touch() + + checkpoint = resolve_checkpoint_selector( + str(tmp_path), + "latest", + library="skrl", + task="Isaac-Cartpole-Play", + checkpoint_pattern=r".*\.pt", + other_dirs=["checkpoints"], + metadata={"algorithm": "ppo"}, + ) + + assert checkpoint == str(expected.resolve()) + + +def test_best_prefers_final_checkpoint(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + run_dir.mkdir() + write_run_manifest(str(run_dir), library="sb3", task="Isaac-Cartpole") + (run_dir / "model_2000_steps.zip").touch() + expected = run_dir / "model.zip" + expected.touch() + + checkpoint = resolve_checkpoint_selector( + str(tmp_path), + "best", + library="sb3", + task="Isaac-Cartpole", + checkpoint_pattern=r"model(?:_.*)?\.zip", + preferred_checkpoint_pattern=r"model\.zip", + ) + + assert checkpoint == str(expected.resolve()) + + +def test_latest_skips_newer_run_without_checkpoint(tmp_path: Path) -> None: + complete_run = tmp_path / "complete" + incomplete_run = tmp_path / "incomplete" + complete_run.mkdir() + incomplete_run.mkdir() + write_run_manifest(str(complete_run), library="rsl_rl", task="Isaac-Cartpole") + write_run_manifest(str(incomplete_run), library="rsl_rl", task="Isaac-Cartpole") + _set_created_at(complete_run, "2026-01-01T00:00:00+00:00") + _set_created_at(incomplete_run, "2026-01-02T00:00:00+00:00") + expected = complete_run / "model_10.pt" + expected.touch() + + checkpoint = resolve_checkpoint_selector( + str(tmp_path), + "latest", + library="rsl_rl", + task="Isaac-Cartpole", + checkpoint_pattern=r"model_.*\.pt", + ) + + assert checkpoint == str(expected.resolve()) + + +def test_latest_rejects_unmanifested_historical_run(tmp_path: Path) -> None: + historical_run = tmp_path / "2025-01-01_00-00-00" + historical_run.mkdir() + (historical_run / "model_10.pt").touch() + + with pytest.raises(ValueError, match="current unified training entrypoint"): + resolve_checkpoint_selector( + str(tmp_path), + "latest", + library="rsl_rl", + task="Isaac-Cartpole", + checkpoint_pattern=r"model_.*\.pt", + ) diff --git a/scripts/tools/record_demos.py b/scripts/tools/record_demos.py index dbfdec95c30..48932746fd8 100644 --- a/scripts/tools/record_demos.py +++ b/scripts/tools/record_demos.py @@ -131,6 +131,10 @@ import gymnasium as gym import torch +from isaaclab_physx.renderers import IsaacRtxRendererGlobalSettingsCfg +from isaaclab_physx.renderers.isaac_rtx_renderer_utils import ( + apply_isaac_rtx_global_settings, +) import omni.ui as ui @@ -276,7 +280,9 @@ def create_environment_config( # If cameras are not enabled and XR is enabled, remove camera configs if not args_cli.enable_cameras: env_cfg = remove_camera_configs(env_cfg) - env_cfg.sim.render.antialiasing_mode = "DLSS" + apply_isaac_rtx_global_settings( + IsaacRtxRendererGlobalSettingsCfg(antialiasing_mode="DLSS"), + ) # modify configuration such that the environment runs indefinitely until # the goal is reached or other termination conditions are met diff --git a/scripts/tutorials/00_sim/set_rendering_mode.py b/scripts/tutorials/00_sim/set_rendering_mode.py deleted file mode 100644 index d0feb87dee3..00000000000 --- a/scripts/tutorials/00_sim/set_rendering_mode.py +++ /dev/null @@ -1,93 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""This script demonstrates how to spawn prims into the scene. - -.. code-block:: bash - - # Usage - ./isaaclab.sh -p scripts/tutorials/00_sim/set_rendering_mode.py - -""" - -"""Launch Isaac Sim Simulator first.""" - - -import argparse - -from isaaclab.app import AppLauncher - -# create argparser -parser = argparse.ArgumentParser( - description="Tutorial on viewing a warehouse scene with a given rendering mode preset." -) -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -# tutorials should open Kit visualizer by default -parser.set_defaults(visualizer=["kit"]) -# parse the arguments -args_cli = parser.parse_args() -# launch omniverse app -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - -import isaaclab.sim as sim_utils -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR - - -def add_remote_usd_reference(prim_path: str, usd_path: str) -> None: - """Add a remote USD reference without localizing it through the temp asset cache.""" - stage = sim_utils.get_current_stage() - prim = stage.DefinePrim(prim_path, "Xform") - if not prim.GetReferences().AddReference(usd_path): - raise RuntimeError(f"Unable to add USD reference to '{prim_path}' from '{usd_path}'.") - - -def main(): - """Main function.""" - - # rendering modes include performance, balanced, and quality - # note, the rendering_mode specified in the CLI argument (--rendering_mode) takes precedence over - # this Render Config setting - rendering_mode = "balanced" - - # carb setting dictionary can include any rtx carb setting which will overwrite the native preset setting - carb_settings = {"rtx.reflections.enabled": True} - - # Initialize render config - render_cfg = sim_utils.RenderCfg( - rendering_mode=rendering_mode, - carb_settings=carb_settings, - ) - - # Initialize the simulation context with render coofig - sim_cfg = sim_utils.SimulationCfg(render=render_cfg) - sim = sim_utils.SimulationContext(sim_cfg) - - # Pose camera in the hospital lobby area - sim.set_camera_view([-11, -0.5, 2], [0, 0, 0.5]) - - # Load hospital scene - hospital_usd_path = f"{ISAAC_NUCLEUS_DIR}/Environments/Hospital/hospital.usd" - add_remote_usd_reference("/Scene", hospital_usd_path) - - # Play the simulator - sim.reset() - - # Now we are ready! - print("[INFO]: Setup complete...") - - # Run simulation and view scene - while simulation_app.is_running(): - sim.step() - - -if __name__ == "__main__": - # run the main function - main() - # close sim app - simulation_app.close() diff --git a/scripts/tutorials/04_sensors/run_usd_camera.py b/scripts/tutorials/04_sensors/run_usd_camera.py index 941a34bc7df..ab7b59a2c3d 100644 --- a/scripts/tutorials/04_sensors/run_usd_camera.py +++ b/scripts/tutorials/04_sensors/run_usd_camera.py @@ -14,8 +14,8 @@ # Usage with GUI ./isaaclab.sh -p scripts/tutorials/04_sensors/run_usd_camera.py --enable_cameras --viz kit - # Usage with headless - ./isaaclab.sh -p scripts/tutorials/04_sensors/run_usd_camera.py --headless --enable_cameras + # Usage with no visualizer + ./isaaclab.sh -p scripts/tutorials/04_sensors/run_usd_camera.py --enable_cameras """ diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 00000000000..d4a03a5cdf9 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,105 @@ +# Isaac Lab Agent Skills + +Isaac Lab skills are repo-owned instructions that help agents follow project workflows and user-facing Isaac Lab patterns. They are guidance assets, not runtime Python packages. + +## Catalog + +Developer skills: + +- `developer/pr-workflow/`: prepare changes for review using Isaac Lab's PR, commit, changelog, and validation conventions. +- `developer/changelog-fragments/`: add and validate package changelog fragments. +- `developer/coding-style/`: apply Isaac Lab coding style, API design, docstring, type-hint, lazy export, and contribution conventions. + +User skills: + +- `user/migrate-from-isaac-gym/`: migrate Isaac Gym tasks, assets, and training workflows to Isaac Lab. +- `user/migrate-2x-to-3x/`: migrate Isaac Lab 2.x projects to Isaac Lab 3.0 using the official migration guide. +- `user/domain-randomization-events/`: implement domain randomization through Isaac Lab event terms. +- `user/create-environments/`: create manager-based Isaac Lab environments by default, with direct environments for special cases. +- `user/convert-direct-to-manager/`: convert validated direct Isaac Lab environments into manager-based task configurations. +- `user/train-rl-agents/`: configure and run Isaac Lab reinforcement learning workflows. +- `user/debug-rl-training/`: diagnose RL rewards, task metrics, checkpoint compatibility, and training experiments. +- `user/plan-manipulation-tasks/`: stage manipulation tasks through scene, reset, action, reward, and behavior gates. +- `user/use-sensors-actuators/`: add sensors, sensor observations, and actuator models to tasks. +- `user/diagnose-joint-poses/`: measure and correct robot initial joint poses from semantic or visual pose requests. +- `user/select-backends/`: choose and validate PhysX, Newton, and backend-specific task presets. +- `user/use-presets/`: define and use preset configurations for multi-backend and variant-rich tasks. +- `user/prepare-assets-for-newton/`: validate and prepare PhysX-compatible USD assets for Newton task workflows. +- `user/setup-troubleshooting/`: route installation, verification, and setup issues to official docs and canonical commands. + +Planned user skills: + +- `user/import-robot-urdf-mjcf/` + +## Discovery + +Codex and Claude discover these skills automatically from project-native aliases: + +- Codex scans `.agents/skills//`. +- Claude scans `.claude/skills//`, which links to the same alias set. + +The aliases are named from each `SKILL.md` frontmatter `name` and point back to the canonical skill directory under `skills/`. This keeps one maintained copy of each skill and preserves repository-relative references. Do not flatten-copy skills into an agent's global skill directory. + +Agents that do not support native skill discovery should start at this file. Match the user's request against each `SKILL.md` frontmatter `description`, then read only the selected skill and its directly linked files. When one skill routes to another, use the frontmatter `name` as the stable identifier and the catalog path as the file location. + +## Common Import Paths + +Use these current import paths before searching for alternatives: + +| Concept | Import path | +| --- | --- | +| Direct RL environment config | `from isaaclab.envs import DirectRLEnvCfg` | +| Direct multi-agent environment config | `from isaaclab.envs import DirectMARLEnvCfg` | +| Manager-based RL environment config | `from isaaclab.envs import ManagerBasedRLEnvCfg` | +| Event term config | `from isaaclab.managers import EventTermCfg as EventTerm` | +| Scene entity config | `from isaaclab.managers import SceneEntityCfg` | +| Preset config | `from isaaclab_tasks.utils import PresetCfg` | +| Simulation config | `from isaaclab.sim import SimulationCfg` | +| PhysX physics config | `from isaaclab_physx.physics import PhysxCfg` | +| Newton physics config | `from isaaclab_newton.physics import NewtonCfg` | +| Base contact sensor config | `from isaaclab.sensors import ContactSensorCfg` | +| PhysX contact sensor config | `from isaaclab_physx.sensors import ContactSensorCfg as PhysXContactSensorCfg` | +| Newton contact sensor config | `from isaaclab_newton.sensors import ContactSensorCfg as NewtonContactSensorCfg` | +| Ray caster config | `from isaaclab.sensors import RayCasterCfg` | +| Tiled camera config | `from isaaclab.sensors import TiledCameraCfg` | +| Implicit actuator config | `from isaaclab.actuators import ImplicitActuatorCfg` | +| Core schema fragments and base cfgs | `from isaaclab.sim import schemas` | +| PhysX schema cfgs | `from isaaclab_physx.sim import schemas as physx_schemas` | +| Newton schema cfgs | `from isaaclab_newton.sim import schemas as newton_schemas` | + +## Authoring Rules + +Every skill directory must contain a `SKILL.md` file with frontmatter: + +```yaml +name: isaaclab-example-skill +description: Does a specific Isaac Lab task. Use when the user mentions the task or related trigger terms. +audience: user +status: stable +owners: + - isaaclab-maintainers +``` + +Directory slugs are stable canonical file paths for humans and reviewers. The frontmatter `name` is the agent discovery identifier, must match the aliases under `.agents/skills/`, and should be used when one skill routes to another. `.claude/skills` exposes the same aliases to Claude. + +Required `SKILL.md` sections: + +- `When To Use` +- `Workflow` +- `Validation` +- `Maintenance` +- `References` + +User-facing skills must also link to `evaluations.md` with at least three representative scenarios. Each scenario must include a sample query, expected behavior, and known failure modes or pass/fail criteria. + +Keep skills concise. Use `SKILL.md` for the main workflow and link directly to one-level files such as `reference.md`, `examples.md`, or `evaluations.md` for details. Use forward-slash paths, avoid time-sensitive wording, and provide one recommended default before listing alternatives. + +Keep skills synchronized by making official docs and source code the source of truth. If a skill needs documentation-level details, update `docs/source/` or a maintained source example first, then link to it from the skill. The `Maintenance` section must name the authoritative files that should be reviewed when code changes. When adding, removing, or renaming a skill, update its `.agents/skills/` alias; the validator checks both Codex and Claude discovery paths. + +Skills should add agent-specific routing, sequencing, validation checks, and decision points. They should not vendor installation guides, API catalogs, generated logs, hardware benchmark reports, or large tutorial copies. + +Run the validator before submitting skill changes: + +```bash +uv run --no-project python tools/skills/cli.py check +``` diff --git a/skills/developer/changelog-fragments/SKILL.md b/skills/developer/changelog-fragments/SKILL.md new file mode 100644 index 00000000000..acd24f08b8a --- /dev/null +++ b/skills/developer/changelog-fragments/SKILL.md @@ -0,0 +1,54 @@ +--- +name: isaaclab-writing-changelog-fragments +description: Writes and validates Isaac Lab package changelog fragments using the repository fragment format and bump rules. Use when source package changes need release notes, migration guidance, or changelog validation. +audience: developer +status: stable +owners: + - isaaclab-maintainers +--- + +# Writing Changelog Fragments + +## When To Use + +Use this skill when a PR changes code under `source//` and needs a changelog fragment, or when reviewing fragment formatting. + +Do not use this skill for pure docs, CI, tools, or skills changes unless they also modify `source//`. + +## Workflow + +1. Identify each changed package under `source/`. +2. Add one fragment per touched package under `source//changelog.d/`. +3. Choose the suffix: + - `.rst` for patch changes. + - `.minor.rst` for minor changes. + - `.major.rst` for major changes. + - `.skip` for package changes that intentionally have no user-facing entry. +4. Place entries under `Added`, `Changed`, `Deprecated`, `Removed`, or `Fixed`. +5. Include migration guidance for `Deprecated`, `Changed`, and `Removed` entries. +6. Prefix breaking changes with `**Breaking:**`. + +## Validation + +Run the changelog gate: + +```bash +./isaaclab.sh -p tools/changelog/cli.py check develop +``` + +Then run the normal formatting gate: + +```bash +./isaaclab.sh -f +``` + +## Maintenance + +Keep this skill synchronized with `AGENTS.md`, `docs/source/refs/contributing.rst`, and `tools/changelog/cli.py`. If changelog policy changes, update those authoritative sources first and keep this skill focused on routing agents to the right workflow. + +## References + +- [Contributing guide](../../../docs/source/refs/contributing.rst) +- [Changelog tool](../../../tools/changelog/cli.py) +- [Integration examples](../../../tools/changelog/test/integration/README.md) +- [Examples](examples.md) diff --git a/skills/developer/changelog-fragments/examples.md b/skills/developer/changelog-fragments/examples.md new file mode 100644 index 00000000000..ece55bae6f8 --- /dev/null +++ b/skills/developer/changelog-fragments/examples.md @@ -0,0 +1,33 @@ +# Changelog Fragment Examples + +## Contents + +- Patch fragment +- Minor fragment +- Skip fragment + +## Patch Fragment + +Use `source/isaaclab/changelog.d/.rst` for a bug fix: + +```rst +Fixed +^^^^^ + +* Fixed contact sensor reset behavior when environments are partially reset. +``` + +## Minor Fragment + +Use `source/isaaclab/changelog.d/.minor.rst` for a new public feature: + +```rst +Added +^^^^^ + +* Added :class:`~isaaclab.sensors.ExampleSensor` for configurable example sensing. +``` + +## Skip Fragment + +Use `source/isaaclab/changelog.d/.skip` when a `source//` change is test-only and has no user-facing release note. diff --git a/skills/developer/coding-style/SKILL.md b/skills/developer/coding-style/SKILL.md new file mode 100644 index 00000000000..fc138a7b0ee --- /dev/null +++ b/skills/developer/coding-style/SKILL.md @@ -0,0 +1,57 @@ +--- +name: isaaclab-following-coding-style +description: Applies Isaac Lab coding style, API design, docstring, type-hint, lazy export, and contribution conventions. Use when writing or reviewing Isaac Lab Python code, public APIs, config classes, module exports, or documentation strings. +audience: developer +status: stable +owners: + - isaaclab-maintainers +--- + +# Following Coding Style + +## When To Use + +Use this skill when adding or reviewing Isaac Lab code, especially public APIs, config classes, package exports, docstrings, type hints, or files that may import simulator-dependent modules. + +Do not use this skill as a replacement for the contribution guide. Read the authoritative docs before making broad style decisions. + +## Workflow + +1. Read the `Coding Style` section of `docs/source/refs/contributing.rst`. +2. Check `AGENTS.md` for always-on Isaac Lab API, docstring, dependency, and tooling rules. +3. Keep the change aligned with local patterns in the touched package. +4. Use the documented file and class member ordering. +5. Avoid runtime circular imports with `TYPE_CHECKING`, resolvable strings, and lazy exports where the contribution guide calls for them. +6. Use modern, specific type hints and Google-style docstrings. +7. Keep public-facing docs and auto-generated API docs current when adding public symbols. +8. Run the formatting and lint feedback loop. + +## Validation + +Run formatting and lint checks: + +```bash +./isaaclab.sh -f +``` + +For focused tests, use: + +```bash +./isaaclab.sh -p -m pytest PATH_TO_TEST +``` + +For skill changes, run: + +```bash +./isaaclab.sh -p tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `AGENTS.md`, `docs/source/refs/contributing.rst`, `docs/source/refs/snippets/code_skeleton.py`, and `.pre-commit-config.yaml`. If coding-style guidance changes, update those authoritative files first and keep this skill as a routing checklist. + +## References + +- [Contributing guide](../../../docs/source/refs/contributing.rst) +- [Code skeleton](../../../docs/source/refs/snippets/code_skeleton.py) +- [Examples](examples.md) diff --git a/skills/developer/coding-style/examples.md b/skills/developer/coding-style/examples.md new file mode 100644 index 00000000000..698d8257330 --- /dev/null +++ b/skills/developer/coding-style/examples.md @@ -0,0 +1,41 @@ +# Coding Style Examples + +## Contents + +- Public API change +- Package export change +- Config class with simulator-dependent implementation + +## Public API Change + +Input: adding a public method to an Isaac Lab class. + +Expected workflow: + +1. Check naming against `AGENTS.md`. +2. Use specific type hints and Google-style docstrings. +3. Add SI units in public docstrings for physical quantities. +4. Preserve deprecation policy for renamed or removed APIs. +5. Run `./isaaclab.sh -f` and focused tests. + +## Package Export Change + +Input: adding a public symbol to a package `__init__.py`. + +Expected workflow: + +1. Follow the lazy export pattern from the contribution guide. +2. Update the adjacent `.pyi` stub with explicit public exports. +3. Use relative imports for local submodules. +4. Confirm package import remains lightweight before simulator startup. + +## Config Class With Simulator-Dependent Implementation + +Input: a config class needs to refer to an implementation that imports simulator runtime modules. + +Expected workflow: + +1. Avoid eager runtime imports in the config module. +2. Use a resolvable string or `TYPE_CHECKING` guard as documented. +3. Keep the config constructable before `SimulationApp` launches. +4. Add tests or smoke checks for the resolved runtime path. diff --git a/skills/developer/pr-workflow/SKILL.md b/skills/developer/pr-workflow/SKILL.md new file mode 100644 index 00000000000..1eb5aa80548 --- /dev/null +++ b/skills/developer/pr-workflow/SKILL.md @@ -0,0 +1,59 @@ +--- +name: isaaclab-preparing-pr-workflow +description: Prepares Isaac Lab changes for review using the repository PR checklist, validation commands, commit rules, and changelog policy. Use when opening a PR, finishing a branch, preparing a commit, or checking contribution readiness. +audience: developer +status: stable +owners: + - isaaclab-maintainers +--- + +# Preparing PR Workflow + +## When To Use + +Use this skill when preparing Isaac Lab changes for review, checking a branch before a PR, or helping a contributor understand final readiness steps. + +Do not use this skill to bypass repository checks or to push to `origin`. + +## Workflow + +1. Inspect the changed files and identify touched packages. +2. Confirm the branch is focused on one logical change. +3. Run targeted tests for the touched behavior. +4. For skill changes, inspect the changed skill's adjacent `evaluations.md` when present, plus directly linked `examples.md` or `reference.md`, and confirm the representative scenarios still match the skill guidance. +5. Run formatting and lint checks with `./isaaclab.sh -f`. +6. Add package changelog fragments when `source//` code changes. +7. Check whether `CONTRIBUTORS.md` needs an update for a new contributor. +8. Draft a commit message in imperative mood with no AI attribution. +9. Use the PR checklist in `.github/PULL_REQUEST_TEMPLATE.md`. + +## Validation + +Run the feedback loop until checks pass: + +```bash +./isaaclab.sh -f +``` + +For targeted tests, use: + +```bash +./isaaclab.sh -p -m pytest PATH_TO_TEST +``` + +If skills changed, run: + +```bash +./isaaclab.sh -p tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `AGENTS.md`, `.github/PULL_REQUEST_TEMPLATE.md`, and `docs/source/refs/contributing.rst`. If a PR workflow rule changes, update the authoritative file first and keep this skill as a short routing checklist. + +## References + +- [PR template](../../../.github/PULL_REQUEST_TEMPLATE.md) +- [Contributing guide](../../../docs/source/refs/contributing.rst) +- [Changelog skill](../changelog-fragments/SKILL.md) +- [Examples](examples.md) diff --git a/skills/developer/pr-workflow/examples.md b/skills/developer/pr-workflow/examples.md new file mode 100644 index 00000000000..74d84bd7fff --- /dev/null +++ b/skills/developer/pr-workflow/examples.md @@ -0,0 +1,38 @@ +# PR Workflow Examples + +## Contents + +- Source package change +- Docs-only change +- Skill-only change + +## Source Package Change + +Input: a PR modifies `source/isaaclab/isaaclab/assets/`. + +Expected workflow: + +1. Run targeted tests for the changed asset behavior. +2. Add a fragment under `source/isaaclab/changelog.d/`. +3. Run `./isaaclab.sh -f`. +4. Fill the PR checklist with test results. + +## Docs-Only Change + +Input: a PR modifies `docs/source/overview/`. + +Expected workflow: + +1. Build or preview docs when the change affects rendering. +2. Run `./isaaclab.sh -f`. +3. Do not add a package changelog fragment unless `source//` changed. + +## Skill-Only Change + +Input: a PR modifies `skills/user/domain-randomization-events/SKILL.md`. + +Expected workflow: + +1. Run `./isaaclab.sh -p tools/skills/cli.py check`. +2. Inspect `skills/user/domain-randomization-events/evaluations.md` and directly linked `examples.md` or `reference.md` to confirm scenarios, examples, and source references still match the changed guidance. +3. Let the path-scoped skills CI gate validate the change on the PR. diff --git a/skills/user/convert-direct-to-manager/SKILL.md b/skills/user/convert-direct-to-manager/SKILL.md new file mode 100644 index 00000000000..bc4c4a3f765 --- /dev/null +++ b/skills/user/convert-direct-to-manager/SKILL.md @@ -0,0 +1,64 @@ +--- +name: isaaclab-converting-direct-to-manager +description: Converts validated Isaac Lab direct workflow environments into manager-based task configurations. Use when a direct Isaac Lab environment or Isaac Gym migration already resets, steps, and trains, and the user wants reusable observations, rewards, commands, events, curricula, or terminations. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Converting Direct Environments To Manager-Based + +## When To Use + +Use this skill when a direct Isaac Lab environment is already runnable and the next goal is a reusable manager-based task. This is a follow-up workflow, not the first parity pass for Isaac Gym migration. + +Do not use this skill when the direct environment still fails to construct, reset, step, or start training. Fix the direct baseline first with `isaaclab-building-environments` or `isaaclab-migrating-from-isaac-gym`. + +## Workflow + +1. Keep the direct environment as a parity baseline until the manager-based task passes equivalent smoke tests. +2. Read the task workflow overview, manager-based RL tutorial, and the closest direct/manager pair before proposing code. +3. Split scene construction into an `InteractiveSceneCfg` subclass with assets, terrain, sensors, and lights. +4. Move action application into action configs such as joint position, velocity, or effort action terms. +5. Move observation assembly into `ObservationGroupCfg` and `ObservationTermCfg` entries, preserving block order and scaling unless the user accepts a policy interface change. +6. Move reward helpers into standalone MDP functions or `ManagerTermBase` classes, then wire them with `RewardTermCfg` weights and params. +7. Move reset logic and randomization into `EventTermCfg` entries with the correct mode (`prestartup`, `startup`, `reset`, or `interval`). +8. Move done logic into `TerminationTermCfg` entries and preserve timeout versus failure semantics. +9. Move command sampling into command configs when goals, velocities, poses, or targets are part of the observation/reward loop. +10. Keep backend-specific physics, sensor, and schema variants in `PresetCfg` classes rather than runtime conditionals. +11. Register the manager-based task under the suffixless task name when it is intended as the canonical task, and keep direct variants with a `-Direct` suffix. +12. Validate import, reset, random actions, and a short training run before removing or de-emphasizing the direct baseline. + +## Validation + +Use the same gates as the direct baseline: + +1. Import the task module. +2. Instantiate a small number of environments. +3. Reset and step with random actions. +4. Compare observation shape, reward signs, termination rates, and reset behavior against the direct baseline. +5. Run a short training command after random-agent validation passes. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `docs/source/overview/core-concepts/task_workflows.rst`, `docs/source/tutorials/03_envs/create_manager_rl_env.rst`, direct/manager paired examples under `source/isaaclab_tasks/isaaclab_tasks/core/`, and shared MDP terms under task-specific `mdp/` packages. + +## References + +- [Examples](examples.md) +- [Evaluations](evaluations.md) +- [Environment building skill](../create-environments/SKILL.md) +- [Isaac Gym migration skill](../migrate-from-isaac-gym/SKILL.md) +- [Task workflows](../../../docs/source/overview/core-concepts/task_workflows.rst) +- [Create manager-based RL environment tutorial](../../../docs/source/tutorials/03_envs/create_manager_rl_env.rst) +- [Register Gym environment tutorial](../../../docs/source/tutorials/03_envs/register_rl_env_gym.rst) +- [Ant direct environment](../../../source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env.py) +- [Ant direct config](../../../source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py) +- [Ant manager config](../../../source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py) diff --git a/skills/user/convert-direct-to-manager/evaluations.md b/skills/user/convert-direct-to-manager/evaluations.md new file mode 100644 index 00000000000..94f7fe02d31 --- /dev/null +++ b/skills/user/convert-direct-to-manager/evaluations.md @@ -0,0 +1,48 @@ +# Direct To Manager Evaluations + +## Scenario 1: Convert A Validated Direct Locomotion Task + +Query: "My direct Ant task runs now. Convert it to the manager workflow so rewards and observations are reusable." + +Expected behavior: + +- Keeps the direct task as a comparison baseline. +- Maps scene, actions, observations, rewards, resets, terminations, and physics presets into manager configs. +- Uses the maintained Ant direct and manager pair as a reference. +- Runs random-agent and short-training smoke tests for the manager-based task. + +Known failure modes: + +- Deletes or rewrites the direct baseline before parity is checked. +- Changes observation ordering or scaling without calling out the policy interface change. +- Leaves reward logic buried in an environment subclass instead of MDP terms. + +## Scenario 2: Convert A Direct Isaac Gym Migration + +Query: "The Isaac Gym migration runs in direct form. Now make it an Isaac Lab-style task." + +Expected behavior: + +- Confirms direct reset, step, and short training have already passed. +- Recommends manager-based conversion because reusable managers are the main Isaac Lab task-framework benefit. +- Moves command sampling, randomization, observations, rewards, and terminations into manager terms incrementally. + +Known failure modes: + +- Starts manager conversion before the direct migration is a working parity baseline. +- Copies legacy Isaac Gym tensor code into manager config classes without reusable MDP functions. + +## Scenario 3: Decide Not To Convert Yet + +Query: "My direct environment still has shape errors, but I want it manager-based." + +Expected behavior: + +- Routes back to direct environment debugging first. +- Explains that manager conversion should happen after construction, reset, step, and short training pass. +- Suggests the smallest random-agent smoke test needed before conversion. + +Known failure modes: + +- Hides direct bugs by moving code into managers. +- Starts a larger refactor before the task has a trusted baseline. diff --git a/skills/user/convert-direct-to-manager/examples.md b/skills/user/convert-direct-to-manager/examples.md new file mode 100644 index 00000000000..a890c5e4c01 --- /dev/null +++ b/skills/user/convert-direct-to-manager/examples.md @@ -0,0 +1,44 @@ +# Direct To Manager Examples + +## Ant Locomotion Pair + +Use the maintained Ant pair as the first comparison point: + +- Direct baseline: `source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env.py` +- Direct config: `source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py` +- Manager config: `source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py` + +Important mappings: + +| Direct source | Manager-based target | +| --- | --- | +| `_setup_scene()` and direct terrain config | `AntSceneCfg` with terrain, robot, sensors, and lights | +| `_apply_action()` joint efforts | `ActionsCfg` with `JointEffortActionCfg` | +| `_get_observations()` tensor concatenation | `ObservationsCfg.PolicyCfg` with ordered `ObsTerm` entries | +| `_get_rewards()` and reward helpers | `RewardsCfg` with `RewTerm` entries and shared MDP functions/classes | +| `_get_dones()` | `TerminationsCfg` with `DoneTerm` entries | +| `_reset_idx()` state reset | `EventCfg` reset terms | +| Physics variants in direct cfg | `PresetCfg` physics variants in manager cfg | + +## Conversion Checklist + +- Preserve the direct environment until the manager-based task passes smoke tests. +- Keep the policy observation contract stable unless the user explicitly wants to change policy inputs. +- Put reusable math and stateful reward logic in a task `mdp/` package. +- Use `SceneEntityCfg` to bind MDP terms to assets, joints, bodies, and sensors. +- Prefer parameter changes in config classes over custom environment subclasses. + +## Smoke-Test Commands + +Use suffixless task names for canonical manager-based tasks: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Ant --num_envs 8 +uv run isaaclab train --rl_library rsl_rl --task Isaac-Ant --num_envs 64 --max_iterations 5 +``` + +Keep the direct baseline available for comparison: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Ant-Direct --num_envs 8 +``` diff --git a/skills/user/create-environments/SKILL.md b/skills/user/create-environments/SKILL.md new file mode 100644 index 00000000000..1c08273353b --- /dev/null +++ b/skills/user/create-environments/SKILL.md @@ -0,0 +1,64 @@ +--- +name: isaaclab-building-environments +description: Builds Isaac Lab manager-based environments from task requirements, with direct environments reserved for migrations, custom control flow, or performance-specialized prototypes. Use when creating a new environment, choosing between direct and manager-based workflows, registering Gym environments, or adapting existing task examples. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Building Environments + +## When To Use + +Use this skill when a user wants to create a new Isaac Lab environment, choose the right task workflow, or adapt an existing task example. + +Default to manager-based environments for new Isaac Lab tasks because reusable scene, action, observation, reward, command, event, curriculum, and termination terms are the main benefit of the Isaac Lab task framework. Do not use this skill for migrating Isaac Gym tasks. Use the `isaaclab-migrating-from-isaac-gym` skill when the source is an Isaac Gym or IsaacGymEnvs task. For contact-rich manipulation task staging, pair this with `isaaclab-planning-manipulation-tasks`. + +## Workflow + +1. Identify the task type, assets, action space, observation needs, rewards, resets, termination conditions, sensors, training framework, and target backend. +2. Read the task workflow overview and the relevant manager-based tutorial before proposing code. +3. Choose manager-based workflow first for new Isaac Lab tasks. Express the task as scene, action, observation, reward, command, event, curriculum, and termination configs using existing MDP terms where possible. +4. Choose direct workflow only when the task is an Isaac Gym migration, needs bespoke step/reset/control flow that does not fit managers, requires monolithic parity with a source task, or is intentionally a short-lived performance prototype. +5. When a request mentions custom commands or rewards, try to make them manager terms first. Ask one clarifying question only when the behavior could be either reusable task logic or low-level control flow. +6. Start from the closest maintained source example under `source/isaaclab_tasks/isaaclab_tasks/`. +7. Define the scene and asset configs before adding rewards or training configuration. +8. Add observations, actions, rewards, resets, and terminations incrementally. +9. Register the environment and connect the smallest compatible agent config. +10. Use suffixless task names in smoke-test and training commands. +11. Run a random-action or short training smoke test before scaling environment count. +12. If a direct prototype becomes reusable, route the user to `isaaclab-converting-direct-to-manager` and move stable logic into shared MDP terms. + +## Validation + +Check the environment in this order: + +1. Import the task module without launching a long training job. +2. Instantiate a small number of environments. +3. Reset and step with random actions. +4. Verify action, observation, reward, reset, and termination shapes. +5. Run a short training command only after the smoke test passes. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `docs/source/overview/core-concepts/task_workflows.rst`, the environment tutorials under `docs/source/tutorials/03_envs/`, and maintained task examples under `source/isaaclab_tasks/isaaclab_tasks/`. If workflow documentation is missing or stale, update the docs or examples first and keep this skill focused on choosing the right path. + +## References + +- [Evaluations](evaluations.md) +- [Examples](examples.md) +- [Manipulation planning skill](../plan-manipulation-tasks/SKILL.md) +- [Task workflows](../../../docs/source/overview/core-concepts/task_workflows.rst) +- [Create manager-based base environment tutorial](../../../docs/source/tutorials/03_envs/create_manager_base_env.rst) +- [Create manager-based RL environment tutorial](../../../docs/source/tutorials/03_envs/create_manager_rl_env.rst) +- [Register Gym environment tutorial](../../../docs/source/tutorials/03_envs/register_rl_env_gym.rst) +- [Direct to manager conversion skill](../convert-direct-to-manager/SKILL.md) +- [Create direct workflow environment tutorial](../../../docs/source/tutorials/03_envs/create_direct_rl_env.rst) +- [Modify direct workflow environment tutorial](../../../docs/source/tutorials/03_envs/modify_direct_rl_env.rst) diff --git a/skills/user/create-environments/evaluations.md b/skills/user/create-environments/evaluations.md new file mode 100644 index 00000000000..c4e0a9ed7f0 --- /dev/null +++ b/skills/user/create-environments/evaluations.md @@ -0,0 +1,49 @@ +# Environment Building Evaluations + +## Scenario 1: Choose A Workflow + +Query: "I want to build a quadruped locomotion task with custom command sampling and custom rewards." + +Expected behavior: + +- Asks for assets, actions, observations, rewards, reset conditions, sensors, and backend targets. +- Recommends manager-based workflow first and maps custom commands and rewards to reusable command and reward terms. +- Points to current maintained locomotion examples such as `source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py`, robot-specific configs under `source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/`, and manager-based environment tutorials. +- Mentions direct workflow only if the user needs bespoke step/reset logic that cannot fit managers. +- Defines a smoke-test plan before training. + +Known failure modes: + +- Defaults to direct workflow just because the request mentions custom rewards or commands. +- Copies tutorial code without checking the task's action and observation requirements. +- Uses stale source paths from older Isaac Lab layouts instead of current `source/isaaclab_tasks/isaaclab_tasks/core/` or `contrib/` examples. + +## Scenario 2: Build A Manager-Based Task + +Query: "Create a reusable reaching task where observations and rewards should be reusable across robot arms." + +Expected behavior: + +- Recommends manager-based workflow because observations and rewards are reusable. +- Maps task pieces to scene, observation, action, command, reward, termination, event, and curriculum configs. +- Points to manager-based tutorials and source examples. + +Known failure modes: + +- Implements all behavior in a direct environment even though reuse is the user's main requirement. +- Adds custom abstractions before checking existing MDP terms. + +## Scenario 3: Register And Train + +Query: "I created a new environment config and need to train it with Gym registration." + +Expected behavior: + +- Points to the Gym registration and RL training tutorials. +- Verifies the environment imports, resets, and steps before training. +- Checks that the agent config matches the selected training framework. + +Known failure modes: + +- Starts a large training run before checking reset and step behavior. +- Mixes agent config formats from different RL frameworks. diff --git a/skills/user/create-environments/examples.md b/skills/user/create-environments/examples.md new file mode 100644 index 00000000000..9b03622b161 --- /dev/null +++ b/skills/user/create-environments/examples.md @@ -0,0 +1,57 @@ +# Environment Creation Examples + +## Manager-Based Workflow + +Use manager-based workflow by default for new Isaac Lab tasks. This is the framework's main task-building path because observations, rewards, commands, events, curricula, and terminations can be reused and tuned independently. + +Start from: + +- `docs/source/tutorials/03_envs/create_manager_base_env.rst` +- `docs/source/tutorials/03_envs/create_manager_rl_env.rst` +- `source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py` +- `source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py` +- `source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py` + +For quadruped locomotion requests with custom command sampling or custom rewards, first try to model the behavior as reusable `CommandManager`, `RewardManager`, `ObservationManager`, and shared MDP functions. Inspect `source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py` and robot-specific configs such as `source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py`. + +Smoke-test pattern: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Cartpole --num_envs 8 +``` + +## Direct Workflow + +Use direct workflow when the task has custom low-level step/reset logic, must stay close to a monolithic Isaac Gym task during migration, or is a performance prototype that will be converted later if it becomes reusable. + +Start from: + +- `docs/source/tutorials/03_envs/create_direct_rl_env.rst` +- `source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py` +- `source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env_cfg.py` + +Smoke-test pattern: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Cartpole-Direct --num_envs 8 +``` + +Training pattern: + +```bash +uv run isaaclab train --rl_library rl_games --task Isaac-Cartpole-Direct +``` + +## Registration And Agent Configs + +When adding a new Gym task: + +1. Register the task with an `env_cfg_entry_point`. +2. Add one agent config entry point per supported RL framework. +3. Run `random_agent.py` before training. +4. Train only after action, observation, reward, reset, and termination shapes are stable. + +Reference: + +- `docs/source/tutorials/03_envs/register_rl_env_gym.rst` +- `docs/source/tutorials/03_envs/configuring_rl_training.rst` diff --git a/skills/user/debug-rl-training/SKILL.md b/skills/user/debug-rl-training/SKILL.md new file mode 100644 index 00000000000..15f453cf0b7 --- /dev/null +++ b/skills/user/debug-rl-training/SKILL.md @@ -0,0 +1,58 @@ +--- +name: isaaclab-debugging-rl-training +description: Diagnoses Isaac Lab reinforcement learning behavior, rewards, metrics, checkpoints, and training experiments. Use when reward curves look wrong, policies fail despite training, checkpoints mismatch, or RL changes need focused ablations. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Debugging RL Training + +## When To Use + +Use this skill when a user needs to debug learned behavior, reward hacking, checkpoint compatibility, unstable training, or training-result trustworthiness. + +Do not use this skill for first-time training commands. Use `isaaclab-training-rl-agents` for launch commands and agent config wiring. + +## Workflow + +1. Identify task name, workflow type, RL library, agent config, seed, backend, and exact launch command. +2. Confirm the environment contract: action space, observation space, reward terms, termination terms, reset logic, and success metric. +3. Run the smallest reproduction: import, reset/step, one-iteration training, or deterministic playback depending on where the failure appears. +4. Change one variable per training experiment. Mark multi-variable runs as exploratory. +5. Compare reward curves against task metrics. Reward increases are not proof that the task behavior improved. +6. For reward issues, map every reward term to a named task phase and check that success reward, termination, and evaluation metric use consistent geometry. +7. For checkpoint issues, compare current observation/action dimensions with the saved training configuration before editing policy code. +8. For contact-rich tasks, collect state traces for controlled-frame pose, object pose, contacts, gripper state, per-term rewards, and termination flags. +9. Select checkpoints by task metrics, rollout behavior, and stability, not reward alone. + +## Validation + +Use this checklist: + +1. The exact command and failing symptom are recorded. +2. The failed layer is classified as environment, reward, reset, physics, runner, or checkpoint compatibility. +3. A focused reproduction isolates one variable. +4. Reward terms and task metrics are inspected together. +5. A deterministic rollout or state trace confirms the behavior change. +6. Any recommended next run changes only one variable. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `skills/user/train-rl-agents/`, `docs/source/overview/reinforcement-learning/training_guide.rst`, the uv-based `train` and `play` entry points, and task examples under `source/isaaclab_tasks/isaaclab_tasks/`. If recurring reward or checkpoint guidance belongs in user docs, update `docs/source/` first. + +## References + +- [Reference](reference.md) +- [Examples](examples.md) +- [Evaluations](evaluations.md) +- [RL training skill](../train-rl-agents/SKILL.md) +- [RL training guide](../../../docs/source/overview/reinforcement-learning/training_guide.rst) +- [Task examples](../../../source/isaaclab_tasks/isaaclab_tasks) diff --git a/skills/user/debug-rl-training/evaluations.md b/skills/user/debug-rl-training/evaluations.md new file mode 100644 index 00000000000..5c4348d19ba --- /dev/null +++ b/skills/user/debug-rl-training/evaluations.md @@ -0,0 +1,47 @@ +# RL Debugging Evaluations + +## Scenario 1: Reward Hacking + +Query: "My reward goes up, but the robot never completes the task." + +Expected behavior: + +- Separates reward curves from task success metrics. +- Audits per-term rewards against task phases. +- Checks success reward, termination, and metric geometry. +- Recommends deterministic rollout or state traces before retuning PPO. + +Known failure modes: + +- Declares the run successful from reward alone. +- Changes PPO hyperparameters before checking reward and observation semantics. + +## Scenario 2: Checkpoint Mismatch + +Query: "My old checkpoint no longer loads after I changed observations." + +Expected behavior: + +- Compares current observation/action spaces with the checkpoint training config. +- Treats shape mismatch as an environment-contract issue. +- Recommends retraining or reverting unintended contract changes. + +Known failure modes: + +- Patches the runner to ignore shape mismatches. +- Assumes the checkpoint is corrupt before checking environment dimensions. + +## Scenario 3: One-Variable Ablation + +Query: "I changed reward, observations, and reset randomization. Which run should I trust?" + +Expected behavior: + +- Marks the run as exploratory because multiple variables changed. +- Recommends control runs or one-variable ablations. +- Defines success metrics before launching the next run. + +Known failure modes: + +- Attributes behavior to one change without controls. +- Chooses checkpoints only from total reward. diff --git a/skills/user/debug-rl-training/examples.md b/skills/user/debug-rl-training/examples.md new file mode 100644 index 00000000000..c13b4411654 --- /dev/null +++ b/skills/user/debug-rl-training/examples.md @@ -0,0 +1,36 @@ +# RL Debugging Examples + +## Reward Improves But Success Does Not + +Input: a run has increasing reward, but the success rate stays near zero. + +Expected workflow: + +1. Inspect per-term rewards and success metric definitions. +2. Check whether dense shaping saturates before task completion. +3. Compare success reward, termination, and metric geometry. +4. Run deterministic playback and inspect task-state traces. +5. Change only the smallest reward or observation issue before retraining. + +## Checkpoint Shape Mismatch + +Input: playback or resume fails after changing observations. + +Expected workflow: + +1. Print current action and observation spaces. +2. Compare them to the checkpoint's saved config. +3. Revert accidental environment-contract changes or retrain. +4. Do not patch the runner to ignore shape mismatches. + +## Contact-Rich Manipulation Stall + +Input: a manipulation policy reaches the object but cannot grasp, insert, or lift. + +Expected workflow: + +1. Check controlled-frame, object, goal, gripper, and contact observations. +2. Validate reset geometry and gripper/object initial state. +3. Inspect action scaling and controlled joints or body names. +4. Confirm contacts and physics are stable before tuning reward scales. +5. Train one subtask at a time if end-to-end behavior is too hard to interpret. diff --git a/skills/user/debug-rl-training/reference.md b/skills/user/debug-rl-training/reference.md new file mode 100644 index 00000000000..74b5132f575 --- /dev/null +++ b/skills/user/debug-rl-training/reference.md @@ -0,0 +1,69 @@ +# RL Debugging Reference + +## Contents + +- Experiment discipline +- Reward audit +- Checkpoint compatibility +- State traces +- Decision rules + +## Experiment Discipline + +Record these before a run: + +- Task name and environment config. +- RL library and agent config. +- Backend, renderer, device, seed, number of environments, and iteration count. +- Checkpoint path if resuming. +- Single variable being tested. +- Primary success metric and stop condition. + +One-iteration training proves only runner plumbing. Use short deterministic rollout or task metrics before judging behavior. + +## Reward Audit + +Reward is a training signal, not a success metric. For every reward term, record: + +- The task phase it is intended to teach. +- The state variables it reads. +- The scale and units. +- Whether it saturates before real success. +- Whether it shares geometry with success termination and evaluation metrics. + +Common reward failures: + +- Reward increases while success stays flat. +- Dense shaping reward can be maximized without completing the task. +- Success reward, termination, and metric use different thresholds. +- The policy cannot observe the state needed to optimize the reward. +- Stateful reward buffers are not reset for the correct `env_ids`. + +## Checkpoint Compatibility + +Before replaying or resuming: + +- Compare current observation and action dimensions with the checkpoint's training config. +- Confirm task ID and agent config match the run. +- Confirm the backend and sensor presets are compatible. +- Treat shape mismatches as environment-contract changes, not runner bugs. + +## State Traces + +When reward and behavior disagree, inspect task state directly. Useful trace fields include: + +- Raw and processed policy actions. +- Observation term names and shapes. +- Per-term reward values. +- Termination and truncation flags. +- Robot root, joint, and controlled-frame pose. +- Object pose, velocity, goal error, and contact state. +- Reset state for robot, object, and goal. + +## Decision Rules + +- Reward rises and success rises: continue or scale. +- Reward rises and success stays flat: inspect reward saturation, observations, and success geometry. +- Reward is flat from the start: check action interface, target reachability, reset state, and observation coverage. +- Entropy collapses early: inspect exploration settings and overly strong penalties. +- Physics warnings appear: fix assets, contacts, or buffers before interpreting RL curves. diff --git a/skills/user/diagnose-joint-poses/SKILL.md b/skills/user/diagnose-joint-poses/SKILL.md new file mode 100644 index 00000000000..1d74f82e69a --- /dev/null +++ b/skills/user/diagnose-joint-poses/SKILL.md @@ -0,0 +1,54 @@ +--- +name: isaaclab-diagnosing-joint-poses +description: Diagnoses and validates Isaac Lab robot initial joint poses from semantic or visual requests. Use when a robot starts with the wrong wrist, gripper, tool, camera, or end-effector orientation and the fix should be measured from articulation state instead of guessed from screenshots. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Diagnosing Joint Poses + +## When To Use + +Use this skill when a user asks to set, fix, or validate an initial robot pose from language such as "gripper fingers down", "tool points forward", "camera looks at the object", or "end effector aligns with the target". + +## Workflow + +1. Translate the visual request into a measurable body-axis or target-pose condition. +2. Inspect the actual articulation `body_names` and `joint_names`; do not rely only on USD prim names. +3. Measure current body position and orientation from simulation state. +4. Sweep candidate joints one variable at a time, respecting joint limits. +5. Choose the pose that satisfies the axis target while preserving reachability and avoiding self-collision. +6. Patch the task's initial joint positions or reset configuration. +7. Validate with compile checks and a one-env reset or zero-action smoke. +8. Report the measured before/after axis alignment, not just visual appearance. + +## Validation + +Use these acceptance checks: + +1. The body or frame being measured is named explicitly. +2. The requested local axis and target world axis are documented. +3. Body-axis dot product is reported before and after the change. +4. Joint values are inside joint limits. +5. The pose remains reachable for the task target. +6. Observation and action dimensions remain unchanged unless the user requested a contract change. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with robot and articulation APIs under `source/isaaclab/isaaclab/assets/`, environment smoke-test patterns, and examples that use `ArticulationCfg.InitialStateCfg`. Do not include generated screenshots, private assets, or local machine paths in the skill. + +## References + +- [Examples](examples.md) +- [Evaluations](evaluations.md) +- [Articulation assets](../../../source/isaaclab/isaaclab/assets/articulation) +- [Asset configuration APIs](../../../source/isaaclab/isaaclab/assets/asset_base_cfg.py) +- [Zero-agent script](../../../scripts/environments/zero_agent.py) diff --git a/skills/user/diagnose-joint-poses/evaluations.md b/skills/user/diagnose-joint-poses/evaluations.md new file mode 100644 index 00000000000..7fad4c5bfa3 --- /dev/null +++ b/skills/user/diagnose-joint-poses/evaluations.md @@ -0,0 +1,47 @@ +# Joint Pose Diagnostics Evaluations + +## Scenario 1: Gripper Orientation + +Query: "Set the initial gripper pose so the fingers point downward." + +Expected behavior: + +- Converts the request into a measured body-axis target. +- Inspects actual articulation body and joint names. +- Sweeps candidate joints within limits. +- Reports measured before/after alignment. + +Known failure modes: + +- Guesses joint values from a screenshot. +- Assumes USD prim names are articulation body names. + +## Scenario 2: Pose Fix Breaks Reachability + +Query: "The wrist now points the right way, but the robot can no longer reach the object." + +Expected behavior: + +- Treats axis alignment and target reachability as separate acceptance checks. +- Rechecks body-to-target distance or task-specific reach metric. +- Suggests another joint combination or reset pose instead of only maximizing axis alignment. + +Known failure modes: + +- Optimizes orientation while ignoring task reachability. +- Changes observations or actions unnecessarily. + +## Scenario 3: Ambiguous Frame Request + +Query: "Make the tool face forward." + +Expected behavior: + +- Identifies ambiguity in body and local-axis convention. +- Measures candidate axes and states which convention is used. +- Avoids claiming visual success without state evidence. + +Known failure modes: + +- Picks a body and axis silently. +- Reports only a rendered view. diff --git a/skills/user/diagnose-joint-poses/examples.md b/skills/user/diagnose-joint-poses/examples.md new file mode 100644 index 00000000000..dca6f0f0cbf --- /dev/null +++ b/skills/user/diagnose-joint-poses/examples.md @@ -0,0 +1,35 @@ +# Joint Pose Diagnostics Examples + +## Gripper Fingers Should Point Down + +Input: "The gripper should start with the fingers pointing down." + +Expected workflow: + +1. Identify the finger, tool, or gripper-base body that represents the visual request. +2. Measure local body axes in world frame after reset. +3. Define the target direction, such as local `+Z` or `-Z` aligned to world `[0, 0, -1]`. +4. Sweep wrist joints inside joint limits. +5. Patch `init_state.joint_pos` only after the measured axis meets the tolerance. + +## Camera Should Look At Object + +Input: "The wrist camera should face the cube at reset." + +Expected workflow: + +1. Confirm the camera or camera mount is an exposed body or sensor frame. +2. Measure camera forward axis and object position after reset. +3. Compare the camera forward axis with the camera-to-object direction. +4. Adjust wrist or mount joints only if the camera frame is controlled through articulation joints. +5. Validate the result with a reset smoke and state measurement. + +## Ambiguous Tool Axis + +Input: "Make the tool point forward." + +Expected workflow: + +1. Ask or infer which body and local axis represents "tool forward". +2. If ambiguous, measure multiple candidate axes and explain the selected convention. +3. Report the final measured axis, dot product, and any reachability tradeoff. diff --git a/skills/user/domain-randomization-events/SKILL.md b/skills/user/domain-randomization-events/SKILL.md new file mode 100644 index 00000000000..2c2054ffda6 --- /dev/null +++ b/skills/user/domain-randomization-events/SKILL.md @@ -0,0 +1,65 @@ +--- +name: isaaclab-randomizing-with-events +description: Implements Isaac Lab domain randomization with event terms in direct and manager-based workflows. Use when adding domain randomization, randomizing physics or observations, porting reset randomization, or configuring event-based variation. +audience: user +status: stable +owners: + - isaaclab-maintainers +--- + +# Randomizing With Events + +## When To Use + +Use this skill when a user wants to add domain randomization to an Isaac Lab task through event terms. + +Do not use this skill for unrelated curriculum, command sampling, or reward shaping unless those changes interact with randomization. + +## Workflow + +1. Identify what should vary: assets, physics properties, observations, initial state, external disturbances, or rendering. +2. Identify the task workflow: direct (`DirectRLEnv` or `DirectMARLEnv`) or manager-based. Both can use `EventManager` through an `events` config. +3. Identify the active target backend: PhysX, Newton, or both through `PresetCfg`. +4. Check whether the randomization function has backend-specific behavior or unsupported backends in `source/isaaclab/isaaclab/envs/mdp/events.py`. +5. Choose the event mode: + - Use prestartup events for USD-level properties that must be authored before simulation starts. + - Use startup events for one-time setup randomization after simulation starts. + - Use reset events for per-episode randomization. + - Use interval events for repeated disturbances during an episode. +6. Check timing limitations before editing. Some USD-stage or topology-level changes are prestartup-only and cannot be safely moved to reset or interval events. +7. Define event terms in the environment configuration. In direct workflows, assign the event config to the task config's `events` field. In manager-based workflows, assign it to the manager-based env config's `events` field. +8. Use backend-specific `PresetCfg` event configs when PhysX and Newton need different terms. +9. Scope each term to the correct scene entities. +10. Use one clear distribution and range for each randomized quantity. +11. Validate with a small number of environments and repeated resets on each backend. +12. Expand ranges only after the baseline randomized task is stable. + +## Validation + +Use the plan-validate-execute loop: + +1. List each randomized property, target entity, backend, event mode, distribution, range, and timing limitation. +2. Check the list against the environment config before editing. +3. Check CPU/GPU expectations in the implementation. Some PhysX paths use CPU tensors while Newton paths may operate on the environment device. +4. Run a small reset or rollout smoke test for every targeted backend. +5. Fix shape, device, backend, and entity-name errors before scaling. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `source/isaaclab/isaaclab/managers/event_manager.py`, `source/isaaclab/isaaclab/envs/direct_rl_env.py`, `source/isaaclab/isaaclab/envs/direct_marl_env.py`, the direct and manager-based environment tutorials, and the managers API docs. If event-term behavior or mode semantics change, update the official docs or examples first and keep this skill focused on selecting the right workflow. + +## References + +- [Reference](reference.md) +- [Examples](examples.md) +- [Evaluations](evaluations.md) +- [Event manager source](../../../source/isaaclab/isaaclab/managers/event_manager.py) +- [Direct workflow randomization tutorial](../../../docs/source/tutorials/03_envs/create_direct_rl_env.rst) +- [Manager-based event terms tutorial](../../../docs/source/tutorials/03_envs/create_manager_base_env.rst) +- [Managers API](../../../docs/source/api/lab/isaaclab.managers.rst) diff --git a/skills/user/domain-randomization-events/evaluations.md b/skills/user/domain-randomization-events/evaluations.md new file mode 100644 index 00000000000..6fe8cf2363a --- /dev/null +++ b/skills/user/domain-randomization-events/evaluations.md @@ -0,0 +1,86 @@ +# Event Randomization Evaluations + +## Contents + +- Scenario 1: reset randomization +- Scenario 2: prestartup-only randomization +- Scenario 3: interval disturbance +- Scenario 4: PhysX and Newton material randomization +- Scenario 5: direct workflow events + +## Scenario 1: Reset Randomization + +Query: "Randomize initial joint positions every episode." + +Expected behavior: + +- Selects reset mode. +- Places randomization in event terms. +- Checks entity names, ranges, and reset behavior. + +Known failure modes: + +- Adds randomization to the reward function. +- Uses startup mode for per-episode behavior. + +## Scenario 2: Prestartup-Only Randomization + +Query: "Randomize authored USD properties for each run, but I also want them to change every reset." + +Expected behavior: + +- Identifies that some USD-stage or topology-level randomizations are prestartup-only. +- Warns not to place prestartup-only changes in reset or interval events. +- Suggests pre-generated variants, separate authored assets, or fixed-per-run randomization if per-reset changes are unsupported. + +Known failure modes: + +- Promises per-reset changes for a property that must be authored before simulation starts. +- Omits the timing limitation from the plan. + +## Scenario 3: Interval Disturbance + +Query: "Apply random pushes to the robot during training." + +Expected behavior: + +- Selects interval mode. +- Defines magnitude and interval ranges. +- Recommends small-scale rollout validation before training at scale. + +Known failure modes: + +- Implements pushes in the training loop. +- Does not check tensor device and shape compatibility. + +## Scenario 4: PhysX and Newton Material Randomization + +Query: "Randomize friction and restitution for both PhysX and Newton." + +Expected behavior: + +- Checks the current event implementation for backend-specific behavior. +- Uses PhysX bucket behavior for PhysX. +- Calls out that Newton uses one friction coefficient and ignores `dynamic_friction_range` and `num_buckets`. +- Recommends separate `PresetCfg` event configs and separate smoke tests per backend. + +Known failure modes: + +- Copies PhysX material parameters directly into Newton. +- Ignores CPU/GPU differences in the event implementation. + +## Scenario 5: Direct Workflow Events + +Query: "Add domain randomization events to my DirectRLEnv task." + +Expected behavior: + +- Recognizes that direct RL environments can use `EventManager` through the `events` config field. +- Keeps observations and rewards in direct workflow methods. +- Adds event terms only for randomization behavior. +- References the direct workflow randomization tutorial. + +Known failure modes: + +- Claims event terms are only for manager-based environments. +- Converts the task to manager-based solely to use randomization events. diff --git a/skills/user/domain-randomization-events/examples.md b/skills/user/domain-randomization-events/examples.md new file mode 100644 index 00000000000..4010eb0818e --- /dev/null +++ b/skills/user/domain-randomization-events/examples.md @@ -0,0 +1,122 @@ +# Event Randomization Examples + +## Contents + +- Reset state randomization +- Direct workflow event config +- Prestartup USD randomization +- Startup property randomization +- Backend-specific material randomization +- Interval disturbance + +## Reset State Randomization + +Input: randomize robot initial joint positions at the start of each episode. + +Expected setup: + +- Use a reset event. +- Scope the term to the robot articulation. +- Use conservative joint ranges first. +- Validate with repeated resets before training. + +## Direct Workflow Event Config + +Input: add friction and gravity randomization to a `DirectRLEnv` task. + +Expected setup: + +- Define an `EventCfg` with `EventTerm` entries in the direct task config module. +- Assign `events: EventCfg = EventCfg()` on the direct task config. +- Keep reward and observation logic in direct methods. +- Validate that `prestartup`, `startup`, `reset`, and `interval` modes fire at the expected times for direct workflows. + +## Startup Property Randomization + +Input: randomize a property once after simulation starts. + +Expected setup: + +- Use a startup event. +- Keep ranges tied to physical units. +- Confirm the randomized values are applied before rollout. + +## Prestartup USD Randomization + +Input: randomize authored USD-stage properties or asset variants before simulation buffers are created. + +Expected setup: + +- Use a prestartup event. +- Do not move the change to reset or interval events unless the backend explicitly supports changing that property after initialization. +- If per-episode variation is required but unsupported, pre-generate variants or use separate authored assets. + +## Interval Disturbance + +Input: apply random pushes during locomotion training. + +Expected setup: + +- Use an interval event. +- Choose a clear interval and magnitude range. +- Start with a small number of environments and inspect failures before scaling. + +## Backend-Specific Material Randomization + +Input: randomize rigid-body material properties in an environment that should support PhysX and Newton. + +Expected setup: + +- Read the `randomize_rigid_body_material` implementation before choosing parameters. +- Use PhysX buckets and static/dynamic friction ranges for the PhysX preset. +- Use Newton's single friction coefficient behavior for the Newton preset. +- Do not assume `dynamic_friction_range`, `num_buckets`, or CPU/GPU behavior are identical across backends. + +Pattern: + +```python +import isaaclab.envs.mdp as mdp +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils.configclass import configclass +from isaaclab_tasks.utils import PresetCfg + + +@configclass +class PhysxEventCfg: + physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=".*"), + "static_friction_range": (0.6, 1.2), + "dynamic_friction_range": (0.5, 1.0), + "restitution_range": (0.0, 0.1), + "num_buckets": 64, + }, + ) + + +@configclass +class NewtonEventCfg: + physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=".*"), + "static_friction_range": (0.6, 1.2), + "dynamic_friction_range": (0.6, 1.2), + "restitution_range": (0.0, 0.1), + "num_buckets": 1, + }, + ) + + +@configclass +class EventCfg(PresetCfg): + default = PhysxEventCfg() + physx = PhysxEventCfg() + newton_mjwarp = NewtonEventCfg() +``` + +Then assign `events: EventCfg = EventCfg()` on the environment config. Verify the exact event parameters against `source/isaaclab/isaaclab/envs/mdp/events.py` before using this pattern. diff --git a/skills/user/domain-randomization-events/reference.md b/skills/user/domain-randomization-events/reference.md new file mode 100644 index 00000000000..4b35f042425 --- /dev/null +++ b/skills/user/domain-randomization-events/reference.md @@ -0,0 +1,80 @@ +# Event Randomization Reference + +## Contents + +- Direct and manager-based usage +- Event mode selection +- Backend compatibility +- Timing limitations +- Current workflow +- Old patterns +- Validation checklist + +## Direct and Manager-Based Usage + +Event randomization is not limited to manager-based environments. Direct RL and direct multi-agent RL configs also expose an `events` field that is handled by `EventManager`. + +Use the same event-term structure in both workflows: + +| Workflow | Where events are configured | +| --- | --- | +| Direct RL | Add an event config class to the direct task config's `events` field. | +| Direct MARL | Add an event config class to the direct multi-agent task config's `events` field. | +| Manager-based | Add an event config class to the manager-based env config's `events` field. | + +In direct workflows, observations and rewards remain direct methods; only randomization is routed through event terms. + +## Event Mode Selection + +Use this default mapping: + +| Desired behavior | Event mode | +| --- | --- | +| Author USD-level properties before simulation starts | `prestartup` | +| Randomize once after simulation starts | `startup` | +| Randomize at episode reset | `reset` | +| Apply repeated disturbances during an episode | `interval` | + +## Backend Compatibility + +Check each event function against the current implementation before assuming the same behavior on PhysX and Newton. Some event functions are backend-neutral through asset APIs, while others dispatch to backend-specific implementations. + +Important examples from `source/isaaclab/isaaclab/envs/mdp/events.py`: + +| Event concern | PhysX behavior | Newton behavior | +| --- | --- | --- | +| Rigid body material randomization | Bucket-based static friction, dynamic friction, and restitution; uses CPU tensors and the PhysX tensor API. | Continuous per-shape friction and restitution; Newton uses one friction coefficient, so `dynamic_friction_range` and `num_buckets` are ignored. | +| Rigid body CoM randomization | Writes full CoM pose data. | Writes position-only CoM data; runtime CoM changes may have different stability implications. | +| Collider offset randomization | Uses PhysX rest/contact offsets. | Maps to Newton margin/gap concepts. | + +For multi-backend environments, use `PresetCfg` event configs. Keep PhysX-only terms in the PhysX preset and provide a Newton-compatible preset when an event is unsupported or has different parameters. + +## Timing Limitations + +Some randomizations are only valid before simulation starts. Use `prestartup` for USD-stage or topology-level authoring that must happen before physics views and simulation buffers are created. Do not move those changes to `reset` or `interval` just to get per-episode variation. + +Use `startup` for one-time setup after simulation starts. Use `reset` only for state or parameter changes that the environment and backend support after initialization. Use `interval` for runtime disturbances such as pushes. + +If the desired property cannot be changed after startup, choose between pre-generating variants, cloning separate authored assets, or documenting that the randomization is fixed per run. + +## Current Workflow + +Define randomization as event terms in the environment configuration. Each term should identify the workflow, target scene entity, backend, mode, distribution, range, and timing limitation. + +Keep randomization ranges conservative at first. Expand ranges after reset and rollout smoke tests pass. + +## Old Patterns + +Avoid hiding domain randomization in environment constructors, training scripts, or reward functions. Those patterns make the randomized behavior hard to inspect and hard to validate. + +## Validation Checklist + +- Each randomized property has a target scene entity. +- The plan states whether the task is direct or manager-based. +- Each randomized property has an explicit backend compatibility note. +- Event modes match the intended timing. +- Prestartup-only changes are not placed in reset or interval events. +- CPU/GPU expectations match the event implementation. +- Distribution ranges use the expected units. +- Repeated resets produce valid states. +- Interval disturbances do not break tensor shapes or device placement. diff --git a/skills/user/migrate-2x-to-3x/SKILL.md b/skills/user/migrate-2x-to-3x/SKILL.md new file mode 100644 index 00000000000..88c9dba8582 --- /dev/null +++ b/skills/user/migrate-2x-to-3x/SKILL.md @@ -0,0 +1,54 @@ +--- +name: isaaclab-migrating-2x-to-3x +description: Migrates Isaac Lab 2.x projects to Isaac Lab 3.0 by routing agents through the official migration guide, current source APIs, and focused compatibility checks. Use when users mention Isaac Lab 3.0 migration, 2.x projects, quaternion order changes, ProxyArray data access, backend migration, or visualization CLI changes. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Migrating 2.x To 3.x + +## When To Use + +Use this skill when migrating a downstream Isaac Lab 2.x project to Isaac Lab 3.0 or diagnosing migration errors after an upgrade. + +Do not copy migration tables into answers from memory. Read the official migration guide first, then inspect the current source or examples for the specific API involved. + +## Workflow + +1. Read the official migration guide in `docs/source/migration/migrating_to_isaaclab_3-0.rst`. +2. Identify which migration area applies: visualization CLI, backend packages, schema cfgs, quaternion order, `ProxyArray`, asset views, RSL-RL config, or project-specific scripts. +3. Search the downstream project for old API symbols before editing. +4. Apply the smallest focused migration change. +5. Run a targeted smoke test or import test. +6. If the official docs are missing a recurring migration issue, update `docs/source/migration/migrating_to_isaaclab_3-0.rst` instead of expanding this skill with standalone documentation. + +## Validation + +Use this feedback loop: + +```bash +uv run --with pytest python -m pytest PATH_TO_DOWNSTREAM_TEST +``` + +For quaternion migrations, use the repository quaternion tooling documented in the official migration guide. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `docs/source/migration/migrating_to_isaaclab_3-0.rst`, `docs/source/setup/installation/uv_run.rst`, `source/isaaclab_rl/isaaclab_rl/rsl_rl/utils.py`, and the uv-based `train` and `play` entry points. If code changes invalidate migration guidance, update the official migration document first and keep this skill as a router plus checklist. + +## References + +- [Reference](reference.md) +- [Supplemental checks](supplemental-checks.md) +- [Examples](examples.md) +- [Evaluations](evaluations.md) +- [Official migration guide](../../../docs/source/migration/migrating_to_isaaclab_3-0.rst) +- [RSL-RL compatibility helper](../../../source/isaaclab_rl/isaaclab_rl/rsl_rl/utils.py) diff --git a/skills/user/migrate-2x-to-3x/evaluations.md b/skills/user/migrate-2x-to-3x/evaluations.md new file mode 100644 index 00000000000..886e2a40850 --- /dev/null +++ b/skills/user/migrate-2x-to-3x/evaluations.md @@ -0,0 +1,53 @@ +# Isaac Lab 3.0 Migration Evaluations + +## Contents + +- Scenario 1: quaternion migration +- Scenario 2: ProxyArray migration +- Scenario 3: RSL-RL migration + +## Scenario 1: Quaternion Migration + +Query: "My Isaac Lab 2.x project has hardcoded quaternions and now behaves incorrectly in 3.0." + +Expected behavior: + +- Reads the official migration guide before proposing edits. +- Uses the quaternion finder workflow documented in the repo. +- Distinguishes quaternions from colors or other four-element values. +- Recommends a small task smoke test. + +Known failure modes: + +- Reorders every four-element tuple blindly. +- Ignores runtime quaternion index assignments. + +## Scenario 2: ProxyArray Migration + +Query: "After upgrading, `asset.data.joint_pos.detach()` fails." + +Expected behavior: + +- Routes to the current `ProxyArray` migration guidance. +- Verifies the current access pattern against docs/source before editing. +- Avoids stale raw `wp.to_torch()`-only guidance when the current docs recommend explicit `ProxyArray` accessors. + +Known failure modes: + +- Copies old raw Warp-array instructions from an external skill. +- Changes unrelated asset APIs without checking current source. + +## Scenario 3: RSL-RL Migration + +Query: "My old RSL-RL training script fails after migrating to Isaac Lab 3.0." + +Expected behavior: + +- Checks maintained Isaac Lab RSL-RL scripts and compatibility helpers. +- Identifies whether the downstream script can be replaced with or aligned to the maintained script. +- Runs a small config or training smoke test. + +Known failure modes: + +- Patches a copied training script without comparing to the current maintained implementation. +- Hardcodes old version-specific assumptions into the skill. diff --git a/skills/user/migrate-2x-to-3x/examples.md b/skills/user/migrate-2x-to-3x/examples.md new file mode 100644 index 00000000000..feeb3fd67af --- /dev/null +++ b/skills/user/migrate-2x-to-3x/examples.md @@ -0,0 +1,41 @@ +# Isaac Lab 3.0 Migration Examples + +## Contents + +- Quaternion migration +- ProxyArray migration +- RSL-RL compatibility migration + +## Quaternion Migration + +Input: downstream code contains hardcoded quaternions and direct quaternion index assignments. + +Expected workflow: + +1. Read the quaternion section of the official migration guide. +2. Run the documented quaternion finder tool on the downstream project. +3. Fix identity quaternions mechanically when safe. +4. Review non-identity quaternions and runtime index assignments manually. +5. Run a small task smoke test. + +## ProxyArray Migration + +Input: downstream code calls tensor-only methods on asset or sensor data. + +Expected workflow: + +1. Read the `ProxyArray` sections of the official migration guide. +2. Search for tensor-only method calls on `.data.*` properties. +3. Use the current `ProxyArray` access pattern from the docs and source. +4. Run focused tests around the migrated logic. + +## RSL-RL Compatibility Migration + +Input: downstream training scripts fail after the RSL-RL upgrade. + +Expected workflow: + +1. Inspect the current Isaac Lab RSL-RL entry points, agent configs, and `source/isaaclab_rl/isaaclab_rl/rsl_rl/utils.py`. +2. Use the compatibility helper when it applies. +3. Avoid preserving copied training scripts if a maintained Isaac Lab script can be reused or imported. +4. Run a short training or config-construction smoke test. diff --git a/skills/user/migrate-2x-to-3x/reference.md b/skills/user/migrate-2x-to-3x/reference.md new file mode 100644 index 00000000000..656175f6dc9 --- /dev/null +++ b/skills/user/migrate-2x-to-3x/reference.md @@ -0,0 +1,36 @@ +# Isaac Lab 2.x To 3.x Migration Reference + +## Contents + +- Source of truth +- Current workflow +- Old patterns +- Maintenance rule + +## Source of Truth + +The official migration guide is the authoritative source: + +- `docs/source/migration/migrating_to_isaaclab_3-0.rst` + +Use this skill to decide where to look and how to validate. Do not treat this file as a replacement for the migration guide. + +## Current Workflow + +Start with the official migration guide, then verify behavior against current source files and examples. Prioritize these areas: + +- Visualizer CLI and headless behavior. +- Multi-backend architecture and backend-specific packages. +- Schema configuration class refactors. +- Quaternion convention changes. +- `ProxyArray` access for asset and sensor data. +- Asset and physics view API renames. +- RL workflow compatibility helpers. + +## Old Patterns + +Avoid carrying forward old project-specific migration notes as general guidance. The external prototype skill included useful discoveries, but it also contained downstream-project paths, stale raw Warp-array guidance, and one-off commands. Convert those into short search checks and official doc improvements. + +## Maintenance Rule + +If a repeated migration fix is not covered by the official guide, update `docs/source/migration/migrating_to_isaaclab_3-0.rst`. Keep this skill small so it does not become a second migration document that drifts from the codebase. diff --git a/skills/user/migrate-2x-to-3x/supplemental-checks.md b/skills/user/migrate-2x-to-3x/supplemental-checks.md new file mode 100644 index 00000000000..3b42532fd25 --- /dev/null +++ b/skills/user/migrate-2x-to-3x/supplemental-checks.md @@ -0,0 +1,50 @@ +# Supplemental Migration Checks + +## Contents + +- Current-source checks +- Prototype-skill checks +- Documentation gaps + +## Current-Source Checks + +Use these checks to route investigation, not as standalone migration docs: + +| Symptom or old pattern | Current source of truth | +| --- | --- | +| Task names include the old Gym version suffix, such as `-v0` | Current task docs and the environment catalog; use suffixless task names in examples | +| `--headless` launch behavior changed | `docs/source/migration/migrating_to_isaaclab_3-0.rst` and `source/isaaclab/isaaclab/app/app_launcher.py` | +| Camera examples require `--enable_cameras` by default | Current sensor, renderer, and visualization docs; do not add the flag unless the task or docs explicitly require it | +| Backend-specific physics or schema cfgs | `docs/source/overview/core-concepts/multi_backend_architecture.rst` and `docs/source/overview/core-concepts/schema_cfgs.rst` | +| Imports of PhysX/Newton schema cfgs from `isaaclab.sim.schemas` | Move backend-specific imports to `isaaclab_physx.sim.schemas` or `isaaclab_newton.sim.schemas`; core forwarding shims are deprecated | +| Spawner schema overrides that need multiple namespaces in one slot | Prefer schema fragments such as `UsdPhysicsDriveCfg`, `PhysxJointCfg`, `NewtonCollisionCfg`, or `MujocoJointCfg` instead of forcing one legacy property cfg to carry every backend attribute | +| Quaternion order changed from WXYZ to XYZW | `docs/source/migration/migrating_to_isaaclab_3-0.rst` and `scripts/tools/find_quaternions.py` | +| Asset or sensor data no longer behaves like plain tensors | `ProxyArray` sections in `docs/source/migration/migrating_to_isaaclab_3-0.rst` | +| `root_physx_view` or object API warnings | asset view sections in `docs/source/migration/migrating_to_isaaclab_3-0.rst` | +| RSL-RL config compatibility errors | `source/isaaclab_rl/isaaclab_rl/rsl_rl/utils.py` | +| Pretrained checkpoint helper import path | `source/isaaclab_rl/isaaclab_rl/utils/pretrained_checkpoint.py` | + +## Prototype-Skill Checks + +The external prototype migration skill called out useful search terms. Before using any fix from that prototype, verify against current docs/source: + +- `AdditiveUniformNoiseCfg` +- `SimulationCfg.physics` +- `PhysxCfg` +- `isaaclab.sim.schemas` +- `PhysxRigidBodyPropertiesCfg` +- `NewtonCollisionPropertiesCfg` +- `UsdPhysicsDriveCfg` +- `PhysxJointCfg` +- `MujocoJointCfg` +- `asset.data.*.detach()` +- `root_physx_view` +- `get_published_pretrained_checkpoint` +- `noise_std_type` +- `--viz` +- `--enable_cameras` +- `-v0` + +## Documentation Gaps + +If one of these checks reveals a real migration issue that is missing from the official migration guide, add the migration note to `docs/source/migration/migrating_to_isaaclab_3-0.rst` and keep this file as a pointer. diff --git a/skills/user/migrate-from-isaac-gym/SKILL.md b/skills/user/migrate-from-isaac-gym/SKILL.md new file mode 100644 index 00000000000..583cbe66a48 --- /dev/null +++ b/skills/user/migrate-from-isaac-gym/SKILL.md @@ -0,0 +1,78 @@ +--- +name: isaaclab-migrating-from-isaac-gym +description: Migrates Isaac Gym tasks, assets, vectorized environments, and training workflows to Isaac Lab. Use when porting Isaac Gym environments, legacy Gym tasks, or Isaac Gym reinforcement learning code to Isaac Lab. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Migrating From Isaac Gym + +## When To Use + +Use this skill when a user wants to port an Isaac Gym task, asset workflow, observation/reward implementation, or training setup into Isaac Lab. + +Do not use this skill for Isaac Lab 2.x to 3.x migration. Use the `isaaclab-migrating-2x-to-3x` skill for that workflow. + +## Workflow + +1. Identify the Isaac Gym task structure: assets, environment state tensors, observations, rewards, resets, and training runner. +2. If the user needs a new full-feature Isaac Sim setup, point them to the pip/uv installation docs first. If the user expects execution or training, run a runtime preflight from the Isaac Lab checkout before a long port: verify `uv run python` uses the intended Python environment and checkout, imports `isaacsim` and `omni`, and provides the requested RL library. +3. Read the IsaacGymEnvs migration guide and direct workflow docs before proposing edits. +4. For a scratch or external migration project, start from the Isaac Lab template generator instead of hand-rolling package scaffolding. From the Isaac Lab checkout, use `uv run isaaclab -n`, choose an external project, choose the scratch path, choose the direct single-agent workflow for Isaac Gym style tasks, and select the needed RL library such as `rsl_rl`. +5. Migrate to a direct workflow first by default. This preserves the single-class structure that most Isaac Gym tasks already use. +6. Choose the initial backend target. Start with PhysX when matching Isaac Gym behavior; add Newton only after the direct PhysX migration is validated or if the user explicitly targets Newton. +7. Map Isaac Gym PhysX parameters through the schema cfg docs: first to Isaac Lab PhysX cfgs, then to backend-portable base cfgs or Newton/MuJoCo cfgs where an equivalent exists. +8. Map assets to Isaac Lab asset configs and scene entities. +9. Move action application, observation assembly, reward computation, termination checks, and reset logic into a `DirectRLEnv` or `DirectMARLEnv` implementation. +10. Port training configuration to the selected Isaac Lab reinforcement learning workflow. +11. Run a small smoke test before scaling training. Do not use deprecated `--headless` examples; omit `--viz` for headless execution, or use `--viz none` only when a config or command would otherwise enable a visualizer. +12. For locomotion migrations, run the policy-success validation loop in [Reference](reference.md#policy-success-validation-loop). Validate a flat walking policy before rough-terrain curriculum training; rough terrain can start and still be unhealthy if episodes terminate immediately. If the legacy task's command range is broad, use a staged command curriculum or a simpler flat source config such as IsaacGymEnvs `Anymal.yaml` before claiming policy success. +13. After the direct migration resets, steps, and trains, recommend a manager-based follow-up when the task has reusable observation, reward, command, curriculum, termination, or event logic. +14. Use the `isaaclab-converting-direct-to-manager` skill for that follow-up instead of mixing manager conversion into the first parity pass. +15. Iterate through the validation loop until the environment resets, steps, trains, and reaches a task-appropriate policy metric. Do not claim policy success from a completed training command, checkpoint file, or improving scalar alone; parse training metrics and run a bounded checkpoint rollout. + +## Validation + +Use this feedback loop: + +```bash +uv run --with pytest python -m pytest PATH_TO_MIGRATION_TEST +``` + +For manual smoke testing, run the smallest random-action entry point available for the migrated task before training. For external scratch work, prefer a template-generated external project and install its extension in editable mode, or put the generated project extension and every package under the Isaac Lab checkout's `source/` directory at the front of `PYTHONPATH`; this avoids accidentally importing `isaaclab_tasks` or extension packages from another checkout or installed wheel. Ensure the task package is imported before Gym lookup; use a small wrapper for scripts without `--external_callback`, and use the callback option when a training script exposes one. + +For policy validation, follow the policy-success loop in [Reference](reference.md#policy-success-validation-loop). The loop must import/register the migrated task, smoke-test reset and random steps, train to a useful budget, parse TensorBoard or equivalent scalars, evaluate the saved checkpoint in a bounded rollout, then adjust the migration and rerun the shortest affected gate until the policy succeeds or a concrete blocker is identified. + +Runtime preflight for execution/training requests: + +```bash +uv run python -c "import importlib.util, sys; print(sys.executable); print(sys.version); print(importlib.util.find_spec('isaacsim')); print(importlib.util.find_spec('omni'))" +``` + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `docs/source/migration/migrating_from_isaacgymenvs.rst`, `docs/source/setup/installation/pip_installation.rst`, `docs/source/overview/core-concepts/task_workflows.rst`, `docs/source/overview/core-concepts/multi_backend_architecture.rst`, `docs/source/overview/core-concepts/schema_cfgs.rst`, the direct environment tutorial, and direct task examples such as `source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/`, `source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/`, and `source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_d/`. If the migration requires documentation-level details, update `docs/source/` or the maintained examples first and keep this skill as a workflow router. + +## References + +- [Reference](reference.md) +- [Examples](examples.md) +- [Rough locomotion validation](validation-rough-locomotion.md) +- [Initial Ant smoke validation](validation-ant-fresh-agent.md) +- [Evaluations](evaluations.md) +- [Direct to manager conversion skill](../convert-direct-to-manager/SKILL.md) +- [IsaacGymEnvs migration guide](../../../docs/source/migration/migrating_from_isaacgymenvs.rst) +- [Task workflows](../../../docs/source/overview/core-concepts/task_workflows.rst) +- [Multi-backend architecture](../../../docs/source/overview/core-concepts/multi_backend_architecture.rst) +- [Schema cfgs](../../../docs/source/overview/core-concepts/schema_cfgs.rst) +- [Environments overview](../../../docs/source/overview/environments.rst) +- [Create direct workflow environment tutorial](../../../docs/source/tutorials/03_envs/create_direct_rl_env.rst) +- [Create manager-based environment tutorial](../../../docs/source/tutorials/03_envs/create_manager_rl_env.rst) diff --git a/skills/user/migrate-from-isaac-gym/evaluations.md b/skills/user/migrate-from-isaac-gym/evaluations.md new file mode 100644 index 00000000000..b92b64eb065 --- /dev/null +++ b/skills/user/migrate-from-isaac-gym/evaluations.md @@ -0,0 +1,75 @@ +# Isaac Gym Migration Evaluations + +## Contents + +- Scenario 1: rough locomotion migration +- Scenario 2: sensor and terrain migration +- Scenario 3: randomization migration +- Scenario 4: Newton backend follow-up + +## Scenario 1: Rough Locomotion Migration + +Query: "Port this IsaacGymEnvs rough-terrain quadruped task to Isaac Lab." + +Expected behavior: + +- Identifies terrain generation, robot asset, commands, observations, rewards, resets, sensors, randomization, and training configuration. +- Recommends a direct environment as the first migration target. +- Maps reward, termination, command, and reset logic into direct environment methods. +- Recommends a manager-based follow-up after direct reset, step, and short training validation. +- Gives import, reset/random-step, short-training, full-training, metric-parsing, and checkpoint-rollout gates before claiming policy success. +- Stages flat locomotion before rough-terrain curriculum when the rough source command ranges or terrain curriculum prevent a healthy first policy. + +Known failure modes: + +- Starts by decomposing into manager terms before preserving behavior in direct form. +- Treats the direct migration as the final structure even after behavior is stable and reusable manager terms would help. +- Omits terrain or sensor behavior from the migration plan. +- Treats a completed training command or checkpoint file as policy success without parsed metrics and rollout evidence. + +## Scenario 2: Sensor and Terrain Migration + +Query: "Migrate the height measurements and contact force observations from my Isaac Gym quadruped terrain task." + +Expected behavior: + +- Maps terrain generation to `TerrainImporterCfg` and a maintained terrain generator. +- Maps height measurements to a ray-caster height scanner when appropriate. +- Maps contact-force usage to contact sensors. +- Calls out observation-block parity and shape validation. + +Known failure modes: + +- Drops the height measurement block because it is not part of the flat locomotion task. +- Assumes contact-force tensors map directly without sensor configuration. + +## Scenario 3: Randomization Migration + +Query: "Move my Isaac Gym reset randomization to Isaac Lab." + +Expected behavior: + +- Preserves reset randomization behavior in the direct migration first. +- Distinguishes startup, reset, and interval randomization when the user asks for event terms. +- Recommends validation with repeated resets and small environment counts. + +Known failure modes: + +- Places all randomization in environment constructor code. +- Does not verify reset determinism or tensor shapes. + +## Scenario 4: Newton Backend Follow-Up + +Query: "The migrated Isaac Gym task works on PhysX. Add Newton support." + +Expected behavior: + +- Keeps the validated direct workflow intact. +- Uses `PresetCfg` variants for backend-specific physics settings. +- Maps PhysX schema cfgs through the multi-backend and schema cfg docs before proposing Newton equivalents. +- Calls out parameters with no direct Newton equivalent instead of silently copying them. + +Known failure modes: + +- Assumes every PhysX parameter maps one-to-one to Newton. +- Replaces the PhysX config instead of adding a backend preset. diff --git a/skills/user/migrate-from-isaac-gym/examples.md b/skills/user/migrate-from-isaac-gym/examples.md new file mode 100644 index 00000000000..9c0e3adb972 --- /dev/null +++ b/skills/user/migrate-from-isaac-gym/examples.md @@ -0,0 +1,50 @@ +# Isaac Gym Migration Examples + +## Contents + +- Rough locomotion task +- PhysX to Newton follow-up +- Domain randomization handoff + +## Rough Locomotion Task + +Input: an Isaac Gym rough-terrain quadruped task uses generated terrain, velocity commands, contact forces, height measurements, reset randomization, and random pushes. + +Output: an Isaac Lab direct environment with: + +- Terrain importer config with a maintained rough terrain generator. +- Articulation config for the quadruped. +- Contact sensors for feet and undesired body contacts. +- Ray-caster height scanner for terrain perception. +- A `DirectRLEnvCfg` config. +- Direct command, action, observation, reward, termination, and reset methods. +- Randomization added after the deterministic direct task runs. + +Follow-up: after reset, step, and short training pass, recommend converting reusable command, observation, reward, termination, event, and curriculum logic into a manager-based environment with `isaaclab-converting-direct-to-manager`. + +## Domain Randomization Handoff + +When the migration task is mostly about randomization behavior, first preserve behavior in the direct migration. If the user asks for manager-based randomization or reusable event terms, switch to the `isaaclab-randomizing-with-events` skill. + +## Manager-Based Follow-Up + +Input: a migrated direct locomotion task now runs and trains, and the user wants reusable terms. + +Expected workflow: + +- Use `isaaclab-converting-direct-to-manager`. +- Keep the direct environment as a parity baseline until the manager-based version matches core behavior. +- Move observations to `ObsTerm` entries, rewards to `RewTerm` entries, resets/randomization to `EventTerm` entries, termination checks to `DoneTerm` entries, and command sampling to command configs. +- Validate the manager-based task with the same random-agent and short-training gates used for the direct migration. + +## PhysX to Newton Follow-Up + +Input: a migrated direct task runs on PhysX and the user asks to add Newton support. + +Expected workflow: + +- Read the multi-backend and schema cfg docs. +- Add `PresetCfg` variants for PhysX and Newton physics settings. +- Keep PhysX-specific parameters in the PhysX preset. +- Add Newton or MuJoCo solver parameters only where the docs/source show an equivalent. +- Validate PhysX and Newton separately with small smoke tests. diff --git a/skills/user/migrate-from-isaac-gym/reference.md b/skills/user/migrate-from-isaac-gym/reference.md new file mode 100644 index 00000000000..59de05d2ebd --- /dev/null +++ b/skills/user/migrate-from-isaac-gym/reference.md @@ -0,0 +1,203 @@ +# Isaac Gym Migration Reference + +## Contents + +- Direct workflow mapping +- External template projects +- Locomotion training gates +- Policy success validation loop +- Legacy force and torque sensors +- Backend mapping +- Manager-based follow-up mapping +- Current workflow +- Old patterns +- Validation checklist + +## Direct Workflow Mapping + +Use this mapping as the default starting point: + +| Isaac Gym concept | Isaac Lab target | +| --- | --- | +| Task class with tensor buffers | `DirectRLEnv` or `DirectMARLEnv` subclass | +| YAML task config | `DirectRLEnvCfg` or `DirectMARLEnvCfg` config class | +| Asset loading in task setup | Asset config and scene config | +| Action application | Direct environment action methods | +| Observation buffer assembly | Direct observation method | +| Reward tensor functions | Direct reward method or helper function | +| Reset logic | Direct reset method | +| Domain randomization hooks | Direct reset/startup logic first, event terms if the user wants manager-style randomization | +| RL runner script | Isaac Lab training script for the chosen framework | + +## External Template Projects + +When validating a migration outside the Isaac Lab tree, start with the template generator instead of hand-rolling the external package structure. From the Isaac Lab checkout, run `uv run isaaclab -n`, then choose: + +- `External` project. +- The scratch directory as the project path. +- A valid Python identifier for the project name, such as `isaacgym_anymal_migration`. +- `Direct | single-agent` for the first Isaac Gym parity pass. +- The target RL library, usually `rsl_rl` for locomotion validation. + +Replace the generated task implementation with the migrated environment, config, registration, and agent config, preserving the generated project layout. The generated project gives agents a known `source///tasks/...` structure, `pyproject.toml`, extension metadata, scripts, and task registration pattern. + +For validation, install the generated extension in editable mode with `uv pip` for the target Isaac Lab environment, or keep it importable through `PYTHONPATH`. If using `PYTHONPATH`, put the generated project's extension package first, then every package directory under the target Isaac Lab checkout's `source/` directory. This prevents Python from mixing the active checkout with another installed Isaac Lab checkout and causing duplicate Gym registration or stale task imports. If editable install makes task registration available automatically, omit external callbacks. For uninstalled scratch validation, use a small wrapper for scripts without `--external_callback`, and use the callback option when a training script exposes one. + +Do not treat successful config loading as training success. Import/register, config resolution, static compilation, reset/step, random-agent, and short training are separate gates. + +## Locomotion Training Gates + +For quadruped and rough-terrain migrations, start with the flat walking variant before training terrain curriculum. A rough-terrain run can execute while still failing behaviorally because the robot falls or terminates on base contact immediately. + +Full legacy command ranges may be too hard for a first policy validation. For example, IsaacGymEnvs `AnymalTerrain.yaml` samples yaw commands across `[-3.14, 3.14]`. When the goal is to prove a walking policy, either stage the command ranges as curriculum or validate first against the simpler flat `Anymal.yaml` behavior, and document the temporary deviation from exact terrain-task parity. + +Treat these as separate gates: + +- Flat task reset/step succeeds with expected observation and action shapes. +- Flat training improves reward and mean episode length toward the timeout horizon. +- The saved checkpoint loads in `play` or an equivalent bounded rollout. +- Rough-terrain training starts only after the flat policy is healthy, then tracks terrain-specific curriculum, height-scanner, and contact metrics. + +## Policy Success Validation Loop + +When a user asks for a migrated RL environment that trains successfully, automate the full train/evaluate/iterate loop. A training command with exit code 0 only proves that the runner executed. A checkpoint file only proves that the runner saved state. Policy success requires task-level evidence from training metrics and a loaded checkpoint rollout. + +Before training, define the success criteria in the validation notes. Prefer an explicit task success metric from the source task or benchmark. If the task has no explicit success scalar, define proxy gates such as: + +- Mean episode reward improves and remains stable over the last training window. +- Mean episode length approaches the task timeout horizon instead of ending mostly from falls, collisions, or invalid resets. +- Checkpoint rollout loads the saved policy and completes bounded episodes with behavior consistent with the task. +- Locomotion tasks keep base-contact or fall terminations low and report reasonable command-tracking errors. +- Any task-specific success rate, distance, velocity error, pose error, or object-state metric crosses the declared threshold. + +Use this loop for each implementation iteration: + +1. Register the migrated task from the external package or local module before any Gym lookup. +2. Run import and registration checks against the active Isaac Lab checkout. +3. Run a small reset and random-step smoke test; treat sensor-path warnings, invalid observation shapes, NaNs, and immediate terminations as failures. +4. Run a short training smoke only to verify runner integration. +5. Run the policy training budget needed for the task's declared success criteria. +6. Parse TensorBoard, JSON, or stdout scalars from that run; do not inspect only the final terminal lines. +7. Load the saved checkpoint in `play` or an equivalent bounded rollout and collect rollout metrics. +8. If the policy fails, modify the migration and rerun the shortest affected gate. Continue until success or until a concrete blocker is documented. + +For an external template project, use commands like these as templates. Replace the task id, callback, project path, extension path, and log paths with the migrated package names: + +```bash +export LAB=/path/to/IsaacLab +export PROJECT=/path/to/migration-scratch/isaacgym_anymal_migration +export EXTENSION="$PROJECT/source/isaacgym_anymal_migration" +export PYTHONPATH="$EXTENSION:$(find "$LAB/source" -mindepth 1 -maxdepth 1 -type d | paste -sd: -)" +cd "$LAB" + +# Optional when validating as an installed template project: +uv pip install -e "$EXTENSION" + +uv run python -c "import gymnasium as gym; import my_migration; tid='My-Migrated-Task-v0'; spec=gym.spec(tid); print(spec.entry_point); print(spec.kwargs)" + +uv run python "$PROJECT/validation/smoke_my_task.py" --device cuda:0 --num_envs 16 --steps 8 + +uv run isaaclab train --rl_library rsl_rl \ + --task My-Migrated-Task-v0 \ + --external_callback my_migration.register.register \ + --device cuda:0 --num_envs 4096 --max_iterations 500 + +uv run python "$PROJECT/validation/parse_tensorboard.py" logs/path/to/run --output "$PROJECT/validation/train_metrics.json" + +uv run python "$PROJECT/validation/evaluate_checkpoint.py" \ + --checkpoint logs/path/to/run/model_499.pt \ + --num_envs 64 --steps 256 --device cuda:0 +``` + +Omit `--viz` for headless validation. Use `--viz none` only when a config or command would otherwise enable visualizers. Do not use deprecated `--headless` in new validation commands. + +On Windows, use the same `uv` commands from PowerShell and set the same path order: + +```powershell +$lab = "C:/path/to/IsaacLab" +$project = "C:/path/to/migration-scratch/isaacgym_anymal_migration" +$extension = "$project/source/isaacgym_anymal_migration" +$srcs = Get-ChildItem "$lab/source" -Directory | ForEach-Object { $_.FullName } +$env:PYTHONPATH = $extension + ";" + ($srcs -join ";") +Set-Location $lab +``` + +If no validation helper scripts exist, create the smallest scratch-only smoke, scalar parsing, and checkpoint evaluation scripts needed for the migration task. Do not add those helpers to Isaac Lab unless the user asks for committed validation files. + +When a locomotion policy fails despite import/reset/step/training success, check these migration points before increasing training time: + +- Command ranges and curriculum staging; broad yaw or velocity ranges may need staged validation. +- Reward signs, scales, clipping, alive terms, and episode-length scaling. +- Fall, base-contact, and timeout termination thresholds. +- Contact sensor, force sensor, ray caster, and body-name paths. +- Default pose, joint order, action scaling, PD gains, and drive modes. +- Observation order, units, clipping, normalization, noise, and missing history terms. +- Reset height, terrain origin, terrain curriculum, friction, mass, and push randomization. + +For IsaacGymEnvs Anymal migration, validate flat walking before rough terrain. If `AnymalTerrain.yaml`'s full yaw command range prevents a healthy policy, narrow or curriculum-stage the commands, or first migrate the flat `Anymal.yaml` behavior, then reintroduce AnymalTerrain yaw ranges and rough-terrain curriculum. + +## Legacy Force And Torque Sensors + +Isaac Gym locomotion tasks may use force sensor tensors or net contact force tensors as policy inputs. In Isaac Lab, map these deliberately: + +- Use `ContactSensorCfg` when the legacy observation only needs body net contact forces. +- Use `JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot")` plus `SceneEntityCfg(..., body_names=[...])` when the legacy task depends on foot force and torque components. The maintained manager Ant task uses `mdp.body_incoming_wrench` this way for foot observations. +- Preserve the original observation shape only when parity requires it, and document any zero-padded or intentionally dropped torque slots. +- Validate sensor paths against the runtime asset's body names before training. Treat warnings such as "Failed to find rigid body" or "Failed to find contact report API" as validation failures, not harmless noise. + +## Backend Mapping + +Use PhysX as the first target when preserving Isaac Gym behavior, because Isaac Gym tasks were PhysX-based. Do not assume every PhysX parameter has a Newton equivalent. + +Map backend parameters through the official schema docs. Current spawner slots may accept either property cfg wrappers or schema-fragment lists. Use property cfg wrappers for the common single-cfg path, and use fragments when a slot must combine multiple USD namespaces such as universal USD physics plus PhysX, Newton, or MuJoCo attributes. + +| Isaac Gym / PhysX concern | Isaac Lab PhysX target | Newton target | +| --- | --- | --- | +| Simulation-level PhysX settings | `PhysxCfg` on `SimulationCfg.physics` | `NewtonCfg` with a solver cfg such as `MJWarpSolverCfg` | +| Rigid-body settings | `PhysxRigidBodyPropertiesCfg`, backend-portable `RigidBodyBaseCfg`, or fragments such as `UsdPhysicsRigidBodyCfg` plus `PhysxRigidBodyCfg` | `NewtonRigidBodyPropertiesCfg`, `MujocoRigidBodyPropertiesCfg`, or fragments such as `MujocoRigidBodyCfg` | +| Collision settings | `PhysxCollisionPropertiesCfg`, `CollisionBaseCfg`, or fragments such as `UsdPhysicsCollisionCfg` plus `PhysxCollisionCfg` | `NewtonCollisionPropertiesCfg`, `NewtonMeshCollisionPropertiesCfg`, `NewtonSDFCollisionPropertiesCfg`, or fragments such as `NewtonCollisionCfg` | +| Mesh cooking settings | `PhysxConvexHullPropertiesCfg`, `PhysxConvexDecompositionPropertiesCfg`, `PhysxTriangleMeshPropertiesCfg`, `PhysxTriangleMeshSimplificationPropertiesCfg`, or `PhysxSDFMeshPropertiesCfg` | `NewtonMeshCollisionPropertiesCfg` or `NewtonSDFCollisionPropertiesCfg` | +| Joint-drive settings | `JointDriveBaseCfg` or fragments such as `UsdPhysicsDriveCfg` plus `PhysxJointCfg` | `NewtonJointDrivePropertiesCfg`, `MujocoJointDrivePropertiesCfg`, or fragments such as `MujocoJointCfg` | +| Material settings | `PhysxRigidBodyMaterialCfg` or `RigidBodyMaterialBaseCfg` | `NewtonMaterialPropertiesCfg` | + +For multi-backend tasks, use `PresetCfg` variants so the PhysX and Newton configs can differ cleanly. Keep backend-specific ranges, solver values, and unsupported options in separate presets. + +Import backend schema classes from their backend packages, not through deprecated core shims: + +- Core universal fragments/base cfgs: `from isaaclab.sim import schemas` +- PhysX cfgs/fragments: `from isaaclab_physx.sim import schemas as physx_schemas` +- Newton and MuJoCo cfgs/fragments: `from isaaclab_newton.sim import schemas as newton_schemas` + +## Manager-Based Follow-Up Mapping + +Use this mapping after the direct migration has reset, stepped, and trained. Recommend the manager-based follow-up when the task's observation, reward, command, event, curriculum, or termination logic should be reusable across robots, terrains, backends, or experiments. Use the `isaaclab-converting-direct-to-manager` skill for the conversion workflow. + +| Direct migration concern | Manager-based target | +| --- | --- | +| Observation method | Observation manager terms | +| Reward method | Reward manager terms | +| Termination checks | Termination manager terms | +| Reset randomization | Event manager reset terms | +| Command sampling | Command manager terms | + +## Current Workflow + +Prefer a direct environment for the first migration pass. This is closer to Isaac Gym task structure and makes it easier to compare observations, rewards, resets, and actions against the original implementation. + +After the direct migration is validated, recommend trying a manager-based version when the task should benefit from Isaac Lab's reusable managers. Keep the first pass direct, but do not leave users with the impression that direct is the desired long-term structure for reusable Isaac Lab tasks. + +## Old Patterns + +Legacy Isaac Gym tasks often combine asset loading, reward computation, reset logic, and randomization in one Python class. During the direct migration, keep the logic easy to compare with the original task, but structure methods according to the Isaac Lab direct workflow. + +## Validation Checklist + +- The migrated environment can construct with a small number of environments. +- `reset()` succeeds repeatedly. +- `step()` returns observations with expected shapes. +- Sensor paths resolve without missing rigid-body or contact-report warnings. +- Rewards and terminations match the intended task behavior. +- Training starts with the chosen RL framework. +- TensorBoard, JSON, or equivalent scalar parsing shows the declared policy-success metrics. +- A saved checkpoint loads in a bounded rollout and meets the declared rollout thresholds. +- A short training run only proves the runner can execute. Claim a successful policy only after a run of sufficient length shows stable reward improvement, episode lengths approaching the task horizon, or the task's explicit success metric. diff --git a/skills/user/migrate-from-isaac-gym/validation-ant-fresh-agent.md b/skills/user/migrate-from-isaac-gym/validation-ant-fresh-agent.md new file mode 100644 index 00000000000..34d691f8b28 --- /dev/null +++ b/skills/user/migrate-from-isaac-gym/validation-ant-fresh-agent.md @@ -0,0 +1,54 @@ +# Initial Ant Smoke Validation + +## Scope + +A fresh agent used this skill to clone `https://github.com/isaac-sim/IsaacGymEnvs`, select the Ant locomotion task, and build a scratch Isaac Lab direct migration outside the Isaac Lab checkout. This is a historical smoke validation for a simple locomotion task; use [rough locomotion validation](validation-rough-locomotion.md) as the representative policy-success validation. For future validations, start scratch work from the Isaac Lab template generator's external project layout. + +Source artifacts: + +- `isaacgymenvs/tasks/ant.py` +- `isaacgymenvs/cfg/task/Ant.yaml` +- `assets/mjcf/nv_ant.xml` + +The scratch migration registered a direct task named `IsaacGym-Ant-Migrated-Direct-v0` with an Isaac Lab Ant analogue, PhysX settings, a 60-dimensional policy observation, contact sensor inputs, and RSL-RL / RL-Games training configs. + +## Results + +Passed gates: + +- Cloned IsaacGymEnvs. +- Imported and registered the migrated task. +- Resolved environment and agent configs. +- Statically compiled the migrated package. + +Runtime rerun with a compatible Isaac Sim runtime: + +- The runtime used the PR checkout, Python 3.12.13, and a Kit 110-era Isaac Sim runtime. +- `isaacsim` and `omni` resolved from the active runtime. The `isaacsim.simulation_app` submodule was not present as a standalone import, but Isaac Lab's `AppLauncher` worked. +- A clean `PYTHONPATH` was required: the generated project extension first, then every package directory under this checkout's `source/`. Without this, Python mixed packages from another Isaac Lab checkout and hit duplicate Gym registrations. +- The migrated environment constructed with 4 environments on `cuda:0`, `reset()` returned observations with shape `(4, 60)`, and one random step returned observations with shape `(4, 60)`. +- A 2-iteration RSL-RL smoke run with 64 environments completed and wrote metrics. Iteration 0 logged mean reward `4.10`, mean episode length `13.00`, and success rate `1.0000`; iteration 1 logged mean reward `-19.77`, mean episode length `43.29`, and success rate `0.0000`. + +Open gates: + +- The scratch migration used a broad `ContactSensorCfg` pattern for foot observations and emitted missing rigid-body/contact-report warnings. Future migrations should validate sensor body names and use `JointWrenchSensorCfg` when legacy force-torque observations need torque components. +- Isaac Lab's random-agent entry point was not validated for this external migration package because it needs a wrapper or callback that imports the task before Gym lookup. +- The short training smoke proved that training starts and steps, but did not prove a successful Ant policy. Do not claim policy success until a longer run shows stable success or reward improvement. + +## Skill Updates From This Validation + +- Run runtime preflight through `uv run python` from the Isaac Lab checkout before promising reset, random-agent, or training success. +- Start external migrations from the Isaac Lab template generator, then put the generated project extension and all target checkout `source/` packages first on `PYTHONPATH` during external validation. +- Make external migration package registration explicit for scripts that do not expose `--external_callback`. +- Add legacy force/torque sensor mapping guidance so agents do not silently drop force sensor observations or ignore unresolved sensor prim warnings. + +## Pass Criteria For A Complete Runtime Validation + +For a complete validation, repeat the same migration and require: + +1. The migrated task constructs with a small environment count. +2. `reset()` and several random `step()` calls succeed. +3. Isaac Lab's random-agent entry point runs against the migrated task. +4. A short training job starts and logs policy metrics. +5. Sensor prim paths and body names resolve without missing rigid-body or contact-report warnings. +6. A longer training job reaches the task's success criterion or shows clear reward improvement against the direct baseline. diff --git a/skills/user/migrate-from-isaac-gym/validation-rough-locomotion.md b/skills/user/migrate-from-isaac-gym/validation-rough-locomotion.md new file mode 100644 index 00000000000..4e0a580a52b --- /dev/null +++ b/skills/user/migrate-from-isaac-gym/validation-rough-locomotion.md @@ -0,0 +1,128 @@ +# Rough Locomotion Migration Validation + +## Contents + +- Source task +- How to use this validation +- Why this is the representative validation +- Direct workflow mapping +- Terrain mapping +- Sensors and observations +- Randomization and backend checks +- Training validation +- Result + +## Source Task + +This validation uses IsaacGymEnvs AnymalTerrain: + +- `isaacgymenvs/tasks/anymal_terrain.py` +- `isaacgymenvs/cfg/task/AnymalTerrain.yaml` + +## How To Use This Validation + +Use this file as an evaluation checklist for the migration skill, not as a ready-made migrated task. A useful migration attempt should: + +- Read the IsaacGymEnvs source task and config. +- Start with a direct Isaac Lab environment target. +- Recommend a manager-based follow-up after the direct task validates, especially for reusable velocity commands, observations, rewards, randomization events, and terminations. +- Produce an explicit mapping for terrain, sensors, observations, rewards, resets, randomization, and backend settings. +- Identify behavior differences between the legacy task and maintained Isaac Lab examples. +- Define small import, reset, step, and training smoke tests before scaling. +- Validate flat walking before rough-terrain curriculum training. + +## Why This Is the Representative Validation + +AnymalTerrain is representative because it combines several migration concerns that usually fail when a skill only handles simple control tasks: + +- Rough triangle-mesh terrain generation with curriculum levels and terrain proportions. +- A quadruped URDF asset with PD control parameters and default joint poses. +- Velocity command sampling. +- Height measurements for terrain perception. +- Net contact force usage for feet, knees, and termination. +- Observation noise, action noise, friction randomization, random pushes, and setup-only mass randomization. +- PhysX simulation parameters such as contact collection, GPU contact pair count, and solver iterations. + +## Direct Workflow Mapping + +| IsaacGymEnvs AnymalTerrain | Isaac Lab direct rough Anymal-C | +| --- | --- | +| `VecTask` subclass | `AnymalCEnv`, a `DirectRLEnv` subclass | +| `AnymalTerrain.yaml` | `AnymalCRoughEnvCfg` config class | +| `numActions = 12` | `action_space = 12` | +| `numObservations = 188` | `observation_space = 235` in the maintained Isaac Lab rough task | +| `dt = 0.005`, `control.decimation = 4` | `sim.dt = 1 / 200`, `decimation = 4` | +| `urdf/anymal_c/urdf/anymal_minimal.urdf` | `isaaclab_assets.robots.anymal.ANYMAL_C_CFG` | +| `create_sim()`, `_create_trimesh()`, `_create_envs()` | `TerrainImporterCfg`, rough terrain generator, and `_setup_scene()` | +| `compute_observations()` | `_get_observations()` with robot state, commands, contact sensor data, and height scanner data | +| `compute_reward()` and reward helpers | `_get_rewards()` with explicit reward terms in the direct environment | +| `reset_idx()` | `_reset_idx()` with command and robot state reset | + +## Terrain Mapping + +IsaacGymEnvs creates a custom triangle mesh through `Terrain(...)` and `gym.add_triangle_mesh(...)`. In Isaac Lab, route this through `TerrainImporterCfg` with `terrain_type="generator"` and a maintained terrain generator such as `ROUGH_TERRAINS_CFG`. + +The IsaacGymEnvs terrain settings map conceptually as: + +| IsaacGymEnvs terrain setting | Isaac Lab target | +| --- | --- | +| `terrainType: trimesh` | `TerrainImporterCfg(terrain_type="generator")` | +| `curriculum` | terrain generator curriculum setting | +| `numLevels`, `numTerrains` | terrain generator rows / columns or difficulty grid | +| `terrainProportions` | terrain generator sub-terrain proportions | +| friction / restitution | terrain physics material | +| custom terrain origins | terrain importer environment origins | + +## Sensors and Observations + +IsaacGymEnvs AnymalTerrain uses net contact forces and sampled terrain heights. The maintained Isaac Lab direct rough Anymal-C task uses: + +- `ContactSensorCfg` for contact history, feet air time, and undesired contacts. +- `RayCasterCfg` mounted on the robot base with rays cast against `/World/ground`. +- Direct observation assembly that includes height scanner data for rough terrain. + +Do not assume observation dimensions will match exactly. A migration that needs behavior parity should compare every observation block and decide whether to preserve IsaacGymEnvs' 188-dimensional observation or adopt the maintained Isaac Lab rough locomotion observation layout. + +## Randomization and Backend Checks + +IsaacGymEnvs AnymalTerrain includes: + +- Observation and action noise. +- Friction randomization. +- Random pushes. +- Setup-only rigid-body mass randomization. +- DOF damping, stiffness, and limit randomization. + +In Isaac Lab, first migrate the direct task until it runs with the maintained baseline randomization. Then port the legacy task's additional randomization through event terms or noise models, checking each item against current source: + +- Use the `isaaclab-randomizing-with-events` skill for event timing and backend compatibility. +- Treat setup-only randomization as `prestartup` or `startup` unless the backend supports runtime changes. +- Check PhysX versus Newton behavior before adding `PresetCfg` support. +- Validate CPU/GPU assumptions for each event implementation. + +## Training Validation + +Use IsaacGymEnvs `AnymalTerrain` as the source mapping, but validate training in stages: + +1. Start from a direct flat migration target and confirm it can learn a walking policy. +2. Load the saved checkpoint in a bounded play rollout. +3. Move to the direct rough Anymal-C target and treat immediate base-contact termination as a behavioral failure even if the runner completes. +4. Convert the reusable command, observation, reward, terrain curriculum, and termination logic to the manager workflow after the direct parity pass is healthy. + +A fresh-agent validation used this skill to clone IsaacGymEnvs, migrate AnymalTerrain into an external package named `isaacgym_anymal_migration`, and register `IsaacGym-AnymalTerrain-Flat-Migrated-Direct-v0`. The package implemented its own `DirectRLEnv`, preserved a 188-dimensional AnymalTerrain-style observation, and validated import, registration, reset, random steps, RSL-RL training, and checkpoint loading. + +The migrated task trained for 500 RSL-RL iterations with 4096 environments. Mean reward improved from `0.003` to `3.30`, and XY velocity error improved from `0.735` to `0.222`, but the result was not a successful walking policy: final mean episode length was about `293` steps against a 1000-step horizon, yaw error stayed high at about `1.43`, success rate stayed near `0.12`, and base-contact terminations remained common. A 256-step checkpoint rollout with 64 environments had mean completed episode length `135.3` and success rate `0.165`. + +This failure mode points to command staging rather than simple execution failure. IsaacGymEnvs `AnymalTerrain.yaml` uses a yaw command range of `[-3.14, 3.14]`, which made the flat walking validation unstable. Future validation agents should narrow or curriculum-stage commands, or first migrate the simpler `Anymal.yaml` flat behavior, before returning to full AnymalTerrain parity and rough-terrain curriculum. + +After adding the policy-success validation loop, a fresh agent reran the migration against IsaacGymEnvs commit `aeed298638a1f7b5421b38f5f3cc2d1079b6d9c3` and followed that staged path. It migrated `isaacgymenvs/tasks/anymal.py`, `cfg/task/Anymal.yaml`, and `cfg/train/AnymalPPO.yaml` into an external direct Isaac Lab package registering `IsaacGym-Anymal-Flat-Migrated-Direct-v0`. The package implemented its own `DirectRLEnv` with a 48-dimensional Anymal-style observation, IsaacGymEnvs reward/reset/command mapping, contact-sensor termination checks, and RSL-RL config. + +That fresh validation defined policy-success criteria before training, then ran registration, reset/random-step smoke, short training, 500-iteration RSL-RL training with 4096 environments, TensorBoard scalar parsing, and a bounded checkpoint rollout. The final 50-iteration training window reached mean reward `72.23`, mean episode length `2493.94` of a `2499`-step horizon, XY velocity error `0.00865`, yaw velocity error `0.01475`, and success rate `1.0`. A 64-environment, 512-step rollout from `model_499.pt` had zero done events, zero base or knee contact terminations, mean XY error `0.00983`, mean yaw error `0.01006`, and tracking success rate `0.99997`. + +This confirms the skill can guide a fresh agent to a successful migrated Anymal walking policy when it first validates flat locomotion and uses parsed metrics plus checkpoint rollout evidence. Rough terrain remains a follow-up stage: add terrain generation, height observations, and command/curriculum staging only after the flat migration is healthy. + +## Result + +The migration skill is actionable for a non-toy quadruped locomotion task. The direct-first path remains valid because Isaac Lab has direct flat and rough Anymal-C tasks at `source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/`. + +This validation also shows the limits of direct copying: terrain generation, sensor models, observation layout, randomization timing, PhysX/Newton support, and staged training must be mapped deliberately through Isaac Lab docs and maintained source. After the parity pass works, the agent should steer users toward manager-based task structure for reusable Isaac Lab development. diff --git a/skills/user/plan-manipulation-tasks/SKILL.md b/skills/user/plan-manipulation-tasks/SKILL.md new file mode 100644 index 00000000000..7af45a64494 --- /dev/null +++ b/skills/user/plan-manipulation-tasks/SKILL.md @@ -0,0 +1,59 @@ +--- +name: isaaclab-planning-manipulation-tasks +description: Plans Isaac Lab manipulation tasks through phase gates for reaching, grasping, lifting, placing, insertion, and contact-rich workflows. Use when building or debugging manipulation environments where scene setup, reset geometry, action contracts, rewards, and validation must be staged. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Planning Manipulation Tasks + +## When To Use + +Use this skill when a user is creating, migrating, or debugging manipulation tasks such as reach, grasp, lift, place, insertion, or tool-use environments. + +Do not use this skill as a replacement for environment construction details. Pair it with `isaaclab-building-environments`, `isaaclab-debugging-rl-training`, and `isaaclab-using-sensors-actuators` as needed. + +## Workflow + +1. Define the task objective as measurable phases: approach, align, pre-contact, contact or grasp, transport or lift, and final goal. +2. Build the minimum scene needed for the first phase: robot, support surfaces, task object, goal, lights, and required sensors. +3. Validate asset physics before training: collision geometry, mass, inertia, joint limits, friction, and contact materials. +4. Validate reset geometry before stepping: robot pose, object pose, goal pose, reachability, and absence of interpenetration. +5. Prove the action contract with scripted or zero-action probes before interpreting PPO results. +6. Add observations that expose the state required by the current phase, especially controlled-frame pose for end-effector or contact tasks. +7. Add rewards for one phase at a time. Keep success reward, termination, and metric geometry consistent. +8. Run a training smoke only after scene, reset, action, observation, and reward checks pass. +9. Validate behavior using deterministic rollout state metrics, not just total reward. + +## Validation + +Use these gates before calling a manipulation task ready: + +1. Environment launches with the intended backend and task ID. +2. Scene assets spawn with usable collision and support geometry. +3. Reset state is physically valid and reachable. +4. Action dimensions and controlled joints or bodies match the agent config. +5. Observation terms expose the task frames needed by rewards and actions. +6. Reward, termination, and metric geometry agree. +7. A deterministic rollout or scripted probe satisfies the current phase. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with manipulation examples such as `source/isaaclab_tasks/isaaclab_tasks/core/lift/` and `source/isaaclab_tasks/isaaclab_tasks/contrib/stack/`, environment authoring docs, and RL debugging guidance. Put project-specific history and experiment logs in the project, not in this public skill. + +## References + +- [Examples](examples.md) +- [Evaluations](evaluations.md) +- [Create environments skill](../create-environments/SKILL.md) +- [Debug RL training skill](../debug-rl-training/SKILL.md) +- [Use sensors and actuators skill](../use-sensors-actuators/SKILL.md) +- [Manipulation task example (lift)](../../../source/isaaclab_tasks/isaaclab_tasks/core/lift) diff --git a/skills/user/plan-manipulation-tasks/evaluations.md b/skills/user/plan-manipulation-tasks/evaluations.md new file mode 100644 index 00000000000..e0214358cf0 --- /dev/null +++ b/skills/user/plan-manipulation-tasks/evaluations.md @@ -0,0 +1,47 @@ +# Manipulation Task Evaluations + +## Scenario 1: Contact-Rich Task Planning + +Query: "Help me create a grasp-and-place task for a new gripper." + +Expected behavior: + +- Decomposes the task into reach, align, grasp, lift, transport, and place phases. +- Requires asset physics and reset checks before training. +- Recommends controlled-frame observations and phase-specific rewards. +- Routes command details to environment and training skills. + +Known failure modes: + +- Proposes one monolithic reward before validating the scene and action contract. +- Ignores gripper contacts, support collision, or reset geometry. + +## Scenario 2: Insertion Failure + +Query: "My insertion policy reaches the hole but never inserts." + +Expected behavior: + +- Checks action interface, task-frame alignment, collision geometry, and controlled-frame observations. +- Separates approach/alignment from descent and insertion-depth success. +- Recommends deterministic state traces before reward-scale tuning. + +Known failure modes: + +- Tunes PPO first. +- Uses object-center distance as the only diagnostic. + +## Scenario 3: Visual Success But Bad Metrics + +Query: "The policy looks like it solved the task, but the success metric is zero." + +Expected behavior: + +- Compares success reward, termination, and evaluation metric geometry. +- Checks offsets, target frames, and thresholds. +- Recommends logging task-state metrics from rollout. + +Known failure modes: + +- Declares success from visual inspection alone. +- Changes metric thresholds without checking reward and termination definitions. diff --git a/skills/user/plan-manipulation-tasks/examples.md b/skills/user/plan-manipulation-tasks/examples.md new file mode 100644 index 00000000000..2633c6039b9 --- /dev/null +++ b/skills/user/plan-manipulation-tasks/examples.md @@ -0,0 +1,36 @@ +# Manipulation Task Examples + +## Reaching Before Grasping + +Input: a user wants to train a grasp-and-lift task from scratch. + +Recommended staging: + +1. Validate reachability to the pregrasp pose without object contact. +2. Add alignment rewards and controlled-frame observations. +3. Validate gripper open and close behavior with a scripted command. +4. Add contact or hold rewards only after grasp geometry is reliable. +5. Add lift or transport success last. + +## Insertion Task + +Input: a user wants a peg-in-hole task. + +Recommended staging: + +1. Separate approach, alignment, descent, contact, and insertion-depth phases. +2. Use task-frame or keypoint geometry instead of generic object-center distance. +3. Validate the action interface can move in the insertion direction. +4. Check that collision geometry permits insertion under the intended backend. +5. Select checkpoints by insertion-depth metrics and sustained success, not reward alone. + +## Object Falls Through Table + +Input: an object falls through a visually correct table. + +Recommended staging: + +1. Check whether the table is visual-only. +2. Add or reference explicit support collision geometry. +3. Validate object height and contacts immediately after reset. +4. Only resume training after the physics scene is valid. diff --git a/skills/user/prepare-assets-for-newton/SKILL.md b/skills/user/prepare-assets-for-newton/SKILL.md new file mode 100644 index 00000000000..807c874aa42 --- /dev/null +++ b/skills/user/prepare-assets-for-newton/SKILL.md @@ -0,0 +1,60 @@ +--- +name: isaaclab-preparing-assets-for-newton +description: Validates and prepares PhysX-compatible USD assets for Isaac Lab Newton workflows. Use when an asset runs under PhysX but Newton reports missing mass or inertia, placeholder inertials, unsupported collision or joint topology, unstable control, or task-level action and actuator mismatches. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Preparing Assets For Newton + +## When To Use + +Use this skill when a user needs to validate or prepare a USD robot, object, or scene asset for Newton after it already works or partially works under PhysX. + +Do not use this skill for choosing a backend at the environment level. Use `isaaclab-selecting-backends` for backend selection and `isaaclab-using-presets` for backend-specific configuration variants. + +## Workflow + +1. Establish a PhysX baseline for the same asset path and task spawn path. +2. Classify the asset as PhysX-compatible, Newton-runnable, or Newton-clean. +3. Audit authored physics metadata: rigid bodies, colliders, mass, inertia, center of mass, joint topology, and material properties. +4. Fix authored USD physics data instead of hiding Newton warnings with task code. +5. If runtime-resolved mass properties are needed, produce a local package or authored layer with explicit mass, diagonal inertia, and center of mass. +6. When fixing task-side schema overrides, import universal schema fragments from `isaaclab.sim.schemas` and Newton or MuJoCo-specific cfgs from `isaaclab_newton.sim.schemas` instead of relying on deprecated core forwarding imports. +7. Re-audit the converted asset under Newton. +8. Validate the asset inside the target Isaac Lab task, not only in a standalone USD viewer. +9. Check actuator joint patterns, controller body names, action dimensions, and zero-action rollout stability. +10. Record source path, converted path, audit verdicts, smoke command, and residual warnings in project documentation. + +## Validation + +An asset is Newton-clean only when: + +1. All rigid bodies have intentional mass properties. +2. Runtime mass and inertia values are finite and positive. +3. Collision geometry is parseable by Newton. +4. Joint topology is accepted by Newton. +5. The target task can spawn and reset the asset under Newton. +6. Zero-action rollout has finite observations, rewards, positions, and velocities. +7. Actuator and controller names still resolve after any USD conversion. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with Newton backend documentation, asset conversion utilities, and backend-specific examples. Avoid storing converted USD packages, generated audit logs, or private asset paths in this skill. + +## References + +- [Reference](reference.md) +- [Examples](examples.md) +- [Evaluations](evaluations.md) +- [Backend selection skill](../select-backends/SKILL.md) +- [Preset skill](../use-presets/SKILL.md) +- [Newton documentation](../../../docs/source/overview/core-concepts/physical-backends/newton) diff --git a/skills/user/prepare-assets-for-newton/evaluations.md b/skills/user/prepare-assets-for-newton/evaluations.md new file mode 100644 index 00000000000..87d1d12b57d --- /dev/null +++ b/skills/user/prepare-assets-for-newton/evaluations.md @@ -0,0 +1,47 @@ +# Newton Asset Preparation Evaluations + +## Scenario 1: Placeholder Inertia + +Query: "My PhysX robot runs, but Newton reports placeholder inertia." + +Expected behavior: + +- Establishes a PhysX baseline. +- Audits authored mass, inertia, and center of mass. +- Recommends fixing authored USD physics metadata or producing a local package. +- Requires task-level Newton validation after the asset audit. + +Known failure modes: + +- Treats PhysX runtime success as proof of Newton readiness. +- Suppresses warnings without fixing asset metadata. + +## Scenario 2: Asset Imports But Control Fails + +Query: "The converted robot spawns under Newton, but the policy actions do nothing." + +Expected behavior: + +- Checks actuator joint patterns, controller body names, and action dimensions. +- Runs zero-action and small nonzero-action rollouts. +- Separates asset import success from control readiness. + +Known failure modes: + +- Keeps changing USD mass properties when the task action config is stale. +- Declares the asset ready after standalone import only. + +## Scenario 3: Task-Level Failure + +Query: "The object passes a standalone Newton check but fails inside my environment." + +Expected behavior: + +- Validates the exact task spawn path and overrides. +- Audits support collision, contact materials, and nested references. +- Checks reset and first-step finite state in the target task. + +Known failure modes: + +- Assumes standalone USD parsing covers task-level material and collision overrides. +- Ignores support geometry and contact-relevant scene assets. diff --git a/skills/user/prepare-assets-for-newton/examples.md b/skills/user/prepare-assets-for-newton/examples.md new file mode 100644 index 00000000000..684c50c56ba --- /dev/null +++ b/skills/user/prepare-assets-for-newton/examples.md @@ -0,0 +1,35 @@ +# Newton Asset Preparation Examples + +## Missing Mass Properties + +Input: Newton reports placeholder inertia for a robot that works in PhysX. + +Expected workflow: + +1. Confirm the same asset path works in a PhysX baseline. +2. Inspect whether rigid bodies have authored mass, inertia, and center of mass. +3. Author or bake explicit physical metadata in a reusable USD layer or package. +4. Re-run a Newton smoke in the target task. +5. Record residual warnings and modeling choices. + +## Converted Robot Does Not Move + +Input: the converted asset spawns under Newton, but actions do not move the robot. + +Expected workflow: + +1. Check action dimensions and actuator joint name patterns. +2. Check controller body or frame names after conversion. +3. Run zero-action and small nonzero-action rollouts. +4. Fix task config names or actuator gains rather than assuming asset import is enough. + +## Object Works Alone But Fails In Task + +Input: a standalone object audit passes, but the task fails under Newton. + +Expected workflow: + +1. Validate the exact task spawn path. +2. Check task-level material or collision overrides. +3. Inspect support surfaces and contact-relevant rigid objects separately. +4. Confirm observations and rewards remain finite during reset and first steps. diff --git a/skills/user/prepare-assets-for-newton/reference.md b/skills/user/prepare-assets-for-newton/reference.md new file mode 100644 index 00000000000..79f1f84ec64 --- /dev/null +++ b/skills/user/prepare-assets-for-newton/reference.md @@ -0,0 +1,47 @@ +# Newton Asset Preparation Reference + +## Contents + +- Asset classifications +- Audit checklist +- Task-level control checks +- Common failures + +## Asset Classifications + +Use these labels when reporting status: + +- PhysX-compatible: the asset works in the current PhysX task or standalone smoke. +- Newton-runnable: Newton can parse and simulate the asset enough for a limited smoke. +- Newton-clean: authored metadata, task spawn path, and control path pass the validation checklist. + +## Audit Checklist + +Inspect: + +- Rigid body APIs and authored mass properties. +- Diagonal inertia and center-of-mass values. +- Collider types and collision approximation. +- Joint topology and fixed-joint structure. +- Friction and material overrides. +- Nested references and package dependencies. +- Whether task-level overrides apply to the same prims under Newton. + +## Task-Level Control Checks + +Passing asset import is not enough. Also verify: + +- Actuator joint name patterns resolve to the converted USD joint names. +- Controller body names and frame names resolve. +- Action dimensions match the environment action term. +- Stiffness, damping, armature, effort limits, and friction are intentional for Newton. +- Zero-action and small nonzero-action rollouts are finite and move the expected joints or bodies. + +## Common Failures + +- Missing authored mass, inertia, or center of mass. +- Placeholder inertia warnings. +- Fixed-joint topology rejected by Newton. +- Nested references resolve locally but fail in CI or containers. +- Visual-only support geometry causes objects to fall or contact counts to spike. +- Asset import passes, but stale actuator or controller names break the task. diff --git a/skills/user/select-backends/SKILL.md b/skills/user/select-backends/SKILL.md new file mode 100644 index 00000000000..d87e8e7f892 --- /dev/null +++ b/skills/user/select-backends/SKILL.md @@ -0,0 +1,60 @@ +--- +name: isaaclab-selecting-backends +description: Selects and validates Isaac Lab physics and rendering backends. Use when choosing PhysX or Newton, adding backend presets, comparing backend behavior, or debugging backend-specific simulation, randomization, sensor, or renderer issues. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Selecting Backends + +## When To Use + +Use this skill when a user needs to choose, configure, compare, or debug Isaac Lab physical backends or renderer-related behavior. + +Do not use this skill to duplicate backend reference material. Link to the multi-backend docs, schema cfg docs, and source examples for current configuration details. If the user is converting or validating a specific USD asset for Newton, use `isaaclab-preparing-assets-for-newton`. + +## Workflow + +1. Identify the target backend: PhysX, Newton, or a task that must support both through presets. +2. Read the multi-backend architecture and schema cfg docs before editing backend configs. +3. Start with the backend that best matches the source task or current maintained example. Use PhysX first when matching Isaac Gym behavior. +4. Add backend presets only after the task runs on one backend. +5. Map simulation parameters through public cfg schemas instead of copying old simulator-specific keys. Import universal schema fragments and base cfgs from `isaaclab.sim.schemas`, PhysX-specific cfgs from `isaaclab_physx.sim.schemas`, and Newton or MuJoCo cfgs from `isaaclab_newton.sim.schemas`. +6. Check backend support for sensors, randomization events, terrain, contacts, and actuators before enabling them. +7. Separate backend-specific differences using `PresetCfg` or existing preset helpers rather than runtime conditionals scattered through task code. +8. Use suffixless task names in backend smoke-test and training commands. +9. Validate each backend with a small reset/step rollout before training. +10. Document intentional behavior differences, especially around contacts, randomization timing, CPU/GPU data paths, and renderer requirements. + +## Validation + +Use this checklist: + +1. Run a small reset/step smoke test on the primary backend. +2. If adding another backend, run the same smoke test there before training. +3. Compare observation shape, action shape, reset behavior, and contact behavior. +4. Check randomization events for backend-specific support and device assumptions. +5. Run short training only after both backends pass smoke tests. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `docs/source/overview/core-concepts/multi_backend_architecture.rst`, `docs/source/overview/core-concepts/schema_cfgs.rst`, backend installation docs, and backend-aware task examples under `source/isaaclab_tasks/isaaclab_tasks/`. If backend docs are incomplete, improve the docs rather than expanding this skill into a backend reference. + +## References + +- [Evaluations](evaluations.md) +- [Examples](examples.md) +- [Prepare assets for Newton skill](../prepare-assets-for-newton/SKILL.md) +- [Multi-backend architecture](../../../docs/source/overview/core-concepts/multi_backend_architecture.rst) +- [Schema cfgs](../../../docs/source/overview/core-concepts/schema_cfgs.rst) +- [PhysX installation](../../../docs/source/overview/core-concepts/physical-backends/physx/installation.rst) +- [Newton installation](../../../docs/source/overview/core-concepts/physical-backends/newton/installation.rst) +- [Task examples](../../../source/isaaclab_tasks/isaaclab_tasks) diff --git a/skills/user/select-backends/evaluations.md b/skills/user/select-backends/evaluations.md new file mode 100644 index 00000000000..2e8cd050d7b --- /dev/null +++ b/skills/user/select-backends/evaluations.md @@ -0,0 +1,63 @@ +# Backend Selection Evaluations + +## Scenario 1: Isaac Gym Parity + +Query: "I am porting an Isaac Gym task. Should I start with PhysX or Newton?" + +Expected behavior: + +- Recommends PhysX first when matching Isaac Gym behavior. +- Maps simulation parameters through Isaac Lab cfg schemas. +- Defers Newton support until the direct PhysX migration is stable unless the user explicitly targets Newton. + +Known failure modes: + +- Starts with Newton without explaining behavior differences. +- Copies Isaac Gym PhysX keys directly into Isaac Lab configs. + +## Scenario 2: Add Presets + +Query: "My task works on PhysX. Add Newton support too." + +Expected behavior: + +- Adds backend-specific presets or config variants. +- Checks sensors, contacts, actuators, terrain, and randomization events for backend support. +- Runs reset/step smoke tests on both backends. + +Known failure modes: + +- Adds runtime conditionals throughout task logic instead of config presets. +- Assumes event randomization works identically on both backends. + +## Scenario 3: Renderer Or Sensor Issue + +Query: "My camera observations work in one mode but fail with another backend or renderer." + +Expected behavior: + +- Separates physics backend, renderer, sensor type, and device assumptions. +- Points to relevant backend and sensor docs. +- Recommends a small reproducible smoke test before training. + +Known failure modes: + +- Treats all visual failures as physics backend bugs. +- Ignores renderer and app-launch requirements. + +## Scenario 4: Concrete Backend Smoke Test + +Query: "Give me a command to check whether my Ant task runs on Newton before training." + +Expected behavior: + +- Starts by listing or checking available presets for the task. +- Uses a small `random_agent.py` rollout with a `physics=...` selector. +- Warns not to guess preset names that are not exposed by the task. +- Recommends repeating the same smoke test on PhysX for comparison. + +Known failure modes: + +- Starts training before reset/step validation. +- Invents backend selector names without checking task presets. +- Treats Newton and PhysX contact behavior as directly comparable without validation. diff --git a/skills/user/select-backends/examples.md b/skills/user/select-backends/examples.md new file mode 100644 index 00000000000..21e81a4c30b --- /dev/null +++ b/skills/user/select-backends/examples.md @@ -0,0 +1,45 @@ +# Backend Selection Examples + +## List Available Presets + +Start by checking which preset selectors the task exposes: + +```bash +uv run python scripts/environments/list_envs.py --show_presets +``` + +Preset selectors are typed tokens such as `physics=NAME`, `renderer=NAME`, and `presets=NAME`. + +## Smoke Test A Backend + +Use a small random-agent rollout before training: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Ant --num_envs 4 physics=physx +``` + +For Newton, use the physics preset name exposed by `list_envs.py` for that task, for example: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Ant --num_envs 4 physics=newton_mjwarp +``` + +If the preset name is not listed, do not guess. Add or update the task's backend presets first. + +## Train After Smoke Tests + +Once reset/step behavior is stable on the selected backend: + +```bash +uv run isaaclab train --rl_library rsl_rl --task Isaac-Ant physics=physx +``` + +Repeat the same small smoke test on every backend before comparing training curves. + +## Common Decision Points + +- Use PhysX first when preserving Isaac Gym behavior. +- Use Newton when the task specifically targets kit-less or Warp-native workflows. +- Use backend presets for solver, contact, material, sensor, and renderer differences. +- Do not copy PhysX parameters directly into Newton configs without checking schema docs. +- For USD physics authoring, import backend schema cfgs from `isaaclab_physx.sim.schemas` or `isaaclab_newton.sim.schemas`. Use backend-specific property cfgs for simple spawner slots, and schema fragments such as `PhysxRigidBodyCfg`, `PhysxCollisionCfg`, `NewtonCollisionCfg`, or `MujocoJointCfg` when combining multiple USD namespaces in one slot. diff --git a/skills/user/setup-troubleshooting/SKILL.md b/skills/user/setup-troubleshooting/SKILL.md new file mode 100644 index 00000000000..581118b18d1 --- /dev/null +++ b/skills/user/setup-troubleshooting/SKILL.md @@ -0,0 +1,63 @@ +--- +name: isaaclab-setup-troubleshooting +description: Routes Isaac Lab installation, verification, and common troubleshooting issues to official docs and canonical commands. Use when installing Isaac Lab, verifying setup, debugging launch failures, or diagnosing environment problems. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Setup Troubleshooting + +## When To Use + +Use this skill when a user asks for help installing Isaac Lab, verifying a local setup, or diagnosing common setup and launch failures. + +Do not duplicate installation or troubleshooting docs in this skill. The official docs are the source of truth. + +## Workflow + +1. Identify the install mode: pip, uv, source, cloud, kitless, backend-specific setup, or an existing binary setup. For a new full-feature Isaac Sim setup, prefer the pip/uv installation guide. +2. Identify OS, Python environment, GPU/driver context, Isaac Sim source, and target backend. +3. Read the matching installation guide and troubleshooting reference before prescribing commands. +4. From the Isaac Lab checkout, use documented uv commands such as `uv run python`, `uv run isaaclab train`, and `uv run isaaclab play` for Python, verification, and RL entry points. +5. Use suffixless task names in verification and training commands. +6. Ask for the smallest relevant error output when the failure mode is unclear. +7. Prefer a minimal verification command before running examples, training, or rendering workflows. +8. Route backend-specific setup to the relevant PhysX or Newton docs. +9. If the docs are incomplete or stale, update the docs rather than expanding this skill. + +## Validation + +Use this checklist: + +1. Confirm the user is following one supported install path. +2. Confirm Python and package commands run from the intended Isaac Lab checkout and uv-managed environment when applicable. +3. Run a minimal import or verification command before larger tests. +4. Check troubleshooting docs for the observed error class. +5. Escalate to environment-specific debugging only after the documented checks are exhausted. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with installation docs under `docs/source/setup/installation/`, quick installation docs, backend installation docs, and `docs/source/refs/troubleshooting.rst`. Setup guidance changes often, so keep this skill as a router to official docs and minimal verification steps. + +## References + +- [Evaluations](evaluations.md) +- [Reference](reference.md) +- [Quickstart](../../../docs/source/setup/quickstart.rst) +- [Source installation](../../../docs/source/setup/installation/source_installation.rst) +- [Pip installation](../../../docs/source/setup/installation/pip_installation.rst) +- [Isaac Lab pip installation](../../../docs/source/setup/installation/isaaclab_pip_installation.rst) +- [Binary installation](../../../docs/source/setup/installation/binaries_installation.rst) +- [Cloud installation](../../../docs/source/setup/installation/cloud_installation.rst) +- [Kitless installation](../../../docs/source/setup/installation/kitless_installation.rst) +- [PhysX installation](../../../docs/source/overview/core-concepts/physical-backends/physx/installation.rst) +- [Newton installation](../../../docs/source/overview/core-concepts/physical-backends/newton/installation.rst) +- [Troubleshooting](../../../docs/source/refs/troubleshooting.rst) diff --git a/skills/user/setup-troubleshooting/evaluations.md b/skills/user/setup-troubleshooting/evaluations.md new file mode 100644 index 00000000000..b49efed366a --- /dev/null +++ b/skills/user/setup-troubleshooting/evaluations.md @@ -0,0 +1,63 @@ +# Setup Troubleshooting Evaluations + +## Scenario 1: Fresh Install + +Query: "Help me install Isaac Lab from source on my machine." + +Expected behavior: + +- Asks for OS, Python environment, Isaac Sim source, GPU/driver context, and desired backend. +- Points to the official pip/uv Isaac Sim installation guide unless the user has a reason to use another supported path. +- Uses documented uv commands for verification. + +Known failure modes: + +- Copies an installation recipe into the skill response without checking the current docs. +- Mixes pip, source, and binary installation steps. + +## Scenario 2: Import Failure + +Query: "Isaac Lab installed, but imports fail when I run my script." + +Expected behavior: + +- Checks whether the user is running from the intended Isaac Lab checkout or correct environment. +- Points to troubleshooting docs for the observed error. +- Requests the smallest relevant traceback if the failure is ambiguous. + +Known failure modes: + +- Suggests reinstalling before checking the active environment. +- Diagnoses from a partial error message without asking for the missing context. + +## Scenario 3: Backend Setup + +Query: "I want to run this task with Newton but setup fails." + +Expected behavior: + +- Routes to backend-specific installation docs. +- Separates backend installation issues from task implementation issues. +- Verifies setup with a minimal command before running training. + +Known failure modes: + +- Treats backend setup as a task bug. +- Gives backend-specific commands without checking the docs. + +## Scenario 4: Minimal Reproduction + +Query: "Training fails with an import error after install. What should I run first?" + +Expected behavior: + +- Asks for the exact command and traceback. +- Verifies the active install path and Python environment. +- Uses the minimal import command from `reference.md`. +- Escalates to random-agent or training checks only after imports work. + +Known failure modes: + +- Recommends reinstalling before checking the active Python environment. +- Starts debugging the training runner before validating imports. +- Ignores the official troubleshooting page. diff --git a/skills/user/setup-troubleshooting/reference.md b/skills/user/setup-troubleshooting/reference.md new file mode 100644 index 00000000000..43325425a70 --- /dev/null +++ b/skills/user/setup-troubleshooting/reference.md @@ -0,0 +1,67 @@ +# Setup Troubleshooting Reference + +## Contents + +- Install path routing +- Minimal verification +- Common failure routing +- Escalation checklist + +## Install Path Routing + +Ask which install path the user is following before prescribing commands. For a new full-feature Isaac Sim setup, route to the pip/uv guide first. + +| User context | First reference | +| --- | --- | +| Source checkout | `docs/source/setup/installation/source_installation.rst` | +| uv-managed environment | `docs/source/setup/quickstart.rst` and `docs/source/setup/installation/uv_run.rst` | +| Pip package | `docs/source/setup/installation/pip_installation.rst` | +| Isaac Lab pip package | `docs/source/setup/installation/isaaclab_pip_installation.rst` | +| Binary package | `docs/source/setup/installation/binaries_installation.rst` | +| Cloud setup | `docs/source/setup/installation/cloud_installation.rst` | +| Kit-less setup | `docs/source/setup/installation/kitless_installation.rst` | +| Newton setup | `docs/source/overview/core-concepts/physical-backends/newton/installation.rst` | +| PhysX setup | `docs/source/overview/core-concepts/physical-backends/physx/installation.rst` | + +## Minimal Verification + +Use the smallest command that exercises the failing layer: + +```bash +uv run python -c "import isaaclab; print('ok')" +``` + +For task import and stepping: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Cartpole --num_envs 4 +``` + +For training entry points: + +```bash +uv run isaaclab train --rl_library rsl_rl --task Isaac-Cartpole --max_iterations 1 +``` + +## Common Failure Routing + +| Symptom | First check | +| --- | --- | +| Import fails | Active Python environment and Isaac Lab checkout | +| `isaaclab_tasks` import fails | Run through `uv run python` from the intended Isaac Lab checkout, then confirm the source packages or external task package are installed with the intended `uv pip` environment | +| App launch fails | Isaac Sim, display, driver, and launcher docs | +| Task registration fails | Gym registration and task package import | +| Backend preset fails | `uv run python scripts/environments/list_envs.py --show_presets` | +| Camera or renderer fails | Renderer selection and sensor docs | +| Training starts but shapes fail | Environment reset/step smoke test before runner | + +## Escalation Checklist + +Before suggesting broad reinstall steps: + +1. Capture the exact command and traceback. +2. Confirm the install path and Python executable. +3. Run a minimal import check. +4. Run a small random-agent task check. +5. Check `docs/source/refs/troubleshooting.rst`. +6. Escalate only after the documented checks do not match the failure. diff --git a/skills/user/train-rl-agents/SKILL.md b/skills/user/train-rl-agents/SKILL.md new file mode 100644 index 00000000000..583216f09fe --- /dev/null +++ b/skills/user/train-rl-agents/SKILL.md @@ -0,0 +1,60 @@ +--- +name: isaaclab-training-rl-agents +description: Configures and runs Isaac Lab reinforcement learning workflows. Use when selecting RL frameworks, wiring agent configs, launching training, resuming runs, or troubleshooting training setup for Isaac Lab tasks. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Training RL Agents + +## When To Use + +Use this skill when a user wants to train, resume, evaluate, or configure reinforcement learning for an Isaac Lab task. + +Do not use this skill to design environment observations, rewards, or resets from scratch. Use `isaaclab-building-environments` (`skills/user/create-environments/`) for environment construction first, and use `isaaclab-debugging-rl-training` when training behavior or reward metrics are already failing. + +## Workflow + +1. Identify the registered task name, workflow type, action space, observation space, target backend, device, and desired RL framework. +2. Read the RL training guide and the training tutorial before writing commands or configs. +3. Start from an existing agent config under `source/isaaclab_tasks/isaaclab_tasks/` that matches the framework and task family. +4. Keep framework-specific config formats separate. Do not mix RSL-RL Python configs with RL-Games, SKRL, or SB3 YAML/config files. +5. Use suffixless task names in commands, for example `Isaac-Cartpole`. +6. From the Isaac Lab checkout, use `uv run isaaclab train` and `uv run isaaclab play` with `--rl_library` rather than the deprecated per-library scripts under `scripts/reinforcement_learning/`. +7. Omit visualizer flags for fastest headless training. Use `--viz` only when an explicit visualizer is needed. +8. Run a small smoke training job before scaling environment count, horizon, network size, or logging integrations. +9. For visual observations, confirm the sensor pipeline and renderer requirements before enabling large environment counts. +10. For multi-backend tasks, validate training on one backend before introducing backend presets. +11. Record the exact task, framework, backend, seed, and config overrides needed to reproduce the result. + +## Validation + +Use this checklist: + +1. Confirm the task can reset and step without the training runner. +2. Confirm the selected agent config belongs to the intended RL framework. +3. Run a short training command with a small number of environments. +4. Resume or load a checkpoint only after the initial run writes expected artifacts. +5. Check logs for device, observation shape, action shape, and backend errors. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `docs/source/overview/reinforcement-learning/training_guide.rst`, `docs/source/setup/installation/uv_run.rst`, RL training tutorials under `docs/source/tutorials/03_envs/`, and agent configs under `source/isaaclab_tasks/isaaclab_tasks/`. If framework commands or config formats change, update the official training docs or maintained examples first. + +## References + +- [Evaluations](evaluations.md) +- [Examples](examples.md) +- [Debug RL training skill](../debug-rl-training/SKILL.md) +- [RL training guide](../../../docs/source/overview/reinforcement-learning/training_guide.rst) +- [Configure RL training tutorial](../../../docs/source/tutorials/03_envs/configuring_rl_training.rst) +- [Run RL training tutorial](../../../docs/source/tutorials/03_envs/run_rl_training.rst) +- [Task examples](../../../source/isaaclab_tasks/isaaclab_tasks) diff --git a/skills/user/train-rl-agents/evaluations.md b/skills/user/train-rl-agents/evaluations.md new file mode 100644 index 00000000000..98244122f3a --- /dev/null +++ b/skills/user/train-rl-agents/evaluations.md @@ -0,0 +1,63 @@ +# RL Training Evaluations + +## Scenario 1: Choose A Framework + +Query: "I have a new locomotion task. Should I train it with RSL-RL, RL-Games, SKRL, or SB3?" + +Expected behavior: + +- Asks about task type, observation space, action space, desired framework, and existing nearby examples. +- Points to official training docs and maintained agent configs. +- Recommends starting from the closest existing task config. + +Known failure modes: + +- Recommends hyperparameters without checking any maintained examples. +- Mixes framework-specific config formats. + +## Scenario 2: Smoke Train A New Task + +Query: "My environment imports now. Help me run the first training test." + +Expected behavior: + +- Runs or proposes reset/step validation before training. +- Uses documented Isaac Lab training entry points. +- Keeps environment count and run length small for the first training check. + +Known failure modes: + +- Starts a full-scale training run before checking shape and reset behavior. +- Uses a raw `python` command instead of the Isaac Lab wrapper. + +## Scenario 3: Visual Observations + +Query: "Train a policy using camera observations." + +Expected behavior: + +- Checks renderer, sensor config, observation shape, memory cost, and framework support. +- Points to sensor docs and camera-enabled examples. +- Recommends a small environment count until the visual pipeline is stable. + +Known failure modes: + +- Treats camera observations like low-dimensional state without checking rendering requirements. +- Ignores GPU memory and renderer constraints. + +## Scenario 4: Concrete RSL-RL Command + +Query: "How do I train Cartpole with RSL-RL?" + +Expected behavior: + +- Gives the RSL-RL command from `examples.md`. +- Mentions the matching Cartpole agent config location. +- Suggests a random-agent smoke test before training if the environment was just modified. +- Explains where logs/checkpoints are written instead of sending the user hunting through docs. + +Known failure modes: + +- Answers only with a link to the training guide. +- Uses the SB3 command for an RSL-RL request. +- Uses deprecated per-library scripts under `scripts/reinforcement_learning/`. diff --git a/skills/user/train-rl-agents/examples.md b/skills/user/train-rl-agents/examples.md new file mode 100644 index 00000000000..5bb3bce00b8 --- /dev/null +++ b/skills/user/train-rl-agents/examples.md @@ -0,0 +1,69 @@ +# RL Training Examples + +## Quick Commands + +Use these as starting points, then confirm the task's registered agent config exists. + +Training runs headless by default; omit any visualizer flag for fastest training. The legacy `--headless` flag is deprecated. To watch a run, pass `--viz kit` (or `--viz rerun,newton,viser`); use `--viz none` to force-disable configured visualizers. Use suffixless task names, for example `Isaac-Cartpole` instead of `Isaac-Cartpole-v0`. + +RSL-RL: + +```bash +uv run isaaclab train --rl_library rsl_rl --task Isaac-Cartpole --run_name ppo +``` + +RL-Games direct Cartpole: + +```bash +uv run isaaclab train --rl_library rl_games --task Isaac-Cartpole-Direct +``` + +Stable Baselines 3: + +```bash +uv run isaaclab train --rl_library sb3 --task Isaac-Cartpole --num_envs 64 +``` + +SKRL: + +```bash +uv run isaaclab train --rl_library skrl --task Isaac-Cartpole +``` + +## Before Training + +Always run a small random-action check first: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Cartpole --num_envs 8 +``` + +For visual observations or camera tasks, lower `--num_envs` and confirm renderer and sensor support before scaling. Do not add `--enable_cameras` unless the current task or docs explicitly require it. + +## After Training + +TensorBoard example: + +```bash +uv run --with tensorboard python -m tensorboard.main --logdir logs/rsl_rl/cartpole +``` + +Play example: + +```bash +uv run isaaclab play --rl_library rsl_rl --task Isaac-Cartpole --checkpoint logs/rsl_rl/cartpole/RUN_NAME/model_100.pt --viz kit +``` + +Resume example: + +```bash +uv run isaaclab train --rl_library rsl_rl --task Isaac-Cartpole --resume --load_run RUN_NAME --checkpoint model_100.pt +``` + +## Config Lookup + +Agent configs live near the task implementation, for example: + +- `source/isaaclab_tasks/isaaclab_tasks/core/cartpole/agents/` + +Do not mix framework formats: RSL-RL configs are Python classes, while RL-Games, SKRL, and SB3 commonly use YAML or framework-specific config files. diff --git a/skills/user/use-presets/SKILL.md b/skills/user/use-presets/SKILL.md new file mode 100644 index 00000000000..41bbface3a3 --- /dev/null +++ b/skills/user/use-presets/SKILL.md @@ -0,0 +1,63 @@ +--- +name: isaaclab-using-presets +description: Defines and uses Isaac Lab preset configurations for multi-backend tasks. Use when adding PhysX/Newton variants, renderer variants, domain presets, or deciding whether a task needs PresetCfg. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Using Presets + +## When To Use + +Use this skill when a user needs to define, select, or debug Isaac Lab `PresetCfg` variants for environments, physics backends, renderers, sensors, events, or task-specific configuration options. + +Do not use presets for simple one-backend tasks with no meaningful configuration variants. Prefer a plain config until the task needs a real selectable alternative. + +## Workflow + +1. Identify whether the task needs variants. Common reasons are physics backend differences, renderer differences, camera data types, event randomization differences, or play/train variants. +2. If there is only one supported behavior, keep the config simple and do not add `PresetCfg`. +3. If variants are needed, choose the selector category: + - Use a `PhysicsCfg(PresetCfg)` field for physics backend variants selected by `physics=NAME`. + - Use a renderer preset for renderer variants selected by `renderer=NAME`. + - Use task/domain presets for environment-specific variants selected by `presets=NAME[,NAME,...]`. +4. Define a `default` variant. Add explicit named variants such as `physx`, `newton_mjwarp`, `newton_kamino`, `ovphysx`, `rgb`, or `depth` only when the task supports them. +5. Assign the preset wrapper to the owning environment config field, for example `sim: SimulationCfg = SimulationCfg(physics=PhysicsCfg())`. +6. Keep backend-specific values inside preset classes rather than scattering runtime conditionals through task logic. +7. Use suffixless task names in commands. +8. List available preset names before using them in commands. +9. Smoke-test every preset with a small random-agent rollout before training. + +## Validation + +Use this checklist: + +1. Confirm a plain config would not be sufficient. +2. Confirm every preset variant has a clear reason to exist. +3. Confirm `default` points to a valid config. +4. Confirm selector names are discoverable through the task's preset help or environment list. +5. Run a small reset/step smoke test for every new preset. +6. Run training only after preset-specific shape, device, backend, and renderer behavior is stable. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with `source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py`, `source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py`, `source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py`, the environment catalog, and maintained preset examples under `source/isaaclab_tasks/isaaclab_tasks/`. If preset selector behavior changes, update the source docs or examples first and keep this skill as a routing checklist. + +## References + +- [Reference](reference.md) +- [Examples](examples.md) +- [Evaluations](evaluations.md) +- [Preset utility source](../../../source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py) +- [Preset CLI source](../../../source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py) +- [Preset targets source](../../../source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py) +- [Environment catalog](../../../docs/source/overview/environments.rst) +- [Quickstart preset details](../../../docs/source/setup/quickstart_details.rst) diff --git a/skills/user/use-presets/evaluations.md b/skills/user/use-presets/evaluations.md new file mode 100644 index 00000000000..c27999965b6 --- /dev/null +++ b/skills/user/use-presets/evaluations.md @@ -0,0 +1,65 @@ +# Preset System Evaluations + +## Scenario 1: No Preset Needed + +Query: "I have a simple direct task that only runs on PhysX. Should I add PresetCfg?" + +Expected behavior: + +- Recommends a plain config without `PresetCfg`. +- Explains that presets are useful only when there are meaningful named variants. +- Shows the simplified `SimulationCfg(physics=PhysxCfg())` pattern. + +Known failure modes: + +- Adds a preset wrapper with only one variant. +- Claims every task must use presets. + +## Scenario 2: Add PhysX And Newton Variants + +Query: "Make this locomotion environment support PhysX and Newton MJWarp." + +Expected behavior: + +- Uses a `PhysicsCfg(PresetCfg)` wrapper. +- Provides `default`, `physx`, and `newton_mjwarp` variants. +- Keeps solver-specific values in the preset definitions. +- Recommends random-agent smoke tests with `physics=physx` and `physics=newton_mjwarp`. + +Known failure modes: + +- Copies PhysX parameters directly into Newton. +- Uses runtime conditionals instead of config variants. +- Omits the default preset. + +## Scenario 3: Camera Data-Type Presets + +Query: "Expose RGB and depth versions of my camera task." + +Expected behavior: + +- Uses a domain preset selected by `presets=rgb` or `presets=depth`. +- Updates observation shape per data type. +- Calls out renderer and camera requirements. +- Points to maintained Cartpole camera preset examples. + +Known failure modes: + +- Uses `physics=` for a camera data-type preset. +- Omits the `renderer=` selector for a camera task. +- Leaves observation shape unchanged when switching RGB to depth. + +## Scenario 4: Discover Available Options + +Query: "What preset names can I pass for this task?" + +Expected behavior: + +- Recommends listing available presets before guessing names. +- Explains `physics=`, `renderer=`, and `presets=` selector categories. +- Warns that not every task supports every common preset name. + +Known failure modes: + +- Invents preset names not exposed by the task. +- Confuses typed selectors with task-specific domain presets. diff --git a/skills/user/use-presets/examples.md b/skills/user/use-presets/examples.md new file mode 100644 index 00000000000..6e584647aaf --- /dev/null +++ b/skills/user/use-presets/examples.md @@ -0,0 +1,109 @@ +# Preset Examples + +## Contents + +- Simplified config without presets +- Physics presets +- Domain presets +- Combined presets +- Existing source examples + +## Simplified Config Without Presets + +Use this when the environment has one supported physics setup and no user-selectable variants. + +```python +from isaaclab.sim import SimulationCfg +from isaaclab_physx.physics import PhysxCfg + + +class MySimpleEnvCfg: + sim: SimulationCfg = SimulationCfg(physics=PhysxCfg()) +``` + +This is enough when the task only supports PhysX and there are no renderer, sensor, event, or domain variants to expose. + +## Physics Presets + +Use `PresetCfg` when the same task supports multiple physics backends. + +```python +from isaaclab.sim import SimulationCfg +from isaaclab.utils.configclass import configclass +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_physx.physics import PhysxCfg +from isaaclab_tasks.utils import PresetCfg + + +@configclass +class PhysicsCfg(PresetCfg): + default = PhysxCfg(gpu_max_rigid_patch_count=10 * 2**15) + physx = default + newton_mjwarp = NewtonCfg( + solver_cfg=MJWarpSolverCfg(njmax=120, nconmax=15), + num_substeps=1, + ) + + +@configclass +class MyMultiBackendEnvCfg: + sim: SimulationCfg = SimulationCfg(physics=PhysicsCfg()) +``` + +Command examples: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Ant --num_envs 4 physics=physx +uv run python scripts/environments/random_agent.py --task Isaac-Ant --num_envs 4 physics=newton_mjwarp +``` + +## Domain Presets + +Use domain presets for environment-specific variants such as camera output type. + +```python +from isaaclab.envs import DirectRLEnvCfg +from isaaclab.utils.configclass import configclass +from isaaclab_tasks.utils import PresetCfg + + +@configclass +class CameraTaskCfg(PresetCfg): + @configclass + class BaseCfg(DirectRLEnvCfg): + observation_space = [100, 100, 3] + + default = BaseCfg() + rgb = default + depth = BaseCfg(observation_space=[100, 100, 1]) +``` + +Command examples: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Cartpole-Camera-Direct --num_envs 4 presets=rgb +uv run python scripts/environments/random_agent.py --task Isaac-Cartpole-Camera-Direct --num_envs 4 presets=depth +``` + +## Combined Presets + +For camera tasks that expose physics, renderer, and data-type variants, combine selectors: + +```bash +uv run python scripts/environments/random_agent.py --task Isaac-Cartpole-Camera-Direct --num_envs 4 physics=physx renderer=isaacsim_rtx_renderer presets=rgb +uv run python scripts/environments/random_agent.py --task Isaac-Cartpole-Camera-Direct --num_envs 4 physics=newton_mjwarp renderer=newton_renderer presets=depth +``` + +Before using a name, list the task's exposed presets: + +```bash +uv run python scripts/environments/list_envs.py --show_presets +``` + +## Existing Source Examples + +Inspect these maintained examples before adding new preset patterns: + +- `source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py` +- `source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py` +- `source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env_cfg.py` diff --git a/skills/user/use-presets/reference.md b/skills/user/use-presets/reference.md new file mode 100644 index 00000000000..1ec8871dcd1 --- /dev/null +++ b/skills/user/use-presets/reference.md @@ -0,0 +1,85 @@ +# Preset System Reference + +## Contents + +- When presets are useful +- When presets are unnecessary +- Selector types +- Definition pattern +- Validation checklist + +## When Presets Are Useful + +Use `PresetCfg` when a field has multiple meaningful named variants that users should select from the command line or through task registration. + +Good candidates: + +- Physics backend settings, such as PhysX versus Newton MJWarp. +- Renderer settings, such as Isaac RTX, Newton Warp, or OVRTX renderers. +- Camera data types, such as RGB, depth, albedo, or segmentation. +- Backend-specific event or sensor configs. +- Backend-specific schema cfg choices when PhysX, Newton, or MuJoCo require different USD physics attributes. +- Domain variants where one task supports multiple authored modes. + +## When Presets Are Unnecessary + +Do not add presets when: + +- The task supports only one backend or renderer. +- The difference is a one-off training override. +- A plain config field is clearer. +- The variant is not exposed or tested. + +Prefer the simple config first. Add presets after the task has at least two tested variants. + +## Selector Types + +Isaac Lab preset-aware entry points recognize three selector forms: + +| Selector | Purpose | +| --- | --- | +| `physics=NAME` | Selects variants whose values are physics config objects. | +| `renderer=NAME` | Selects variants whose values are renderer config objects. | +| `presets=NAME[,NAME,...]` | Applies domain-specific variants or broadcasts preset names across matching preset fields. | + +From the Isaac Lab checkout, use `uv run python scripts/environments/list_envs.py --show_presets` to inspect available names before guessing. + +## Definition Pattern + +Import paths: + +```python +from isaaclab.sim import SimulationCfg +from isaaclab.utils.configclass import configclass +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_physx.physics import PhysxCfg +from isaaclab_tasks.utils import PresetCfg +``` + +Pattern: + +```python +@configclass +class PhysicsCfg(PresetCfg): + default = PhysxCfg() + physx = default + newton_mjwarp = NewtonCfg(solver_cfg=MJWarpSolverCfg()) + + +@configclass +class MyEnvCfg: + sim: SimulationCfg = SimulationCfg(physics=PhysicsCfg()) +``` + +For multi-backend tasks, keep backend-specific solver values in the preset wrapper. Do not branch on backend names inside step, reward, or reset logic unless behavior truly cannot be represented as config. + +For schema presets, import universal fragments and base cfgs from `isaaclab.sim.schemas`, PhysX-specific cfgs from `isaaclab_physx.sim.schemas`, and Newton or MuJoCo cfgs from `isaaclab_newton.sim.schemas`. + +## Validation Checklist + +- The `default` variant is valid. +- Every named variant is tested. +- Selector names match existing conventions such as `physx`, `newton_mjwarp`, `newton_kamino`, `ovphysx`, `rgb`, and `depth`. +- A small random-agent rollout succeeds for each variant. +- Training commands include only preset names that the task exposes. +- Backend-specific schema, sensor, or event variants are kept inside preset classes rather than hidden in scattered runtime conditionals. diff --git a/skills/user/use-sensors-actuators/SKILL.md b/skills/user/use-sensors-actuators/SKILL.md new file mode 100644 index 00000000000..5ad433dcaa6 --- /dev/null +++ b/skills/user/use-sensors-actuators/SKILL.md @@ -0,0 +1,62 @@ +--- +name: isaaclab-using-sensors-actuators +description: Adds and configures Isaac Lab sensors and actuators. Use when adding cameras, contact sensors, ray casters, IMUs, joint wrench sensors, tiled cameras, actuator models, or sensor observations to tasks. +audience: user +status: experimental +owners: + - isaaclab-maintainers +--- + +# Using Sensors And Actuators + +## When To Use + +Use this skill when a user wants to add, configure, or debug sensors or actuator models in an Isaac Lab task. + +Do not use this skill as a complete sensor or actuator catalog. Point to the API docs and maintained examples for available classes and parameters. + +## Workflow + +1. Identify the data needed by the task: contacts, ray hits, camera images, IMU data, joint wrench data, actuator dynamics, or low-dimensional robot state. +2. Read the relevant sensor or actuator docs before editing configs. +3. Check whether the sensor requires a simulation app, renderer, backend-specific support, or special update period. +4. Add sensor or actuator configs to the environment scene or asset config using existing task examples as templates. +5. Register the sensor in the scene setup for direct workflows, or in the scene config for manager-based workflows. +6. Add observation terms only after confirming the sensor data shape and device. +7. For contact-heavy tasks, verify body name patterns and history length. +8. For ray-cast terrain perception, verify mesh paths and terrain import setup. +9. For camera-based RL, start with small environment counts and confirm renderer memory behavior. +10. For multi-backend contact, ray, frame, IMU, PVA, or joint-wrench sensors, check whether the task needs backend-specific sensor cfgs from `isaaclab_physx.sensors` or `isaaclab_newton.sensors` wrapped in `PresetCfg`. +11. For actuator changes, compare default joint names, limits, stiffness, damping, effort limits, and backend differences. + +## Validation + +Use this checklist: + +1. Instantiate the environment with a small number of environments. +2. Confirm the sensor or actuator config binds to the intended prims or joints. +3. Step the simulation and inspect data shape, dtype, device, and update timing. +4. Confirm observations include the intended data and no stale values. +5. Run a short rollout or training smoke test after shape validation. + +For skill changes, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep this skill synchronized with sensor and actuator docs under `docs/source/overview/core-concepts/`, sensor tutorials under `docs/source/tutorials/04_sensors/`, API docs under `docs/source/api/`, and maintained task examples under `source/isaaclab_tasks/isaaclab_tasks/`. If sensor support changes for PhysX, Newton, or renderers, update the docs or source examples first. + +## References + +- [Evaluations](evaluations.md) +- [Examples](examples.md) +- [Add sensors on robot tutorial](../../../docs/source/tutorials/04_sensors/add_sensors_on_robot.rst) +- [Contact sensor docs](../../../docs/source/overview/core-concepts/sensors/contact_sensor.rst) +- [Joint wrench sensor docs](../../../docs/source/overview/core-concepts/sensors/joint_wrench_sensor.rst) +- [Actuators docs](../../../docs/source/overview/core-concepts/actuators.rst) +- [Sensors API](../../../docs/source/api/lab/isaaclab.sensors.rst) +- [Sensor patterns API](../../../docs/source/api/lab/isaaclab.sensors.patterns.rst) +- [Actuators API](../../../docs/source/api/lab/isaaclab.actuators.rst) diff --git a/skills/user/use-sensors-actuators/evaluations.md b/skills/user/use-sensors-actuators/evaluations.md new file mode 100644 index 00000000000..7052cdb6ae2 --- /dev/null +++ b/skills/user/use-sensors-actuators/evaluations.md @@ -0,0 +1,63 @@ +# Sensors And Actuators Evaluations + +## Scenario 1: Contact Sensor + +Query: "Add foot contact observations and air-time rewards to my quadruped task." + +Expected behavior: + +- Points to contact sensor docs and nearby locomotion examples. +- Checks body name patterns, history length, update period, and contact thresholds. +- Validates sensor data shape before adding observations and rewards. + +Known failure modes: + +- Reads raw contact tensors directly without configuring the scene sensor. +- Uses body name patterns that do not match the robot asset. + +## Scenario 2: Ray-Cast Terrain Perception + +Query: "Add height measurements for rough terrain locomotion." + +Expected behavior: + +- Points to ray-caster usage in maintained rough locomotion examples. +- Checks terrain mesh paths and ray pattern configuration. +- Adds observation data only after validating ray hit shape and device. + +Known failure modes: + +- Adds height observations without binding the ray caster to the ground mesh. +- Assumes flat-terrain observation dimensions still apply. + +## Scenario 3: Actuator Model + +Query: "Replace ideal position control with a learned actuator model." + +Expected behavior: + +- Checks actuator docs, robot asset config, joint names, and backend constraints. +- Preserves joint limits and control semantics when changing actuator config. +- Runs a small rollout to catch stability or device issues. + +Known failure modes: + +- Changes actuator type without checking joint names and limits. +- Treats actuator behavior as independent of physics backend. + +## Scenario 4: Camera Observation + +Query: "Add image observations to Cartpole training." + +Expected behavior: + +- Points to camera-enabled Cartpole examples and sensor docs. +- Checks renderer requirements, data types, image size, and memory cost. +- Recommends a small environment count before training. +- Verifies the observation shape and framework support before changing agent configs. + +Known failure modes: + +- Adds camera tensors to observations without enabling a compatible renderer. +- Starts with thousands of environments before validating memory usage. +- Ignores whether the selected RL framework can consume the observation space. diff --git a/skills/user/use-sensors-actuators/examples.md b/skills/user/use-sensors-actuators/examples.md new file mode 100644 index 00000000000..eb35fd9a4fb --- /dev/null +++ b/skills/user/use-sensors-actuators/examples.md @@ -0,0 +1,65 @@ +# Sensors And Actuators Examples + +## Contact Sensors + +Use contact sensors for feet air time, undesired contacts, grasp checks, and termination conditions. + +Examples to inspect: + +- `source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env_cfg.py` +- `source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py` + +For multi-backend tasks, follow the `VelocityEnvContactSensorCfg` pattern: wrap PhysX, Newton, and OvPhysX contact sensor configs in a `PresetCfg` instead of assuming the base contact sensor cfg works identically on every backend. + +Validation checklist: + +- Body name patterns match the asset. +- `history_length` is sufficient for the reward or termination term. +- Sensor update period matches the control and simulation step. +- Contact data shape is checked before adding observations or rewards. + +## Ray-Cast Height Scanners + +Use ray casters for terrain perception on rough locomotion tasks. + +Examples to inspect: + +- `source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env_cfg.py` +- `source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py` + +Validation checklist: + +- The scanner is mounted on the intended robot body. +- `mesh_prim_paths` points to the terrain mesh. +- Observation dimensions are updated for the ray pattern. +- Flat-terrain configs remove or disable height scan observations. + +## Cameras + +Use camera or tiled-camera examples before adding visual observations to training. + +Examples to inspect: + +- `source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env_cfg.py` +- `docs/source/tutorials/04_sensors/add_sensors_on_robot.rst` + +Validation checklist: + +- Renderer requirements are met. +- Data types and image sizes fit memory limits. +- Training starts with a small number of environments. + +## Actuators + +Use actuator configs in robot assets or task overrides, then validate joint names and limits. + +Examples to inspect: + +- `source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py` +- `source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py` + +Validation checklist: + +- `joint_names_expr` matches the intended joints. +- Effort, velocity, stiffness, damping, and armature are compatible with the robot. +- Backend-specific behavior is checked before adding presets. diff --git a/source/isaaclab/changelog.d/0xadvait-fix-configclass-ordering.rst b/source/isaaclab/changelog.d/0xadvait-fix-configclass-ordering.rst new file mode 100644 index 00000000000..428e38859a5 --- /dev/null +++ b/source/isaaclab/changelog.d/0xadvait-fix-configclass-ordering.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.utils.configclass.configclass` reordering class attributes when type + annotations are provided on only some of them. Annotated attributes no longer jump ahead of + non-annotated ones, so the resulting field order now matches the declaration order. This + is important for configuration classes where the attribute order is meaningful, such as + :class:`~isaaclab.scene.InteractiveSceneCfg`. diff --git a/source/isaaclab/changelog.d/antoiner-benchmark-cleanup.major.rst b/source/isaaclab/changelog.d/antoiner-benchmark-cleanup.major.rst new file mode 100644 index 00000000000..5ab36e6e800 --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-benchmark-cleanup.major.rst @@ -0,0 +1,11 @@ +Changed +^^^^^^^ + +* **Breaking:** Removed the legacy benchmark entry-point scripts now superseded by the unified + ``runtime.py``, ``startup.py``, and ``training.py`` scripts: ``benchmark_non_rl.py``, + ``benchmark_startup.py``, ``benchmark_rsl_rl.py``, and ``benchmark_rlgames.py``. The + ``run_non_rl_benchmarks.sh``, ``run_physx_benchmarks.sh``, and ``run_training_benchmarks.sh`` + runner shells and the obsolete ``scripts/benchmarks/utils.py`` helper module were removed as + well. Use ``runtime.py``, ``startup.py``, and ``training.py --rl_library `` instead; + run the PhysX micro-benchmarks under ``source/isaaclab_physx/benchmark/`` directly. See the + "Benchmark Scripts" section of the Isaac Lab 3.0 migration guide for the full command mapping. diff --git a/source/isaaclab/changelog.d/antoiner-benchmark-play.minor.rst b/source/isaaclab/changelog.d/antoiner-benchmark-play.minor.rst new file mode 100644 index 00000000000..360ee64ce57 --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-benchmark-play.minor.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added a ``PlayBundle`` schema type and ``isaaclab benchmark play`` to load a trained checkpoint + and benchmark policy inference for the RL library selected with ``--rl_library`` + (``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``), emitting the policy's inference throughput plus + reward / episode-length / success. The checkpoint is taken + from ``--checkpoint`` (a local path, Nucleus URI, or ``latest``/``best`` selector) + or, failing that, the published Nucleus checkpoint for the task. diff --git a/source/isaaclab/changelog.d/fix-termination-manager-comments.skip b/source/isaaclab/changelog.d/fix-termination-manager-comments.skip new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/isaaclab/changelog.d/jichuanh-arm-ci.rst b/source/isaaclab/changelog.d/jichuanh-arm-ci.rst new file mode 100644 index 00000000000..376f7f58917 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-arm-ci.rst @@ -0,0 +1,11 @@ +Fixed +^^^^^ + +* Added a defensive fallback in :class:`isaaclab.app.AppLauncher` so it derives + ``EXP_PATH`` from the installed ``isaacsim`` package when the env var is not + set. ``isaacsim.bootstrap_kernel`` normally sets ``EXP_PATH`` on first import, + but the early-return path in its bootstrap (triggered under some pip install + layouts on aarch64) skips the env-var setup. Previously this caused + ``KeyError: 'EXP_PATH'`` deep inside ``_resolve_experience_file``; now + AppLauncher resolves the path from ``isaacsim.__file__`` and stores it back + into the environment so subsequent code can rely on it. diff --git a/source/isaaclab/changelog.d/jmart-cartpole-rtx.minor.rst b/source/isaaclab/changelog.d/jmart-cartpole-rtx.minor.rst deleted file mode 100644 index c7b1de77a76..00000000000 --- a/source/isaaclab/changelog.d/jmart-cartpole-rtx.minor.rst +++ /dev/null @@ -1,12 +0,0 @@ -Added -^^^^^ - -* Added the :meth:`~isaaclab.physics.physics_manager.PhysicsManager.provides_implicit_damping` and - :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.provides_temporal_camera_data` capability - classmethods on the runtime backend bases, so physics and renderer backends declare whether a - camera observation carries the temporal information a policy needs to infer velocity (used to - decide frame stacking). Base defaults: physics has implicit damping (``True``); a renderer - provides no temporal data (``False``). -* Added :meth:`~isaaclab.renderers.Renderer.resolve_class` to resolve a renderer's implementation - class from its configuration without instantiating it (so tasks can query the above classmethod - before a simulation exists). diff --git a/source/isaaclab/changelog.d/jmart-dr-reset.rst b/source/isaaclab/changelog.d/jmart-dr-reset.rst deleted file mode 100644 index 9088b9800e1..00000000000 --- a/source/isaaclab/changelog.d/jmart-dr-reset.rst +++ /dev/null @@ -1,9 +0,0 @@ -Fixed -^^^^^ - -* Fixed a crash where :class:`~isaaclab.envs.mdp.randomize_visual_color` used in ``reset`` mode - raised ``AttributeError: 'NoneType' object has no attribute 'link_count'`` during environment - startup on the PhysX backend. The randomizer authored USD (``SetInstanceable`` and material - binding) on the articulation root prim, which invalidated the PhysX articulation view so that - the subsequent at-play body-name resolution dereferenced a ``None`` metatype. It now scopes to - descendant visual prims, mirroring :class:`~isaaclab.envs.mdp.randomize_visual_texture_material`. diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst new file mode 100644 index 00000000000..dd740d58524 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -0,0 +1,22 @@ +Added +^^^^^ + +* Added the articulation-root schema-fragment API: + :class:`~isaaclab.sim.schemas.ArticulationRootFragment` (marker) and + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`, which applies a list of + articulation-root fragments with ``UsdPhysics.ArticulationRootAPI`` as a presence-gated anchor + and reproduces the legacy ``fix_root_link`` fixed-joint logic via a spawner-level flag. +* Added the :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability, which fixes an + articulation base to the world frame and returns the resulting root prim. The base implementation + authors a backend-neutral fixed joint; backends whose parser relocates the articulation root (e.g. + PhysX) override it, so :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` applies every + fragment to the single resulting root regardless of backend. + +Changed +^^^^^^^ + +* Changed the spawner ``articulation_props`` slot + (:attr:`~isaaclab.sim.spawners.UsdFileCfg.articulation_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.ArticulationRootFragment` fragments, and added the spawner-level + :attr:`~isaaclab.sim.spawners.UsdFileCfg.fix_root_link` flag. Legacy single cfgs continue to + work through a transition bridge in the spawn writer. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 6fd0197bebd..2605e11954e 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 = "7.0.5" +version = "10.2.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index ba7727cba56..ff2e6fa5e00 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,662 @@ Changelog --------- +10.2.0 (2026-07-10) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``isaaclab benchmark training`` to run real training with the library selected by + ``--rl_library`` (``rsl_rl``, ``rl_games``, ``skrl``, or ``sb3``) and emit + :class:`~isaaclab.test.benchmark.TrainingBundle` output through ``--benchmark_formatter``. + The RSL-RL and RL-Games adapters support optional success-metric + early stopping; physics and rendering backends are selected with ``presets=`` Hydra tokens. +* Added ``run.json`` manifests to benchmark training logs, enabling compatible + runs to be selected through ``--checkpoint latest`` or ``--checkpoint best``. + +Fixed +^^^^^ + +* Fixed SKRL and Stable-Baselines3 training bundles omitting task success rates + reported by the environment. +* Fixed training bundles omitting Python-import and task-configuration startup timings. +* Fixed benchmark training help and RL-library adapter dispatch through + ``isaaclab benchmark training``. +* Fixed RSL-RL training benchmarks failing in environments without Git LFS. + Benchmark runs now skip RSL-RL source-state archiving while retaining + TensorBoard metric logging. +* Fixed headless video recording (``--video`` / ``rgb_array``) forcing a Kit ``app.update()`` + on every environment step. :attr:`~isaaclab.sim.SimulationContext.is_rendering` no longer + reports offscreen rendering as continuous rendering, so Kit is now pumped on demand only when + a frame is actually requested. GUI, RTX sensor, visualizer, and XR rendering are unaffected. + + +10.1.0 (2026-07-09) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added physics-backend-agnostic ``runtime.py`` and ``startup.py`` benchmark + entry points. They emit :class:`~isaaclab.test.benchmark.RuntimeBundle` and + :class:`~isaaclab.test.benchmark.StartupBundle` outputs and select physics and + rendering backends with ``presets=`` Hydra tokens. +* Added the ``uv run isaaclab benchmark`` entry point for runtime and startup benchmarks. +* Added OVPhysX backend support to :class:`~isaaclab.envs.mdp.events.randomize_rigid_body_material`. + OVPhysX runs the PhysX solver, so materials are bucket-sampled (PhysX's 64000-material limit + applies) and written per shape through the asset's + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`. Randomizes all shapes; per-body selection via + ``asset_cfg.body_ids`` is not supported, as the ovphysx wheel exposes no per-body shape counts. + +Changed +^^^^^^^ + +* **Breaking:** Removed the ``--rendering_mode`` launcher argument. Isaac RTX + camera rendering now uses the default rendering experience settings; override + individual RTX settings through + :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg`. +* Changed the Pink IK force-install stack in the install CLI to derive from the + root ``pyproject.toml`` pins instead of a hardcoded list, so version bumps are + made in one place. +* Moved Python logging-level resolution out of :class:`~isaaclab.app.AppLauncher` into the + backend-agnostic helpers :func:`~isaaclab.app.logging_utils.resolve_python_logging_level` and + :func:`~isaaclab.app.logging_utils.apply_python_logging_level`, so that ``--verbose`` / ``--info`` + now switch the Python logging level for kitless backends (Newton, OvPhysX) launched via + :func:`~isaaclab.app.launch_simulation`, not just Kit-based runs. + +Fixed +^^^^^ + +* Fixed benchmark recorder imports in uv environments by declaring the + ``psutil`` dependency. +* Fixed the runtime benchmark to honor ``--device`` for Kitless physics + backends. +* Fixed runtime and startup bundle metadata to record resolved task-default + physics and rendering backends. +* Fixed runtime benchmark output to record Python-import and task-configuration + startup timings. +* Fixed the startup benchmark total duration to end at the first synchronized + environment step instead of including profile-report generation. +* Fixed passing a single Kit argument in the space-separated form (e.g. + ``--kit_args "--ext-folder=/path/to/ext"``) failing with the argparse error + "argument --kit_args: expected one argument" on every entry point, including the unified + ``train``/``play`` commands and all ranks of the multi-GPU training launcher. + :meth:`~isaaclab.app.AppLauncher.add_app_launcher_args` and the unified RL entry point + dispatcher now normalize such pairs into single ``--kit_args=`` tokens before parsing. +* Fixed duplicate ``L_thumb_distal_joint`` entry in the Fourier left hand DexPilot retargeting config + (:file:`fourier_hand_left_dexpilot.yml`). +* Fixed docker installs deleting ``packaging`` from Isaac Sim's + ``omni.isaac.core_archive`` prebundle by removing the ``packaging<24`` bound + (no consumer requires it). The deletion dangled the symlink farm that + ``omni.services.pip_archive`` shares with it and broke 13 extensions at + startup. +* Added a fail-loud post-install check that aborts installation when pip + operations leave a prebundled Isaac Sim package with a dangling + ``__init__.py`` (other new dangling symlinks are reported as warnings). +* Fixed the ``isaacsim.robot_motion.pink`` extension failing to load after + installation by moving the ``pin-pink`` pin from ``3.1.0`` to ``3.3.0``, which + provides ``pink.exceptions.NoSolutionFound`` while staying below the pink 3.4 + task-API break. Environments installed manually should update with + ``pip install pin-pink==3.3.0``. + + +10.0.0 (2026-07-08) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added explicit local/world scale getters + :meth:`~isaaclab.sim.views.BaseFrameView.get_local_scales` and + :meth:`~isaaclab.sim.views.BaseFrameView.get_world_scales` to the FrameView + API, implemented for :class:`~isaaclab.sim.views.UsdFrameView`. Scale + writes go through the writer scope (see the ``xform-space-writer`` + fragment). + +* Added :func:`~isaaclab.utils.warp.fabric.decompose_indexed_fabric_transforms`, + :func:`~isaaclab.utils.warp.fabric.compose_indexed_fabric_transforms`, + :func:`~isaaclab.utils.warp.fabric.update_indexed_local_matrix_from_world`, and + :func:`~isaaclab.utils.warp.fabric.update_indexed_world_matrix_from_local` + Warp kernels operating on :class:`wp.indexedfabricarray` for reading and + writing Fabric ``Matrix4d`` attributes (``omni:fabric:worldMatrix`` / + ``omni:fabric:localMatrix``). +* Added :class:`~isaaclab.sim.views.FrameViewSpaceWriterBase`, the new context-managed + write API for ``FrameView``-managed prim transforms. Open with + ``view.xform_world_space_writer()`` or ``view.xform_local_space_writer()`` and call + :meth:`~isaaclab.sim.views.FrameViewSpaceWriterBase.set_poses` / + :meth:`~isaaclab.sim.views.FrameViewSpaceWriterBase.set_scales` inside the scope; + the writer's ``__exit__`` derives the opposite-space matrices once and + synchronizes once. Only one writer scope may be active per view at a + time. View-level getters + (:meth:`~isaaclab.sim.views.BaseFrameView.get_world_poses` etc.) raise + :class:`RuntimeError` while a writer scope is active. + +* Added the two concrete tag classes + :class:`~isaaclab.sim.views.FrameViewWorldSpaceWriter` and + :class:`~isaaclab.sim.views.FrameViewLocalSpaceWriter` returned by + :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / + :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer`. +* Added pytest *level* markers (``unit`` and ``integration``) and a ``rendering`` *flavor* marker + registered in the repository-root ``pyproject.toml`` and applied per file via a module-level + ``pytestmark``, so tests can be filtered by kind (e.g. ``pytest -m unit``, ``pytest -m "not unit"``, + or ``pytest -m "integration and not rendering"``). The repository-root ``conftest.py`` records + each test's markers into the JUnit XML report for CI to categorize uploaded results. +* Added ``ISAACLAB_FABRIC_USE_GPU_INTEROP`` to override the corresponding PhysX + Fabric Kit setting without changing renderer multi-GPU behavior. The multi-GPU + CI override is a temporary workaround to remove after the underlying Kit/PhysX + problem is fixed. +* Added an ``isaacsim`` extra to the root ``pyproject.toml`` so the PhysX + backend can be pulled in directly under uv, e.g. + ``uv run --extra isaacsim isaaclab train --task Isaac-Cartpole-Direct presets=physx``. + Isaac Sim narrows ``newton`` to its own pinned version, while the base install + otherwise tracks the latest ``newton[sim]>=1.2.0`` from the package index. +* Added documentation for ``uv run`` workflows with repo-local Isaac Sim source or binary installs. + +Changed +^^^^^^^ + +* Changed the Isaac Lab Kit experiences to use one renderer GPU by default. To + enable single-process multi-GPU rendering, pass the ``renderer.multiGpu`` + settings explicitly through :class:`~isaaclab.app.AppLauncher`'s ``kit_args``. +* Centralized all Isaac Lab third-party dependencies (required and optional) + into the root ``pyproject.toml`` as the single source of truth. The wheel + builder (``tools/wheel_builder/gen_pyproject.py``) and the ``./isaaclab.sh -i`` + install CLI now read the root project's ``dependencies`` and + ``optional-dependencies`` instead of per-sub-package declarations and + ``tools/wheel_builder/res/python_packages.toml`` (removed). Sub-package + ``pyproject.toml`` files no longer declare dependencies. The ``./isaaclab.sh -i`` + token syntax is unchanged. +* **Changed:** Newton (``newton[sim]``) is now a core dependency installed in + every environment as the default physics engine, rather than an opt-in extra. + The Newton interactive viewer GUI is also part of the base install, so the + ``newton`` optional extra has been removed; the ``newton`` install token / + ``--extra newton`` is now a no-op kept for backward compatibility. +* ``./isaaclab.sh -i`` now force-installs the pinned Newton git build (from + ``[tool.uv].override-dependencies``) over the older ``newton[sim]`` bundled by + Isaac Sim, so environments get the Newton version Isaac Lab targets. +* Added the ``[tool.isaaclab.versions]`` table to the root ``pyproject.toml`` as + the single source of truth for externally-pinned versions (Isaac Sim, the + torch stack, and the OV renderer/physics wheels). The install CLI, docs, and + CI read these values; a unit test enforces that the literal pins in the extras + and ``[tool.uv].override-dependencies`` stay in sync with the table. +* **Changed:** The aggregate ``all`` extra now contains only packages that can + co-resolve with ``isaacsim`` (the documented ``[all,isaacsim]`` install). + ``ov`` (OVRTX / OvPhysX), ``viser``, and the mimic USD-to-URDF converter + (``nvidia-srl-usd-to-urdf``) are no longer pulled in by ``all`` because their + pins conflict with isaacsim's; install them explicitly with ``--extra ov`` / + ``--extra viser`` / ``--extra mimic`` when not using isaacsim. +* The Newton interactive viewer GUI (``imgui-bundle``, ``typing-extensions``) is + now part of the base install, so the viewer's HUD controls work without + ``--extra newton``. The ``newton`` extra / ``-i newton`` token is retained as a + backwards-compatible alias. +* The Isaac Sim version is now declared once in the root ``pyproject.toml`` + ``isaacsim`` extra and read from there by the documentation build and the + license-check CI workflow, instead of being hard-coded in each location. +* **Breaking:** Removed the standalone ``train``, ``play``, and ``train_multigpu`` + console scripts. These are now subcommands of the ``isaaclab`` entry point + (``isaaclab train`` / ``isaaclab play`` / ``isaaclab train_multigpu``, e.g. + ``uv run isaaclab train ...``) so they no longer clash with ``train``/``play`` + commands provided by other installed packages. +* Updated the ``launch_simulation`` runtime-compatibility error and help text to + reference the suffix-less renderer presets ``ovrtx`` and ``isaacsim_rtx`` + instead of ``ovrtx_renderer`` and ``isaacsim_rtx_renderer``. + +Fixed +^^^^^ + +* Fixed repeated sensor data reads re-running backend updates when the cached + data was already fresh. +* Fixed PyTorch and Warp selecting different CUDA devices during simulation + initialization. +* Fixed ``./isaaclab.sh -i`` (PhysX path) to install the Isaac Sim requirement + pinned in the root ``pyproject.toml`` ``isaacsim`` extra, instead of a + hard-coded ``isaacsim[all]>=6.0.0``. The install CLI now shares the single + source of truth used by the docs, CI, and ``uv run --extra isaacsim``. +* Fixed a broken ``pxr`` (OpenUSD) runtime on x86_64 caused by co-installing + ``usd-core`` and ``usd-exchange``. Both wheels vendor a complete ``pxr`` + package at different USD versions, so installing both left a mixed + installation that raised ``RuntimeError: extension class wrapper for base + class ... Tf_PyEnumWrapper has not been created yet`` on ``import pxr``. + ``usd-exchange`` is now installed only on ``aarch64``/``arm64`` (where + ``usd-core`` has no wheel); x86_64 uses ``usd-core`` as the sole ``pxr`` + provider. + +Notes +^^^^^ + +* :meth:`~isaaclab.sim.views.BaseFrameView.get_scales` and + :meth:`~isaaclab.sim.views.BaseFrameView.set_scales` remain supported as + convenience helpers (not deprecated). For reads where the space matters, + prefer the explicit ``get_local_scales`` (operates on ``xformOp:scale``) or + ``get_world_scales`` (composed world-space scale). For writes that also + update poses, prefer batching inside one scope: + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` + (or ``xform_local_space_writer``). + :class:`~isaaclab.sim.views.UsdFrameView` preserves prior behavior by + defaulting :meth:`get_scales` / :meth:`set_scales` to local scales. +* :meth:`~isaaclab.sim.views.BaseFrameView.set_world_poses`, + :meth:`~isaaclab.sim.views.BaseFrameView.set_local_poses`, and + :meth:`~isaaclab.sim.views.BaseFrameView.set_scales` remain supported as + convenience helpers (not deprecated). Each opens a single-statement writer + scope internally, so updating poses and scales through separate calls derives + the opposite-space matrices and synchronizes twice. For best performance, + update both inside one scope: + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` + (or :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer`). + The bundled examples use the writer scope to get this benefit; callers may + keep the convenience helpers when code simplicity matters more than shaving + a redundant derive/sync. + + +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) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed Newton training videos to reuse the active visualizer framebuffer, preserving selected + environments, the live camera, and markers without a second rendering pass. + + +8.0.1 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the Isaac Lab wheel's ``newton[sim]`` dependency pin to Newton + commit ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee`` and the core + ``warp-lang`` dependency pin from ``1.14.0`` to + ``1.15.0.dev20260626``. Projects that install these dependencies separately + should use the same versions. + + +8.0.0 (2026-06-30) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* **Breaking:** Removed ``RenderCfg`` and ``SimulationCfg.render``. Configure + Isaac RTX quality settings through + :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` on + :attr:`~isaaclab_physx.renderers.IsaacRtxRendererCfg.global_settings` instead. + +Fixed +^^^^^ + +* Fixed a crash (``undefined symbol: ncclDevCommCreate``) when launching the Isaac Sim + streaming app (e.g. ``isaac-sim.streaming.sh`` / ``runheadless.sh``) from an Isaac Lab + install against Isaac Sim 6.0. Isaac Sim's deprecated ``omni.isaac.ml_archive`` prebundle + ships its own PyTorch and NCCL while Isaac Lab installs a different pinned PyTorch; on + launch paths that do not import Isaac Lab (which otherwise deprioritizes the prebundle on + ``sys.path``), the two NCCL copies collide and the prebundled torch binds to the wrong + one. The install step that repoints the prebundle to the active environment now uses + overlayfs-safe filesystem operations, so it works inside the Docker image build (where it + previously failed silently with ``EXDEV`` / ``EINVAL``) and fails loudly if a shadowing + copy remains. + + +7.5.0 (2026-06-28) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the mesh-collision schema-fragment API: the + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsMeshCollisionCfg` (carrying the standard + ``physics:approximation`` token via ``UsdPhysics.MeshCollisionAPI``). +* Added :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`, which applies + ``UsdPhysics.MeshCollisionAPI`` as the implicit anchor, resolves the + ``physics:approximation`` token from whichever cooking fragment is present (validated against + :const:`~isaaclab.sim.schemas.MESH_APPROXIMATION_TOKENS`), and dispatches each fragment via its + ``func``. +* Added the joint-drive schema-fragment API: the + :class:`~isaaclab.sim.schemas.JointDriveFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` (writing the typed multi-instance + ``UsdPhysics.DriveAPI`` attributes). The drive fragment overrides its ``func`` with + :func:`~isaaclab.sim.schemas.apply_drive`, which selects the angular/linear instance, performs + the radian-to-degree conversion for angular drives, and skips tendon child prims. +* Added :func:`~isaaclab.sim.schemas.apply_joint_drive_properties` (applies a list of joint-drive + fragments to all joint prims under a path; ``UsdPhysics.DriveAPI`` is presence-gated and applied + only when a :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present). +* Added an opt-in ``compute_final_obs`` flag (default ``False``) to + :class:`~isaaclab.envs.DirectRLEnvCfg`, :class:`~isaaclab.envs.DirectMARLEnvCfg`, and + :class:`~isaaclab.envs.ManagerBasedRLEnvCfg` that captures the terminal observation before a + Same-Step autoreset and exposes it through ``extras["final_obs"]``. The captured observation has + the same observation noise applied as the returned observation. When the flag is ``False`` the + previous behavior is preserved (no capture, no extra observation computation). +* Declared the Same-Step autoreset mode in RL environment metadata. + +Changed +^^^^^^^ + +* Changed the mesh-converter ``mesh_collision_props`` slot + (:attr:`~isaaclab.sim.converters.MeshConverterCfg.mesh_collision_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` fragments. Legacy single cfgs continue to + work through a transition bridge in the converter. +* Changed the spawner ``joint_drive_props`` slot + (:attr:`~isaaclab.sim.spawners.FileCfg.joint_drive_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.JointDriveFragment` fragments. Legacy single cfgs continue to work + through a transition bridge at the from-files spawn site. Added the spawner-level + ``ensure_drives_exist`` flag to reproduce the legacy minimal-stiffness behaviour for the fragment + path. + + +7.4.0 (2026-06-27) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added an ``"adaptive_dls"`` ``ik_method`` to :class:`~isaaclab.controllers.DifferentialIKController`: + a manipulability-aware damped least squares whose damping ramps from ``lambda_min`` toward + ``lambda_max`` as the smallest task-Jacobian singular value drops below ``sigma_thresh`` + (Maciejewski-Klein style), keeping low-DOF / near-singular arms well-conditioned. +* Added an optional per-axis :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.orientation_weight` + (scalar or ``(wx, wy, wz)``) that soft-weights the orientation rows of a ``"pose"`` task, so an + arm that cannot serve a full 6-DOF pose degrades gracefully instead of leaking orientation error + into position. +* Added null-space joint-limit avoidance to :class:`~isaaclab.controllers.DifferentialIKController` + via :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_gain` / + :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_margin` and + :meth:`~isaaclab.controllers.DifferentialIKController.set_joint_pos_limits`. When enabled, a + center-seeking bias is projected into the null space of the position rows so it never perturbs + the commanded end-effector position; :class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction` + injects the joint limits automatically. +* Added the tendon schema-fragment markers + :class:`~isaaclab.sim.schemas.FixedTendonFragment` and + :class:`~isaaclab.sim.schemas.SpatialTendonFragment`, which type the spawner + ``fixed_tendons_props`` / ``spatial_tendons_props`` slots. +* Added :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and + :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`, the family writers that + dispatch a list of tendon fragments via each fragment's ``func``. Tendons are a + *tune-not-apply* family, so the writers tune the existing multi-instance tendon schemas + without applying a new anchor schema. + +Changed +^^^^^^^ + +* Changed :meth:`~isaaclab.controllers.DifferentialIKController.set_command` to renormalize the + commanded quaternion for absolute ``"pose"`` commands, hardening the controller against slightly + non-unit quaternion inputs. Existing unit-quaternion callers are unaffected. +* Changed the spawner ``fixed_tendons_props`` / ``spatial_tendons_props`` slots + (:attr:`~isaaclab.sim.spawners.from_files.FileCfg.fixed_tendons_props`, + :attr:`~isaaclab.sim.spawners.from_files.FileCfg.spatial_tendons_props`) to also accept one + or more tendon fragments. Legacy single cfgs continue to work through a transition bridge in + the spawn writer. +* :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and + :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties` now raise + ``ValueError`` when the prim at ``prim_path`` does not exist in the stage. + Callers that previously relied on an implicit no-op for invalid paths must + either validate the path beforehand or catch ``ValueError``. + The aggregated return value is now ``False`` whenever any fragment applier + reports failure; callers must not assume the return is always ``True`` even + when the prim is valid. + +Fixed +^^^^^ + +* Fixed native (non-Docker) ARM installation failing when ``swig`` is missing. + The ``nlopt==2.6.2`` source build now installs ``swig`` via apt only for the + duration of the build and purges it afterwards, so the GPL-licensed ``swig`` + is never left behind — in particular it is never shipped in the Docker image. +* Fixed Linux ARM installation by pre-installing ``nlopt==2.6.2`` before + Isaac Lab dependencies are resolved, and by keeping ``swig`` available in + Docker only until dependency installation completes. + + +7.3.0 (2026-06-26) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the mass schema-fragment API: the :class:`~isaaclab.sim.schemas.MassFragment` marker and + :class:`~isaaclab.sim.schemas.MassCfg` (writes ``physics:mass`` / ``physics:density`` via + ``UsdPhysics.MassAPI``). The legacy :class:`~isaaclab.sim.schemas.MassPropertiesCfg` remains the + canonical name and continues to work unchanged. +* Added :func:`~isaaclab.sim.schemas.apply_mass_properties`, which applies a list of mass fragments + with ``UsdPhysics.MassAPI`` as the implicit anchor. +* Added the collision schema-fragment API: the + :class:`~isaaclab.sim.schemas.CollisionFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` (the ``physics:collisionEnabled`` + single-namespace fragment). Each fragment carries ``_usd_namespace`` / ``_usd_applied_schema`` + metadata and a ``func`` applier so a prim can carry collision properties from multiple USD + namespaces at once. +* Added :func:`~isaaclab.sim.schemas.apply_collision_properties`, which applies a list of + collision fragments with ``UsdPhysics.CollisionAPI`` as the implicit anchor. + +Changed +^^^^^^^ + +* Changed automatic ``rtx`` launch-time renderer resolution to support the + ``renderer=rtx`` selector. +* Changed the spawner ``mass_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.mass_props`) to also accept a single + :class:`~isaaclab.sim.schemas.MassFragment` or a list of them. Legacy + :class:`~isaaclab.sim.schemas.MassPropertiesCfg` cfgs continue to work through a transition bridge + in the spawn writers. +* Changed the spawner ``collision_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.collision_props`) and the mesh-converter + ``collision_props`` slot to also accept a list of + :class:`~isaaclab.sim.schemas.CollisionFragment` fragments. Legacy single cfgs continue to work + through a transition bridge in the spawn writers. + +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.sim.schemas.apply_mass_properties` to raise ``ValueError`` on an invalid + prim path and to aggregate per-fragment results instead of always returning ``True``, matching + :func:`~isaaclab.sim.schemas.apply_rigid_body_properties`. +* Fixed the spawn writers so an empty ``mass_props`` list is a harmless no-op rather than being + forwarded to :func:`~isaaclab.sim.schemas.define_mass_properties` as an unexpected list. + + +7.2.0 (2026-06-25) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :func:`~isaaclab.utils.renderers.isaac_rtx_per_env_scene_partition_enabled` to + query whether per-environment Isaac RTX scene partitioning is enabled. Set + ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to enable authoring of + ``primvars:omni:scenePartition`` and ``omni:scenePartition`` on the USD stage. +* Added the single-namespace schema-fragment API: :class:`~isaaclab.sim.schemas.SchemaFragment`, + the :class:`~isaaclab.sim.schemas.RigidBodyFragment` marker, and + :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg`. Each fragment carries + ``_usd_namespace`` / ``_usd_applied_schema`` metadata and a ``func`` applier so a prim can + carry rigid-body properties from multiple USD namespaces at once. +* Added :func:`~isaaclab.sim.schemas.apply_namespaced` (generic fragment writer) and + :func:`~isaaclab.sim.schemas.apply_rigid_body_properties` (applies a list of rigid-body + fragments with ``UsdPhysics.RigidBodyAPI`` as the implicit anchor). + +Changed +^^^^^^^ + +* Changed the spawner ``rigid_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.rigid_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.RigidBodyFragment` fragments. Legacy single cfgs continue to + work through a transition bridge in the spawn writers. + +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.sim.save_stage` rewriting URI asset paths, such as + DomeLight texture URLs, into relative filesystem paths. + + +7.1.0 (2026-06-24) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the :meth:`~isaaclab.physics.physics_manager.PhysicsManager.provides_implicit_damping` and + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.provides_temporal_camera_data` capability + classmethods on the runtime backend bases, so physics and renderer backends declare whether a + camera observation carries the temporal information a policy needs to infer velocity (used to + decide frame stacking). Base defaults: physics has implicit damping (``True``); a renderer + provides no temporal data (``False``). +* Added :meth:`~isaaclab.renderers.Renderer.resolve_class` to resolve a renderer's implementation + class from its configuration without instantiating it (so tasks can query the above classmethod + before a simulation exists). + +Fixed +^^^^^ + +* Fixed a crash where :class:`~isaaclab.envs.mdp.randomize_visual_color` used in ``reset`` mode + raised ``AttributeError: 'NoneType' object has no attribute 'link_count'`` during environment + startup on the PhysX backend. The randomizer authored USD (``SetInstanceable`` and material + binding) on the articulation root prim, which invalidated the PhysX articulation view so that + the subsequent at-play body-name resolution dereferenced a ``None`` metatype. It now scopes to + descendant visual prims, mirroring :class:`~isaaclab.envs.mdp.randomize_visual_texture_material`. +* Fixed the wheel-builder ``newton[sim]`` dependency pin to use Newton commit + ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. + + 7.0.5 (2026-06-23) ~~~~~~~~~~~~~~~~~~ @@ -451,8 +1107,6 @@ Added :func:`~isaaclab.sim.utils.resolve_matching_prims_from_source` to allow callers to opt out of the default no-match error. * Added Kit version, kernel version, and git hash logging to :class:`~isaaclab.app.AppLauncher` startup. -* Added the ``rl-games`` optional dependency to the ``isaaclab`` wheel and - included it in the wheel's ``all`` extra. * Added :attr:`~isaaclab.managers.EventTermCfg.resample_interval_on_reset` to allow ``"interval"`` event terms to keep their per-environment timer across resets while still firing asynchronously per environment. Defaults to ``True`` to preserve the existing behavior. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index ab70e000f42..5ec5ec89497 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -28,11 +28,15 @@ import isaacsim # noqa: F401 from isaacsim import SimulationApp +from isaaclab.app.logging_utils import apply_python_logging_level, resolve_python_logging_level from isaaclab.app.settings_manager import get_settings_manager, initialize_carb_settings +from isaaclab.utils._device import set_cuda_device # import logger logger = logging.getLogger(__name__) +_FABRIC_GPU_INTEROP_ENV = "ISAACLAB_FABRIC_USE_GPU_INTEROP" + # Suppress noisy debug-level logs from third-party libraries logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("matplotlib").setLevel(logging.WARNING) @@ -80,6 +84,10 @@ class AppLauncher: value >-1. In other words, if ``livestream=-1``, then the value from the environment variable ``LIVESTREAM`` is used. + The ``ISAACLAB_FABRIC_USE_GPU_INTEROP`` environment variable optionally overrides the + ``/physics/fabricUseGPUInterop`` Kit setting. Set it to ``1`` or ``0`` to enable or disable the setting. + When unset, Kit's configured default is preserved. + """ @staticmethod @@ -188,25 +196,6 @@ def _normalize_visualizer_intent(intent: Any) -> tuple[bool, bool]: ) return has_any, has_kit - @staticmethod - def _resolve_python_logging_level(launcher_args: dict) -> int: - """Resolve the Python logging level that should survive Kit startup.""" - if launcher_args.get("verbose", False) or "--verbose" in sys.argv: - return logging.DEBUG - if launcher_args.get("info", False) or "--info" in sys.argv: - return logging.INFO - - level = logging.getLogger().getEffectiveLevel() - return logging.WARNING if level == logging.NOTSET else level - - @staticmethod - def _apply_python_logging_level(level: int) -> None: - """Apply a Python logging level to the root logger and its handlers.""" - root_logger = logging.getLogger() - root_logger.setLevel(level) - for handler in root_logger.handlers: - handler.setLevel(level) - @staticmethod def _ensure_isaaclab_info_stream_handler() -> None: """Add a stream handler for Isaac Lab INFO records hidden by Kit logging.""" @@ -277,7 +266,7 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa launcher_args.update(kwargs) # Preserve the Python logging intent before Kit installs its own logging bridge. - self._python_logging_level = AppLauncher._resolve_python_logging_level(launcher_args) + self._python_logging_level = resolve_python_logging_level(launcher_args) # Define config members that are read from env-vars or keyword args self._headless: bool # 0: GUI, 1: Headless @@ -312,8 +301,6 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa # Hide the stop button in the toolbar self._hide_stop_button() - # Set settings from the given rendering mode - self._set_rendering_mode_settings(launcher_args) # Set animation recording settings self._set_animation_recording_settings(launcher_args) # Set visualizer settings (if requested) @@ -377,6 +364,39 @@ def app(self) -> SimulationApp: Operations. """ + @staticmethod + def _fuse_kit_args(argv: list[str]) -> list[str]: + """Fuse ``["--kit_args", ""]`` pairs into single ``--kit_args=`` tokens. + + Argparse rejects a value token that itself looks like an option (starts with ``-`` and contains + no space) with "expected one argument", and Kit arguments always start with ``--``. Fusing the + pair into the ``=``-attached form before parsing makes the documented space-separated form work + for a single Kit argument. All other forms pass through unchanged. + + Args: + argv: Command-line tokens, excluding the program name. + + Returns: + Tokens with any affected pair replaced by one fused token. + """ + fused: list[str] = [] + index = 0 + while index < len(argv): + token = argv[index] + next_token = argv[index + 1] if index + 1 < len(argv) else None + if ( + token == "--kit_args" + and next_token is not None + and next_token.startswith("-") + and " " not in next_token + ): + fused.append(f"--kit_args={next_token}") + index += 2 + else: + fused.append(token) + index += 1 + return fused + @staticmethod def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: """Utility function to configure AppLauncher arguments with an existing argument parser object. @@ -431,6 +451,10 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: * ``kit_args`` (str): Optional command line arguments to be passed to Omniverse Kit directly. Arguments should be combined into a single string separated by space. Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2" + A single Kit argument works in both the space-separated and the ``=``-attached form + (e.g. ``--kit_args "--ext-folder=/path/to/ext1"`` or ``--kit_args=--ext-folder=/path/to/ext1``). + Isaac Lab experiences use one renderer GPU by default. Applications that need single-process + multi-GPU rendering can override the ``renderer.multiGpu`` settings through this argument. * ``visualizer`` (str): Visualizer backends to enable. Valid options are: @@ -452,6 +476,10 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: Args: parser: An argument parser instance to be extended with the AppLauncher specific options. """ + # argparse rejects an option-like value token after "--kit_args"; fuse the pair before + # anything parses the command line so the space-separated form works on every entry point + sys.argv[1:] = AppLauncher._fuse_kit_args(sys.argv[1:]) + # If the passed parser has an existing _HelpAction when passed, # we here remove the options which would invoke it, # to be added back after the additional AppLauncher args @@ -559,24 +587,15 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: default=AppLauncher._APPLAUNCHER_CFG_INFO["deterministic"][1], help="After startup, apply RTX/RTPT settings for reproducible rendering (see AppLauncher docs).", ) - arg_group.add_argument( - "--rendering_mode", - type=str, - action=ExplicitAction, - choices={"performance", "balanced", "quality"}, - help=( - "Sets the rendering mode. Preset settings files can be found in apps/rendering_modes." - ' Can be "performance", "balanced", or "quality".' - " Individual settings can be overwritten by using the RenderCfg class." - ), - ) arg_group.add_argument( "--kit_args", type=str, default="", help=( "Command line arguments for Omniverse Kit as a string separated by a space delimiter." - ' Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2"' + ' Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2".' + ' A single Kit argument works in both forms: --kit_args "--ext-folder=/path/to/ext1"' + " or --kit_args=--ext-folder=/path/to/ext1." ), ) arg_group.add_argument( @@ -628,7 +647,6 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "device": ([str], "cuda:0"), "experience": ([str], ""), "deterministic": ([bool], False), - "rendering_mode": ([str], "balanced"), "max_visible_envs": ([int, type(None)], None), } """A dictionary of arguments added manually by the :meth:`AppLauncher.add_app_launcher_args` method. @@ -1072,13 +1090,11 @@ def _resolve_device_settings(self, launcher_args: dict): logger.info("Using device: %s", device) def _set_deferred_cuda_device(self) -> None: - """Set the current torch CUDA device after Kit startup.""" + """Set the current CUDA device after Kit startup.""" if self._deferred_cuda_device_id is None: return - import torch - - torch.cuda.set_device(self._deferred_cuda_device_id) + set_cuda_device(self._deferred_cuda_device_id) def _resolve_experience_file(self, launcher_args: dict): """Resolve experience file related settings.""" @@ -1089,8 +1105,22 @@ def _resolve_experience_file(self, launcher_args: dict): launcher_args.get("deterministic", AppLauncher._APPLAUNCHER_CFG_INFO["deterministic"][1]) ) - # If nothing is provided resolve the experience file based on the headless flag - kit_app_exp_path = os.environ["EXP_PATH"] + # If nothing is provided resolve the experience file based on the headless flag. + # EXP_PATH is normally set by ``isaacsim.bootstrap_kernel()`` on first import. + # If it is not set (e.g. on aarch64 where the bootstrap early-return triggered + # under certain install layouts), derive it from the installed isaacsim package. + kit_app_exp_path = os.environ.get("EXP_PATH") + if not kit_app_exp_path: + try: + import isaacsim as _isaacsim_for_paths + except ImportError as e: + raise RuntimeError( + "EXP_PATH is not set and the 'isaacsim' package is not importable." + " Install Isaac Sim (`pip install isaacsim` or the binary distribution)" + " before launching AppLauncher." + ) from e + kit_app_exp_path = os.path.join(os.path.dirname(_isaacsim_for_paths.__file__), "apps") + os.environ["EXP_PATH"] = kit_app_exp_path isaaclab_app_exp_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 4, "apps") # For Isaac Sim 4.5 compatibility, we use the 4.5 app files in a different folder # if launcher_args.get("use_isaacsim_45", False): @@ -1168,11 +1198,21 @@ def _resolve_anim_recording_settings(self, launcher_args: dict): def _resolve_kit_args(self, launcher_args: dict): """Resolve additional arguments passed to Kit.""" - # Resolve additional arguments passed to Kit - self._kit_args = [] - if "kit_args" in launcher_args: - self._kit_args = [arg for arg in launcher_args["kit_args"].split()] - sys.argv += self._kit_args + self._kit_args = launcher_args.get("kit_args", "").split() + + fabric_gpu_interop = os.environ.get(_FABRIC_GPU_INTEROP_ENV) + if fabric_gpu_interop is not None: + if fabric_gpu_interop not in {"0", "1"}: + raise ValueError( + f"Invalid value for environment variable `{_FABRIC_GPU_INTEROP_ENV}`: {fabric_gpu_interop}." + " Expected: 0 or 1." + ) + argument = f"--/physics/fabricUseGPUInterop={'true' if fabric_gpu_interop == '1' else 'false'}" + setting = argument.partition("=")[0] + if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): + self._kit_args.append(argument) + + sys.argv += self._kit_args def _create_app(self): """Launch and create the SimulationApp based on the parsed simulation config.""" @@ -1252,7 +1292,7 @@ def _load_extensions(self): # After SimulationApp starts, Kit installs its Python log bridge at DEBUG level. # Re-apply the intended Python logging level, then add a scoped stream handler for # Isaac Lab INFO records that Kit's bridge does not mirror to the console. - AppLauncher._apply_python_logging_level(self._python_logging_level) + apply_python_logging_level(self._python_logging_level) if self._python_logging_level <= logging.INFO: AppLauncher._ensure_isaaclab_info_stream_handler() elif self._python_logging_level == logging.WARNING: @@ -1262,6 +1302,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) @@ -1305,18 +1349,6 @@ def _hide_stop_button(self): play_button_group._stop_button.enabled = False # type: ignore play_button_group._stop_button = None # type: ignore - def _set_rendering_mode_settings(self, launcher_args: dict) -> None: - """Store RTX rendering mode in settings.""" - rendering_mode = launcher_args.get("rendering_mode") - - if rendering_mode is None: - # use default kit rendering settings if cameras are disabled and a rendering mode is not selected - if not self._enable_cameras: - return - rendering_mode = "" - - get_settings_manager().set_string("/isaaclab/rendering/rendering_mode", rendering_mode) - def _set_animation_recording_settings(self, launcher_args: dict) -> None: """Store animation recording settings in settings.""" recording_enabled = launcher_args.get("anim_recording_enabled", False) diff --git a/source/isaaclab/isaaclab/app/logging_utils.py b/source/isaaclab/isaaclab/app/logging_utils.py new file mode 100644 index 00000000000..6b087260cd0 --- /dev/null +++ b/source/isaaclab/isaaclab/app/logging_utils.py @@ -0,0 +1,60 @@ +# 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 resolution and application of the Python logging level. + +The logging intent expressed by the ``--verbose`` / ``--info`` CLI arguments must be +honored by every simulation backend, not just the Kit-based one. Keeping the resolution +here (rather than inside :class:`~isaaclab.app.AppLauncher`) lets the kitless launch path +apply the same level without constructing Kit. +""" + +from __future__ import annotations + +import argparse +import logging +import sys + + +def resolve_python_logging_level(launcher_args: argparse.Namespace | dict | None = None) -> int: + """Resolve the intended Python logging level from launcher args and ``sys.argv``. + + ``--verbose`` maps to :data:`logging.DEBUG` and ``--info`` to :data:`logging.INFO`. + When neither is requested, the current effective level of the root logger is kept + (falling back to :data:`logging.WARNING` when unset). + + Args: + launcher_args: Parsed launcher arguments as a namespace or dict. Defaults to None, + which is treated as an empty set of arguments. + + Returns: + The resolved logging level. + """ + if launcher_args is None: + args = {} + elif isinstance(launcher_args, argparse.Namespace): + args = launcher_args.__dict__ + else: + args = launcher_args + + if args.get("verbose", False) or "--verbose" in sys.argv: + return logging.DEBUG + if args.get("info", False) or "--info" in sys.argv: + return logging.INFO + + level = logging.getLogger().getEffectiveLevel() + return logging.WARNING if level == logging.NOTSET else level + + +def apply_python_logging_level(level: int) -> None: + """Apply a Python logging level to the root logger and its handlers. + + Args: + level: The logging level to apply. + """ + root_logger = logging.getLogger() + root_logger.setLevel(level) + for handler in root_logger.handlers: + handler.setLevel(level) diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index 39662d3e967..ca7ec26e9aa 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -22,12 +22,16 @@ from typing import Any from isaaclab_newton.physics import NewtonCfg +from isaaclab_ov.renderers import OVRTXRendererCfg from isaaclab_ovphysx.physics import OvPhysxCfg from isaaclab_physx.physics import PhysxCfg +from isaaclab_physx.renderers import IsaacRtxRendererCfg +from isaaclab.app.logging_utils import apply_python_logging_level, resolve_python_logging_level from isaaclab.physics.physics_manager_cfg import PhysicsCfg from isaaclab.renderers.renderer_cfg import RendererCfg from isaaclab.sensors.camera.camera_cfg import CameraCfg +from isaaclab.utils._device import set_cuda_device logger = logging.getLogger(__name__) @@ -78,6 +82,11 @@ def _is_ovrtx_renderer(node) -> bool: return isinstance(node, RendererCfg) and getattr(node, "renderer_type", None) == "ovrtx" +def _is_auto_rtx_renderer(node) -> bool: + """True when the node is an automatic RTX renderer placeholder.""" + return getattr(node, "renderer_type", None) == "auto_rtx" + + def _is_kit_camera(node) -> bool: """True for a CameraCfg whose renderer requires Kit (not Newton).""" if not isinstance(node, CameraCfg): @@ -85,6 +94,10 @@ def _is_kit_camera(node) -> bool: renderer_cfg = getattr(node, "renderer_cfg", None) if renderer_cfg is None: return True + if _is_auto_rtx_renderer(renderer_cfg): + # ``auto_rtx`` is resolved after the initial scan once physics and + # visualizer intent are known; ie. it may become OVRTX for a kitless run. + return False if isinstance(renderer_cfg, RendererCfg): return renderer_cfg.renderer_type in ("default", "isaac_rtx") # PresetCfg renderers (e.g. MultiBackendRendererCfg) are resolved during @@ -182,10 +195,12 @@ def _get_visualizer_intent(cfg) -> dict[str, bool]: class Scan: """Signals gathered from one walk of the config tree (see :func:`scan`). - Every field is a plain snapshot computed during that single walk; nothing here - is recomputed or mutated afterwards. ``needs_kit`` is the headline decision: a - Kit-renderer camera or non-kitless physics requires Kit (the launcher additionally - forces Kit when ``--visualizer kit`` is requested). + Every field starts as a plain snapshot computed during that single walk. + Automatic RTX renderer placeholders are also recorded so launch-time resolution + can update the renderer-related fields without traversing the config tree again. + ``needs_kit`` is the headline decision: a Kit-renderer camera or non-kitless + physics requires Kit (the launcher additionally forces Kit when ``--visualizer + kit`` is requested). """ resolved_physics_cfg: PhysicsCfg | None # first physics config in walk order (post --physics override) @@ -199,21 +214,30 @@ class Scan: needs_kit: bool -def scan(cfg, physics_str: str | None = None) -> Scan: +def scan(cfg, launcher_args: argparse.Namespace | dict | None = None) -> Scan: """Walk *cfg* once, collecting all launch signals and applying ``--physics``. - When *physics_str* is set, every physics config is replaced by the requested - backend (see :func:`make_physics_cfg`): nested configs in place, a root config - via :attr:`Scan.effective_cfg` (it cannot be mutated in place). + When the ``physics`` key is present in *launcher_args*, every physics config is + replaced by the requested backend (see :func:`make_physics_cfg`): nested configs + in place, a root config via :attr:`Scan.effective_cfg` (it cannot be mutated in + place). Automatic RTX renderer placeholders (``renderer_type="auto_rtx"``) are + also resolved at this stage using the full *launcher_args* context. """ + physics_str = _get_arg(launcher_args, "physics", None) physics_cfgs: list[PhysicsCfg] = [] effective_cfg: Any = cfg has_ovrtx = False + has_auto_rtx = False has_kit_camera = False + auto_rtx_locations: list[tuple[Any, Any, bool]] = [] # (parent, key, is_cam_renderer) for each auto RTX placeholder visited: set[int] = set() - def visit(node, parent, attr): - nonlocal effective_cfg, has_ovrtx, has_kit_camera + def visit(node, parent, key): + nonlocal effective_cfg, has_ovrtx, has_auto_rtx, has_kit_camera + if _is_auto_rtx_renderer(node): + has_auto_rtx = True + auto_rtx_locations.append((parent, key, isinstance(parent, CameraCfg) and key == "renderer_cfg")) + if id(node) in visited: return visited.add(id(node)) @@ -222,7 +246,7 @@ def visit(node, parent, attr): if physics_str: node = make_physics_cfg(physics_str) if parent is not None: - setattr(parent, attr, node) + setattr(parent, key, node) else: effective_cfg = node physics_cfgs.append(node) @@ -244,7 +268,7 @@ def visit(node, parent, attr): names = [type(pcfg).__name__ for pcfg in physics_cfgs] has_kitless_physics = any(name in _KITLESS_PHYSICS_CFGS for name in names) - return Scan( + config_scan = Scan( resolved_physics_cfg=physics_cfgs[0] if physics_cfgs else None, effective_cfg=effective_cfg, visualizer_intent=_get_visualizer_intent(cfg), @@ -256,20 +280,54 @@ def visit(node, parent, attr): needs_kit=has_kit_camera or not has_kitless_physics, ) + # Resolve recorded auto RTX renderer placeholders + if not has_auto_rtx: + return config_scan + + use_isaac_sim = _uses_isaac_sim_runtime(config_scan, launcher_args) + renderer_factory = IsaacRtxRendererCfg if use_isaac_sim else OVRTXRendererCfg + + # Resolve every auto RTX placeholder in place, tracking camera renderers that may require Kit. + has_auto_camera = False + for location in auto_rtx_locations: + if location[0] is None: + raise ValueError("Automatic RTX renderer placeholders cannot be resolved as the root config.") + setattr(location[0], location[1], renderer_factory()) + has_auto_camera = has_auto_camera or location[2] + + # Update the scan with the resolved auto RTX renderer type and Kit-camera status. + if use_isaac_sim: + config_scan.has_kit_camera = config_scan.has_kit_camera or has_auto_camera + else: + config_scan.has_ovrtx = True + config_scan.needs_kit = config_scan.has_kit_camera or not config_scan.has_kitless_physics + + return config_scan + """ Launch Decisions (derived purely from a scan). """ +def _has_kit_visualizer(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> bool: + """Return whether the run requests the Kit visualizer through config or CLI.""" + visualizer_types = _get_visualizer_types(launcher_args) + return "kit" in visualizer_types or config_scan.visualizer_intent["has_kit_visualizer"] + + +def _uses_isaac_sim_runtime(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> bool: + """Return whether the scanned config requires Isaac Sim / Kit.""" + return config_scan.needs_kit or _has_kit_visualizer(config_scan, launcher_args) + + def _validate_runtime(scan: Scan, launcher_args: argparse.Namespace | dict | None) -> None: """Raise if *scan*'s physics/renderer/visualizer combination is unsupported. OVRTX is kitless and cannot share a process with Kit-based runtimes (PhysX physics or the Kit visualizer); OvPhysX physics likewise cannot run with the Kit visualizer. """ - visualizer_types = _get_visualizer_types(launcher_args) - has_kit_visualizer = "kit" in visualizer_types or scan.visualizer_intent["has_kit_visualizer"] + has_kit_visualizer = _has_kit_visualizer(scan, launcher_args) if scan.has_ovphysx_physics and has_kit_visualizer: raise ValueError( @@ -295,11 +353,11 @@ def _validate_runtime(scan: Scan, launcher_args: argparse.Namespace | dict | Non "\n" "To fix this, pick one of the following supported combinations:\n" " * Keep Isaac Sim / Kit and switch the renderer:\n" - " presets=isaacsim_rtx_renderer\n" + " presets=isaacsim_rtx\n" " (uses `IsaacRtxRendererCfg`, the Kit-compatible renderer.)\n" " * Keep the OVRTX renderer and switch to a kitless physics backend\n" " (and avoid `--visualizer kit`):\n" - " presets=newton_mjwarp,ovrtx_renderer\n" + " presets=newton_mjwarp,ovrtx\n" ) @@ -323,7 +381,7 @@ def _resolve_distributed_device(cfg, launcher_args: argparse.Namespace | dict | sim_cfg = getattr(cfg, "sim", None) if sim_cfg is not None: sim_cfg.device = device_str - torch.cuda.set_device(device_str) + set_cuda_device(device_str) logger.info( "Distributed device resolved to %s (local_rank=%d, visible_gpus=%d)", device_str, @@ -352,11 +410,14 @@ def launch_simulation( Callers that do not need the value simply omit ``as``. """ + # Livestreaming implies a Kit visualizer; make that visible to auto RTX + # resolution during the single scan. + _ensure_livestream_kit_visualizer(launcher_args) + # The single walk: collect every signal and apply the --physics override. - config_scan = scan(cfg, _get_arg(launcher_args, "physics")) + config_scan = scan(cfg, launcher_args) effective_cfg = config_scan.effective_cfg physics_cfg = config_scan.resolved_physics_cfg - _ensure_livestream_kit_visualizer(launcher_args) visualizer_types = _get_visualizer_types(launcher_args) # ovrtx + Kit visualizer share conflicting RTX hydra libraries under different USD @@ -364,7 +425,7 @@ def launch_simulation( # with a targeted hint (_validate_runtime covers the broader ovrtx-vs-Kit cases). if "kit" in visualizer_types and config_scan.has_ovrtx: raise ValueError( - "[launch_simulation] '--visualizer kit' is incompatible with 'ovrtx_renderer'. " + "[launch_simulation] '--visualizer kit' is incompatible with 'ovrtx'. " "Both Kit (Isaac Sim) and ovrtx ship conflicting RTX hydra libraries " "(librtx.hydra.so, liblegacy.hydra.so) compiled against different USD namespaces, " "which causes a dynamic-linker crash when loaded into the same process. " @@ -372,9 +433,14 @@ def launch_simulation( ) _validate_runtime(config_scan, launcher_args) - needs_kit = config_scan.needs_kit or "kit" in visualizer_types + needs_kit = _uses_isaac_sim_runtime(config_scan, launcher_args) _set_arg(launcher_args, "visualizer_intent", config_scan.visualizer_intent) + # Kit-based backends apply the Python logging level inside AppLauncher; kitless backends + # never construct it, so honor --verbose / --info here to keep behavior consistent. + if not needs_kit: + apply_python_logging_level(resolve_python_logging_level(launcher_args)) + if needs_kit and config_scan.has_kit_camera and launcher_args is not None: if not _get_arg(launcher_args, "enable_cameras", False): logger.info("Auto-enabling cameras: scene contains camera sensors with a Kit renderer.") @@ -414,16 +480,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/cli/__init__.py b/source/isaaclab/isaaclab/cli/__init__.py index d33dd001935..cc64b7133f0 100644 --- a/source/isaaclab/isaaclab/cli/__init__.py +++ b/source/isaaclab/isaaclab/cli/__init__.py @@ -53,8 +53,29 @@ def play(args: list[str] | None = None) -> None: run_python_command(ISAACLAB_ROOT / "scripts" / "reinforcement_learning" / "play.py", args, check=True) +def benchmark(args: list[str] | None = None) -> None: + """Run a runtime, startup, training, or play benchmark. + + Args: + args: Command-line arguments. Uses ``sys.argv`` when omitted. + """ + parser = argparse.ArgumentParser(description="Run an Isaac Lab benchmark.") + parser.add_argument("command", choices=("runtime", "startup", "training", "play"), help="Benchmark workflow to run.") + if args is None: + args = sys.argv[1:] + if not args or args[0] in ("-h", "--help"): + parser.parse_args(args) + parsed_args = parser.parse_args(args[:1]) + run_python_command( + ISAACLAB_ROOT / "scripts" / "benchmarks" / f"{parsed_args.command}.py", args[1:], check=True + ) + + def cli() -> None: """Parse CLI arguments and run the requested command.""" + if len(sys.argv) > 1 and sys.argv[1] == "benchmark": + benchmark(sys.argv[2:]) + return if len(sys.argv) > 1 and sys.argv[1] == "train": train(sys.argv[2:]) return @@ -73,6 +94,7 @@ def cli() -> None: formatter_class=argparse.RawTextHelpFormatter, epilog=( "commands:\n" + " benchmark Run a runtime, startup, training, or play benchmark\n" " train Run scripts/reinforcement_learning/train.py\n" " train_multigpu Run scripts/reinforcement_learning/train_multigpu.py\n" " play Run scripts/reinforcement_learning/play.py" @@ -105,7 +127,7 @@ def cli() -> None: " contrib[rlinf]\n" " ov[ovrtx|ovphysx|all]\n" " rl[rsl-rl|skrl|sb3|rl-games] (default: all)\n" - " visualizer[kit|newton|rerun|viser] (default: all)\n" + " visualizer[kit|rerun|viser] (default: all)\n" " On Linux/macOS, quote selectors containing brackets:\n" " --install 'rl[rsl-rl]'\n" "\n" @@ -123,7 +145,7 @@ def cli() -> None: "Examples:\n" " ./isaaclab.sh -i\n" " ./isaaclab.sh -i core\n" - " ./isaaclab.sh -i newton,'rl[rsl-rl]'\n" + " ./isaaclab.sh -i 'rl[rsl-rl]'\n" " ./isaaclab.sh -i mimic,teleop,'visualizer[rerun]'\n" " ./isaaclab.sh -i 'ov[ovrtx]'\n" "\n" diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index be7c3aa5b0e..97a7fc83da9 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -79,22 +79,6 @@ def _install_system_deps() -> None: ] run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) - # nlopt has no aarch64 manylinux wheel for the version pinned by - # isaacteleop[retargeters], so pip falls back to a CMake source build - # that needs SWIG. Mirrors the apt step in docker/Dockerfile.base. - if not shutil.which("swig"): - if os.geteuid() != 0 and not shutil.which("sudo"): - print_info( - "swig is missing and sudo is unavailable; skipping swig install. " - "Pre-install swig in your image if you need to build nlopt from source." - ) - else: - print_info("Installing swig (required for building nlopt on ARM)...") - cmd = ["apt-get", "update"] - run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) - cmd = ["apt-get", "install", "-y", "--no-install-recommends", "swig"] - run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) - # imgui-bundle has no aarch64 manylinux wheel, so pip falls back to a # CMake source build that needs GL/X11 dev headers (via glfw). # Mirrors the apt step in docker/Dockerfile.base. @@ -154,25 +138,6 @@ def _torch_first_on_sys_path_is_prebundle(python_exe: str, *, env: dict[str, str return result.returncode == 1 -def _maybe_preinstall_arm_nlopt(pip_cmd: list[str]) -> None: - """Pre-install ``nlopt==2.6.2`` on ARM Linux to skip the source-build fallback. - - There is no aarch64 manylinux wheel for the ``nlopt 2.6.2`` version pinned - by ``isaacteleop[retargeters]``, so pip falls back to a CMake source build - that hides the host-Python ``numpy`` from its isolated build env. Mirror - the docker/Dockerfile.base arm64 step: install ``setuptools wheel numpy`` - in the host Python first, then ``--no-build-isolation`` install nlopt so - later submodule installs see it as already satisfied. - """ - if is_windows() or not is_arm(): - return - print_info("Pre-installing nlopt==2.6.2 on ARM (no-build-isolation)...") - print_info(" step 1/2: ensure setuptools/wheel/numpy are importable for the no-build-isolation backend") - run_command(pip_cmd + ["install", "setuptools", "wheel", "numpy"]) - print_info(" step 2/2: install nlopt==2.6.2 with --no-build-isolation") - run_command(pip_cmd + ["install", "--no-build-isolation", "nlopt==2.6.2"]) - - def _maybe_uninstall_prebundled_torch( python_exe: str, pip_cmd: list[str], @@ -195,11 +160,118 @@ def _maybe_uninstall_prebundled_torch( ) -# Dependency stack required by isaaclab.controllers.pink_ik. Pinocchio is installed -# via the cmeel ``pin`` wheel, which provides the ``pinocchio`` Python module under +def _ensure_swig_installed() -> bool: + """Install ``swig`` via apt when missing so the nlopt source build can run. + + Returns: + ``True`` when this call installed ``swig`` (so the caller is responsible + for purging it afterwards), ``False`` when ``swig`` was already present or + could not be installed. + """ + if shutil.which("swig"): + return False + if os.geteuid() != 0 and not shutil.which("sudo"): + print_warning( + "swig is required to build nlopt==2.6.2 from source on ARM but is missing and sudo is " + "unavailable. Pre-install swig (or nlopt==2.6.2) manually; the build below will fail otherwise." + ) + return False + print_info("Temporarily installing swig to build nlopt==2.6.2 from source on ARM...") + update = ["apt-get", "update"] + run_command(["sudo"] + update if os.geteuid() != 0 else update) + install = ["apt-get", "install", "-y", "--no-install-recommends", "swig"] + run_command(["sudo"] + install if os.geteuid() != 0 else install) + return shutil.which("swig") is not None + + +def _purge_swig() -> None: + """Remove the ``swig`` package that was installed for the nlopt build. + + ``swig`` is GPL-licensed and must not be shipped (e.g. in the Docker image), + so it is purged immediately after nlopt is built. ``nlopt`` is already a + compiled wheel at this point and does not need ``swig`` at runtime. + Best-effort: failures are logged but do not abort the install. + """ + print_info("Removing swig now that nlopt is built (it must not remain installed)...") + purge = ["apt-get", "purge", "-y", "--auto-remove", "swig"] + run_command(["sudo"] + purge if os.geteuid() != 0 else purge, check=False) + + +def _maybe_preinstall_arm_nlopt(python_exe: str, pip_cmd: list[str]) -> None: + """Pre-install ``nlopt==2.6.2`` on ARM Linux to skip the source-build fallback. + + There is no aarch64 manylinux wheel for the ``nlopt 2.6.2`` version pinned + by ``isaacteleop[retargeters]``, so pip falls back to a CMake source build + that hides the host-Python ``numpy`` from its isolated build env. Mirror + the docker/Dockerfile.base arm64 step: install ``setuptools wheel numpy`` + in the host Python first, then ``--no-build-isolation`` install nlopt so + later submodule installs see it as already satisfied. + + The source build requires ``swig``. When it is missing it is installed via + apt only for the duration of the build and purged afterwards, so the + GPL-licensed ``swig`` package is never left behind — in particular it is + never shipped in the Docker image. In the Docker build nlopt is pre-installed, + so this function returns early and never touches ``swig`` (the Dockerfile + manages its own temporary swig install and purge). + """ + if is_windows() or not is_arm(): + return + + probe_result = run_command( + [ + python_exe, + "-c", + "import importlib.metadata as metadata; import nlopt; " + "raise SystemExit(0 if metadata.version('nlopt') == '2.6.2' else 1)", + ], + check=False, + capture_output=True, + text=True, + ) + if probe_result.returncode == 0: + print_info("nlopt==2.6.2 is already installed on ARM.") + return + + # The from-source build needs swig; install it only if missing and purge it + # afterwards so swig is never left behind (it is GPL and must not ship). + swig_installed_by_us = _ensure_swig_installed() + try: + print_info("Pre-installing nlopt==2.6.2 on ARM (no-build-isolation)...") + print_info(" step 1/2: ensure setuptools/wheel/numpy are importable for the no-build-isolation backend") + run_command(pip_cmd + ["install", "setuptools", "wheel", "numpy"]) + print_info(" step 2/2: install nlopt==2.6.2 with --no-build-isolation") + run_command(pip_cmd + ["install", "--no-build-isolation", "nlopt==2.6.2"]) + finally: + if swig_installed_by_us: + _purge_swig() + + +# Packages forming the Pink IK dependency stack. Pinocchio is installed via the +# cmeel ``pin`` wheel, which provides the ``pinocchio`` Python module under # ``cmeel.prefix/lib/python3.12/site-packages/`` and registers it on sys.path via a # ``cmeel.pth`` hook. DAQP provides the QP solver selected by the Pink IK controller. -_PINK_IK_STACK = ("pin", "pin-pink==3.1.0", "daqp==0.8.5") +# Versions (e.g. the pink 3.3.x window required by Isaac Sim 6.x) are pinned in the +# root pyproject.toml; :func:`_pink_ik_stack` derives the requirements from there. +_PINK_IK_PACKAGES = ("pin", "pin-pink", "daqp") + + +def _pink_ik_stack() -> tuple[str, ...]: + """Return the Pink IK stack requirements pinned in the root ``pyproject.toml``. + + Derives the requirement strings for :data:`_PINK_IK_PACKAGES` from the + centralized ``[project.dependencies]`` table so the pins live in one place. + Environment markers are stripped because + :func:`_ensure_pink_ik_dependencies_installed` gates on platform itself. + + Raises: + KeyError: If a stack package is missing from the root dependencies. + """ + dependencies = _load_root_pyproject().get("project", {}).get("dependencies", []) + requirements = {_requirement_name(r): r.split(";", 1)[0].strip() for r in dependencies} + missing = [name for name in _PINK_IK_PACKAGES if name not in requirements] + if missing: + raise KeyError(f"{missing} missing from [project.dependencies] in the root pyproject.toml.") + return tuple(requirements[name] for name in _PINK_IK_PACKAGES) def _ensure_pink_ik_dependencies_installed(python_exe: str, pip_cmd: list[str], *, probe_env: dict[str, str]) -> None: @@ -246,15 +318,16 @@ def _ensure_pink_ik_dependencies_installed(python_exe: str, pip_cmd: list[str], return print_info("Pink IK dependency probe failed. Force-installing the cmeel pinocchio and DAQP stack.") + pink_ik_stack = _pink_ik_stack() install_result = run_command( - pip_cmd + ["install", "--upgrade", "--force-reinstall", *_PINK_IK_STACK], + pip_cmd + ["install", "--upgrade", "--force-reinstall", *pink_ik_stack], check=False, ) if install_result.returncode != 0: print_warning( "Force-installing the cmeel pinocchio and DAQP stack failed (returncode " f"{install_result.returncode}). The pink IK controller and its tests will not be" - " usable until ``pin pin-pink==3.1.0 daqp==0.8.5`` is installed manually." + f" usable until ``{' '.join(pink_ik_stack)}`` is installed manually." ) @@ -267,9 +340,9 @@ def _ensure_cuda_torch() -> None: # Base index for torch. base_index = "https://download.pytorch.org/whl" - # Choose pins per arch. - torch_ver = "2.10.0" - tv_ver = "0.25.0" + # Pinned versions (single source of truth: [tool.isaaclab.versions]). + torch_ver = _pinned_version("torch") + tv_ver = _pinned_version("torchvision") if is_arm(): cuda_ver = "130" @@ -316,9 +389,42 @@ def _ensure_cuda_torch() -> None: run_command(pip_cmd + ["install", "--index-url", index_url, f"torch=={torch_ver}", f"torchvision=={tv_ver}"]) +def _ensure_newton() -> None: + """Install the pinned Newton git build, replacing any index version. + + Isaac Sim bundles ``newton[sim]==1.2.0``, which satisfies the loose core bound in + the root pyproject, so the centralized install would otherwise keep the older + Newton. Isaac Lab owns the exact commit via ``[tool.uv].override-dependencies`` + (``uv sync`` honors it, ``pip``/``uv pip`` installs do not), so force it in here + from that single source. + """ + overrides = _load_root_pyproject().get("tool", {}).get("uv", {}).get("override-dependencies", []) + requirement = next((r for r in overrides if _requirement_name(r) == "newton"), None) + if not requirement: + raise KeyError("Newton git pin is missing from [tool.uv].override-dependencies in the root pyproject.toml.") + commit = _pinned_version("newton") + # Newton-matched schemas (isaacsim pins the older ==0.2.0); force it alongside newton. + schemas = next((r for r in overrides if _requirement_name(r) == "newton-usd-schemas"), None) + + python_exe = extract_python_exe() + pip_cmd = get_pip_command(python_exe) + using_uv = pip_cmd[0] == "uv" + + # git installs record the commit in freeze output; skip if it is already present. + frozen = run_command(pip_cmd + ["freeze"], capture_output=True, text=True, check=False) + if frozen.returncode == 0 and any( + _requirement_name(line) == "newton" and commit in line for line in frozen.stdout.splitlines() + ): + print_info(f"Newton git build ({commit[:10]}) already installed.") + return + + print_info(f"Installing pinned Newton git build ({commit[:10]})...") + uninstall_flags = ["-y"] if not using_uv else [] + run_command(pip_cmd + ["uninstall"] + uninstall_flags + ["newton"], check=False) + run_command(pip_cmd + ["install", requirement, *([schemas] if schemas else [])]) + + # Isaac Sim install settings. -ISAACSIM_VERSION_SPEC = ">=6.0.0" -ISAACSIM_EXTRAS = "all" NVIDIA_INDEX_URL = "https://pypi.nvidia.com" @@ -333,6 +439,119 @@ def _requirement_name(requirement: str) -> str: return re.split(r"\s|<|>|=|!|~|\[|@", requirement, maxsplit=1)[0] +# Distributions installed from the PyTorch index by :func:`_ensure_cuda_torch`; +# excluded from the centralized core-dependency install so they are not pulled +# from PyPI first. +_TORCH_DISTRIBUTIONS = {"torch", "torchvision", "torchaudio"} + + +def _is_isaaclab_requirement(requirement: str) -> bool: + """Return True for ``isaaclab*`` self-references (installed as editable submodules).""" + return _normalize_package_name(_requirement_name(requirement)).startswith("isaaclab") + + +def _load_root_pyproject() -> dict: + """Load the root development ``pyproject.toml`` (single source of dependency truth).""" + with (ISAACLAB_ROOT / "pyproject.toml").open("rb") as fd: + return tomllib.load(fd) + + +def _pinned_version(package: str) -> str: + """Return the pinned version for ``package`` from ``[tool.isaaclab.versions]``. + + This table is the single source of truth for externally-pinned versions; the + literal pins in the extras and uv constraints mirror it. + + Args: + package: Key in the ``[tool.isaaclab.versions]`` table (e.g. ``"torch"``). + """ + versions = _load_root_pyproject().get("tool", {}).get("isaaclab", {}).get("versions", {}) + version = versions.get(package) + if not version: + raise KeyError(f"'{package}' is missing from [tool.isaaclab.versions] in the root pyproject.toml.") + return version + + +def _isaacsim_requirement() -> str: + """Return the pinned ``isaacsim`` requirement from the root ``isaacsim`` extra.""" + optional = _load_root_pyproject().get("project", {}).get("optional-dependencies", {}) + requirement = next((r for r in optional.get("isaacsim", []) if _requirement_name(r) == "isaacsim"), None) + if not requirement: + raise KeyError( + "The 'isaacsim' extra is missing from [project.optional-dependencies] in the root pyproject.toml." + ) + return requirement + + +def _root_core_dependencies() -> list[str]: + """Return the third-party core requirements declared in the root pyproject. + + Workspace members (installed as editable submodules) and the torch stack + (installed by :func:`_ensure_cuda_torch`) are excluded. + """ + project = _load_root_pyproject().get("project", {}) + dependencies = [] + for requirement in project.get("dependencies", []): + if _is_isaaclab_requirement(requirement): + continue + if _normalize_package_name(_requirement_name(requirement)) in _TORCH_DISTRIBUTIONS: + continue + dependencies.append(requirement) + return dependencies + + +def _root_extra_dependencies(extra: str) -> list[str]: + """Return the third-party requirements for a root ``optional-dependencies`` group. + + Workspace member self-references are stripped (the editable submodules are + installed separately). + + Args: + extra: Name of the optional-dependency group in the root pyproject. + """ + optional = _load_root_pyproject().get("project", {}).get("optional-dependencies", {}) + if extra not in optional: + print_warning(f"Unknown root extra '{extra}'. Available: {', '.join(sorted(optional))}. Skipping.") + return [] + return [requirement for requirement in optional[extra] if not _is_isaaclab_requirement(requirement)] + + +def _install_root_extra(extra: str) -> None: + """Install the third-party dependencies of a root ``optional-dependencies`` group.""" + dependencies = _root_extra_dependencies(extra) + if not dependencies: + return + python_exe = extract_python_exe() + pip_cmd = get_pip_command(python_exe) + print_info(f"Installing '{extra}' extra dependencies from the root pyproject...") + run_command(pip_cmd + ["install"] + dependencies) + + +def _install_centralized_dependencies(pip_cmd: list[str], optional_submodules: list[str]) -> None: + """Install the centralized third-party dependencies for the current install. + + The editable sub-packages no longer declare dependencies, so the core + requirements come from the root pyproject; the runtime extras for any + requested optional submodules are installed on top. + + Args: + pip_cmd: Base pip command (e.g. ``["uv", "pip"]`` or ``["python", "-m", "pip"]``). + optional_submodules: Names of requested optional submodules whose root + extras should also be installed. + """ + core_dependencies = _root_core_dependencies() + if core_dependencies: + print_info("Installing core dependencies from the root pyproject...") + run_command(pip_cmd + ["install"] + core_dependencies) + # dict preserves order while de-duplicating extras shared across submodules. + extras: dict[str, None] = {} + for submodule_name in optional_submodules: + for extra in OPTIONAL_SUBMODULE_ROOT_EXTRAS.get(submodule_name, ()): + extras.setdefault(extra) + for extra in extras: + _install_root_extra(extra) + + def _get_installed_distribution_requirements(python_exe: str, distribution_name: str) -> list[str]: """Return installed ``Requires-Dist`` requirements for a distribution.""" probe = """import importlib.metadata @@ -456,7 +675,7 @@ def _install_isaacsim() -> None: pip_cmd + [ "install", - f"isaacsim[{ISAACSIM_EXTRAS}]{ISAACSIM_VERSION_SPEC}", + _isaacsim_requirement(), "--extra-index-url", NVIDIA_INDEX_URL, ] @@ -491,6 +710,18 @@ def _install_isaacsim() -> None: "teleop": ("isaaclab_teleop",), } +# Root pyproject optional-dependency groups that carry the third-party runtime +# requirements for each optional submodule (the submodules themselves no longer +# declare dependencies). Derived from OPTIONAL_ISAACLAB_SUBMODULES rather than +# redefined: each ``isaaclab_`` source dir maps to the same-named root +# extra (so ``mimic`` pulls in the ``teleop`` stack as well, matching the +# editable-install behavior it replaces). The extra names are validated against +# the root pyproject by :func:`_root_extra_dependencies` at install time. +OPTIONAL_SUBMODULE_ROOT_EXTRAS: dict[str, tuple[str, ...]] = { + submodule: tuple(directory.removeprefix("isaaclab_") for directory in directories) + for submodule, directories in OPTIONAL_ISAACLAB_SUBMODULES.items() +} + # Extra feature sets that install optional heavy dependencies on top of the # always-installed core submodules. Each name corresponds to one or more # 'pip install --editable path[extra]' calls against packages already in the @@ -587,12 +818,8 @@ def _install_contrib_extra_dependencies(selector: str) -> None: ) return - python_exe = extract_python_exe() - pip_cmd = get_pip_command(python_exe) - source_dir = ISAACLAB_ROOT / "source" - print_info(f"Installing contrib optional dependencies: {selector}...") - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_contrib[{selector}]"]) + _install_root_extra(selector) def _install_ov_extra_dependencies(selector: str) -> None: @@ -609,10 +836,6 @@ def _install_ov_extra_dependencies(selector: str) -> None: ) return - python_exe = extract_python_exe() - pip_cmd = get_pip_command(python_exe) - source_dir = ISAACLAB_ROOT / "source" - selectors = {item.strip().lower() for item in selector.split(",") if item.strip()} valid_selectors = {"all", "ovrtx", "ovphysx"} unknown_selectors = selectors - valid_selectors @@ -623,19 +846,20 @@ def _install_ov_extra_dependencies(selector: str) -> None: ) if "all" in selectors: selectors.update({"ovrtx", "ovphysx"}) + # The ov[ovrtx] selector maps to the root 'rtx' extra; ov[ovphysx] to 'ov'. if "ovrtx" in selectors: print_info("Installing OVRTX optional dependency...") - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_ov[ovrtx]"]) + _install_root_extra("rtx") if "ovphysx" in selectors: print_info("Installing OVPhysX optional dependency...") - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_ovphysx[ovphysx]"]) + _install_root_extra("ov") def _install_extra_feature(feature_name: str, selector: str = "") -> None: """Install optional extra dependencies for a feature set. - Each feature maps to one or more editable installs with extras applied to - packages that are already part of the core set. + Each feature maps the CLI token to one or more root ``optional-dependencies`` + groups and installs their third-party requirements. Args: feature_name: One of :data:`VALID_EXTRA_FEATURES`. @@ -643,29 +867,32 @@ def _install_extra_feature(feature_name: str, selector: str = "") -> None: ``rl[rsl-rl]``). When empty a sensible default is chosen per feature (``"all"`` for ``rl`` and ``visualizer``). """ - python_exe = extract_python_exe() - pip_cmd = get_pip_command(python_exe) - source_dir = ISAACLAB_ROOT / "source" - if feature_name == "contrib": _install_contrib_extra_dependencies(selector) elif feature_name == "newton": if selector: - print_warning(f"'newton' does not support selectors (got '{selector}'). Installing all newton extras.") - print_info( - "Installing newton extras (newton[sim], pyglet, PyOpenGL-accelerate, imgui-bundle, typing-extensions)..." - ) - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_newton[all]"]) - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_physx[newton]"]) - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_visualizers[newton]"]) + print_warning(f"'newton' does not support selectors (got '{selector}').") + # The Newton physics engine and its interactive viewer GUI (imgui-bundle, + # typing-extensions) are part of the base install; this token is a no-op. + print_info("Newton (engine + viewer) is part of the base install; nothing to install.") elif feature_name == "rl": extra = selector if selector else "all" + # rl[all] installs every RL framework extra; other selectors map by name + # (rsl_rl -> rsl-rl, skrl, sb3, rl-games). + frameworks = {"sb3", "skrl", "rl-games", "rsl-rl"} if extra == "all" else {extra.replace("_", "-")} print_info(f"Installing RL framework extras: {extra}...") - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_rl[{extra}]"]) + for framework in sorted(frameworks): + _install_root_extra(framework) elif feature_name == "visualizer": extra = selector if selector else "all" + backends = {"newton", "rerun", "viser"} if extra == "all" else {extra} print_info(f"Installing visualizer extras: {extra}...") - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_visualizers[{extra}]"]) + for backend in sorted(backends): + # 'kit' (Omniverse-provided) and 'newton' (part of the base install) + # have no extra to install. + if backend in {"kit", "newton"}: + continue + _install_root_extra(backend) elif feature_name == "ov": _install_ov_extra_dependencies(selector) else: @@ -703,6 +930,116 @@ def _install_extra_feature(feature_name: str, selector: str = "") -> None: """ +def _force_remove(path: Path) -> None: + """Recursively remove a file, directory, or symlink. A missing path is a no-op. + + Uses absolute-path :func:`os.unlink` / :func:`os.rmdir` rather than the + ``dir_fd``-relative operations :func:`shutil.rmtree` performs internally. On + an overlayfs *lower* layer (e.g. inside a Docker image build) the ``dir_fd`` + variant raises ``EINVAL``, whereas the plain ``unlink(2)`` / ``rmdir(2)`` + syscalls create the proper whiteout. This makes prebundle neutralization + behave identically on a normal filesystem and on an overlayfs lower layer. + """ + if path.is_symlink() or path.is_file(): + os.unlink(path) + elif path.is_dir(): + for child in path.iterdir(): + _force_remove(child) + os.rmdir(path) + + +def _discover_prebundle_dirs() -> set[Path]: + """Find every ``pip_prebundle`` directory under the Isaac Sim installation. + + Searches both the Isaac Sim tree and the Omniverse cache roots — some Isaac + Sim directories are symlinked into ``~/.local/share/ov`` and would be missed + by a plain ``rglob()`` on ``_isaac_sim``. Returns an empty set when no Isaac + Sim installation is present. + """ + isaacsim_path = extract_isaacsim_path(required=False) + if isaacsim_path is None or not isaacsim_path.exists(): + return set() + + candidate_roots: set[Path] = set() + for root in ( + isaacsim_path, + isaacsim_path.resolve(), + isaacsim_path / "extscache", + Path.home() / ".local" / "share" / "ov" / "data" / "exts", + Path.home() / ".local" / "share" / "ov" / "data" / "exts" / "v2", + ): + if root.exists(): + candidate_roots.add(root) + candidate_roots.add(root.resolve()) + + prebundle_dirs: set[Path] = set() + for root in candidate_roots: + prebundle_dirs.update(root.rglob("pip_prebundle")) + return prebundle_dirs + + +def _find_dangling_prebundle_symlinks() -> set[Path]: + """Find symlinks under Isaac Sim prebundles whose targets do not resolve. + + Isaac Sim deduplicates packages shared by several extensions as per-file + symlink farms between ``pip_prebundle`` directories. pip operations routinely + replace prebundled distributions with copies in ``site-packages`` — harmless + on its own — but deleting a copy that other prebundles link into leaves + dangling symlinks that break extension startup at runtime. + """ + dangling: set[Path] = set() + for prebundle_dir in _discover_prebundle_dirs(): + for root, _dirs, files in os.walk(prebundle_dir): + for name in files: + path = Path(root) / name + if path.is_symlink() and not path.exists(): + dangling.add(path) + return dangling + + +def _assert_no_new_dangling_prebundle_symlinks(before: set[Path]) -> None: + """Fail when the installation broke a prebundled package's symlinked ``__init__.py``. + + A new dangling symlink means a pip operation deleted a prebundled package + that other extensions reference through Isaac Sim's symlink farms — the + failure mode behind the ``packaging`` removal cascade in nvbugs 6343978 + (14 extensions failing to start). Routine pip replacements do leave a few + dozen dangling links to files Python never imports at startup (test modules, + ``WHEEL``/license files, cmake hooks), so only a dangling ``__init__.py`` — + which makes the whole package unimportable — fails the install; other new + dangling links are reported as warnings. + + Args: + before: Dangling symlinks from :func:`_find_dangling_prebundle_symlinks`, + collected before the pip operations. + + Raises: + RuntimeError: If the installation left a prebundled package with a + dangling ``__init__.py``. + """ + introduced = sorted(_find_dangling_prebundle_symlinks() - before) + if not introduced: + return + broken_packages = [p for p in introduced if p.name == "__init__.py"] + if broken_packages: + shown = "\n ".join(str(p) for p in broken_packages) + raise RuntimeError( + f"Installation broke {len(broken_packages)} prebundled package(s) in Isaac Sim" + f" (dangling __init__.py, {len(introduced)} new dangling symlink(s) total):\n " + + shown + + "\nA pip operation deleted a prebundled package that other Isaac Sim extensions share" + " via symlinks; extensions will fail to start at runtime (see nvbugs 6343978). This" + " usually means a dependency pin forced pip to downgrade/replace the prebundled copy —" + " fix that pin instead of shipping a broken prebundle, and restore the Isaac Sim" + " installation before retrying." + ) + print_warning( + f"Installation left {len(introduced)} new dangling symlink(s) in Isaac Sim prebundles" + " (no package __init__.py affected — extensions should still start). First few: " + + ", ".join(str(p) for p in introduced[:5]) + ) + + def _repoint_prebundle_packages() -> None: """Replace prebundled packages in Isaac Sim with symlinks to the active environment. @@ -736,25 +1073,7 @@ def _repoint_prebundle_packages() -> None: print_warning(f"site-packages directory not found: {site_packages} — skipping prebundle repoint.") return - # Discover pip_prebundle directories from both the Isaac Sim tree and - # Omniverse cache roots. Some Isaac Sim directories are symlinked into - # ~/.local/share/ov and may be missed by a plain rglob() on _isaac_sim. - candidate_roots: set[Path] = set() - for root in ( - isaacsim_path, - isaacsim_path.resolve(), - isaacsim_path / "extscache", - Path.home() / ".local" / "share" / "ov" / "data" / "exts", - Path.home() / ".local" / "share" / "ov" / "data" / "exts" / "v2", - ): - if root.exists(): - candidate_roots.add(root) - candidate_roots.add(root.resolve()) - - prebundle_dirs: set[Path] = set() - for root in candidate_roots: - prebundle_dirs.update(root.rglob("pip_prebundle")) - + prebundle_dirs = _discover_prebundle_dirs() if not prebundle_dirs: print_debug("No pip_prebundle directories found under Isaac Sim.") return @@ -782,16 +1101,15 @@ def _repoint_prebundle_packages() -> None: continue try: - if prebundled.is_symlink(): - if prebundled.resolve() == venv_pkg.resolve(): - continue - prebundled.unlink() - else: - backup = prebundle_dir / f"{pkg_name}.bak" - if backup.exists() or backup.is_symlink(): - shutil.rmtree(backup) if backup.is_dir() else backup.unlink() - prebundled.rename(backup) - + # Already repointed to the right place — nothing to do. + if prebundled.is_symlink() and prebundled.resolve() == venv_pkg.resolve(): + continue + # Replace the prebundled copy (a stale symlink or a real directory) + # with a symlink to the active environment. We remove rather than + # rename-to-``.bak``: the env copy is the symlink target, so the + # prebundle content is redundant, and renaming a directory on an + # overlayfs lower layer (Docker image build) fails with ``EXDEV``. + _force_remove(prebundled) if use_symlinks: prebundled.symlink_to(venv_pkg) else: @@ -808,6 +1126,24 @@ def _repoint_prebundle_packages() -> None: else: print_debug("All prebundled packages already up-to-date — nothing to repoint.") + # Fail loud: a real (non-symlink) prebundled ``torch`` left behind shadows the + # pip-installed torch on launch paths that do not import ``isaaclab`` (e.g. + # ``isaac-sim.streaming.sh``), pulling a mismatched NCCL and crashing with + # ``undefined symbol: ncclDevCommCreate``. Never let that state ship silently. + # Only relevant when symlinking (Linux); the Windows branch deliberately copies the + # env package into the prebundle, which is a real directory by design. + if use_symlinks and (site_packages / "torch").exists(): + shadowing = [ + prebundle_dir / "torch" + for prebundle_dir in prebundle_dirs + if (prebundle_dir / "torch").is_dir() and not (prebundle_dir / "torch").is_symlink() + ] + if shadowing: + raise RuntimeError( + "Failed to neutralize prebundled torch under Isaac Sim; the following would shadow the " + "pip-installed torch and crash non-isaaclab launches:\n " + "\n ".join(str(p) for p in shadowing) + ) + def command_install(install_type: str = "all") -> None: """Install Isaac Lab extensions and optional extras. @@ -829,15 +1165,15 @@ def command_install(install_type: str = "all") -> None: optional submodules and extra features. Valid tokens: - Optional submodules: ``mimic``, ``teleop`` - - Extra features: ``contrib[rlinf]``, ``newton``, ``rl[]``, + - Extra features: ``contrib[rlinf]``, ``rl[]``, ``visualizer[]``, ``ov[ovrtx|ovphysx|all]`` - Special: ``isaacsim`` Examples:: - ./isaaclab.sh -i newton,rl[rsl-rl] + ./isaaclab.sh -i rl[rsl-rl] ./isaaclab.sh -i mimic,visualizer[rerun] - ./isaaclab.sh -i teleop,rl[skrl],newton + ./isaaclab.sh -i teleop,rl[skrl],ov[ovrtx] """ # Install system dependencies first. @@ -859,6 +1195,8 @@ def command_install(install_type: str = "all") -> None: extra_features: list[tuple[str, str]] = [] # List of (submodule_name, selector) tuples for optional submodule extras. optional_submodule_extra_dependencies: list[tuple[str, str]] = [] + # Names of requested optional submodules (used to install their root extras). + requested_optional_submodules: list[str] = [] def append_submodules_once(package_dirs: tuple[str, ...]) -> None: for pkg_dir in package_dirs: @@ -872,6 +1210,7 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: if install_type == "all": for package_dirs in OPTIONAL_ISAACLAB_SUBMODULES.values(): append_submodules_once(package_dirs) + requested_optional_submodules = list(OPTIONAL_ISAACLAB_SUBMODULES) extra_features = [(name, "") for name in sorted(VALID_EXTRA_FEATURES - MANUAL_EXTRA_FEATURES)] elif install_type == "core": # Core only — no optional submodules, no extra features. @@ -893,6 +1232,7 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: install_isaacsim = True elif name in OPTIONAL_ISAACLAB_SUBMODULES: append_submodules_once(OPTIONAL_ISAACLAB_SUBMODULES[name]) + requested_optional_submodules.append(name) if selector: optional_submodule_extra_dependencies.append((name, selector)) elif name in VALID_EXTRA_FEATURES: @@ -943,6 +1283,10 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: if saved_pythonpath is not None: probe_env["PYTHONPATH"] = saved_pythonpath + # Baseline for the post-install integrity check: no pip operation below may + # leave new dangling symlinks in Isaac Sim's prebundles (nvbugs 6343978). + dangling_symlinks_before = _find_dangling_prebundle_symlinks() + try: # Upgrade pip first to avoid compatibility issues (skip when using uv). if not using_uv: @@ -953,7 +1297,7 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: run_command(pip_cmd + ["install", "setuptools<82.0.0"]) # On ARM Linux pre-install nlopt to dodge its from-source build fallback. - _maybe_preinstall_arm_nlopt(pip_cmd) + _maybe_preinstall_arm_nlopt(python_exe, pip_cmd) # Drop pip-installed torch if Isaac Sim's deprecated ML prebundle would shadow it. _maybe_uninstall_prebundled_torch(python_exe, pip_cmd, using_uv, probe_env=probe_env) @@ -968,6 +1312,15 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: # Install all submodules (core set + any explicitly requested optional ones). _install_isaaclab_submodules(submodules_to_install) + # The submodules no longer declare third-party dependencies; install the + # centralized core requirements (and optional-submodule extras) from the + # root pyproject. torch is excluded — it is handled by _ensure_cuda_torch. + _install_centralized_dependencies(pip_cmd, requested_optional_submodules) + + # Isaac Sim's bundled newton==1.2.0 satisfies the loose core bound, so force the + # pinned Newton git build (the default physics engine) over it. + _ensure_newton() + # Install requested optional submodule dependency extras. if optional_submodule_extra_dependencies: print_info("Installing optional submodule dependencies...") @@ -994,6 +1347,12 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: # ordering (e.g. torch+cu130 in venv vs torch+cu128 in prebundle on aarch64). _repoint_prebundle_packages() + # Fail loud if any pip operation above broke Isaac Sim's cross-extension + # symlink farms. Prebundle deletions on their own are routine (pip + # replaces those packages in site-packages, which shadows the prebundle + # at runtime); only newly dangling symlinks break extension startup. + _assert_no_new_dangling_prebundle_symlinks(dangling_symlinks_before) + finally: # Restore LD_PRELOAD if we cleared it. if saved_ld_preload: diff --git a/source/isaaclab/isaaclab/controllers/differential_ik.py b/source/isaaclab/isaaclab/controllers/differential_ik.py index 9c8f74bade8..51f399ba4be 100644 --- a/source/isaaclab/isaaclab/controllers/differential_ik.py +++ b/source/isaaclab/isaaclab/controllers/differential_ik.py @@ -69,6 +69,20 @@ def __init__(self, cfg: DifferentialIKControllerCfg, num_envs: int, device: str) self.ee_quat_des = torch.zeros(self.num_envs, 4, device=self._device) # -- input command self._command = torch.zeros(self.num_envs, self.action_dim, device=self._device) + # -- optional per-axis orientation task weights (used for "pose" command types only) + if self.cfg.orientation_weight is None: + self._orientation_weight = None + else: + ori_weight = self.cfg.orientation_weight + weight_tuple = ( + (float(ori_weight),) * 3 + if isinstance(ori_weight, (int, float)) + else tuple(float(value) for value in ori_weight) + ) + self._orientation_weight = torch.tensor(weight_tuple, device=self._device) + # -- optional joint position limits for null-space joint-limit avoidance (set externally) + self._joint_pos_lower = None + self._joint_pos_upper = None """ Properties. @@ -144,7 +158,24 @@ def set_command( self.ee_pos_des, self.ee_quat_des = apply_delta_pose(ee_pos, ee_quat, self._command) else: self.ee_pos_des = self._command[:, 0:3] - self.ee_quat_des = self._command[:, 3:7] + # renormalize the commanded quaternion (callers may pass a slightly non-unit quat) + quat = self._command[:, 3:7] + self.ee_quat_des = quat / torch.linalg.norm(quat, dim=-1, keepdim=True) + + def set_joint_pos_limits(self, lower: torch.Tensor, upper: torch.Tensor) -> None: + """Provide the controlled joints' position limits for null-space joint-limit avoidance. + + Only used when + :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_gain` + is positive. The IK action term injects these automatically on its first step; call this + manually only when using the controller standalone. + + Args: + lower: Lower joint-position limits in shape (num_joints,). + upper: Upper joint-position limits in shape (num_joints,). + """ + self._joint_pos_lower = lower.to(self._device) + self._joint_pos_upper = upper.to(self._device) def compute( self, ee_pos: torch.Tensor, ee_quat: torch.Tensor, jacobian: torch.Tensor, joint_pos: torch.Tensor @@ -160,17 +191,16 @@ def compute( Returns: The target joint positions commands in shape (N, num_joints). """ - # compute the delta in joint-space + # assemble the task Jacobian and task-space error if "position" in self.cfg.command_type: - position_error = self.ee_pos_des - ee_pos - jacobian_pos = jacobian[:, 0:3] - delta_joint_pos = self._compute_delta_joint_pos(delta_pose=position_error, jacobian=jacobian_pos) + task_jacobian = jacobian[:, 0:3] + task_error = self.ee_pos_des - ee_pos else: - position_error, axis_angle_error = compute_pose_error( - ee_pos, ee_quat, self.ee_pos_des, self.ee_quat_des, rot_error_type="axis_angle" - ) - pose_error = torch.cat((position_error, axis_angle_error), dim=1) - delta_joint_pos = self._compute_delta_joint_pos(delta_pose=pose_error, jacobian=jacobian) + task_jacobian, task_error = self._compute_pose_task(ee_pos, ee_quat, jacobian) + # compute the delta in joint-space + delta_joint_pos = self._compute_delta_joint_pos(delta_pose=task_error, jacobian=task_jacobian) + # add an optional null-space joint-limit-avoidance bias (a no-op when joint_limit_avoidance_gain == 0) + delta_joint_pos = delta_joint_pos + self._joint_limit_avoidance(joint_pos, task_jacobian) # return the desired joint positions return joint_pos + delta_joint_pos @@ -235,7 +265,87 @@ def _compute_delta_joint_pos(self, delta_pose: torch.Tensor, jacobian: torch.Ten jacobian_T @ torch.inverse(jacobian @ jacobian_T + lambda_matrix) @ delta_pose.unsqueeze(-1) ) delta_joint_pos = delta_joint_pos.squeeze(-1) + elif self.cfg.ik_method == "adaptive_dls": # manipulability-aware damped least squares + # parameters + lambda_min = self.cfg.ik_params["lambda_min"] + lambda_max = self.cfg.ik_params["lambda_max"] + sigma_thresh = self.cfg.ik_params["sigma_thresh"] + # per-environment squared damping: lambda_min^2 away from singularities, ramping + # quadratically up to lambda_max^2 as the smallest task-Jacobian singular value -> 0 + # (Maciejewski-Klein). Keying off the full task Jacobian damps both position and + # orientation rank-loss configurations. + sigma_min = torch.linalg.svdvals(jacobian)[:, -1] # (N,) + ratio = (sigma_min / sigma_thresh).clamp(max=1.0) + lambda_sq = lambda_min**2 + (1.0 - ratio**2) * (lambda_max**2 - lambda_min**2) # (N,) + jacobian_T = torch.transpose(jacobian, dim0=1, dim1=2) + lambda_matrix = lambda_sq.view(-1, 1, 1) * torch.eye(n=jacobian.shape[1], device=self._device) + delta_joint_pos = torch.bmm( + jacobian_T, + torch.linalg.solve(torch.bmm(jacobian, jacobian_T) + lambda_matrix, delta_pose.unsqueeze(-1)), + ).squeeze(-1) else: raise ValueError(f"Unsupported inverse-kinematics method: {self.cfg.ik_method}") return delta_joint_pos + + def _compute_pose_task( + self, ee_pos: torch.Tensor, ee_quat: torch.Tensor, jacobian: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + """Assemble the (optionally orientation-weighted) pose task Jacobian and error. + + The orientation error is the axis-angle of ``q_des * q_cur^-1`` from + :func:`~isaaclab.utils.math.compute_pose_error`. When + :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.orientation_weight` + is set, the 3 orientation rows of both the Jacobian and the error are scaled per + base-frame axis (a weight of 0 drops that axis from the solve). Subclasses may override + this to further shape the task (e.g. masking which joints serve orientation). + + Args: + ee_pos: Current end-effector position in shape (N, 3). + ee_quat: Current end-effector orientation (x, y, z, w) in shape (N, 4). + jacobian: The geometric Jacobian in shape (N, 6, num_joints). + + Returns: + A tuple ``(task_jacobian, task_error)`` with the (N, 6, num_joints) task Jacobian and + the (N, 6) task-space error. + """ + position_error, axis_angle_error = compute_pose_error( + ee_pos, ee_quat, self.ee_pos_des, self.ee_quat_des, rot_error_type="axis_angle" + ) + task_jacobian = jacobian + if self._orientation_weight is not None: + weight = self._orientation_weight + task_jacobian = torch.cat([jacobian[:, 0:3, :], jacobian[:, 3:6, :] * weight.view(1, 3, 1)], dim=1) + axis_angle_error = axis_angle_error * weight.view(1, 3) + task_error = torch.cat((position_error, axis_angle_error), dim=1) + return task_jacobian, task_error + + def _joint_limit_avoidance(self, joint_pos: torch.Tensor, task_jacobian: torch.Tensor) -> torch.Tensor: + """Null-space joint-centering bias that keeps joints off their position limits. + + Projects a center-seeking joint velocity (active only within + :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_margin` + of a limit) into the null space of the position (linear) task rows, so it never perturbs + the commanded end-effector position. Returns zeros when disabled (``joint_limit_avoidance_gain == 0``) or + before joint limits are provided via :meth:`set_joint_pos_limits`. + + Args: + joint_pos: Current joint positions in shape (N, num_joints). + task_jacobian: The task Jacobian in shape (N, T, num_joints); rows 0-2 are the + position (linear) rows. + + Returns: + The joint-space correction in shape (N, num_joints). + """ + if self.cfg.joint_limit_avoidance_gain <= 0.0 or self._joint_pos_lower is None: + return torch.zeros_like(joint_pos) + lower, upper = self._joint_pos_lower, self._joint_pos_upper + q_mid = 0.5 * (lower + upper) + dist = torch.minimum(joint_pos - lower, upper - joint_pos) # margin to nearest limit + activation = 1.0 - (dist / self.cfg.joint_limit_avoidance_margin).clamp(0.0, 1.0) # 1 at the limit, 0 mid-range + dq_center = -self.cfg.joint_limit_avoidance_gain * activation * (joint_pos - q_mid) # toward the joint center + j_pos = task_jacobian[:, :3, :] + j_pos_pinv = torch.linalg.pinv(j_pos) + num_joints = task_jacobian.shape[2] + null_proj = torch.eye(num_joints, device=self._device) - torch.bmm(j_pos_pinv, j_pos) + return torch.bmm(null_proj, dq_center.unsqueeze(-1)).squeeze(-1) diff --git a/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py b/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py index 409ecc9429f..58c2fbcce2c 100644 --- a/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py +++ b/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py @@ -35,7 +35,7 @@ class DifferentialIKControllerCfg: Otherwise, the controller treats the input command as the absolute position/pose. """ - ik_method: Literal["pinv", "svd", "trans", "dls"] = MISSING + ik_method: Literal["pinv", "svd", "trans", "dls", "adaptive_dls"] = MISSING """Method for computing inverse of Jacobian.""" ik_params: dict[str, float] | None = None @@ -51,13 +51,48 @@ class DifferentialIKControllerCfg: - "k_val": Scaling of computed delta-joint positions (default: 1.0). - Damped Moore-Penrose pseudo-inverse ("dls"): - "lambda_val": Damping coefficient (default: 0.01). + - Manipulability-aware damped least squares ("adaptive_dls"): + - "lambda_min": Baseline damping coefficient used away from singularities (default: 0.05). + - "lambda_max": Maximum damping coefficient, reached as the smallest task-Jacobian + singular value approaches zero (default: 0.2). + - "sigma_thresh": Smallest-singular-value threshold below which the damping ramps + quadratically from ``lambda_min`` toward ``lambda_max`` (Maciejewski-Klein style) + (default: 0.02). """ + orientation_weight: float | tuple[float, float, float] | None = None + """Soft weight on the orientation task rows for ``"pose"`` command types. Defaults to ``None`` + (the orientation rows keep weight 1, i.e. unchanged behavior). + + A scalar weights all three orientation rows equally; a per-axis ``(wx, wy, wz)`` weights the + base-frame orientation axes independently. Scaling an orientation row (and its error) by a + weight de-emphasises -- or, at weight 0, drops -- that rotation DOF in the solve without + changing the task dimensionality. This is useful for arms that cannot serve a full 6-DOF pose + (e.g. a 5-DOF arm) so the unreachable orientation DOF degrades gracefully instead of leaking + error into the position rows. Ignored for ``"position"`` command types. + """ + + joint_limit_avoidance_gain: float = 0.0 + """Gain for the null-space joint-limit-avoidance bias. ``0`` disables it (default). + + When positive, a center-seeking joint velocity (active only within + :attr:`joint_limit_avoidance_margin` of a limit) is projected into the null space of the + position task rows, so it keeps joints off their limits without perturbing the commanded + end-effector position. Active only once joint limits are provided via + :meth:`~isaaclab.controllers.differential_ik.DifferentialIKController.set_joint_pos_limits` + (the IK action term injects them automatically when ``joint_limit_avoidance_gain > 0``). + """ + + joint_limit_avoidance_margin: float = 0.3 + """Joint-range margin within which the joint-limit-avoidance bias activates (1 at the limit, + ramping to 0 at ``joint_limit_avoidance_margin`` away from it). Units match the joints + (e.g. [rad] for revolute joints).""" + def __post_init__(self): # check valid input if self.command_type not in ["position", "pose"]: raise ValueError(f"Unsupported inverse-kinematics command: {self.command_type}.") - if self.ik_method not in ["pinv", "svd", "trans", "dls"]: + if self.ik_method not in ["pinv", "svd", "trans", "dls", "adaptive_dls"]: raise ValueError(f"Unsupported inverse-kinematics method: {self.ik_method}.") # default parameters for different inverse kinematics approaches. default_ik_params = { @@ -65,9 +100,30 @@ def __post_init__(self): "svd": {"k_val": 1.0, "min_singular_value": 1e-5}, "trans": {"k_val": 1.0}, "dls": {"lambda_val": 0.01}, + "adaptive_dls": {"lambda_min": 0.05, "lambda_max": 0.2, "sigma_thresh": 0.02}, } # update parameters for IK-method if not provided ik_params = default_ik_params[self.ik_method].copy() if self.ik_params is not None: ik_params.update(self.ik_params) self.ik_params = ik_params + # validate adaptive_dls parameters + if self.ik_method == "adaptive_dls": + if self.ik_params["sigma_thresh"] <= 0.0: + raise ValueError(f"adaptive_dls sigma_thresh must be > 0, got {self.ik_params['sigma_thresh']}.") + if self.ik_params["lambda_min"] > self.ik_params["lambda_max"]: + raise ValueError( + f"adaptive_dls lambda_min ({self.ik_params['lambda_min']}) must be <= " + f"lambda_max ({self.ik_params['lambda_max']})." + ) + # validate optional orientation weighting / joint-limit-avoidance settings + if self.orientation_weight is not None and not isinstance(self.orientation_weight, (int, float)): + if len(self.orientation_weight) != 3: + raise ValueError( + "orientation_weight must be a scalar or a length-3 (wx, wy, wz) tuple, got " + f"{self.orientation_weight}." + ) + if self.joint_limit_avoidance_gain < 0.0: + raise ValueError(f"joint_limit_avoidance_gain must be >= 0, got {self.joint_limit_avoidance_gain}.") + if self.joint_limit_avoidance_margin <= 0.0: + raise ValueError(f"joint_limit_avoidance_margin must be > 0, got {self.joint_limit_avoidance_margin}.") diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py index 1520afdb017..ed69b6a4cee 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py @@ -271,14 +271,16 @@ def _return_current_joint_positions(error: Exception) -> torch.Tensor: except SolverNotFound as e: raise RuntimeError( f"Pink IK requires the '{_QP_SOLVER}' QP solver. Install the Pink IK stack with " - "``./isaaclab.sh -i`` or manually install ``pin pin-pink==3.1.0 daqp==0.8.5``." + "``./isaaclab.sh -i`` or manually install the ``pin``, ``pin-pink`` and ``daqp``" + " versions pinned in Isaac Lab's root ``pyproject.toml``." ) from e except TypeError as e: if "primal_start" in str(e): raise RuntimeError( "Pink IK requires a DAQP version compatible with qpsolvers warm-start arguments. " - "Install the Pink IK stack with ``./isaaclab.sh -i`` or manually install " - "``pin pin-pink==3.1.0 daqp==0.8.5``." + "Install the Pink IK stack with ``./isaaclab.sh -i`` or manually install the " + "``pin``, ``pin-pink`` and ``daqp`` versions pinned in Isaac Lab's root " + "``pyproject.toml``." ) from e return _return_current_joint_positions(e) except (AssertionError, Exception) as e: diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/fourier/data/configs/dex-retargeting/fourier_hand_left_dexpilot.yml b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/fourier/data/configs/dex-retargeting/fourier_hand_left_dexpilot.yml index 1e203d11e7e..3adb2139dfe 100644 --- a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/fourier/data/configs/dex-retargeting/fourier_hand_left_dexpilot.yml +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/fourier/data/configs/dex-retargeting/fourier_hand_left_dexpilot.yml @@ -24,7 +24,6 @@ retargeting: - L_thumb_proximal_yaw_joint - L_thumb_proximal_pitch_joint - L_thumb_distal_joint - - L_thumb_distal_joint type: DexPilot urdf_path: /tmp/GR1_T2_left_hand.urdf wrist_link_name: l_hand_base_link diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env.py b/source/isaaclab/isaaclab/envs/direct_marl_env.py index 9ebc983c6da..c7ef5934490 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env.py @@ -67,6 +67,7 @@ class DirectMARLEnv(gym.Env): metadata: ClassVar[dict[str, Any]] = { "render_modes": [None, "human", "rgb_array"], + "autoreset_mode": gym.vector.AutoresetMode.SAME_STEP, } """Metadata for the environment.""" @@ -480,6 +481,17 @@ def step(self, actions: dict[AgentID, ActionType]) -> EnvStepReturn: # -- reset envs that terminated/timed-out and log the episode information reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1) if len(reset_env_ids) > 0: + # capture the per-agent terminal observation before reset and expose it for Same-Step + # autoreset. apply the same observation noise as the returned obs so the bootstrapped + # terminal value matches the distribution the policy is trained on. + if self.cfg.compute_final_obs: + terminal_obs = self._get_observations() + if self.cfg.observation_noise_model: + for agent, obs in terminal_obs.items(): + if agent in self._observation_noise_model: + terminal_obs[agent] = self._observation_noise_model[agent](obs) + for agent, obs in terminal_obs.items(): + self.extras[agent]["final_obs"] = obs self._reset_idx(reset_env_ids) # post-step: step interval event diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py b/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py index de8381e1050..eeebfeab51c 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py @@ -84,6 +84,20 @@ class DirectMARLEnvCfg: wrappers to determine what type of done signal to send to the corresponding learning agent. """ + compute_final_obs: bool = False + """Whether to capture the per-agent terminal observation before a Same-Step autoreset and expose it. + + Under Same-Step autoreset (see :attr:`~isaaclab.envs.DirectMARLEnv.metadata`), an agent whose + environment terminates is reset within the same :meth:`~isaaclab.envs.DirectMARLEnv.step` call, so + the returned observation belongs to the *new* episode. When this flag is True, the observation is + computed once more *before* the reset and stored per agent under ``extras[agent]["final_obs"]`` + (with the same observation noise as the returned observation applied), so wrappers can report it as + the true terminal observation for value bootstrapping. + + Defaults to False, which preserves the previous behavior: no terminal observation is captured, + ``extras[agent]["final_obs"]`` is not populated, and the extra observation computation is skipped. + """ + episode_length_s: float = MISSING """Duration of an episode (in seconds). diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env.py b/source/isaaclab/isaaclab/envs/direct_rl_env.py index bfe97948ac4..ab04ee72457 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env.py @@ -72,6 +72,7 @@ class DirectRLEnv(gym.Env): """Whether the environment is a vectorized environment.""" metadata: ClassVar[dict[str, Any]] = { "render_modes": [None, "human", "rgb_array"], + "autoreset_mode": gym.vector.AutoresetMode.SAME_STEP, } """Metadata for the environment.""" @@ -471,6 +472,14 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: # -- reset envs that terminated/timed-out and log the episode information reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1).int() if len(reset_env_ids) > 0: + # capture the terminal observation before reset and expose it for Same-Step autoreset. + # apply the same observation noise as the returned obs (policy space only) so the + # bootstrapped terminal value matches the distribution the policy is trained on. + if self.cfg.compute_final_obs: + terminal_obs = self._get_observations() + if self.cfg.observation_noise_model: + terminal_obs["policy"] = self._observation_noise_model(terminal_obs["policy"]) + self.extras["final_obs"] = terminal_obs self._reset_idx(reset_env_ids) # if sensors are added to the scene, make sure we render to reflect changes in reset if self.render_enabled and is_rendering and self.has_rtx_sensors and self.cfg.num_rerenders_on_reset > 0: diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py b/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py index 484338337e7..65aa0403dd0 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py @@ -83,6 +83,23 @@ class DirectRLEnvCfg: wrappers to determine what type of done signal to send to the corresponding learning agent. """ + compute_final_obs: bool = False + """Whether to capture the terminal observation before a Same-Step autoreset and expose it. + + Under Same-Step autoreset (see :attr:`~isaaclab.envs.DirectRLEnv.metadata`), an environment that + terminates is reset within the same :meth:`~isaaclab.envs.DirectRLEnv.step` call, so the returned + observation belongs to the *new* episode. When this flag is True, the observation is computed once + more *before* the reset and stored under ``extras["final_obs"]`` (with the same observation noise + as the returned observation applied), so wrappers can report it as the true terminal observation + for value bootstrapping. + + Defaults to False, which preserves the previous behavior: no terminal observation is captured, + ``extras["final_obs"]`` is not populated, and the extra observation computation is skipped. + + Note: + Currently consumed by the :class:`~isaaclab_rl.sb3.Sb3VecEnvWrapper` wrapper. + """ + episode_length_s: float = MISSING """Duration of an episode (in seconds). diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index dfdbef5091f..d06c77a000b 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -56,6 +56,7 @@ class ManagerBasedRLEnv(ManagerBasedEnv, gym.Env): """Whether the environment is a vectorized environment.""" metadata: ClassVar[dict[str, Any]] = { "render_modes": [None, "human", "rgb_array"], + "autoreset_mode": gym.vector.AutoresetMode.SAME_STEP, } """Metadata for the environment.""" @@ -247,6 +248,9 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: # -- reset envs that terminated/timed-out and log the episode information reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1).int() if len(reset_env_ids) > 0: + # capture the terminal observation before reset and expose it for Same-Step autoreset. + if self.cfg.compute_final_obs: + self.extras["final_obs"] = self.observation_manager.compute() # trigger recorder terms for pre-reset calls self.recorder_manager.record_pre_reset(reset_env_ids) diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py index 8e85a3bd3ab..1f30784d5f7 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py @@ -42,6 +42,22 @@ class ManagerBasedRLEnvCfg(ManagerBasedEnvCfg): wrappers to determine what type of done signal to send to the corresponding learning agent. """ + compute_final_obs: bool = False + """Whether to capture the terminal observation before a Same-Step autoreset and expose it. + + Under Same-Step autoreset (see :attr:`~isaaclab.envs.ManagerBasedRLEnv.metadata`), an environment + that terminates is reset within the same :meth:`~isaaclab.envs.ManagerBasedRLEnv.step` call, so the + returned observation belongs to the *new* episode. When this flag is True, the observation is + computed once more *before* the reset and stored under ``extras["final_obs"]``, so wrappers can + report it as the true terminal observation for value bootstrapping. + + Defaults to False, which preserves the previous behavior: no terminal observation is captured, + ``extras["final_obs"]`` is not populated, and the extra observation computation is skipped. + + Note: + Currently consumed by the :class:`~isaaclab_rl.sb3.Sb3VecEnvWrapper` wrapper. + """ + episode_length_s: float = MISSING """Duration of an episode (in seconds). diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py index 8d5496b7678..ac1a713d234 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py @@ -90,6 +90,9 @@ def __init__(self, cfg: actions_cfg.DifferentialInverseKinematicsActionCfg, env: self._ik_controller = DifferentialIKController( cfg=self.cfg.controller, num_envs=self.num_envs, device=self.device ) + # joint limits are injected lazily on the first apply (asset data is populated by then) so + # the controller can do null-space joint-limit avoidance; only needed when joint_limit_avoidance_gain > 0. + self._limits_injected = False # create tensors for raw and processed actions self._raw_actions = torch.zeros(self.num_envs, self.action_dim, device=self.device) @@ -201,6 +204,12 @@ def apply_actions(self): # obtain quantities from simulation ee_pos_curr, ee_quat_curr = self._compute_frame_pose() joint_pos = self._asset.data.joint_pos.torch[:, self._joint_ids] + # lazily provide joint limits to the controller for null-space joint-limit avoidance + # (limits are uniform across envs for these articulations; env 0 is representative) + if not self._limits_injected and getattr(self.cfg.controller, "joint_limit_avoidance_gain", 0.0) > 0.0: + limits = self._asset.data.soft_joint_pos_limits.torch[0, self._joint_ids, :] + self._ik_controller.set_joint_pos_limits(limits[:, 0].clone(), limits[:, 1].clone()) + self._limits_injected = True # compute the delta in joint-space if ee_quat_curr.norm() != 0: jacobian = self._compute_frame_jacobian() diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index ca8647fb688..b651d244a34 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -332,14 +332,94 @@ def __call__( self._newton_manager.add_model_change(self._notify_shape_properties) +class _RandomizeRigidBodyMaterialOvPhysx: + """OVPhysX backend implementation for material randomization. + + OVPhysX runs the PhysX solver, so PhysX's 64000 unique-material limit applies and this + mirrors the PhysX bucket approach: ``num_buckets`` materials are pre-sampled once and + randomly assigned to shapes. Materials are written through the asset's + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` on the per-collision-shape + ``shape_friction_and_restitution`` binding (shape ``[N, S, 3]`` = static friction, + dynamic friction, restitution). + + OVPhysX does not expose per-body shape counts, so only whole-asset (all shapes) + randomization is supported; ``asset_cfg.body_ids`` must select all bodies. + """ + + def __init__( + self, cfg: EventTermCfg, env: ManagerBasedEnv, asset: RigidObject | Articulation, asset_cfg: SceneEntityCfg + ): + import isaaclab_ovphysx.tensor_types as ovphysx_tt # noqa: PLC0415 + + from isaaclab.assets import BaseArticulation # noqa: PLC0415 + + # OVPhysX cannot map body ids to shape ranges (no per-body shape counts), so a + # per-body subset cannot be indexed -- fail loud rather than silently randomize all. + if asset_cfg.body_ids != slice(None): + raise NotImplementedError( + "randomize_rigid_body_material on the OVPhysX backend randomizes all shapes only; " + "per-body selection via 'asset_cfg.body_ids' is not supported because the ovphysx " + "wheel does not expose per-body shape counts. Use the default (all bodies)." + ) + + # sample material buckets once (PhysX-style; the 64000 unique-material limit applies) + static_friction_range = cfg.params.get("static_friction_range", (1.0, 1.0)) + dynamic_friction_range = cfg.params.get("dynamic_friction_range", (1.0, 1.0)) + restitution_range = cfg.params.get("restitution_range", (0.0, 0.0)) + num_buckets = int(cfg.params.get("num_buckets", 1)) + ranges = torch.tensor([static_friction_range, dynamic_friction_range, restitution_range], device="cpu") + self.material_buckets = math_utils.sample_uniform(ranges[:, 0], ranges[:, 1], (num_buckets, 3), device="cpu") + if cfg.params.get("make_consistent", False): + self.material_buckets[:, 1] = torch.min(self.material_buckets[:, 0], self.material_buckets[:, 1]) + + self.asset = asset + self.asset_cfg = asset_cfg + # per-shape material tensor type for this asset family (articulation vs rigid body) + self._material_type = ( + ovphysx_tt.SHAPE_FRICTION_AND_RESTITUTION + if isinstance(asset, BaseArticulation) + else ovphysx_tt.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION + ) + + def __call__( + self, + env: ManagerBasedEnv, + env_ids: torch.Tensor | None, + static_friction_range: tuple[float, float], + dynamic_friction_range: tuple[float, float], + restitution_range: tuple[float, float], + num_buckets: int, + asset_cfg: SceneEntityCfg, + make_consistent: bool = False, + ): + view = self.asset.root_view + # read the current per-shape material [N, S, 3] on the binding's native (sim) device + materials = wp.to_torch(view.get_attribute(self._material_type)) + num_instances, num_shapes = materials.shape[0], materials.shape[1] + + # resolve environment ids on the material buffer's device + if env_ids is None: + env_ids = torch.arange(num_instances, device=materials.device) + else: + env_ids = env_ids.to(materials.device) + + # randomly assign pre-sampled bucket materials to every shape of the selected envs + bucket_ids = torch.randint(0, num_buckets, (len(env_ids), num_shapes), device="cpu") + material_samples = self.material_buckets[bucket_ids].to(materials.device) # [len(env_ids), S, 3] + materials[env_ids] = material_samples + + # write the full buffer back through the view (read-modify-write keeps non-selected envs) + view.set_attribute(self._material_type, wp.from_torch(materials.contiguous(), dtype=wp.float32)) + + class randomize_rigid_body_material(ManagerTermBase): """Randomize the physics materials on all geometries of the asset. This function creates a set of physics materials with random static friction, dynamic friction, and restitution values and assigns them to the geometries of the asset. - Automatically detects the active physics backend (PhysX or Newton) and delegates to - the appropriate backend-specific implementation: + Automatically detects the active physics backend (PhysX, Newton, or OVPhysX) and delegates + to the appropriate backend-specific implementation: - **PhysX**: Uses the 3-tuple material format (static_friction, dynamic_friction, restitution) with bucket-based assignment (limited to 64000 unique materials). Applied via the PhysX @@ -347,6 +427,11 @@ class randomize_rigid_body_material(ManagerTermBase): - **Newton**: Samples friction (mu) and restitution continuously per shape (no bucket limitation). Newton uses a single friction coefficient, so ``dynamic_friction_range`` and ``num_buckets`` are ignored. Applied directly to Newton's view-level bindings. + - **OVPhysX**: Runs the PhysX solver, so the same 3-tuple, bucket-based assignment is used, + written through the :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` on the per-shape + ``shape_friction_and_restitution`` binding. Randomizes all shapes only -- per-body + selection (``asset_cfg.body_ids``) is not supported (the wheel exposes no per-body shape + counts). If the flag ``make_consistent`` is set to ``True``, the dynamic friction is set to be less than or equal to the static friction (PhysX only). This obeys the physics constraint on friction values. @@ -394,22 +479,7 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv): # ``NewtonKaminoManager``, ...) are caught by the substring branch. manager_name = env.sim.physics_manager.__name__.lower() if manager_name == "ovphysxmanager": - # No OVPhysX implementation yet — wheel-side - # ``RIGID_BODY_MATERIAL`` tensor binding is missing; randomization - # would require per-body view creation that ovphysx does not yet - # expose. Run with material randomization disabled (warns once). - import logging # noqa: PLC0415 - - logging.getLogger(__name__).warning( - "randomize_rigid_body_material is a no-op on the OVPhysX backend " - "(wheel-side gap — see docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md)." - ) - - class _Noop: - def __call__(self, *args, **kwargs): - pass - - self._impl = _Noop() + self._impl = _RandomizeRigidBodyMaterialOvPhysx(cfg, env, self.asset, self.asset_cfg) elif "newton" in manager_name: self._impl = _RandomizeRigidBodyMaterialNewton(cfg, env, self.asset, self.asset_cfg) elif "physx" in manager_name: diff --git a/source/isaaclab/isaaclab/envs/utils/video_recorder.py b/source/isaaclab/isaaclab/envs/utils/video_recorder.py index 0925b4a0ab1..93736999c8b 100644 --- a/source/isaaclab/isaaclab/envs/utils/video_recorder.py +++ b/source/isaaclab/isaaclab/envs/utils/video_recorder.py @@ -5,104 +5,58 @@ """Video recorder implementation. -Backend resolution (``--video`` + ``--visualizer``): - -1. **Active visualizer** - ``"kit"`` uses the Kit camera; ``"newton"`` uses the Newton GL viewer. - ``"viser"`` / ``"rerun"`` have no capture API and fall through to rule 2. -2. **Physics/renderer stack** - - - PhysX or Isaac RTX uses the Kit camera; - - Newton physics or Newton Warp uses the Newton GL viewer. - Kit wins when both signals present. Raises if nothing resolves. - -Set :attr:`~isaaclab.envs.utils.video_recorder_cfg.VideoRecorderCfg.backend_source` to ``"renderer"`` -to ignore active visualizers and record from the physics/renderer stack. - -Camera sync when a visualizer drives the backend: construction copies the visualizer config's -``eye`` / ``lookat`` into the recorder config; each :meth:`~VideoRecorder.render_rgb_array` -call then re-reads the Newton viewer's live ``camera.pos/pitch/yaw``. Kit video uses the -configured ``eye`` / ``lookat`` at construction time. - -See :mod:`video_recorder_cfg` for configuration. +An active Kit or Newton visualizer selects the capture backend by default. Newton video reuses the +active visualizer framebuffer, while renderer-selected Newton video uses a separate headless viewer. """ from __future__ import annotations -import logging -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, cast import numpy as np if TYPE_CHECKING: + from isaaclab_newton.video_recording.newton_gl_perspective_video import NewtonGlPerspectiveVideo + from isaaclab_physx.video_recording.isaacsim_kit_perspective_video import IsaacsimKitPerspectiveVideo + from isaaclab_visualizers.newton import NewtonVisualizer + from isaaclab.scene import InteractiveScene + from isaaclab.visualizers import VisualizerCfg from .video_recorder_cfg import VideoRecorderCfg -logger = logging.getLogger(__name__) - _VideoBackend = Literal["kit", "newton_gl"] -# visualizer types that map to a supported video backend. -# viser and rerun are intentionally absent - they have no video-capture API. -_VISUALIZER_TO_VIDEO_BACKEND: dict[str, _VideoBackend] = { - "kit": "kit", - "newton": "newton_gl", -} - -def _resolve_video_backend( - scene: InteractiveScene, backend_source: str = "visualizer" -) -> tuple[_VideoBackend, str | None]: - """Return ``(backend, matched_visualizer_type)`` for the active scene. - - ``matched_visualizer_type`` is ``"kit"`` / ``"newton"`` when a visualizer drove the - selection, or ``None`` when the physics/renderer preset stack was used instead. +def _select_video_backend(scene: InteractiveScene, backend_source: str) -> tuple[_VideoBackend, VisualizerCfg | None]: + """Resolve the capture backend and visualizer configuration that selected it. Args: - scene: The interactive scene that owns the sim context. - backend_source: ``"visualizer"`` to let active visualizers choose the backend, or ``"renderer"`` - to ignore active visualizers and use the physics/renderer stack. + scene: The interactive scene that owns the simulation context. + backend_source: Source used to select the capture backend. + + Returns: + Backend identifier and the visualizer configuration that selected it, if any. Raises: - RuntimeError: If no supported backend is detected. + ValueError: If backend_source is invalid. + RuntimeError: If no supported backend is available. """ if backend_source not in ("visualizer", "renderer"): raise ValueError("VideoRecorderCfg.backend_source must be either 'visualizer' or 'renderer'.") - # Prefer the visualizer backend when --visualizer is active alongside --video. - visualizer_types: list[str] = scene.sim.resolve_visualizer_types() if backend_source == "visualizer" else [] - if visualizer_types: - supported_visualizers = [viz for viz in ("kit", "newton") if viz in visualizer_types] - if len(supported_visualizers) > 1: - logger.warning( - "[VideoRecorder] Multiple video-capable visualizers are active (%s), but --video records one " - "env.render() stream. Using Kit because it has priority. Run with only --viz newton to record " - "a Newton GL video.", - supported_visualizers, - ) - # kit takes priority when multiple visualizers are active - for preferred in ("kit", "newton"): - if preferred in visualizer_types: - backend = _VISUALIZER_TO_VIDEO_BACKEND[preferred] - logger.debug("[VideoRecorder] Using '%s' backend from active '%s' visualizer.", backend, preferred) - return backend, preferred - # only unsupported visualizer types (viser, rerun) are active. - logger.warning( - "[VideoRecorder] Active visualizer(s) %s do not support video capture; " - "falling back to physics/renderer stack detection.", - visualizer_types, - ) - - # fall back to physics/renderer preset stack detection. - sim = scene.sim - physics_name = sim.physics_manager.__name__.lower() - renderer_types: list[str] = scene._sensor_renderer_types() - - use_kit = "physx" in physics_name or "isaac_rtx" in renderer_types - use_newton_gl = "newton" in physics_name or "newton_warp" in renderer_types - - if use_kit: + if backend_source == "visualizer": + visualizer_cfgs = scene.sim._resolve_visualizer_cfgs() + for visualizer_type, backend in (("kit", "kit"), ("newton", "newton_gl")): + for visualizer_cfg in visualizer_cfgs: + if visualizer_cfg.visualizer_type == visualizer_type: + return backend, visualizer_cfg + + physics_name = scene.sim.physics_manager.__name__.lower() + renderer_types = scene._sensor_renderer_types() + if "physx" in physics_name or "isaac_rtx" in renderer_types: return "kit", None - if use_newton_gl: + if "newton" in physics_name or "newton_warp" in renderer_types: return "newton_gl", None raise RuntimeError( "Video recording (--video) requires a supported backend: " @@ -111,48 +65,6 @@ def _resolve_video_backend( ) -def _sync_camera_from_visualizer( - scene: InteractiveScene, - visualizer_type: str, - cfg: VideoRecorderCfg, -) -> None: - """Overwrite ``cfg.eye`` and ``cfg.lookat`` from the active visualizer. - - Args: - scene: The interactive scene that owns the sim context. - visualizer_type: The visualizer type string matched by ``_resolve_video_backend`` - (e.g. ``"kit"`` or ``"newton"``). - cfg: The recorder configuration to update in place. - """ - try: - resolved_cfgs = scene.sim._resolve_visualizer_cfgs() - except Exception as exc: - logger.debug("[VideoRecorder] Could not resolve visualizer cfgs for camera sync: %s", exc) - return - - for vcfg in resolved_cfgs: - if getattr(vcfg, "visualizer_type", None) != visualizer_type: - continue - pos = getattr(vcfg, "eye", None) - tgt = getattr(vcfg, "lookat", None) - if pos is None or tgt is None: - break - cfg.eye = tuple(float(x) for x in pos) - cfg.lookat = tuple(float(x) for x in tgt) - logger.debug( - "[VideoRecorder] Camera synced from '%s' visualizer: position=%s, target=%s.", - visualizer_type, - cfg.eye, - cfg.lookat, - ) - return - - logger.debug( - "[VideoRecorder] Could not find eye/lookat on '%s' visualizer cfg; keeping existing camera values.", - visualizer_type, - ) - - class VideoRecorder: """Records perspective video frames from the scene's active renderer. @@ -164,92 +76,63 @@ class VideoRecorder: def __init__(self, cfg: VideoRecorderCfg, scene: InteractiveScene): self.cfg = cfg self._scene = scene - self._backend: _VideoBackend | None = None - self._capture = None - # visualizer type that drove backend selection (or None when using physics/renderer stack). - self._matched_visualizer: str | None = None - # live visualizer instance - looked up lazily on first render_rgb_array() call because - # visualizers are initialised by sim.reset(), which runs after VideoRecorder.__init__. - self._live_visualizer = None - - if cfg.env_render_mode == "rgb_array": - backend_source = getattr(cfg, "backend_source", "visualizer") - self._backend, self._matched_visualizer = _resolve_video_backend(scene, backend_source) - if self._matched_visualizer is not None: - _sync_camera_from_visualizer(scene, self._matched_visualizer, cfg) - if self._backend == "newton_gl": - try: - import pyglet as _pyglet # noqa: F401 - verify pyglet is available - except ImportError as e: - raise ImportError( - "The Newton GL video backend requires 'pyglet'. Install IsaacLab with './isaaclab.sh -i'." - ) from e - from isaaclab_newton.video_recording.newton_gl_perspective_video import ( - create_newton_gl_perspective_video, - ) - from isaaclab_newton.video_recording.newton_gl_perspective_video_cfg import NewtonGlPerspectiveVideoCfg + self._capture: NewtonGlPerspectiveVideo | IsaacsimKitPerspectiveVideo | NewtonVisualizer | None = None + self._use_newton_visualizer = False + + if cfg.env_render_mode != "rgb_array": + return + + backend, visualizer_cfg = _select_video_backend(scene, cfg.backend_source) + eye = cfg.eye if visualizer_cfg is None else visualizer_cfg.eye + lookat = cfg.lookat if visualizer_cfg is None else visualizer_cfg.lookat - ncfg = NewtonGlPerspectiveVideoCfg( + if backend == "newton_gl" and visualizer_cfg is not None: + self._use_newton_visualizer = True + elif backend == "newton_gl": + from isaaclab_newton.video_recording.newton_gl_perspective_video import ( + create_newton_gl_perspective_video, + ) + from isaaclab_newton.video_recording.newton_gl_perspective_video_cfg import NewtonGlPerspectiveVideoCfg + + self._capture = create_newton_gl_perspective_video( + NewtonGlPerspectiveVideoCfg( window_width=cfg.window_width, window_height=cfg.window_height, - eye=cfg.eye, - lookat=cfg.lookat, - ) - self._capture = create_newton_gl_perspective_video(ncfg) - else: - from isaaclab_physx.video_recording.isaacsim_kit_perspective_video import ( - create_isaacsim_kit_perspective_video, - ) - from isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg import ( - IsaacsimKitPerspectiveVideoCfg, + eye=eye, + lookat=lookat, ) + ) + else: + from isaaclab_physx.video_recording.isaacsim_kit_perspective_video import ( + create_isaacsim_kit_perspective_video, + ) + from isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg import ( + IsaacsimKitPerspectiveVideoCfg, + ) - kcfg = IsaacsimKitPerspectiveVideoCfg( - eye=cfg.eye, - lookat=cfg.lookat, + self._capture = create_isaacsim_kit_perspective_video( + IsaacsimKitPerspectiveVideoCfg( + eye=eye, + lookat=lookat, window_width=cfg.window_width, window_height=cfg.window_height, ) - self._capture = create_isaacsim_kit_perspective_video(kcfg) - - def _sync_newton_camera(self) -> None: - """Push the Newton visualizer's live camera pose into the capture object. - - Called once per :meth:`render_rgb_array` when a Newton visualizer is active. - The live visualizer instance is resolved lazily (visualizers are initialised by - ``sim.reset()``, which runs after ``VideoRecorder.__init__``). - """ - if self._live_visualizer is None: - for viz in self._scene.sim.visualizers: - if getattr(getattr(viz, "cfg", None), "visualizer_type", None) == "newton": - self._live_visualizer = viz - break - if self._live_visualizer is None: - return - - viewer = getattr(self._live_visualizer, "_viewer", None) - if viewer is None: - return - - import math - - cam = viewer.camera - pos = (float(cam.pos[0]), float(cam.pos[1]), float(cam.pos[2])) - yaw_rad = math.radians(float(cam.yaw)) - pitch_rad = math.radians(float(cam.pitch)) - dx = math.cos(pitch_rad) * math.cos(yaw_rad) - dy = math.cos(pitch_rad) * math.sin(yaw_rad) - dz = math.sin(pitch_rad) - target = (pos[0] + dx, pos[1] + dy, pos[2] + dz) - self._capture.update_camera(pos, target) + ) def render_rgb_array(self) -> np.ndarray | None: - """Return an RGB frame for the resolved backend. Fails if backend is unavailable.""" - if self._backend is None or self._capture is None: + """Return an RGB frame for the resolved backend.""" + if self._use_newton_visualizer and self._capture is None: + visualizer = next( + ( + visualizer + for visualizer in self._scene.sim.visualizers + if visualizer.cfg.visualizer_type == "newton" + ), + None, + ) + if visualizer is None: + raise RuntimeError("The Newton visualizer was selected for video capture but is not initialized.") + self._capture = cast("NewtonVisualizer", visualizer) + if self._capture is None: return None - if self._matched_visualizer == "newton": - # Newton GL camera state lives in the capture object and must be synced each frame - # to follow interactive viewer movement. - self._sync_newton_camera() - # Kit capture uses the configured eye/lookat applied to the recording camera at construction time. return self._capture.render_rgb_array() diff --git a/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py b/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py index f795ad114aa..e6268429cbf 100644 --- a/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py +++ b/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py @@ -36,12 +36,15 @@ class VideoRecorderCfg: """Perspective camera position in world space (metres). Direct RL / MARL and manager-based RL environments overwrite this from - :attr:`~isaaclab.envs.common.ViewerCfg.eye` before recording so ``--video`` matches the - task viewport for both Kit (PhysX / Isaac RTX) and Newton GL (Newton / OVRTX / etc.). + :attr:`~isaaclab.envs.common.ViewerCfg.eye`. Kit and renderer-selected Newton capture use this + value. Visualizer-selected Newton capture uses the active visualizer camera instead. """ lookat: tuple[float, float, float] = (0.0, 0.0, 0.0) - """Perspective camera look-at target in world space (metres). Set from ``ViewerCfg.lookat`` at env init.""" + """Perspective camera look-at target in world space (metres). + + Visualizer-selected Newton capture uses the active visualizer camera instead. + """ backend_source: Literal["visualizer", "renderer"] = "visualizer" """Source used to resolve the video capture backend. @@ -52,7 +55,13 @@ class VideoRecorderCfg: """ window_width: int = 1280 - """Width in pixels of the recorded frame.""" + """Width of the recorded frame in pixels. + + Visualizer-selected Newton capture uses the active visualizer width instead. + """ window_height: int = 720 - """Height in pixels of the recorded frame.""" + """Height of the recorded frame in pixels. + + Visualizer-selected Newton capture uses the active visualizer height instead. + """ diff --git a/source/isaaclab/isaaclab/managers/termination_manager.py b/source/isaaclab/isaaclab/managers/termination_manager.py index 0a557df628a..194d57ddcc5 100644 --- a/source/isaaclab/isaaclab/managers/termination_manager.py +++ b/source/isaaclab/isaaclab/managers/termination_manager.py @@ -127,14 +127,14 @@ def terminated(self) -> torch.Tensor: """ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor]: - """Returns the episodic counts of individual termination terms. + """Returns the per-term mean activation across environments. Args: env_ids: The environment ids. Defaults to None, in which case all environments are considered. Returns: - Dictionary of episodic sum of individual reward terms. + Dictionary mapping each termination term to its mean activation. """ # resolve environment ids if env_ids is None: @@ -145,7 +145,7 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor] for i, key in enumerate(self._term_names): # store information extras["Episode_Termination/" + key] = last_episode_done_stats[i].item() - # reset all the reward terms + # reset all the termination terms for term_cfg in self._class_term_cfgs: term_cfg.func.reset(env_ids=env_ids) # return logged information diff --git a/source/isaaclab/isaaclab/markers/visualization_markers.py b/source/isaaclab/isaaclab/markers/visualization_markers.py index ef9eb65e992..c02cbcab60e 100644 --- a/source/isaaclab/isaaclab/markers/visualization_markers.py +++ b/source/isaaclab/isaaclab/markers/visualization_markers.py @@ -256,8 +256,16 @@ def _ensure_backends_initialized(self) -> None: self._ensure_kit_backend() return - needs_kit_backend = sim.is_rendering or any( - viz.supports_markers() and viz.pumps_app_update() and viz.cfg.enable_markers for viz in sim.visualizers + # Markers need the Kit (USD) backend to appear in any rendered frame: continuous rendering + # (``is_rendering``), headless offscreen video capture (``has_offscreen_render``), or a + # Kit-pumping visualizer. Offscreen is excluded from ``is_rendering`` (see + # :attr:`~isaaclab.sim.SimulationContext.is_rendering`), so it is checked explicitly here. + needs_kit_backend = ( + sim.is_rendering + or getattr(sim, "has_offscreen_render", False) + or any( + viz.supports_markers() and viz.pumps_app_update() and viz.cfg.enable_markers for viz in sim.visualizers + ) ) if needs_kit_backend: self._ensure_kit_backend() diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 5255af87360..3ea6abcd9ee 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -14,6 +14,9 @@ from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar +from isaaclab.sim.utils.stage import get_current_stage +from isaaclab.utils._device import set_cuda_device + if TYPE_CHECKING: from isaaclab.scene_data import SceneDataBackend from isaaclab.sim.simulation_context import SimulationContext @@ -99,6 +102,110 @@ def provides_implicit_damping(cls) -> bool: """ return True + @classmethod + def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: + """Ensure that an articulation root has one enabled world fixed joint. + + The base implementation leaves the root in place. Backends whose parser requires a different + root topology may relocate it and return the resulting root prim. + + Args: + articulation_prim: The articulation-root prim to fix. + stage: The stage containing the prim. Defaults to the current stage. + + Returns: + The articulation-root prim after backend normalization. + + Raises: + NotImplementedError: If a new joint is needed and the root is not a rigid body. + """ + # Keep this import local to avoid the SimulationContext -> PhysicsManager -> + # sim.utils.queries -> SimulationContext import cycle. + # Keep pxr local as well: this module is imported while environment configs load (via the + # manager classes), and config loading must not pull USD/omni modules before the simulation + # app starts. + from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 + + from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 + + if stage is None: + stage = get_current_stage() + root_path = articulation_prim.GetPath().pathString + joint = find_global_fixed_joint_prim(root_path, stage=stage) + if joint is not None: + joint.GetJointEnabledAttr().Set(True) + return articulation_prim + if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise NotImplementedError(f"Cannot fix non-rigid articulation root '{root_path}'.") + + joint_path = f"{root_path}/FixedJoint" + index = 0 + while stage.GetPrimAtPath(joint_path).IsValid(): + index += 1 + joint_path = f"{root_path}/FixedJoint{index}" + + world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() + joint = UsdPhysics.FixedJoint.Define(stage, joint_path) + joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) + joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) + joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) + return articulation_prim + + @staticmethod + def _relocate_articulation_root( + articulation_prim: Any, + companion_schema: str, + companion_namespace: str, + ) -> Any: + """Move root-bearing schemas and authored properties to the root link's parent.""" + # Keep pxr local: this module is imported while environment configs load (via the manager + # classes), and config loading must not pull USD/omni modules before the simulation app + # starts. + from pxr import Usd, UsdPhysics # noqa: PLC0415 + + new_root = articulation_prim.GetParent() + if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): + raise RuntimeError( + f"Cannot relocate '{articulation_prim.GetPath()}' to existing articulation root '{new_root.GetPath()}'." + ) + + registry = Usd.SchemaRegistry() + root_schema = UsdPhysics.Tokens.PhysicsArticulationRootAPI + schemas_to_move = [] + for schema_name in articulation_prim.GetPrimTypeInfo().GetAppliedAPISchemas(): + definition = registry.FindAppliedAPIPrimDefinition(schema_name) + if schema_name == companion_schema: + properties = list(articulation_prim.GetAuthoredPropertiesInNamespace(companion_namespace)) + elif schema_name == root_schema or ( + definition is not None and root_schema in definition.GetAppliedAPISchemas() + ): + properties = [] + if definition is not None: + for property_name in definition.GetPropertyNames(): + prop = articulation_prim.GetProperty(property_name) + if prop and prop.IsAuthored(): + properties.append(prop) + else: + continue + schemas_to_move.append((schema_name, properties)) + + for schema_name, properties in schemas_to_move: + if not new_root.AddAppliedSchema(schema_name): + raise RuntimeError(f"Failed to apply '{schema_name}' to '{new_root.GetPath()}'.") + for prop in properties: + if not prop.FlattenTo(new_root): + raise RuntimeError(f"Failed to move '{prop.GetPath()}' to '{new_root.GetPath()}'.") + for schema_name, _ in schemas_to_move: + if not articulation_prim.RemoveAppliedSchema(schema_name): + raise RuntimeError(f"Failed to remove '{schema_name}' from '{articulation_prim.GetPath()}'.") + if articulation_prim.HasAPI(UsdPhysics.ArticulationRootAPI) or not new_root.HasAPI( + UsdPhysics.ArticulationRootAPI + ): + raise RuntimeError( + f"Failed to relocate articulation root '{articulation_prim.GetPath()}' to '{new_root.GetPath()}'." + ) + return new_root + @classmethod def register_callback( cls, @@ -271,6 +378,12 @@ def initialize(cls, sim_context: SimulationContext) -> None: PhysicsManager._device = sim_context.cfg.device PhysicsManager._sim_time = 0.0 + # Synchronize the process-wide CUDA device before backend-specific + # initialization allocates state. PyTorch must select the device before + # Warp so that both runtimes retain the same primary CUDA context. + if "cuda" in PhysicsManager._device: + set_cuda_device(PhysicsManager._device) + @classmethod @abstractmethod def reset(cls, soft: bool = False) -> None: diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index a6e49883350..fecea42d2a6 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -46,6 +46,20 @@ def __init__(self) -> None: self._prepared_num_envs: int | None = None self._last_transforms_step: int | None = None + def _check_global_settings_compatible(self, cfg: RendererCfg) -> None: + """Reject conflicting process-global renderer settings.""" + if getattr(cfg, "renderer_type", None) != "isaac_rtx" or not hasattr(cfg, "global_settings"): + return + for stored_cfg, _renderer in self._renderer_entries: + if getattr(stored_cfg, "renderer_type", None) != "isaac_rtx" or not hasattr(stored_cfg, "global_settings"): + continue + if stored_cfg.global_settings != cfg.global_settings: + raise ValueError( + "Isaac RTX global settings differ across camera renderer configs. " + "These settings are process-global; configure the same " + "IsaacRtxRendererCfg.global_settings for every Isaac RTX camera." + ) + def get_renderer(self, cfg: RendererCfg) -> BaseRenderer: """Return a backend for this configuration, reusing a matching instance if present. @@ -58,6 +72,7 @@ def get_renderer(self, cfg: RendererCfg) -> BaseRenderer: Returns: A shared or newly created renderer backend. """ + self._check_global_settings_compatible(cfg) for stored_cfg, r in self._renderer_entries: if type(stored_cfg) is type(cfg) and stored_cfg == cfg: return r diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 11df307b235..05500207663 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 ba4c051920e..8d770a3da57 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/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 5ed97a3825f..58cf4423eaf 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -376,7 +376,8 @@ def set_world_poses( orientations = convert_camera_frame_orientation_convention(orientations, origin=convention, target="opengl") ori_wp = wp.from_torch(orientations.contiguous(), dtype=wp.vec4f) idx_wp = self._resolve_env_ids_wp(env_ids) - self._view.set_world_poses(pos_wp, ori_wp, idx_wp) + with self._view.xform_world_space_writer() as writer: + writer.set_poses(pos_wp, ori_wp, idx_wp) def set_world_poses_from_view( self, eyes: torch.Tensor, targets: torch.Tensor, env_ids: Sequence[int] | None = None @@ -434,11 +435,12 @@ def set_world_poses_from_view( env_ids_torch = env_ids_torch.index_select(0, valid_indices) orientations = quat_from_matrix(rotation_matrix) idx_wp = wp.from_torch(env_ids_torch.contiguous(), dtype=wp.int32) - self._view.set_world_poses( - wp.from_torch(eyes.contiguous(), dtype=wp.vec3f), - wp.from_torch(orientations.contiguous(), dtype=wp.vec4f), - idx_wp, - ) + with self._view.xform_world_space_writer() as writer: + writer.set_poses( + wp.from_torch(eyes.contiguous(), dtype=wp.vec3f), + wp.from_torch(orientations.contiguous(), dtype=wp.vec4f), + idx_wp, + ) """ Operations diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index 8b885a91d14..4d011f647d1 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 @@ -188,11 +185,13 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None inputs=[env_mask, self._is_outdated, self._timestamp, self._timestamp_last_update], device=self._device, ) + self._data_generation += 1 def update(self, dt: float, force_recompute: bool = False): # Skip update if sensor is not initialized if not self._is_initialized: return + self._data_generation += 1 # Update the timestamp for the sensors wp.launch( update_timestamp_kernel, @@ -208,7 +207,7 @@ def update(self, dt: float, force_recompute: bool = False): ) # Update the buffers if force_recompute or self._is_visualizing: - self._update_outdated_buffers() + self._update_outdated_buffers(force_recompute=force_recompute) """ Implementation specific. @@ -231,6 +230,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 = [] @@ -255,6 +256,8 @@ def _initialize_impl(self): self._is_outdated = wp.ones(self._num_envs, dtype=wp.bool, device=self._device) self._timestamp = wp.zeros(self._num_envs, dtype=wp.float32, device=self._device) self._timestamp_last_update = wp.zeros_like(self._timestamp) + self._data_generation = 0 + self._data_generation_last_update = -1 # Initialize debug visualization handle if self._debug_vis_handle is None: @@ -390,8 +393,10 @@ def _clear_callbacks(self) -> None: Helper functions. """ - def _update_outdated_buffers(self): + def _update_outdated_buffers(self, force_recompute: bool = False) -> None: """Fills the sensor data for the outdated sensors.""" + if not force_recompute and self._data_generation == self._data_generation_last_update: + return self._update_buffers_impl(self._is_outdated) # update timestamps and clear outdated flags wp.launch( @@ -400,6 +405,7 @@ def _update_outdated_buffers(self): inputs=[self._is_outdated, self._timestamp, self._timestamp_last_update], device=self._device, ) + self._data_generation_last_update = self._data_generation def _resolve_indices_and_mask( self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None @@ -457,6 +463,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/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 05b76be4270..1b52e8f6cfc 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -4,7 +4,6 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ - "RenderCfg", "SimulationCfg", "SimulationContext", "build_simulation_context", @@ -39,12 +38,17 @@ __all__ = [ "BoundingCubePropertiesCfg", "BoundingSpherePropertiesCfg", "CollisionBaseCfg", + "CollisionFragment", "ConvexDecompositionPropertiesCfg", "ConvexHullPropertiesCfg", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", + "FixedTendonFragment", "FixedTendonPropertiesCfg", "JointDriveBaseCfg", + "MassCfg", + "MassFragment", + "JointDriveFragment", "MassPropertiesCfg", "MeshCollisionPropertiesCfg", "MujocoJointDrivePropertiesCfg", @@ -56,9 +60,29 @@ __all__ = [ "NewtonMeshCollisionPropertiesCfg", "NewtonRigidBodyPropertiesCfg", "NewtonSDFCollisionPropertiesCfg", + "MeshCollisionFragment", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyPropertiesCfg", + "ArticulationRootFragment", "RigidBodyBaseCfg", + "RigidBodyFragment", + "SchemaFragment", + "SpatialTendonFragment", + "UsdPhysicsCollisionCfg", + "UsdPhysicsMeshCollisionCfg", + "UsdPhysicsRigidBodyCfg", + "apply_articulation_root_properties", + "apply_collision_properties", + "apply_fixed_tendon_properties", + "apply_mass_properties", + "UsdPhysicsDriveCfg", + "apply_drive", + "apply_joint_drive_properties", + "apply_mesh_collision", + "apply_mesh_collision_properties", + "apply_namespaced", + "apply_rigid_body_properties", + "apply_spatial_tendon_properties", "SDFMeshPropertiesCfg", "SpatialTendonPropertiesCfg", "TriangleMeshPropertiesCfg", @@ -192,8 +216,6 @@ __all__ = [ "XformPrimView", ] -from .simulation_cfg import RenderCfg, SimulationCfg -from .simulation_context import SimulationContext, build_simulation_context from .converters import ( AssetConverterBase, AssetConverterBaseCfg, @@ -208,42 +230,69 @@ from .schemas import ( MESH_APPROXIMATION_TOKENS, PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, - activate_contact_sensors, - define_articulation_root_properties, - define_collision_properties, - define_deformable_body_properties, - define_mass_properties, - define_mesh_collision_properties, - define_rigid_body_properties, - modify_articulation_root_properties, - modify_collision_properties, - modify_deformable_body_properties, - modify_fixed_tendon_properties, - modify_joint_drive_properties, - modify_mass_properties, - modify_mesh_collision_properties, - modify_rigid_body_properties, - modify_spatial_tendon_properties, + ArticulationRootFragment, ArticulationRootPropertiesCfg, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, CollisionBaseCfg, + CollisionFragment, ConvexDecompositionPropertiesCfg, ConvexHullPropertiesCfg, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, + FixedTendonFragment, FixedTendonPropertiesCfg, JointDriveBaseCfg, + MassCfg, + MassFragment, + JointDriveFragment, MassPropertiesCfg, + MeshCollisionFragment, MeshCollisionPropertiesCfg, PhysxJointDrivePropertiesCfg, PhysxRigidBodyPropertiesCfg, RigidBodyBaseCfg, + RigidBodyFragment, + SchemaFragment, SDFMeshPropertiesCfg, + SpatialTendonFragment, SpatialTendonPropertiesCfg, TriangleMeshPropertiesCfg, TriangleMeshSimplificationPropertiesCfg, + UsdPhysicsCollisionCfg, + UsdPhysicsMeshCollisionCfg, + UsdPhysicsRigidBodyCfg, + activate_contact_sensors, + apply_articulation_root_properties, + apply_collision_properties, + apply_fixed_tendon_properties, + apply_mass_properties, + UsdPhysicsDriveCfg, + apply_drive, + apply_joint_drive_properties, + apply_mesh_collision, + apply_mesh_collision_properties, + apply_namespaced, + apply_rigid_body_properties, + apply_spatial_tendon_properties, + define_articulation_root_properties, + define_collision_properties, + define_deformable_body_properties, + define_mass_properties, + define_mesh_collision_properties, + define_rigid_body_properties, + modify_articulation_root_properties, + modify_collision_properties, + modify_deformable_body_properties, + modify_fixed_tendon_properties, + modify_joint_drive_properties, + modify_mass_properties, + modify_mesh_collision_properties, + modify_rigid_body_properties, + modify_spatial_tendon_properties, ) +from .simulation_cfg import SimulationCfg +from .simulation_context import SimulationContext, build_simulation_context # Forwarded to isaaclab_newton.sim.schemas via __getattr__ shim MujocoJointDrivePropertiesCfg = ... @@ -256,46 +305,22 @@ NewtonMeshCollisionPropertiesCfg = ... NewtonRigidBodyPropertiesCfg = ... NewtonSDFCollisionPropertiesCfg = ... from .spawners import ( - SpawnerCfg, - RigidObjectSpawnerCfg, - DeformableObjectSpawnerCfg, - spawn_from_mjcf, - spawn_from_urdf, - spawn_from_usd, - spawn_from_usd_with_compliant_contact_material, - spawn_ground_plane, - GroundPlaneCfg, - MjcfFileCfg, - UrdfFileCfg, - UsdFileCfg, - UsdFileWithCompliantContactCfg, - spawn_light, + CapsuleCfg, + ConeCfg, + CuboidCfg, + CylinderCfg, CylinderLightCfg, + DeformableBodyMaterialBaseCfg, + DeformableBodyMaterialCfg, + DeformableObjectSpawnerCfg, DiskLightCfg, DistantLightCfg, DomeLightCfg, - LightCfg, - SphereLightCfg, - spawn_rigid_body_material, - spawn_deformable_body_material, - PhysicsMaterialCfg, - RigidBodyMaterialCfg, - DeformableBodyMaterialBaseCfg, - DeformableBodyMaterialCfg, - SurfaceDeformableBodyMaterialBaseCfg, - SurfaceDeformableBodyMaterialCfg, - spawn_from_mdl_file, - spawn_preview_surface, + FisheyeCameraCfg, GlassMdlCfg, + GroundPlaneCfg, + LightCfg, MdlFileCfg, - PreviewSurfaceCfg, - VisualMaterialCfg, - spawn_mesh_capsule, - spawn_mesh_cone, - spawn_mesh_cuboid, - spawn_mesh_cylinder, - spawn_mesh_rectangle, - spawn_mesh_sphere, MeshCapsuleCfg, MeshCfg, MeshConeCfg, @@ -303,83 +328,111 @@ from .spawners import ( MeshCylinderCfg, MeshRectangleCfg, MeshSphereCfg, - spawn_camera, - spawn_sensor_frame, - FisheyeCameraCfg, + MjcfFileCfg, + MultiAssetSpawnerCfg, + MultiUsdFileCfg, + PhysicsMaterialCfg, PinholeCameraCfg, + PreviewSurfaceCfg, + RigidBodyMaterialCfg, + RigidObjectSpawnerCfg, SensorFrameCfg, + ShapeCfg, + SpawnerCfg, + SphereCfg, + SphereLightCfg, + SurfaceDeformableBodyMaterialBaseCfg, + SurfaceDeformableBodyMaterialCfg, + UrdfFileCfg, + UsdFileCfg, + UsdFileWithCompliantContactCfg, + VisualMaterialCfg, + spawn_camera, spawn_capsule, spawn_cone, spawn_cuboid, spawn_cylinder, - spawn_sphere, - CapsuleCfg, - ConeCfg, - CuboidCfg, - CylinderCfg, - ShapeCfg, - SphereCfg, + spawn_deformable_body_material, + spawn_from_mdl_file, + spawn_from_mjcf, + spawn_from_urdf, + spawn_from_usd, + spawn_from_usd_with_compliant_contact_material, + spawn_ground_plane, + spawn_light, + spawn_mesh_capsule, + spawn_mesh_cone, + spawn_mesh_cuboid, + spawn_mesh_cylinder, + spawn_mesh_rectangle, + spawn_mesh_sphere, spawn_multi_asset, spawn_multi_usd_file, - MultiAssetSpawnerCfg, - MultiUsdFileCfg, + spawn_preview_surface, + spawn_rigid_body_material, + spawn_sensor_frame, + spawn_sphere, ) from .utils import ( + add_labels, add_reference_to_stage, - get_stage_up_axis, - traverse_stage, - get_prim_at_path, - get_prim_path, - is_prim_path_valid, - define_prim, - get_prim_type_name, - get_next_free_path, + add_usd_reference, + apply_nested, + bind_physics_material, + bind_visual_material, + change_prim_property, + check_missing_labels, + clear_stage, + clone, + close_stage, + convert_world_pose_to_local, + count_total_labels, + create_new_stage, create_prim, + define_prim, delete_prim, - make_uninstanceable, - set_prim_visibility, - safe_set_attribute_on_usd_schema, - safe_set_attribute_on_usd_prim, - change_prim_property, export_prim_to_file, - apply_nested, - clone, - bind_visual_material, - bind_physics_material, - add_usd_reference, - get_usd_references, - select_usd_variants, - get_next_free_prim_path, - get_first_matching_ancestor_prim, - get_first_matching_child_prim, - get_all_matching_child_prims, find_first_matching_prim, - find_matching_prims, - matches_path_expr_prefix, - resolve_matching_prims_from_source, - find_matching_prim_paths, find_global_fixed_joint_prim, - add_labels, + find_matching_prim_paths, + find_matching_prims, + get_all_matching_child_prims, + get_current_stage, + get_current_stage_id, + get_first_matching_ancestor_prim, + get_first_matching_child_prim, get_labels, - remove_labels, - check_missing_labels, - count_total_labels, - resolve_paths, - create_new_stage, + get_next_free_path, + get_next_free_prim_path, + get_prim_at_path, + get_prim_path, + get_prim_type_name, + get_stage_up_axis, + get_usd_references, is_current_stage_in_memory, + is_prim_path_valid, + make_uninstanceable, + matches_path_expr_prefix, open_stage, - use_stage, - update_stage, + remove_labels, + resolve_matching_prims_from_source, + resolve_paths, + resolve_prim_pose, + resolve_prim_scale, + safe_set_attribute_on_usd_prim, + safe_set_attribute_on_usd_schema, save_stage, - close_stage, - clear_stage, - get_current_stage, - get_current_stage_id, + select_usd_variants, + set_prim_visibility, standardize_xform_ops, + traverse_stage, + update_stage, + use_stage, validate_standard_xform_ops, - resolve_prim_pose, - resolve_prim_scale, - convert_world_pose_to_local, ) -from .views import BaseFrameView, UsdFrameView, FrameView -from .views import XformPrimView # deprecated alias +from .views import ( + BaseFrameView, + FrameView, + UsdFrameView, + XformPrimView, # deprecated alias +) diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py index 74ba8b470c3..186811143b7 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py @@ -15,6 +15,7 @@ from isaaclab.sim.converters.asset_converter_base import AssetConverterBase from isaaclab.sim.converters.mesh_converter_cfg import MeshConverterCfg from isaaclab.sim.schemas import schemas +from isaaclab.sim.schemas.schemas_cfg import SchemaFragment from isaaclab.sim.utils import delete_prim, export_prim_to_file # import logger @@ -126,14 +127,34 @@ def _convert_asset(self, cfg: MeshConverterCfg): # Apply collider properties to mesh if cfg.collision_props is not None: # -- Collider properties such as offset, scale, etc. - schemas.define_collision_properties( - prim_path=child_mesh_prim.GetPath(), cfg=cfg.collision_props, stage=stage + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* + coll_frags = ( + cfg.collision_props if isinstance(cfg.collision_props, (list, tuple)) else [cfg.collision_props] ) + if coll_frags and all(isinstance(f, SchemaFragment) for f in coll_frags): + schemas.apply_collision_properties(str(child_mesh_prim.GetPath()), coll_frags, stage=stage) + else: + schemas.define_collision_properties( + prim_path=child_mesh_prim.GetPath(), cfg=cfg.collision_props, stage=stage + ) # Add collision mesh if cfg.mesh_collision_props is not None: - schemas.define_mesh_collision_properties( - prim_path=child_mesh_prim.GetPath(), cfg=cfg.mesh_collision_props, stage=stage + # Transition bridge: route a fragment (or list of fragments) through the new + # ``apply_mesh_collision_properties`` family writer; otherwise fall back to the + # legacy single-cfg ``define_mesh_collision_properties`` path. + mesh_collision_frags = ( + cfg.mesh_collision_props + if isinstance(cfg.mesh_collision_props, (list, tuple)) + else [cfg.mesh_collision_props] ) + if all(isinstance(f, SchemaFragment) for f in mesh_collision_frags): + schemas.apply_mesh_collision_properties( + prim_path=child_mesh_prim.GetPath(), fragments=mesh_collision_frags, stage=stage + ) + else: + schemas.define_mesh_collision_properties( + prim_path=child_mesh_prim.GetPath(), cfg=cfg.mesh_collision_props, stage=stage + ) # Delete the old Xform and make the new Xform the default prim stage.SetDefaultPrim(xform_prim) # Apply default Xform rotation to mesh -> enable to set rotation and scale @@ -182,12 +203,21 @@ def _convert_asset(self, cfg: MeshConverterCfg): # Apply mass and rigid body properties after everything else # Properties are applied to the top level prim to avoid the case where all instances of this # asset unintentionally share the same rigid body properties - # apply mass properties + # apply mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.mass_props is not None: - schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) - # apply rigid body properties + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(str(xform_prim.GetPath()), mass_frags, stage=stage) + else: + schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) + # apply rigid body properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.rigid_props is not None: - schemas.define_rigid_body_properties(prim_path=xform_prim.GetPath(), cfg=cfg.rigid_props, stage=stage) + rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + if rigid_frags and all(isinstance(f, SchemaFragment) for f in rigid_frags): + schemas.apply_rigid_body_properties(str(xform_prim.GetPath()), rigid_frags, stage=stage) + else: + schemas.define_rigid_body_properties(prim_path=xform_prim.GetPath(), cfg=cfg.rigid_props, stage=stage) # Save changes to USD stage stage.Save() diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py index 73ec37e777b..df25b5cba3f 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py @@ -26,14 +26,35 @@ class MeshConverterCfg(AssetConverterBaseCfg): If None, then no rigid body properties will be added. """ - collision_props: schemas_cfg.CollisionPropertiesCfg = None + collision_props: ( + schemas_cfg.CollisionPropertiesCfg | schemas_cfg.CollisionFragment | list[schemas_cfg.CollisionFragment] + ) = None """Collision properties to apply to the USD. Defaults to None. + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.CollisionBaseCfg`) or a + list of :class:`~isaaclab.sim.schemas.CollisionFragment` fragments. When a fragment list is + given, ``UsdPhysics.CollisionAPI`` is applied as the implicit anchor and each fragment writes + its own namespace. + Note: If None, then no collision properties will be added. """ - mesh_collision_props: schemas_cfg.MeshCollisionBaseCfg = None + mesh_collision_props: ( + schemas_cfg.MeshCollisionBaseCfg + | schemas_cfg.MeshCollisionFragment + | list[schemas_cfg.MeshCollisionFragment] + | None + ) = None """Mesh approximation properties to apply to all collision meshes in the USD. + + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg` or + a ``Physx*PropertiesCfg`` cooking cfg) or a list of + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` fragments (e.g. + ``[UsdPhysicsMeshCollisionCfg(...), PhysxConvexHullCfg(...)]``). When a fragment list is given, + ``UsdPhysics.MeshCollisionAPI`` is applied as the implicit anchor, the ``physics:approximation`` + token is resolved from whichever cooking fragment is present, and each fragment writes its own + namespace. + Note: If None, then no mesh approximation properties will be added. """ diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index 49eff741c8c..a735bf638e1 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -8,6 +8,17 @@ __all__ = [ "PHYSX_MESH_COLLISION_CFGS", "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", + "apply_articulation_root_properties", + "apply_collision_properties", + "apply_fixed_tendon_properties", + "apply_mass_properties", + "apply_drive", + "apply_joint_drive_properties", + "apply_mesh_collision", + "apply_mesh_collision_properties", + "apply_namespaced", + "apply_rigid_body_properties", + "apply_spatial_tendon_properties", "define_actuator_properties", "define_articulation_root_properties", "define_collision_properties", @@ -28,11 +39,25 @@ __all__ = [ "BoundingCubePropertiesCfg", "BoundingSpherePropertiesCfg", "CollisionBaseCfg", + "CollisionFragment", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", + "FixedTendonFragment", "JointDriveBaseCfg", + "MassCfg", + "MassFragment", + "JointDriveFragment", "MassPropertiesCfg", "MeshCollisionBaseCfg", + "ArticulationRootFragment", + "MeshCollisionFragment", + "RigidBodyFragment", + "SchemaFragment", + "SpatialTendonFragment", + "UsdPhysicsCollisionCfg", + "UsdPhysicsDriveCfg", + "UsdPhysicsMeshCollisionCfg", + "UsdPhysicsRigidBodyCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyPropertiesCfg", "NewtonArticulationRootPropertiesCfg", @@ -50,6 +75,17 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_articulation_root_properties, + apply_collision_properties, + apply_fixed_tendon_properties, + apply_mass_properties, + apply_drive, + apply_joint_drive_properties, + apply_mesh_collision, + apply_mesh_collision_properties, + apply_namespaced, + apply_rigid_body_properties, + apply_spatial_tendon_properties, define_articulation_root_properties, define_collision_properties, define_deformable_body_properties, @@ -71,15 +107,29 @@ from .schemas_actuators import ( ) from .schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, CollisionBaseCfg, + CollisionFragment, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, + FixedTendonFragment, JointDriveBaseCfg, + MassCfg, + MassFragment, + JointDriveFragment, MassPropertiesCfg, MeshCollisionBaseCfg, + MeshCollisionFragment, RigidBodyBaseCfg, + RigidBodyFragment, + SchemaFragment, + SpatialTendonFragment, + UsdPhysicsCollisionCfg, + UsdPhysicsDriveCfg, + UsdPhysicsMeshCollisionCfg, + UsdPhysicsRigidBodyCfg, ) # Forwarded to isaaclab_newton.sim.schemas via __getattr__ shim diff --git a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py new file mode 100644 index 00000000000..3c25d668a3f --- /dev/null +++ b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py @@ -0,0 +1,43 @@ +# 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 registration hooks for schema writers. + +This module holds the inversion-of-control registries that let physics backends (e.g. +``isaaclab_physx``, ``isaaclab_newton``) inject backend-specific behaviour into the core schema +writers without core importing any backend. It is deliberately kept free of ``pxr``/``omni`` imports +so a backend can register its hook at package-import time without eagerly pulling USD libraries into +an otherwise USD-free import path. +""" + +from __future__ import annotations + +from collections.abc import Callable + +# Backend-registered predicates that exclude a joint prim from joint-drive authoring. Backends (e.g. +# PhysX tendons) register here via :func:`register_joint_drive_skip_predicate` so the core joint-drive +# writers can skip backend-controlled joints without core carrying any backend-specific schema name. +_JOINT_DRIVE_SKIP_PREDICATES: list[Callable] = [] + + +def register_joint_drive_skip_predicate(predicate: Callable) -> None: + """Register a predicate that excludes a joint prim from joint-drive authoring. + + The joint-drive writers (:func:`~isaaclab.sim.schemas.apply_drive`, + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`) skip any joint for which a registered + predicate returns ``True``. This is the backend hook for cases like PhysX fixed tendons, where the + controlling backend owns certain joints and no drive should be authored on them -- the backend + registers its own detector so core needs no backend-specific knowledge. + + Args: + predicate: A callable ``predicate(prim) -> bool`` returning True to exclude the prim. + """ + if predicate not in _JOINT_DRIVE_SKIP_PREDICATES: + _JOINT_DRIVE_SKIP_PREDICATES.append(predicate) + + +def _skip_joint_drive(prim) -> bool: + """Return whether any backend-registered predicate excludes ``prim`` from joint-drive authoring.""" + return any(predicate(prim) for predicate in _JOINT_DRIVE_SKIP_PREDICATES) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 23f53b105cb..b9e59f7d70c 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -9,6 +9,7 @@ import dataclasses import logging import math +from collections.abc import Iterable import numpy as np import warp as wp @@ -16,7 +17,7 @@ from pxr import Sdf, Usd, UsdGeom, UsdPhysics from isaaclab.sim.utils.stage import get_current_stage -from isaaclab.utils.string import to_camel_case +from isaaclab.utils.string import string_to_callable, to_camel_case from ..utils import ( apply_nested, @@ -27,6 +28,7 @@ safe_set_attribute_on_usd_schema, ) from . import schemas_cfg +from ._backend_hooks import _skip_joint_drive # import logger logger = logging.getLogger(__name__) @@ -214,11 +216,169 @@ class that declares it (walking the MRO). Each group writes under that class's safe_set_attribute_on_usd_prim(prim, f"{namespace}:{usd_attr}", value, camel_case=False) +def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Default fragment applier: apply the fragment's schema and write its namespaced attrs. + + Reads :attr:`~isaaclab.sim.schemas.SchemaFragment._usd_namespace` / + :attr:`~isaaclab.sim.schemas.SchemaFragment._usd_applied_schema` from the cfg's class. If the + fragment owns an applied schema, it is applied (once). Each non-``None`` dataclass field is + written as ``:``; the ``func`` field is skipped. ``None`` fields + are left unchanged on the prim (partial update). + + Args: + cfg: The fragment instance carrying ``_usd_namespace`` / ``_usd_applied_schema`` metadata. + prim_path: The prim path to author on. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the properties were successfully set. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the legacy define_/modify_ writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + namespace = type(cfg)._usd_namespace + applied = type(cfg)._usd_applied_schema + # every fragment field is a namespaced USD attribute, so a namespace is required + if namespace is None: + raise ValueError( + f"Fragment '{type(cfg).__name__}' has no '_usd_namespace' set. Every fragment field is" + " authored as ':', so a USD namespace is required; non-USD state must" + " live on the spawner cfg or be passed as a writer keyword argument, not as a fragment" + " field." + ) + if applied and applied not in prim.GetAppliedSchemas(): + prim.AddAppliedSchema(applied) + for f in dataclasses.fields(cfg): + # ``func`` is the only non-USD field; non-scalar values raise in the setter + if f.name == "func": + continue + # ``mesh_approximation_name`` is not a namespaced attribute: it is the standard + # ``physics:approximation`` token, written by ``apply_mesh_collision_properties`` (the + # family writer) which validates it against ``MESH_APPROXIMATION_TOKENS``. Skip it here + # so a mesh-collision cooking fragment dispatched through this generic applier does not + # author a spurious ``:meshApproximationName`` attribute. + if f.name == "mesh_approximation_name": + continue + value = getattr(cfg, f.name) + if value is None: + continue + safe_set_attribute_on_usd_prim(prim, f"{namespace}:{to_camel_case(f.name, 'cC')}", value, camel_case=False) + return True + + """ Articulation root properties. """ +def apply_articulation_root_properties( + prim_path: str, + fragments: Iterable[schemas_cfg.ArticulationRootFragment], + stage: Usd.Stage | None = None, + fix_root_link: bool | None = None, +) -> bool: + """Apply fragments to every top-level articulation root under a prim. + + Existing roots are discovered before a fresh anchor is applied, including roots hidden in + instances. Nested roots are pruned, while sibling roots are all processed. Instance roots are + reported but not authored. + + When fix_root_link is True, the active physics manager creates or enables the world joint and + returns the backend's final root prim. False only disables an existing joint. + + Args: + prim_path: The prim path whose subtree is searched for articulation roots. + fragments: Articulation-root fragments to apply. + stage: The stage containing the prim. Defaults to the current stage. + fix_root_link: Whether to fix the root link. None leaves topology unchanged. + + Returns: + True if every writable root and fragment succeeds and no instance root is skipped. + + Raises: + TypeError: If fragments contains a non-articulation fragment. + ValueError: If prim_path is invalid. + RuntimeError: If fixing cannot resolve the active backend or relocate the root. + NotImplementedError: If the backend cannot fix the resolved root. + """ + fragments = list(fragments) + for fragment in fragments: + if not isinstance(fragment, schemas_cfg.ArticulationRootFragment): + raise TypeError( + f"Expected ArticulationRootFragment, got '{type(fragment).__name__}'." + " Pass legacy cfgs to modify_articulation_root_properties." + ) + dispatchers = [ + fragment.func if callable(fragment.func) else string_to_callable(fragment.func) for fragment in fragments + ] + if stage is None: + stage = get_current_stage() + + roots = [] + for candidate in get_all_matching_child_prims( + prim_path, + lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), + stage=stage, + traverse_instance_prims=True, + ): + if not any(candidate.GetPath().HasPrefix(root.GetPath()) for root in roots): + roots.append(candidate) + + writable_roots = [] + skipped_roots = [] + for root in roots: + if root.IsInstance() or root.IsInstanceProxy(): + skipped_roots.append(root) + else: + writable_roots.append(root) + + if not roots and fragments: + root = stage.GetPrimAtPath(prim_path) + if root.IsInstance() or root.IsInstanceProxy(): + skipped_roots.append(root) + else: + UsdPhysics.ArticulationRootAPI.Apply(root) + writable_roots.append(root) + + if skipped_roots: + logger.warning( + "Skipping articulation-root updates on instanced prims: %s.", + [root.GetPath().pathString for root in skipped_roots], + ) + if not writable_roots: + if fix_root_link is not None and not skipped_roots: + logger.warning( + "No articulation root found under '%s': ignoring fix_root_link=%s.", prim_path, fix_root_link + ) + return not skipped_roots and fix_root_link is None + + if fix_root_link: + from isaaclab.sim import SimulationContext + + sim = SimulationContext.instance() + if sim is None: + raise RuntimeError(f"Cannot fix articulation roots under '{prim_path}' without an active simulation.") + + success = not skipped_roots + for root in writable_roots: + if fix_root_link: + root = sim.physics_manager.fix_articulation_root(root, stage) + elif fix_root_link is False: + joint = find_global_fixed_joint_prim(root.GetPath().pathString, stage=stage) + if joint is not None: + joint.GetJointEnabledAttr().Set(False) + + root_path = root.GetPath().pathString + for fragment, func in zip(fragments, dispatchers): + success = bool(func(fragment, root_path, stage)) and success + + return success + + def define_articulation_root_properties( prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None ): @@ -386,6 +546,143 @@ def modify_articulation_root_properties( """ +def apply_rigid_body_properties( + prim_path: str, fragments: Iterable[schemas_cfg.RigidBodyFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of rigid-body fragments to a prim. + + Applies ``UsdPhysics.RigidBodyAPI`` as the implicit anchor (the defining schema for a rigid + body), then dispatches each fragment via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + Backend fragments carry backend-specific funcs, so core never imports a backend. + + Args: + prim_path: The prim path to apply the rigid-body schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.RigidBodyFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the properties were successfully set. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the legacy define_rigid_body_properties writer) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + if not UsdPhysics.RigidBodyAPI(prim): + UsdPhysics.RigidBodyAPI.Apply(prim) + # aggregate per-fragment results so a reported failure is not masked by the always-applied anchor + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + +def apply_mesh_collision( + cfg: schemas_cfg.MeshCollisionFragment, prim_path: str, stage: Usd.Stage | None = None +) -> bool: + """Apply a single mesh-collision fragment: its namespaced cooking attrs plus the shared token. + + This is the default :attr:`~isaaclab.sim.schemas.SchemaFragment.func` for every + :class:`~isaaclab.sim.schemas.MeshCollisionFragment`. Unlike the generic :func:`apply_namespaced`, + a mesh-collision fragment additionally authors the shared ``physics:approximation`` token (via the + standard ``UsdPhysics.MeshCollisionAPI``) on top of its own backend cooking namespace. + + The token is *not* a plain namespaced attribute -- it is shared state on the family anchor implied + by the present cooking fragment. Each fragment carries a :attr:`mesh_approximation_name` whose + default encodes the token its schema implies (e.g. ``"convexHull"`` for :class:`PhysxConvexHullCfg`, + ``"sdf"`` for :class:`PhysxSDFMeshCfg`). A name of ``"none"`` leaves the token unchanged, so when + several fragments are dispatched in order by :func:`apply_mesh_collision_properties` the last one + with a non-``"none"`` name wins -- this is how a core fragment composes with a backend cooking + fragment. The name is validated against :const:`MESH_APPROXIMATION_TOKENS`; an unknown name raises + ``ValueError``. :attr:`mesh_approximation_name` is skipped by :func:`apply_namespaced`, so it is + never authored as a spurious ``:meshApproximationName`` attribute. + + Args: + cfg: The mesh-collision fragment to apply. + prim_path: The prim path to author on. This prim should be a Mesh. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the fragment was applied successfully. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid, or when the fragment's mesh + approximation name is not in :const:`MESH_APPROXIMATION_TOKENS`. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # ensure the standard MeshCollisionAPI anchor (carrier of ``physics:approximation``) exists + if not UsdPhysics.MeshCollisionAPI(prim): + UsdPhysics.MeshCollisionAPI.Apply(prim) + # write the fragment's backend cooking namespace + applied schema; ``mesh_approximation_name`` is + # skipped by the generic applier as it is the shared token handled below + success = apply_namespaced(cfg, prim_path, stage) + # author the shared ``physics:approximation`` token this fragment implies; ``"none"`` leaves the + # token untouched so a later non-"none" fragment in a list dispatch wins + name = getattr(cfg, "mesh_approximation_name", None) + if name is not None and name != "none": + if name not in MESH_APPROXIMATION_TOKENS: + raise ValueError( + f"Invalid mesh approximation name: '{name}'. " + f"Valid options are: {list(MESH_APPROXIMATION_TOKENS.keys())}" + ) + safe_set_attribute_on_usd_schema( + UsdPhysics.MeshCollisionAPI(prim), "Approximation", MESH_APPROXIMATION_TOKENS[name], camel_case=False + ) + return success + + +def apply_mesh_collision_properties( + prim_path: str, fragments: Iterable[schemas_cfg.MeshCollisionFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of mesh-collision fragments to a prim. + + Applies ``UsdPhysics.MeshCollisionAPI`` as the implicit anchor (the carrier of the + ``physics:approximation`` token), then dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. The default mesh-collision func + (:func:`apply_mesh_collision`) authors both the fragment's backend cooking namespace and the + shared approximation token it implies, so composing a core fragment with a backend cooking + fragment lets the last fragment with a non-``"none"`` :attr:`mesh_approximation_name` set the + token. Backend cooking fragments carry their own funcs, so core never imports a backend. + + Args: + prim_path: The prim path to apply the mesh-collision schemas on. This prim should be a Mesh. + fragments: An iterable of :class:`~isaaclab.sim.schemas.MeshCollisionFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if all fragments applied successfully, False if any fragment reported failure. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid, or when a fragment's mesh + approximation name is not in :const:`MESH_APPROXIMATION_TOKENS`. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the sibling apply_* writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # apply the standard MeshCollisionAPI anchor (carrier of ``physics:approximation``) + if not UsdPhysics.MeshCollisionAPI(prim): + UsdPhysics.MeshCollisionAPI.Apply(prim) + # dispatch each fragment via its ``func`` (cooking-schema namespace + implied approximation + # token), aggregating per-fragment results so a reported failure is not masked by the anchor + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + def define_rigid_body_properties(prim_path: str, cfg: schemas_cfg.RigidBodyBaseCfg, stage: Usd.Stage | None = None): """Apply the rigid body schema on the input prim and set its properties. @@ -475,6 +772,36 @@ def modify_rigid_body_properties( """ +def apply_collision_properties( + prim_path: str, fragments: Iterable[schemas_cfg.CollisionFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of collision fragments to a prim. + + Applies ``UsdPhysics.CollisionAPI`` as the implicit anchor (the defining schema for a + collider), then dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Backend fragments carry backend-specific + funcs, so core never imports a backend. + + Args: + prim_path: The prim path to apply the collision schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.CollisionFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the properties were successfully set. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + if not UsdPhysics.CollisionAPI(prim): + UsdPhysics.CollisionAPI.Apply(prim) + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, prim_path, stage) + return True + + def define_collision_properties( prim_path: str, cfg: schemas_cfg.CollisionPropertiesCfg, stage: Usd.Stage | None = None ): @@ -569,6 +896,39 @@ def modify_collision_properties( """ +def apply_mass_properties( + prim_path: str, fragments: Iterable[schemas_cfg.MassFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of mass fragments to a prim. + + Applies ``UsdPhysics.MassAPI`` as the implicit anchor (the defining schema for mass properties), + then dispatches each fragment via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + + Args: + prim_path: The prim path to apply the mass schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.MassFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the properties were successfully set. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the legacy define_mass_properties writer) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + if not UsdPhysics.MassAPI(prim): + UsdPhysics.MassAPI.Apply(prim) + # aggregate per-fragment results so a reported failure is not masked by the always-applied anchor + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + def define_mass_properties(prim_path: str, cfg: schemas_cfg.MassPropertiesCfg, stage: Usd.Stage | None = None): """Apply the mass schema on the input prim and set its properties. @@ -677,33 +1037,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: @@ -730,6 +1082,226 @@ def activate_contact_sensors(prim_path: str, threshold: float = 0.0, stage: Usd. """ +def _drive_instance_name(prim) -> str | None: + """Return the ``UsdPhysics.DriveAPI`` instance for a joint prim, or ``None`` if it has no drive. + + Revolute joints use the ``"angular"`` instance, prismatic joints the ``"linear"`` instance; any + other prim type has no joint drive. Shared by :func:`apply_drive` and :func:`_ensure_drive_exists`. + + Args: + prim: The candidate joint prim. + + Returns: + ``"angular"``, ``"linear"``, or ``None`` when the prim is not a revolute/prismatic joint. + """ + if prim.IsA(UsdPhysics.RevoluteJoint): + return "angular" + if prim.IsA(UsdPhysics.PrismaticJoint): + return "linear" + return None + + +def apply_drive(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Apply a :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment to a single joint prim. + + This is the override ``func`` for the ``UsdPhysics.DriveAPI`` fragment: the drive attributes + live under a multi-instance schema, so the generic :func:`apply_namespaced` writer cannot be + used. The writer reproduces the solver-common drive logic of + :func:`modify_joint_drive_properties`: + + * Selects the drive instance: ``"angular"`` for a revolute joint, ``"linear"`` for a prismatic + joint. For any other prim type, the function is a no-op and returns ``False``. + * Skips joints excluded by a backend-registered predicate (see + :func:`register_joint_drive_skip_predicate`, e.g. PhysX tendon members), returning ``False``. + * Applies ``UsdPhysics.DriveAPI`` for the selected instance (presence-gated -- only applied when + this fragment is present). + * Converts angular-drive :attr:`stiffness` and :attr:`damping` from radians to degrees + (``NĀ·m/rad`` -> ``NĀ·m/deg`` and ``NĀ·mĀ·s/rad`` -> ``NĀ·mĀ·s/deg``); linear drives are written + as-is. + * Writes the typed ``drive::physics:{type,maxForce,stiffness,damping}`` attributes, + mapping the :attr:`drive_type` field to the USD attribute named ``type``. + + Args: + cfg: The :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment to apply. + prim_path: The joint prim path to author on. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the drive was applied to a joint prim, False if the prim is not a revolute or + prismatic joint (or is a tendon child). + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + + # select the drive instance based on the joint type + drive_api_name = _drive_instance_name(prim) + if drive_api_name is None: + return False + # skip joints a backend owns (e.g. PhysX tendon members); see register_joint_drive_skip_predicate + if _skip_joint_drive(prim): + return False + + # apply the multi-instance drive API (presence-gated anchor for the joint-drive family) + usd_drive_api = UsdPhysics.DriveAPI(prim, drive_api_name) + if not usd_drive_api: + usd_drive_api = UsdPhysics.DriveAPI.Apply(prim, drive_api_name) + + # gather the solver-common drive fields + drive_type = cfg.drive_type + max_force = cfg.max_force + stiffness = cfg.stiffness + damping = cfg.damping + + # angular drives use degree units in USD; convert stiffness/damping from radian units + if drive_api_name == "angular": + if stiffness is not None: + # N-m/rad --> N-m/deg + stiffness = stiffness * math.pi / 180.0 + if damping is not None: + # N-m-s/rad --> N-m-s/deg + damping = damping * math.pi / 180.0 + + # ``drive_type`` is a permanent inline carve-out: the USD attribute is named ``type`` + # (a Python keyword-like name we cannot use as a cfg field). All other solver-common + # joint-drive fields follow the snake_case = camelCase convention. + for field_name, value in ( + ("drive_type", drive_type), + ("max_force", max_force), + ("stiffness", stiffness), + ("damping", damping), + ): + if value is None: + continue + usd_attr_name = "type" if field_name == "drive_type" else field_name + safe_set_attribute_on_usd_schema(usd_drive_api, usd_attr_name, value, camel_case=True) + + return True + + +def apply_joint_drive_properties( + prim_path: str, fragments, stage: Usd.Stage | None = None, ensure_drives_exist: bool = False +) -> bool: + """Apply a list of joint-drive fragments to all joint prims under a prim path. + + Mirrors the recursion behaviour of :func:`modify_joint_drive_properties` (decorated with + :func:`~isaaclab.sim.utils.apply_nested`): the prim path and all its descendants are visited, + and the fragments are dispatched to every revolute/prismatic joint prim found. As soon as a + prim is successfully handled, its children are not descended into (nested joints are not + allowed). + + Unlike :func:`apply_rigid_body_properties`, the joint-drive family has no implicit anchor: + ``UsdPhysics.DriveAPI`` is *presence-gated* and applied only by :func:`apply_drive` when a + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present in ``fragments``. Each + fragment is dispatched via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, so backend + fragments carry backend-specific funcs and core never imports a backend. + + Args: + prim_path: The root prim path to search for joint prims under. + fragments: An iterable of :class:`~isaaclab.sim.schemas.JointDriveFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + ensure_drives_exist: If True, write a minimal stiffness (``1e-3``) to any drive whose + authored stiffness *and* damping are both zero, so that backends (e.g. Newton) treat + the drive as active. Reproduces the legacy + :attr:`~isaaclab.sim.schemas.JointDriveBaseCfg.ensure_drives_exist` behaviour. This is + a spawner-level flag, not a fragment field. + + Returns: + True if the fragments were applied to at least one joint prim, False otherwise. + """ + if stage is None: + stage = get_current_stage() + + fragments = list(fragments) + # detect whether a UsdPhysics.DriveAPI fragment is present (governs presence-gating + the + # ensure_drives_exist behaviour, which only makes sense for the solver-common drive fragment) + drive_cfg = next((f for f in fragments if isinstance(f, schemas_cfg.UsdPhysicsDriveCfg)), None) + + root_prim = stage.GetPrimAtPath(prim_path) + if not root_prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + + count_success = 0 + instanced_prim_paths = [] + all_prims = [root_prim] + while len(all_prims) > 0: + child_prim = all_prims.pop(0) + child_prim_path = child_prim.GetPath().pathString + # skip instanced prims (cannot author on prototypes) + if child_prim.IsInstance(): + instanced_prim_paths.append(child_prim_path) + continue + # a prim is a valid joint-drive target only if it is a revolute/prismatic joint + is_joint = child_prim.IsA(UsdPhysics.RevoluteJoint) or child_prim.IsA(UsdPhysics.PrismaticJoint) + if not is_joint: + all_prims += child_prim.GetChildren() + continue + # skip backend-owned joints wholesale (e.g. PhysX tendon members): no fragment may author on + # them. The backend registers the detector via register_joint_drive_skip_predicate, so this + # gate carries no backend-specific knowledge; descend into children to reach nested joints. + if _skip_joint_drive(child_prim): + all_prims += child_prim.GetChildren() + continue + # dispatch each fragment via its func + success = False + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + if func(cfg, child_prim_path, stage): + success = True + # seed a minimal stiffness only when the drive fragment authored neither stiffness nor + # damping and the resulting drive is fully passive + if ensure_drives_exist and drive_cfg is not None and success: + _ensure_drive_exists(drive_cfg, child_prim) + if success: + count_success += 1 + else: + all_prims += child_prim.GetChildren() + + if count_success == 0: + logger.warning( + f"Could not apply joint-drive properties on any prims under: '{prim_path}'." + " This might be because no revolute/prismatic joint prims were found, or they are" + f" instanced. Discovered list of instanced prim paths: {instanced_prim_paths}" + ) + return count_success > 0 + + +def _ensure_drive_exists(drive_cfg, prim) -> None: + """Seed a minimal stiffness on a fully-passive drive so backends treat it as active. + + Reproduces the legacy ``ensure_drives_exist`` behaviour: if the drive fragment authored + neither :attr:`stiffness` nor :attr:`damping` and the authored drive currently has zero + (or unset) stiffness *and* damping, write a minimal stiffness of ``1e-3`` directly to the + drive API (converted to degree units for angular drives, matching :func:`apply_drive`). The + fragment is not mutated, so this is safe across multiple joint prims sharing one fragment. + + Args: + drive_cfg: The :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment. + prim: The joint prim being authored. + """ + if drive_cfg.stiffness is not None or drive_cfg.damping is not None: + return + drive_api_name = _drive_instance_name(prim) + if drive_api_name is None: + return + usd_drive_api = UsdPhysics.DriveAPI(prim, drive_api_name) + if not usd_drive_api: + usd_drive_api = UsdPhysics.DriveAPI.Apply(prim, drive_api_name) + cur_stiffness = usd_drive_api.GetStiffnessAttr().Get() + cur_damping = usd_drive_api.GetDampingAttr().Get() + if (cur_stiffness is None or cur_stiffness == 0.0) and (cur_damping is None or cur_damping == 0.0): + # mirror the legacy writer: 1e-3 is set before the rad->deg conversion, so an angular + # drive ends up with ``1e-3 * pi / 180``. + stiffness = 1e-3 + if drive_api_name == "angular": + stiffness = stiffness * math.pi / 180.0 + safe_set_attribute_on_usd_schema(usd_drive_api, "stiffness", stiffness, camel_case=True) + + @apply_nested def modify_joint_drive_properties( prim_path: str, cfg: schemas_cfg.JointDriveBaseCfg, stage: Usd.Stage | None = None @@ -845,6 +1417,48 @@ def modify_joint_drive_properties( """ +def apply_fixed_tendon_properties( + prim_path: str, fragments: Iterable[schemas_cfg.FixedTendonFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of fixed-tendon fragments to a prim. + + Fixed tendons are a *tune-not-apply* family: the applied ``PhysxTendonAxisRootAPI`` + multi-instance schemas already exist on the prim (authored in the source asset). This writer + therefore applies no anchor schema; it only dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, which tunes the existing instances. + Backend fragments carry backend-specific funcs, so core never imports a backend. + + Each fragment tunes only its own schema and returns ``False`` when that schema is not + present on the prim. A prim carries a single tendon backend, so compose backends across + prims rather than mixing PhysX and Mujoco fragments in one list on one prim. + + Args: + prim_path: The prim path to apply the fixed-tendon schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.FixedTendonFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if all fragments applied successfully, False if any fragment reported failure. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the sibling apply_* writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # tune-not-apply: the PhysxTendonAxisRootAPI instances already exist; apply no anchor. + # aggregate per-fragment results so a reported failure is not silently masked. + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + @apply_nested def modify_fixed_tendon_properties( prim_path: str, cfg: schemas_cfg.PhysxFixedTendonPropertiesCfg, stage: Usd.Stage | None = None @@ -876,6 +1490,9 @@ def modify_fixed_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ + # Retained for backward compatibility with callers passing PhysxFixedTendonPropertiesCfg + # directly. Will be removed in a future release once callers adopt the fragment-based + # apply_fixed_tendon_properties path. # get stage handle if stage is None: stage = get_current_stage() @@ -903,6 +1520,7 @@ def modify_fixed_tendon_properties( camel_case=False, ) else: + # NOTE: ``mjc:*`` branch (``MjcTendon`` prim) kept inline; future split candidate into isaaclab_newton. # only stiffness and damping in the cfg map to mjc attributes for attr_name, value in cfg.items(): safe_set_attribute_on_usd_prim( @@ -917,6 +1535,50 @@ def modify_fixed_tendon_properties( """ +def apply_spatial_tendon_properties( + prim_path: str, fragments: Iterable[schemas_cfg.SpatialTendonFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of spatial-tendon fragments to a prim. + + Spatial tendons are a *tune-not-apply* family: the applied + ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` multi-instance schemas + already exist on the prim (authored in the source asset). This writer therefore applies no + anchor schema; it only dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, which tunes the existing instances. + Backend fragments carry backend-specific funcs, so core never imports a backend. + + Each fragment tunes only its own schema and returns ``False`` when that schema is not + present on the prim. A prim carries a single tendon backend, so compose backends across + prims rather than mixing PhysX and Mujoco fragments in one list on one prim. + + Args: + prim_path: The prim path to apply the spatial-tendon schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.SpatialTendonFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if all fragments applied successfully, False if any fragment reported failure. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the sibling apply_* writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # tune-not-apply: the PhysxTendonAttachmentRootAPI / PhysxTendonAttachmentLeafAPI instances + # already exist; apply no anchor. + # aggregate per-fragment results so a reported failure is not silently masked. + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + @apply_nested def modify_spatial_tendon_properties( prim_path: str, cfg: schemas_cfg.PhysxSpatialTendonPropertiesCfg, stage: Usd.Stage | None = None @@ -950,6 +1612,9 @@ def modify_spatial_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ + # Retained for backward compatibility with callers passing PhysxSpatialTendonPropertiesCfg + # directly. Will be removed in a future release once callers adopt the fragment-based + # apply_spatial_tendon_properties path. # obtain stage if stage is None: stage = get_current_stage() diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 4eaec2004e9..797b907ed55 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -6,6 +6,7 @@ from __future__ import annotations import warnings +from collections.abc import Callable from typing import ClassVar, Literal from isaaclab.utils.configclass import configclass @@ -106,6 +107,284 @@ def _deprecate_field_alias(cfg, alias: str, canonical: str) -> None: setattr(cfg, alias, None) +@configclass +class SchemaFragment: + """Base for a single-namespace USD-schema config fragment. + + Each subclass mirrors exactly one USD applied schema. The fragment carries class-level + metadata describing which USD namespace its fields write to (:attr:`_usd_namespace`) and + which applied schema, if any, it owns (:attr:`_usd_applied_schema`). The :attr:`func` + field names the callable that applies the fragment to a prim; the default generic applier + (:func:`~isaaclab.sim.schemas.apply_namespaced`) reads the metadata and writes each + non-``None`` field as ``:``. Irregular APIs override + :attr:`func` with a custom applier. + + .. note:: + A fragment present in a spawner slot means its schema is applied. ``None`` fields are + left unchanged on the prim (partial update). + + .. important:: + Every dataclass field other than :attr:`func` is authored as a USD attribute + ``<_usd_namespace>:``. A fragment must not carry non-USD/bookkeeping + fields -- such state belongs on the spawner cfg or as a writer keyword argument (this is + why ``fix_root_link`` / ``ensure_drives_exist`` are not fragment fields). The generic + applier (:func:`~isaaclab.sim.schemas.apply_namespaced`) enforces the invariant: it raises + when a fragment has no ``_usd_namespace``, and unsupported (non-scalar) value types raise + when written. + """ + + # -- Class metadata (not dataclass fields) -- + _usd_namespace: ClassVar[str | None] = None + _usd_applied_schema: ClassVar[str | None] = None + + func: Callable | str = "isaaclab.sim.schemas:apply_namespaced" + """Callable (or its ``module:attr`` import string) that applies this fragment to a prim. + + Resolved via :func:`~isaaclab.utils.string.string_to_callable` when a string. The callable + signature is ``func(cfg, prim_path, stage)``. + """ + + +@configclass +class RigidBodyFragment(SchemaFragment): + """Marker base for rigid-body fragments; types the ``rigid_props`` slot.""" + + pass + + +@configclass +class UsdPhysicsRigidBodyCfg(RigidBodyFragment): + """``physics:*`` rigid-body attributes from `UsdPhysics.RigidBodyAPI`_. + + The ``UsdPhysics.RigidBodyAPI`` schema is applied as the implicit anchor by the rigid-body + family writer, so this fragment owns no applied schema of its own. + + .. _UsdPhysics.RigidBodyAPI: https://openusd.org/dev/api/class_usd_physics_rigid_body_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # RigidBodyAPI applied by the family anchor + + rigid_body_enabled: bool | None = None + """Whether to enable or disable the rigid body.""" + + kinematic_enabled: bool | None = None + """Determines whether the body is kinematic or not. + + A kinematic body is moved through animated or user-defined poses; the simulation still + derives velocities for it based on the external motion. + """ + + +@configclass +class CollisionFragment(SchemaFragment): + """Marker base for collision fragments; types the ``collision_props`` slot.""" + + pass + + +@configclass +class ArticulationRootFragment(SchemaFragment): + """Marker base for articulation-root fragments; types the ``articulation_props`` slot. + + Articulation-root fragments author backend-specific articulation properties (solver + iterations, sleep / stabilization thresholds, self-collision toggles). The defining + ``UsdPhysics.ArticulationRootAPI`` anchor is applied by the articulation-root family + writer (:func:`~isaaclab.sim.schemas.apply_articulation_root_properties`) only when the + ``articulation_props`` slot carries fragments (presence-gated, matching the legacy + :func:`~isaaclab.sim.schemas.modify_articulation_root_properties` behaviour). + """ + + pass + + +@configclass +class JointDriveFragment(SchemaFragment): + """Marker base for joint-drive fragments; types the ``joint_drive_props`` slot.""" + + pass + + +@configclass +class MeshCollisionFragment(SchemaFragment): + """Marker base for mesh-collision fragments; types the ``mesh_collision_props`` slot. + + A mesh-collision concept is split across one *core* fragment carrying the standard + ``physics:approximation`` token (:class:`UsdPhysicsMeshCollisionCfg`) and one cooking + fragment per backend cooking schema (PhysX convex hull / decomposition / triangle mesh / + SDF, Newton mesh / SDF). Whichever cooking fragment is present implies the approximation + token written to ``physics:approximation`` -- see + :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + """ + + # Mesh-collision fragments author the shared ``physics:approximation`` token in addition to their + # own namespaced cooking attrs, so they dispatch through :func:`~isaaclab.sim.schemas.apply_mesh_collision` + # (not the generic :func:`~isaaclab.sim.schemas.apply_namespaced`). See that func for the token coupling. + func: Callable | str = "isaaclab.sim.schemas:apply_mesh_collision" + + +@configclass +class FixedTendonFragment(SchemaFragment): + """Marker base for fixed-tendon fragments; types the ``fixed_tendons_props`` slot. + + Fixed tendons are a *tune-not-apply* family: the applied ``PhysxTendonAxisRootAPI`` + multi-instance schemas already exist on the prim (authored in the source asset), so the + family writer (:func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`) does not apply + any anchor schema; it only tunes the existing instances via each fragment's + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + """ + + pass + + +@configclass +class SpatialTendonFragment(SchemaFragment): + """Marker base for spatial-tendon fragments; types the ``spatial_tendons_props`` slot. + + Spatial tendons are a *tune-not-apply* family: the applied + ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` multi-instance schemas + already exist on the prim (authored in the source asset), so the family writer + (:func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`) does not apply any anchor + schema; it only tunes the existing instances via each fragment's + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + """ + + pass + + +@configclass +class UsdPhysicsCollisionCfg(CollisionFragment): + """``physics:*`` collision attributes from `UsdPhysics.CollisionAPI`_. + + The ``UsdPhysics.CollisionAPI`` schema is applied as the implicit anchor by the collision + family writer (:func:`~isaaclab.sim.schemas.apply_collision_properties`), so this fragment + owns no applied schema of its own. + + .. _UsdPhysics.CollisionAPI: https://openusd.org/dev/api/class_usd_physics_collision_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # CollisionAPI applied by the family anchor + + collision_enabled: bool | None = None + """Whether to enable or disable collisions. + + Writes ``physics:collisionEnabled`` via :class:`UsdPhysics.CollisionAPI`. + """ + + +@configclass +class UsdPhysicsDriveCfg(JointDriveFragment): + """``drive::physics:*`` joint-drive attributes from `UsdPhysics.DriveAPI`_. + + The drive attributes live under a multi-instance ``UsdPhysics.DriveAPI`` (instance + ``"angular"`` for revolute joints, ``"linear"`` for prismatic joints), so this fragment + cannot use the generic :func:`~isaaclab.sim.schemas.apply_namespaced` writer. It overrides + :attr:`func` with :func:`~isaaclab.sim.schemas.apply_drive`, which selects the instance, + applies ``UsdPhysics.DriveAPI`` (presence-gated, the conditional anchor for the joint-drive + family), performs the radian-to-degree conversion for angular drives, and writes the typed + ``drive::physics:{type,maxForce,stiffness,damping}`` attributes. + + .. note:: + Unlike most fragments, this one is not a metadata-driven write. ``DriveAPI`` is applied + only when this fragment is present in the slot. + + .. _UsdPhysics.DriveAPI: https://openusd.org/dev/api/class_usd_physics_drive_a_p_i.html + """ + + # No metadata-driven namespace: the typed multi-instance ``UsdPhysics.DriveAPI`` is written + # directly by ``apply_drive``. ``DriveAPI`` is presence-gated, not an implicit anchor. + _usd_namespace: ClassVar[str | None] = None + _usd_applied_schema: ClassVar[str | None] = None + + func: Callable | str = "isaaclab.sim.schemas:apply_drive" + + def __post_init__(self): + # Deprecation alias: ``max_effort`` -> ``max_force`` (the USD attr is ``maxForce``). + # Mirrors the legacy :class:`JointDriveBaseCfg` alias forwarding. + _deprecate_field_alias(self, "max_effort", "max_force") + + drive_type: Literal["force", "acceleration"] | None = None + """Joint drive type to apply. + + If the drive type is ``"force"``, then the joint is driven by a force. If the drive type is + ``"acceleration"``, then the joint is driven by an acceleration (usually used for kinematic + joints). Written to ``drive::physics:type`` (the USD attr is ``type``, a permanent + inline carve-out from the snake-to-camel convention). + """ + + max_force: float | None = None + """Maximum force/torque that can be applied to the joint [N for linear joints, NĀ·m for angular joints]. + + Written to ``drive::physics:maxForce`` via :class:`UsdPhysics.DriveAPI`. + """ + + max_effort: float | None = None + """Deprecated alias for :attr:`max_force`. + + .. deprecated:: 4.6.25 + Use :attr:`max_force` instead. The cfg field is renamed so its snake_case name maps + identity-style to the USD camelCase attribute (``maxForce`` on ``UsdPhysics.DriveAPI``). + The alias is forwarded to :attr:`max_force` in :meth:`__post_init__` and will be removed + in 4.0. + """ + + stiffness: float | None = None + """Stiffness of the joint drive. + + The unit depends on the joint model: + + * For linear joints, the unit is kg-m/s² (N/m). + * For angular joints, the unit is kg-m²/s²/rad (NĀ·m/rad). + + Angular drives are converted from radians to degrees (``NĀ·m/rad`` -> ``NĀ·m/deg``) before + being written to ``drive:angular:physics:stiffness``. + """ + + damping: float | None = None + """Damping of the joint drive. + + The unit depends on the joint model: + + * For linear joints, the unit is kg-m/s (NĀ·s/m). + * For angular joints, the unit is kg-m²/s/rad (NĀ·mĀ·s/rad). + + Angular drives are converted from radians to degrees (``NĀ·mĀ·s/rad`` -> ``NĀ·mĀ·s/deg``) before + being written to ``drive:angular:physics:damping``. + """ + + +@configclass +class UsdPhysicsMeshCollisionCfg(MeshCollisionFragment): + """``physics:approximation`` mesh-collision token from `UsdPhysics.MeshCollisionAPI`_. + + Carries the standard mesh-collision approximation token (:attr:`mesh_approximation_name` + written to ``physics:approximation``). The ``UsdPhysics.MeshCollisionAPI`` schema is applied + as the implicit anchor by the mesh-collision family writer + (:func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`), so this fragment owns no + applied schema of its own. + + .. note:: + The ``physics:approximation`` attribute is a ``TfToken`` validated against + :const:`~isaaclab.sim.schemas.MESH_APPROXIMATION_TOKENS`; the family writer (not the generic + :func:`~isaaclab.sim.schemas.apply_namespaced` applier) handles the token write, so this + fragment overrides nothing but the namespace metadata. When a PhysX/Newton cooking fragment + is present alongside this one, its default :attr:`mesh_approximation_name` sets the token. + + .. _UsdPhysics.MeshCollisionAPI: https://openusd.org/release/api/class_usd_physics_mesh_collision_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # MeshCollisionAPI applied by the family anchor + + mesh_approximation_name: str = "none" + """Name of mesh collision approximation method. Default: "none". + + Writes the ``physics:approximation`` token via :class:`UsdPhysics.MeshCollisionAPI`. + Refer to :const:`~isaaclab.sim.schemas.MESH_APPROXIMATION_TOKENS` for available options. + """ + + @configclass class ArticulationRootBaseCfg: """Solver-common properties to apply to the root of an articulation. @@ -335,6 +614,48 @@ class MassPropertiesCfg: """ +@configclass +class MassFragment(SchemaFragment): + """Marker base for mass fragments; types the ``mass_props`` slot.""" + + pass + + +@configclass +class MassCfg(MassFragment): + """``physics:*`` mass attributes from `UsdPhysics.MassAPI`_. + + The ``UsdPhysics.MassAPI`` schema is applied as the implicit anchor by the mass family writer + (:func:`~isaaclab.sim.schemas.apply_mass_properties`), so this fragment owns no applied schema + of its own. Mirrors the legacy :class:`MassPropertiesCfg`. + + .. note:: + A fragment present in a spawner slot means its schema is applied. ``None`` fields are left + unchanged on the prim (partial update). + + .. _UsdPhysics.MassAPI: https://openusd.org/dev/api/class_usd_physics_mass_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # MassAPI applied by the family anchor + + mass: float | None = None + """The mass of the rigid body [kg]. + + Writes ``physics:mass`` via :class:`UsdPhysics.MassAPI`. + + Note: + If ``density`` is non-zero, it takes precedence and is used to compute the mass instead. + """ + + density: float | None = None + """The density of the rigid body [kg/m^3]. + + Writes ``physics:density`` via :class:`UsdPhysics.MassAPI`. The density indirectly defines the + mass of the rigid body. It is generally computed using the collision approximation of the body. + """ + + @configclass class JointDriveBaseCfg: """Solver-common properties to define the drive mechanism of a joint. @@ -468,10 +789,7 @@ class MeshCollisionBaseCfg: """ # -- Class metadata (not dataclass fields) -- - # The standard ``UsdPhysics.MeshCollisionAPI`` is always applied by the writer when a - # mesh-collision cfg is supplied; ``_usd_applied_schema`` here records the standard - # API name so subclasses that author no PhysX namespace can rely on the writer's - # standard-vs-PhysX gating logic. PhysX-cooking subclasses override this. + # Records the standard API name for the writer's standard-vs-PhysX gating; cooking subclasses override. _usd_applied_schema: ClassVar[str | None] = "MeshCollisionAPI" # Base class authors no PhysX-namespaced fields, so no namespace is defined. _usd_namespace: ClassVar[str | None] = None diff --git a/source/isaaclab/isaaclab/sim/simulation_cfg.py b/source/isaaclab/isaaclab/sim/simulation_cfg.py index 2c71c724ad3..8fb93eae989 100644 --- a/source/isaaclab/isaaclab/sim/simulation_cfg.py +++ b/source/isaaclab/isaaclab/sim/simulation_cfg.py @@ -11,7 +11,7 @@ from __future__ import annotations -from typing import Any, Literal # Literal used by RenderCfg +from typing import Literal from isaaclab.physics import PhysicsCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg @@ -19,210 +19,6 @@ from isaaclab.visualizers import VisualizerCfg -@configclass -class RenderCfg: - """Configuration for Omniverse RTX Renderer. - - These parameters are used to configure the Omniverse RTX Renderer. - - The defaults for IsaacLab are set in the experience files: - - * ``apps/isaaclab.python.rendering.kit``: Setting used when running the simulation with the GUI enabled. - * ``apps/isaaclab.python.headless.rendering.kit``: Setting used when running the simulation in headless mode. - - Setting any value here will override the defaults of the experience files. - - For more information, see the `Omniverse RTX Renderer documentation`_. - - .. _Omniverse RTX Renderer documentation: https://docs.omniverse.nvidia.com/materials-and-rendering/latest/rtx-renderer.html - """ - - enable_translucency: bool | None = None - """Enables translucency for specular transmissive surfaces such as glass. - - This comes at the cost of some performance. Default is False. - This is set by the variable: ``/rtx/translucency/enabled``. - """ - - enable_reflections: bool | None = None - """Enables reflections at the cost of some performance. Default is False. - - This is set by the variable: ``/rtx/reflections/enabled``. - """ - - enable_global_illumination: bool | None = None - """Enables Diffused Global Illumination at the cost of some performance. Default is False. - - This is set by the variable: ``/rtx/indirectDiffuse/enabled``. - """ - - antialiasing_mode: Literal["Off", "FXAA", "DLSS", "TAA", "DLAA"] | None = None - """Selects the anti-aliasing mode to use. Defaults to DLSS. - - - **DLSS**: Boosts performance by using AI to output higher resolution frames from a lower resolution input. - DLSS samples multiple lower resolution images and uses motion data and feedback from prior frames to reconstruct - native quality images. - - **DLAA**: Provides higher image quality with an AI-based anti-aliasing technique. DLAA uses the same - Super Resolution technology developed for DLSS, reconstructing a native resolution image to maximize - image quality. - - This is set by the variable: ``/rtx/post/dlss/execMode``. - """ - - enable_dlssg: bool | None = None - """"Enables the use of DLSS-G. Default is False. - - DLSS Frame Generation boosts performance by using AI to generate more frames. DLSS analyzes sequential frames - and motion data to create additional high quality frames. - - .. note:: - - This feature requires an Ada Lovelace architecture GPU. Enabling this feature also enables additional - thread-related activities, which can hurt performance. - - This is set by the variable: ``/rtx-transient/dlssg/enabled``. - """ - - enable_dl_denoiser: bool | None = None - """Enables the use of a DL denoiser. - - The DL denoiser can help improve the quality of renders, but comes at a cost of performance. - - This is set by the variable: ``/rtx-transient/dldenoiser/enabled``. - """ - - dlss_mode: Literal[0, 1, 2, 3] | None = None - """For DLSS anti-aliasing, selects the performance/quality tradeoff mode. Default is 0. - - Valid values are: - - * 0 (Performance) - * 1 (Balanced) - * 2 (Quality) - * 3 (Auto) - - This is set by the variable: ``/rtx/post/dlss/execMode``. - """ - - enable_direct_lighting: bool | None = None - """Enable direct light contributions from lights. Default is False. - - This is set by the variable: ``/rtx/directLighting/enabled``. - """ - - samples_per_pixel: int | None = None - """Defines the Direct Lighting samples per pixel. Default is 1. - - A higher value increases the direct lighting quality at the cost of performance. - - This is set by the variable: ``/rtx/directLighting/sampledLighting/samplesPerPixel``. - """ - - enable_shadows: bool | None = None - """Enables shadows at the cost of performance. Defaults to True. - - When disabled, lights will not cast shadows. - - This is set by the variable: ``/rtx/shadows/enabled``. - """ - - enable_ambient_occlusion: bool | None = None - """Enables ambient occlusion at the cost of some performance. Default is False. - - This is set by the variable: ``/rtx/ambientOcclusion/enabled``. - """ - - dome_light_upper_lower_strategy: Literal[0, 3, 4] | None = None - """Selects how to sample the Dome Light. Default is 0. - For more information, refer to the `documentation`_. - - .. _documentation: https://docs.omniverse.nvidia.com/materials-and-rendering/latest/rtx-renderer_common.html#dome-light - - Valid values are: - - * 0: **Image-Based Lighting (IBL)** - Most accurate even for high-frequency Dome Light textures. - Can introduce sampling artifacts in real-time mode. - * 3: **Limited Image-Based Lighting** - Only sampled for reflection and refraction. Fastest, but least - accurate. Good for cases where the Dome Light contributes less than other light sources. - * 4: **Approximated Image-Based Lighting** - Fast and artifacts-free sampling in real-time mode but only - works well with a low-frequency texture (e.g., a sky with no sun disc where the sun is instead a separate - Distant Light). Requires enabling Direct Lighting denoiser. - - This is set by the variable: ``/rtx/domeLight/upperLowerStrategy``. - """ - - max_bounces: int | None = None - """Maximum number of ray bounces for path tracing (RT2). Default is 2. - - For global illumination (indirect diffuse), this should be at least 3. - - This is set by the variable: ``/rtx/rtpt/maxBounces``. - """ - - split_glass: bool | None = None - """Enables separate glass ray splitting for improved glass rendering (RT2). Default is False. - - Enabling this can reduce noise on glass materials at the cost of performance. - - This is set by the variable: ``/rtx/rtpt/splitGlass``. - """ - - split_clearcoat: bool | None = None - """Enables separate clearcoat ray splitting (RT2). Default is False. - - Enabling this can reduce noise on clearcoat materials at the cost of performance. - - This is set by the variable: ``/rtx/rtpt/splitClearcoat``. - """ - - split_rough_reflection: bool | None = None - """Enables separate rough reflection ray splitting (RT2). Default is False. - - Enabling this can reduce noise on rough reflective materials at the cost of performance. - - This is set by the variable: ``/rtx/rtpt/splitRoughReflection``. - """ - - ambient_light_intensity: float | None = None - """Scene ambient light intensity. Default is 1.0. - - This is set by the variable: ``/rtx/sceneDb/ambientLightIntensity``. - """ - - ambient_occlusion_denoiser_mode: Literal[0, 1] | None = None - """Ambient occlusion denoiser mode. Default is 1. - - Valid values are: - - * 0: Higher quality denoising - * 1: Performance-oriented denoising - - This is set by the variable: ``/rtx/ambientOcclusion/denoiserMode``. - """ - - view_tile_limit: int | None = None - """Maximum number of view tiles. Default is 1000000. - - This setting helps avoid silent trimming of tiles. - - This is set by the variable: ``/rtx/viewTile/limit``. - """ - - carb_settings: dict[str, Any] | None = None - """A general dictionary for users to supply all carb rendering settings with native names. - - The keys of the dictionary can be formatted like a carb setting, .kit file setting, or python variable. - For instance, a key value pair can be ``/rtx/translucency/enabled: False`` (carb), - ``rtx.translucency.enabled: False`` (.kit), or ``rtx_translucency_enabled: False`` (python). - """ - - rendering_mode: Literal["performance", "balanced", "quality"] | None = None - """The rendering mode. - - This behaves the same as the passing the CLI arg ``--rendering_mode`` to an executable script. - """ - - @configclass class SimulationCfg: """Configuration for simulation physics. @@ -311,9 +107,6 @@ class SimulationCfg: a different config (e.g., NewtonManagerCfg) to use a different physics backend. """ - render: RenderCfg = RenderCfg() - """Render settings. Default is RenderCfg().""" - create_stage_in_memory: bool = False """If stage is first created in memory. Default is False. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index bf069b6a14a..123fa316ccc 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -7,14 +7,12 @@ import gc import logging -import os import traceback from collections.abc import Iterator from contextlib import contextmanager from dataclasses import fields from typing import TYPE_CHECKING, Any -import tomllib import torch import isaaclab.sim as sim_utils @@ -175,7 +173,6 @@ def __init__(self, cfg: SimulationCfg | None = None): self.cfg.physics = self._physics self.physics_manager: type[PhysicsManager] = self._physics.class_type self.physics_manager.initialize(self) - self._apply_render_cfg_settings() # Initialize visualizer state (visualizers are created lazily during initialize_visualizers()). self._scene_data_provider = SceneDataProvider(self.physics_manager.get_scene_data_backend()) @@ -193,7 +190,12 @@ def __init__(self, cfg: SimulationCfg | None = None): self._has_gui = bool(self.get_setting("/isaaclab/has_gui")) self._has_offscreen_render = bool(self.get_setting("/isaaclab/render/offscreen")) self._xr_enabled = bool(self.get_setting("/isaaclab/xr/enabled")) - # Note: has_rtx_sensors is NOT cached because it changes when Camera sensors are created + # Note: has_rtx_sensors is NOT cached because it changes when Camera sensors are created. + # It is a global setting flipped to True by RTX Camera creation (see Camera._initialize_impl) + # and is never flipped back. Reset it here so a fresh SimulationContext reflects its own + # cameras rather than inheriting a stale True from a previously torn-down simulation. RTX + # cameras created for this instance re-set it to True before it is read. + self.set_setting("/isaaclab/render/rtx_sensors", False) self._pending_camera_view: tuple[tuple[float, float, float], tuple[float, float, float]] | None = None self.vis_marker_registry = VisMarkerRegistry() @@ -222,106 +224,6 @@ def __init__(self, cfg: SimulationCfg | None = None): type(self)._instance = self # Mark as valid singleton only after successful init - def _apply_render_cfg_settings(self) -> None: - """Apply render preset and overrides from SimulationCfg.render.""" - # TODO: Refactor render preset + override handling to a dedicated RenderingQualityCfg - # (name subject to change) to keep quality profiles and carb mappings centralized. - render_cfg = getattr(self.cfg, "render", None) - if render_cfg is None: - return - - # Priority: - # 1) CLI/AppLauncher setting if present, 2) SimulationCfg.render.rendering_mode. - rendering_mode = self.get_setting("/isaaclab/rendering/rendering_mode") - if not rendering_mode: - rendering_mode = getattr(render_cfg, "rendering_mode", None) - - if rendering_mode: - supported_rendering_modes = {"performance", "balanced", "quality"} - if rendering_mode not in supported_rendering_modes: - raise ValueError( - f"RenderCfg rendering mode '{rendering_mode}' not in supported modes " - f"{sorted(supported_rendering_modes)}." - ) - - isaaclab_app_exp_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 4, "apps") - from isaaclab.utils.version import get_isaac_sim_version - - if get_isaac_sim_version().major < 6: - isaaclab_app_exp_path = os.path.join(isaaclab_app_exp_path, "isaacsim_5") - - preset_filename = os.path.join(isaaclab_app_exp_path, f"rendering_modes/{rendering_mode}.kit") - if os.path.exists(preset_filename): - with open(preset_filename, "rb") as file: - preset_dict = tomllib.load(file) - - def _apply_nested(data: dict[str, Any], path: str = "") -> None: - for key, value in data.items(): - key_path = f"{path}/{key}" if path else f"/{key}" - if isinstance(value, dict): - _apply_nested(value, key_path) - else: - self.set_setting(key_path.replace(".", "/"), value) - - _apply_nested(preset_dict) - else: - logger.warning("[SimulationContext] Render preset file not found: %s", preset_filename) - - # RenderCfg fields mapped to setting paths (stored via SettingsManager) - field_to_setting = { - "enable_translucency": "/rtx/translucency/enabled", - "enable_reflections": "/rtx/reflections/enabled", - "enable_global_illumination": "/rtx/indirectDiffuse/enabled", - "enable_dlssg": "/rtx-transient/dlssg/enabled", - "enable_dl_denoiser": "/rtx-transient/dldenoiser/enabled", - "dlss_mode": "/rtx/post/dlss/execMode", - "enable_direct_lighting": "/rtx/directLighting/enabled", - "samples_per_pixel": "/rtx/directLighting/sampledLighting/samplesPerPixel", - "enable_shadows": "/rtx/shadows/enabled", - "enable_ambient_occlusion": "/rtx/ambientOcclusion/enabled", - "dome_light_upper_lower_strategy": "/rtx/domeLight/upperLowerStrategy", - "ambient_light_intensity": "/rtx/sceneDb/ambientLightIntensity", - "ambient_occlusion_denoiser_mode": "/rtx/ambientOcclusion/denoiserMode", - "subpixel_mode": "/rtx/raytracing/subpixel/mode", - "enable_cached_raytracing": "/rtx/raytracing/cached/enabled", - "max_samples_per_launch": "/rtx/pathtracing/maxSamplesPerLaunch", - "view_tile_limit": "/rtx/viewTile/limit", - # RT2 path tracing settings - "max_bounces": "/rtx/rtpt/maxBounces", - "split_glass": "/rtx/rtpt/splitGlass", - "split_clearcoat": "/rtx/rtpt/splitClearcoat", - "split_rough_reflection": "/rtx/rtpt/splitRoughReflection", - } - - for key, value in vars(render_cfg).items(): - if value is None or key in {"rendering_mode", "carb_settings", "antialiasing_mode"}: - continue - setting_path = field_to_setting.get(key) - if setting_path is not None: - self.set_setting(setting_path, value) - - # Raw overrides from render_cfg (stored via SettingsManager) - extra_settings = getattr(render_cfg, "carb_settings", None) - if extra_settings: - for key, value in extra_settings.items(): - if "_" in key: - path = "/" + key.replace("_", "/") - elif "." in key: - path = "/" + key.replace(".", "/") - else: - path = key - self.set_setting(path, value) - - # Optional anti-aliasing mode via Replicator (best-effort, may use Omniverse APIs) - antialiasing_mode = getattr(render_cfg, "antialiasing_mode", None) - if antialiasing_mode is not None: - try: - import omni.replicator.core as rep - - rep.settings.set_render_rtx_realtime(antialiasing=antialiasing_mode) - except Exception: - pass - def _init_usd_physics_scene(self) -> None: """Create and configure the USD physics scene.""" from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 @@ -401,10 +303,15 @@ def can_render_rgb_array(self) -> bool: @property def is_rendering(self) -> bool: - """Returns whether rendering is active (GUI, RTX sensors, visualizers, or XR).""" + """Returns whether *continuous* rendering is active (GUI, RTX sensors, visualizers, or XR). + + This drives the per-step render/Kit-pump loop, so it deliberately excludes headless + offscreen rendering (``--video`` / ``rgb_array``). Offscreen frames are produced on + demand when a frame is actually requested (via :meth:`render`), not on every step; see + :meth:`has_offscreen_render` and :meth:`can_render_rgb_array` for the capability checks. + """ return ( self._has_gui - or self._has_offscreen_render or self.get_setting("/isaaclab/render/rtx_sensors") or bool(self.resolve_visualizer_types()) or self._xr_enabled 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 3a6fa939a63..985df6edca7 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, @@ -344,46 +345,131 @@ def _spawn_from_usd_file( # modify rigid body properties if cfg.rigid_props is not None: - schemas.modify_rigid_body_properties(prim_path, cfg.rigid_props) + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* + rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): + schemas.apply_rigid_body_properties(prim_path, rigid_frags) + else: + schemas.modify_rigid_body_properties(prim_path, cfg.rigid_props) # modify collision properties if cfg.collision_props is not None: - schemas.modify_collision_properties(prim_path, cfg.collision_props) - # modify mass properties + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* + coll_frags = cfg.collision_props if isinstance(cfg.collision_props, (list, tuple)) else [cfg.collision_props] + if coll_frags and all(isinstance(f, schemas.SchemaFragment) for f in coll_frags): + schemas.apply_collision_properties(prim_path, coll_frags) + else: + schemas.modify_collision_properties(prim_path, cfg.collision_props) + # modify mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> modify_*) if cfg.mass_props is not None: - schemas.modify_mass_properties(prim_path, cfg.mass_props) + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(prim_path, mass_frags) + else: + schemas.modify_mass_properties(prim_path, cfg.mass_props) # modify articulation root properties - if cfg.articulation_props is not None: - schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) + # ``fix_root_link`` is a spawner-level topology flag (not a schema property); it is honored on the + # fragment path independently of whether any articulation schema properties were supplied. + articulation_props = cfg.articulation_props + articulation_fix_root_link = cfg.fix_root_link + # transition shim, remove later: route a legacy single cfg (a dataclass, not a fragment) to the + # legacy writer -- it owns its own ``fix_root_link`` field; everything else goes to the fragment + # writer, routing by type so an empty list is still a valid (topology-only) fragment collection + # rather than being mis-sent to the legacy writer. + if ( + articulation_props is not None + and not isinstance(articulation_props, (list, tuple)) + and not isinstance(articulation_props, schemas.SchemaFragment) + ): + if articulation_fix_root_link is not None: + logger.warning( + f"Ignoring the spawner-level 'fix_root_link={articulation_fix_root_link}' because" + " 'articulation_props' is a legacy cfg, which owns its own 'fix_root_link' field. Set" + " it on that cfg instead." + ) + schemas.modify_articulation_root_properties(prim_path, articulation_props) + else: + articulation_frags = ( + list(articulation_props) + if isinstance(articulation_props, (list, tuple)) + else ([articulation_props] if isinstance(articulation_props, schemas.SchemaFragment) else []) + ) + if articulation_frags or articulation_fix_root_link is not None: + schemas.apply_articulation_root_properties( + prim_path, articulation_frags, fix_root_link=articulation_fix_root_link + ) # modify tendon properties if cfg.fixed_tendons_props is not None: - schemas.modify_fixed_tendon_properties(prim_path, cfg.fixed_tendons_props) + # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> modify_* + # normalize a single fragment to a list so the convenience form (and an empty list) route like a list + fixed_tendon_frags = ( + [cfg.fixed_tendons_props] + if isinstance(cfg.fixed_tendons_props, schemas.SchemaFragment) + else cfg.fixed_tendons_props + ) + if isinstance(fixed_tendon_frags, (list, tuple)) and all( + isinstance(f, schemas.SchemaFragment) for f in fixed_tendon_frags + ): + schemas.apply_fixed_tendon_properties(prim_path, fixed_tendon_frags) + else: + schemas.modify_fixed_tendon_properties(prim_path, cfg.fixed_tendons_props) if cfg.spatial_tendons_props is not None: - schemas.modify_spatial_tendon_properties(prim_path, cfg.spatial_tendons_props) + # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> modify_* + # normalize a single fragment to a list so the convenience form (and an empty list) route like a list + spatial_tendon_frags = ( + [cfg.spatial_tendons_props] + if isinstance(cfg.spatial_tendons_props, schemas.SchemaFragment) + else cfg.spatial_tendons_props + ) + if isinstance(spatial_tendon_frags, (list, tuple)) and all( + isinstance(f, schemas.SchemaFragment) for f in spatial_tendon_frags + ): + schemas.apply_spatial_tendon_properties(prim_path, spatial_tendon_frags) + else: + schemas.modify_spatial_tendon_properties(prim_path, cfg.spatial_tendons_props) # define drive API on the joints # note: these are only for setting low-level simulation properties. all others should be set or are # and overridden by the articulation/actuator properties. if cfg.joint_drive_props is not None: - # auto-enable body-level gravcomp if joint-level actuator gravcomp is requested - # without it — actuatorgravcomp has no effect since there are no forces to route. - # Only auto-populates when the user did not already set ``gravcomp`` themselves; - # an explicit ``MujocoRigidBodyPropertiesCfg(gravcomp=0.5)`` is preserved as-is. - from isaaclab_newton.sim.schemas.schemas_cfg import MujocoJointDrivePropertiesCfg, MujocoRigidBodyPropertiesCfg - - body_gravcomp_unset = ( - not isinstance(cfg.rigid_props, MujocoRigidBodyPropertiesCfg) or cfg.rigid_props.gravcomp is None + # transition shim, remove later: a fragment list -> apply_joint_drive_properties (the + # MujocoJointCfg fragment handles its own body-gravcomp coupling in apply_mujoco_joint, so + # the fragment path adds no backend coupling here); a legacy single cfg -> the pre-existing + # gravcomp auto-enable + modify_joint_drive_properties below. + joint_frags = ( + cfg.joint_drive_props if isinstance(cfg.joint_drive_props, (list, tuple)) else [cfg.joint_drive_props] ) - if ( - isinstance(cfg.joint_drive_props, MujocoJointDrivePropertiesCfg) - and cfg.joint_drive_props.actuatorgravcomp - and body_gravcomp_unset - ): - logger.info( - "Joint-level actuator gravity compensation requires body-level gravcomp." - " Auto-setting MujocoRigidBodyPropertiesCfg(gravcomp=1.0)." + if joint_frags and all(isinstance(f, schemas.SchemaFragment) for f in joint_frags): + schemas.apply_joint_drive_properties(prim_path, joint_frags, ensure_drives_exist=cfg.ensure_drives_exist) + else: + # auto-enable body-level gravcomp if joint-level actuator gravcomp is requested + # without it — actuatorgravcomp has no effect since there are no forces to route. + # Only auto-populates when the user did not already set ``gravcomp`` themselves; + # an explicit ``MujocoRigidBodyPropertiesCfg(gravcomp=0.5)`` is preserved as-is. + from isaaclab_newton.sim.schemas.schemas_cfg import ( + MujocoJointDrivePropertiesCfg, + MujocoRigidBodyCfg, + MujocoRigidBodyPropertiesCfg, + ) + + # gravcomp may be authored either via the legacy MujocoRigidBodyPropertiesCfg or via a + # MujocoRigidBodyCfg fragment in a rigid_props list. Treat either as "already set". + rigid_props_list = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + body_gravcomp_unset = not any( + isinstance(f, (MujocoRigidBodyPropertiesCfg, MujocoRigidBodyCfg)) and f.gravcomp is not None + for f in rigid_props_list ) - schemas.modify_rigid_body_properties(prim_path, MujocoRigidBodyPropertiesCfg(gravcomp=1.0)) - schemas.modify_joint_drive_properties(prim_path, cfg.joint_drive_props) + if ( + isinstance(cfg.joint_drive_props, MujocoJointDrivePropertiesCfg) + and cfg.joint_drive_props.actuatorgravcomp + and body_gravcomp_unset + ): + logger.info( + "Joint-level actuator gravity compensation requires body-level gravcomp." + " Auto-setting MujocoRigidBodyPropertiesCfg(gravcomp=1.0)." + ) + schemas.modify_rigid_body_properties(prim_path, MujocoRigidBodyPropertiesCfg(gravcomp=1.0)) + schemas.modify_joint_drive_properties(prim_path, cfg.joint_drive_props) # define deformable body properties, or modify if deformable body API is present (PhysX only) if cfg.deformable_props is not None: @@ -421,8 +507,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 22857f8d45b..ff047d6f933 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 @@ -34,18 +34,68 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): scale: tuple[float, float, float] | None = None """Scale of the asset. Defaults to None, in which case the scale is not modified.""" - articulation_props: schemas.ArticulationRootPropertiesCfg | None = None - """Properties to apply to the articulation root.""" + articulation_props: ( + schemas.ArticulationRootBaseCfg + | schemas.ArticulationRootFragment + | list[schemas.ArticulationRootFragment] + | None + ) = None + """Properties to apply to the articulation root. + + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg`) + or a list of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` fragments + (e.g. ``[PhysxArticulationCfg(...), NewtonArticulationCfg(...)]``). When a fragment list is + given, ``UsdPhysics.ArticulationRootAPI`` is applied as the anchor (presence-gated) and each + fragment writes its own namespace. + """ + + fix_root_link: bool | None = None + """Whether to fix the root link of the articulation. Defaults to None. + + This is a non-USD, spawner-level behaviour flag consumed by + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` on the fragment/topology path, + including when :attr:`articulation_props` is ``None`` or an empty fragment collection. It is handled + independently of whether any schema properties are supplied: + + * If set to None, the root link is not modified. + * If the articulation already has a fixed root link, this flag enables or disables the fixed joint. + * If the articulation does not have a fixed root link, this flag creates a fixed joint between the + world frame and the root link (named "FixedJoint" under the articulation prim). + + When :attr:`articulation_props` is given as a legacy cfg, set + :attr:`~isaaclab.sim.schemas.ArticulationRootBaseCfg.fix_root_link` on that cfg instead. + """ + + fixed_tendons_props: ( + schemas.FixedTendonPropertiesCfg | schemas.FixedTendonFragment | list[schemas.FixedTendonFragment] | None + ) = None + """Properties to apply to the fixed tendons (if any). + + Accepts either the legacy :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg` + or one or more :class:`~isaaclab.sim.schemas.FixedTendonFragment` instances. + """ - fixed_tendons_props: schemas.FixedTendonPropertiesCfg | None = None - """Properties to apply to the fixed tendons (if any).""" + spatial_tendons_props: ( + schemas.SpatialTendonPropertiesCfg | schemas.SpatialTendonFragment | list[schemas.SpatialTendonFragment] | None + ) = None + """Properties to apply to the spatial tendons (if any). - spatial_tendons_props: schemas.SpatialTendonPropertiesCfg | None = None - """Properties to apply to the spatial tendons (if any).""" + Accepts either the legacy :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonPropertiesCfg` + or one or more :class:`~isaaclab.sim.schemas.SpatialTendonFragment` instances. + """ - joint_drive_props: schemas.JointDriveBaseCfg | None = None + joint_drive_props: ( + schemas.JointDriveBaseCfg | schemas.JointDriveFragment | list[schemas.JointDriveFragment] | None + ) = None """Properties to apply to a joint. + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`) or a + list of :class:`~isaaclab.sim.schemas.JointDriveFragment` fragments + (e.g. ``[UsdPhysicsDriveCfg(...), PhysxJointCfg(...)]``). When a fragment list is given, + ``UsdPhysics.DriveAPI`` is applied (presence-gated) only when a + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present, and each fragment writes + its own namespace. + .. note:: The joint drive properties set the USD attributes of all the joint drives in the asset. We recommend using this attribute sparingly and only when necessary. Instead, please use the @@ -53,6 +103,17 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): for specific joints in an articulation. """ + ensure_drives_exist: bool = False + """Whether to ensure every joint drive is active when authoring :attr:`joint_drive_props`. + + When True, any joint drive whose authored stiffness *and* damping are both zero is given a + minimal stiffness (``1e-3``) so that backends (e.g. Newton) create proper actuators for it. + This is a spawner-level behavior flag (not a USD attribute and not a fragment field). It is + only consumed when :attr:`joint_drive_props` is given as a fragment list; legacy + :class:`~isaaclab.sim.schemas.JointDriveBaseCfg` cfgs carry their own + ``ensure_drives_exist`` field. + """ + visual_material_path: str = "material" """Path to the visual material to use for the prim. Defaults to "material". @@ -74,9 +135,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. """ @@ -227,5 +297,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 7ce7815955c..305c980d231 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 8c12bee9442..51f19226bb1 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 5c88731cf8d..6dbe000e773 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 cfc7f51b9ff..f46d06c6856 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 @@ -415,7 +429,12 @@ def _spawn_mesh_geom_from_mesh( mesh_collision_api = UsdPhysics.MeshCollisionAPI.Apply(mesh_prim) mesh_collision_api.GetApproximationAttr().Set(collision_approximation) # apply collision properties - schemas.define_collision_properties(mesh_prim_path, cfg.collision_props, stage=stage) + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* + coll_frags = cfg.collision_props if isinstance(cfg.collision_props, (list, tuple)) else [cfg.collision_props] + if coll_frags and all(isinstance(f, schemas.SchemaFragment) for f in coll_frags): + schemas.apply_collision_properties(mesh_prim_path, coll_frags, stage=stage) + else: + schemas.define_collision_properties(mesh_prim_path, cfg.collision_props, stage=stage) # apply visual material if cfg.visual_material is not None: @@ -434,15 +453,24 @@ 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) # note: we apply the rigid properties to the parent prim in case of rigid objects. if cfg.rigid_props is not None: - # apply mass properties + # apply mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.mass_props is not None: - schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) - # apply rigid properties - schemas.define_rigid_body_properties(prim_path, cfg.rigid_props, stage=stage) + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(prim_path, mass_frags, stage=stage) + else: + schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) + # apply rigid properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) + rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): + schemas.apply_rigid_body_properties(prim_path, rigid_frags, stage=stage) + else: + schemas.define_rigid_body_properties(prim_path, cfg.rigid_props, 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 c6eb26507d3..aafd4d3cd7e 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 9e8eafc1c57..2b8177423a8 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: @@ -294,7 +295,12 @@ def _spawn_geom_from_prim_type( create_prim(mesh_prim_path, prim_type, scale=scale, attributes=attributes, stage=stage) # apply collision properties if cfg.collision_props is not None: - schemas.define_collision_properties(mesh_prim_path, cfg.collision_props, stage=stage) + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* + coll_frags = cfg.collision_props if isinstance(cfg.collision_props, (list, tuple)) else [cfg.collision_props] + if coll_frags and all(isinstance(f, schemas.SchemaFragment) for f in coll_frags): + schemas.apply_collision_properties(mesh_prim_path, coll_frags, stage=stage) + else: + schemas.define_collision_properties(mesh_prim_path, cfg.collision_props, stage=stage) # apply visual material if cfg.visual_material is not None: if not cfg.visual_material_path.startswith("/"): @@ -311,15 +317,26 @@ 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) # note: we apply rigid properties in the end to later make the instanceable prim # apply mass properties if cfg.mass_props is not None: - schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) + # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> define_* + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(prim_path, mass_frags, stage=stage) + else: + schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) # apply rigid body properties if cfg.rigid_props is not None: - schemas.define_rigid_body_properties(prim_path, cfg.rigid_props, stage=stage) + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* + rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): + schemas.apply_rigid_body_properties(prim_path, rigid_frags, stage=stage) + else: + schemas.define_rigid_body_properties(prim_path, cfg.rigid_props, 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 b111bdbb2bf..04d0bc42a76 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/spawners/spawner_cfg.py b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py index 3f1eef72a2f..5a655ce38db 100644 --- a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py @@ -82,18 +82,40 @@ class RigidObjectSpawnerCfg(SpawnerCfg): to the prim outside of the properties available by default when spawning the prim. """ - mass_props: schemas.MassPropertiesCfg | None = None - """Mass properties.""" + mass_props: schemas.MassPropertiesCfg | schemas.MassFragment | list[schemas.MassFragment] | None = None + """Mass properties. + + Accepts either a single legacy :class:`~isaaclab.sim.schemas.MassPropertiesCfg` or a list of + :class:`~isaaclab.sim.schemas.MassFragment` fragments (e.g. ``[MassCfg(...)]``). When a fragment + list is given, ``UsdPhysics.MassAPI`` is applied as the implicit anchor and each fragment writes + its own namespace. + """ - rigid_props: schemas.RigidBodyBaseCfg | None = None + rigid_props: schemas.RigidBodyBaseCfg | schemas.RigidBodyFragment | list[schemas.RigidBodyFragment] | None = None """Rigid body properties. + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`) or a + list of :class:`~isaaclab.sim.schemas.RigidBodyFragment` fragments + (e.g. ``[UsdPhysicsRigidBodyCfg(...), PhysxRigidBodyCfg(...)]``). When a fragment list is given, + ``UsdPhysics.RigidBodyAPI`` is applied as the implicit anchor and each fragment writes its own + namespace. + For making a rigid object static, set the :attr:`schemas.RigidBodyBaseCfg.kinematic_enabled` - as True. This will make the object static and will not be affected by gravity or other forces. + (or :attr:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg.kinematic_enabled`) as True. This will + make the object static and will not be affected by gravity or other forces. """ - collision_props: schemas.CollisionPropertiesCfg | None = None - """Properties to apply to all collision meshes.""" + collision_props: ( + schemas.CollisionPropertiesCfg | schemas.CollisionFragment | list[schemas.CollisionFragment] | None + ) = None + """Properties to apply to all collision meshes. + + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.CollisionBaseCfg`) or a + list of :class:`~isaaclab.sim.schemas.CollisionFragment` fragments + (e.g. ``[UsdPhysicsCollisionCfg(...), PhysxCollisionCfg(...)]``). When a fragment list is given, + ``UsdPhysics.CollisionAPI`` is applied as the implicit anchor and each fragment writes its own + namespace. + """ activate_contact_sensors: bool = False """Activate contact reporting on all rigid bodies. Defaults to False. diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index 4d5dcb3ac02..1b38f0aae47 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 2a9cf12ffdd..b7eaff26937 100644 --- a/source/isaaclab/isaaclab/sim/utils/stage.py +++ b/source/isaaclab/isaaclab/sim/utils/stage.py @@ -59,6 +59,17 @@ def _check_ancestral_node(node) -> bool: return _check_ancestral_node(prim_index.rootNode) +def _is_uri_path(asset_path: str) -> bool: + """Return whether an asset path has an explicit URI scheme.""" + scheme = asset_path.split("://", 1)[0] + return ( + scheme != asset_path + and len(scheme) > 1 + and scheme[0].isalpha() + and all(c.isalnum() or c in "+-." for c in scheme[1:]) + ) + + def resolve_paths( src_layer_identifier: str, dst_layer_identifier: str, @@ -105,9 +116,11 @@ def resolve_paths( dst_dir = os.path.dirname(dst_layer.realPath or dst_layer.identifier) def _modify_path(asset_path: str) -> str: - if not asset_path: + if not asset_path or _is_uri_path(asset_path): return asset_path resolved = src_layer.ComputeAbsolutePath(asset_path) + if resolved and _is_uri_path(resolved): + return resolved if store_relative_path and resolved and dst_dir: try: return os.path.relpath(resolved, dst_dir) @@ -123,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: @@ -154,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() @@ -213,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): @@ -510,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/sim/views/__init__.pyi b/source/isaaclab/isaaclab/sim/views/__init__.pyi index d578f85d6ad..734e925c19b 100644 --- a/source/isaaclab/isaaclab/sim/views/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/views/__init__.pyi @@ -7,6 +7,9 @@ __all__ = [ "BaseFrameView", "UsdFrameView", "FrameView", + "FrameViewSpaceWriterBase", + "FrameViewWorldSpaceWriter", + "FrameViewLocalSpaceWriter", # Deprecated alias "XformPrimView", ] @@ -14,5 +17,6 @@ __all__ = [ from .base_frame_view import BaseFrameView from .usd_frame_view import UsdFrameView from .frame_view import FrameView +from .xform_space_writer import FrameViewSpaceWriterBase, FrameViewWorldSpaceWriter, FrameViewLocalSpaceWriter # Deprecated alias from .xform_prim_view import XformPrimView diff --git a/source/isaaclab/isaaclab/sim/views/base_frame_view.py b/source/isaaclab/isaaclab/sim/views/base_frame_view.py index 656108f24d2..79c672b7cfd 100644 --- a/source/isaaclab/isaaclab/sim/views/base_frame_view.py +++ b/source/isaaclab/isaaclab/sim/views/base_frame_view.py @@ -8,23 +8,48 @@ from __future__ import annotations import abc +from typing import TYPE_CHECKING import warp as wp from isaaclab.utils.warp import ProxyArray +if TYPE_CHECKING: + from .xform_space_writer import FrameViewLocalSpaceWriter, FrameViewSpaceWriterBase, FrameViewWorldSpaceWriter + class BaseFrameView(abc.ABC): - """Abstract interface for reading and writing world-space transforms of multiple prims. + """Abstract interface for reading and writing transforms of multiple prims. Backend-specific implementations (USD/Fabric, Newton GPU state, etc.) subclass this to provide efficient batched pose queries. The factory :class:`~isaaclab.sim.views.FrameView` selects the correct implementation at runtime based on the active physics backend. - All getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. + All getters return :class:`~isaaclab.utils.warp.ProxyArray`. All writes go + through the writer-scope API -- :meth:`xform_world_space_writer` or + :meth:`xform_local_space_writer`: + + .. code-block:: python + + with view.xform_world_space_writer() as writer: + writer.set_poses(positions=p, orientations=o) + writer.set_scales(scales=s) + # Derived-space matrices are recomputed and the writer scope is closed. + + Only one writer scope may be active per view at a time. While a writer + scope is active, the view-level getters + (:meth:`get_world_poses`, :meth:`get_local_poses`, + :meth:`get_world_scales`, :meth:`get_local_scales`) raise + :class:`RuntimeError` -- use the writer's :meth:`~FrameViewSpaceWriterBase.get_poses` + or :meth:`~FrameViewSpaceWriterBase.get_scales` inside the scope, or exit the + scope first. """ + # Class-level default; instance-level value is set by the writer's + # __enter__ / __exit__ to track the active scope on this view. + _active_writer: FrameViewSpaceWriterBase | None = None + @property @abc.abstractmethod def count(self) -> int: @@ -37,7 +62,77 @@ def device(self) -> str: """Device where arrays are allocated (``"cpu"`` or ``"cuda:0"``).""" ... + # ------------------------------------------------------------------ + # Write scope -- recommended API for all transform writes. + # ------------------------------------------------------------------ + + def xform_world_space_writer(self) -> FrameViewWorldSpaceWriter: + """Open a world-space write scope on this view (recommended write API). + + Inside the scope, :meth:`~FrameViewSpaceWriterBase.set_poses` / + :meth:`~FrameViewSpaceWriterBase.set_scales` write world-space values. + + Returns: + A :class:`~isaaclab.sim.views.FrameViewWorldSpaceWriter` context manager. + + Raises: + RuntimeError: On ``__enter__``, if another writer is already active + on this view. + + Example: + .. code-block:: python + + with view.xform_world_space_writer() as w: + w.set_poses(positions=p, orientations=o) + w.set_scales(scales=s) + """ + return self._make_world_space_writer() + + def xform_local_space_writer(self) -> FrameViewLocalSpaceWriter: + """Open a local-space write scope on this view (recommended write API). + + Inside the scope, :meth:`~FrameViewSpaceWriterBase.set_poses` / + :meth:`~FrameViewSpaceWriterBase.set_scales` write local-space values. + + Returns: + A :class:`~isaaclab.sim.views.FrameViewLocalSpaceWriter` context manager. + + Raises: + RuntimeError: On ``__enter__``, if another writer is already active + on this view. + + Example: + .. code-block:: python + + with view.xform_local_space_writer() as w: + w.set_poses(translations=t, orientations=o) + w.set_scales(scales=s) + """ + return self._make_local_space_writer() + + @abc.abstractmethod + def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: + """Backend hook: return a fresh :class:`FrameViewWorldSpaceWriter` for this view.""" + ... + @abc.abstractmethod + def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: + """Backend hook: return a fresh :class:`FrameViewLocalSpaceWriter` for this view.""" + ... + + def _assert_no_active_writer(self, method_name: str) -> None: + """Raise :class:`RuntimeError` if a writer scope is currently active on this view.""" + if self._active_writer is not None: + raise RuntimeError( + f"{type(self).__name__}.{method_name}() is not allowed while a writer " + f"scope is active ({type(self._active_writer).__name__}). Use the writer's " + f"get_poses / get_scales inside the scope, or exit the scope first." + ) + + # ------------------------------------------------------------------ + # Public getters -- guarded; delegate to backend ``_*_impl`` hooks. + # ------------------------------------------------------------------ + def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: """Get world-space positions and orientations for prims in the view. @@ -46,12 +141,110 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, Returns: A tuple ``(positions, orientations)`` of :class:`~isaaclab.utils.warp.ProxyArray` - wrappers. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a - cached zero-copy ``torch.Tensor`` view. + wrappers. + + Raises: + RuntimeError: If a writer scope is active on this view. """ + self._assert_no_active_writer("get_world_poses") + return self._get_world_poses_impl(indices) + + def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Get local-space translations and orientations for prims in the view. + + Args: + indices: Subset of prims to query. ``None`` means all prims. + + Returns: + A tuple ``(translations, orientations)`` of :class:`~isaaclab.utils.warp.ProxyArray` + wrappers. + + Raises: + RuntimeError: If a writer scope is active on this view. + """ + self._assert_no_active_writer("get_local_poses") + return self._get_local_poses_impl(indices) + + def get_local_scales(self, indices: wp.array | None = None) -> ProxyArray: + """Get local-space scales for prims in the view. + + Args: + indices: Subset of prims to query. ``None`` means all prims. + + Returns: + A :class:`~isaaclab.utils.warp.ProxyArray` of shape ``(M, 3)``. + + Raises: + RuntimeError: If a writer scope is active on this view. + """ + self._assert_no_active_writer("get_local_scales") + return self._get_local_scales_impl(indices) + + def get_world_scales(self, indices: wp.array | None = None) -> ProxyArray: + """Get world-space (composed) scales for prims in the view. + + Returns the effective scale in world space (``parent_scale * local_scale``). + + .. note:: + Scale extraction uses TRS (Translation-Rotation-Scale) decomposition, + which assumes no shear/skew in the transform matrix. If a prim's + world transform contains shear, the extracted scale values will be + approximate. + + Args: + indices: Subset of prims to query. ``None`` means all prims. + + Returns: + A :class:`~isaaclab.utils.warp.ProxyArray` of shape ``(M, 3)``. + + Raises: + RuntimeError: If a writer scope is active on this view. + """ + self._assert_no_active_writer("get_world_scales") + return self._get_world_scales_impl(indices) + + # ------------------------------------------------------------------ + # Backend hooks for the public getters above. + # ------------------------------------------------------------------ + + @abc.abstractmethod + def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Backend implementation of :meth:`get_world_poses`.""" ... @abc.abstractmethod + def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Backend implementation of :meth:`get_local_poses`.""" + ... + + @abc.abstractmethod + def _get_local_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Backend implementation of :meth:`get_local_scales`.""" + ... + + @abc.abstractmethod + def _get_world_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Backend implementation of :meth:`get_world_scales`.""" + ... + + # ------------------------------------------------------------------ + # Convenience pose/scale setters -- route through the writer scope. + # + # These are kept as first-class convenience APIs (not deprecated). Each + # call opens its own single-statement writer scope internally, so writing + # poses and scales through two separate calls performs the opposite-space + # recompute + synchronize twice. For the best performance when updating + # both poses and scales together, open one writer scope and issue both + # writes inside it:: + # + # with view.xform_world_space_writer() as w: + # w.set_poses(...) + # w.set_scales(...) + # + # Prefer the writer scope in hot loops; prefer these helpers when code + # simplicity matters more than shaving a redundant derive/sync. + # ------------------------------------------------------------------ + def set_world_poses( self, positions: wp.array | None = None, @@ -60,28 +253,19 @@ def set_world_poses( ) -> None: """Set world-space positions and/or orientations for prims in the view. + This convenience method opens a single-statement writer scope + internally. To update poses and scales together without paying the + opposite-space derive/sync twice, prefer + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)``. + Args: positions: World-space positions ``(M, 3)``. ``None`` leaves positions unchanged. orientations: World-space quaternions ``(M, 4)``. ``None`` leaves orientations unchanged. indices: Subset of prims to update. ``None`` means all prims. """ - ... - - @abc.abstractmethod - def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: - """Get local-space positions and orientations for prims in the view. - - Args: - indices: Subset of prims to query. ``None`` means all prims. - - Returns: - A tuple ``(translations, orientations)`` of :class:`~isaaclab.utils.warp.ProxyArray` - wrappers. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a - cached zero-copy ``torch.Tensor`` view. - """ - ... + with self.xform_world_space_writer() as writer: + writer.set_poses(positions, orientations, indices) - @abc.abstractmethod def set_local_poses( self, translations: wp.array | None = None, @@ -90,31 +274,66 @@ def set_local_poses( ) -> None: """Set local-space translations and/or orientations for prims in the view. + This convenience method opens a single-statement writer scope + internally. To update poses and scales together without paying the + opposite-space derive/sync twice, prefer + ``with view.xform_local_space_writer() as w: w.set_poses(...); w.set_scales(...)``. + Args: translations: Local-space translations ``(M, 3)``. ``None`` leaves translations unchanged. orientations: Local-space quaternions ``(M, 4)``. ``None`` leaves orientations unchanged. indices: Subset of prims to update. ``None`` means all prims. """ - ... + with self.xform_local_space_writer() as writer: + writer.set_poses(translations, orientations, indices) + + # ------------------------------------------------------------------ + # Scale getter/setter convenience helpers. + # ------------------------------------------------------------------ - @abc.abstractmethod def get_scales(self, indices: wp.array | None = None) -> ProxyArray: """Get scales for prims in the view. + .. note:: + Prefer the explicit :meth:`get_local_scales` or + :meth:`get_world_scales` when the space matters. This method + delegates to :meth:`_get_scales_impl`, which preserves each + backend's legacy space (world for Fabric, local for USD). + Args: indices: Subset of prims to query. ``None`` means all prims. Returns: - A :class:`~isaaclab.utils.warp.ProxyArray` of shape ``(M, 3)``. + A ``ProxyArray`` of shape ``(M, 3)``. + + Raises: + RuntimeError: If a writer scope is active on this view. """ - ... + self._assert_no_active_writer("get_scales") + return self._get_scales_impl(indices) - @abc.abstractmethod def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: """Set scales for prims in the view. + This convenience method delegates to :meth:`_set_scales_impl`, which + opens the backend's legacy space (world for Fabric, local for USD) and + calls ``writer.set_scales``. To update poses and scales together + without paying the opposite-space derive/sync twice, prefer + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` + (or :meth:`xform_local_space_writer`). + Args: scales: Scales ``(M, 3)`` as ``wp.array``. indices: Subset of prims to update. ``None`` means all prims. """ + self._set_scales_impl(scales, indices) + + @abc.abstractmethod + def _get_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Backend-specific implementation for :meth:`get_scales`.""" + ... + + @abc.abstractmethod + def _set_scales_impl(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Backend-specific implementation for :meth:`set_scales`.""" ... diff --git a/source/isaaclab/isaaclab/sim/views/usd_frame_view.py b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py index 31331221e46..22707ceb8a7 100644 --- a/source/isaaclab/isaaclab/sim/views/usd_frame_view.py +++ b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py @@ -17,6 +17,7 @@ from isaaclab.utils.warp import ProxyArray from .base_frame_view import BaseFrameView +from .xform_space_writer import FrameViewLocalSpaceWriter, FrameViewWorldSpaceWriter logger = logging.getLogger(__name__) @@ -35,7 +36,14 @@ class UsdFrameView(BaseFrameView): For GPU-accelerated Fabric operations, use the PhysX backend variant obtained via :class:`~isaaclab.sim.views.FrameView`. - Getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. + All writes go through the writer-scope API (:meth:`xform_world_space_writer` + / :meth:`xform_local_space_writer`). The + USD backend's writers are pass-throughs: each :meth:`set_poses` / + :meth:`set_scales` call directly modifies the prim's USD ``xformOp:*`` + attributes (no batching, no derivation step on exit) -- USD has no + separate world-matrix storage to keep in sync. + + Getters return :class:`~isaaclab.utils.warp.ProxyArray`. .. note:: **Transform Requirements:** @@ -126,24 +134,70 @@ def prim_paths(self) -> list[str]: return self._prim_paths # ------------------------------------------------------------------ - # Setters + # Writer factory hooks (pass-through writers; USD has no derived state) + # ------------------------------------------------------------------ + + def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: + return _UsdWorldSpaceWriter(self) + + def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: + return _UsdLocalSpaceWriter(self) + + # ------------------------------------------------------------------ + # Visibility (USD-only, no writer scope) # ------------------------------------------------------------------ - def set_world_poses( + def set_visibility(self, visibility: torch.Tensor, indices: wp.array | None = None): + """Set visibility for prims in the view. + + Args: + visibility: Visibility as a boolean tensor of shape ``(M,)``. + indices: Indices of prims to set visibility for. Defaults to None (all prims). + """ + indices_list = self._resolve_indices(indices) + + if visibility.shape != (len(indices_list),): + raise ValueError(f"Expected visibility shape ({len(indices_list)},), got {visibility.shape}.") + + with Sdf.ChangeBlock(): + for idx, prim_idx in enumerate(indices_list): + imageable = UsdGeom.Imageable(self._prims[prim_idx]) + if visibility[idx]: + imageable.MakeVisible() + else: + imageable.MakeInvisible() + + def get_visibility(self, indices: wp.array | None = None) -> torch.Tensor: + """Get visibility for prims in the view. + + Args: + indices: Indices of prims to get visibility for. Defaults to None (all prims). + + Returns: + A tensor of shape ``(M,)`` containing the visibility of each prim (bool). + """ + indices_list = self._resolve_indices(indices) + + visibility = torch.zeros(len(indices_list), dtype=torch.bool, device=self._device) + for idx, prim_idx in enumerate(indices_list): + imageable = UsdGeom.Imageable(self._prims[prim_idx]) + visibility[idx] = imageable.ComputeVisibility() != UsdGeom.Tokens.invisible + return visibility + + # ------------------------------------------------------------------ + # Backend hooks: pose / scale writes (called by writers). + # ------------------------------------------------------------------ + + def _apply_world_pose_write( self, positions: wp.array | None = None, orientations: wp.array | None = None, indices: wp.array | None = None, - ): - """Set world-space poses for prims in the view. + ) -> None: + """Apply a world-space pose write directly to USD xform ops. Converts the desired world pose to local-space relative to each prim's - parent before writing to USD xform ops. - - Args: - positions: World-space positions of shape ``(M, 3)``. - orientations: World-space quaternions ``(w, x, y, z)`` of shape ``(M, 4)``. - indices: Indices of prims to set poses for. Defaults to None (all prims). + parent before writing. """ indices_list = self._resolve_indices(indices) @@ -187,19 +241,13 @@ def set_world_poses( if local_quat is not None: prim.GetAttribute("xformOp:orient").Set(local_quat) - def set_local_poses( + def _apply_local_pose_write( self, translations: wp.array | None = None, orientations: wp.array | None = None, indices: wp.array | None = None, - ): - """Set local-space poses for prims in the view. - - Args: - translations: Local-space translations of shape ``(M, 3)``. - orientations: Local-space quaternions ``(w, x, y, z)`` of shape ``(M, 4)``. - indices: Indices of prims to set poses for. Defaults to None (all prims). - """ + ) -> None: + """Apply a local-space pose write directly to USD xform ops.""" indices_list = self._resolve_indices(indices) translations_array = Vt.Vec3dArray.FromNumpy(self._to_numpy(translations)) if translations is not None else None @@ -213,13 +261,8 @@ def set_local_poses( if orientations_array is not None: prim.GetAttribute("xformOp:orient").Set(orientations_array[idx]) - def set_scales(self, scales: wp.array, indices: wp.array | None = None): - """Set scales for prims in the view. - - Args: - scales: Scales of shape ``(M, 3)``. - indices: Indices of prims to set scales for. Defaults to None (all prims). - """ + def _apply_local_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Apply a local-space scale write (``xformOp:scale``).""" indices_list = self._resolve_indices(indices) scales_array = Vt.Vec3dArray.FromNumpy(self._to_numpy(scales)) @@ -228,41 +271,41 @@ def set_scales(self, scales: wp.array, indices: wp.array | None = None): prim = self._prims[prim_idx] prim.GetAttribute("xformOp:scale").Set(scales_array[idx]) - def set_visibility(self, visibility: torch.Tensor, indices: wp.array | None = None): - """Set visibility for prims in the view. + def _apply_world_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Apply a world-space scale write. - Args: - visibility: Visibility as a boolean tensor of shape ``(M,)``. - indices: Indices of prims to set visibility for. Defaults to None (all prims). + Computes ``local_scale = world_scale / parent_world_scale`` and writes + to ``xformOp:scale``. """ indices_list = self._resolve_indices(indices) - - if visibility.shape != (len(indices_list),): - raise ValueError(f"Expected visibility shape ({len(indices_list)},), got {visibility.shape}.") + scales_np = self._to_numpy(scales) + xf_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) with Sdf.ChangeBlock(): for idx, prim_idx in enumerate(indices_list): - imageable = UsdGeom.Imageable(self._prims[prim_idx]) - if visibility[idx]: - imageable.MakeVisible() + prim = self._prims[prim_idx] + parent = prim.GetParent() + if parent and parent.IsValid() and parent.GetPath() != Sdf.Path.absoluteRootPath: + parent_world = xf_cache.GetLocalToWorldTransform(parent) + parent_scale = Gf.Vec3d( + Gf.Vec3d(parent_world[0][0], parent_world[0][1], parent_world[0][2]).GetLength(), + Gf.Vec3d(parent_world[1][0], parent_world[1][1], parent_world[1][2]).GetLength(), + Gf.Vec3d(parent_world[2][0], parent_world[2][1], parent_world[2][2]).GetLength(), + ) else: - imageable.MakeInvisible() + parent_scale = Gf.Vec3d(1.0, 1.0, 1.0) + local_scale = Gf.Vec3d( + float(scales_np[idx][0] / parent_scale[0]), + float(scales_np[idx][1] / parent_scale[1]), + float(scales_np[idx][2] / parent_scale[2]), + ) + prim.GetAttribute("xformOp:scale").Set(local_scale) # ------------------------------------------------------------------ - # Getters + # Backend hooks: pose / scale reads. # ------------------------------------------------------------------ - def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: - """Get world-space poses for prims in the view. - - Args: - indices: Indices of prims to get poses for. Defaults to None (all prims). - - Returns: - A tuple ``(positions, orientations)`` of :class:`~isaaclab.utils.warp.ProxyArray` - wrappers. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a - cached zero-copy ``torch.Tensor`` view. - """ + def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: indices_list = self._resolve_indices(indices) positions = Vt.Vec3dArray(len(indices_list)) @@ -280,17 +323,7 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, quat_wp = wp.array(np.array(orientations, dtype=np.float32), dtype=wp.float32, device=self._device) return ProxyArray(pos_wp), ProxyArray(quat_wp) - def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: - """Get local-space poses for prims in the view. - - Args: - indices: Indices of prims to get poses for. Defaults to None (all prims). - - Returns: - A tuple ``(translations, orientations)`` of :class:`~isaaclab.utils.warp.ProxyArray` - wrappers. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a - cached zero-copy ``torch.Tensor`` view. - """ + def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: indices_list = self._resolve_indices(indices) translations = Vt.Vec3dArray(len(indices_list)) @@ -308,15 +341,7 @@ def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, quat_wp = wp.array(np.array(orientations, dtype=np.float32), dtype=wp.float32, device=self._device) return ProxyArray(pos_wp), ProxyArray(quat_wp) - def get_scales(self, indices: wp.array | None = None) -> ProxyArray: - """Get scales for prims in the view. - - Args: - indices: Indices of prims to get scales for. Defaults to None (all prims). - - Returns: - A :class:`~isaaclab.utils.warp.ProxyArray` of shape ``(M, 3)``. - """ + def _get_local_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: indices_list = self._resolve_indices(indices) scales = Vt.Vec3dArray(len(indices_list)) @@ -324,25 +349,34 @@ def get_scales(self, indices: wp.array | None = None) -> ProxyArray: prim = self._prims[prim_idx] scales[idx] = prim.GetAttribute("xformOp:scale").Get() - scales_wp = wp.array(np.array(scales, dtype=np.float32), dtype=wp.float32, device=self._device) - return ProxyArray(scales_wp) - - def get_visibility(self, indices: wp.array | None = None) -> torch.Tensor: - """Get visibility for prims in the view. - - Args: - indices: Indices of prims to get visibility for. Defaults to None (all prims). + return ProxyArray(wp.array(np.array(scales, dtype=np.float32), dtype=wp.float32, device=self._device)) - Returns: - A tensor of shape ``(M,)`` containing the visibility of each prim (bool). - """ + def _get_world_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: indices_list = self._resolve_indices(indices) + xf_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) - visibility = torch.zeros(len(indices_list), dtype=torch.bool, device=self._device) + scales = np.empty((len(indices_list), 3), dtype=np.float32) for idx, prim_idx in enumerate(indices_list): - imageable = UsdGeom.Imageable(self._prims[prim_idx]) - visibility[idx] = imageable.ComputeVisibility() != UsdGeom.Tokens.invisible - return visibility + prim = self._prims[prim_idx] + world_mtx = xf_cache.GetLocalToWorldTransform(prim) + scales[idx, 0] = Gf.Vec3d(world_mtx[0][0], world_mtx[0][1], world_mtx[0][2]).GetLength() + scales[idx, 1] = Gf.Vec3d(world_mtx[1][0], world_mtx[1][1], world_mtx[1][2]).GetLength() + scales[idx, 2] = Gf.Vec3d(world_mtx[2][0], world_mtx[2][1], world_mtx[2][2]).GetLength() + + return ProxyArray(wp.array(scales, dtype=wp.float32, device=self._device)) + + # ------------------------------------------------------------------ + # Deprecated get_scales / set_scales hooks + # ------------------------------------------------------------------ + + def _get_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """USD legacy: get_scales returns local scales.""" + return self._get_local_scales_impl(indices) + + def _set_scales_impl(self, scales: wp.array, indices: wp.array | None = None) -> None: + """USD legacy: set_scales writes local scales via a one-shot writer scope.""" + with self.xform_local_space_writer() as writer: + writer.set_scales(scales, indices) # ------------------------------------------------------------------ # Helpers @@ -360,3 +394,44 @@ def _to_numpy(data: wp.array | torch.Tensor) -> np.ndarray: if isinstance(data, wp.array): return data.numpy() return data.cpu().numpy() + + +# ---------------------------------------------------------------------- +# Pass-through writer classes +# ---------------------------------------------------------------------- + + +class _UsdWorldSpaceWriter(FrameViewWorldSpaceWriter): + """USD world-space writer: pass-through to backend ``_apply_*`` hooks. + + USD has no separate world-matrix storage to keep in sync; ``__exit__`` + is a no-op beyond releasing the single-writer lock. + """ + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_world_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_world_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_world_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_world_scales_impl(indices) # type: ignore[attr-defined] + + +class _UsdLocalSpaceWriter(FrameViewLocalSpaceWriter): + """USD local-space writer: pass-through to backend ``_apply_*`` hooks.""" + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_local_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_local_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_local_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_local_scales_impl(indices) # type: ignore[attr-defined] diff --git a/source/isaaclab/isaaclab/sim/views/xform_space_writer.py b/source/isaaclab/isaaclab/sim/views/xform_space_writer.py new file mode 100644 index 00000000000..2d657b03199 --- /dev/null +++ b/source/isaaclab/isaaclab/sim/views/xform_space_writer.py @@ -0,0 +1,145 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Context-managed transform writers for :class:`~isaaclab.sim.views.BaseFrameView`. + +This module defines the recommended write API for FrameView poses and scales: + +.. code-block:: python + + with view.xform_world_space_writer() as writer: + writer.set_poses(positions=p, orientations=o) + writer.set_scales(scales=s) + # ... any number of writes ... + # On exit the writer derives the opposite-space matrices once, + # synchronizes once, and restores any saved Fabric tracking state. + +Only one writer may be active per view at a time. While a writer scope is +active on a view, view-level getters (``view.get_world_poses``, +``view.get_local_poses``, ``view.get_world_scales``, +``view.get_local_scales``) raise :class:`RuntimeError` -- use the writer's own +:meth:`~FrameViewSpaceWriterBase.get_poses` / :meth:`~FrameViewSpaceWriterBase.get_scales` +inside the scope, or exit the scope first. + +**Do not advance the simulation or render from inside a scope.** The scope +runs as synchronous Python code, so no ``sim.step()`` / ``world.render()`` / +``SimulationApp.update()`` is allowed inside the ``with`` block. Until the +scope exits, the backend's matrices may be mid-write (some prims updated, +others not; the opposite-space derive has not yet run) and rendering against +that state would read torn data. Keep scopes short and step the +simulation outside them. +""" + +from __future__ import annotations + +import abc +from typing import TYPE_CHECKING + +import warp as wp + +from isaaclab.utils.warp import ProxyArray + +if TYPE_CHECKING: + from .base_frame_view import BaseFrameView + + +class FrameViewSpaceWriterBase(abc.ABC): + """Abstract context-managed writer for a single transform space. + + Subclasses are returned by :meth:`BaseFrameView.xform_world_space_writer` / + :meth:`BaseFrameView.xform_local_space_writer`; they + are not constructed directly. The class is intentionally minimal -- the + pose/scale semantics depend on the writer's space (world or local), which + is conveyed by the concrete tag class :class:`FrameViewWorldSpaceWriter` or + :class:`FrameViewLocalSpaceWriter`. + + The scope runs as synchronous Python code: no simulation step and no + render tick can run while it is open, and the caller must not advance + either from inside the ``with`` block. See the module docstring for the + full contract. + """ + + def __init__(self, view: BaseFrameView): + self._view = view + + @abc.abstractmethod + def set_poses( + self, + positions: wp.array | None = None, + orientations: wp.array | None = None, + indices: wp.array | None = None, + ) -> None: + """Set positions and/or orientations in this writer's space. + + Args: + positions: Positions ``(M, 3)``. ``None`` leaves positions unchanged. + orientations: Quaternions ``(M, 4)`` in ``(x, y, z, w)``. + ``None`` leaves orientations unchanged. + indices: Subset of prims to update. ``None`` means all prims. + """ + ... + + @abc.abstractmethod + def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set scales in this writer's space. + + Args: + scales: Scales ``(M, 3)`` as ``wp.array``. + indices: Subset of prims to update. ``None`` means all prims. + """ + ... + + @abc.abstractmethod + def get_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Return ``(positions, orientations)`` in this writer's space. + + Reflects any in-scope writes that have already been queued on the + underlying device stream. + """ + ... + + @abc.abstractmethod + def get_scales(self, indices: wp.array | None = None) -> ProxyArray: + """Return scales in this writer's space.""" + ... + + def __enter__(self) -> FrameViewSpaceWriterBase: + if self._view._active_writer is not None: + raise RuntimeError( + f"{type(self._view).__name__} already has an active writer scope " + f"({type(self._view._active_writer).__name__}). Exit the existing scope before " + "opening a new one." + ) + self._view._active_writer = self + self._enter_impl() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + try: + self._exit_impl(exc_type, exc_val, exc_tb) + finally: + self._view._active_writer = None + + def _enter_impl(self) -> None: + """Backend hook called after the single-active-writer lock is claimed.""" + + def _exit_impl(self, exc_type, exc_val, exc_tb) -> None: + """Backend hook called before the single-active-writer lock is released.""" + + +class FrameViewWorldSpaceWriter(FrameViewSpaceWriterBase): + """Writer whose :meth:`set_poses` / :meth:`set_scales` write world-space values. + + On context exit the opposite-space (``local``) matrices are derived from + the just-written world matrices in a single Warp kernel launch. + """ + + +class FrameViewLocalSpaceWriter(FrameViewSpaceWriterBase): + """Writer whose :meth:`set_poses` / :meth:`set_scales` write local-space values. + + On context exit the opposite-space (``world``) matrices are derived from + the just-written local matrices in a single Warp kernel launch. + """ diff --git a/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py b/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py index db34c9a1719..d457c1606c8 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 739f8094002..68498cfde40 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/__init__.pyi b/source/isaaclab/isaaclab/test/benchmark/__init__.pyi index 80ebd64f478..d9553e9e52d 100644 --- a/source/isaaclab/isaaclab/test/benchmark/__init__.pyi +++ b/source/isaaclab/isaaclab/test/benchmark/__init__.pyi @@ -31,6 +31,7 @@ __all__ = [ "LearningCurve", "MeanStd", "PhysicsBackend", + "PlayBundle", "RenderingBackend", "Resources", "RunConfig", @@ -78,6 +79,7 @@ from .schema import ( LearningCurve, MeanStd, PhysicsBackend, + PlayBundle, RenderingBackend, Resources, RunConfig, diff --git a/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py b/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py index 7daedbc132a..f485cb30587 100644 --- a/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py +++ b/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py @@ -8,12 +8,34 @@ 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, + PlayBundle, + Runtime, + RuntimeBundle, + StartupBundle, + TrainingBundle, + ) logger = logging.getLogger(__name__) @@ -39,33 +61,146 @@ 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 | PlayBundle", +) -> dict[str, list[Measurement]]: + """Project a typed bundle into flat phases for non-schema formatters.""" + from isaaclab.test.benchmark.schema import PlayBundle, 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 + elif isinstance(bundle, PlayBundle): + play: list[Measurement] = [] + if bundle.reward is not None: + play.extend(_stat_measurements("Reward", bundle.reward, "float")) + if bundle.ep_length is not None: + play.extend(_stat_measurements("Episode Length", bundle.ep_length, "steps")) + if bundle.success_rate is not None: + play.append(SingleMeasurement(name="success_rate", value=bundle.success_rate, unit="float")) + if play: + projected["play"] = play + 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 +212,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 +352,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 | PlayBundle | None") -> None: + """Attach a typed bundle for schema serialization and flat-formatter projection. + + Args: + bundle: Runtime, training, startup, or play 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 +388,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 +438,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 00000000000..a6bb7297d3f --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/builders.py @@ -0,0 +1,356 @@ +# 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, + MeanStd, + PlayBundle, + 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_play_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + runtime: Runtime, + resources: Resources, + success_rate: float | None = None, + reward: MeanStd | None = None, + ep_length: MeanStd | None = None, + checkpoint_path: str | None = None, + video_path: str | None = None, + extra: dict | None = None, +) -> PlayBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.PlayBundle`. + + Args: + run: Run identity metadata. + versions: Software versions snapshot. + hardware: Host hardware snapshot. + runtime: Aggregated runtime metrics. + resources: Aggregated resource-utilisation metrics. + success_rate: Mean success rate ``[0..1]`` over completed episodes, or + ``None`` when the task does not report one. + reward: Episode-return aggregate over completed episodes, or ``None`` + when no episode completed. + ep_length: Episode-length aggregate over completed episodes, or ``None`` + when no episode completed. + checkpoint_path: Path to the policy checkpoint that was rolled out. + 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.PlayBundle`. + """ + return PlayBundle( + run=run, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + success_rate=success_rate, + reward=reward, + ep_length=ep_length, + 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 00000000000..5f93c70f3ef --- /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 6b6c0acacb1..b573f818810 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 PlayBundle, 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 | PlayBundle | 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 54c14b12061..5004a694277 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 00000000000..368fd9dd551 --- /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 00000000000..d108dcfe3ba --- /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 a4396371b3d..66ca064554a 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 7a0e2d733fd..4252e594c18 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) @@ -123,7 +135,7 @@ class RunConfig: rendering_backend: Rendering backend, or ``"none"`` for headless runs with no camera sensors. presets: Active Hydra preset tokens applied to the run (e.g. - ``["rgb", "ovrtx_renderer"]``). Open-ended so sensor data types, + ``["rgb", "ovrtx"]``). Open-ended so sensor data types, resolutions, and any other domain presets are captured without a closed enum; ``physics_backend`` / ``rendering_backend`` surface the two primary grouping dimensions as typed fields. @@ -298,6 +310,46 @@ class TrainingBundle: schema_version: str = SCHEMA_VERSION +@dataclass(frozen=True) +class PlayBundle: + """Top-level shape of ``play.json`` — a checkpoint-driven inference rollout. + + Mirrors :class:`RuntimeBundle` (with :attr:`RunIdentity.framework` set to the + RL library that produced the checkpoint) and adds the inference-evaluation + aggregates: a success rate plus scalar reward and episode-length statistics. + Unlike :class:`TrainingBundle`, :attr:`reward` and :attr:`ep_length` are + scalar :class:`MeanStd` aggregates over completed episodes, **not** + per-iteration learning curves. + + Args: + success_rate: Mean success rate ``[0..1]`` over completed episodes, or + ``None`` when the task does not report one. + reward: Episode-return aggregate over completed episodes, or ``None`` + when no episode completed. + ep_length: Episode-length aggregate over completed episodes, or ``None`` + when no episode completed. + checkpoint_path: Path to the policy checkpoint that was rolled out. + video_path: Path to a recorded rollout video/gif, if any. + extra: Optional free-form scalar values (experimental or producer-specific) + that are **not** part of the stable schema contract. Consumers must + tolerate its absence and must not depend on specific keys; promote a key + to a typed field once it is stable and broadly useful. + """ + + run: RunIdentity + versions: Versions + hardware: Hardware + runtime: Runtime + resources: Resources + success_rate: float | None = None + reward: MeanStd | None = None + ep_length: MeanStd | None = None + checkpoint_path: str | None = None + video_path: str | None = None + extra: dict[str, float | int | str | bool] | None = None + schema_version: str = SCHEMA_VERSION + + @dataclass(frozen=True) class CProfileFunction: """One entry from a cProfile top-N table. diff --git a/source/isaaclab/isaaclab/test/benchmark/stepping.py b/source/isaaclab/isaaclab/test/benchmark/stepping.py new file mode 100644 index 00000000000..9536d626765 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/stepping.py @@ -0,0 +1,207 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""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 + + from .schema import MeanStd + + +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 + + +def _extract_success(extras) -> float | None: + """Pull a scalar success value out of a step's ``extras`` mapping. + + Scans the ``"log"`` and ``"episode"`` sub-dicts of *extras* for the first + key whose name contains ``"success"`` (case-insensitive) and returns its + value as a ``float`` (calling ``.item()`` when the value is a tensor). + Returns ``None`` when *extras* is not a dict (e.g. the per-env list of info + dicts that Stable-Baselines3 vec envs return), since no single scannable + mapping is available in that case. + + Args: + extras: The per-step ``extras``/``info`` value returned by ``env.step``. + + Returns: + The success value as a ``float``, or ``None`` when no success key is + present. + """ + if not isinstance(extras, dict): + return None + for sub_key in ("log", "episode"): + sub = extras.get(sub_key) + if not isinstance(sub, dict): + continue + for key, value in sub.items(): + if "success" in key.lower(): + return float(value.item()) if hasattr(value, "item") else float(value) + return None + + +def run_play_loop(env, policy, num_frames: int) -> tuple[list[float], MeanStd | None, MeanStd | None, float | None]: + """Roll out *policy* in *env* for *num_frames* steps and aggregate episode metrics. + + Resets the environment, then on each frame runs the policy under + ``torch.inference_mode()`` and steps the environment, recording the + per-step wall time [s]. Per-environment returns and lengths are accumulated + and, whenever an environment signals ``done``, that episode's return, + length, and (if present) success value are recorded and the environment's + accumulators are reset. + + Both the four-tuple ``(obs, reward, dones, extras)`` and the Gym five-tuple + ``(obs, reward, terminated, truncated, info)`` step signatures are accepted; + for the latter ``dones`` is ``terminated | truncated`` and ``extras`` is + ``info``. Rewards and dones are coerced via ``torch.as_tensor`` so NumPy + returns (e.g. from Stable-Baselines3) work as well. + + Args: + env: A Gym-compatible environment whose ``unwrapped`` exposes + ``num_envs`` and ``device``. + policy: Callable mapping an observation batch to an action batch. + num_frames: Number of environment steps to run. + + Returns: + A tuple ``(step_times, reward, ep_length, success_rate)`` where + ``step_times`` is the per-step wall times [s], ``reward`` and + ``ep_length`` are :class:`~isaaclab.test.benchmark.schema.MeanStd` + aggregates over completed episodes (or ``None`` if none completed), and + ``success_rate`` is the mean of collected success values rounded to four + decimals (or ``None`` if none were reported). + """ + import torch # noqa: PLC0415 + + from isaaclab.test.benchmark.metrics import mean_std_peak # noqa: PLC0415 + + u = env.unwrapped + num_envs = u.num_envs + device = u.device + + obs = env.reset() + if isinstance(obs, tuple): + obs = obs[0] + + running_return = torch.zeros(num_envs, device=device) + running_length = torch.zeros(num_envs, device=device) + + step_times: list[float] = [] + episode_returns: list[float] = [] + episode_lengths: list[float] = [] + successes: list[float] = [] + + for _ in range(num_frames): + t0 = time.perf_counter_ns() + with torch.inference_mode(): + actions = policy(obs) + result = env.step(actions) + t1 = time.perf_counter_ns() + step_times.append((t1 - t0) / 1e9) + + if len(result) == 5: + obs, reward, terminated, truncated, extras = result + dones = torch.as_tensor(terminated, device=device) | torch.as_tensor(truncated, device=device) + else: + obs, reward, dones, extras = result + dones = torch.as_tensor(dones, device=device) + reward = torch.as_tensor(reward, dtype=torch.float32, device=device) + + # Flatten reward/done to (num_envs,) — some wrappers (e.g. skrl) return shape (num_envs, 1). + reward = reward.reshape(num_envs) + dones = dones.reshape(num_envs) + + running_return += reward + running_length += 1.0 + + done_mask = dones.to(torch.bool) + if bool(done_mask.any()): + success_value = _extract_success(extras) + for env_idx in torch.nonzero(done_mask, as_tuple=False).flatten().tolist(): + episode_returns.append(float(running_return[env_idx].item())) + episode_lengths.append(float(running_length[env_idx].item())) + if success_value is not None: + successes.append(success_value) + running_return[env_idx] = 0.0 + running_length[env_idx] = 0.0 + + reward_agg = mean_std_peak(episode_returns) if episode_returns else None + ep_length_agg = mean_std_peak(episode_lengths) if episode_lengths else None + success_rate = round(sum(successes) / len(successes), 4) if successes else None + + return step_times, reward_agg, ep_length_agg, success_rate diff --git a/source/isaaclab/isaaclab/utils/_device.py b/source/isaaclab/isaaclab/utils/_device.py new file mode 100644 index 00000000000..020be59dfec --- /dev/null +++ b/source/isaaclab/isaaclab/utils/_device.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Internal utilities for selecting compute devices.""" + +from __future__ import annotations + + +def set_cuda_device(device: str | int) -> None: + """Set the process-wide CUDA device for both PyTorch and Warp. + + PyTorch must select the device before Warp so that a newly imported Warp + runtime initializes on that device, or an existing runtime switches to it. + + Args: + device: CUDA device index or a device string such as ``"cuda:1"``. + """ + import torch + + torch.cuda.set_device(device) + + import warp as wp + + warp_device = f"cuda:{device}" if isinstance(device, int) else device + wp.set_device(warp_device) diff --git a/source/isaaclab/isaaclab/utils/configclass.py b/source/isaaclab/isaaclab/utils/configclass.py index f44f1288e01..0de67a21c13 100644 --- a/source/isaaclab/isaaclab/utils/configclass.py +++ b/source/isaaclab/isaaclab/utils/configclass.py @@ -283,20 +283,25 @@ class State: continue # get base class annotations ann = base.__dict__.get("__annotations__", {}) - # directly add all annotations from base class - hints.update(ann) # iterate over base class members # Note: Do not change this to dir(base) since it orders the members alphabetically. # This is not desirable since the order of the members is important in some cases. + # Note: We add annotated members while iterating over the class members (instead of + # bulk-adding all annotations beforehand) to preserve the declaration order when + # only some members have type annotations. Otherwise, annotated members would jump + # ahead of non-annotated ones in the resulting field order. for key in base.__dict__: # get class member value = getattr(base, key) # skip members if _skippable_class_member(key, value, hints): continue + # add type annotations for members that have explicit type annotations + if key in ann: + hints[key] = ann[key] # add type annotations for members that don't have explicit type annotations # for these, we deduce the type from the default value - if not isinstance(value, type): + elif not isinstance(value, type): if key not in hints: # check if var type is not MISSING # we cannot deduce type from MISSING! @@ -312,6 +317,10 @@ class State: # the name of the type matches the name of the variable. # since Python 3.10, type hints are stored as strings hints[key] = f"type[{value.__name__}]" + # add remaining annotations that do not have a corresponding class member (e.g. annotation-only + # declarations) or whose member was skipped above. For keys already present in the hints, + # this only refreshes the type and keeps their original position. + hints.update(ann) # Note: Do not change this line. `cls.__dict__.get("__annotations__", {})` is different from # `cls.__annotations__` because of inheritance. diff --git a/source/isaaclab/isaaclab/utils/images.py b/source/isaaclab/isaaclab/utils/images.py index f832af8381e..bb02548d55c 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/isaaclab/utils/renderers.py b/source/isaaclab/isaaclab/utils/renderers.py new file mode 100644 index 00000000000..171204e9501 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/renderers.py @@ -0,0 +1,18 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Renderer-related utility helpers.""" + +import os + + +def isaac_rtx_per_env_scene_partition_enabled() -> bool: + """Return whether per-environment RTX scene partitioning is enabled. + + Partitioning is opt-in: set ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` + to enable authoring of ``primvars:omni:scenePartition`` and ``omni:scenePartition`` + on the USD stage. + """ + return os.environ.get("ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION", "0") == "1" diff --git a/source/isaaclab/isaaclab/utils/warp/fabric.py b/source/isaaclab/isaaclab/utils/warp/fabric.py index a48f773f499..e0519d98c33 100644 --- a/source/isaaclab/isaaclab/utils/warp/fabric.py +++ b/source/isaaclab/isaaclab/utils/warp/fabric.py @@ -18,12 +18,14 @@ if TYPE_CHECKING: FabricArrayUInt32 = Any FabricArrayMat44d = Any + IndexedFabricArrayMat44d = Any ArrayUInt32 = Any ArrayUInt32_1d = Any ArrayFloat32_2d = Any else: FabricArrayUInt32 = wp.fabricarray(dtype=wp.uint32) FabricArrayMat44d = wp.fabricarray(dtype=wp.mat44d) + IndexedFabricArrayMat44d = wp.indexedfabricarray(dtype=wp.mat44d) ArrayUInt32 = wp.array(ndim=1, dtype=wp.uint32) ArrayUInt32_1d = wp.array(dtype=wp.uint32) ArrayFloat32_2d = wp.array(ndim=2, dtype=wp.float32) @@ -163,6 +165,180 @@ def compose_fabric_transformation_matrix_from_warp_arrays( ) +@wp.kernel(enable_backward=False) +def decompose_indexed_fabric_transforms( + fabric_matrices: IndexedFabricArrayMat44d, + array_positions: ArrayFloat32_2d, + array_orientations: ArrayFloat32_2d, + array_scales: ArrayFloat32_2d, + indices: ArrayUInt32, +): + """Decompose indexed Fabric transformation matrices into position, orientation, and scale. + + Like :func:`decompose_fabric_transformation_matrix_to_warp_arrays` but operates on a + :class:`wp.indexedfabricarray` that already encodes the view-to-fabric mapping, removing + the need for a separate ``mapping`` array. + + Args: + fabric_matrices: Indexed fabric array containing 4x4 transformation matrices. + array_positions: Output array for positions [m], shape (N, 3). + array_orientations: Output array for quaternions in xyzw format, shape (N, 4). + array_scales: Output array for scales, shape (N, 3). + indices: View indices to process (subset selection). + """ + output_index = wp.tid() + view_index = indices[output_index] + + position, rotation, scale = _decompose_transformation_matrix(wp.mat44f(fabric_matrices[view_index])) + + if array_positions.shape[0] > 0: + array_positions[output_index, 0] = position[0] + array_positions[output_index, 1] = position[1] + array_positions[output_index, 2] = position[2] + if array_orientations.shape[0] > 0: + array_orientations[output_index, 0] = rotation[0] + array_orientations[output_index, 1] = rotation[1] + array_orientations[output_index, 2] = rotation[2] + array_orientations[output_index, 3] = rotation[3] + if array_scales.shape[0] > 0: + array_scales[output_index, 0] = scale[0] + array_scales[output_index, 1] = scale[1] + array_scales[output_index, 2] = scale[2] + + +@wp.kernel(enable_backward=False) +def compose_indexed_fabric_transforms( + fabric_matrices: IndexedFabricArrayMat44d, + array_positions: ArrayFloat32_2d, + array_orientations: ArrayFloat32_2d, + array_scales: ArrayFloat32_2d, + broadcast_positions: bool, + broadcast_orientations: bool, + broadcast_scales: bool, + indices: ArrayUInt32, +): + """Compose indexed Fabric transformation matrices from position, orientation, and scale. + + Like :func:`compose_fabric_transformation_matrix_from_warp_arrays` but operates on a + :class:`wp.indexedfabricarray` that already encodes the view-to-fabric mapping, removing + the need for a separate ``mapping`` array. + + Args: + fabric_matrices: Indexed fabric array containing 4x4 transformation matrices to update. + array_positions: Input array for positions [m], shape (N, 3). + array_orientations: Input array for quaternions in xyzw format, shape (N, 4). + array_scales: Input array for scales, shape (N, 3). + broadcast_positions: If True, use first position for all prims. + broadcast_orientations: If True, use first orientation for all prims. + broadcast_scales: If True, use first scale for all prims. + indices: View indices to process (subset selection). + """ + i = wp.tid() + view_index = indices[i] + position, rotation, scale = _decompose_transformation_matrix(wp.mat44f(fabric_matrices[view_index])) + + if array_positions.shape[0] > 0: + if broadcast_positions: + index = 0 + else: + index = i + position[0] = array_positions[index, 0] + position[1] = array_positions[index, 1] + position[2] = array_positions[index, 2] + if array_orientations.shape[0] > 0: + if broadcast_orientations: + index = 0 + else: + index = i + rotation[0] = array_orientations[index, 0] + rotation[1] = array_orientations[index, 1] + rotation[2] = array_orientations[index, 2] + rotation[3] = array_orientations[index, 3] + if array_scales.shape[0] > 0: + if broadcast_scales: + index = 0 + else: + index = i + scale[0] = array_scales[index, 0] + scale[1] = array_scales[index, 1] + scale[2] = array_scales[index, 2] + + fabric_matrices[view_index] = wp.mat44d( # type: ignore[arg-type] + wp.transpose(wp.transform_compose(position, rotation, scale)) # type: ignore[arg-type] + ) + + +@wp.kernel(enable_backward=False) +def update_indexed_local_matrix_from_world( + child_world_matrices: IndexedFabricArrayMat44d, + parent_world_matrices: IndexedFabricArrayMat44d, + child_local_matrices: IndexedFabricArrayMat44d, + indices: ArrayUInt32, +): + """Recompute child localMatrix from (parent worldMatrix, child worldMatrix). + + Computes ``child_local = inv(parent_world) * child_world`` per prim and writes the + result back to the child's :data:`omni:fabric:localMatrix` so that subsequent + ``get_local_poses`` calls see consistent values after a world-pose write. + + All three indexed arrays are expected to be indexed by the same per-view indices + (i.e. ``view_to_child_fabric``, ``view_to_parent_fabric``, ``view_to_child_fabric``) + so the kernel only needs the view-side indices. + + Storage convention: Fabric matrices are stored as the transpose of the standard + column-major math convention. Math is ``local = inv(parent) * world``; under + the transpose identity ``(A * B)^T = B^T * A^T`` (and ``inv(A^T) = inv(A)^T``) + that is equivalent to storage-side ``local^T = world^T * inv(parent^T)``, so we + can compute it directly on the stored matrices without explicit transposes. + + Args: + child_world_matrices: Indexed fabric array of child world matrices (read). + parent_world_matrices: Indexed fabric array of parent world matrices (read). + child_local_matrices: Indexed fabric array of child local matrices (written). + indices: View indices to process. + """ + i = wp.tid() + view_index = indices[i] + child_world = wp.mat44f(child_world_matrices[view_index]) + parent_world = wp.mat44f(parent_world_matrices[view_index]) + child_local_matrices[view_index] = wp.mat44d( # type: ignore[arg-type] + child_world * wp.inverse(parent_world) + ) + + +@wp.kernel(enable_backward=False) +def update_indexed_world_matrix_from_local( + child_local_matrices: IndexedFabricArrayMat44d, + parent_world_matrices: IndexedFabricArrayMat44d, + child_world_matrices: IndexedFabricArrayMat44d, + indices: ArrayUInt32, +): + """Recompute child worldMatrix from (parent worldMatrix, child localMatrix). + + Computes ``child_world = parent_world * child_local`` per prim and writes the + result back to the child's :data:`omni:fabric:worldMatrix`. Used after a + ``set_local_poses`` write so that subsequent ``get_world_poses`` calls see + consistent values. Mirror of :func:`update_indexed_local_matrix_from_world`. + + Args: + child_local_matrices: Indexed fabric array of child local matrices (read). + parent_world_matrices: Indexed fabric array of parent world matrices (read). + child_world_matrices: Indexed fabric array of child world matrices (written). + indices: View indices to process. + + Storage convention: same as :func:`update_indexed_local_matrix_from_world`. + Math is ``world = parent * local``; under the transpose identity that becomes + storage-side ``world^T = local^T * parent^T``, no explicit transposes needed. + """ + i = wp.tid() + view_index = indices[i] + child_local = wp.mat44f(child_local_matrices[view_index]) + parent_world = wp.mat44f(parent_world_matrices[view_index]) + child_world_matrices[view_index] = wp.mat44d( # type: ignore[arg-type] + child_local * parent_world + ) + + @wp.func def _decompose_transformation_matrix(m: Any): # -> tuple[wp.vec3f, wp.quatf, wp.vec3f] """Decompose a 4x4 transformation matrix into position, orientation, and scale. diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 03511cf9f6d..46d4dd7ea1f 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,96 +9,27 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "7.0.5" +version = "10.2.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["kit", "robotics", "learning", "ai"] requires-python = ">=3.12" -dependencies = [ - # generic - "numpy>=2", - "torch>=2.10", - "onnx>=1.18.0", - "prettytable>=3.3.0", - # devices - "hidapi>=0.14.0", - # reinforcement learning - "gymnasium>=1.2.0", - # procedural-generation - "trimesh", - "pyglet>=2.1.6,<3", - # pytetwild ships only an x86_64 manylinux wheel and its sdist fails to build on - # aarch64 (CMake hardcodes -m64). Gate it on x86_64 so ARM64 docker builds are - # not blocked; tetrahedralize callers degrade gracefully via ImportError when missing. - # Pinned to ==0.2.3: >=0.3 unconditionally imports pyvista at package import time. - "pytetwild==0.2.3 ; platform_machine in 'x86_64 AMD64'", - # image processing - "transformers==4.57.6", - "einops", - "warp-lang==1.14.0", - "matplotlib>=3.10.3", - # keep consistent with isaac sim version - "pillow==12.1.1", - # required by omni.replicator.core S3 backend - "botocore", - # livestream (range chosen to coexist with isaacsim 6.0) - "starlette>=0.46.0,<0.50", - "omniverseclient==2.71.1.7015", - # cross-platform file locking - "filelock", - "lazy_loader>=0.4", - # IK controller (Linux x86_64 + ARM64), requires pinning to 3.1.0 and 0.8.5 to avoid breaking change - "pin ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", - "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'", - # 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'", -] +# Third-party requirements are centralized in the root pyproject.toml +# (single source of truth for the uv workspace, the wheel builder, and the +# ./isaaclab.sh -i install CLI). +dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" +# Only the ``isaaclab`` entry point is exposed; training/play are subcommands +# (``isaaclab train`` / ``isaaclab play`` / ``isaaclab train_multigpu``) so they +# do not clash with ``train``/``play`` scripts from other installed packages. [project.scripts] isaaclab = "isaaclab.cli:cli" -play = "isaaclab.cli:play" -train = "isaaclab.cli:train" -train_multigpu = "isaaclab.cli:train_multigpu" - -[project.optional-dependencies] -test = [ - "pytest", - "pytest-mock", - "junitparser", - "flatdict>=4.1.0", - "flaky", - # coverage>=7.6.1 restored coverage.types.Tracer as a shim; required for - # numba compatibility (numba subclasses Tracer at import time) - "coverage>=7.6.1", -] -isaacsim = ["isaacsim[all,extscache]>=6.0.0"] -all = [ - "isaacsim[all,extscache]>=6.0.0", - "isaaclab_assets", - "isaaclab_contrib", - "isaaclab_experimental", - "isaaclab_mimic", - "isaaclab_newton[all]", - "isaaclab_ov", - "isaaclab_ovphysx", - "isaaclab_physx[newton]", - "isaaclab_ppisp", - "isaaclab_rl[all]", - "isaaclab_tasks", - "isaaclab_tasks_experimental", - "isaaclab_teleop", - "isaaclab_visualizers[all]", -] [tool.setuptools] include-package-data = true diff --git a/source/isaaclab/test/actuators/test_dc_motor.py b/source/isaaclab/test/actuators/test_dc_motor.py index 81682cc5d22..a3efc49b3ca 100644 --- a/source/isaaclab/test/actuators/test_dc_motor.py +++ b/source/isaaclab/test/actuators/test_dc_motor.py @@ -8,6 +8,8 @@ from isaaclab.actuators import DCMotorCfg +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("num_envs", [1, 2]) @pytest.mark.parametrize("num_joints", [1, 2]) diff --git a/source/isaaclab/test/actuators/test_ideal_pd_actuator.py b/source/isaaclab/test/actuators/test_ideal_pd_actuator.py index 5fc3a675574..01f19007ae9 100644 --- a/source/isaaclab/test/actuators/test_ideal_pd_actuator.py +++ b/source/isaaclab/test/actuators/test_ideal_pd_actuator.py @@ -9,6 +9,8 @@ from isaaclab.actuators import IdealPDActuatorCfg from isaaclab.utils.types import ArticulationActions +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("num_envs", [1, 2]) @pytest.mark.parametrize("num_joints", [1, 2]) diff --git a/source/isaaclab/test/actuators/test_implicit_actuator.py b/source/isaaclab/test/actuators/test_implicit_actuator.py index c4a26f2f953..860e53765bc 100644 --- a/source/isaaclab/test/actuators/test_implicit_actuator.py +++ b/source/isaaclab/test/actuators/test_implicit_actuator.py @@ -18,6 +18,8 @@ from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.sim import build_simulation_context +pytestmark = pytest.mark.integration + @pytest.fixture def sim(request): diff --git a/source/isaaclab/test/app/test_argparser_launch.py b/source/isaaclab/test/app/test_argparser_launch.py index 6bce001522b..21c75d24dac 100644 --- a/source/isaaclab/test/app/test_argparser_launch.py +++ b/source/isaaclab/test/app/test_argparser_launch.py @@ -9,6 +9,8 @@ from isaaclab.app import AppLauncher +pytestmark = pytest.mark.integration + @pytest.mark.usefixtures("mocker") def test_livestream_launch_with_argparser(mocker): diff --git a/source/isaaclab/test/app/test_env_var_launch.py b/source/isaaclab/test/app/test_env_var_launch.py index 6a58b220692..6e98df4aef2 100644 --- a/source/isaaclab/test/app/test_env_var_launch.py +++ b/source/isaaclab/test/app/test_env_var_launch.py @@ -4,11 +4,15 @@ # SPDX-License-Identifier: BSD-3-Clause import os +import sys import pytest +import isaaclab.app.app_launcher as app_launcher_module from isaaclab.app import AppLauncher +pytestmark = pytest.mark.integration + @pytest.mark.usefixtures("mocker") def test_livestream_launch_with_env_vars(mocker): @@ -33,3 +37,48 @@ def test_livestream_launch_with_env_vars(mocker): # close the app on exit app.close() + + +def _resolve_kit_args( + monkeypatch: pytest.MonkeyPatch, launcher_args: dict, argv: list[str] | None = None +) -> tuple[list[str], list[str]]: + monkeypatch.setattr(sys, "argv", ["pytest", *(argv or [])]) + launcher = AppLauncher.__new__(AppLauncher) + launcher._resolve_kit_args(launcher_args) + return sys.argv[1:], launcher._kit_args + + +@pytest.mark.parametrize("env_value, expected", [("0", "false"), ("1", "true")]) +def test_fabric_gpu_interop_env_adds_override(monkeypatch: pytest.MonkeyPatch, env_value: str, expected: str): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, env_value) + + kit_args, _ = _resolve_kit_args(monkeypatch, {}) + + assert kit_args == [f"--/physics/fabricUseGPUInterop={expected}"] + + +def test_fabric_gpu_interop_env_rejects_invalid_value(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "false") + + with pytest.raises(ValueError, match="Expected: 0 or 1"): + _resolve_kit_args(monkeypatch, {}) + + +def test_explicit_kit_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") + explicit_arg = "--/physics/fabricUseGPUInterop=true" + + kit_args, resolved_args = _resolve_kit_args(monkeypatch, {}, [explicit_arg]) + + assert kit_args == [explicit_arg] + assert resolved_args == [] + + +def test_explicit_kit_args_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") + explicit_arg = "--/physics/fabricUseGPUInterop=true" + + kit_args, resolved_args = _resolve_kit_args(monkeypatch, {"kit_args": explicit_arg}) + + assert kit_args == [explicit_arg] + assert resolved_args == [explicit_arg] diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index 1eb5b59764a..2359f009e82 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -4,13 +4,19 @@ # 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 +pytestmark = pytest.mark.integration + @pytest.mark.usefixtures("mocker") def test_livestream_launch_with_kwargs(mocker): @@ -40,6 +46,52 @@ 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} + + +def test_deferred_cuda_device_synchronizes_torch_and_warp(monkeypatch: pytest.MonkeyPatch): + """The post-Kit device hook must synchronize both CUDA runtimes.""" + devices = [] + monkeypatch.setattr(app_launcher_module, "set_cuda_device", devices.append) + launcher = AppLauncher.__new__(AppLauncher) + launcher._deferred_cuda_device_id = 2 + + launcher._set_deferred_cuda_device() + + assert devices == [2] + + class _DummySettings: def __init__(self): self.values = {} @@ -54,6 +106,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(app_launcher_module, "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/app/test_non_headless_launch.py b/source/isaaclab/test/app/test_non_headless_launch.py index 5c7b5bad24c..0dab61f16e7 100644 --- a/source/isaaclab/test/app/test_non_headless_launch.py +++ b/source/isaaclab/test/app/test_non_headless_launch.py @@ -26,6 +26,8 @@ from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @configclass class SensorsSceneCfg(InteractiveSceneCfg): 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 00000000000..426c43f0f7f --- /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 00000000000..1004d3db048 --- /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_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 8dbf19291c8..4f9e072cb75 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -67,6 +67,8 @@ from isaaclab_physx.physics import PhysxManager as SimulationManager +pytestmark = pytest.mark.integration + SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) """ @@ -1097,6 +1099,133 @@ def test_body_com_pose_b(self, backend, num_instances, num_joints, num_bodies, d name="body_com_pose_b", ) + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_body_com_pose_b_is_cached_across_sim_timestamps(self): + art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + + num_get_coms_calls = 0 + get_coms = view.get_coms + + def counted_get_coms(): + nonlocal num_get_coms_calls + num_get_coms_calls += 1 + return get_coms() + + view.get_coms = counted_get_coms + + art.data.update(dt=0.01) + art.data.body_com_pose_b + assert num_get_coms_calls == 1 + + art.data.update(dt=0.01) + art.data.body_com_pose_b + assert num_get_coms_calls == 1 + + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_set_coms_index_updates_body_com_pose_b_cache(self): + art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + + num_get_coms_calls = 0 + get_coms = view.get_coms + + def counted_get_coms(): + nonlocal num_get_coms_calls + num_get_coms_calls += 1 + return get_coms() + + view.get_coms = counted_get_coms + + coms = wp.zeros((art.num_instances, art.num_bodies), dtype=wp.transformf, device="cpu") + art.set_coms_index(coms=coms, full_data=True) + art.data.body_com_pose_b + + assert num_get_coms_calls == 0 + + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_joint_position_write_preserves_body_com_pose_b_cache(self): + art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + + num_get_coms_calls = 0 + get_coms = view.get_coms + + def counted_get_coms(): + nonlocal num_get_coms_calls + num_get_coms_calls += 1 + return get_coms() + + view.get_coms = counted_get_coms + + art.data.update(dt=0.01) + art.data.body_com_pose_b + assert num_get_coms_calls == 1 + + joint_pos = torch.zeros((art.num_instances, art.num_joints), device="cpu") + art.write_joint_position_to_sim_index(position=joint_pos, full_data=True) + art.data.body_com_pose_b + + assert num_get_coms_calls == 1 + + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_partial_set_coms_index_initializes_cold_body_com_pose_b_cache(self): + art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + initial_coms = view.get_coms().numpy().copy() + + num_get_coms_calls = 0 + get_coms = view.get_coms + + def counted_get_coms(): + nonlocal num_get_coms_calls + num_get_coms_calls += 1 + return get_coms() + + view.get_coms = counted_get_coms + + coms = wp.zeros((1, 1), dtype=wp.transformf, device="cpu") + art.set_coms_index( + coms=coms, + env_ids=wp.array([0], dtype=wp.int32, device="cpu"), + body_ids=wp.array([0], dtype=wp.int32, device="cpu"), + ) + body_com_pose_b = art.data.body_com_pose_b.torch + + assert num_get_coms_calls == 1 + torch.testing.assert_close(body_com_pose_b[1, 1], torch.from_numpy(initial_coms[1, 1])) + + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_set_coms_index_invalidates_body_com_pose_b_dependents(self): + art, _ = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + + art.data.update(dt=0.01) + dependent_buffers = [ + ("root_com_pose_w", art.data._root_com_pose_w), + ("root_com_vel_w", art.data._root_com_vel_w), + ("root_link_vel_w", art.data._root_link_vel_w), + ("body_com_pose_w", art.data._body_com_pose_w), + ("body_com_vel_w", art.data._body_com_vel_w), + ("body_link_vel_w", art.data._body_link_vel_w), + ("root_link_lin_vel_b", art.data._root_link_lin_vel_b), + ("root_link_ang_vel_b", art.data._root_link_ang_vel_b), + ("root_com_lin_vel_b", art.data._root_com_lin_vel_b), + ("root_com_ang_vel_b", art.data._root_com_ang_vel_b), + ("root_state_w", art.data._root_state_w), + ("root_link_state_w", art.data._root_link_state_w), + ("root_com_state_w", art.data._root_com_state_w), + ("body_state_w", art.data._body_state_w), + ("body_link_state_w", art.data._body_link_state_w), + ("body_com_state_w", art.data._body_com_state_w), + ("body_com_jacobian_w", art.data._body_com_jacobian_w), + ("mass_matrix", art.data._mass_matrix), + ("gravity_compensation_forces", art.data._gravity_compensation_forces), + ] + for _, buffer in dependent_buffers: + buffer.timestamp = art.data._sim_timestamp + + coms = wp.zeros((art.num_instances, art.num_bodies), dtype=wp.transformf, device="cpu") + art.set_coms_index(coms=coms, full_data=True) + + for name, buffer in dependent_buffers: + assert buffer.timestamp < art.data._sim_timestamp, name + @_backends @_default_dims @_default_devices 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 e5753943bcb..05ed5675533 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,13 @@ 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 _rigid_object_collection_iface_test_utils import BACKENDS, get_rigid_object_collection -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}") +pytestmark = pytest.mark.integration @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 772130149ee..ddfc3d212ad 100644 --- a/source/isaaclab/test/assets/test_rigid_object_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_iface.py @@ -13,318 +13,13 @@ 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 _rigid_object_iface_test_utils import BACKENDS, get_rigid_object -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}") +pytestmark = pytest.mark.integration @pytest.fixture diff --git a/source/isaaclab/test/benchmark/test_benchmark_core.py b/source/isaaclab/test/benchmark/test_benchmark_core.py index d679880d82c..d45c0553c0c 100644 --- a/source/isaaclab/test/benchmark/test_benchmark_core.py +++ b/source/isaaclab/test/benchmark/test_benchmark_core.py @@ -3,321 +3,293 @@ # # 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 +pytestmark = pytest.mark.benchmark + # ============================================================================== # BaseIsaacLabBenchmark Tests # ============================================================================== -class TestBaseIsaacLabBenchmark: - """Tests for BaseIsaacLabBenchmark.""" - - @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_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, +@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 + ) + ], ) - assert os.path.exists(output_path) - - 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 = [ + }, + config=StartupConfig(top_n=1, whitelist=None), + ) + + +def _minimal_play_bundle(): + from isaaclab.test.benchmark.schema import MeanStd, PlayBundle + + bundle = _minimal_runtime_bundle() + return PlayBundle( + run=replace(bundle.run, framework="rsl_rl"), + versions=bundle.versions, + hardware=bundle.hardware, + runtime=bundle.runtime, + resources=bundle.resources, + success_rate=0.75, + reward=MeanStd(mean=4.0, std=1.0, peak=5.0), + ep_length=MeanStd(mean=20.0, std=2.0, peak=25.0), + checkpoint_path="model.pt", + ) + + +def _formatter_keys(benchmark: BaseIsaacLabBenchmark) -> list[str]: + return [key for key, _ in benchmark._metrics] + + +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", + ) + + 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 = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - 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.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_play_bundle(), "play", "Mean Reward", 4.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, - 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() - - # 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, + f"bundle_{index}", + formatter_type="omniperf", + output_path=str(tmp_path), use_recorders=False, - output_prefix="test", + output_prefix=f"bundle_{index}", ) + benchmark.attach_bundle(bundle) 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 00000000000..1555c55e565 --- /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 00000000000..01c3ba794a7 --- /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 00000000000..c1bc3f860a2 --- /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 00000000000..53f14dff701 --- /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_play_schema.py b/source/isaaclab/test/benchmark/test_play_schema.py new file mode 100644 index 00000000000..f155f69e280 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_play_schema.py @@ -0,0 +1,186 @@ +# 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 play (inference) benchmark bundle, builder, and stepping helper.""" + +import json +import os + +import pytest +import torch + +from isaaclab.test.benchmark import builders +from isaaclab.test.benchmark.schema import ( + SCHEMA_VERSION, + GpuDeviceInfo, + Hardware, + MeanStd, + PlayBundle, + Resources, + RunConfig, + RunIdentity, + Runtime, + StartupTime, + Versions, +) +from isaaclab.test.benchmark.serialize import write_bundle_file + + +def _versions() -> Versions: + return Versions( + isaaclab="4.6.8", + isaacsim="5.0.0", + kit="107.1.0", + newton="0.1.2", + warp="1.7.3", + mjwarp="0.0.4", + torch="2.5.1", + rsl_rl="2.3.0", + rl_games=None, + skrl=None, + sb3=None, + git_commit="3d42b11d513", + git_branch="develop", + git_dirty=False, + ) + + +def _hardware() -> Hardware: + return 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, + ) + + +def _run_identity() -> RunIdentity: + return RunIdentity( + run_id="rsl-rl_newton_mjwarp_Isaac-Ant-Direct-v0_20260422-131500_seed42", + framework="rsl_rl", + 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:30Z", + duration_s=30.0, + status="completed", + num_envs=64, + ) + + +def _runtime() -> Runtime: + return Runtime( + startup_time_s=StartupTime(app_launch=18.4, env_creation=22.9, first_step=4.1), + iterations_completed=300, + total_wall_time_s=12.0, + steps_per_iteration=1, + iteration_time_s=MeanStd(mean=0.04, std=0.004), + collection_fps=MeanStd(mean=1_142_000.0, std=9_500.0), + total_fps=MeanStd(mean=1_142_000.0, std=9_500.0), + iterations_per_s=MeanStd(mean=25.0, std=1.0), + ) + + +def _resources() -> Resources: + return Resources( + gpu_util_pct=MeanStd(mean=87.2, std=6.1), + gpu_mem_gb=MeanStd(mean=18.4, std=0.3, peak=19.2), + cpu_util_pct=MeanStd(mean=31.5, std=4.8), + ram_gb=MeanStd(mean=22.1, std=0.4, peak=24.8), + ) + + +def _minimal_play_bundle() -> PlayBundle: + return PlayBundle( + run=_run_identity(), + versions=_versions(), + hardware=_hardware(), + runtime=_runtime(), + resources=_resources(), + success_rate=0.83, + reward=MeanStd(mean=1823.4, std=44.0, peak=1980.0), + ep_length=MeanStd(mean=987.0, std=12.0, peak=1000.0), + checkpoint_path="logs/rsl_rl/ant/2026-04-22_13-15-00/model_499.pt", + video_path=None, + ) + + +def test_play_bundle_round_trip(tmp_path): + """PlayBundle round-trips through JSON with framework, success_rate, reward, and checkpoint.""" + bundle = _minimal_play_bundle() + path = os.path.join(tmp_path, "play.json") + write_bundle_file(bundle, path) + + with open(path) as f: + data = json.load(f) + + assert data["schema_version"] == SCHEMA_VERSION + assert data["run"]["framework"] == "rsl_rl" + assert data["success_rate"] == pytest.approx(0.83) + assert data["reward"]["peak"] == pytest.approx(1980.0) + assert data["ep_length"]["mean"] == pytest.approx(987.0) + assert data["checkpoint_path"].endswith("model_499.pt") + assert data["video_path"] is None + assert "learning" not in data + + +def test_build_play_bundle_passes_fields_through(): + """build_play_bundle forwards every field unchanged onto the PlayBundle.""" + reward = MeanStd(mean=12.0, std=1.0, peak=15.0) + ep_length = MeanStd(mean=100.0, std=5.0, peak=120.0) + b = builders.build_play_bundle( + run=_run_identity(), + versions=_versions(), + hardware=_hardware(), + runtime=_runtime(), + resources=_resources(), + success_rate=0.7, + reward=reward, + ep_length=ep_length, + checkpoint_path="/tmp/m.pt", + ) + assert isinstance(b, PlayBundle) + assert b.run.framework == "rsl_rl" + assert b.success_rate == pytest.approx(0.7) + assert b.reward is reward + assert b.ep_length is ep_length + assert b.checkpoint_path == "/tmp/m.pt" + assert b.video_path is None + + +class _PlayEnv: + """Minimal fake env: env 0 finishes a 2-step episode on the 2nd step, env 1 never finishes.""" + + num_envs = 2 + device = "cpu" + + def __init__(self): + self.unwrapped = self + self._calls = 0 + + def reset(self): + return torch.zeros(2, 3), {} + + def step(self, actions): + self._calls += 1 + dones = torch.tensor([True, False]) if self._calls == 2 else torch.tensor([False, False]) + extras = {"log": {"Episode_Reward/success": torch.tensor(1.0)}} + return torch.zeros(2, 3), torch.ones(2), dones, extras + + +def test_run_play_loop_aggregates_episodes(): + """run_play_loop times each frame and aggregates returns/lengths over completed episodes.""" + from isaaclab.test.benchmark.stepping import run_play_loop + + env = _PlayEnv() + step_times, reward, ep_length, success_rate = run_play_loop(env, policy=lambda obs: torch.zeros(2, 1), num_frames=3) + assert len(step_times) == 3 + assert all(t >= 0.0 for t in step_times) + # env 0 completed a 2-step episode with reward 1 per step -> return 2.0, length 2. + assert reward.mean == pytest.approx(2.0) + assert ep_length.mean == pytest.approx(2.0) + assert success_rate == pytest.approx(1.0) diff --git a/source/isaaclab/test/benchmark/test_profiling.py b/source/isaaclab/test/benchmark/test_profiling.py new file mode 100644 index 00000000000..f8396b11253 --- /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 b708035067b..300e743bf92 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 @@ -13,6 +17,8 @@ from isaaclab.test.benchmark.recorders.record_memory_info import MemoryInfoRecorder from isaaclab.test.benchmark.recorders.record_version_info import VersionInfoRecorder +pytestmark = pytest.mark.benchmark + # ============================================================================== # CPUInfoRecorder Tests # ============================================================================== @@ -596,12 +602,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_schema.py b/source/isaaclab/test/benchmark/test_schema.py index 862d2d80d7c..12e6d6437f4 100644 --- a/source/isaaclab/test/benchmark/test_schema.py +++ b/source/isaaclab/test/benchmark/test_schema.py @@ -33,6 +33,8 @@ ) from isaaclab.test.benchmark.serialize import write_bundle_file +pytestmark = pytest.mark.benchmark + def _versions() -> Versions: return Versions( @@ -298,13 +300,13 @@ def test_extra_field_round_trips(tmp_path): def test_run_config_presets_round_trip(tmp_path): """RunConfig.presets is an open-ended token list; defaults to [] and round-trips.""" assert RunConfig(physics_backend="physx").presets == [] - cfg = RunConfig(physics_backend="newton_mjwarp", rendering_backend="ovrtx", presets=["rgb", "ovrtx_renderer"]) + cfg = RunConfig(physics_backend="newton_mjwarp", rendering_backend="ovrtx", presets=["rgb", "ovrtx"]) base = _minimal_training_bundle() bundle = dataclasses.replace(base, run=dataclasses.replace(base.run, config=cfg)) path = os.path.join(tmp_path, "training.json") write_bundle_file(bundle, path) with open(path) as f: data = json.load(f) - assert data["run"]["config"]["presets"] == ["rgb", "ovrtx_renderer"] + assert data["run"]["config"]["presets"] == ["rgb", "ovrtx"] assert "sensor_dtype" not in data["run"]["config"] assert "sensor_resolution" not in data["run"]["config"] diff --git a/source/isaaclab/test/benchmark/test_stepping.py b/source/isaaclab/test/benchmark/test_stepping.py new file mode 100644 index 00000000000..d2c53117230 --- /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_benchmark_entrypoint.py b/source/isaaclab/test/cli/test_benchmark_entrypoint.py new file mode 100644 index 00000000000..9155747acb6 --- /dev/null +++ b/source/isaaclab/test/cli/test_benchmark_entrypoint.py @@ -0,0 +1,31 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for the unified benchmark console entry point.""" + +import sys +from unittest import mock + +import pytest + +import isaaclab.cli as cli + + +@pytest.mark.parametrize("command", ["runtime", "startup", "training", "play"]) +def test_benchmark_dispatches_to_requested_script(command): + """The ``isaaclab benchmark`` command forwards arguments to the requested script.""" + args = [command, "--help"] + + with ( + mock.patch.object(sys, "argv", ["isaaclab", "benchmark", *args]), + mock.patch("isaaclab.cli.run_python_command") as run_python, + ): + cli.cli() + + run_python.assert_called_once_with( + cli.ISAACLAB_ROOT / "scripts" / "benchmarks" / f"{command}.py", + args[1:], + check=True, + ) diff --git a/source/isaaclab/test/cli/test_install.py b/source/isaaclab/test/cli/test_install.py index 13494cec696..511a709d074 100644 --- a/source/isaaclab/test/cli/test_install.py +++ b/source/isaaclab/test/cli/test_install.py @@ -20,6 +20,8 @@ get_pip_command, ) +pytestmark = pytest.mark.unit + def _python_in_venv(venv: Path) -> Path: if sys.platform == "win32": @@ -222,3 +224,121 @@ def test_uses_package_metadata_when_no_version_file(self, tmp_path): ): result = determine_python_version() assert result == "3.11" + + +# --------------------------------------------------------------------------- +# Prebundled-torch shadowing invariant (regression: nvbugs 6343978) +# --------------------------------------------------------------------------- + + +class TestEnsureNewton: + """Tests for :func:`~isaaclab.cli.commands.install._ensure_newton`. + + Isaac Sim bundles ``newton[sim]==1.2.0``; the install CLI must force the pinned + Newton git build (sourced from ``[tool.uv].override-dependencies``) over it. + """ + + @staticmethod + def _completed(stdout: str = "", returncode: int = 0) -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="") + + def test_installs_pinned_git_build_when_absent(self): + """When the pinned commit is not installed, uninstall newton then install the git build.""" + from isaaclab.cli.commands import install + + commit = install._pinned_version("newton") + calls = [] + + def fake_run(cmd, *args, **kwargs): + calls.append(cmd) + return self._completed(stdout="numpy==2.0.0\n") if cmd[-1] == "freeze" else self._completed() + + with ( + mock.patch.object(install, "extract_python_exe", return_value="python"), + mock.patch.object(install, "get_pip_command", return_value=["uv", "pip"]), + mock.patch.object(install, "run_command", side_effect=fake_run), + ): + install._ensure_newton() + + assert any("uninstall" in cmd for cmd in calls), "old Newton should be uninstalled first" + install_cmds = [cmd for cmd in calls if "install" in cmd] + assert install_cmds, "expected a pip install call" + install_args = install_cmds[-1] + assert any(arg.startswith("newton[sim]") and arg.endswith(commit) for arg in install_args) + assert any(arg.startswith("newton-usd-schemas") for arg in install_args), "schemas must be forced too" + + def test_skips_when_commit_already_installed(self): + """When freeze already reports the pinned commit, do not reinstall.""" + from isaaclab.cli.commands import install + + commit = install._pinned_version("newton") + calls = [] + + def fake_run(cmd, *args, **kwargs): + calls.append(cmd) + if cmd[-1] == "freeze": + stdout = f"newton @ git+https://github.com/newton-physics/newton.git@{commit}\n" + return self._completed(stdout=stdout) + return self._completed() + + with ( + mock.patch.object(install, "extract_python_exe", return_value="python"), + mock.patch.object(install, "get_pip_command", return_value=["uv", "pip"]), + mock.patch.object(install, "run_command", side_effect=fake_run), + ): + install._ensure_newton() + + assert not any("install" in cmd for cmd in calls), "should not install when commit already present" + assert not any("uninstall" in cmd for cmd in calls), "should not uninstall when commit already present" + + +def test_no_shadowing_prebundled_torch_in_isaac_sim(): + """A prebundled torch must not shadow the pip-installed torch. + + Regression test for nvbugs 6343978: Isaac Sim 6.0 ships a prebundled PyTorch under + the deprecated ``omni.isaac.ml_archive`` extension whose ``libtorch_cuda.so`` + requires an NCCL symbol the co-bundled NCCL does not export. Launch paths that do + not import :mod:`isaaclab` (e.g. ``isaac-sim.streaming.sh`` / ``runheadless.sh``) + bypass the ``sys.path`` deprioritization and import this broken copy, crashing with + ``undefined symbol: ncclDevCommCreate``. After install/build, every + ``pip_prebundle/torch`` under Isaac Sim must therefore be either removed or a + symlink into the active environment, never a real shadowing directory. + """ + isaacsim_path = extract_isaacsim_path(required=False) + if isaacsim_path is None or not isaacsim_path.exists(): + pytest.skip("Isaac Sim installation not found; skipping prebundle-shadow invariant check") + + shadowing = [ + prebundled_torch + for prebundled_torch in isaacsim_path.rglob("pip_prebundle/torch") + if prebundled_torch.is_dir() and not prebundled_torch.is_symlink() + ] + assert not shadowing, ( + "Found prebundled torch directories that shadow the pip-installed torch (nvbugs 6343978). " + "They must be removed at image build time or repointed to the active environment:\n " + + "\n ".join(str(p) for p in shadowing) + ) + + +# --------------------------------------------------------------------------- +# Pink IK stack derivation (single-source pins) +# --------------------------------------------------------------------------- + + +class TestPinkIkStack: + """Tests for :func:`~isaaclab.cli.commands.install._pink_ik_stack`. + + The Pink IK pins live only in the root ``pyproject.toml`` + ``[project.dependencies]``; the install CLI derives its force-install + stack from there instead of mirroring the versions. + """ + + def test_stack_derived_from_root_pyproject_pins(self): + """The derived stack covers every stack package, exactly pinned, markers stripped.""" + from isaaclab.cli.commands import install + + stack = install._pink_ik_stack() + assert [install._requirement_name(r) for r in stack] == list(install._PINK_IK_PACKAGES) + assert any(r.startswith("pin-pink==") for r in stack), "pin-pink must stay exactly pinned" + assert any(r.startswith("daqp==") for r in stack), "daqp must stay exactly pinned" + assert all(";" not in r for r in stack), "environment markers must be stripped" diff --git a/source/isaaclab/test/cli/test_install_command_parsing.py b/source/isaaclab/test/cli/test_install_command_parsing.py index 27281ec013f..8a6abe52313 100644 --- a/source/isaaclab/test/cli/test_install_command_parsing.py +++ b/source/isaaclab/test/cli/test_install_command_parsing.py @@ -15,6 +15,8 @@ import os from unittest.mock import patch +import pytest + from isaaclab.cli.commands.install import ( CORE_ISAACLAB_SUBMODULES, MANUAL_EXTRA_FEATURES, @@ -24,6 +26,8 @@ split_install_items, ) +pytestmark = pytest.mark.unit + def _optional_submodule_packages() -> list[str]: """Return flattened optional submodule source package names.""" @@ -156,6 +160,9 @@ def test_optional_submodules_not_in_core(self): f"{_INSTALL_MODULE}._install_isaaclab_submodules", f"{_INSTALL_MODULE}._install_extra_feature", f"{_INSTALL_MODULE}._install_optional_submodule_extra_dependencies", + # Centralized dependency installs read the root pyproject and shell out to pip. + f"{_INSTALL_MODULE}._root_core_dependencies", + f"{_INSTALL_MODULE}._install_root_extra", f"{_INSTALL_MODULE}._install_isaacsim", f"{_INSTALL_MODULE}._ensure_cuda_torch", f"{_INSTALL_MODULE}._maybe_preinstall_arm_nlopt", diff --git a/source/isaaclab/test/cli/test_install_commands.py b/source/isaaclab/test/cli/test_install_commands.py index f773d9f026c..fdf854f3e60 100644 --- a/source/isaaclab/test/cli/test_install_commands.py +++ b/source/isaaclab/test/cli/test_install_commands.py @@ -26,6 +26,8 @@ _torch_first_on_sys_path_is_prebundle, ) +pytestmark = pytest.mark.unit + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -363,10 +365,10 @@ class TestEnsureCudaTorch: # ---- x86 scenarios ------------------------------------------------------- def test_x86_skips_install_when_correct_version_present(self, tmp_path): - """x86: torch 2.10.0+cu128 already installed → pip install is not called.""" + """x86: torch 2.11.0+cu128 already installed → pip install is not called.""" py = str(tmp_path / "python") pip_cmd = [py, "-m", "pip"] - pip_show_out = "Name: torch\nVersion: 2.10.0+cu128\n" + pip_show_out = "Name: torch\nVersion: 2.11.0+cu128\n" with ( mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), @@ -411,7 +413,7 @@ def test_x86_reinstalls_when_wrong_cuda_tag(self, tmp_path): def _run(cmd, **kwargs): calls.append(list(cmd)) - stdout = "Name: torch\nVersion: 2.10.0+cu130\n" if "show" in cmd else "" + stdout = "Name: torch\nVersion: 2.11.0+cu130\n" if "show" in cmd else "" return _cp(0, stdout) with ( @@ -452,10 +454,10 @@ def _run(cmd, **kwargs): assert "cu130" in combined def test_arm_skips_install_when_correct_version_present(self, tmp_path): - """ARM: torch 2.10.0+cu130 already installed → pip install is not called.""" + """ARM: torch 2.11.0+cu130 already installed → pip install is not called.""" py = str(tmp_path / "python") pip_cmd = [py, "-m", "pip"] - pip_show_out = "Name: torch\nVersion: 2.10.0+cu130\n" + pip_show_out = "Name: torch\nVersion: 2.11.0+cu130\n" with ( mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), @@ -475,7 +477,7 @@ def test_arm_reinstalls_when_wrong_cuda_tag(self, tmp_path): def _run(cmd, **kwargs): calls.append(list(cmd)) - stdout = "Name: torch\nVersion: 2.10.0+cu128\n" if "show" in cmd else "" + stdout = "Name: torch\nVersion: 2.11.0+cu128\n" if "show" in cmd else "" return _cp(0, stdout) with ( @@ -643,7 +645,7 @@ def test_local_build_symlinks_torch_to_venv_site_packages(self, tmp_path): symlink = prebundle / "torch" assert symlink.is_symlink(), "torch should be a symlink after repoint" assert symlink.resolve() == (site_pkgs / "torch").resolve() - assert (prebundle / "torch.bak").is_dir(), "Original torch should be backed up" + assert not (prebundle / "torch.bak").exists(), "repoint replaces in place — no .bak (env copy is the target)" def test_local_build_skips_nvidia_when_cudnn_absent_kit_python(self, tmp_path): """Local build + kit Python: site-packages/nvidia has only 'srl' (no cudnn) → nvidia NOT repointed. @@ -717,23 +719,20 @@ def test_updates_stale_symlink_pointing_to_old_env(self, tmp_path): assert (prebundle / "torch").resolve() == (site_pkgs / "torch").resolve(), "Stale symlink must be updated" - def test_removes_old_backup_before_renaming(self, tmp_path): - """A pre-existing .bak directory is removed before the current package is backed up.""" + def test_raises_when_prebundled_torch_not_neutralized(self, tmp_path): + """Fail loud: a real prebundled torch surviving repoint would shadow the pip torch + on launch paths that do not import isaaclab (nvbugs 6343978), so repoint raises + instead of silently leaving the broken state in place.""" isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) py = str(tmp_path / "env" / "bin" / "python") - # Simulate leftover backup from a previous partial install. - old_backup = prebundle / "torch.bak" - old_backup.mkdir() - (old_backup / "stale_file.py").touch() - + # Simulate the removal not taking effect (e.g. an unhandled filesystem quirk): the + # prebundled torch stays a real directory rather than becoming a symlink. with self._patch(isaacsim_path, site_pkgs, py): - _repoint_prebundle_packages() - - assert (prebundle / "torch").is_symlink(), "torch must be repointed" - # The old backup was replaced by the fresh backup. - assert (prebundle / "torch.bak").is_dir() + with mock.patch("isaaclab.cli.commands.install._force_remove"): + with pytest.raises(RuntimeError, match="neutralize"): + _repoint_prebundle_packages() # ---- pip-installed isaacsim (path found via import probe) ---------------- diff --git a/source/isaaclab/test/cli/test_install_prebundle.py b/source/isaaclab/test/cli/test_install_prebundle.py index 021a247a8e2..eeae9653651 100644 --- a/source/isaaclab/test/cli/test_install_prebundle.py +++ b/source/isaaclab/test/cli/test_install_prebundle.py @@ -3,19 +3,27 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Tests for prebundle probe and _split_install_items. +"""Tests for prebundle probe, _split_install_items, and prebundle dist integrity. Supplements test_install_commands.py with tests that verify the probe -script text and the comma-separated install item parser. +script text, the comma-separated install item parser, and the +snapshot/assert pair guarding Isaac Sim prebundles against pip removals. """ +import shutil from unittest import mock +import pytest + from isaaclab.cli.commands.install import ( + _assert_no_new_dangling_prebundle_symlinks, + _find_dangling_prebundle_symlinks, _torch_first_on_sys_path_is_prebundle, split_install_items, ) +pytestmark = pytest.mark.unit + # --------------------------------------------------------------------------- # split_install_items # --------------------------------------------------------------------------- @@ -79,3 +87,76 @@ def fake_run(cmd, *, env=None, check=False, capture_output=False, text=False): probe_script = captured_cmd[2] # [python_exe, "-c", probe] assert "pip_prebundle" in probe_script, "Probe must check for 'pip_prebundle'" assert "extsDeprecated" not in probe_script, "Probe must NOT check only for 'extsDeprecated'" + + +# --------------------------------------------------------------------------- +# prebundle dangling-symlink integrity +# --------------------------------------------------------------------------- + + +class TestPrebundleSymlinkIntegrity: + """Tests for :func:`_find_dangling_prebundle_symlinks` and + :func:`_assert_no_new_dangling_prebundle_symlinks`. + + Regression guard for nvbugs 6343978: a pip downgrade deleted ``packaging`` + from the ``omni.isaac.core_archive`` prebundle, dangling the per-file + symlink farm ``omni.services.pip_archive`` shares with it and cascading + into 14 extension startup failures. Prebundle deletions by themselves are + routine (site-packages shadows them); only new dangling symlinks fail. + """ + + def _make_prebundles(self, tmp_path): + core = tmp_path / "exts" / "omni.isaac.core_archive" / "pip_prebundle" + (core / "packaging").mkdir(parents=True) + (core / "packaging" / "__init__.py").write_text("") + services = tmp_path / "extscache" / "omni.services.pip_archive" / "pip_prebundle" + (services / "packaging").mkdir(parents=True) + (services / "packaging" / "__init__.py").symlink_to(core / "packaging" / "__init__.py") + return core, services + + def test_intact_farm_has_no_dangling_links(self, tmp_path): + core, services = self._make_prebundles(tmp_path) + with mock.patch("isaaclab.cli.commands.install._discover_prebundle_dirs", return_value={core, services}): + assert _find_dangling_prebundle_symlinks() == set() + _assert_no_new_dangling_prebundle_symlinks(set()) + + def test_raises_when_symlink_target_deleted(self, tmp_path): + """Deleting the shared copy must fail the install, naming the broken link.""" + core, services = self._make_prebundles(tmp_path) + with mock.patch("isaaclab.cli.commands.install._discover_prebundle_dirs", return_value={core, services}): + before = _find_dangling_prebundle_symlinks() + shutil.rmtree(core / "packaging") + with pytest.raises(RuntimeError, match="dangling symlink") as excinfo: + _assert_no_new_dangling_prebundle_symlinks(before) + assert str(services / "packaging" / "__init__.py") in str(excinfo.value) + + def test_preexisting_dangling_links_are_tolerated(self, tmp_path): + """Links already broken before the install do not fail it.""" + core, services = self._make_prebundles(tmp_path) + (services / "stale.py").symlink_to(core / "does-not-exist.py") + with mock.patch("isaaclab.cli.commands.install._discover_prebundle_dirs", return_value={core, services}): + before = _find_dangling_prebundle_symlinks() + assert before == {services / "stale.py"} + _assert_no_new_dangling_prebundle_symlinks(before) + + def test_routine_dist_replacement_passes(self, tmp_path): + """Deleting a prebundled package nothing links into is not a violation.""" + core, services = self._make_prebundles(tmp_path) + (core / "six.py").write_text("") + with mock.patch("isaaclab.cli.commands.install._discover_prebundle_dirs", return_value={core, services}): + before = _find_dangling_prebundle_symlinks() + (core / "six.py").unlink() + _assert_no_new_dangling_prebundle_symlinks(before) + + def test_non_package_dangles_warn_but_pass(self, tmp_path): + """Routine residue (dangling non-__init__ files) warns without failing. + + Every docker build leaves a few dozen dangling links to files Python + never imports at startup (test modules, WHEEL/license files, cmake + hooks); those must not abort the install. + """ + core, services = self._make_prebundles(tmp_path) + (services / "WHEEL").symlink_to(core / "gone-WHEEL") + (services / "test_module.py").symlink_to(core / "gone-test.py") + with mock.patch("isaaclab.cli.commands.install._discover_prebundle_dirs", return_value={core, services}): + _assert_no_new_dangling_prebundle_symlinks(set()) diff --git a/source/isaaclab/test/cli/test_source_package_metadata.py b/source/isaaclab/test/cli/test_source_package_metadata.py index ec2b1c35461..5f2ff176899 100644 --- a/source/isaaclab/test/cli/test_source_package_metadata.py +++ b/source/isaaclab/test/cli/test_source_package_metadata.py @@ -9,8 +9,11 @@ from pathlib import Path +import pytest import tomllib +pytestmark = pytest.mark.unit + def _repo_root() -> Path: """Find the Isaac Lab repository root from this test file.""" @@ -22,7 +25,7 @@ def _repo_root() -> Path: 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.""" - with (_repo_root() / "source/isaaclab/pyproject.toml").open("rb") as f: + with (_repo_root() / "pyproject.toml").open("rb") as f: pyproject = tomllib.load(f) usd_core_dependencies = [ @@ -30,3 +33,15 @@ def test_isaaclab_usd_core_pin_stays_on_isaacsim_compatible_usd25_abi(): ] assert usd_core_dependencies == ["usd-core>=25.11,<26.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() / "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/cli/test_train_multigpu_command_building.py b/source/isaaclab/test/cli/test_train_multigpu_command_building.py new file mode 100644 index 00000000000..93567d8dec0 --- /dev/null +++ b/source/isaaclab/test/cli/test_train_multigpu_command_building.py @@ -0,0 +1,215 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Regression tests for passing ``--kit_args`` with an option-like value. + +Kit arguments always start with ``--``, and argparse rejects a value token that +itself looks like an option (starts with ``-`` and contains no space) with +"expected one argument" (exit code 2). This used to break the documented +space-separated form ``--kit_args "--foo=/bar"`` for a single Kit argument on +every entry point, including all ranks of the multi-GPU launcher. + +:meth:`~isaaclab.app.AppLauncher.add_app_launcher_args` now fuses such pairs in +``sys.argv`` into single ``--kit_args=`` tokens before any parsing, and +``dispatch_library_entrypoint`` applies the same fusing to the explicit argv list +it forwards to the per-library scripts (which parse that list, not ``sys.argv``). +The multi-GPU launcher forwards the tokens verbatim; each child rank runs through +the dispatcher and normalizes them at startup. + +These tests exercise the pure normalization and command-building logic and run +without a GPU or Isaac Sim installation. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import shlex +import sys +from pathlib import Path + +import pytest + +from isaaclab.app.app_launcher import AppLauncher + +# The launcher script lives outside the installed packages; load it by path. +# This test lives at source/isaaclab/test/cli/test_train_multigpu_command_building.py. +_REPO_ROOT = Path(__file__).resolve().parents[4] +_TRAIN_MULTIGPU_PATH = _REPO_ROOT / "scripts" / "reinforcement_learning" / "train_multigpu.py" + +_spec = importlib.util.spec_from_file_location("train_multigpu", _TRAIN_MULTIGPU_PATH) +train_multigpu = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(train_multigpu) + +_COMMON_PATH = _REPO_ROOT / "scripts" / "reinforcement_learning" / "common.py" +_common_spec = importlib.util.spec_from_file_location("rl_common", _COMMON_PATH) +rl_common = importlib.util.module_from_spec(_common_spec) +_common_spec.loader.exec_module(rl_common) + + +def _build_command(argv: list[str]) -> list[str]: + """Build the distributed launcher command the same way ``main`` does.""" + args_cli, train_args = train_multigpu._parse_args(argv) + return train_multigpu._build_distributed_command(args_cli, train_args) + + +def _forwarded_train_argv(command: list[str]) -> list[str]: + """Return the argv forwarded to the child training script.""" + return command[command.index(str(train_multigpu.TRAIN_SCRIPT)) + 1 :] + + +def _parse_as_training_script(child_argv: list[str], monkeypatch: pytest.MonkeyPatch) -> str: + """Parse forwarded argv the way the child training script does. + + Replicates the training-script startup: ``sys.argv`` holds the forwarded + tokens, the parser is extended via :meth:`AppLauncher.add_app_launcher_args` + (which installs the ``--kit_args`` normalization), and parsing must succeed. + """ + monkeypatch.setattr(sys, "argv", ["train.py", *child_argv]) + parser = argparse.ArgumentParser() + parser.add_argument("--task", type=str, default=None) + AppLauncher.add_app_launcher_args(parser) + try: + args, _unknown = parser.parse_known_args() + except SystemExit: + pytest.fail(f"training script argparse rejected the forwarded arguments: {child_argv}") + return args.kit_args + + +class TestFuseKitArgs: + """Unit tests for :meth:`AppLauncher._fuse_kit_args`.""" + + def test_space_separated_option_like_value_is_fused(self): + assert AppLauncher._fuse_kit_args(["--kit_args", "--foo=/bar"]) == ["--kit_args=--foo=/bar"] + + def test_equals_attached_value_is_unchanged(self): + argv = ["--kit_args=--foo=/bar"] + assert AppLauncher._fuse_kit_args(argv) == argv + + def test_value_with_space_is_unchanged(self): + # a token containing a space cannot be mistaken for an option by argparse + argv = ["--kit_args", "--foo=/bar --baz=1"] + assert AppLauncher._fuse_kit_args(argv) == argv + + def test_non_option_value_is_unchanged(self): + argv = ["--kit_args", "foo.txt"] + assert AppLauncher._fuse_kit_args(argv) == argv + + def test_trailing_kit_args_is_unchanged(self): + # argparse should still report the missing value normally + argv = ["--task", "Isaac-Cartpole-Direct", "--kit_args"] + assert AppLauncher._fuse_kit_args(argv) == argv + + def test_multiple_occurrences_are_all_fused(self): + argv = ["--kit_args", "--foo=/a", "--task", "X", "--kit_args", "--bar=/b"] + assert AppLauncher._fuse_kit_args(argv) == ["--kit_args=--foo=/a", "--task", "X", "--kit_args=--bar=/b"] + + def test_surrounding_tokens_are_preserved(self): + argv = ["--task", "X", "--kit_args", "--foo=/bar", "--headless"] + assert AppLauncher._fuse_kit_args(argv) == ["--task", "X", "--kit_args=--foo=/bar", "--headless"] + + +class TestAddAppLauncherArgsNormalization: + """Integration tests for the ``sys.argv`` normalization in ``add_app_launcher_args``.""" + + def test_space_separated_single_kit_arg_parses(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["prog", "--task", "X", "--kit_args", "--foo=/bar"]) + parser = argparse.ArgumentParser() + parser.add_argument("--task", type=str, default=None) + AppLauncher.add_app_launcher_args(parser) + args, unknown = parser.parse_known_args() + assert args.kit_args == "--foo=/bar" + assert unknown == [] + + def test_unknown_leftovers_are_preserved(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["prog", "--task", "X", "--kit_args", "--foo=/bar", "env.param=1"]) + parser = argparse.ArgumentParser() + parser.add_argument("--task", type=str, default=None) + AppLauncher.add_app_launcher_args(parser) + args, unknown = parser.parse_known_args() + assert args.kit_args == "--foo=/bar" + assert unknown == ["env.param=1"] + + +class TestDispatchLibraryEntrypoint: + """Tests for the ``--kit_args`` normalization in ``dispatch_library_entrypoint``. + + The unified ``train.py``/``play.py`` dispatchers hand the per-library scripts an + explicit argv list (not ``sys.argv``), so the normalization must also run on that + list before it is forwarded. + """ + + @staticmethod + def _dispatch(tmp_path: Path, argv: list[str]) -> list[str]: + """Dispatch to a stub library entry point and return the argv it received.""" + stub = tmp_path / "stub_train.py" + stub.write_text( + "received = None\n\n\ndef run(argv):\n global received\n globals()['received'] = list(argv)\n" + ) + exit_code = rl_common.dispatch_library_entrypoint( + argv, {"stub": stub}, action="train", description="test", library_help="test" + ) + assert exit_code == 0 + return sys.modules["isaaclab_rl_train_stub"].received + + def test_space_separated_kit_args_fused_before_library_script(self, tmp_path): + received = self._dispatch(tmp_path, ["--rl_library", "stub", "--task", "X", "--kit_args", "--foo=/bar"]) + assert received == ["--task", "X", "--kit_args=--foo=/bar"] + + def test_already_working_argv_forms_forwarded_unchanged(self, tmp_path): + argv = ["--task", "X", "--kit_args=--foo=/bar", "--kit_args", "--a=/x --b=/y", "--num_envs", "16", "--headless"] + received = self._dispatch(tmp_path, ["--rl_library", "stub", *argv]) + assert received == argv + + +class TestKitArgsForwarding: + """Tests for forwarding ``--kit_args`` through the multi-GPU launcher.""" + + def test_space_separated_kit_args_forwarded_verbatim(self): + command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar"]) + index = command.index("--kit_args") + assert command[index + 1] == "--foo=/bar" + + def test_equals_attached_kit_args_forwarded_unchanged(self): + command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args=--foo=/bar"]) + assert "--kit_args=--foo=/bar" in command + + def test_multi_token_kit_args_value_forwarded_as_single_token(self): + command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar --baz=1"]) + index = command.index("--kit_args") + assert command[index + 1] == "--foo=/bar --baz=1" + + def test_forwarded_space_separated_kit_args_accepted_by_training_script(self, monkeypatch): + command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar"]) + kit_args = _parse_as_training_script(_forwarded_train_argv(command), monkeypatch) + assert kit_args == "--foo=/bar" + + def test_forwarded_skrl_jax_kit_args_accepted_by_training_script(self, monkeypatch): + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "0,1") + command = _build_command( + [ + "--rl_library", + "skrl", + "--num_gpus", + "2", + "--task", + "Isaac-Cartpole-Direct", + "--ml_framework", + "jax", + "--kit_args", + "--foo=/bar", + ] + ) + assert "skrl.utils.distributed.jax" in command + kit_args = _parse_as_training_script(_forwarded_train_argv(command), monkeypatch) + assert kit_args == "--foo=/bar" + + def test_dry_run_prints_shell_parsable_command(self, capsys): + exit_code = train_multigpu.main(["--dry_run", "--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar"]) + assert exit_code == 0 + printed = capsys.readouterr().out.strip() + tokens = shlex.split(printed) + index = tokens.index("--kit_args") + assert tokens[index + 1] == "--foo=/bar" diff --git a/source/isaaclab/test/cli/test_uv_run_pyproject.py b/source/isaaclab/test/cli/test_uv_run_pyproject.py index ab9194d3f9e..4731f80d10d 100644 --- a/source/isaaclab/test/cli/test_uv_run_pyproject.py +++ b/source/isaaclab/test/cli/test_uv_run_pyproject.py @@ -10,8 +10,11 @@ import re from pathlib import Path +import pytest import tomllib +pytestmark = pytest.mark.unit + def _repo_root() -> Path: """Find the Isaac Lab repository root from this test file.""" @@ -38,39 +41,101 @@ def test_uv_run_extra_names_match_documented_workflow(): assert documented_extras <= set(optional_dependencies) -def test_uv_run_keeps_modular_extras_without_isaacsim(): - """The root dev project keeps local module extras but leaves Isaac Sim opt-in out.""" +def test_uv_run_exposes_centralized_feature_extras(): + """The root project centralizes optional third-party deps into named extras.""" optional_dependencies = _root_pyproject()["project"]["optional-dependencies"] + # Feature extras a user can activate with ``uv run --extra``. expected_extras = { - "contrib": ["isaaclab-contrib"], - "mimic": ["isaaclab-mimic"], - "newton": ["isaaclab-newton[all]", "isaaclab-physx[newton]", "isaaclab-visualizers[newton]"], - "ov": ["isaaclab-ovphysx[ovphysx]"], - "rl": ["isaaclab-rl[rsl-rl]"], - "rl-all": ["isaaclab-rl[all]"], - "rtx": ["isaaclab-ov[ovrtx]"], - "all": [ - "isaaclab-mimic", - "isaaclab-newton[all]", - "isaaclab-physx[newton]", - "isaaclab-ppisp", - "isaaclab-rl[all]", - "isaaclab-visualizers[all]", - ], + "test", + "sb3", + "skrl", + "rl-games", + "rsl-rl", + "viser", + "rerun", + "ov", + "rtx", + "mimic", + "teleop", + "rlinf", + "all", } + assert expected_extras <= set(optional_dependencies) + + # The Newton viewer GUI is part of the base install, so there is no ``newton`` extra. + assert "newton" not in optional_dependencies + + # Concrete third-party deps live in the extras (not subpackage self-references). + # OVPhysX and OVRTX are separate extras, selectable via ``ov[ovphysx]`` / ``ov[ovrtx]``. + assert any(dep.startswith("skrl") for dep in optional_dependencies["skrl"]) + assert any(dep.startswith("ovphysx") for dep in optional_dependencies["ov"]) + assert any(dep.startswith("ovrtx") for dep in optional_dependencies["rtx"]) + + +def test_version_single_source_matches_literal_pins(): + """``[tool.isaaclab.versions]`` is the single source for externally-pinned versions. + + TOML cannot interpolate, so the literal pins in ``[project.dependencies]``, + ``[project.optional-dependencies]``, and ``[tool.uv].override-dependencies`` must + mirror the table exactly. This test fails if any of them drift apart. + """ + pyproject = _root_pyproject() + versions = pyproject["tool"]["isaaclab"]["versions"] + dependencies = pyproject["project"]["dependencies"] + optional = pyproject["project"]["optional-dependencies"] + overrides = pyproject["tool"]["uv"]["override-dependencies"] + + # Isaac Sim extra mirrors the table. + assert optional["isaacsim"] == [f"isaacsim[all,extscache]=={versions['isaacsim']}"] + + # OV extras mirror the table (ovphysx exact pin in ``ov``, ovrtx range spec in ``rtx``). + assert f"ovphysx=={versions['ovphysx']}" in optional["ov"] + assert f"ovrtx{versions['ovrtx']}" in optional["rtx"] + + # uv torch-stack overrides mirror the table. + for package in ("torch", "torchvision", "torchaudio"): + assert f"{package}=={versions[package]}" in overrides + + # Newton git commit is pinned via a uv override; warp-lang is a core dependency. + assert any(dep.endswith(f"newton.git@{versions['newton']}") for dep in overrides) + assert f"warp-lang=={versions['warp']}" in dependencies + + +def test_uv_run_isaacsim_extra_is_conflict_forked(): + """Isaac Sim is an opt-in uv workspace extra, forked away from clashing extras. + + PhysX/Isaac Sim is never a base dependency, but it must be a real + ``optional-dependencies`` extra so ``uv run --extra isaacsim`` resolves. Its + exact pins clash with several other extras, so it is declared in + ``[tool.uv].conflicts`` (forked resolution) rather than co-resolved with them. + """ + pyproject = _root_pyproject() + project = pyproject["project"] + base_dependency_names = {re.split(r"[\s<>=!~\[;]", dep, maxsplit=1)[0] for dep in project["dependencies"]} + + # PhysX/Isaac Sim is opt-in, never installed by the bare ``uv run``. + assert "isaacsim" not in base_dependency_names + # ...but it is a workspace extra so ``uv run --extra isaacsim`` works. + assert "isaacsim" in project["optional-dependencies"] + assert any(dep.startswith("isaacsim[") for dep in project["optional-dependencies"]["isaacsim"]) + # The legacy wheel-only table is gone (isaacsim now lives in the extras). + assert "wheel-extras" not in pyproject.get("tool", {}).get("isaaclab", {}) - assert optional_dependencies == expected_extras - assert "isaacsim" not in optional_dependencies + # isaacsim is forked away from every extra whose pins clash with it. + conflict_groups = [{entry["extra"] for entry in group} for group in pyproject["tool"]["uv"]["conflicts"]] + for extra in ("teleop", "ov", "viser", "mimic", "all", "test"): + assert {"isaacsim", extra} in conflict_groups, f"isaacsim must declare a conflict with '{extra}'" def test_uv_run_base_dependencies_cover_newton_rsl_rl_training(): - """The documented bare ``uv run train`` command needs Newton and RSL-RL extras.""" + """The documented bare ``uv run isaaclab train`` command needs Newton and RSL-RL in core.""" dependencies = _root_pyproject()["project"]["dependencies"] - assert "isaaclab-newton[all]" in dependencies - assert "isaaclab-physx[newton]" in dependencies - assert "isaaclab-rl[rsl-rl]" in dependencies + # Newton is the default physics engine and RSL-RL the default training library, + # so both ship as core third-party requirements (not opt-in extras). + assert any(dep.startswith("newton[sim]") for dep in dependencies) + assert any(dep.startswith("rsl-rl-lib") for dep in dependencies) def test_uv_run_uses_managed_python(): diff --git a/source/isaaclab/test/cli/test_wheel_builder_metadata.py b/source/isaaclab/test/cli/test_wheel_builder_metadata.py index c04db628aa7..cc878bf520f 100644 --- a/source/isaaclab/test/cli/test_wheel_builder_metadata.py +++ b/source/isaaclab/test/cli/test_wheel_builder_metadata.py @@ -3,14 +3,19 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Tests for wheel-builder package metadata.""" +"""Tests for wheel-builder package metadata generated from the root pyproject.""" from __future__ import annotations +import subprocess +import sys from pathlib import Path +import pytest import tomllib +pytestmark = pytest.mark.unit + def _repo_root() -> Path: """Find the Isaac Lab repository root from this test file.""" @@ -20,95 +25,62 @@ def _repo_root() -> Path: raise RuntimeError("Could not find Isaac Lab repository root.") -def _load_toml(relative_path: str) -> dict: - """Load a TOML file from the Isaac Lab repository root.""" - with (_repo_root() / relative_path).open("rb") as f: +def _root_rsl_rl_pin() -> str: + """Return the ``rsl-rl-lib`` pin declared by the root ``pyproject.toml`` core deps.""" + with (_repo_root() / "pyproject.toml").open("rb") as f: + data = tomllib.load(f) + for dependency in data["project"]["dependencies"]: + if dependency.startswith("rsl-rl-lib=="): + return dependency + raise AssertionError("Could not find rsl-rl-lib pin in the root pyproject.toml") + + +def _generate_wheel_pyproject(tmp_path: Path) -> dict: + """Run ``gen_pyproject.py`` against the root pyproject and return the parsed result.""" + repo_root = _repo_root() + output = tmp_path / "pyproject.toml" + subprocess.run( + [ + sys.executable, + str(repo_root / "tools/wheel_builder/gen_pyproject.py"), + str(repo_root / "pyproject.toml"), + str(output), + "3.0.0", + ], + check=True, + ) + with output.open("rb") as f: return tomllib.load(f) -def _single_dependency(dependencies: list[str], prefix: str, source: str) -> str: - """Return the only dependency in a list matching the provided prefix.""" - matches = [dependency for dependency in dependencies if dependency.startswith(prefix)] - assert len(matches) == 1, f"Expected one {prefix!r} dependency in {source}, got {matches}" - return matches[0] +def test_wheel_builder_drops_workspace_members(tmp_path): + """The generated wheel metadata must not depend on the bundled ``isaaclab*`` packages.""" + generated = _generate_wheel_pyproject(tmp_path) + dependencies = generated["project"]["dependencies"] + assert not [dep for dep in dependencies if dep.lower().startswith("isaaclab")] -def _wheel_builder_dependencies_by_extra() -> dict[str, list[str]]: - """Return the wheel-builder optional dependencies grouped by extra name.""" - packages = _load_toml("tools/wheel_builder/res/python_packages.toml") - optional_dependencies = packages["isaaclab"]["pyproject"]["optional-dependencies"]["all"] - return {name: dependencies for entry in optional_dependencies for name, dependencies in entry.items()} +def test_wheel_builder_includes_isaacsim_extra(tmp_path): + """The ``isaacsim`` extra must ship in the generated wheel metadata.""" + generated = _generate_wheel_pyproject(tmp_path) + optional_dependencies = generated["project"]["optional-dependencies"] -def _rsl_rl_pin_from_pyproject() -> str: - """Return the ``rsl-rl-lib`` pin declared by ``source/isaaclab_rl/pyproject.toml``.""" - dependencies = _load_toml("source/isaaclab_rl/pyproject.toml")["project"]["optional-dependencies"]["rsl-rl"] - return _single_dependency(dependencies, "rsl-rl-lib==", "source/isaaclab_rl/pyproject.toml") + assert "isaacsim" in optional_dependencies + assert any(dep.startswith("isaacsim[") for dep in optional_dependencies["isaacsim"]) -def _newton_pin_from_pyproject(relative_path: str, extra_name: str) -> str: - """Return the ``newton[sim]`` direct URL pin declared by a package extra.""" - dependencies = _load_toml(relative_path)["project"]["optional-dependencies"][extra_name] - return _single_dependency( - dependencies, - "newton[sim] @ git+https://github.com/newton-physics/newton.git@", - f"{relative_path}[{extra_name}]", - ) - +def test_wheel_builder_rsl_rl_pin_matches_root_pyproject(tmp_path): + """The bundled wheel metadata must install the RSL-RL version declared at the root.""" + expected_pin = _root_rsl_rl_pin() + generated = _generate_wheel_pyproject(tmp_path) -def _warp_pin_from_core_pyproject() -> str: - """Return the core ``warp-lang`` pin declared by ``source/isaaclab/pyproject.toml``.""" - dependencies = _load_toml("source/isaaclab/pyproject.toml")["project"]["dependencies"] - return _single_dependency(dependencies, "warp-lang==", "source/isaaclab/pyproject.toml") - - -def test_wheel_builder_rsl_rl_pin_matches_source_package(): - """The bundled wheel metadata must install the RSL-RL version required by training scripts.""" - expected_pin = _rsl_rl_pin_from_pyproject() - dependencies_by_extra = _wheel_builder_dependencies_by_extra() + # RSL-RL is a core dependency (default training library) and also exposed as an extra. + core_pins = [dep for dep in generated["project"]["dependencies"] if dep.startswith("rsl-rl-lib==")] + assert core_pins == [expected_pin] + optional_dependencies = generated["project"]["optional-dependencies"] + # RSL-RL ships in its own ``rsl-rl`` extra and in the aggregate ``all`` extra. for extra_name in ("rsl-rl", "all"): - rsl_rl_pin = _single_dependency(dependencies_by_extra[extra_name], "rsl-rl-lib==", extra_name) - assert rsl_rl_pin == expected_pin - - -def test_wheel_builder_newton_pin_matches_source_packages(): - """The bundled wheel metadata must install the Newton revision used by source packages.""" - expected_pin = _newton_pin_from_pyproject("source/isaaclab_newton/pyproject.toml", "all") - source_pins = [ - _newton_pin_from_pyproject("source/isaaclab_physx/pyproject.toml", "newton"), - _newton_pin_from_pyproject("source/isaaclab_visualizers/pyproject.toml", "newton"), - _newton_pin_from_pyproject("source/isaaclab_visualizers/pyproject.toml", "rerun"), - _newton_pin_from_pyproject("source/isaaclab_visualizers/pyproject.toml", "viser"), - _newton_pin_from_pyproject("source/isaaclab_visualizers/pyproject.toml", "all"), - ] - assert source_pins == [expected_pin] * len(source_pins) - - wheel_newton_pin = _single_dependency( - _wheel_builder_dependencies_by_extra()["newton"], - "newton[sim] @ git+https://github.com/newton-physics/newton.git@", - "tools/wheel_builder/res/python_packages.toml[newton]", - ) - assert wheel_newton_pin == expected_pin - - -def test_wheel_builder_warp_pin_matches_core_package(): - """The bundled wheel metadata must keep Warp aligned with the core package pin.""" - expected_pin = _warp_pin_from_core_pyproject() - packages = _load_toml("tools/wheel_builder/res/python_packages.toml") - wheel_core_dependencies = packages["isaaclab"]["pyproject"]["dependencies"]["all"] - dependencies_by_extra = _wheel_builder_dependencies_by_extra() - - wheel_core_pin = _single_dependency( - wheel_core_dependencies, - "warp-lang==", - "tools/wheel_builder/res/python_packages.toml[dependencies]", - ) - wheel_newton_pin = _single_dependency( - dependencies_by_extra["newton"], - "warp-lang==", - "tools/wheel_builder/res/python_packages.toml[newton]", - ) - - assert wheel_core_pin == expected_pin - assert wheel_newton_pin == expected_pin + rsl_rl_pins = [dep for dep in optional_dependencies[extra_name] if dep.startswith("rsl-rl-lib==")] + assert rsl_rl_pins == [expected_pin] diff --git a/source/isaaclab/test/controllers/test_controller_utils.py b/source/isaaclab/test/controllers/test_controller_utils.py index 5c33ce7d818..0ec8b0c4532 100644 --- a/source/isaaclab/test/controllers/test_controller_utils.py +++ b/source/isaaclab/test/controllers/test_controller_utils.py @@ -29,6 +29,8 @@ from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path from isaaclab.utils.io.torchscript import load_torchscript_model +pytestmark = pytest.mark.integration + @pytest.fixture def mock_urdf_content(): diff --git a/source/isaaclab/test/controllers/test_differential_ik.py b/source/isaaclab/test/controllers/test_differential_ik.py index 2ba7af0ec02..ae02894aaba 100644 --- a/source/isaaclab/test/controllers/test_differential_ik.py +++ b/source/isaaclab/test/controllers/test_differential_ik.py @@ -33,6 +33,8 @@ ## from isaaclab_assets import FRANKA_PANDA_HIGH_PD_CFG, UR10_CFG # isort:skip +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): diff --git a/source/isaaclab/test/controllers/test_differential_ik_features.py b/source/isaaclab/test/controllers/test_differential_ik_features.py new file mode 100644 index 00000000000..e4ac4a041ef --- /dev/null +++ b/source/isaaclab/test/controllers/test_differential_ik_features.py @@ -0,0 +1,197 @@ +# 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 + +"""Sim-free unit tests for the optional :class:`DifferentialIKController` features. + +Covers the ``adaptive_dls`` ik-method, per-axis orientation weighting, null-space joint-limit +avoidance, and quaternion renormalization -- all exercised with hand-built tensors (no gym.make, +USD, or GPU). The simulated convergence tests live in ``test_differential_ik.py``. +""" + +import math + +import pytest +import torch + +from isaaclab.controllers.differential_ik import DifferentialIKController +from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg + +pytestmark = pytest.mark.integration + +_NUM_JOINTS = 5 +_ID_QUAT = [0.0, 0.0, 0.0, 1.0] # xyzw identity + + +def _quat_xyzw(axis: list[float], angle: float) -> list[float]: + """Build a unit xyzw quaternion from an axis (need not be unit) and angle [rad].""" + norm = math.sqrt(sum(a * a for a in axis)) or 1.0 + s = math.sin(angle / 2.0) + return [axis[0] / norm * s, axis[1] / norm * s, axis[2] / norm * s, math.cos(angle / 2.0)] + + +def _make_controller( + command_type: str = "pose", + ik_method: str = "adaptive_dls", + ik_params: dict | None = None, + orientation_weight=None, + joint_limit_avoidance_gain: float = 0.0, + joint_limit_avoidance_margin: float = 0.3, + num_envs: int = 1, +) -> DifferentialIKController: + cfg = DifferentialIKControllerCfg( + command_type=command_type, + use_relative_mode=False, + ik_method=ik_method, + ik_params=ik_params, + orientation_weight=orientation_weight, + joint_limit_avoidance_gain=joint_limit_avoidance_gain, + joint_limit_avoidance_margin=joint_limit_avoidance_margin, + ) + return DifferentialIKController(cfg, num_envs=num_envs, device="cpu") + + +def test_adaptive_dls_default_params(): + """The cfg fills the adaptive_dls defaults when ``ik_params`` is not provided.""" + cfg = DifferentialIKControllerCfg(command_type="pose", use_relative_mode=False, ik_method="adaptive_dls") + assert set(cfg.ik_params) == {"lambda_min", "lambda_max", "sigma_thresh"} + + +def test_cfg_rejects_bad_orientation_weight(): + with pytest.raises(ValueError): + DifferentialIKControllerCfg( + command_type="pose", use_relative_mode=False, ik_method="dls", orientation_weight=(0.3, 0.3) + ) + + +def test_cfg_rejects_bad_adaptive_params(): + with pytest.raises(ValueError): + DifferentialIKControllerCfg( + command_type="pose", + use_relative_mode=False, + ik_method="adaptive_dls", + ik_params={"lambda_min": 0.5, "lambda_max": 0.1, "sigma_thresh": 0.02}, + ) + + +def test_set_command_renormalizes_quat(): + """A non-unit commanded quaternion is stored renormalized in pose (absolute) mode.""" + c = _make_controller() + raw = torch.tensor([0.2588, 0.0, 0.0, 0.9659]) # xyzw + cmd = torch.cat([torch.tensor([0.3, -0.1, 0.2]), raw * 3.0]).unsqueeze(0) # non-unit + c.set_command(cmd) + stored = c.ee_quat_des[0] + assert torch.linalg.norm(stored).item() == pytest.approx(1.0, abs=1e-6) + torch.testing.assert_close(stored, raw / torch.linalg.norm(raw), atol=1e-6, rtol=0.0) + + +def test_orientation_weight_none_is_unweighted(): + """With no orientation weight, the pose task Jacobian equals the raw Jacobian.""" + c = _make_controller(orientation_weight=None) + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + ee_quat = torch.tensor([_ID_QUAT]) + c.set_command(torch.tensor([[0.31, 0.0, 0.2] + _quat_xyzw([1.0, 0.0, 0.0], 0.5)])) + jac = torch.arange(6 * _NUM_JOINTS, dtype=torch.float32).reshape(1, 6, _NUM_JOINTS) + task_jac, _ = c._compute_pose_task(ee_pos, ee_quat, jac) + torch.testing.assert_close(task_jac, jac) + + +def test_orientation_weight_per_axis_scales_rows_and_error(): + """A per-axis (wx, wy, wz) weight scales each base-frame orientation row and error; wz=0 drops + the yaw row. Position rows/error are untouched.""" + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + ee_quat = torch.tensor([_ID_QUAT]) + jac = torch.arange(6 * _NUM_JOINTS, dtype=torch.float32).reshape(1, 6, _NUM_JOINTS) + cmd = torch.tensor([[0.31, 0.0, 0.2] + _quat_xyzw([0.3, 0.5, 0.8], 0.7)]) + + base = _make_controller(orientation_weight=None) + base.set_command(cmd) + jb, eb = base._compute_pose_task(ee_pos, ee_quat, jac) + + c = _make_controller(orientation_weight=(0.4, 0.2, 0.0)) + c.set_command(cmd) + jp, ep = c._compute_pose_task(ee_pos, ee_quat, jac) + + torch.testing.assert_close(jp[:, :3, :], jb[:, :3, :]) # position rows unchanged + torch.testing.assert_close(jp[:, 3, :], 0.4 * jb[:, 3, :]) + torch.testing.assert_close(jp[:, 4, :], 0.2 * jb[:, 4, :]) + torch.testing.assert_close(jp[:, 5, :], torch.zeros_like(jb[:, 5, :])) # yaw dropped + torch.testing.assert_close(ep[:, :3], eb[:, :3]) + torch.testing.assert_close(ep[:, 3], 0.4 * eb[:, 3]) + torch.testing.assert_close(ep[:, 5], torch.zeros_like(eb[:, 5])) + + +def test_compute_pose_task_quat_convention_xyzw(): + """Discriminating regression for the xyzw quaternion convention: commanding the EE's current + orientation yields zero orientation error. A wxyz mis-read would corrupt this.""" + rot = pytest.importorskip("scipy.spatial.transform").Rotation.from_euler("x", 30.0, degrees=True) + q_xyzw = rot.as_quat() # [x, y, z, w] + ee_quat = torch.tensor(q_xyzw, dtype=torch.float32).unsqueeze(0) + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + c = _make_controller(orientation_weight=1.0) + cmd = torch.cat([torch.tensor([0.3, 0.0, 0.2]), torch.tensor(q_xyzw, dtype=torch.float32)]).unsqueeze(0) + c.set_command(cmd) + _, err = c._compute_pose_task(ee_pos, ee_quat, torch.zeros(1, 6, _NUM_JOINTS)) + assert torch.linalg.norm(err[0, 3:6]).item() == pytest.approx(0.0, abs=1e-6) + + +def test_adaptive_dls_damps_singularity(): + """Near a task-Jacobian singularity, the adaptive ramp produces a smaller (more damped) and + finite step than a fixed ``lambda_min`` solve would.""" + c = _make_controller(ik_params={"lambda_min": 0.01, "lambda_max": 0.5, "sigma_thresh": 0.1}) + j_task = torch.zeros(1, 6, _NUM_JOINTS) + j_task[0, 0, 0] = j_task[0, 1, 1] = j_task[0, 2, 2] = 1.0 # well-conditioned position block + eps = 1e-3 + j_task[0, 3, 3] = j_task[0, 4, 4] = eps # near-singular orientation block + err = torch.zeros(1, 6) + err[0, 3] = err[0, 4] = 1.0 + + dq = c._compute_delta_joint_pos(delta_pose=err, jacobian=j_task) + # reference: fixed lambda_min damped least squares + jt = j_task.transpose(1, 2) + a_min = torch.bmm(j_task, jt) + (0.01**2) * torch.eye(6) + dq_min = torch.bmm(jt, torch.linalg.solve(a_min, err.unsqueeze(-1))).squeeze(-1) + assert torch.isfinite(dq).all() + assert dq.norm().item() < dq_min.norm().item() + + +def test_joint_limit_avoidance_zero_when_disabled(): + """JLA returns zeros when joint_limit_avoidance_gain == 0 (default) or before limits are provided.""" + c = _make_controller(joint_limit_avoidance_gain=0.0) + out = c._joint_limit_avoidance(torch.zeros(1, _NUM_JOINTS), torch.ones(1, 6, _NUM_JOINTS)) + torch.testing.assert_close(out, torch.zeros(1, _NUM_JOINTS)) + # enabled but limits not set yet -> still zeros + c2 = _make_controller(joint_limit_avoidance_gain=1.0) + out2 = c2._joint_limit_avoidance(torch.zeros(1, _NUM_JOINTS), torch.ones(1, 6, _NUM_JOINTS)) + torch.testing.assert_close(out2, torch.zeros(1, _NUM_JOINTS)) + + +def test_joint_limit_avoidance_stays_in_position_nullspace(): + """When enabled, the JLA correction lies in the null space of the position rows, so it does not + perturb the commanded end-effector position (``J_pos @ correction ~= 0``).""" + c = _make_controller(joint_limit_avoidance_gain=2.0, joint_limit_avoidance_margin=0.3) + c.set_joint_pos_limits(torch.full((_NUM_JOINTS,), -1.0), torch.full((_NUM_JOINTS,), 1.0)) + # a generic well-conditioned task Jacobian + torch.manual_seed(0) + j_task = torch.randn(1, 6, _NUM_JOINTS) + # joints near their limits -> non-zero center-seeking bias + joint_pos = torch.tensor([[0.95, -0.9, 0.0, 0.8, -0.85]]) + correction = c._joint_limit_avoidance(joint_pos, j_task) + assert correction.norm().item() > 0.0 # bias is active + residual = torch.bmm(j_task[:, :3, :], correction.unsqueeze(-1)).squeeze(-1) + torch.testing.assert_close(residual, torch.zeros_like(residual), atol=1e-5, rtol=0.0) + + +def test_compute_returns_joint_targets_shape(): + """compute returns one target per joint (joint_pos + delta).""" + c = _make_controller(orientation_weight=(0.5, 0.5, 0.0), joint_limit_avoidance_gain=0.5) + c.set_joint_pos_limits(torch.full((_NUM_JOINTS,), -1.0), torch.full((_NUM_JOINTS,), 1.0)) + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + ee_quat = torch.tensor([_ID_QUAT]) + c.set_command(torch.tensor([[0.31, 0.0, 0.2] + _ID_QUAT])) + jac = torch.zeros(1, 6, _NUM_JOINTS) + for i in range(5): + jac[0, i, i] = 1.0 + out = c.compute(ee_pos, ee_quat, jac, torch.zeros(1, _NUM_JOINTS)) + assert out.shape == (1, _NUM_JOINTS) diff --git a/source/isaaclab/test/controllers/test_local_frame_task.py b/source/isaaclab/test/controllers/test_local_frame_task.py index 524b68f83c8..f96269c1e56 100644 --- a/source/isaaclab/test/controllers/test_local_frame_task.py +++ b/source/isaaclab/test/controllers/test_local_frame_task.py @@ -14,6 +14,8 @@ from isaaclab.controllers.pink_ik.pink_kinematics_configuration import PinkKinematicsConfiguration from isaaclab.controllers.pink_ik.pink_tasks import LocalFrameTask +pytestmark = pytest.mark.integration + # class TestLocalFrameTask: # """Test suite for LocalFrameTask class.""" diff --git a/source/isaaclab/test/controllers/test_null_space_posture_task.py b/source/isaaclab/test/controllers/test_null_space_posture_task.py index fa78704bb60..b6e84621380 100644 --- a/source/isaaclab/test/controllers/test_null_space_posture_task.py +++ b/source/isaaclab/test/controllers/test_null_space_posture_task.py @@ -14,6 +14,8 @@ from isaaclab.controllers.pink_ik.null_space_posture_task import NullSpacePostureTask from isaaclab.controllers.pink_ik.pink_task_cfg import NullSpacePostureTaskCfg +pytestmark = pytest.mark.integration + class TestNullSpacePostureTaskSimplifiedRobot: """Test cases for NullSpacePostureTask with simplified robot configuration.""" diff --git a/source/isaaclab/test/controllers/test_operational_space.py b/source/isaaclab/test/controllers/test_operational_space.py index bed0760271e..94f84899401 100644 --- a/source/isaaclab/test/controllers/test_operational_space.py +++ b/source/isaaclab/test/controllers/test_operational_space.py @@ -16,6 +16,8 @@ import torch from flaky import flaky +pytestmark = pytest.mark.arm_ci + import isaaclab.envs.mdp as mdp import isaaclab.sim as sim_utils from isaaclab import cloner @@ -49,6 +51,8 @@ from isaaclab_assets import FRANKA_PANDA_CFG, G1_29DOF_CFG # isort:skip +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): diff --git a/source/isaaclab/test/controllers/test_pink_ik.py b/source/isaaclab/test/controllers/test_pink_ik.py index 70423ebabe7..a7935875fa7 100644 --- a/source/isaaclab/test/controllers/test_pink_ik.py +++ b/source/isaaclab/test/controllers/test_pink_ik.py @@ -30,7 +30,7 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] def load_test_config(env_name): diff --git a/source/isaaclab/test/controllers/test_pink_ik_components.py b/source/isaaclab/test/controllers/test_pink_ik_components.py index ea5cf8ae344..4f1b25146b8 100644 --- a/source/isaaclab/test/controllers/test_pink_ik_components.py +++ b/source/isaaclab/test/controllers/test_pink_ik_components.py @@ -14,7 +14,7 @@ from isaaclab.controllers.pink_ik.pink_kinematics_configuration import PinkKinematicsConfiguration -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] class TestPinkKinematicsConfiguration: diff --git a/source/isaaclab/test/deps/test_scipy.py b/source/isaaclab/test/deps/test_scipy.py index d697716aad7..6ddf0441da6 100644 --- a/source/isaaclab/test/deps/test_scipy.py +++ b/source/isaaclab/test/deps/test_scipy.py @@ -13,6 +13,8 @@ import numpy as np import scipy.interpolate as interpolate +pytestmark = [pytest.mark.unit, pytest.mark.arm_ci] + @pytest.mark.isaacsim_ci def test_interpolation(): diff --git a/source/isaaclab/test/deps/test_torch.py b/source/isaaclab/test/deps/test_torch.py index 6a50110757d..342e3731078 100644 --- a/source/isaaclab/test/deps/test_torch.py +++ b/source/isaaclab/test/deps/test_torch.py @@ -7,6 +7,8 @@ import torch import torch.utils.benchmark as benchmark +pytestmark = [pytest.mark.unit, pytest.mark.arm_ci] + @pytest.mark.isaacsim_ci def test_array_slicing(): diff --git a/source/isaaclab/test/devices/test_device_constructors.py b/source/isaaclab/test/devices/test_device_constructors.py index 6c939503fce..a816013b2f4 100644 --- a/source/isaaclab/test/devices/test_device_constructors.py +++ b/source/isaaclab/test/devices/test_device_constructors.py @@ -27,6 +27,8 @@ Se3SpaceMouseCfg, ) +pytestmark = pytest.mark.integration + @pytest.fixture def mock_environment(mocker): diff --git a/source/isaaclab/test/devices/test_physics_manager_device.py b/source/isaaclab/test/devices/test_physics_manager_device.py new file mode 100644 index 00000000000..6b8face7f30 --- /dev/null +++ b/source/isaaclab/test/devices/test_physics_manager_device.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for physics-manager CUDA device initialization.""" + +from types import SimpleNamespace + +from isaaclab.physics import PhysicsManager +from isaaclab.physics import physics_manager as physics_manager_module + + +def test_initialize_synchronizes_cuda_before_backend_setup(monkeypatch): + """The base manager must synchronize CUDA before backend-specific setup.""" + calls = [] + monkeypatch.setattr(physics_manager_module, "set_cuda_device", lambda device: calls.append(("cuda", device))) + monkeypatch.setattr(PhysicsManager, "_sim", None) + monkeypatch.setattr(PhysicsManager, "_cfg", None) + monkeypatch.setattr(PhysicsManager, "_device", "cuda:0") + + class _TestManager(PhysicsManager): + @classmethod + def initialize(cls, sim_context): + super().initialize(sim_context) + calls.append(("backend", PhysicsManager._device)) + + sim_context = SimpleNamespace(cfg=SimpleNamespace(physics=object(), device="cuda:2")) + + _TestManager.initialize(sim_context) + + assert calls == [("cuda", "cuda:2"), ("backend", "cuda:2")] + + +def test_initialize_does_not_synchronize_cpu_device(monkeypatch): + """CPU simulation must not initialize or select CUDA runtimes.""" + devices = [] + monkeypatch.setattr(physics_manager_module, "set_cuda_device", devices.append) + monkeypatch.setattr(PhysicsManager, "_sim", None) + monkeypatch.setattr(PhysicsManager, "_cfg", None) + monkeypatch.setattr(PhysicsManager, "_device", "cuda:0") + sim_context = SimpleNamespace(cfg=SimpleNamespace(physics=object(), device="cpu")) + + PhysicsManager.initialize(sim_context) + + assert devices == [] diff --git a/source/isaaclab/test/envs/test_action_state_recorder_term.py b/source/isaaclab/test/envs/test_action_state_recorder_term.py index 2e269c55212..4eca8bfcd47 100644 --- a/source/isaaclab/test/envs/test_action_state_recorder_term.py +++ b/source/isaaclab/test/envs/test_action_state_recorder_term.py @@ -27,6 +27,8 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg +pytestmark = pytest.mark.integration + @pytest.fixture(scope="session", autouse=True) def setup_carb_settings(): diff --git a/source/isaaclab/test/envs/test_color_randomization.py b/source/isaaclab/test/envs/test_color_randomization.py index cb0bf609ffb..30ad1d447f6 100644 --- a/source/isaaclab/test/envs/test_color_randomization.py +++ b/source/isaaclab/test/envs/test_color_randomization.py @@ -34,6 +34,8 @@ from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleSceneCfg +pytestmark = pytest.mark.integration + @configclass class ActionsCfg: diff --git a/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py b/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py index 084ea1a47bc..86e287cb952 100644 --- a/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py +++ b/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py @@ -21,6 +21,8 @@ from isaaclab.envs.mdp.actions.task_space_actions import DifferentialInverseKinematicsAction from isaaclab.utils import math as math_utils +pytestmark = pytest.mark.unit + class _Stub: """Minimal stand-in for ``DifferentialInverseKinematicsAction`` that exposes only diff --git a/source/isaaclab/test/envs/test_direct_marl_env.py b/source/isaaclab/test/envs/test_direct_marl_env.py index 7454f21c876..a0fa14b3eaf 100644 --- a/source/isaaclab/test/envs/test_direct_marl_env.py +++ b/source/isaaclab/test/envs/test_direct_marl_env.py @@ -24,6 +24,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @configclass class EmptySceneCfg(InteractiveSceneCfg): diff --git a/source/isaaclab/test/envs/test_env_destructors.py b/source/isaaclab/test/envs/test_env_destructors.py index 60e0bedcd28..cb96b9b3874 100644 --- a/source/isaaclab/test/envs/test_env_destructors.py +++ b/source/isaaclab/test/envs/test_env_destructors.py @@ -13,6 +13,8 @@ from isaaclab.envs import DirectMARLEnv, DirectRLEnv, ManagerBasedEnv from isaaclab.envs.utils.marl import multi_agent_to_single_agent, multi_agent_with_one_agent +pytestmark = pytest.mark.unit + class _FakeMultiAgentEnv: possible_agents = ["agent_0", "agent_1"] diff --git a/source/isaaclab/test/envs/test_env_rendering_logic.py b/source/isaaclab/test/envs/test_env_rendering_logic.py index ca05ef2f2e2..8c8962fc9c9 100644 --- a/source/isaaclab/test/envs/test_env_rendering_logic.py +++ b/source/isaaclab/test/envs/test_env_rendering_logic.py @@ -31,7 +31,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.configclass import configclass -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci] @configclass @@ -417,6 +417,84 @@ def wrapped_step(dt): SimulationContext.clear_instance() +def create_manager_based_env_no_visualizer(render_interval: int): + """Create a manager based env with no visualizer (offscreen render only).""" + + @configclass + class EnvCfg(ManagerBasedEnvCfg): + """Configuration for the test environment.""" + + decimation: int = 4 + episode_length_s: float = 100.0 + # empty visualizer_cfgs => offscreen render is the only possible rendering path + sim: SimulationCfg = SimulationCfg(dt=0.005, render_interval=render_interval, visualizer_cfgs=[]) + scene: InteractiveSceneCfg = InteractiveSceneCfg(num_envs=1, env_spacing=1.0) + actions: EmptyManagerCfg = EmptyManagerCfg() + observations: EmptyManagerCfg = EmptyManagerCfg() + + return ManagerBasedEnv(cfg=EnvCfg()) + + +def test_headless_offscreen_render_does_not_pump_kit_every_step(): + """Regression test for issue #6316. + + With headless video recording (offscreen render enabled) but no continuous-rendering + consumer (GUI, RTX sensors, visualizers, XR), the per-step decimation loop must NOT call + :meth:`~isaaclab.sim.SimulationContext.render` (which pumps Kit's ``app.update()``). Frames + are produced on demand only when :meth:`render` is explicitly called (e.g. by the + ``RecordVideo`` wrapper). Before the fix, ``is_rendering`` reported offscreen rendering as + continuous rendering, so Kit was pumped on every environment step. + """ + env = None + original_render = None + try: + sim_utils.create_new_stage() + + env = create_manager_based_env_no_visualizer(render_interval=1) + # simulate ``--video``; leave rtx_sensors False so offscreen is the only render reason + env.sim.set_setting("/isaaclab/video/enabled", True) + env.sim._app_control_on_stop_handle = None # type: ignore + + env.reset() + + # offscreen render is enabled (module launched with enable_cameras=True) ... + assert env.sim.has_offscreen_render, "expected offscreen render to be enabled for this test" + # ... but it must NOT count as continuous rendering (this is the regression). + assert not env.sim.is_rendering, "offscreen-only rendering must not report is_rendering=True (issue #6316)" + assert not env.sim.visualizers, "expected no visualizers for the offscreen-only case" + + # Count calls into sim.render() (each in-loop call would pump Kit). + render_calls = {"n": 0} + original_render = env.sim.render + + def counting_render(*args, **kwargs): + render_calls["n"] += 1 + return original_render(*args, **kwargs) + + env.sim.render = counting_render # type: ignore[method-assign] + + actions = torch.zeros((env.num_envs, 0), device=env.device) + for _ in range(10): + env.step(action=actions) + + # No per-step rendering / Kit pumping while no frame is requested. + assert render_calls["n"] == 0, ( + f"offscreen-only stepping must not call sim.render() (issue #6316), got {render_calls['n']} calls" + ) + + # On demand (what RecordVideo does to grab a frame) rendering still works. + env.sim.render() + assert render_calls["n"] == 1, "explicit on-demand render() must still run" + + finally: + if env is not None and original_render is not None: + env.sim.render = original_render # type: ignore[method-assign] + if env is not None: + env.close() + else: + SimulationContext.clear_instance() + + def test_env_render_false_with_resets(physics_callback, render_callback): """Test that render_enabled=False skips post-reset re-renders during short episodes. diff --git a/source/isaaclab/test/envs/test_manager_based_env.py b/source/isaaclab/test/envs/test_manager_based_env.py index aede8915b58..3e5cf4c32a6 100644 --- a/source/isaaclab/test/envs/test_manager_based_env.py +++ b/source/isaaclab/test/envs/test_manager_based_env.py @@ -27,6 +27,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @configclass class EmptyManagerCfg: diff --git a/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py b/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py index a8008da887d..11c756f03be 100644 --- a/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py +++ b/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py @@ -17,9 +17,13 @@ import isaaclab.sim as sim_utils from isaaclab.envs import ManagerBasedRLEnv +from isaaclab.managers import ObservationGroupCfg from isaaclab_tasks.contrib.velocity.config.anymal_c.rough_env_cfg import AnymalCRoughEnvCfg from isaaclab_tasks.core.cartpole.cartpole_manager_camera_env_cfg import CartpoleCameraEnvCfg +from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleEnvCfg + +pytestmark = pytest.mark.integration @pytest.mark.parametrize("device", ["cpu", "cuda"]) @@ -29,80 +33,34 @@ def test_non_concatenated_obs_groups_contain_all_terms(device): Before the fix, only the last term in each non-concatenated group would be present in the observation space Dict. This test ensures all terms are correctly included. """ - from isaaclab_tasks.contrib.stack.config.franka.stack_joint_pos_env_cfg import ( - FrankaCubeStackEnvCfg, - ) - # new USD stage sim_utils.create_new_stage() - # configure the stack env - it has multiple non-concatenated observation groups - env_cfg = FrankaCubeStackEnvCfg() + # configure the policy group to return its terms separately + env_cfg = CartpoleEnvCfg() env_cfg.scene.num_envs = 2 # keep num_envs small for testing + env_cfg.observations.policy.concatenate_terms = False env_cfg.sim.device = device env = ManagerBasedRLEnv(cfg=env_cfg) + try: + assert isinstance(env.observation_space, gym.spaces.Dict) + policy_space = env.observation_space.spaces["policy"] + assert isinstance(policy_space, gym.spaces.Dict) - # Verify that observation space is properly structured - assert isinstance(env.observation_space, gym.spaces.Dict), "Top-level observation space should be Dict" - - # Test 'policy' group - should have 9 terms (not just the last one due to the bug) - assert "policy" in env.observation_space.spaces, "Policy group missing from observation space" - policy_space = env.observation_space.spaces["policy"] - assert isinstance(policy_space, gym.spaces.Dict), "Policy group should be Dict space" - - expected_policy_terms = [ - "actions", - "joint_pos", - "joint_vel", - "object", - "cube_positions", - "cube_orientations", - "eef_pos", - "eef_quat", - "gripper_pos", - ] - - # This is the key test - before the fix, only "gripper_pos" (last term) would be present - assert len(policy_space.spaces) == len(expected_policy_terms), ( - f"Policy group should have {len(expected_policy_terms)} terms, got {len(policy_space.spaces)}:" - f" {list(policy_space.spaces.keys())}" - ) - - for term_name in expected_policy_terms: - assert term_name in policy_space.spaces, f"Term '{term_name}' missing from policy group" - assert isinstance(policy_space.spaces[term_name], gym.spaces.Box), f"Term '{term_name}' should be Box space" + expected_policy_terms = ["joint_pos_rel", "joint_vel_rel"] - # Test 'subtask_terms' group - should have 3 terms (not just the last one) - assert "subtask_terms" in env.observation_space.spaces, "Subtask_terms group missing from observation space" - subtask_space = env.observation_space.spaces["subtask_terms"] - assert isinstance(subtask_space, gym.spaces.Dict), "Subtask_terms group should be Dict space" + assert list(policy_space.spaces) == expected_policy_terms + for term_name in expected_policy_terms: + assert isinstance(policy_space.spaces[term_name], gym.spaces.Box) - expected_subtask_terms = ["grasp_1", "stack_1", "grasp_2"] - - # Before the fix, only "grasp_2" (last term) would be present - assert len(subtask_space.spaces) == len(expected_subtask_terms), ( - f"Subtask_terms group should have {len(expected_subtask_terms)} terms, got {len(subtask_space.spaces)}:" - f" {list(subtask_space.spaces.keys())}" - ) - - for term_name in expected_subtask_terms: - assert term_name in subtask_space.spaces, f"Term '{term_name}' missing from subtask_terms group" - assert isinstance(subtask_space.spaces[term_name], gym.spaces.Box), f"Term '{term_name}' should be Box space" - - # Test that we can get observations and they match the space structure - env.reset() - action = torch.tensor(env.action_space.sample(), device=env.device) - obs, reward, terminated, truncated, info = env.step(action) - - # Verify all terms are present in actual observations - for term_name in expected_policy_terms: - assert term_name in obs["policy"], f"Term '{term_name}' missing from policy observation" - - for term_name in expected_subtask_terms: - assert term_name in obs["subtask_terms"], f"Term '{term_name}' missing from subtask_terms observation" - - env.close() + # Test that observations match the space structure. + env.reset() + action = torch.tensor(env.action_space.sample(), device=env.device) + obs, _, _, _, _ = env.step(action) + assert list(obs["policy"]) == expected_policy_terms + finally: + env.close() @pytest.mark.parametrize( @@ -125,19 +83,23 @@ def test_obs_space_follows_clip_contraint(env_cfg_cls, presets, device): env_cfg = resolve_presets(env_cfg_cls(), presets) env_cfg.scene.num_envs = 2 # keep num_envs small for testing - env_cfg.observations.policy.concatenate_terms = False + for group_cfg in vars(env_cfg.observations).values(): + if isinstance(group_cfg, ObservationGroupCfg): + group_cfg.concatenate_terms = False env_cfg.sim.device = device env = ManagerBasedRLEnv(cfg=env_cfg) - for group_name, group_space in env.observation_space.spaces.items(): - for term_name, term_space in group_space.spaces.items(): - term_cfg = getattr(getattr(env_cfg.observations, group_name), term_name) - low = -np.inf if term_cfg.clip is None else term_cfg.clip[0] - high = np.inf if term_cfg.clip is None else term_cfg.clip[1] - assert isinstance(term_space, gym.spaces.Box), ( - f"Expected Box space for {term_name} in {group_name}, got {type(term_space)}" - ) - assert np.all(term_space.low == low) - assert np.all(term_space.high == high) - - env.close() + try: + for group_name, group_space in env.observation_space.spaces.items(): + assert isinstance(group_space, gym.spaces.Dict) + for term_name, term_space in group_space.spaces.items(): + term_cfg = getattr(getattr(env_cfg.observations, group_name), term_name) + low = -np.inf if term_cfg.clip is None else term_cfg.clip[0] + high = np.inf if term_cfg.clip is None else term_cfg.clip[1] + assert isinstance(term_space, gym.spaces.Box), ( + f"Expected Box space for {term_name} in {group_name}, got {type(term_space)}" + ) + assert np.all(term_space.low == low) + assert np.all(term_space.high == high) + finally: + env.close() diff --git a/source/isaaclab/test/envs/test_manager_based_rl_env_ui.py b/source/isaaclab/test/envs/test_manager_based_rl_env_ui.py index 678e28ffded..ed95c94a02f 100644 --- a/source/isaaclab/test/envs/test_manager_based_rl_env_ui.py +++ b/source/isaaclab/test/envs/test_manager_based_rl_env_ui.py @@ -16,6 +16,8 @@ """Rest everything follows.""" +import pytest + from isaacsim.core.experimental.utils.app import enable_extension import isaaclab.sim as sim_utils @@ -24,6 +26,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + enable_extension("isaacsim.gui.components") diff --git a/source/isaaclab/test/envs/test_modify_env_param_curr_term.py b/source/isaaclab/test/envs/test_modify_env_param_curr_term.py index add28d254f8..d4b4c129766 100644 --- a/source/isaaclab/test/envs/test_modify_env_param_curr_term.py +++ b/source/isaaclab/test/envs/test_modify_env_param_curr_term.py @@ -22,6 +22,8 @@ from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleEnvCfg +pytestmark = pytest.mark.integration + def replace_value(env, env_id, data, value, num_steps): if env.common_step_counter > num_steps and data != value: diff --git a/source/isaaclab/test/envs/test_null_command_term.py b/source/isaaclab/test/envs/test_null_command_term.py index 48b48d09a43..a6233ab252c 100644 --- a/source/isaaclab/test/envs/test_null_command_term.py +++ b/source/isaaclab/test/envs/test_null_command_term.py @@ -9,6 +9,8 @@ from isaaclab.envs.mdp import NullCommandCfg +pytestmark = pytest.mark.unit + @pytest.fixture def env(): diff --git a/source/isaaclab/test/envs/test_scale_randomization.py b/source/isaaclab/test/envs/test_scale_randomization.py index 1104425b4b1..997b6f985f6 100644 --- a/source/isaaclab/test/envs/test_scale_randomization.py +++ b/source/isaaclab/test/envs/test_scale_randomization.py @@ -37,6 +37,8 @@ from isaaclab.terrains import TerrainImporterCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + ## # Custom action term ## diff --git a/source/isaaclab/test/envs/test_spaces_utils.py b/source/isaaclab/test/envs/test_spaces_utils.py index 57f64770b80..f6ff45091f9 100644 --- a/source/isaaclab/test/envs/test_spaces_utils.py +++ b/source/isaaclab/test/envs/test_spaces_utils.py @@ -9,6 +9,7 @@ from __future__ import annotations import numpy as np +import pytest import torch from gymnasium.spaces import Box, Dict, Discrete, MultiDiscrete, Tuple @@ -20,6 +21,8 @@ spec_to_gym_space, ) +pytestmark = pytest.mark.unit + def test_spec_to_gym_space(): """Test conversion of specs to gym spaces.""" diff --git a/source/isaaclab/test/envs/test_stacked_image_mdp.py b/source/isaaclab/test/envs/test_stacked_image_mdp.py index 7fd5a6f0b0e..60181099bce 100644 --- a/source/isaaclab/test/envs/test_stacked_image_mdp.py +++ b/source/isaaclab/test/envs/test_stacked_image_mdp.py @@ -17,7 +17,7 @@ import pytest import torch -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] from isaaclab.envs.mdp.observations import stacked_image diff --git a/source/isaaclab/test/envs/test_stacked_image_obs_manager.py b/source/isaaclab/test/envs/test_stacked_image_obs_manager.py index 840a6395478..dec8843dd02 100644 --- a/source/isaaclab/test/envs/test_stacked_image_obs_manager.py +++ b/source/isaaclab/test/envs/test_stacked_image_obs_manager.py @@ -31,7 +31,7 @@ from isaaclab.managers import ObservationGroupCfg, ObservationManager, ObservationTermCfg from isaaclab.utils import configclass -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] NUM_ENVS = 4 HEIGHT = 8 diff --git a/source/isaaclab/test/envs/test_texture_randomization.py b/source/isaaclab/test/envs/test_texture_randomization.py index c84bc8e4f4e..83208117f01 100644 --- a/source/isaaclab/test/envs/test_texture_randomization.py +++ b/source/isaaclab/test/envs/test_texture_randomization.py @@ -34,6 +34,8 @@ from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleSceneCfg +pytestmark = pytest.mark.integration + @configclass class ActionsCfg: diff --git a/source/isaaclab/test/envs/test_video_recorder.py b/source/isaaclab/test/envs/test_video_recorder.py index e22cbe65614..5bc92d54b72 100644 --- a/source/isaaclab/test/envs/test_video_recorder.py +++ b/source/isaaclab/test/envs/test_video_recorder.py @@ -2,9 +2,9 @@ # All rights reserved. # # SPDX-License-Identifier: BSD-3-Clause + """Unit tests for VideoRecorder.""" -import math import sys from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -12,433 +12,234 @@ import numpy as np import pytest -from isaaclab.envs.utils import video_recorder as _video_recorder_module -from isaaclab.envs.utils.video_recorder import VideoRecorder, _resolve_video_backend, _sync_camera_from_visualizer - -pytestmark = pytest.mark.isaacsim_ci -_BLANK_720p = np.zeros((720, 1280, 3), dtype=np.uint8) -_DEFAULT_CFG = dict( - env_render_mode="rgb_array", - eye=(7.5, 7.5, 7.5), - lookat=(0.0, 0.0, 0.0), - backend_source="visualizer", - window_width=1280, - window_height=720, -) +from isaaclab.envs.utils.video_recorder import VideoRecorder, _select_video_backend +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] +_FRAME = np.zeros((8, 12, 3), dtype=np.uint8) +_DEFAULT_CFG = { + "env_render_mode": "rgb_array", + "eye": (7.5, 7.5, 7.5), + "lookat": (0.0, 0.0, 0.0), + "backend_source": "visualizer", + "window_width": 1280, + "window_height": 720, +} -def _create_recorder(**kw): - """Return a VideoRecorder with ``__init__`` bypassed and all deps mocked out.""" - backend = kw.pop("_backend", None) - matched_visualizer = kw.pop("_matched_visualizer", None) - live_visualizer = kw.pop("_live_visualizer", None) - recorder = object.__new__(VideoRecorder) - recorder.cfg = SimpleNamespace(**{**_DEFAULT_CFG, **kw}) - recorder._scene = MagicMock() - recorder._scene.sensors = {} - recorder._scene._sensor_renderer_types = MagicMock(return_value=[]) - recorder._scene.sim.visualizers = [] - recorder._backend = backend - recorder._matched_visualizer = matched_visualizer - recorder._live_visualizer = live_visualizer - cap = MagicMock() - cap.render_rgb_array = MagicMock(return_value=_BLANK_720p) - recorder._capture = cap if backend else None - return recorder - - -def test_init_perspective_mode_creates_kit_capture(): - """With kit backend, __init__ builds Isaac Sim Kit perspective capture.""" - scene = MagicMock() - scene.sensors = {} - scene.num_envs = 1 - cfg = SimpleNamespace(**_DEFAULT_CFG) - fake_capture = MagicMock() - kit_mod = MagicMock() - kit_mod.create_isaacsim_kit_perspective_video = MagicMock(return_value=fake_capture) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("kit", None)): - with patch.object(_video_recorder_module, "_sync_camera_from_visualizer"): - with patch.dict( - sys.modules, - { - "isaaclab_physx.video_recording": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": kit_mod, - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), - }, - ): - vr = VideoRecorder(cfg, scene) - kit_mod.create_isaacsim_kit_perspective_video.assert_called_once() - assert vr._capture is fake_capture - assert vr._matched_visualizer is None - - -def test_init_newton_backend_creates_newton_capture(): - """With newton_gl backend, __init__ builds Newton GL perspective capture.""" - scene = MagicMock() - cfg = SimpleNamespace(**_DEFAULT_CFG) - fake_capture = MagicMock() - newton_mod = MagicMock() - newton_mod.create_newton_gl_perspective_video = MagicMock(return_value=fake_capture) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("newton_gl", "newton")): - with patch.object(_video_recorder_module, "_sync_camera_from_visualizer"): - with patch.dict( - sys.modules, - { - "pyglet": MagicMock(), - "isaaclab_newton.video_recording": MagicMock(), - "isaaclab_newton.video_recording.newton_gl_perspective_video": newton_mod, - "isaaclab_newton.video_recording.newton_gl_perspective_video_cfg": MagicMock(), - }, - ): - vr = VideoRecorder(cfg, scene) - newton_mod.create_newton_gl_perspective_video.assert_called_once() - assert vr._capture is fake_capture - assert vr._matched_visualizer == "newton" - - -def test_init_kit_from_visualizer_syncs_camera(): - """When backend comes from a visualizer, _sync_camera_from_visualizer is called.""" - scene = MagicMock() - cfg = SimpleNamespace(**_DEFAULT_CFG) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("kit", "kit")): - with patch.object(_video_recorder_module, "_sync_camera_from_visualizer") as mock_sync: - with patch.dict( - sys.modules, - { - "isaaclab_physx.video_recording": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), - }, - ): - VideoRecorder(cfg, scene) - mock_sync.assert_called_once_with(scene, "kit", cfg) - - -def test_init_no_visualizer_skips_camera_sync(): - """When backend comes from physics/renderer stack, camera sync is skipped.""" - scene = MagicMock() - cfg = SimpleNamespace(**_DEFAULT_CFG) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("kit", None)): - with patch.object(_video_recorder_module, "_sync_camera_from_visualizer") as mock_sync: - with patch.dict( - sys.modules, - { - "isaaclab_physx.video_recording": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), - }, - ): - VideoRecorder(cfg, scene) - mock_sync.assert_not_called() - - -def _make_scene(visualizer_types, physics_name="PhysxPhysicsManager", renderer_types=None): - scene = MagicMock() - scene.sim.resolve_visualizer_types.return_value = visualizer_types - scene.sim.physics_manager.__name__ = physics_name - scene._sensor_renderer_types.return_value = renderer_types or [] - return scene +class _FakeVisualizer: + """Initialized visualizer with a recorder-facing framebuffer.""" -def test_resolve_backend_prefers_kit_visualizer(): - """When 'kit' visualizer is active, backend is 'kit' with matched type 'kit'.""" - scene = _make_scene(["kit"]) - backend, matched = _resolve_video_backend(scene) - assert backend == "kit" - assert matched == "kit" + def __init__( + self, + visualizer_type: str, + *, + eye: tuple[float, float, float] = (1.0, 2.0, 3.0), + lookat: tuple[float, float, float] = (4.0, 5.0, 6.0), + ): + self.cfg = SimpleNamespace(visualizer_type=visualizer_type, eye=eye, lookat=lookat) + self.render_calls = 0 + def render_rgb_array(self) -> np.ndarray: + self.render_calls += 1 + return _FRAME -def test_resolve_backend_prefers_newton_visualizer(): - """When 'newton' visualizer is active, backend is 'newton_gl' with matched type 'newton'.""" - scene = _make_scene(["newton"], physics_name="NewtonPhysicsManager") - backend, matched = _resolve_video_backend(scene) - assert backend == "newton_gl" - assert matched == "newton" +def _make_cfg(**overrides): + return SimpleNamespace(**(_DEFAULT_CFG | overrides)) -def test_resolve_backend_renderer_source_ignores_visualizer(): - """When backend_source is 'renderer', active visualizers do not drive backend selection.""" - scene = _make_scene(["newton"], physics_name="PhysxPhysicsManager") - backend, matched = _resolve_video_backend(scene, backend_source="renderer") - assert backend == "kit" - assert matched is None +def _make_visualizer_cfg(visualizer_type, eye=(1.0, 2.0, 3.0), lookat=(4.0, 5.0, 6.0)): + return SimpleNamespace(visualizer_type=visualizer_type, eye=eye, lookat=lookat) -def test_resolve_backend_kit_wins_over_newton_visualizer(): - """When both kit and newton visualizers are active, kit takes priority.""" - scene = _make_scene(["newton", "kit"]) - backend, matched = _resolve_video_backend(scene) - assert backend == "kit" - assert matched == "kit" +def _make_scene(visualizer_cfgs=(), physics_backend="PhysxPhysicsManager", renderer_types=()): + scene = MagicMock() + scene.sim._resolve_visualizer_cfgs.return_value = list(visualizer_cfgs) + scene.sim.physics_manager.__name__ = physics_backend + scene.sim.visualizers = [] + scene._sensor_renderer_types.return_value = list(renderer_types) + return scene -def test_resolve_backend_warns_when_multiple_video_capable_visualizers(caplog: pytest.LogCaptureFixture): - """The Gymnasium video wrapper records one stream even if both Kit and Newton are active.""" - scene = _make_scene(["newton", "kit"]) - with caplog.at_level("WARNING"): - backend, matched = _resolve_video_backend(scene) - assert backend == "kit" - assert matched == "kit" - assert any("Multiple video-capable visualizers are active" in record.getMessage() for record in caplog.records) +def test_resolve_backend_prefers_kit_visualizer(): + """Kit has priority when both video-capable visualizers are configured.""" + newton_cfg = _make_visualizer_cfg("newton") + kit_cfg = _make_visualizer_cfg("kit") + scene = _make_scene([newton_cfg, kit_cfg]) + backend, visualizer_cfg = _select_video_backend(scene, "visualizer") -def test_resolve_backend_unsupported_visualizer_falls_through(): - """viser/rerun visualizers fall through to physics stack detection.""" - scene = _make_scene(["viser"], physics_name="PhysxPhysicsManager") - backend, matched = _resolve_video_backend(scene) assert backend == "kit" - assert matched is None + assert visualizer_cfg is kit_cfg -def test_resolve_backend_fallback_physx_returns_none_matched(): - """Physics/renderer fallback returns None as matched visualizer.""" - scene = _make_scene([], physics_name="PhysxPhysicsManager") - backend, matched = _resolve_video_backend(scene) - assert backend == "kit" - assert matched is None +def test_resolve_backend_selects_newton_visualizer(): + """A configured Newton visualizer selects direct Newton framebuffer capture.""" + newton_cfg = _make_visualizer_cfg("newton") + backend, visualizer_cfg = _select_video_backend(_make_scene([newton_cfg], "NewtonPhysicsManager"), "visualizer") -def test_resolve_backend_fallback_newton_physics_returns_none_matched(): - """Newton physics fallback returns None as matched visualizer.""" - scene = _make_scene([], physics_name="NewtonPhysicsManager") - backend, matched = _resolve_video_backend(scene) assert backend == "newton_gl" - assert matched is None + assert visualizer_cfg is newton_cfg -def test_resolve_backend_raises_when_no_supported_backend(): - """RuntimeError when no supported backend can be detected.""" - scene = _make_scene([], physics_name="UnknownManager") - with pytest.raises(RuntimeError, match="No supported backend detected"): - _resolve_video_backend(scene) +def test_resolve_backend_renderer_source_ignores_visualizers(): + """Renderer source bypasses active visualizers.""" + newton_cfg = _make_visualizer_cfg("newton") + backend, visualizer_cfg = _select_video_backend(_make_scene([newton_cfg], "PhysxPhysicsManager"), "renderer") -def test_resolve_backend_raises_for_invalid_backend_source(): - """Only 'visualizer' and 'renderer' are valid backend source modes.""" - scene = _make_scene([]) - with pytest.raises(ValueError, match="backend_source"): - _resolve_video_backend(scene, backend_source="invalid") - - -def _make_visualizer_cfg(visualizer_type, eye=None, lookat=None): - return SimpleNamespace(visualizer_type=visualizer_type, eye=eye, lookat=lookat) - + assert backend == "kit" + assert visualizer_cfg is None -def test_sync_camera_overwrites_cfg_from_visualizer(): - """Visualizer cfg eye/lookat are written into VideoRecorderCfg.""" - scene = MagicMock() - scene.sim._resolve_visualizer_cfgs.return_value = [ - _make_visualizer_cfg("newton", eye=(1.0, 2.0, 3.0), lookat=(4.0, 5.0, 6.0)), - ] - cfg = SimpleNamespace(**_DEFAULT_CFG) - _sync_camera_from_visualizer(scene, "newton", cfg) - assert cfg.eye == (1.0, 2.0, 3.0) - assert cfg.lookat == (4.0, 5.0, 6.0) +def test_resolve_backend_unsupported_visualizer_falls_back(): + """A visualizer without RGB capture falls back to the renderer stack.""" + viser_cfg = _make_visualizer_cfg("viser") -def test_sync_camera_skips_wrong_visualizer_type(): - """Only the matching visualizer type updates the cfg.""" - scene = MagicMock() - scene.sim._resolve_visualizer_cfgs.return_value = [ - _make_visualizer_cfg("kit", eye=(9.0, 9.0, 9.0), lookat=(1.0, 1.0, 1.0)), - ] - cfg = SimpleNamespace(**_DEFAULT_CFG) - original_eye = cfg.eye - _sync_camera_from_visualizer(scene, "newton", cfg) - assert cfg.eye == original_eye # unchanged + backend, visualizer_cfg = _select_video_backend(_make_scene([viser_cfg], "PhysxPhysicsManager"), "visualizer") + assert backend == "kit" + assert visualizer_cfg is None -def test_sync_camera_handles_missing_camera_fields(): - """If visualizer cfg has no camera fields, existing cfg values are kept.""" - scene = MagicMock() - vcfg = _make_visualizer_cfg("newton", eye=None, lookat=None) - scene.sim._resolve_visualizer_cfgs.return_value = [vcfg] - cfg = SimpleNamespace(**_DEFAULT_CFG) - original_eye = cfg.eye - _sync_camera_from_visualizer(scene, "newton", cfg) - assert cfg.eye == original_eye +@pytest.mark.parametrize( + ("physics_backend", "renderer_types", "expected"), + [ + ("PhysxPhysicsManager", (), "kit"), + ("unknown", ("isaac_rtx",), "kit"), + ("NewtonPhysicsManager", (), "newton_gl"), + ("unknown", ("newton_warp",), "newton_gl"), + ], +) +def test_resolve_backend_uses_renderer_stack(physics_backend, renderer_types, expected): + """Physics and sensor renderers select the fallback capture backend.""" + backend, visualizer = _select_video_backend( + _make_scene(physics_backend=physics_backend, renderer_types=renderer_types), + "visualizer", + ) -def test_sync_camera_handles_resolve_exception(): - """If _resolve_visualizer_cfgs raises, no exception propagates and cfg is unchanged.""" - scene = MagicMock() - scene.sim._resolve_visualizer_cfgs.side_effect = RuntimeError("boom") - cfg = SimpleNamespace(**_DEFAULT_CFG) - original_eye = cfg.eye - _sync_camera_from_visualizer(scene, "newton", cfg) - assert cfg.eye == original_eye - - -def test_render_rgb_array_delegates_to_capture(): - """render_rgb_array returns capture.render_rgb_array().""" - recorder = _create_recorder(_backend="kit") - result = recorder.render_rgb_array() - recorder._capture.render_rgb_array.assert_called_once() - assert result.shape == (720, 1280, 3) - - -def test_render_rgb_array_none_when_no_backend(): - """Without rgb_array env_render_mode, _capture is None and render returns None.""" - recorder = _create_recorder(env_render_mode=None) - recorder._backend = None - recorder._capture = None - assert recorder.render_rgb_array() is None + assert backend == expected + assert visualizer is None -def test_capture_exception_propagates(): - """Failures in backend capture propagate.""" - recorder = _create_recorder(_backend="newton_gl") - recorder._capture.render_rgb_array.side_effect = RuntimeError("fail") - with pytest.raises(RuntimeError, match="fail"): - recorder.render_rgb_array() +def test_resolve_backend_raises_without_supported_source(): + """An unsupported stack cannot provide RGB video.""" + with pytest.raises(RuntimeError, match="No supported backend detected"): + _select_video_backend(_make_scene(physics_backend="unknown"), "visualizer") -def test_render_rgb_array_calls_capture_each_step(): - """Each render_rgb_array call hits the backend capture.""" - recorder = _create_recorder(_backend="kit") - for _ in range(3): +def test_resolve_backend_rejects_invalid_source(): + """Only visualizer and renderer are valid backend sources.""" + with pytest.raises(ValueError, match="backend_source"): + _select_video_backend(_make_scene(), "invalid") + + +def test_newton_visualizer_capture_is_bound_on_first_render(): + """Newton video reads the initialized visualizer framebuffer without creating another viewer.""" + newton_cfg = _make_visualizer_cfg("newton") + newton = _FakeVisualizer("newton") + scene = _make_scene([newton_cfg], "NewtonPhysicsManager") + recorder = VideoRecorder(_make_cfg(), scene) + + assert recorder._capture is None + scene.sim.visualizers = [newton] + assert recorder.render_rgb_array() is _FRAME + assert recorder._capture is newton + + scene.sim.visualizers = [] + assert recorder.render_rgb_array() is _FRAME + assert newton.render_calls == 2 + + +def test_init_creates_kit_capture_with_visualizer_camera(): + """Kit capture is created before reset using the selected visualizer camera.""" + kit_cfg = _make_visualizer_cfg("kit", eye=(1.0, 2.0, 3.0), lookat=(4.0, 5.0, 6.0)) + capture = MagicMock() + capture.render_rgb_array.return_value = _FRAME + create_capture = MagicMock(return_value=capture) + capture_cfg = MagicMock() + capture_cfg_type = MagicMock(return_value=capture_cfg) + capture_module = SimpleNamespace(create_isaacsim_kit_perspective_video=create_capture) + cfg_module = SimpleNamespace(IsaacsimKitPerspectiveVideoCfg=capture_cfg_type) + + with patch.dict( + sys.modules, + { + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": capture_module, + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": cfg_module, + }, + ): + recorder = VideoRecorder(_make_cfg(), _make_scene([kit_cfg])) + create_capture.assert_called_once_with(capture_cfg) + frame = recorder.render_rgb_array() + + assert frame is _FRAME + capture_cfg_type.assert_called_once_with( + eye=(1.0, 2.0, 3.0), + lookat=(4.0, 5.0, 6.0), + window_width=1280, + window_height=720, + ) + + +def test_renderer_source_creates_standalone_newton_capture(): + """Renderer-selected Newton video uses the recorder camera and standalone capture.""" + newton_cfg = _make_visualizer_cfg("newton") + capture = MagicMock() + capture.render_rgb_array.return_value = _FRAME + create_capture = MagicMock(return_value=capture) + capture_cfg = MagicMock() + capture_cfg_type = MagicMock(return_value=capture_cfg) + capture_module = SimpleNamespace(create_newton_gl_perspective_video=create_capture) + cfg_module = SimpleNamespace(NewtonGlPerspectiveVideoCfg=capture_cfg_type) + + with patch.dict( + sys.modules, + { + "isaaclab_newton.video_recording.newton_gl_perspective_video": capture_module, + "isaaclab_newton.video_recording.newton_gl_perspective_video_cfg": cfg_module, + }, + ): + recorder = VideoRecorder( + _make_cfg(backend_source="renderer"), + _make_scene([newton_cfg], "NewtonPhysicsManager"), + ) + create_capture.assert_called_once_with(capture_cfg) + frame = recorder.render_rgb_array() + + assert frame is _FRAME + capture_cfg_type.assert_called_once_with( + window_width=1280, + window_height=720, + eye=(7.5, 7.5, 7.5), + lookat=(0.0, 0.0, 0.0), + ) + + +def test_newton_visualizer_capture_requires_initialized_visualizer(): + """The selected Newton visualizer must be initialized before its framebuffer is read.""" + newton_cfg = _make_visualizer_cfg("newton") + recorder = VideoRecorder(_make_cfg(), _make_scene([newton_cfg], "NewtonPhysicsManager")) + + with pytest.raises(RuntimeError, match="not initialized"): recorder.render_rgb_array() - assert recorder._capture.render_rgb_array.call_count == 3 -def test_render_rgb_array_calls_sync_newton_camera_when_newton_visualizer(): - """render_rgb_array triggers _sync_newton_camera when matched_visualizer is 'newton'.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - with patch.object(recorder, "_sync_newton_camera") as mock_sync: - recorder.render_rgb_array() - mock_sync.assert_called_once() +def test_render_rgb_array_is_disabled_for_other_render_modes(): + """The recorder remains dormant unless Gym requests RGB arrays.""" + recorder = VideoRecorder(_make_cfg(env_render_mode=None), _make_scene()) + assert recorder.render_rgb_array() is None + assert recorder._capture is None -def test_render_rgb_array_skips_sync_for_kit_visualizer(): - """render_rgb_array does NOT call _sync_newton_camera for kit backend.""" - recorder = _create_recorder(_backend="kit", _matched_visualizer="kit") - with patch.object(recorder, "_sync_newton_camera") as mock_sync: - recorder.render_rgb_array() - mock_sync.assert_not_called() +def test_capture_errors_propagate(): + """Capture failures are not hidden by VideoRecorder.""" + capture = MagicMock() + capture.render_rgb_array.side_effect = RuntimeError("capture failed") + recorder = VideoRecorder(_make_cfg(), _make_scene()) + recorder._capture = capture -def test_render_rgb_array_skips_sync_when_no_visualizer(): - """render_rgb_array does NOT call _sync_newton_camera when using physics/renderer stack.""" - recorder = _create_recorder(_backend="kit", _matched_visualizer=None) - with patch.object(recorder, "_sync_newton_camera") as mock_sync: + with pytest.raises(RuntimeError, match="capture failed"): recorder.render_rgb_array() - mock_sync.assert_not_called() - - -def _make_newton_visualizer(pos=(1.0, 2.0, 3.0), yaw_deg=45.0, pitch_deg=30.0): - """Return a mock that quacks like a NewtonVisualizer with a live camera.""" - viz = MagicMock() - viz.cfg.visualizer_type = "newton" - cam = MagicMock() - cam.pos = pos - cam.yaw = yaw_deg - cam.pitch = pitch_deg - viz._viewer = MagicMock() - viz._viewer.camera = cam - return viz - - -def test_sync_newton_camera_lazy_lookup_finds_visualizer(): - """_sync_newton_camera resolves the Newton visualizer on the first call.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - newton_viz = _make_newton_visualizer() - recorder._scene.sim.visualizers = [newton_viz] - - recorder._sync_newton_camera() - - assert recorder._live_visualizer is newton_viz - recorder._capture.update_camera.assert_called_once() - - -def test_sync_newton_camera_uses_cached_visualizer(): - """_sync_newton_camera uses the cached _live_visualizer and skips the list walk.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - newton_viz = _make_newton_visualizer() - # pre-cache the visualizer - recorder._live_visualizer = newton_viz - - other_viz = _make_newton_visualizer(pos=(99.0, 99.0, 99.0)) - # replace sim.visualizers with a second Newton visualizer - # if the cache is bypassed the recorder would use this one instead. - recorder._scene.sim.visualizers = [other_viz] - recorder._sync_newton_camera() - position = recorder._capture.update_camera.call_args[0][0] - assert position != (99.0, 99.0, 99.0) - recorder._capture.update_camera.assert_called_once() - - -def test_sync_newton_camera_correct_position_forwarded(): - """_sync_newton_camera reads cam.pos and passes it as position to update_camera.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - newton_viz = _make_newton_visualizer(pos=(10.0, 20.0, 30.0), yaw_deg=0.0, pitch_deg=0.0) - recorder._live_visualizer = newton_viz - - recorder._sync_newton_camera() - - args = recorder._capture.update_camera.call_args - position = args[0][0] - assert position == (10.0, 20.0, 30.0) - - -def test_sync_newton_camera_target_derived_from_pitch_yaw(): - """Target is reconstructed from pitch/yaw and is unit-distance from position.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - pos = (0.0, 0.0, 0.0) - yaw_deg, pitch_deg = 0.0, 0.0 # looking along +X at horizon - newton_viz = _make_newton_visualizer(pos=pos, yaw_deg=yaw_deg, pitch_deg=pitch_deg) - recorder._live_visualizer = newton_viz - - recorder._sync_newton_camera() - - args = recorder._capture.update_camera.call_args[0] - position, target = args - dx = target[0] - position[0] - dy = target[1] - position[1] - dz = target[2] - position[2] - dist = math.sqrt(dx**2 + dy**2 + dz**2) - assert abs(dist - 1.0) < 1e-6 - assert abs(dx - 1.0) < 1e-6 - assert abs(dy) < 1e-6 - assert abs(dz) < 1e-6 - - -def test_sync_newton_camera_no_visualizer_does_not_raise(): - """_sync_newton_camera silently skips when no Newton visualizer is registered.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - recorder._scene.sim.visualizers = [] - recorder._sync_newton_camera() # must not raise - recorder._capture.update_camera.assert_not_called() - - -def test_sync_newton_camera_skips_non_newton_visualizers(): - """_sync_newton_camera ignores visualizers whose type is not 'newton'.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - kit_viz = MagicMock() - kit_viz.cfg.visualizer_type = "kit" - recorder._scene.sim.visualizers = [kit_viz] - recorder._sync_newton_camera() - recorder._capture.update_camera.assert_not_called() - - -def test_sync_newton_camera_skips_when_viewer_is_none(): - """_sync_newton_camera skips camera update when _viewer is None (headless fallback).""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - viz = MagicMock() - viz.cfg.visualizer_type = "newton" - viz._viewer = None - recorder._live_visualizer = viz - recorder._sync_newton_camera() - recorder._capture.update_camera.assert_not_called() - - -def test_sync_newton_camera_called_per_frame(): - """_sync_newton_camera (and thus update_camera) is called on every render step.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - newton_viz = _make_newton_visualizer() - recorder._live_visualizer = newton_viz - - for _ in range(4): - recorder.render_rgb_array() - - assert recorder._capture.update_camera.call_count == 4 diff --git a/source/isaaclab/test/install_ci/Dockerfile.installci b/source/isaaclab/test/install_ci/Dockerfile.installci index 662af204ce3..68b127df7b3 100644 --- a/source/isaaclab/test/install_ci/Dockerfile.installci +++ b/source/isaaclab/test/install_ci/Dockerfile.installci @@ -42,9 +42,9 @@ RUN apt-get update && \ sudo \ && rm -rf /var/lib/apt/lists/* -# ARM-specific build deps (swig, GL/X11 dev headers) are intentionally NOT -# pre-installed here. ``./isaaclab.sh -i`` auto-installs them via apt when -# missing, so install_ci exercises the real user-visible install path on ARM. +# ARM-specific GL/X11 dev headers are intentionally NOT pre-installed here. +# ``./isaaclab.sh -i`` auto-installs them via apt when missing, so install_ci +# exercises the real user-visible install path on ARM. # Make python3 the default python RUN ln -sf /usr/bin/python3 /usr/bin/python @@ -100,7 +100,7 @@ RUN if getent passwd ubuntu >/dev/null; then userdel -r ubuntu; fi \ && chown -R isaaclab:isaaclab ${ISAACLAB_PATH} # Passwordless sudo for the runtime user so ``./isaaclab.sh -i`` can apt-get -# install missing build deps (swig, GL/X11) without prompting at test time. +# install missing GL/X11 build deps without prompting at test time. RUN echo "isaaclab ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/isaaclab \ && chmod 0440 /etc/sudoers.d/isaaclab diff --git a/source/isaaclab/test/install_ci/cli/test_cli_install_all_in_uvenv_training.py b/source/isaaclab/test/install_ci/cli/test_cli_install_all_in_uvenv_training.py index 79e0ac180ea..8cb3d8e85cc 100644 --- a/source/isaaclab/test/install_ci/cli/test_cli_install_all_in_uvenv_training.py +++ b/source/isaaclab/test/install_ci/cli/test_cli_install_all_in_uvenv_training.py @@ -7,8 +7,17 @@ Setup: - ./isaaclab.sh -u Tests: - - ./isaaclab.sh -i core -> verify core submodules importable - - ./isaaclab.sh -i all -> verify cartpole training works + - ./isaaclab.sh -i core + -> verify core submodules are importable + - ./isaaclab.sh -i all + -> verify the full installation succeeds + - ./isaaclab.sh train --rl_library rsl_rl --task Isaac-Cartpole-Direct + --num_envs 16 presets=newton_mjwarp --max_iterations 5 + -> verify state training completes + - ./isaaclab.sh train --rl_library rsl_rl --task Isaac-Cartpole-Camera-Direct + --num_envs 16 presets=newton_mjwarp,newton_renderer --max_iterations 2 + --headless --enable_cameras + -> verify camera rendering is valid and camera training completes """ from __future__ import annotations @@ -18,36 +27,6 @@ import pytest from utils import UV_Mixin -# --------------------------------------------------------------------------- -# Shared training helper -# --------------------------------------------------------------------------- - -_TRAIN_CMD = [ - "train", - "--rl_library", - "rsl_rl", - "--task", - "Isaac-Cartpole-Direct", - "--num_envs", - "16", - "presets=newton_mjwarp", - "--max_iterations", - "5", - "--headless", -] - - -def _assert_training_passed(result) -> None: - output = result.stdout + (result.stderr or "") - assert result.returncode == 0, f"Training failed (rc={result.returncode}):\n{output}" - assert "Traceback (most recent call last):" not in output, f"Training produced a traceback:\n{output}" - assert "Training time:" in output, f"Training did not report completion:\n{output}" - - -# --------------------------------------------------------------------------- -# uv-based tests -# --------------------------------------------------------------------------- - class Test_Cli_Install_All_In_Uvenv_Training(UV_Mixin): """Installation and training smoke tests using uv environments.""" @@ -93,9 +72,9 @@ def test_install_core_makes_core_submodules_importable(self, isaaclab_root): @pytest.mark.uv @pytest.mark.slow @pytest.mark.gpu - @pytest.mark.timeout(1800) - def test_install_all_trains_cartpole(self, isaaclab_root): - """``./isaaclab.sh -i all`` (full install) + training completes successfully.""" + @pytest.mark.timeout(3600) + def test_install_all_trains_cartpole(self, isaaclab_root, cartpole_smoke_script): + """``-i all`` supports state training, camera rendering, and camera training.""" try: self.create_uv_env(isaaclab_root) result = self.run_in_uv_env( @@ -105,10 +84,10 @@ def test_install_all_trains_cartpole(self, isaaclab_root): ) assert result.returncode == 0, f"isaaclab -i all failed:\n{result.stdout}\n{result.stderr}" result = self.run_in_uv_env( - [str(self.cli_script)] + _TRAIN_CMD, + [str(self.python), str(cartpole_smoke_script)], cwd=isaaclab_root, - timeout=600, + timeout=3000, ) - _assert_training_passed(result) + assert result.returncode == 0, f"Cartpole smoke failed:\n{result.stdout}\n{result.stderr}" finally: self.destroy_uv_env() 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 1d39de3137a..10d0ff5ff34 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 322c4b29352..7de51d7afc7 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 747330dfd84..d7bc590c828 100644 --- a/source/isaaclab/test/install_ci/conftest.py +++ b/source/isaaclab/test/install_ci/conftest.py @@ -41,6 +41,12 @@ def isaaclab_root() -> Path: return find_isaaclab_root() +@pytest.fixture(scope="session") +def cartpole_smoke_script() -> Path: + """Path to the shared Cartpole smoke probe executed inside installed environments.""" + return Path(__file__).resolve().parent / "misc" / "cartpole_training_smoke.py" + + @pytest.fixture def tmp_venv(tmp_path: Path): """Create a temporary Python virtual-environment and tear it down after the test. @@ -100,6 +106,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/cartpole_training_smoke.py b/source/isaaclab/test/install_ci/misc/cartpole_training_smoke.py new file mode 100644 index 00000000000..58fd7d1fc0c --- /dev/null +++ b/source/isaaclab/test/install_ci/misc/cartpole_training_smoke.py @@ -0,0 +1,124 @@ +# 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 + +"""Shared state-training, camera-rendering, and camera-training Cartpole smoke probes. + +The caller owns environment preparation. Installation CI executes this file +inside the environment it just installed, while architecture CI executes the +same probes inside its prepared image. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +_STATE_TRAIN_CMD = [ + "train", + "--rl_library", + "rsl_rl", + "--task", + "Isaac-Cartpole-Direct", + "--num_envs", + "16", + "presets=newton_mjwarp", + "--max_iterations", + "5", +] + +_CAMERA_TRAIN_CMD = [ + "train", + "--rl_library", + "rsl_rl", + "--task", + "Isaac-Cartpole-Camera-Direct", + "--num_envs", + "16", + "presets=newton_mjwarp,newton_renderer", + "--max_iterations", + "2", + "--headless", + "--enable_cameras", +] + + +def _find_isaaclab_root() -> Path: + """Return the repository root containing the Isaac Lab launcher.""" + for parent in Path(__file__).resolve().parents: + if (parent / "isaaclab.sh").exists() or (parent / "isaaclab.bat").exists(): + return parent + raise FileNotFoundError("Could not locate the Isaac Lab repository root") + + +def _assert_training_passed(result: subprocess.CompletedProcess[str]) -> None: + """Assert that a short training run completed without a traceback.""" + output = result.stdout + (result.stderr or "") + assert result.returncode == 0, f"Training failed (rc={result.returncode}):\n{output}" + assert "Traceback (most recent call last):" not in output, f"Training produced a traceback:\n{output}" + assert "Training time:" in output, f"Training did not report completion:\n{output}" + + +def _run_training(command: list[str], timeout: int) -> None: + """Run one training command in the caller's active environment.""" + isaaclab_root = _find_isaaclab_root() + launcher = isaaclab_root / ("isaaclab.bat" if os.name == "nt" else "isaaclab.sh") + result = subprocess.run( + [str(launcher)] + command, + cwd=isaaclab_root, + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + _assert_training_passed(result) + + +def test_train_cartpole_state_completes() -> None: + """Verify that state-observation Cartpole completes short rsl_rl training.""" + _run_training(_STATE_TRAIN_CMD, timeout=600) + + +def test_render_cartpole_camera_produces_valid_observation_and_reward() -> None: + """Verify that camera Cartpole renders varying pixels and produces finite rewards.""" + import torch + + from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env import CartpoleCameraEnv + from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env_cfg import CartpoleCameraEnvCfg + from isaaclab_tasks.utils.hydra import resolve_presets + + env_cfg = resolve_presets(CartpoleCameraEnvCfg(), selected={"newton_mjwarp", "newton_renderer"}) + env_cfg.scene.num_envs = 2 + env_cfg.frame_stack = 1 + env = None + try: + env = CartpoleCameraEnv(cfg=env_cfg) + obs, _ = env.reset() + image = obs["policy"] + expected_shape = (2, 3, env_cfg.tiled_camera.height, env_cfg.tiled_camera.width) + assert tuple(image.shape) == expected_shape, ( + f"Camera observation shape {tuple(image.shape)} != {expected_shape}" + ) + assert torch.isfinite(image).all(), "Camera observation contains NaN or infinity" + assert image.amax() > image.amin(), "Camera observation is constant" + + action = torch.zeros(env.num_envs, 1, device=env.device) + _, reward, _, _, _ = env.step(action) + assert torch.isfinite(reward).all(), "Cartpole reward contains NaN or infinity" + finally: + if env is not None: + env.close() + + +def test_train_cartpole_camera_completes() -> None: + """Verify that camera-observation Cartpole completes short RSL-RL training.""" + # A cold camera run compiles shaders before training begins. + _run_training(_CAMERA_TRAIN_CMD, timeout=1800) + + +if __name__ == "__main__": + test_train_cartpole_state_completes() + test_render_cartpole_camera_produces_valid_observation_and_reward() + test_train_cartpole_camera_completes() 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 e111b0fec3c..fe79a384228 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 ab7e9d50137..93b6cec4183 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/utils.py b/source/isaaclab/test/install_ci/utils.py index 793219b6d8d..f2e3719392f 100644 --- a/source/isaaclab/test/install_ci/utils.py +++ b/source/isaaclab/test/install_ci/utils.py @@ -198,6 +198,19 @@ def cuda_torch_index_url() -> str: return "https://download.pytorch.org/whl/cu128" +def pinned_torch_specs() -> list[str]: + """Return the pinned ``torch``/``torchvision`` install specs from the repo pyproject. + + Reads ``[tool.isaaclab.versions]`` (the single source of truth) so these + install commands track the same versions as the docs and the install CLI. + """ + import tomllib + + with (find_isaaclab_root() / "pyproject.toml").open("rb") as fd: + versions = tomllib.load(fd)["tool"]["isaaclab"]["versions"] + return [f"torch=={versions['torch']}", f"torchvision=={versions['torchvision']}"] + + def aarch64_isaacsim_env() -> dict[str, str]: """Return env vars required to import ``isaacsim`` from a ``uv pip`` install on aarch64. 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 eab7d5ba937..5d201989ac4 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 @@ -10,13 +10,16 @@ - uv pip install [all,isaacsim] --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow - uv pip install --reinstall-package torch --reinstall-package torchvision - torch==2.10.0 torchvision==0.25.0 --index-url + torch== torchvision== --index-url + (versions read from [tool.isaaclab.versions] in the root pyproject.) (cu128 on x86_64, cu130 on aarch64; per docs/source/setup/installation/pip_installation.rst. Reinstall AFTER the wheel install: unsafe-best-match re-resolves torch from PyPI to CPU.) - (aarch64 only) export LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 Tests: - ./isaaclab.sh train --rl_library rsl_rl --task Isaac-Cartpole-Direct --num_envs 16 - presets=newton_mjwarp --max_iterations 5 --headless + presets=newton_mjwarp --max_iterations 5; ./isaaclab.sh train --rl_library rsl_rl + --task Isaac-Cartpole-Camera-Direct --num_envs 16 presets=newton_mjwarp,newton_renderer --max_iterations 2 + --headless --enable_cameras -> verify state training, camera rendering, and camera training work """ from __future__ import annotations @@ -24,28 +27,7 @@ import shutil import pytest -from utils import UV_Mixin, aarch64_isaacsim_env, cuda_torch_index_url - -_TRAIN_CMD = [ - "train", - "--rl_library", - "rsl_rl", - "--task", - "Isaac-Cartpole-Direct", - "--num_envs", - "16", - "presets=newton_mjwarp", - "--max_iterations", - "5", - "--headless", -] - - -def _assert_training_passed(result) -> None: - output = result.stdout + (result.stderr or "") - assert result.returncode == 0, f"Training failed (rc={result.returncode}):\n{output}" - assert "Traceback (most recent call last):" not in output, f"Training produced a traceback:\n{output}" - assert "Training time:" in output, f"Training did not report completion:\n{output}" +from utils import UV_Mixin, aarch64_isaacsim_env, cuda_torch_index_url, pinned_torch_specs @pytest.mark.install_path_uv_pip @@ -58,11 +40,12 @@ 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 - @pytest.mark.timeout(3600) - def test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole(self, isaaclab_root, wheel): + @pytest.mark.timeout(4800) + def test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole(self, isaaclab_root, wheel, cartpole_smoke_script): """Install the runner-supplied wheel with ``[all,isaacsim]`` via ``uv pip``, run cartpole training.""" try: # 1. Create the uv env and install the wheel with [all,isaacsim] extras. @@ -93,8 +76,9 @@ def test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole(self, isaaclab_roo ) # 2. uv pip install --reinstall-package torch --reinstall-package torchvision - # torch==2.10.0 torchvision==0.25.0 --index-url - # cu128 on x86_64, cu130 on aarch64 (e.g. GB10 / DGX Spark with CUDA capability 12.x). + # torch== torchvision== --index-url + # (versions from [tool.isaaclab.versions]; cu128 on x86_64, cu130 on aarch64, + # e.g. GB10 / DGX Spark with CUDA capability 12.x). # --reinstall-package forces uv to swap the CPU torch installed above with the CUDA build. result = self.run_in_uv_env( [ @@ -105,8 +89,7 @@ def test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole(self, isaaclab_roo "torch", "--reinstall-package", "torchvision", - "torch==2.10.0", - "torchvision==0.25.0", + *pinned_torch_specs(), "--index-url", cuda_torch_index_url(), ], @@ -115,14 +98,13 @@ def test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole(self, isaaclab_roo ) assert result.returncode == 0, f"uv pip install CUDA torch failed:\n{result.stdout}\n{result.stderr}" - # 3. Run cartpole training via ./isaaclab.sh train (same invocation as - # test_cli_install_training_in_uvenv::test_install_all_trains_cartpole). + # 3. Run the shared state and camera Cartpole smoke in the installed environment. result = self.run_in_uv_env( - [str(self.cli_script)] + _TRAIN_CMD, + [str(self.python), str(cartpole_smoke_script)], cwd=isaaclab_root, env=aarch64_isaacsim_env(), - timeout=900, + timeout=3000, ) - _assert_training_passed(result) + assert result.returncode == 0, f"Cartpole smoke failed:\n{result.stdout}\n{result.stderr}" finally: self.destroy_uv_env() diff --git a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_isaacsim_imports_simulation_context.py b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_isaacsim_imports_simulation_context.py index 4b3851e0962..0d2d1361001 100644 --- a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_isaacsim_imports_simulation_context.py +++ b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_isaacsim_imports_simulation_context.py @@ -10,7 +10,8 @@ - uv pip install [isaacsim] --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow - uv pip install --reinstall-package torch --reinstall-package torchvision - torch==2.10.0 torchvision==0.25.0 --index-url + torch== torchvision== --index-url + (versions read from [tool.isaaclab.versions] in the root pyproject.) (cu128 on x86_64, cu130 on aarch64; per docs/source/setup/installation/pip_installation.rst. Reinstall AFTER the wheel install: unsafe-best-match re-resolves torch from PyPI to CPU.) - (aarch64 only) export LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 @@ -24,7 +25,7 @@ import shutil import pytest -from utils import UV_Mixin, aarch64_isaacsim_env, cuda_torch_index_url +from utils import UV_Mixin, aarch64_isaacsim_env, cuda_torch_index_url, pinned_torch_specs @pytest.mark.install_path_uv_pip @@ -82,8 +83,7 @@ def _install_wheel(self, isaaclab_root, wheel): "torch", "--reinstall-package", "torchvision", - "torch==2.10.0", - "torchvision==0.25.0", + *pinned_torch_specs(), "--index-url", cuda_torch_index_url(), ], diff --git a/source/isaaclab/test/managers/test_event_manager.py b/source/isaaclab/test/managers/test_event_manager.py index e716de2f0ba..cd17912515c 100644 --- a/source/isaaclab/test/managers/test_event_manager.py +++ b/source/isaaclab/test/managers/test_event_manager.py @@ -28,6 +28,8 @@ from isaaclab.sim import SimulationContext from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + DummyEnv = namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim", "dummy1", "dummy2"]) """Dummy environment for testing.""" diff --git a/source/isaaclab/test/managers/test_manager_base.py b/source/isaaclab/test/managers/test_manager_base.py index 87d068a300d..74c97639919 100644 --- a/source/isaaclab/test/managers/test_manager_base.py +++ b/source/isaaclab/test/managers/test_manager_base.py @@ -23,6 +23,8 @@ from isaaclab.managers import ManagerTermBase, ManagerTermBaseCfg from isaaclab.managers.manager_base import ManagerBase +pytestmark = pytest.mark.integration + DummyEnv = namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim", "dummy1", "dummy2"]) """Dummy environment for testing.""" diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index e821fafe23c..7226029c9ce 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -32,6 +32,8 @@ from isaaclab.utils import modifiers from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + if TYPE_CHECKING: from isaaclab.envs import ManagerBasedEnv diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index 443fe50fb64..b7787b04995 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -33,6 +33,8 @@ from isaaclab.sim import SimulationContext from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + if TYPE_CHECKING: import numpy as np diff --git a/source/isaaclab/test/managers/test_reward_manager.py b/source/isaaclab/test/managers/test_reward_manager.py index 92554292e6f..6038404a759 100644 --- a/source/isaaclab/test/managers/test_reward_manager.py +++ b/source/isaaclab/test/managers/test_reward_manager.py @@ -21,6 +21,8 @@ from isaaclab.sim import SimulationContext from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + def grilled_chicken(env): return 1 diff --git a/source/isaaclab/test/managers/test_termination_manager.py b/source/isaaclab/test/managers/test_termination_manager.py index db96e93675c..095a95d69db 100644 --- a/source/isaaclab/test/managers/test_termination_manager.py +++ b/source/isaaclab/test/managers/test_termination_manager.py @@ -18,6 +18,8 @@ from isaaclab.managers import TerminationManager, TerminationTermCfg from isaaclab.sim import SimulationContext +pytestmark = pytest.mark.integration + class DummyEnv: """Minimal mutable env stub for the termination manager tests.""" diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index 79a66443eca..7fdcde236ad 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -19,6 +19,7 @@ import numpy as np import pytest import torch +import warp as wp from isaaclab_visualizers.kit.kit_visualizer import KitVisualizer from isaaclab_visualizers.kit.kit_visualizer_cfg import KitVisualizerCfg from isaaclab_visualizers.newton.newton_visualizer_cfg import NewtonVisualizerCfg @@ -31,6 +32,8 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.math import random_orientation +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): @@ -498,8 +501,22 @@ class _FakeNewtonMarkerMesh: uvs = np.zeros((0, 2), dtype=np.float32) +_NEWTON_MARKER_SPECS = { + "arrow": newton_markers._NewtonMarkerSpec( + renderer="mesh", + mesh_type="box", + mesh_params={"size": (1.0, 1.0, 1.0)}, + color=(1.0, 1.0, 1.0), + texture=np.zeros((2, 2, 3), dtype=np.uint8), + ), + "sphere": newton_markers._NewtonMarkerSpec(renderer="mesh", mesh_type="sphere", mesh_params={"radius": 1.0}), + "frame": newton_markers._NewtonMarkerSpec(renderer="frame"), +} + + class _FakeNewtonMarkerViewer: - def __init__(self): + def __init__(self, world_offsets): + self.world_offsets = world_offsets self.meshes = [] self.instances = [] self.lines = [] @@ -552,53 +569,218 @@ def _make_newton_marker_for_render( marker.count = translations.shape[0] marker._registered_meshes = set() marker._warned_unsupported = set() + marker._marker_specs = {name: _NEWTON_MARKER_SPECS[name] for name in marker_names} return marker -def _patch_newton_marker_render_deps(monkeypatch: pytest.MonkeyPatch) -> None: - specs = { - "arrow": newton_markers._NewtonMarkerSpec( - renderer="mesh", - mesh_type="box", - mesh_params={"size": (1.0, 1.0, 1.0)}, - color=(1.0, 1.0, 1.0), - texture=np.zeros((2, 2, 3), dtype=np.uint8), - ), - "sphere": newton_markers._NewtonMarkerSpec(renderer="mesh", mesh_type="sphere", mesh_params={"radius": 1.0}), - "frame": newton_markers._NewtonMarkerSpec(renderer="frame"), - } +def _patch_newton_marker_render_deps( + monkeypatch: pytest.MonkeyPatch, world_offsets: np.ndarray | None = None, world_offsets_device: str = "cpu" +): + if world_offsets is None: + world_offsets = np.zeros((4, 3), dtype=np.float32) + warp_world_offsets = wp.array(world_offsets, dtype=wp.vec3, device=world_offsets_device) monkeypatch.setattr(newton_markers, "_create_mesh", lambda cfg: _FakeNewtonMarkerMesh()) monkeypatch.setattr(newton_markers.wp, "array", lambda value, dtype=None: value) - monkeypatch.setattr(newton_markers, "_resolve_newton_marker_cfg", lambda name, marker_cfg, cfg: specs[name]) + return warp_world_offsets + + +def test_newton_marker_partial_update_preserves_prototype_indices(): + marker = _make_newton_marker_for_render( + marker_names=["arrow", "sphere"], + translations=torch.zeros((4, 3), dtype=torch.float32), + marker_indices=torch.tensor([0, 1, 0, 1], dtype=torch.int32), + ) + expected_indices = marker.marker_indices + + marker.visualize( + translations=torch.ones((4, 3), dtype=torch.float32), + orientations=None, + scales=None, + marker_indices=None, + ) + + assert marker.marker_indices is expected_indices def test_newton_marker_render_filters_visible_envs(monkeypatch: pytest.MonkeyPatch): - _patch_newton_marker_render_deps(monkeypatch) + world_offsets = _patch_newton_marker_render_deps(monkeypatch) translations = torch.arange(8, dtype=torch.float32).unsqueeze(1).repeat(1, 3) marker = _make_newton_marker_for_render( marker_names=["arrow"], translations=translations, marker_indices=torch.zeros(8, dtype=torch.int32), ) - viewer = _FakeNewtonMarkerViewer() + viewer = _FakeNewtonMarkerViewer(world_offsets) marker.render(viewer, visible_env_ids=[1, 3], num_envs=4) assert len(viewer.instances) == 1 assert viewer.instances[0]["hidden"] is False - assert viewer.instances[0]["xforms"][:, 0].tolist() == [1.0, 3.0, 5.0, 7.0] + assert viewer.instances[0]["xforms"][:, 0].tolist() == [2.0, 3.0, 6.0, 7.0] + + +@pytest.mark.parametrize( + ("visible_env_ids", "expected"), + [ + ([1, 3], [12.0, 13.0, 36.0, 37.0]), + (None, [0.0, 1.0, 12.0, 13.0, 24.0, 25.0, 36.0, 37.0]), + ], +) +def test_newton_marker_render_applies_world_offsets( + monkeypatch: pytest.MonkeyPatch, visible_env_ids: list[int] | None, expected: list[float] +): + world_offsets_device = "cuda:0" if wp.is_cuda_available() else "cpu" + world_offsets = _patch_newton_marker_render_deps( + monkeypatch, + np.array( + [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0], [30.0, 0.0, 0.0]], + dtype=np.float32, + ), + world_offsets_device=world_offsets_device, + ) + translations = torch.arange(8, dtype=torch.float32).unsqueeze(1).repeat(1, 3) + marker = _make_newton_marker_for_render( + marker_names=["arrow"], + translations=translations, + marker_indices=torch.zeros(8, dtype=torch.int32), + ) + viewer = _FakeNewtonMarkerViewer(world_offsets) + + marker.render(viewer, visible_env_ids=visible_env_ids, num_envs=4) + + assert viewer.instances[0]["xforms"][:, 0].tolist() == expected + + +def test_newton_marker_render_preserves_reordered_env_state(monkeypatch: pytest.MonkeyPatch): + offsets = np.array( + [ + [0.0, 0.0, 0.0], + [100.0, 200.0, 300.0], + [200.0, 400.0, 600.0], + [300.0, 600.0, 900.0], + ], + dtype=np.float32, + ) + world_offsets = _patch_newton_marker_render_deps(monkeypatch, offsets) + translations = torch.arange(24, dtype=torch.float32).reshape(3, 8).T + orientations = torch.arange(32, dtype=torch.float32).reshape(4, 8).T + scales = torch.arange(1, 25, dtype=torch.float32).reshape(3, 8).T + marker = _make_newton_marker_for_render( + marker_names=["arrow"], + translations=translations, + marker_indices=torch.zeros(8, dtype=torch.int32), + ) + marker.orientations = orientations + marker.scales = scales + source_state = ( + marker.translations, + marker.orientations, + marker.scales, + marker.marker_indices, + ) + source_values = tuple(value.clone() for value in source_state) + viewer = _FakeNewtonMarkerViewer(world_offsets) + + selections = ( + ([3, 1], torch.tensor([6, 7, 2, 3])), + ([1, 3], torch.tensor([2, 3, 6, 7])), + ) + + monkeypatch.setattr( + newton_markers.torch, + "arange", + lambda *args, **kwargs: pytest.fail("render should not construct environment index tensors"), + ) + + for visible_env_ids, expected_indices in selections: + selected_offsets = torch.from_numpy(offsets[np.repeat(visible_env_ids, 2)]) + expected_positions = translations[expected_indices] + selected_offsets + expected_xforms = torch.cat((expected_positions, orientations[expected_indices]), dim=1) + expected_scales = scales[expected_indices] + marker.render(viewer, visible_env_ids=visible_env_ids, num_envs=4) + call = viewer.instances[-1] + assert call["hidden"] is False + np.testing.assert_allclose(call["xforms"], expected_xforms.numpy(), rtol=0.0, atol=0.0) + np.testing.assert_allclose(call["scales"], expected_scales.numpy(), rtol=0.0, atol=0.0) + + current_state = ( + marker.translations, + marker.orientations, + marker.scales, + marker.marker_indices, + ) + for current, source, expected in zip(current_state, source_state, source_values): + assert current is source + assert torch.equal(current, expected) + assert len(viewer.instances) == 2 + + +def test_newton_marker_render_hides_empty_env_selection(monkeypatch: pytest.MonkeyPatch): + world_offsets = _patch_newton_marker_render_deps(monkeypatch) + marker = _make_newton_marker_for_render( + marker_names=["arrow"], + translations=torch.zeros((8, 3), dtype=torch.float32), + ) + viewer = _FakeNewtonMarkerViewer(world_offsets) + + marker.render(viewer, visible_env_ids=[], num_envs=4) + + assert len(viewer.instances) == 1 + assert viewer.instances[0]["hidden"] is True + + +def test_newton_marker_render_defaults_to_first_prototype(monkeypatch: pytest.MonkeyPatch): + world_offsets = _patch_newton_marker_render_deps(monkeypatch) + marker = _make_newton_marker_for_render( + marker_names=["arrow", "sphere"], + translations=torch.zeros((4, 3), dtype=torch.float32), + ) + marker.orientations = None + marker.scales = None + viewer = _FakeNewtonMarkerViewer(world_offsets) + + marker.render(viewer, visible_env_ids=None, num_envs=4) + + visible_instances = [call for call in viewer.instances if not call["hidden"]] + assert len(visible_instances) == 1 + assert visible_instances[0]["batch_name"] == "/Visuals/marker::test/arrow" + assert visible_instances[0]["xforms"][:, 3:].tolist() == [[0.0, 0.0, 0.0, 1.0]] * 4 + assert visible_instances[0]["scales"].tolist() == [[1.0, 1.0, 1.0]] * 4 + hidden_batches = [call["batch_name"] for call in viewer.instances if call["hidden"]] + assert hidden_batches == ["/Visuals/marker::test/sphere"] + + +def test_newton_marker_render_keeps_global_batch_unmodified(monkeypatch: pytest.MonkeyPatch): + world_offsets = _patch_newton_marker_render_deps( + monkeypatch, + np.array( + [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0], [30.0, 0.0, 0.0]], + dtype=np.float32, + ), + ) + translations = torch.arange(3, dtype=torch.float32).unsqueeze(1).repeat(1, 3) + marker = _make_newton_marker_for_render( + marker_names=["arrow"], + translations=translations, + marker_indices=torch.zeros(3, dtype=torch.int32), + ) + viewer = _FakeNewtonMarkerViewer(world_offsets) + + marker.render(viewer, visible_env_ids=[1, 3], num_envs=4) + + assert viewer.instances[0]["xforms"][:, 0].tolist() == [0.0, 1.0, 2.0] def test_newton_marker_render_routes_instances_by_prototype(monkeypatch: pytest.MonkeyPatch): - _patch_newton_marker_render_deps(monkeypatch) + world_offsets = _patch_newton_marker_render_deps(monkeypatch) translations = torch.arange(4, dtype=torch.float32).unsqueeze(1).repeat(1, 3) marker = _make_newton_marker_for_render( marker_names=["arrow", "sphere"], translations=translations, marker_indices=torch.tensor([0, 1, 0, 1], dtype=torch.int32), ) - viewer = _FakeNewtonMarkerViewer() + viewer = _FakeNewtonMarkerViewer(world_offsets) marker.render(viewer, visible_env_ids=None, num_envs=4) @@ -613,13 +795,13 @@ def test_newton_marker_render_routes_instances_by_prototype(monkeypatch: pytest. def test_newton_marker_render_hides_unselected_prototypes(monkeypatch: pytest.MonkeyPatch): - _patch_newton_marker_render_deps(monkeypatch) + world_offsets = _patch_newton_marker_render_deps(monkeypatch) marker = _make_newton_marker_for_render( marker_names=["arrow", "sphere", "frame"], translations=torch.zeros((3, 3), dtype=torch.float32), marker_indices=torch.zeros(3, dtype=torch.int32), ) - viewer = _FakeNewtonMarkerViewer() + viewer = _FakeNewtonMarkerViewer(world_offsets) marker.render(viewer, visible_env_ids=None, num_envs=3) diff --git a/source/isaaclab/test/performance/test_kit_startup_performance.py b/source/isaaclab/test/performance/test_kit_startup_performance.py index 7607f30b792..e8d1de4aae4 100644 --- a/source/isaaclab/test/performance/test_kit_startup_performance.py +++ b/source/isaaclab/test/performance/test_kit_startup_performance.py @@ -11,8 +11,12 @@ import os import time +import pytest + from isaaclab.app import AppLauncher +pytestmark = pytest.mark.integration + _LOCAL_STARTUP_TIME_LIMIT = 15.0 _CI_STARTUP_TIME_LIMIT = 20.0 diff --git a/source/isaaclab/test/performance/test_robot_load_performance.py b/source/isaaclab/test/performance/test_robot_load_performance.py index a298c8b746e..ee2ac17158c 100644 --- a/source/isaaclab/test/performance/test_robot_load_performance.py +++ b/source/isaaclab/test/performance/test_robot_load_performance.py @@ -25,6 +25,8 @@ from isaaclab_assets import ANYMAL_D_CFG, CARTPOLE_CFG +pytestmark = pytest.mark.integration + NUM_ENVS = 4096 SPACING = 2.0 diff --git a/source/isaaclab/test/renderers/test_camera_output_contract.py b/source/isaaclab/test/renderers/test_camera_output_contract.py index 776f40288c4..0f88dd3e289 100644 --- a/source/isaaclab/test/renderers/test_camera_output_contract.py +++ b/source/isaaclab/test/renderers/test_camera_output_contract.py @@ -17,6 +17,8 @@ from isaaclab.sensors.camera.camera_data import CameraData, RenderBufferKind, RenderBufferSpec from isaaclab.sim import PinholeCameraCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + _SPAWN = PinholeCameraCfg( focal_length=24.0, focus_distance=400.0, diff --git a/source/isaaclab/test/renderers/test_renderer_factory.py b/source/isaaclab/test/renderers/test_renderer_factory.py index 66bd76fba59..91095d61c26 100644 --- a/source/isaaclab/test/renderers/test_renderer_factory.py +++ b/source/isaaclab/test/renderers/test_renderer_factory.py @@ -20,6 +20,8 @@ from isaaclab_ov.renderers import OVRTXRendererCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + def _make_mock_renderer_class(name: str): """Create a minimal concrete BaseRenderer subclass for testing.""" diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index 4b32544eab9..147398580f0 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -26,6 +26,8 @@ from isaaclab_newton.renderers import NewtonWarpRendererCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + class _FakeBackend(BaseRenderer): """Test double for :class:`BaseRenderer`; does not load PhysX/Newton/OV renderer classes.""" diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index a2ddc05f5ea..41aaaf7f9c3 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -27,6 +27,8 @@ from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @configclass class MySceneCfg(InteractiveSceneCfg): diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index 9b41899765b..a191142914c 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -30,7 +30,7 @@ import isaaclab.sim as sim_utils from isaaclab.sensors.camera import Camera, CameraCfg -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci] # sample camera poses POSITION = (2.5, 2.5, 2.5) diff --git a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py index 6f1e681efec..98339c94136 100644 --- a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py +++ b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py @@ -56,6 +56,8 @@ from isaaclab.sim import SimulationCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + def _collect_renderer_cfg_params() -> list: """Return pytest.param entries for installed RTX-backed renderer packages. diff --git a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py index 26aa6787e45..7df34aa9bb8 100644 --- a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py +++ b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py @@ -34,6 +34,8 @@ import pytest +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + _REQUIRED_MODULES = ("isaaclab_newton", "newton") _MISSING_MODULES = [module for module in _REQUIRED_MODULES if importlib.util.find_spec(module) is None] _SKIP_MISSING_NEWTON = pytest.mark.skipif( diff --git a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py index 82fd1f82953..b0d7c9b404a 100644 --- a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py +++ b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py @@ -63,6 +63,8 @@ from isaaclab.sim import SimulationCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # OVRTX renderer + Newton physics are required (kit-less + non-PhysX). Use a # collection-time skip marker instead of module-level ``importorskip`` so CI's # per-file runner does not see pytest's "no tests collected" exit code. diff --git a/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py b/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py index 1c2ef0ec7b0..8ae3da57599 100644 --- a/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py +++ b/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py @@ -21,6 +21,8 @@ from isaaclab.sensors.camera import Camera, CameraCfg from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # resolution HEIGHT = 256 WIDTH = 256 diff --git a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py index dcd58fa5acd..85c9438f458 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py @@ -15,6 +15,8 @@ from isaaclab.utils.math import matrix_from_quat, quat_from_euler_xyz, random_orientation from isaaclab.utils.warp.ops import convert_to_warp_mesh, raycast_dynamic_meshes, raycast_single_mesh +pytestmark = pytest.mark.integration + @pytest.fixture(scope="module") def device(): diff --git a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py index 360eb8f4d9e..1bd18904310 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py @@ -37,6 +37,8 @@ from isaaclab_assets.robots.anymal import ANYMAL_C_CFG from isaaclab_assets.robots.spot import SPOT_CFG +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # sample camera poses (quaternions in xyzw format) POSITION = [2.5, 2.5, 2.5] QUAT_ROS = [0.33985114, 0.82047325, -0.42470819, -0.17591989] diff --git a/source/isaaclab/test/sensors/test_multi_tiled_camera.py b/source/isaaclab/test/sensors/test_multi_tiled_camera.py index 5188f5a897e..fe8f091b225 100644 --- a/source/isaaclab/test/sensors/test_multi_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_multi_tiled_camera.py @@ -32,7 +32,7 @@ # Deprecation warnings from TiledCamera/TiledCameraCfg are expected in this file; # the deprecation mechanism itself is validated in test_tiled_camera.py. -pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") +pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.filterwarnings("ignore::DeprecationWarning")] @pytest.fixture() diff --git a/source/isaaclab/test/sensors/test_outdated_sensor.py b/source/isaaclab/test/sensors/test_outdated_sensor.py index 4c4f4d337b6..d58b1ccfed6 100644 --- a/source/isaaclab/test/sensors/test_outdated_sensor.py +++ b/source/isaaclab/test/sensors/test_outdated_sensor.py @@ -25,6 +25,8 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg +pytestmark = pytest.mark.integration + @pytest.fixture() def temp_dir(): diff --git a/source/isaaclab/test/sensors/test_ray_caster.py b/source/isaaclab/test/sensors/test_ray_caster.py index 1749f537ab2..a68a60b25e6 100644 --- a/source/isaaclab/test/sensors/test_ray_caster.py +++ b/source/isaaclab/test/sensors/test_ray_caster.py @@ -23,6 +23,8 @@ from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel as _raycast_mesh_masked_kernel from isaaclab.utils.warp.ops import convert_to_warp_mesh, raycast_dynamic_meshes, raycast_mesh +pytestmark = pytest.mark.integration + @pytest.fixture(scope="module") def raycast_setup(): diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index f255caaf94a..f4ed2b97f80 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -32,6 +32,8 @@ from isaaclab.terrains.trimesh.utils import make_plane from isaaclab.terrains.utils import create_prim_from_mesh +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # sample camera poses POSITION = [2.5, 2.5, 2.5] QUAT_ROS = [0.33985114, 0.82047325, -0.42470819, -0.17591989] diff --git a/source/isaaclab/test/sensors/test_ray_caster_integration.py b/source/isaaclab/test/sensors/test_ray_caster_integration.py index 2043d6e34d2..6f38b95ccfd 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_integration.py +++ b/source/isaaclab/test/sensors/test_ray_caster_integration.py @@ -45,6 +45,8 @@ from isaaclab.terrains.trimesh.utils import make_plane from isaaclab.terrains.utils import create_prim_from_mesh +pytestmark = pytest.mark.integration + _GROUND_PATH = "/World/Ground" _DT = 0.01 diff --git a/source/isaaclab/test/sensors/test_ray_caster_kernels.py b/source/isaaclab/test/sensors/test_ray_caster_kernels.py index b0853c2a3bc..a1d78744e53 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_kernels.py +++ b/source/isaaclab/test/sensors/test_ray_caster_kernels.py @@ -24,6 +24,8 @@ import pytest import warp as wp +pytestmark = pytest.mark.unit + # --------------------------------------------------------------------------- # Import kernel modules directly (avoids Isaac Sim / Omniverse dependencies) # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/sensors/test_ray_caster_patterns.py b/source/isaaclab/test/sensors/test_ray_caster_patterns.py index 8e1b484968d..de5f9ae9aa5 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_patterns.py +++ b/source/isaaclab/test/sensors/test_ray_caster_patterns.py @@ -18,6 +18,8 @@ from isaaclab.sensors.ray_caster.patterns import patterns, patterns_cfg +pytestmark = pytest.mark.integration + @pytest.fixture(scope="module", params=["cuda", "cpu"]) def device(request): diff --git a/source/isaaclab/test/sensors/test_ray_caster_sensor.py b/source/isaaclab/test/sensors/test_ray_caster_sensor.py index a3571fc222d..a82a4175ce6 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_sensor.py +++ b/source/isaaclab/test/sensors/test_ray_caster_sensor.py @@ -23,6 +23,8 @@ from isaaclab.terrains.utils import create_prim_from_mesh from isaaclab.utils.math import quat_from_euler_xyz +pytestmark = pytest.mark.integration + # ------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------- diff --git a/source/isaaclab/test/sensors/test_sensor_base.py b/source/isaaclab/test/sensors/test_sensor_base.py index ddf3d1f9d9f..072d17c37d4 100644 --- a/source/isaaclab/test/sensors/test_sensor_base.py +++ b/source/isaaclab/test/sensors/test_sensor_base.py @@ -27,16 +27,27 @@ from isaaclab.sensors import SensorBase, SensorBaseCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @dataclass class DummyData: count: torch.Tensor = None +@wp.kernel +def increment_count_kernel(env_mask: wp.array(dtype=wp.bool), count: wp.array(dtype=wp.int32)): + """Increment the count for the selected environments.""" + env_id = wp.tid() + if env_mask[env_id]: + count[env_id] += 1 + + class DummySensor(SensorBase): def __init__(self, cfg): super().__init__(cfg) self._data = DummyData() + self.backend_update_count = 0 def _initialize_impl(self): super()._initialize_impl() @@ -50,10 +61,13 @@ def data(self): return self._data def _update_buffers_impl(self, env_mask: wp.array | None = None): - env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) - if len(env_ids) == 0: - return - self._data.count[env_ids] += 1 + self.backend_update_count += 1 + wp.launch( + increment_count_kernel, + dim=self._num_envs, + inputs=[env_mask, wp.from_torch(self._data.count)], + device=self.device, + ) def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None): super().reset(env_ids=env_ids, env_mask=env_mask) @@ -232,6 +246,91 @@ def test_sensor_reset(create_dummy_sensor, device): ) +@pytest.mark.parametrize("device", ("cpu", "cuda")) +def test_repeated_data_reads_update_backend_once(create_dummy_sensor, device): + """Test that repeated data reads update the backend once per sensor update.""" + sensor_cfg, sim, dt = create_dummy_sensor + sensor = DummySensor(cfg=sensor_cfg) + sim.step() + sim.reset() + + sensor.update(dt=dt) + _ = sensor.data + backend_update_count = sensor.backend_update_count + _ = sensor.data + + assert sensor.backend_update_count == backend_update_count + + +@pytest.mark.parametrize("device", ("cpu", "cuda")) +def test_reset_invalidates_cached_sensor_data(create_dummy_sensor, device): + """Test that full and partial resets each invalidate cached sensor data once.""" + sensor_cfg, sim, _ = create_dummy_sensor + sensor = DummySensor(cfg=sensor_cfg) + sim.step() + sim.reset() + _ = sensor.data + + sensor.reset() + backend_update_count = sensor.backend_update_count + _ = sensor.data + _ = sensor.data + assert sensor.backend_update_count == backend_update_count + 1 + + reset_ids = [2, 4] + continued_ids = [0, 1, 3] + sensor.reset(env_ids=reset_ids) + backend_update_count = sensor.backend_update_count + _ = sensor.data + _ = sensor.data + + assert sensor.backend_update_count == backend_update_count + 1 + torch.testing.assert_close( + sensor.data.count[reset_ids], torch.ones(len(reset_ids), dtype=torch.int32, device=device) + ) + torch.testing.assert_close( + sensor.data.count[continued_ids], torch.ones(len(continued_ids), dtype=torch.int32, device=device) + ) + + +@pytest.mark.parametrize("device", ("cpu", "cuda")) +def test_force_recompute_bypasses_sensor_data_cache(create_dummy_sensor, device): + """Test that forced recomputation bypasses a consumed freshness generation.""" + sensor_cfg, sim, _ = create_dummy_sensor + sensor = DummySensor(cfg=sensor_cfg) + sim.step() + sim.reset() + _ = sensor.data + backend_update_count = sensor.backend_update_count + + sensor._update_outdated_buffers(force_recompute=True) + _ = sensor.data + + assert sensor.backend_update_count == backend_update_count + 1 + + +@pytest.mark.parametrize("device", ("cuda",)) +def test_repeated_data_reads_are_graph_safe(create_dummy_sensor, device): + """Test that CUDA graph capture records one backend refresh for repeated reads.""" + sensor_cfg, sim, dt = create_dummy_sensor + sensor = DummySensor(cfg=sensor_cfg) + sim.step() + sim.reset() + + # Warm up the kernels before capture. + sensor.update(dt=dt) + _ = sensor.data + backend_update_count = sensor.backend_update_count + + with wp.ScopedCapture(device=device) as capture: + sensor.update(dt=dt) + _ = sensor.data + _ = sensor.data + + assert sensor.backend_update_count == backend_update_count + 1 + wp.capture_launch(capture.graph) + + @pytest.mark.parametrize("device", ("cpu",)) def test_rigid_body_ancestor_expr_trims_only_terminal_suffix(create_dummy_sensor, device): """Test that ancestor expression trimming keeps repeated path segments above the sensor.""" diff --git a/source/isaaclab/test/sensors/test_tiled_camera.py b/source/isaaclab/test/sensors/test_tiled_camera.py index 47c5aee61d6..a83aca8e63b 100644 --- a/source/isaaclab/test/sensors/test_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_tiled_camera.py @@ -36,6 +36,8 @@ import isaaclab.sim as sim_utils from isaaclab.sensors.camera import Camera, CameraCfg, TiledCamera, TiledCameraCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + @pytest.fixture(scope="function") def setup_camera(device) -> tuple[sim_utils.SimulationContext, CameraCfg, float]: diff --git a/source/isaaclab/test/sensors/test_tiled_camera_env.py b/source/isaaclab/test/sensors/test_tiled_camera_env.py index 68adc23dd85..5b52f925dda 100644 --- a/source/isaaclab/test/sensors/test_tiled_camera_env.py +++ b/source/isaaclab/test/sensors/test_tiled_camera_env.py @@ -39,7 +39,7 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci] @pytest.mark.skip(reason="Currently takes too long to run") diff --git a/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py b/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py index 65402518b7a..1eaa3592c51 100644 --- a/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py +++ b/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py @@ -21,6 +21,8 @@ import torch import warp as wp +pytestmark = pytest.mark.unit + # Import the kernel module directly to avoid pulling in the full isaaclab package # (which requires Isaac Sim / Omniverse dependencies). The kernel file itself only # depends on warp. diff --git a/source/isaaclab/test/sim/frame_view_contract_utils.py b/source/isaaclab/test/sim/frame_view_contract_utils.py index 8cd73c02b6f..7ff53025415 100644 --- a/source/isaaclab/test/sim/frame_view_contract_utils.py +++ b/source/isaaclab/test/sim/frame_view_contract_utils.py @@ -193,7 +193,8 @@ def test_set_world_roundtrip(device, view_factory): try: new_pos = _wp_vec3f([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]], device=device) new_quat = _wp_vec4f([[0.0, 0.0, 0.7071068, 0.7071068], [0.0, 0.0, 0.0, 1.0]], device=device) - bundle.view.set_world_poses(new_pos, new_quat) + with bundle.view.xform_world_space_writer() as w: + w.set_poses(new_pos, new_quat) ret_pos, ret_quat = bundle.view.get_world_poses() torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) @@ -209,7 +210,8 @@ def test_set_local_roundtrip(device, view_factory): try: new_pos = _wp_vec3f([[0.5, 0.3, 0.1], [0.2, 0.7, 0.4]], device=device) new_quat = _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device) - bundle.view.set_local_poses(new_pos, new_quat) + with bundle.view.xform_local_space_writer() as w: + w.set_poses(new_pos, new_quat) ret_pos, ret_quat = bundle.view.get_local_poses() torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) @@ -224,10 +226,11 @@ def test_set_world_does_not_move_parent(device, view_factory): bundle = view_factory(num_envs=2, device=device) try: parent_before = bundle.get_parent_pos(2, device).clone() - bundle.view.set_world_poses( - _wp_vec3f([[99.0, 99.0, 99.0], [88.0, 88.0, 88.0]], device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) + with bundle.view.xform_world_space_writer() as w: + w.set_poses( + _wp_vec3f([[99.0, 99.0, 99.0], [88.0, 88.0, 88.0]], device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) parent_after = bundle.get_parent_pos(2, device) torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) @@ -241,10 +244,11 @@ def test_set_local_does_not_move_parent(device, view_factory): bundle = view_factory(num_envs=2, device=device) try: parent_before = bundle.get_parent_pos(2, device).clone() - bundle.view.set_local_poses( - _wp_vec3f([[0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) + with bundle.view.xform_local_space_writer() as w: + w.set_poses( + _wp_vec3f([[0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) parent_after = bundle.get_parent_pos(2, device) torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) @@ -264,10 +268,11 @@ def test_set_world_updates_local(device, view_factory): desired_offset = torch.tensor([[0.3, 0.7, 0.2], [0.8, 0.1, 0.6]], device=device) new_world = parent_pos + desired_offset - bundle.view.set_world_poses( - _wp_vec3f(new_world.tolist(), device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) + with bundle.view.xform_world_space_writer() as w: + w.set_poses( + _wp_vec3f(new_world.tolist(), device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) local_pos = _t(bundle.view.get_local_poses()[0]) torch.testing.assert_close(local_pos, desired_offset, atol=ATOL, rtol=0) @@ -285,10 +290,11 @@ def test_set_local_updates_world(device, view_factory): try: parent_pos = bundle.get_parent_pos(2, device) new_offset = torch.tensor([[0.4, 0.9, 0.15], [0.6, 0.2, 0.85]], device=device) - bundle.view.set_local_poses( - _wp_vec3f(new_offset.tolist(), device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) + with bundle.view.xform_local_space_writer() as w: + w.set_poses( + _wp_vec3f(new_offset.tolist(), device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) world_pos = _t(bundle.view.get_world_poses()[0]) torch.testing.assert_close(world_pos, parent_pos + new_offset, atol=ATOL, rtol=0) @@ -303,7 +309,8 @@ def test_set_world_partial_position_only(device, view_factory): try: _, orig_quat = bundle.view.get_world_poses() new_pos = _wp_vec3f([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], device=device) - bundle.view.set_world_poses(positions=new_pos) + with bundle.view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos) ret_pos, ret_quat = bundle.view.get_world_poses() torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) @@ -319,7 +326,8 @@ def test_set_world_partial_orientation_only(device, view_factory): try: orig_pos, _ = bundle.view.get_world_poses() new_quat = _wp_vec4f([[0.0, 0.0, 0.7071068, 0.7071068], [0.7071068, 0.0, 0.0, 0.7071068]], device=device) - bundle.view.set_world_poses(orientations=new_quat) + with bundle.view.xform_world_space_writer() as w: + w.set_poses(orientations=new_quat) ret_pos, ret_quat = bundle.view.get_world_poses() torch.testing.assert_close(_t(ret_pos), _t(orig_pos), atol=ATOL, rtol=0) @@ -335,7 +343,8 @@ def test_set_local_partial_position_only(device, view_factory): try: _, orig_quat = bundle.view.get_local_poses() new_pos = _wp_vec3f([[0.2, 0.3, 0.4], [0.5, 0.6, 0.7]], device=device) - bundle.view.set_local_poses(translations=new_pos) + with bundle.view.xform_local_space_writer() as w: + w.set_poses(positions=new_pos) ret_pos, ret_quat = bundle.view.get_local_poses() torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) @@ -352,7 +361,8 @@ def test_set_world_indexed_only_affects_subset(device, view_factory): orig_pos = _t(bundle.view.get_world_poses()[0]).clone() indices = wp.array([1, 3], dtype=wp.int32, device=device) new_pos = _wp_vec3f([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]], device=device) - bundle.view.set_world_poses(positions=new_pos, indices=indices) + with bundle.view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos, indices=indices) updated = _t(bundle.view.get_world_poses()[0]) torch.testing.assert_close(updated[0], orig_pos[0], atol=0, rtol=0) @@ -401,6 +411,24 @@ def test_return_types_are_torcharray(device, view_factory): f"get_local_poses(indices)[1] must be ProxyArray, got {type(lquat_idx).__name__}" ) + world_scales_full = bundle.view.get_world_scales() + assert isinstance(world_scales_full, ProxyArray), ( + f"get_world_scales() must be ProxyArray, got {type(world_scales_full).__name__}" + ) + world_scales_idx = bundle.view.get_world_scales(indices) + assert isinstance(world_scales_idx, ProxyArray), ( + f"get_world_scales(indices) must be ProxyArray, got {type(world_scales_idx).__name__}" + ) + + local_scales_full = bundle.view.get_local_scales() + assert isinstance(local_scales_full, ProxyArray), ( + f"get_local_scales() must be ProxyArray, got {type(local_scales_full).__name__}" + ) + local_scales_idx = bundle.view.get_local_scales(indices) + assert isinstance(local_scales_idx, ProxyArray), ( + f"get_local_scales(indices) must be ProxyArray, got {type(local_scales_idx).__name__}" + ) + scales_full = bundle.view.get_scales() assert isinstance(scales_full, ProxyArray), f"get_scales() must be ProxyArray, got {type(scales_full).__name__}" scales_idx = bundle.view.get_scales(indices) @@ -409,3 +437,110 @@ def test_return_types_are_torcharray(device, view_factory): ) finally: bundle.teardown() + + +# ================================================================== +# Contract: Scales +# ================================================================== + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_scales_default_identity(device, view_factory): + """Local scales are (1, 1, 1) by default (no authored scale transforms).""" + bundle = view_factory(num_envs=2, device=device) + try: + scales = _t(bundle.view.get_local_scales()) + expected = torch.ones(2, 3, device=device) + torch.testing.assert_close(scales, expected, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_world_scales_default_identity(device, view_factory): + """World scales are (1, 1, 1) by default (no authored scale transforms).""" + bundle = view_factory(num_envs=2, device=device) + try: + scales = _t(bundle.view.get_world_scales()) + expected = torch.ones(2, 3, device=device) + torch.testing.assert_close(scales, expected, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_scales_roundtrip(device, view_factory): + """Writing scales through the local-space writer roundtrips via ``get_local_scales``.""" + bundle = view_factory(num_envs=2, device=device) + try: + new_scales = _wp_vec3f([[2.0, 3.0, 4.0], [0.5, 1.5, 2.5]], device=device) + with bundle.view.xform_local_space_writer() as w: + w.set_scales(new_scales) + + ret_scales = _t(bundle.view.get_local_scales()) + torch.testing.assert_close(ret_scales, _t(new_scales), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_world_scales_roundtrip(device, view_factory): + """Writing scales through the world-space writer roundtrips via ``get_world_scales``.""" + bundle = view_factory(num_envs=2, device=device) + try: + new_scales = _wp_vec3f([[2.0, 3.0, 4.0], [0.5, 1.5, 2.5]], device=device) + with bundle.view.xform_world_space_writer() as w: + w.set_scales(new_scales) + + ret_scales = _t(bundle.view.get_world_scales()) + torch.testing.assert_close(ret_scales, _t(new_scales), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_scales_do_not_affect_local_poses(device, view_factory): + """Changing scales does not change local pose translations/orientations.""" + bundle = view_factory(num_envs=2, device=device) + try: + local_pos_before = _t(bundle.view.get_local_poses()[0]).clone() + local_ori_before = _t(bundle.view.get_local_poses()[1]).clone() + + new_scales = _wp_vec3f([[3.0, 3.0, 3.0], [5.0, 5.0, 5.0]], device=device) + with bundle.view.xform_local_space_writer() as w: + w.set_scales(new_scales) + + local_pos_after = _t(bundle.view.get_local_poses()[0]) + local_ori_after = _t(bundle.view.get_local_poses()[1]) + + torch.testing.assert_close(local_pos_after, local_pos_before, atol=ATOL, rtol=0) + torch.testing.assert_close(local_ori_after, local_ori_before, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_scale_getters_return_proxyarray(device, view_factory): + """Public API contract -- scale getters return ProxyArray.""" + bundle = view_factory(num_envs=2, device=device) + try: + local_scales = bundle.view.get_local_scales() + assert isinstance(local_scales, ProxyArray), ( + f"get_local_scales() must return ProxyArray, got {type(local_scales).__name__}" + ) + world_scales = bundle.view.get_world_scales() + assert isinstance(world_scales, ProxyArray), ( + f"get_world_scales() must return ProxyArray, got {type(world_scales).__name__}" + ) + + indices = wp.array([0], dtype=wp.int32, device=bundle.view.device) + local_indexed = bundle.view.get_local_scales(indices) + assert isinstance(local_indexed, ProxyArray), ( + f"get_local_scales(indices) must return ProxyArray, got {type(local_indexed).__name__}" + ) + world_indexed = bundle.view.get_world_scales(indices) + assert isinstance(world_indexed, ProxyArray), ( + f"get_world_scales(indices) must return ProxyArray, got {type(world_indexed).__name__}" + ) + finally: + bundle.teardown() diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py new file mode 100644 index 00000000000..2319363122e --- /dev/null +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -0,0 +1,724 @@ +# 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 os + +import pytest + +from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_xform(stage, path="/World/Art"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# ArticulationRootFragment marker + metadata +# ------------------------------------------------------------------------------------- + + +def test_articulation_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import ArticulationRootFragment, SchemaFragment + + cfg = PhysxArticulationCfg(articulation_enabled=True) + assert isinstance(cfg, ArticulationRootFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physxArticulation" + assert type(cfg)._usd_applied_schema == "PhysxArticulationAPI" + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.articulation_enabled is True and cfg.enabled_self_collisions is None + + +# ------------------------------------------------------------------------------------- +# PhysxArticulationCfg writes physxArticulation:* namespace +# ------------------------------------------------------------------------------------- + + +def test_physx_articulation_fragment_writes_physx_namespace(): + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/A1") + UsdPhysics.ArticulationRootAPI.Apply(prim) + apply_namespaced( + PhysxArticulationCfg(articulation_enabled=True, enabled_self_collisions=False, sleep_threshold=0.1), + "/World/A1", + stage, + ) + assert prim.GetAttribute("physxArticulation:articulationEnabled").Get() is True + assert prim.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is False + assert abs(prim.GetAttribute("physxArticulation:sleepThreshold").Get() - 0.1) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# NewtonArticulationCfg writes newton:* namespace +# ------------------------------------------------------------------------------------- + + +def test_newton_articulation_fragment_writes_newton_namespace(): + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/A2") + UsdPhysics.ArticulationRootAPI.Apply(prim) + apply_namespaced(NewtonArticulationCfg(self_collision_enabled=True), "/World/A2", stage) + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# apply_articulation_root_properties dispatch (anchor + multi-namespace composition) +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/A3") + apply_articulation_root_properties( + "/World/A3", + [ + PhysxArticulationCfg(enabled_self_collisions=True, solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + ) + prim = stage.GetPrimAtPath("/World/A3") + assert bool(UsdPhysics.ArticulationRootAPI(prim)) # presence-gated anchor applied + assert prim.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is True + assert prim.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# Regression: root on a CHILD prim (USD assets) must be tuned in place, not duplicated +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_tunes_existing_child_root(): + """When the articulation root lives on a child prim (as in USD assets), the writer must tune + that existing root rather than stamp a second ``ArticulationRootAPI`` on the input (top) prim + -- a duplicate root mis-writes the properties and violates the 'exactly one root' invariant. + """ + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + top = _make_xform(stage, "/World/Asset") + child = _make_xform(stage, "/World/Asset/base") + UsdPhysics.ArticulationRootAPI.Apply(child) # asset already carries its root on a child prim + + apply_articulation_root_properties( + "/World/Asset", + [PhysxArticulationCfg(solver_position_iteration_count=8)], + stage, + ) + + # the existing child root is tuned ... + assert child.HasAPI(UsdPhysics.ArticulationRootAPI) + assert child.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + # ... and NO duplicate root / stray write is added on the top prim + assert not top.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not top.GetAttribute("physxArticulation:solverPositionIterationCount").HasAuthoredValue() + # exactly one ArticulationRootAPI exists in the subtree + roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert len(roots) == 1 and roots[0] == child + + +def test_apply_articulation_root_properties_processes_siblings_and_aggregates_results(): + """Every non-nested sibling root is visited and a failing applier makes the family result False.""" + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + for path in ("/World/A", "/World/B", "/World/A/Nested"): + root = _make_xform(stage, path) + UsdPhysics.ArticulationRootAPI.Apply(root) + + visited = [] + + def record_result(_cfg, path, _stage): + visited.append(path) + return path != "/World/B" + + result = apply_articulation_root_properties("/World", [PhysxArticulationCfg(func=record_result)], stage) + + assert visited == ["/World/A", "/World/B"] + assert result is False + + +def test_apply_articulation_root_properties_does_not_duplicate_instance_proxy_root(caplog): + """A root hidden in an instance suppresses define-fresh but is skipped because proxies are read-only.""" + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + source = _make_xform(stage, "/World/Source") + source_base = _make_xform(stage, "/World/Source/base") + UsdPhysics.ArticulationRootAPI.Apply(source_base) + instance = _make_xform(stage, "/World/Asset") + instance.GetReferences().AddInternalReference(source.GetPath()) + instance.SetInstanceable(True) + proxy_root = stage.GetPrimAtPath("/World/Asset/base") + assert proxy_root.IsInstanceProxy() and proxy_root.HasAPI(UsdPhysics.ArticulationRootAPI) + + with caplog.at_level("WARNING"): + result = apply_articulation_root_properties( + "/World/Asset", [PhysxArticulationCfg(solver_position_iteration_count=4)], stage + ) + + assert result is False + assert not instance.HasAPI(UsdPhysics.ArticulationRootAPI) + assert proxy_root.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not proxy_root.GetAttribute("physxArticulation:solverPositionIterationCount").HasAuthoredValue() + assert "/World/Asset/base" in caplog.text + + +# ------------------------------------------------------------------------------------- +# fix_root_link spawner-level flag: toggles an existing fixed joint +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_toggles_existing_fixed_joint(): + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # root prim with a rigid body and an articulation root + root = _make_xform(stage, "/World/A4") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + # author an existing global fixed joint between the world and the root link + joint = UsdPhysics.FixedJoint.Define(stage, "/World/A4/FixedJoint") + joint.CreateBody1Rel().SetTargets(["/World/A4"]) + joint.CreateJointEnabledAttr(True) + + apply_articulation_root_properties( + "/World/A4", + [PhysxArticulationCfg(articulation_enabled=True)], + stage, + fix_root_link=False, + ) + assert joint.GetJointEnabledAttr().Get() is False + + +def test_apply_articulation_root_properties_enables_existing_joint_and_relocates_root(): + """An existing joint is enabled, but PhysX root placement is still normalized through the manager.""" + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + parent = _make_xform(stage, "/World/ExistingJointRobot") + root = _make_xform(stage, "/World/ExistingJointRobot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + joint = UsdPhysics.FixedJoint.Define(stage, "/World/ExistingJointRobot/base/FixedJoint") + joint.CreateBody1Rel().SetTargets([root.GetPath()]) + joint.CreateJointEnabledAttr(False) + + apply_articulation_root_properties("/World/ExistingJointRobot", [], stage, fix_root_link=True) + + joints = [prim for prim in stage.Traverse() if prim.IsA(UsdPhysics.FixedJoint)] + assert joints == [joint.GetPrim()] + assert joint.GetJointEnabledAttr().Get() is True + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + +# ------------------------------------------------------------------------------------- +# fix_root_link spawner-level flag: creates a fixed joint and reparents the root +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_creates_fixed_joint_and_reparents_root(): + """fix_root_link=True with no existing fixed joint: a fixed joint is created and the + articulation root is moved from the rigid-body root link to its parent (PhysX parser + limitation -- a fixed joint on a rigid body is otherwise treated as a maximal-coordinate tree). + """ + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # parent xform + a rigid-body root link carrying the articulation root + _make_xform(stage, "/World/Robot") + root = _make_xform(stage, "/World/Robot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + # no existing global fixed joint -> the writer must create one + assert not any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + + apply_articulation_root_properties( + "/World/Robot", + [PhysxArticulationCfg(articulation_enabled=True)], + stage, + fix_root_link=True, + ) + + parent = stage.GetPrimAtPath("/World/Robot") + # a fixed joint was created ... + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + # ... and the articulation root was moved from the root link to its parent + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): + """fix_root_link=True on a non-rigid-body root raises NotImplementedError: the writer cannot + determine the first rigid body link to anchor the fixed joint to.""" + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/Robot2") + UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body + with pytest.raises(NotImplementedError): + apply_articulation_root_properties( + "/World/Robot2", + [PhysxArticulationCfg(articulation_enabled=True)], + stage, + fix_root_link=True, + ) + + +def test_apply_articulation_root_properties_fix_root_link_without_active_simulation_raises(monkeypatch): + """Creating a fixed root joint with no active ``SimulationContext`` raises a clear error: the + backend that authors (and possibly relocates) the root is resolved from the live simulation, so + without one core cannot fix the base on its own. + """ + from isaaclab.sim import SimulationContext as _SimCtx + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/Robot3") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + # simulate "no active simulation" so the backend cannot be resolved + monkeypatch.setattr(_SimCtx, "instance", classmethod(lambda cls: None)) + with pytest.raises(RuntimeError): + apply_articulation_root_properties("/World/Robot3", [], stage, fix_root_link=True) + + +def test_physx_and_newton_fragments_fix_root_link_keeps_single_root(): + """Composing a PhysX and a Newton fragment with ``fix_root_link=True`` must leave exactly one + articulation root, with *every* backend's schema on that resulting root. + + Regression: PhysX relocates the root to the parent when fixing the base. Writing fragments after + the relocation lands both the PhysX and Newton schemas on the parent; the former child (root link) + must not retain ``NewtonArticulationRootAPI`` / ``newton:*`` (which would leave a stray second root + because that API composes ``PhysicsArticulationRootAPI``). + """ + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Robot5") + child = _make_xform(stage, "/World/Robot5/base") + UsdPhysics.RigidBodyAPI.Apply(child) + UsdPhysics.ArticulationRootAPI.Apply(child) + + apply_articulation_root_properties( + "/World/Robot5", + [ + PhysxArticulationCfg(solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + fix_root_link=True, + ) + + parent = stage.GetPrimAtPath("/World/Robot5") + # exactly one articulation root remains, and it is the (relocated) parent + roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert len(roots) == 1 and roots[0] == parent + # every backend's schema is authored on that single resulting root ... + assert parent.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + assert parent.GetAttribute("newton:selfCollisionEnabled").Get() is True + # ... and nothing is stranded on the former child root link + assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not child.GetAttribute("newton:selfCollisionEnabled").HasAuthoredValue() + + +def test_physx_fix_root_link_migrates_preauthored_newton_root_api(): + """A pre-authored backend root API (as on URDF/MJCF-imported assets) moves with the root when + PhysX relocates it, together with its authored attributes. + + Regression: ``NewtonArticulationRootAPI`` composes ``PhysicsArticulationRootAPI``, so removing + only the directly applied anchor from the former root link leaves that prim an articulation root + through schema composition -- two roots -- with the ``newton:*`` values stranded on the wrong prim. + """ + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/UrdfBot") + child = _make_xform(stage, "/World/UrdfBot/base") + UsdPhysics.RigidBodyAPI.Apply(child) + # the asset ships with the Newton root API (and its attribute) already authored on the root link + child.AddAppliedSchema("NewtonArticulationRootAPI") + child.CreateAttribute("newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool).Set(True) + # precondition: the composed schema makes the prim an articulation root without a direct anchor + assert child.HasAPI(UsdPhysics.ArticulationRootAPI) + + apply_articulation_root_properties("/World/UrdfBot", [], stage, fix_root_link=True) + + parent = stage.GetPrimAtPath("/World/UrdfBot") + # exactly one articulation root remains, and it is the (relocated) parent + roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert len(roots) == 1 and roots[0] == parent + # the pre-authored backend schema and its value moved with the root + assert "NewtonArticulationRootAPI" in parent.GetAppliedSchemas() + assert parent.GetAttribute("newton:selfCollisionEnabled").Get() is True + # the former root link no longer carries the composed root API + assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_physx_fix_root_link_preserves_complete_authored_property_spec(): + """Relocation preserves sampled, metadata, and connection opinions rather than one default value.""" + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/UsdBot") + child = _make_xform(stage, "/World/UsdBot/base") + UsdPhysics.RigidBodyAPI.Apply(child) + UsdPhysics.MassAPI.Apply(child) + UsdPhysics.ArticulationRootAPI.Apply(child) + child.AddAppliedSchema("PhysxArticulationAPI") + driver = _make_xform(stage, "/World/Driver") + driver_attr = driver.CreateAttribute("output", Sdf.ValueTypeNames.Float) + driver_attr.Set(0.5) + source_attr = child.GetAttribute("physxArticulation:sleepThreshold") + source_attr.Set(0.1, Usd.TimeCode(1.0)) + source_attr.Set(0.2, Usd.TimeCode(2.0)) + source_attr.SetMetadata("documentation", "sampled sleep threshold") + source_attr.AddConnection(driver_attr.GetPath()) + + apply_articulation_root_properties("/World/UsdBot", [], stage, fix_root_link=True) + + parent = stage.GetPrimAtPath("/World/UsdBot") + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert "PhysxArticulationAPI" in parent.GetPrimTypeInfo().GetAppliedAPISchemas() + assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) + assert "PhysxArticulationAPI" not in child.GetPrimTypeInfo().GetAppliedAPISchemas() + moved_attr = parent.GetAttribute("physxArticulation:sleepThreshold") + assert not stage.GetRootLayer().GetAttributeAtPath(moved_attr.GetPath()).HasInfo("default") + assert moved_attr.GetTimeSamples() == [1.0, 2.0] + assert moved_attr.Get(Usd.TimeCode(1.0)) == pytest.approx(0.1) + assert moved_attr.Get(Usd.TimeCode(2.0)) == pytest.approx(0.2) + assert moved_attr.GetMetadata("documentation") == "sampled sleep threshold" + assert moved_attr.GetConnections() == [driver_attr.GetPath()] + assert child.HasAPI(UsdPhysics.RigidBodyAPI) and child.HasAPI(UsdPhysics.MassAPI) + assert not parent.HasAPI(UsdPhysics.RigidBodyAPI) and not parent.HasAPI(UsdPhysics.MassAPI) + + +def test_apply_articulation_root_properties_rejects_non_fragment_items(): + """A list containing a non-fragment (e.g. a legacy single cfg) raises a clear ``TypeError`` -- + not an ``AttributeError`` deep inside fragment dispatch.""" + from isaaclab_physx.sim.schemas import PhysxArticulationRootPropertiesCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/BadList") + with pytest.raises(TypeError, match="ArticulationRootFragment"): + apply_articulation_root_properties( + "/World/BadList", + [PhysxArticulationRootPropertiesCfg(solver_position_iteration_count=8)], + stage, + ) + + +def test_apply_articulation_root_properties_topology_only_does_not_stamp_root(): + """A topology-only call (empty fragments) on a prim with no articulation root anywhere must not + apply ``UsdPhysics.ArticulationRootAPI`` as a side effect -- the anchor is presence-gated on + fragments. The topology flag is ignored with a warning instead.""" + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/NoRoot") + UsdPhysics.RigidBodyAPI.Apply(prim) + + apply_articulation_root_properties("/World/NoRoot", [], stage, fix_root_link=False) + + assert not prim.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not any(p.HasAPI(UsdPhysics.ArticulationRootAPI) for p in stage.Traverse()) + + +def test_apply_articulation_root_properties_honors_explicit_stage(): + """Fragment writes and fixed-joint lookup both stay on the explicitly supplied stage.""" + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + current_stage = sim_utils.get_current_stage() + current_root = _make_xform(current_stage, "/World/AltStageRoot") + UsdPhysics.ArticulationRootAPI.Apply(current_root) + current_joint = UsdPhysics.FixedJoint.Define(current_stage, "/World/AltStageRoot/FixedJoint") + current_joint.CreateBody1Rel().SetTargets([current_root.GetPath()]) + current_joint.CreateJointEnabledAttr(True) + + other_stage = Usd.Stage.CreateInMemory() + UsdGeom.Xform.Define(other_stage, "/World") + other_root = UsdGeom.Xform.Define(other_stage, "/World/AltStageRoot").GetPrim() + UsdPhysics.ArticulationRootAPI.Apply(other_root) + other_joint = UsdPhysics.FixedJoint.Define(other_stage, "/World/AltStageRoot/FixedJoint") + other_joint.CreateBody1Rel().SetTargets([other_root.GetPath()]) + other_joint.CreateJointEnabledAttr(True) + + apply_articulation_root_properties( + "/World/AltStageRoot", + [PhysxArticulationCfg(solver_position_iteration_count=4)], + other_stage, + fix_root_link=False, + ) + + assert other_root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 4 + assert other_joint.GetJointEnabledAttr().Get() is False + assert not current_root.GetAttribute("physxArticulation:solverPositionIterationCount").HasAuthoredValue() + assert current_joint.GetJointEnabledAttr().Get() is True + + +# ------------------------------------------------------------------------------------- +# fix_articulation_root is a PhysicsManager capability (resolved by cfg.physics.class_type) +# ------------------------------------------------------------------------------------- + + +def test_base_manager_fix_articulation_root_is_world_anchored_and_idempotent(): + """The neutral capability pins the current pose and enables rather than duplicates an existing joint.""" + from isaaclab.physics import PhysicsManager + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/BaseRobot") + root = _make_xform(stage, "/World/BaseRobot/base") + UsdGeom.Xform(root).AddTranslateOp().Set(Gf.Vec3d(1.0, 2.0, 3.0)) + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + + result = PhysicsManager.fix_articulation_root(root, stage) + joints = [prim for prim in stage.Traverse() if prim.IsA(UsdPhysics.FixedJoint)] + assert result == root and len(joints) == 1 + joint = UsdPhysics.FixedJoint(joints[0]) + assert list(joint.GetBody1Rel().GetTargets()) == [root.GetPath()] + assert joint.GetPrim().GetAttribute("physics:localPos0").Get() == Gf.Vec3f(1.0, 2.0, 3.0) + assert not stage.GetPrimAtPath("/World/BaseRobot").HasAPI(UsdPhysics.ArticulationRootAPI) + + joint.CreateJointEnabledAttr(False) + assert PhysicsManager.fix_articulation_root(root, stage) == root + assert joint.GetJointEnabledAttr().Get() is True + assert len([prim for prim in stage.Traverse() if prim.IsA(UsdPhysics.FixedJoint)]) == 1 + + +def test_base_manager_fix_articulation_root_requires_rigid_body(): + """The capability raises NotImplementedError on a non-rigid-body root (cannot anchor the joint).""" + from isaaclab.physics import PhysicsManager + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/BaseNoRB") + UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body + with pytest.raises(NotImplementedError): + PhysicsManager.fix_articulation_root(root, stage) + + +def test_articulation_fragment_and_legacy_cfg_match_physx_schema(): + """Both interfaces cover exactly all six attributes registered by PhysxArticulationAPI.""" + import dataclasses + + from isaaclab_physx.sim.schemas import PhysxArticulationCfg, PhysxArticulationRootPropertiesCfg + + from pxr import PhysxSchema + + from isaaclab.utils.string import to_camel_case + + def fields(cls): + return {field.name for field in dataclasses.fields(cls) if field.name != "func"} + + schema_attrs = {name.split(":", 1)[1] for name in PhysxSchema.PhysxArticulationAPI.GetSchemaAttributeNames()} + fragment_attrs = {to_camel_case(name, "cC") for name in fields(PhysxArticulationCfg)} + legacy_attrs = { + to_camel_case(name, "cC") for name in fields(PhysxArticulationRootPropertiesCfg) - {"fix_root_link"} + } + assert fragment_attrs == schema_attrs + assert legacy_attrs == schema_attrs + + +def test_newton_legacy_cfg_matches_equivalent_fragment_composition(): + """A Newton legacy subclass still routes its inherited articulation_enabled base field.""" + from isaaclab_newton.sim.schemas import NewtonArticulationCfg, NewtonArticulationRootPropertiesCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties, modify_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + legacy = _make_xform(stage, "/World/LegacyNewton") + fragments = _make_xform(stage, "/World/FragmentNewton") + UsdPhysics.ArticulationRootAPI.Apply(legacy) + UsdPhysics.ArticulationRootAPI.Apply(fragments) + + modify_articulation_root_properties( + legacy.GetPath(), + NewtonArticulationRootPropertiesCfg(articulation_enabled=False, self_collision_enabled=True), + stage, + ) + apply_articulation_root_properties( + fragments.GetPath(), + [ + PhysxArticulationCfg(articulation_enabled=False), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + ) + + for prim in (legacy, fragments): + assert prim.GetAttribute("physxArticulation:articulationEnabled").Get() is False + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# end-to-end: the from-files transition bridge routes articulation_props by type +# ------------------------------------------------------------------------------------- + + +def _author_articulation_usd(path: str) -> None: + """Author a minimal USD asset: a parent Xform with a rigid-body child carrying the articulation root.""" + asset_stage = Usd.Stage.CreateNew(path) + robot = UsdGeom.Xform.Define(asset_stage, "/Robot") + base = UsdGeom.Xform.Define(asset_stage, "/Robot/base").GetPrim() + UsdPhysics.RigidBodyAPI.Apply(base) + UsdPhysics.ArticulationRootAPI.Apply(base) + asset_stage.SetDefaultPrim(robot.GetPrim()) + asset_stage.Save() + + +@pytest.mark.parametrize("articulation_props", [None, []], ids=["none", "empty"]) +def test_spawn_from_usd_file_topology_only_honors_fix_root_link(tmp_path, articulation_props): + """None and an empty fragment collection both honor the independent topology flag.""" + from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file + from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + usd_path = os.path.join(tmp_path, "articulation.usda") + _author_articulation_usd(usd_path) + + cfg = UsdFileCfg(usd_path=usd_path, articulation_props=articulation_props, fix_root_link=True) + _spawn_from_usd_file("/World/FromUsdTopology", usd_path, cfg) + + stage = sim_utils.get_current_stage() + assert any(prim.IsA(UsdPhysics.FixedJoint) for prim in stage.Traverse()) + assert stage.GetPrimAtPath("/World/FromUsdTopology").HasAPI(UsdPhysics.ArticulationRootAPI) + assert not stage.GetPrimAtPath("/World/FromUsdTopology/base").HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_spawn_from_usd_file_applies_composed_fragment_list(tmp_path): + """The real FileCfg transition bridge composes backend fragments on the relocated root.""" + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file + from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + usd_path = os.path.join(tmp_path, "fragment_articulation.usda") + _author_articulation_usd(usd_path) + cfg = UsdFileCfg( + usd_path=usd_path, + articulation_props=[ + PhysxArticulationCfg(solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + fix_root_link=True, + ) + + _spawn_from_usd_file("/World/FromUsdFragments", usd_path, cfg) + + stage = sim_utils.get_current_stage() + root = stage.GetPrimAtPath("/World/FromUsdFragments") + roots = [prim for prim in stage.Traverse() if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert roots == [root] + assert root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + assert root.GetAttribute("newton:selfCollisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import NewtonArticulationCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxArticulationCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + ArticulationRootFragment, + SchemaFragment, + apply_articulation_root_properties, + ) diff --git a/source/isaaclab/test/sim/test_build_simulation_context_headless.py b/source/isaaclab/test/sim/test_build_simulation_context_headless.py index 4ceae87b987..cf266f73f4f 100644 --- a/source/isaaclab/test/sim/test_build_simulation_context_headless.py +++ b/source/isaaclab/test/sim/test_build_simulation_context_headless.py @@ -27,6 +27,8 @@ from isaaclab.sim.simulation_cfg import SimulationCfg from isaaclab.sim.simulation_context import build_simulation_context +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("gravity_enabled", [True, False]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) diff --git a/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py b/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py index 09bfe309183..2ce2345062c 100644 --- a/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py +++ b/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py @@ -26,6 +26,8 @@ from isaaclab.sim.simulation_cfg import SimulationCfg from isaaclab.sim.simulation_context import build_simulation_context +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("gravity_enabled", [True, False]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index f226cf8aab3..a31138d072c 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -39,7 +39,7 @@ ) from isaaclab.sim import build_simulation_context -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture(params=["cpu", "cuda"]) diff --git a/source/isaaclab/test/sim/test_collision_fragments.py b/source/isaaclab/test/sim/test_collision_fragments.py new file mode 100644 index 00000000000..539701124ab --- /dev/null +++ b/source/isaaclab/test/sim/test_collision_fragments.py @@ -0,0 +1,174 @@ +# 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 UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + +pytestmark = pytest.mark.integration + + +def _make_xform(stage, path="/World/Body"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# CollisionFragment marker + UsdPhysicsCollisionCfg +# ------------------------------------------------------------------------------------- + + +def test_collision_fragment_metadata_defaults(): + from isaaclab.sim.schemas import CollisionFragment, SchemaFragment, UsdPhysicsCollisionCfg + + cfg = UsdPhysicsCollisionCfg(collision_enabled=True) + assert isinstance(cfg, CollisionFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # anchor applies CollisionAPI, not the fragment + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.collision_enabled is True + + +# ------------------------------------------------------------------------------------- +# UsdPhysicsCollisionCfg writes its physics namespace via apply_namespaced +# ------------------------------------------------------------------------------------- + + +def test_usd_physics_collision_fragment_writes_physics_namespace(): + from isaaclab.sim.schemas import UsdPhysicsCollisionCfg, apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage) + UsdPhysics.CollisionAPI.Apply(prim) + apply_namespaced(UsdPhysicsCollisionCfg(collision_enabled=True), "/World/Body", stage) + assert prim.GetAttribute("physics:collisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# PhysxCollisionCfg (isaaclab_physx) +# ------------------------------------------------------------------------------------- + + +def test_physx_collision_fragment_writes_physx_namespace(): + from isaaclab_physx.sim.schemas import PhysxCollisionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/C2") + UsdPhysics.CollisionAPI.Apply(prim) + apply_namespaced( + PhysxCollisionCfg(contact_offset=0.02, rest_offset=0.0, torsional_patch_radius=0.1), "/World/C2", stage + ) + assert abs(prim.GetAttribute("physxCollision:contactOffset").Get() - 0.02) < 1e-6 + assert abs(prim.GetAttribute("physxCollision:restOffset").Get() - 0.0) < 1e-6 + assert abs(prim.GetAttribute("physxCollision:torsionalPatchRadius").Get() - 0.1) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# NewtonCollisionCfg (isaaclab_newton) +# ------------------------------------------------------------------------------------- + + +def test_newton_collision_fragment_writes_newton_namespace(): + from isaaclab_newton.sim.schemas import NewtonCollisionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/C3") + UsdPhysics.CollisionAPI.Apply(prim) + apply_namespaced(NewtonCollisionCfg(contact_margin=0.01, contact_gap=0.005), "/World/C3", stage) + assert abs(prim.GetAttribute("newton:contactMargin").Get() - 0.01) < 1e-6 + assert abs(prim.GetAttribute("newton:contactGap").Get() - 0.005) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# apply_collision_properties dispatch (implicit anchor + multi-namespace) +# ------------------------------------------------------------------------------------- + + +def test_apply_collision_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import NewtonCollisionCfg + from isaaclab_physx.sim.schemas import PhysxCollisionCfg + + from isaaclab.sim.schemas import UsdPhysicsCollisionCfg, apply_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/C4") + apply_collision_properties( + "/World/C4", + [ + UsdPhysicsCollisionCfg(collision_enabled=True), + PhysxCollisionCfg(contact_offset=0.02), + NewtonCollisionCfg(contact_margin=0.01), + ], + stage, + ) + prim = stage.GetPrimAtPath("/World/C4") + assert bool(UsdPhysics.CollisionAPI(prim)) # implicit anchor applied + assert prim.GetAttribute("physics:collisionEnabled").Get() is True + assert abs(prim.GetAttribute("physxCollision:contactOffset").Get() - 0.02) < 1e-6 + assert abs(prim.GetAttribute("newton:contactMargin").Get() - 0.01) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# spawner slot accepts a fragment list + transition routing +# ------------------------------------------------------------------------------------- + + +def test_spawn_shape_with_collision_fragment_list(): + from isaaclab_physx.sim.schemas import PhysxCollisionCfg + + from isaaclab.sim.schemas import UsdPhysicsCollisionCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + collision_props=[UsdPhysicsCollisionCfg(collision_enabled=True), PhysxCollisionCfg(contact_offset=0.03)], + ) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube/geometry/mesh") + assert bool(UsdPhysics.CollisionAPI(prim)) + assert abs(prim.GetAttribute("physxCollision:contactOffset").Get() - 0.03) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import NewtonCollisionCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxCollisionCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + CollisionFragment, + SchemaFragment, + UsdPhysicsCollisionCfg, + apply_collision_properties, + apply_namespaced, + ) diff --git a/source/isaaclab/test/sim/test_deformable_backend_split.py b/source/isaaclab/test/sim/test_deformable_backend_split.py index dc170fe55c5..dfa811e0f96 100644 --- a/source/isaaclab/test/sim/test_deformable_backend_split.py +++ b/source/isaaclab/test/sim/test_deformable_backend_split.py @@ -8,6 +8,7 @@ import dataclasses import isaaclab_physx.sim.schemas as physx_schemas +import pytest from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg from isaaclab_newton.sim.spawners.materials import ( NewtonDeformableBodyMaterialCfg, @@ -34,6 +35,8 @@ SurfaceDeformableBodyMaterialBaseCfg, ) +pytestmark = pytest.mark.integration + def _field_names(cls) -> set[str]: return {field.name for field in dataclasses.fields(cls)} diff --git a/source/isaaclab/test/sim/test_joint_drive_fragments.py b/source/isaaclab/test/sim/test_joint_drive_fragments.py new file mode 100644 index 00000000000..a9c5534ede3 --- /dev/null +++ b/source/isaaclab/test/sim/test_joint_drive_fragments.py @@ -0,0 +1,408 @@ +# 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 math + +import pytest + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + +pytestmark = pytest.mark.integration + + +def _make_revolute_joint(stage, path="/World/Articulation/joint_0"): + UsdGeom.Xform.Define(stage, "/World/Articulation") + UsdGeom.Cube.Define(stage, "/World/Articulation/body0") + UsdGeom.Cube.Define(stage, "/World/Articulation/body1") + UsdPhysics.RevoluteJoint.Define(stage, path) + return stage.GetPrimAtPath(path) + + +def _make_prismatic_joint(stage, path="/World/Articulation/joint_p"): + UsdGeom.Xform.Define(stage, "/World/Articulation") + UsdGeom.Cube.Define(stage, "/World/Articulation/body0") + UsdGeom.Cube.Define(stage, "/World/Articulation/body1") + UsdPhysics.PrismaticJoint.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# Fragment metadata +# ------------------------------------------------------------------------------------- + + +def test_drive_fragment_metadata_defaults(): + from isaaclab.sim.schemas import JointDriveFragment, SchemaFragment, UsdPhysicsDriveCfg + + cfg = UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1) + assert isinstance(cfg, JointDriveFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace is None # typed multi-instance DriveAPI, no namespace writes + assert type(cfg)._usd_applied_schema is None # DriveAPI applied by apply_drive (presence-gated) + assert cfg.func == "isaaclab.sim.schemas:apply_drive" + assert cfg.stiffness == 10.0 and cfg.damping == 0.1 + + +def test_drive_fragment_max_effort_alias(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg + + with pytest.warns(DeprecationWarning, match="max_effort"): + cfg = UsdPhysicsDriveCfg(max_effort=42.0) + assert cfg.max_force == 42.0 + assert cfg.max_effort is None + + +# ------------------------------------------------------------------------------------- +# apply_drive -- revolute (angular) rad->deg conversion +# ------------------------------------------------------------------------------------- + + +def test_apply_drive_revolute_converts_rad_to_deg(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + assert apply_drive( + UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), + prim.GetPath().pathString, + stage, + ) + assert bool(UsdPhysics.DriveAPI(prim, "angular")) + assert prim.GetAttribute("drive:angular:physics:type").Get() == "acceleration" + assert prim.GetAttribute("drive:angular:physics:maxForce").Get() == pytest.approx(80.0, rel=1e-6) + # angular stiffness/damping are converted from radian to degree units + assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(10.0 * math.pi / 180.0, rel=1e-6) + assert prim.GetAttribute("drive:angular:physics:damping").Get() == pytest.approx(0.1 * math.pi / 180.0, rel=1e-6) + + +# ------------------------------------------------------------------------------------- +# apply_drive -- prismatic (linear) no conversion +# ------------------------------------------------------------------------------------- + + +def test_apply_drive_prismatic_writes_linear_unchanged(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_prismatic_joint(stage) + assert apply_drive( + UsdPhysicsDriveCfg(drive_type="force", max_force=42.0, stiffness=10.0, damping=0.1), + prim.GetPath().pathString, + stage, + ) + assert bool(UsdPhysics.DriveAPI(prim, "linear")) + assert prim.GetAttribute("drive:linear:physics:type").Get() == "force" + assert prim.GetAttribute("drive:linear:physics:maxForce").Get() == pytest.approx(42.0, rel=1e-6) + # linear drives are written as authored (no rad->deg conversion) + assert prim.GetAttribute("drive:linear:physics:stiffness").Get() == pytest.approx(10.0, rel=1e-6) + assert prim.GetAttribute("drive:linear:physics:damping").Get() == pytest.approx(0.1, rel=1e-6) + + +def test_apply_drive_returns_false_on_non_joint(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/NotAJoint") + assert apply_drive(UsdPhysicsDriveCfg(stiffness=1.0), "/World/NotAJoint", stage) is False + + +# ------------------------------------------------------------------------------------- +# PhysxJointCfg (isaaclab_physx) -- physxJoint namespace via apply_namespaced +# ------------------------------------------------------------------------------------- + + +def test_physx_joint_fragment_converts_max_velocity_by_joint_type(): + from isaaclab_physx.sim.schemas import PhysxJointCfg, apply_physx_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # angular (revolute) joint: rad/s -> deg/s conversion + rev = _make_revolute_joint(stage) + apply_physx_joint(PhysxJointCfg(max_joint_velocity=10.0), rev.GetPath().pathString, stage) + assert rev.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(10.0 * 180.0 / math.pi, rel=1e-6) + # linear (prismatic) joint: written unchanged + prismatic = _make_prismatic_joint(stage) + apply_physx_joint(PhysxJointCfg(max_joint_velocity=10.0), prismatic.GetPath().pathString, stage) + assert prismatic.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(10.0, rel=1e-6) + + +def test_physx_joint_fragment_max_velocity_alias(): + from isaaclab_physx.sim.schemas import PhysxJointCfg + + with pytest.warns(DeprecationWarning, match="max_velocity"): + cfg = PhysxJointCfg(max_velocity=10.0) + assert cfg.max_joint_velocity == 10.0 + assert cfg.max_velocity is None + + +# ------------------------------------------------------------------------------------- +# MujocoJointCfg (isaaclab_newton) -- mjc namespace via apply_namespaced +# ------------------------------------------------------------------------------------- + + +def test_mujoco_joint_fragment_writes_mjc_namespace(): + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_namespaced(MujocoJointCfg(actuatorgravcomp=True), prim.GetPath().pathString, stage) + assert prim.GetAttribute("mjc:actuatorgravcomp").Get() is True + + +def test_mujoco_joint_applier_does_not_write_actuatorgravcomp_when_none(): + # fragment-path equivalent of the legacy test_mujoco_actuatorgravcomp_not_written_when_none: + # an unset actuatorgravcomp must not *author* mjc:actuatorgravcomp through the joint applier. + # (Unlike the legacy path, the fragment applies its MjcJointAPI schema, so the attribute exists + # and resolves to the schema default ``False`` -- but it must not be authored to ``True``.) + from isaaclab_newton.sim.schemas import MujocoJointCfg, apply_mujoco_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_mujoco_joint(MujocoJointCfg(), prim.GetPath().pathString, stage) + assert not prim.GetAttribute("mjc:actuatorgravcomp").HasAuthoredValue() + + +def test_mujoco_joint_actuatorgravcomp_enables_child_body_gravcomp(): + # actuatorgravcomp is inert unless the actuated body has non-zero mjc:gravcomp, so the Mujoco + # joint applier flips gravcomp on the joint's child body (physics:body1) when it is unset + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + UsdPhysics.RevoluteJoint(prim).CreateBody1Rel().SetTargets(["/World/Articulation/body1"]) + apply_joint_drive_properties("/World/Articulation", [MujocoJointCfg(actuatorgravcomp=True)], stage) + body = stage.GetPrimAtPath("/World/Articulation/body1") + assert body.GetAttribute("mjc:gravcomp").Get() == pytest.approx(1.0) + + +def test_mujoco_joint_without_actuatorgravcomp_leaves_body_gravcomp_untouched(): + # the gravcomp coupling must fire ONLY when actuatorgravcomp is requested; an unset flag leaves + # the child body's mjc:gravcomp unauthored + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + UsdPhysics.RevoluteJoint(prim).CreateBody1Rel().SetTargets(["/World/Articulation/body1"]) + apply_joint_drive_properties("/World/Articulation", [MujocoJointCfg()], stage) + body = stage.GetPrimAtPath("/World/Articulation/body1") + assert body.GetAttribute("mjc:gravcomp").Get() is None + + +def test_mujoco_joint_actuatorgravcomp_enables_gravcomp_on_every_joint_body(): + # per-joint dispatch must enable gravcomp on each joint's own child body across the articulation + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/Articulation") + for link in ("link_a", "link_b"): + UsdGeom.Cube.Define(stage, f"/World/Articulation/{link}") + j0 = UsdPhysics.RevoluteJoint.Define(stage, "/World/Articulation/joint_0") + j0.CreateBody1Rel().SetTargets(["/World/Articulation/link_a"]) + j1 = UsdPhysics.PrismaticJoint.Define(stage, "/World/Articulation/joint_1") + j1.CreateBody1Rel().SetTargets(["/World/Articulation/link_b"]) + apply_joint_drive_properties("/World/Articulation", [MujocoJointCfg(actuatorgravcomp=True)], stage) + # both the revolute joint's body and the prismatic joint's body get gravcomp enabled + assert stage.GetPrimAtPath("/World/Articulation/link_a").GetAttribute("mjc:gravcomp").Get() == pytest.approx(1.0) + assert stage.GetPrimAtPath("/World/Articulation/link_b").GetAttribute("mjc:gravcomp").Get() == pytest.approx(1.0) + + +def test_mujoco_joint_actuatorgravcomp_preserves_authored_body_gravcomp(): + # an explicitly authored body gravcomp must not be clobbered by the actuatorgravcomp auto-enable + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + from isaaclab.sim.utils import safe_set_attribute_on_usd_prim + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + UsdPhysics.RevoluteJoint(prim).CreateBody1Rel().SetTargets(["/World/Articulation/body1"]) + body = stage.GetPrimAtPath("/World/Articulation/body1") + safe_set_attribute_on_usd_prim(body, "mjc:gravcomp", 0.5, camel_case=False) + apply_joint_drive_properties("/World/Articulation", [MujocoJointCfg(actuatorgravcomp=True)], stage) + assert body.GetAttribute("mjc:gravcomp").Get() == pytest.approx(0.5) + + +# ------------------------------------------------------------------------------------- +# apply_joint_drive_properties dispatch (presence-gated DriveAPI + multi-namespace) +# ------------------------------------------------------------------------------------- + + +def test_apply_joint_drive_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import MujocoJointCfg + from isaaclab_physx.sim.schemas import PhysxJointCfg + + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_joint_drive_properties( + "/World/Articulation", + [ + UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), + PhysxJointCfg(max_joint_velocity=5.0), + MujocoJointCfg(actuatorgravcomp=True), + ], + stage, + ) + assert bool(UsdPhysics.DriveAPI(prim, "angular")) # presence-gated anchor applied + assert prim.GetAttribute("drive:angular:physics:maxForce").Get() == pytest.approx(80.0, rel=1e-6) + assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(10.0 * math.pi / 180.0, rel=1e-6) + # revolute joint -> rad/s to deg/s conversion via apply_physx_joint + assert prim.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(5.0 * 180.0 / math.pi, rel=1e-6) + assert prim.GetAttribute("mjc:actuatorgravcomp").Get() is True + + +def test_apply_joint_drive_properties_without_drive_does_not_apply_drive_api(): + from isaaclab_physx.sim.schemas import PhysxJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_joint_drive_properties("/World/Articulation", [PhysxJointCfg(max_joint_velocity=5.0)], stage) + # DriveAPI is presence-gated: not applied when no UsdPhysicsDriveCfg fragment is present + assert not bool(UsdPhysics.DriveAPI(prim, "angular")) + # revolute joint -> rad/s to deg/s conversion via apply_physx_joint + assert prim.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(5.0 * 180.0 / math.pi, rel=1e-6) + + +def test_apply_joint_drive_properties_skips_tendon_child_joint(): + """A tendon-child joint (``PhysxTendonAxisAPI`` without the root API) must be skipped wholesale + by the dispatch loop: no fragment -- drive, physxJoint, or mjc -- may author on it, matching the + legacy :func:`modify_joint_drive_properties` writer (which skipped the whole prim).""" + from isaaclab_physx.sim.schemas import PhysxJointCfg + + from pxr import PhysxSchema + + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + joint = _make_revolute_joint(stage) + PhysxSchema.PhysxTendonAxisAPI.Apply(joint, "axis0") # tendon child: axis API, no root API + applied = str(joint.GetAppliedSchemas()) + assert "PhysxTendonAxisAPI" in applied and "PhysxTendonAxisRootAPI" not in applied + + apply_joint_drive_properties( + "/World/Articulation", + [ + UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), + PhysxJointCfg(max_joint_velocity=5.0), + ], + stage, + ) + # neither the presence-gated DriveAPI nor the physxJoint fragment may author on a tendon child + assert not bool(UsdPhysics.DriveAPI(joint, "angular")) + assert not joint.GetAttribute("physxJoint:maxJointVelocity").HasAuthoredValue() + + +def test_apply_joint_drive_properties_skips_joint_via_registered_predicate(monkeypatch): + """Core delegates joint exclusion to backend-registered predicates: one returning True skips the + joint, with no backend-specific schema knowledge in core. + + monkeypatch clears the module-global predicate list (other tests register the PhysX detector + session-wide) and restores it afterwards, keeping this isolated. + """ + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, _backend_hooks, apply_joint_drive_properties + + monkeypatch.setattr(_backend_hooks, "_JOINT_DRIVE_SKIP_PREDICATES", []) + _backend_hooks.register_joint_drive_skip_predicate(lambda prim: True) # exclude every joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + joint = _make_revolute_joint(stage) + apply_joint_drive_properties("/World/Articulation", [UsdPhysicsDriveCfg(stiffness=10.0)], stage) + assert not bool(UsdPhysics.DriveAPI(joint, "angular")) + + +def test_apply_joint_drive_properties_authors_when_no_skip_predicate(monkeypatch): + """Empty predicate registry (the default) skips nothing -- the writer authors on the joint.""" + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, _backend_hooks, apply_joint_drive_properties + + monkeypatch.setattr(_backend_hooks, "_JOINT_DRIVE_SKIP_PREDICATES", []) + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + joint = _make_revolute_joint(stage) + apply_joint_drive_properties("/World/Articulation", [UsdPhysicsDriveCfg(stiffness=10.0)], stage) + assert bool(UsdPhysics.DriveAPI(joint, "angular")) + + +def test_apply_joint_drive_properties_ensure_drives_exist_seeds_stiffness(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + # neither stiffness nor damping authored -> ensure_drives_exist seeds a minimal stiffness + apply_joint_drive_properties( + "/World/Articulation", [UsdPhysicsDriveCfg(max_force=1.0)], stage, ensure_drives_exist=True + ) + assert bool(UsdPhysics.DriveAPI(prim, "angular")) + assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(1e-3 * math.pi / 180.0, rel=1e-6) + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import MujocoJointCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxJointCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + JointDriveFragment, + SchemaFragment, + UsdPhysicsDriveCfg, + apply_drive, + apply_joint_drive_properties, + ) diff --git a/source/isaaclab/test/sim/test_mass_fragments.py b/source/isaaclab/test/sim/test_mass_fragments.py new file mode 100644 index 00000000000..f017d9d2d16 --- /dev/null +++ b/source/isaaclab/test/sim/test_mass_fragments.py @@ -0,0 +1,185 @@ +# 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 UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + +pytestmark = pytest.mark.integration + + +def _make_xform(stage, path="/World/Body"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# Fragment metadata -- MassFragment marker, MassCfg +# ------------------------------------------------------------------------------------- + + +def test_fragment_metadata_defaults(): + from isaaclab.sim.schemas import MassCfg, MassFragment, SchemaFragment + + cfg = MassCfg(mass=2.0) + assert isinstance(cfg, MassFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # anchor applies MassAPI, not the fragment + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.mass == 2.0 and cfg.density is None + + +# ------------------------------------------------------------------------------------- +# apply_namespaced writes only the set fields under the physics namespace +# ------------------------------------------------------------------------------------- + + +def test_apply_namespaced_writes_only_set_fields(): + from isaaclab.sim.schemas import MassCfg, apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage) + UsdPhysics.MassAPI.Apply(prim) + apply_namespaced(MassCfg(mass=3.0), "/World/Body", stage) + assert abs(prim.GetAttribute("physics:mass").Get() - 3.0) < 1e-6 + # None field must not be authored (density exists as a MassAPI fallback attr) + assert not prim.GetAttribute("physics:density").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# apply_mass_properties dispatch (implicit MassAPI anchor) +# ------------------------------------------------------------------------------------- + + +def test_apply_mass_properties_applies_anchor_and_writes_fields(): + from isaaclab.sim.schemas import MassCfg, apply_mass_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/B2") + apply_mass_properties("/World/B2", [MassCfg(mass=5.0, density=100.0)], stage) + prim = stage.GetPrimAtPath("/World/B2") + assert bool(UsdPhysics.MassAPI(prim)) # implicit anchor applied + assert abs(prim.GetAttribute("physics:mass").Get() - 5.0) < 1e-6 + assert abs(prim.GetAttribute("physics:density").Get() - 100.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# spawner slot accepts a fragment list + transition routing +# ------------------------------------------------------------------------------------- + + +def test_spawn_shape_with_mass_fragment_list(): + from isaaclab.sim.schemas import MassCfg, UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], + mass_props=[MassCfg(mass=4.0)], + ) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") + assert bool(UsdPhysics.MassAPI(prim)) + assert abs(prim.GetAttribute("physics:mass").Get() - 4.0) < 1e-6 + + +def test_spawn_shape_with_single_mass_fragment(): + # the ``mass_props`` slot advertises a single fragment (convenience form), not only a list; + # the spawn shim must route a bare fragment through ``apply_mass_properties`` (not the legacy writer) + from isaaclab.sim.schemas import MassCfg, UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], + mass_props=MassCfg(mass=4.0), + ) + cfg.func("/World/CubeSingle", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/CubeSingle") + assert bool(UsdPhysics.MassAPI(prim)) + assert abs(prim.GetAttribute("physics:mass").Get() - 4.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# Review follow-ups -- prim-validity guard, aggregated return, empty-list no-op +# ------------------------------------------------------------------------------------- + + +def test_apply_mass_properties_raises_on_invalid_prim(): + from isaaclab.sim.schemas import MassCfg, apply_mass_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # no prim authored at this path -> GetPrimAtPath returns an invalid prim + with pytest.raises(ValueError): + apply_mass_properties("/World/DoesNotExist", [MassCfg(mass=1.0)], stage) + + +def test_apply_mass_properties_aggregates_fragment_results(): + from isaaclab.sim.schemas import MassCfg, apply_mass_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Agg") + + # a fragment whose applier reports failure must make the aggregate return False + failing = MassCfg(mass=1.0) + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_mass_properties("/World/Agg", [failing], stage) is False + + # all-succeeding fragments return True + ok = MassCfg(mass=1.0) + assert apply_mass_properties("/World/Agg", [ok], stage) is True + + +def test_spawn_shape_with_empty_mass_list_is_noop(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], + mass_props=[], + ) + # an empty fragment list routes through the fragment path and applies nothing (no exception) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") + # mass anchor is not required when there are zero fragments to apply + assert not prim.GetAttribute("physics:mass").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab.sim.schemas import ( # noqa: F401 + MassCfg, + MassFragment, + SchemaFragment, + apply_mass_properties, + ) diff --git a/source/isaaclab/test/sim/test_material_fragments.py b/source/isaaclab/test/sim/test_material_fragments.py new file mode 100644 index 00000000000..576a087e788 --- /dev/null +++ b/source/isaaclab/test/sim/test_material_fragments.py @@ -0,0 +1,525 @@ +# 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 + +pytestmark = pytest.mark.integration + +# ------------------------------------------------------------------------------------- +# 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_mesh_collision_fragments.py b/source/isaaclab/test/sim/test_mesh_collision_fragments.py new file mode 100644 index 00000000000..ae33dbb938d --- /dev/null +++ b/source/isaaclab/test/sim/test_mesh_collision_fragments.py @@ -0,0 +1,357 @@ +# 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 UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + +pytestmark = pytest.mark.integration + + +def _make_xform(stage, path="/World/Mesh"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +def _has_authored_api_schema(prim, schema_name: str) -> bool: + """Return whether a schema name is applied or authored in ``apiSchemas`` metadata. + + A schema that is authored via ``AddAppliedSchema`` but not registered in the current build + appears in the ``apiSchemas`` listOp yet not in the composed ``GetAppliedSchemas()``. + """ + if schema_name in prim.GetAppliedSchemas(): + return True + api_schemas = prim.GetMetadata("apiSchemas") + if api_schemas is None: + return False + return any( + schema_name in getattr(api_schemas, item_list) + for item_list in ("explicitItems", "prependedItems", "appendedItems", "addedItems") + ) + + +# ------------------------------------------------------------------------------------- +# Fragment metadata + marker hierarchy +# ------------------------------------------------------------------------------------- + + +def test_mesh_collision_fragment_metadata_defaults(): + from isaaclab.sim.schemas import MeshCollisionFragment, SchemaFragment, UsdPhysicsMeshCollisionCfg + + cfg = UsdPhysicsMeshCollisionCfg(mesh_approximation_name="convexHull") + assert isinstance(cfg, MeshCollisionFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # anchor applies MeshCollisionAPI, not the fragment + assert cfg.func == "isaaclab.sim.schemas:apply_mesh_collision" + assert cfg.mesh_approximation_name == "convexHull" + + +# ------------------------------------------------------------------------------------- +# Core USD fragment: physics:approximation token via apply_mesh_collision_properties +# ------------------------------------------------------------------------------------- + + +def test_usd_mesh_collision_fragment_writes_approximation_token(): + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/M0") + apply_mesh_collision_properties( + "/World/M0", [UsdPhysicsMeshCollisionCfg(mesh_approximation_name="boundingCube")], stage + ) + prim = stage.GetPrimAtPath("/World/M0") + assert bool(UsdPhysics.MeshCollisionAPI(prim)) + assert prim.GetAttribute("physics:approximation").Get() == "boundingCube" + + +# ------------------------------------------------------------------------------------- +# PhysX cooking fragments (isaaclab_physx): each writes its own physx*Collision namespace +# ------------------------------------------------------------------------------------- + + +def test_physx_convex_hull_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxConvexHullCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M1") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxConvexHullCfg(hull_vertex_limit=32, min_thickness=0.002), "/World/M1", stage) + assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 32 + assert abs(prim.GetAttribute("physxConvexHullCollision:minThickness").Get() - 0.002) < 1e-6 + # ``mesh_approximation_name`` must NOT be authored as a namespaced attr by the generic applier. + assert not prim.HasAttribute("physxConvexHullCollision:meshApproximationName") + + +def test_physx_convex_decomposition_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxConvexDecompositionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M2") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxConvexDecompositionCfg(max_convex_hulls=8, shrink_wrap=True), "/World/M2", stage) + assert prim.GetAttribute("physxConvexDecompositionCollision:maxConvexHulls").Get() == 8 + assert prim.GetAttribute("physxConvexDecompositionCollision:shrinkWrap").Get() is True + + +def test_physx_triangle_mesh_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxTriangleMeshCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M3") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxTriangleMeshCfg(weld_tolerance=0.01), "/World/M3", stage) + assert abs(prim.GetAttribute("physxTriangleMeshCollision:weldTolerance").Get() - 0.01) < 1e-6 + + +def test_physx_triangle_mesh_simplification_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxTriangleMeshSimplificationCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M4") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxTriangleMeshSimplificationCfg(simplification_metric=0.7), "/World/M4", stage) + ns = "physxTriangleMeshSimplificationCollision" + assert abs(prim.GetAttribute(f"{ns}:simplificationMetric").Get() - 0.7) < 1e-6 + + +def test_physx_sdf_mesh_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxSDFMeshCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M5") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxSDFMeshCfg(sdf_resolution=128, sdf_margin=0.02), "/World/M5", stage) + assert prim.GetAttribute("physxSDFMeshCollision:sdfResolution").Get() == 128 + assert abs(prim.GetAttribute("physxSDFMeshCollision:sdfMargin").Get() - 0.02) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# Newton cooking fragments (isaaclab_newton): newton namespace + applied schema +# ------------------------------------------------------------------------------------- + + +def test_newton_mesh_collision_fragment_writes_namespace(): + from isaaclab_newton.sim.schemas import NewtonMeshCollisionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M6") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(NewtonMeshCollisionCfg(max_hull_vertices=24), "/World/M6", stage) + assert prim.GetAttribute("newton:maxHullVertices").Get() == 24 + assert "NewtonMeshCollisionAPI" in prim.GetAppliedSchemas() + + +def test_newton_sdf_collision_fragment_writes_namespace(): + from isaaclab_newton.sim.schemas import NewtonSDFCollisionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M7") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(NewtonSDFCollisionCfg(sdf_max_resolution=64, hydroelastic_enabled=True), "/World/M7", stage) + assert prim.GetAttribute("newton:sdfMaxResolution").Get() == 64 + assert prim.GetAttribute("newton:hydroelasticEnabled").Get() is True + # ``NewtonSDFCollisionAPI`` is authored into the ``apiSchemas`` listOp (like the legacy cfg) but + # is not a registered schema in this Newton build, so it is absent from the composed + # ``GetAppliedSchemas()``. Assert the authored token, matching the legacy Newton test. + assert _has_authored_api_schema(prim, "NewtonSDFCollisionAPI") + + +# ------------------------------------------------------------------------------------- +# Composition through apply_mesh_collision_properties: token coupling + multi-namespace +# ------------------------------------------------------------------------------------- + + +def test_apply_mesh_collision_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import NewtonMeshCollisionCfg + from isaaclab_physx.sim.schemas import PhysxConvexHullCfg + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/M8") + apply_mesh_collision_properties( + "/World/M8", + [ + UsdPhysicsMeshCollisionCfg(), + PhysxConvexHullCfg(hull_vertex_limit=48), + NewtonMeshCollisionCfg(max_hull_vertices=48), + ], + stage, + ) + prim = stage.GetPrimAtPath("/World/M8") + assert bool(UsdPhysics.MeshCollisionAPI(prim)) # implicit anchor applied + # token coupling: the convex-hull cooking fragment sets ``physics:approximation`` + assert prim.GetAttribute("physics:approximation").Get() == "convexHull" + assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 48 + assert prim.GetAttribute("newton:maxHullVertices").Get() == 48 + + +def test_apply_mesh_collision_properties_rejects_invalid_token(): + import pytest + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/M9") + with pytest.raises(ValueError): + apply_mesh_collision_properties( + "/World/M9", [UsdPhysicsMeshCollisionCfg(mesh_approximation_name="notAToken")], stage + ) + + +def test_apply_mesh_collision_properties_raises_on_invalid_prim(): + import pytest + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_mesh_collision_properties("/World/DoesNotExist", [UsdPhysicsMeshCollisionCfg()], stage) + + +def test_apply_mesh_collision_properties_aggregates_fragment_results(): + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Magg") + + # a fragment whose applier reports failure must make the aggregate return False + failing = UsdPhysicsMeshCollisionCfg() + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_mesh_collision_properties("/World/Magg", [failing], stage) is False + + # all-succeeding fragments return True + ok = UsdPhysicsMeshCollisionCfg() + ok.func = lambda cfg, prim_path, stage=None: True + assert apply_mesh_collision_properties("/World/Magg", [ok], stage) is True + + +def test_apply_mesh_collision_properties_accepts_generator(): + # the writer dispatches fragments from any iterable; a one-shot generator is consumed once and + # each fragment authors both its namespace and its implied approximation token + from isaaclab_physx.sim.schemas import PhysxConvexHullCfg + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Mgen") + frags = (f for f in [UsdPhysicsMeshCollisionCfg(), PhysxConvexHullCfg(hull_vertex_limit=48)]) + apply_mesh_collision_properties("/World/Mgen", frags, stage) + prim = stage.GetPrimAtPath("/World/Mgen") + # both passes ran: approximation token resolved AND the per-fragment namespaced attr written + assert prim.GetAttribute("physics:approximation").Get() == "convexHull" + assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 48 + + +# ------------------------------------------------------------------------------------- +# apply_mesh_collision: the per-fragment func carrying the approximation-token coupling +# ------------------------------------------------------------------------------------- + + +def test_apply_mesh_collision_writes_namespace_and_implied_token(): + # the per-fragment func (the default ``func`` of every MeshCollisionFragment) writes the + # fragment's namespaced cooking attrs AND the ``physics:approximation`` token it implies + from isaaclab_physx.sim.schemas import PhysxConvexHullCfg + + from isaaclab.sim.schemas import apply_mesh_collision + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/Mfunc") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_mesh_collision(PhysxConvexHullCfg(hull_vertex_limit=16), "/World/Mfunc", stage) + assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 16 + assert prim.GetAttribute("physics:approximation").Get() == "convexHull" + + +def test_apply_mesh_collision_rejects_invalid_token(): + import pytest + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Mfunc2") + with pytest.raises(ValueError): + apply_mesh_collision(UsdPhysicsMeshCollisionCfg(mesh_approximation_name="notAToken"), "/World/Mfunc2", stage) + + +# ------------------------------------------------------------------------------------- +# Public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import NewtonMeshCollisionCfg, NewtonSDFCollisionCfg # noqa: F401 + from isaaclab_physx.sim.schemas import ( # noqa: F401 + PhysxConvexDecompositionCfg, + PhysxConvexHullCfg, + PhysxSDFMeshCfg, + PhysxTriangleMeshCfg, + PhysxTriangleMeshSimplificationCfg, + ) + + from isaaclab.sim.schemas import ( # noqa: F401 + MeshCollisionFragment, + SchemaFragment, + UsdPhysicsMeshCollisionCfg, + apply_mesh_collision, + apply_mesh_collision_properties, + apply_namespaced, + ) diff --git a/source/isaaclab/test/sim/test_mesh_converter.py b/source/isaaclab/test/sim/test_mesh_converter.py index 1373f3753d0..f4551b4ba82 100644 --- a/source/isaaclab/test/sim/test_mesh_converter.py +++ b/source/isaaclab/test/sim/test_mesh_converter.py @@ -27,6 +27,8 @@ from isaaclab.sim.schemas import MESH_APPROXIMATION_TOKENS, schemas_cfg from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path +pytestmark = pytest.mark.integration + def random_quaternion(): # Generate four random numbers for the quaternion (x, y, z, w format) diff --git a/source/isaaclab/test/sim/test_mjcf_converter.py b/source/isaaclab/test/sim/test_mjcf_converter.py index 53ef7e5950f..b704d9a64e1 100644 --- a/source/isaaclab/test/sim/test_mjcf_converter.py +++ b/source/isaaclab/test/sim/test_mjcf_converter.py @@ -22,7 +22,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.sim.converters import MjcfConverter, MjcfConverterCfg -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] _MJCF_IMPORTER_EXTENSION = "isaacsim.asset.importer.mjcf" diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index e76e1c613e6..91b49a9c361 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -17,6 +17,10 @@ from types import SimpleNamespace +import pytest + +pytestmark = pytest.mark.integration + def _reset_newton_manager_state(): from isaaclab_newton.physics import NewtonManager diff --git a/source/isaaclab/test/sim/test_newton_model_utils.py b/source/isaaclab/test/sim/test_newton_model_utils.py index de66d30e204..4edfa08934b 100644 --- a/source/isaaclab/test/sim/test_newton_model_utils.py +++ b/source/isaaclab/test/sim/test_newton_model_utils.py @@ -25,6 +25,8 @@ replace_newton_builder_shape_colors, ) +pytestmark = pytest.mark.integration + _WARNING_MESSAGE = "Newton shape color replacement is enabled; this workaround will be deprecated in a future release." diff --git a/source/isaaclab/test/sim/test_rendering_kit_defaults.py b/source/isaaclab/test/sim/test_rendering_kit_defaults.py new file mode 100644 index 00000000000..7dc105bf016 --- /dev/null +++ b/source/isaaclab/test/sim/test_rendering_kit_defaults.py @@ -0,0 +1,106 @@ +# 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 + +"""App-free checks that the high-fidelity RTX defaults live only in the *.rendering.kit and stay in sync.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +_APPS_DIR = Path(__file__).resolve().parents[4] / "apps" + +# Kit loaded when cameras are enabled. +_RENDERING_KIT = ( + "isaaclab.python.rendering.kit", + "isaaclab.python.headless.rendering.kit", +) + +# Kit loaded when cameras are disabled. +_BASE_KIT = "isaaclab.python.kit" + +# Defaults exclusive to the *.rendering.kit; keys also present in the *.kit are excluded so the +# negative test stays meaningful. +_HIGH_FIDELITY_DEFAULTS = ( + "rtx.rtpt.maxBounces", + "rtx.shadows.enabled", + "rtx.ambientOcclusion.enabled", + "rtx.raytracing.cached.enabled", + "rtx.viewTile.limit", +) + +# Defaults that must stay identical across both rendering kits (drift guard). +_SHARED_RTX_DEFAULTS = ( + "rtx.translucency.enabled", + "rtx.reflections.enabled", + "rtx.indirectDiffuse.enabled", + "rtx-transient.dlssg.enabled", + "rtx.directLighting.sampledLighting.enabled", + "rtx.directLighting.sampledLighting.samplesPerPixel", + "rtx.sceneDb.ambientLightIntensity", + "rtx.shadows.enabled", + "rtx.rtpt.maxBounces", + "rtx.rtpt.cached.enabled", + "rtx.rtpt.lightcache.cached.enabled", + "rtx.rtpt.translucency.virtualMotion.enabled", + "rtx.rtpt.splitRoughReflection", + "rtx.rtpt.adaptiveSampling.disocclusion.enabled", + "rtx.rtpt.adaptiveSampling.disocclusion.spp", + "rtx.pathtracing.maxSamplesPerLaunch", + "rtx.viewTile.limit", + "rtx.raytracing.cached.enabled", + "rtx.raytracing.subpixel.mode", + "rtx.ambientOcclusion.enabled", + "rtx.ambientOcclusion.denoiserMode", + "rtx.post.dlss.execMode", +) + + +def _read_kit(name: str) -> str: + """Return the text of kit ``name`` under ``apps/``.""" + return (_APPS_DIR / name).read_text() + + +def _kit_defines(content: str, key: str) -> bool: + """Return whether ``content`` assigns ``key``.""" + return re.search(rf"^\s*{re.escape(key)}\s*=", content, re.MULTILINE) is not None + + +def _kit_value(content: str, key: str) -> str | None: + """Return the value assigned to ``key`` (comment stripped), or ``None`` if unset.""" + match = re.search(rf"^\s*{re.escape(key)}\s*=\s*(.+?)\s*$", content, re.MULTILINE) + if match is None: + return None + return match.group(1).split("#", 1)[0].strip() + + +@pytest.mark.parametrize("kit_name", _RENDERING_KIT) +def test_rendering_kits_define_high_fidelity_defaults(kit_name): + """Rendering kits carry the high-fidelity RTX defaults.""" + content = _read_kit(kit_name) + missing = [key for key in _HIGH_FIDELITY_DEFAULTS if not _kit_defines(content, key)] + assert not missing, f"{kit_name} is missing high-fidelity RTX defaults: {missing}" + + +def test_base_kit_omits_high_fidelity_defaults(): + """Base kit (cameras disabled) omits the high-fidelity RTX defaults.""" + content = _read_kit(_BASE_KIT) + present = [key for key in _HIGH_FIDELITY_DEFAULTS if _kit_defines(content, key)] + assert not present, f"{_BASE_KIT} unexpectedly defines high-fidelity RTX defaults: {present}" + + +def test_rendering_kits_agree_on_rtx_defaults(): + """Both rendering kits define identical values for every shared RTX default.""" + primary, secondary = _RENDERING_KIT + primary_content, secondary_content = _read_kit(primary), _read_kit(secondary) + mismatches = {} + for key in _SHARED_RTX_DEFAULTS: + primary_value = _kit_value(primary_content, key) + secondary_value = _kit_value(secondary_content, key) + if primary_value != secondary_value: + mismatches[key] = {primary: primary_value, secondary: secondary_value} + assert not mismatches, f"RTX defaults drifted between rendering kits: {mismatches}" diff --git a/source/isaaclab/test/sim/test_schema_fragments.py b/source/isaaclab/test/sim/test_schema_fragments.py new file mode 100644 index 00000000000..c712e4f4a31 --- /dev/null +++ b/source/isaaclab/test/sim/test_schema_fragments.py @@ -0,0 +1,255 @@ +# 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 UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + +pytestmark = pytest.mark.integration + + +def _make_xform(stage, path="/World/Body"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# SchemaFragment base, RigidBodyFragment marker, UsdPhysicsRigidBodyCfg +# ------------------------------------------------------------------------------------- + + +def test_fragment_metadata_defaults(): + from isaaclab.sim.schemas import RigidBodyFragment, SchemaFragment, UsdPhysicsRigidBodyCfg + + cfg = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + assert isinstance(cfg, RigidBodyFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # anchor applies RigidBodyAPI, not the fragment + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.rigid_body_enabled is True and cfg.kinematic_enabled is None + + +# ------------------------------------------------------------------------------------- +# apply_namespaced generic applier +# ------------------------------------------------------------------------------------- + + +def test_apply_namespaced_writes_only_set_fields(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage) + UsdPhysics.RigidBodyAPI.Apply(prim) + apply_namespaced(UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), "/World/Body", stage) + assert prim.GetAttribute("physics:rigidBodyEnabled").Get() is True + # ``kinematicEnabled`` is a RigidBodyAPI fallback attr (so HasAttribute is True), but the + # None field must not be authored by apply_namespaced. + assert not prim.GetAttribute("physics:kinematicEnabled").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# PhysxRigidBodyCfg (isaaclab_physx) +# ------------------------------------------------------------------------------------- + + +def test_physx_rigid_body_fragment_writes_physx_namespace(): + from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/B2") + UsdPhysics.RigidBodyAPI.Apply(prim) + apply_namespaced(PhysxRigidBodyCfg(linear_damping=0.1, disable_gravity=True), "/World/B2", stage) + assert abs(prim.GetAttribute("physxRigidBody:linearDamping").Get() - 0.1) < 1e-6 + assert prim.GetAttribute("physxRigidBody:disableGravity").Get() is True + + +# ------------------------------------------------------------------------------------- +# MujocoRigidBodyCfg (isaaclab_newton) +# ------------------------------------------------------------------------------------- + + +def test_mujoco_rigid_body_fragment_writes_mjc_namespace(): + from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/B3") + UsdPhysics.RigidBodyAPI.Apply(prim) + apply_namespaced(MujocoRigidBodyCfg(gravcomp=1.0), "/World/B3", stage) + assert abs(prim.GetAttribute("mjc:gravcomp").Get() - 1.0) < 1e-6 + + +def test_mujoco_rigid_body_fragment_does_not_write_gravcomp_when_none(): + # fragment-path equivalent of the legacy test_mujoco_gravcomp_not_written_when_none: + # an unset gravcomp must not author mjc:gravcomp + from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/B3b") + UsdPhysics.RigidBodyAPI.Apply(prim) + apply_namespaced(MujocoRigidBodyCfg(), "/World/B3b", stage) + assert prim.GetAttribute("mjc:gravcomp").Get() is None + + +# ------------------------------------------------------------------------------------- +# apply_rigid_body_properties dispatch (implicit anchor + multi-namespace) +# ------------------------------------------------------------------------------------- + + +def test_apply_rigid_body_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg + from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg + + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_rigid_body_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/B4") + apply_rigid_body_properties( + "/World/B4", + [ + UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), + PhysxRigidBodyCfg(linear_damping=0.2), + MujocoRigidBodyCfg(gravcomp=1.0), + ], + stage, + ) + prim = stage.GetPrimAtPath("/World/B4") + assert bool(UsdPhysics.RigidBodyAPI(prim)) # implicit anchor applied + assert prim.GetAttribute("physics:rigidBodyEnabled").Get() is True + assert abs(prim.GetAttribute("physxRigidBody:linearDamping").Get() - 0.2) < 1e-6 + assert abs(prim.GetAttribute("mjc:gravcomp").Get() - 1.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# spawner slot accepts a fragment list + transition routing +# ------------------------------------------------------------------------------------- + + +def test_spawn_shape_with_rigid_fragment_list(): + from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg + + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), PhysxRigidBodyCfg(linear_damping=0.3)], + ) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") + assert bool(UsdPhysics.RigidBodyAPI(prim)) + assert abs(prim.GetAttribute("physxRigidBody:linearDamping").Get() - 0.3) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + RigidBodyFragment, + SchemaFragment, + UsdPhysicsRigidBodyCfg, + apply_namespaced, + apply_rigid_body_properties, + ) + + +# ------------------------------------------------------------------------------------- +# Review follow-ups -- prim-validity guard, aggregated return, namespace invariant guard +# ------------------------------------------------------------------------------------- + + +def test_apply_namespaced_raises_on_invalid_prim(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # no prim authored at this path -> GetPrimAtPath returns an invalid prim + with pytest.raises(ValueError): + apply_namespaced(UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), "/World/DoesNotExist", stage) + + +def test_apply_rigid_body_properties_raises_on_invalid_prim(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_rigid_body_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_rigid_body_properties("/World/DoesNotExist", [UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], stage) + + +def test_apply_rigid_body_properties_aggregates_fragment_results(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_rigid_body_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Agg") + + # a fragment whose applier reports failure must make the aggregate return False + failing = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_rigid_body_properties("/World/Agg", [failing], stage) is False + + # all-succeeding fragments return True + ok = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + assert apply_rigid_body_properties("/World/Agg", [ok], stage) is True + + +def test_apply_namespaced_raises_without_namespace(): + from typing import ClassVar + + from isaaclab.sim.schemas import RigidBodyFragment, apply_namespaced + from isaaclab.utils import configclass + + @configclass + class _NoNamespaceFragment(RigidBodyFragment): + # deliberately leaves ``_usd_namespace`` as None, violating the fragment invariant that + # every field is authored as a namespaced USD attribute + _usd_namespace: ClassVar[str | None] = None + rigid_body_enabled: bool | None = None + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/NoNs") + UsdPhysics.RigidBodyAPI.Apply(prim) + with pytest.raises(ValueError): + apply_namespaced(_NoNamespaceFragment(rigid_body_enabled=True), "/World/NoNs", stage) diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 32143a8bb43..5b04d4f0934 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -39,6 +39,8 @@ from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR from isaaclab.utils.string import to_camel_case +pytestmark = pytest.mark.integration + @pytest.fixture def setup_simulation(): @@ -752,6 +754,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/sim/test_schemas_shim.py b/source/isaaclab/test/sim/test_schemas_shim.py index 99d81f55d99..9c9b9ee78ae 100644 --- a/source/isaaclab/test/sim/test_schemas_shim.py +++ b/source/isaaclab/test/sim/test_schemas_shim.py @@ -23,6 +23,8 @@ import isaaclab.sim.spawners.materials as materials import isaaclab.sim.spawners.materials.physics_materials_cfg as materials_cfg_submodule +pytestmark = pytest.mark.integration + FORWARDED_NAMES = [ "RigidBodyPropertiesCfg", "JointDrivePropertiesCfg", diff --git a/source/isaaclab/test/sim/test_service_locator.py b/source/isaaclab/test/sim/test_service_locator.py index 8392fa57a38..28d925dd014 100644 --- a/source/isaaclab/test/sim/test_service_locator.py +++ b/source/isaaclab/test/sim/test_service_locator.py @@ -9,6 +9,8 @@ from isaaclab.sim.service_locator import ServiceLocator +pytestmark = pytest.mark.integration + # -- Dummy service helpers -- diff --git a/source/isaaclab/test/sim/test_simulation_context.py b/source/isaaclab/test/sim/test_simulation_context.py index 6ea578a85e3..65a96259060 100644 --- a/source/isaaclab/test/sim/test_simulation_context.py +++ b/source/isaaclab/test/sim/test_simulation_context.py @@ -23,6 +23,8 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +pytestmark = pytest.mark.integration + @pytest.fixture(autouse=True) def test_setup_teardown(): diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 40ee55884e5..28d7249950b 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -22,6 +22,8 @@ from isaaclab.sim.simulation_context import SimulationContext from isaaclab.visualizers.visualizer_cfg import VisualizerCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + def test_web_visualizer_cfgs_do_not_open_browser_by_default(): assert RerunVisualizerCfg().open_browser is False diff --git a/source/isaaclab/test/sim/test_simulation_render_config.py b/source/isaaclab/test/sim/test_simulation_render_config.py index 8699a874da9..89d9216ec58 100644 --- a/source/isaaclab/test/sim/test_simulation_render_config.py +++ b/source/isaaclab/test/sim/test_simulation_render_config.py @@ -3,288 +3,190 @@ # # SPDX-License-Identifier: BSD-3-Clause +"""Tests for Isaac RTX global render settings.""" -"""Launch Isaac Sim Simulator first.""" +from __future__ import annotations -from isaaclab.app import AppLauncher +import importlib +import sys +import types +from pathlib import Path -# launch omniverse app -simulation_app = AppLauncher(headless=True, enable_cameras=True).app - - -"""Rest everything follows.""" - -import os - -import flatdict import pytest -import tomllib - -from isaaclab.app.settings_manager import get_settings_manager -from isaaclab.sim.simulation_cfg import RenderCfg, SimulationCfg -from isaaclab.sim.simulation_context import SimulationContext -from isaaclab.utils.version import get_isaac_sim_version - - -@pytest.mark.skip(reason="Timeline not stopped") -@pytest.mark.isaacsim_ci -def test_render_cfg(): - """Test that the simulation context is created with the correct render cfg.""" - enable_translucency = True - enable_reflections = True - enable_global_illumination = True - antialiasing_mode = "DLAA" - enable_dlssg = True - enable_dl_denoiser = True - dlss_mode = 0 - enable_direct_lighting = True - samples_per_pixel = 4 - enable_shadows = True - enable_ambient_occlusion = True - # RT2 settings - max_bounces = 4 - split_glass = True - split_clearcoat = True - split_rough_reflection = True - ambient_light_intensity = 0.5 - ambient_occlusion_denoiser_mode = 0 - subpixel_mode = 1 - enable_cached_raytracing = True - max_samples_per_launch = 500000 - view_tile_limit = 500000 - - render_cfg = RenderCfg( - enable_translucency=enable_translucency, - enable_reflections=enable_reflections, - enable_global_illumination=enable_global_illumination, - antialiasing_mode=antialiasing_mode, - enable_dlssg=enable_dlssg, - dlss_mode=dlss_mode, - enable_dl_denoiser=enable_dl_denoiser, - enable_direct_lighting=enable_direct_lighting, - samples_per_pixel=samples_per_pixel, - enable_shadows=enable_shadows, - enable_ambient_occlusion=enable_ambient_occlusion, - # RT2 settings - max_bounces=max_bounces, - split_glass=split_glass, - split_clearcoat=split_clearcoat, - split_rough_reflection=split_rough_reflection, - ambient_light_intensity=ambient_light_intensity, - ambient_occlusion_denoiser_mode=ambient_occlusion_denoiser_mode, - subpixel_mode=subpixel_mode, - enable_cached_raytracing=enable_cached_raytracing, - max_samples_per_launch=max_samples_per_launch, - view_tile_limit=view_tile_limit, +from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import ( + IsaacRtxRendererGlobalSettingsCfg, +) + +_QUALITY_DEFAULT_SETTINGS = { + "rtx.translucency.enabled": True, + "rtx.reflections.enabled": True, + "rtx.indirectDiffuse.enabled": True, + "rtx.rtpt.maxBounces": 3, + "rtx.rtpt.cached.enabled": False, + "rtx.rtpt.lightcache.cached.enabled": False, + "rtx.rtpt.translucency.virtualMotion.enabled": False, + "rtx.rtpt.splitRoughReflection": True, + "rtx.rtpt.adaptiveSampling.disocclusion.enabled": True, + "rtx.rtpt.adaptiveSampling.disocclusion.spp": 4, + "rtx.sceneDb.ambientLightIntensity": 1.0, + "rtx.shadows.enabled": True, + "rtx.ambientOcclusion.enabled": True, + "rtx.ambientOcclusion.denoiserMode": 0, + "rtx.raytracing.subpixel.mode": 1, + "rtx.raytracing.cached.enabled": True, + "rtx-transient.dlssg.enabled": False, + "rtx.post.dlss.execMode": 2, + "rtx.pathtracing.maxSamplesPerLaunch": 1000000, + "rtx.viewTile.limit": 1000000, +} + +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + + +class _FakeSettings: + """Small settings manager double used by the Isaac RTX settings helper.""" + + def __init__(self): + self.values = {} + + def get(self, name: str): + return self.values.get(name) + + def set(self, name: str, value): + self.values[name] = value + + +class _FakeReplicatorSettings: + """Replicator settings double for anti-aliasing calls.""" + + def __init__(self): + self.antialiasing = None + + def set_render_rtx_realtime(self, antialiasing): + self.antialiasing = antialiasing + + +def _install_omni_stubs(monkeypatch) -> _FakeReplicatorSettings: + """Install the Omniverse modules imported by the Isaac RTX utility.""" + omni_mod = types.ModuleType("omni") + omni_mod.__path__ = [] + usd_mod = types.ModuleType("omni.usd") + replicator_mod = types.ModuleType("omni.replicator") + core_mod = types.ModuleType("omni.replicator.core") + + rep_settings = _FakeReplicatorSettings() + setattr(core_mod, "settings", rep_settings) + setattr(omni_mod, "usd", usd_mod) + setattr(omni_mod, "replicator", replicator_mod) + setattr(replicator_mod, "core", core_mod) + + monkeypatch.setitem(sys.modules, "omni", omni_mod) + monkeypatch.setitem(sys.modules, "omni.usd", usd_mod) + monkeypatch.setitem(sys.modules, "omni.replicator", replicator_mod) + monkeypatch.setitem(sys.modules, "omni.replicator.core", core_mod) + return rep_settings + + +def _import_isaac_rtx_utils(monkeypatch): + """Import Isaac RTX utilities after installing Kit/Replicator stubs.""" + _install_omni_stubs(monkeypatch) + return importlib.import_module("isaaclab_physx.renderers.isaac_rtx_renderer_utils") + + +def _parse_kit_setting_values(path: Path) -> dict[str, object]: + """Parse scalar Kit setting assignments used by this test.""" + values = {} + expected_names = set(_QUALITY_DEFAULT_SETTINGS) + for raw_line in path.read_text().splitlines(): + line = raw_line.split("#", 1)[0].strip() + if not line or "=" not in line: + continue + key, raw_value = (item.strip() for item in line.split("=", 1)) + if key not in expected_names: + continue + if raw_value in {"true", "false"}: + values[key] = raw_value == "true" + elif "." in raw_value: + values[key] = float(raw_value) + else: + values[key] = int(raw_value) + return values + + +def test_isaac_rtx_global_settings(monkeypatch): + """Test that Isaac RTX global settings are applied by the helper.""" + rep_settings = _install_omni_stubs(monkeypatch) + utils = importlib.import_module("isaaclab_physx.renderers.isaac_rtx_renderer_utils") + settings = _FakeSettings() + monkeypatch.setattr(utils, "get_settings_manager", lambda: settings) + global_settings = IsaacRtxRendererGlobalSettingsCfg( + enable_translucency=True, + enable_reflections=True, + enable_global_illumination=True, + antialiasing_mode="DLAA", + enable_dlssg=True, + enable_dl_denoiser=True, + dlss_mode=0, + enable_direct_lighting=True, + samples_per_pixel=4, + enable_shadows=True, + enable_ambient_occlusion=True, + max_bounces=4, + split_glass=True, + split_clearcoat=True, + split_rough_reflection=True, + ambient_light_intensity=0.5, + ambient_occlusion_denoiser_mode=0, + subpixel_mode=1, + enable_cached_raytracing=True, + max_samples_per_launch=500000, + view_tile_limit=500000, + carb_settings={ + "rtx.raytracing.subpixel.mode": 2, + "/rtx/custom/setting": True, + }, ) - cfg = SimulationCfg(render=render_cfg) - - # FIXME: when running all tests, the timeline is not stopped, force stop it here but also that does not the timeline - # omni.timeline.get_timeline_interface().stop() - - sim = SimulationContext(cfg) - - assert sim.cfg.render.enable_translucency == enable_translucency - assert sim.cfg.render.enable_reflections == enable_reflections - assert sim.cfg.render.enable_global_illumination == enable_global_illumination - assert sim.cfg.render.antialiasing_mode == antialiasing_mode - assert sim.cfg.render.enable_dlssg == enable_dlssg - assert sim.cfg.render.dlss_mode == dlss_mode - assert sim.cfg.render.enable_dl_denoiser == enable_dl_denoiser - assert sim.cfg.render.enable_direct_lighting == enable_direct_lighting - assert sim.cfg.render.samples_per_pixel == samples_per_pixel - assert sim.cfg.render.enable_shadows == enable_shadows - assert sim.cfg.render.enable_ambient_occlusion == enable_ambient_occlusion - assert sim.cfg.render.max_bounces == max_bounces - assert sim.cfg.render.split_glass == split_glass - assert sim.cfg.render.split_clearcoat == split_clearcoat - assert sim.cfg.render.split_rough_reflection == split_rough_reflection - assert sim.cfg.render.ambient_light_intensity == ambient_light_intensity - assert sim.cfg.render.ambient_occlusion_denoiser_mode == ambient_occlusion_denoiser_mode - assert sim.cfg.render.subpixel_mode == subpixel_mode - assert sim.cfg.render.enable_cached_raytracing == enable_cached_raytracing - assert sim.cfg.render.max_samples_per_launch == max_samples_per_launch - assert sim.cfg.render.view_tile_limit == view_tile_limit - - assert sim.get_setting("/rtx/translucency/enabled") == sim.cfg.render.enable_translucency - assert sim.get_setting("/rtx/reflections/enabled") == sim.cfg.render.enable_reflections - assert sim.get_setting("/rtx/indirectDiffuse/enabled") == sim.cfg.render.enable_global_illumination - assert sim.get_setting("/rtx-transient/dlssg/enabled") == sim.cfg.render.enable_dlssg - assert sim.get_setting("/rtx-transient/dldenoiser/enabled") == sim.cfg.render.enable_dl_denoiser - assert sim.get_setting("/rtx/post/dlss/execMode") == sim.cfg.render.dlss_mode - assert sim.get_setting("/rtx/directLighting/enabled") == sim.cfg.render.enable_direct_lighting - assert sim.get_setting("/rtx/directLighting/sampledLighting/samplesPerPixel") == sim.cfg.render.samples_per_pixel - assert sim.get_setting("/rtx/shadows/enabled") == sim.cfg.render.enable_shadows - assert sim.get_setting("/rtx/ambientOcclusion/enabled") == sim.cfg.render.enable_ambient_occlusion - assert sim.get_setting("/rtx/rtpt/maxBounces") == sim.cfg.render.max_bounces - assert sim.get_setting("/rtx/rtpt/splitGlass") == sim.cfg.render.split_glass - assert sim.get_setting("/rtx/rtpt/splitClearcoat") == sim.cfg.render.split_clearcoat - assert sim.get_setting("/rtx/rtpt/splitRoughReflection") == sim.cfg.render.split_rough_reflection - assert sim.get_setting("/rtx/sceneDb/ambientLightIntensity") == sim.cfg.render.ambient_light_intensity - assert sim.get_setting("/rtx/ambientOcclusion/denoiserMode") == sim.cfg.render.ambient_occlusion_denoiser_mode - assert sim.get_setting("/rtx/raytracing/subpixel/mode") == sim.cfg.render.subpixel_mode - assert sim.get_setting("/rtx/raytracing/cached/enabled") == sim.cfg.render.enable_cached_raytracing - assert sim.get_setting("/rtx/pathtracing/maxSamplesPerLaunch") == sim.cfg.render.max_samples_per_launch - assert sim.get_setting("/rtx/viewTile/limit") == sim.cfg.render.view_tile_limit - assert sim.get_setting("/rtx/post/aa/op") == 4 # dlss = 3, dlaa=4 - - -@pytest.mark.isaacsim_ci -def test_render_cfg_presets(): - """Test that the simulation context is created with the correct render cfg preset with overrides.""" - - # carb setting dictionary overrides - carb_settings = {"/rtx/raytracing/subpixel/mode": 3, "/rtx/pathtracing/maxSamplesPerLaunch": 999999} - # user-friendly setting overrides - dlss_mode = ("/rtx/post/dlss/execMode", 5) - - rendering_modes = ["performance", "balanced", "quality"] - - for rendering_mode in rendering_modes: - # Clear any existing simulation context before creating a new one - SimulationContext.clear_instance() - - # grab isaac lab apps path - isaaclab_app_exp_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 4, "apps") - # for Isaac Sim 5 compatibility, we use the 5 rendering mode app files in a different folder - if get_isaac_sim_version().major < 6: - isaaclab_app_exp_path = os.path.join(isaaclab_app_exp_path, "isaacsim_5") - - # grab preset settings - preset_filename = os.path.join(isaaclab_app_exp_path, f"rendering_modes/{rendering_mode}.kit") - with open(preset_filename, "rb") as file: - preset_dict = tomllib.load(file) - preset_dict = dict(flatdict.FlatDict(preset_dict, delimiter=".")) - - render_cfg = RenderCfg( - rendering_mode=rendering_mode, - dlss_mode=dlss_mode[1], - carb_settings=carb_settings, - ) - - cfg = SimulationCfg(render=render_cfg) - - SimulationContext(cfg) - - settings = get_settings_manager() - for key, val in preset_dict.items(): - setting_name = "/" + key.replace(".", "/") # convert to setting path format - - if setting_name in carb_settings: - setting_gt = carb_settings[setting_name] - elif setting_name == dlss_mode[0]: - setting_gt = dlss_mode[1] - else: - setting_gt = val - - setting_val = settings.get(setting_name) - - assert setting_gt == setting_val, ( - f"Mismatch for '{setting_name}' in mode '{rendering_mode}': " - f"expected {setting_gt!r}, got {setting_val!r}" + utils.apply_isaac_rtx_global_settings(global_settings) + + assert settings.get("/rtx/translucency/enabled") is True + assert settings.get("/rtx/reflections/enabled") is True + assert settings.get("/rtx/indirectDiffuse/enabled") is True + assert settings.get("/rtx-transient/dlssg/enabled") is True + assert settings.get("/rtx-transient/dldenoiser/enabled") is True + assert settings.get("/rtx/post/dlss/execMode") == 0 + assert settings.get("/rtx/directLighting/enabled") is True + assert settings.get("/rtx/directLighting/sampledLighting/samplesPerPixel") == 4 + assert settings.get("/rtx/shadows/enabled") is True + assert settings.get("/rtx/ambientOcclusion/enabled") is True + assert settings.get("/rtx/rtpt/maxBounces") == 4 + assert settings.get("/rtx/rtpt/splitGlass") is True + assert settings.get("/rtx/rtpt/splitClearcoat") is True + assert settings.get("/rtx/rtpt/splitRoughReflection") is True + assert settings.get("/rtx/sceneDb/ambientLightIntensity") == 0.5 + assert settings.get("/rtx/ambientOcclusion/denoiserMode") == 0 + assert settings.get("/rtx/raytracing/subpixel/mode") == 2 + assert settings.get("/rtx/raytracing/cached/enabled") is True + assert settings.get("/rtx/pathtracing/maxSamplesPerLaunch") == 500000 + assert settings.get("/rtx/viewTile/limit") == 500000 + assert settings.get("/rtx/custom/setting") is True + assert rep_settings.antialiasing == "DLAA" + + +def test_isaac_rtx_camera_experience_defaults(): + """Test that camera app files carry the high-fidelity RTX defaults.""" + isaaclab_app_exp_path = Path(__file__).resolve().parents[4] / "apps" + + for app_filename in ( + "isaaclab.python.rendering.kit", + "isaaclab.python.headless.rendering.kit", + ): + app_settings = _parse_kit_setting_values(isaaclab_app_exp_path / app_filename) + + for setting_name, expected_value in _QUALITY_DEFAULT_SETTINGS.items(): + assert setting_name in app_settings, f"'{setting_name}' is not defined in '{app_filename}'" + assert app_settings[setting_name] == expected_value, ( + f"Mismatch for '{setting_name}' in '{app_filename}': " + f"expected {expected_value!r}, " + f"got {app_settings[setting_name]!r}" ) - - # Clean up after the test - SimulationContext.clear_instance() - - -@pytest.mark.skip(reason="Timeline not stopped") -@pytest.mark.isaacsim_ci -def test_render_cfg_defaults(): - """Test that the simulation context is created with the correct render cfg.""" - enable_translucency = False - enable_reflections = False - enable_global_illumination = False - antialiasing_mode = "DLSS" - enable_dlssg = False - enable_dl_denoiser = False - dlss_mode = 2 - enable_direct_lighting = False - samples_per_pixel = 1 - enable_shadows = False - enable_ambient_occlusion = False - # RT2 defaults - max_bounces = 2 - split_glass = False - split_clearcoat = False - split_rough_reflection = False - ambient_light_intensity = 1.0 - ambient_occlusion_denoiser_mode = 1 - subpixel_mode = 0 - enable_cached_raytracing = False - max_samples_per_launch = 1000000 - view_tile_limit = 1000000 - - render_cfg = RenderCfg( - enable_translucency=enable_translucency, - enable_reflections=enable_reflections, - enable_global_illumination=enable_global_illumination, - antialiasing_mode=antialiasing_mode, - enable_dlssg=enable_dlssg, - enable_dl_denoiser=enable_dl_denoiser, - dlss_mode=dlss_mode, - enable_direct_lighting=enable_direct_lighting, - samples_per_pixel=samples_per_pixel, - enable_shadows=enable_shadows, - enable_ambient_occlusion=enable_ambient_occlusion, - # RT2 settings - max_bounces=max_bounces, - split_glass=split_glass, - split_clearcoat=split_clearcoat, - split_rough_reflection=split_rough_reflection, - ambient_light_intensity=ambient_light_intensity, - ambient_occlusion_denoiser_mode=ambient_occlusion_denoiser_mode, - subpixel_mode=subpixel_mode, - enable_cached_raytracing=enable_cached_raytracing, - max_samples_per_launch=max_samples_per_launch, - view_tile_limit=view_tile_limit, - ) - - cfg = SimulationCfg(render=render_cfg) - - sim = SimulationContext(cfg) - - assert sim.cfg.render.enable_translucency == enable_translucency - assert sim.cfg.render.enable_reflections == enable_reflections - assert sim.cfg.render.enable_global_illumination == enable_global_illumination - assert sim.cfg.render.antialiasing_mode == antialiasing_mode - assert sim.cfg.render.enable_dlssg == enable_dlssg - assert sim.cfg.render.enable_dl_denoiser == enable_dl_denoiser - assert sim.cfg.render.dlss_mode == dlss_mode - assert sim.cfg.render.enable_direct_lighting == enable_direct_lighting - assert sim.cfg.render.samples_per_pixel == samples_per_pixel - assert sim.cfg.render.enable_shadows == enable_shadows - assert sim.cfg.render.enable_ambient_occlusion == enable_ambient_occlusion - assert sim.cfg.render.max_bounces == max_bounces - assert sim.cfg.render.split_glass == split_glass - assert sim.cfg.render.split_clearcoat == split_clearcoat - assert sim.cfg.render.split_rough_reflection == split_rough_reflection - assert sim.cfg.render.ambient_light_intensity == ambient_light_intensity - assert sim.cfg.render.ambient_occlusion_denoiser_mode == ambient_occlusion_denoiser_mode - assert sim.cfg.render.subpixel_mode == subpixel_mode - assert sim.cfg.render.enable_cached_raytracing == enable_cached_raytracing - assert sim.cfg.render.max_samples_per_launch == max_samples_per_launch - assert sim.cfg.render.view_tile_limit == view_tile_limit - - assert sim.get_setting("/rtx/translucency/enabled") == sim.cfg.render.enable_translucency - assert sim.get_setting("/rtx/reflections/enabled") == sim.cfg.render.enable_reflections - assert sim.get_setting("/rtx/indirectDiffuse/enabled") == sim.cfg.render.enable_global_illumination - assert sim.get_setting("/rtx-transient/dlssg/enabled") == sim.cfg.render.enable_dlssg - assert sim.get_setting("/rtx-transient/dldenoiser/enabled") == sim.cfg.render.enable_dl_denoiser - assert sim.get_setting("/rtx/post/dlss/execMode") == sim.cfg.render.dlss_mode - assert sim.get_setting("/rtx/directLighting/enabled") == sim.cfg.render.enable_direct_lighting - assert sim.get_setting("/rtx/directLighting/sampledLighting/samplesPerPixel") == sim.cfg.render.samples_per_pixel - assert sim.get_setting("/rtx/shadows/enabled") == sim.cfg.render.enable_shadows - assert sim.get_setting("/rtx/ambientOcclusion/enabled") == sim.cfg.render.enable_ambient_occlusion - assert sim.get_setting("/rtx/rtpt/maxBounces") == sim.cfg.render.max_bounces - assert sim.get_setting("/rtx/rtpt/splitGlass") == sim.cfg.render.split_glass - assert sim.get_setting("/rtx/rtpt/splitClearcoat") == sim.cfg.render.split_clearcoat - assert sim.get_setting("/rtx/rtpt/splitRoughReflection") == sim.cfg.render.split_rough_reflection - assert sim.get_setting("/rtx/sceneDb/ambientLightIntensity") == sim.cfg.render.ambient_light_intensity - assert sim.get_setting("/rtx/ambientOcclusion/denoiserMode") == sim.cfg.render.ambient_occlusion_denoiser_mode - assert sim.get_setting("/rtx/raytracing/subpixel/mode") == sim.cfg.render.subpixel_mode - assert sim.get_setting("/rtx/raytracing/cached/enabled") == sim.cfg.render.enable_cached_raytracing - assert sim.get_setting("/rtx/pathtracing/maxSamplesPerLaunch") == sim.cfg.render.max_samples_per_launch - assert sim.get_setting("/rtx/viewTile/limit") == sim.cfg.render.view_tile_limit - assert sim.get_setting("/rtx/post/aa/op") == 3 # dlss = 3, dlaa=4 diff --git a/source/isaaclab/test/sim/test_simulation_stage_in_memory.py b/source/isaaclab/test/sim/test_simulation_stage_in_memory.py index 1add70c7098..963b2a6a603 100644 --- a/source/isaaclab/test/sim/test_simulation_stage_in_memory.py +++ b/source/isaaclab/test/sim/test_simulation_stage_in_memory.py @@ -29,6 +29,8 @@ from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR from isaaclab.utils.version import get_isaac_sim_version +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): diff --git a/source/isaaclab/test/sim/test_spawn_from_files.py b/source/isaaclab/test/sim/test_spawn_from_files.py index 009949de4c9..630aa123bf5 100644 --- a/source/isaaclab/test/sim/test_spawn_from_files.py +++ b/source/isaaclab/test/sim/test_spawn_from_files.py @@ -20,6 +20,8 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): diff --git a/source/isaaclab/test/sim/test_spawn_lights.py b/source/isaaclab/test/sim/test_spawn_lights.py index 3a019c2f90b..59c77188078 100644 --- a/source/isaaclab/test/sim/test_spawn_lights.py +++ b/source/isaaclab/test/sim/test_spawn_lights.py @@ -21,7 +21,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.string import to_camel_case -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture(autouse=True) diff --git a/source/isaaclab/test/sim/test_spawn_materials.py b/source/isaaclab/test/sim/test_spawn_materials.py index 7f517f2bc1f..d1cb86c8702 100644 --- a/source/isaaclab/test/sim/test_spawn_materials.py +++ b/source/isaaclab/test/sim/test_spawn_materials.py @@ -21,7 +21,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.assets import NVIDIA_NUCLEUS_DIR -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_spawn_meshes.py b/source/isaaclab/test/sim/test_spawn_meshes.py index 97aca4a23f7..a9ad5158c2f 100644 --- a/source/isaaclab/test/sim/test_spawn_meshes.py +++ b/source/isaaclab/test/sim/test_spawn_meshes.py @@ -18,7 +18,7 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_spawn_sensors.py b/source/isaaclab/test/sim/test_spawn_sensors.py index f8604b99e54..9e50b54496b 100644 --- a/source/isaaclab/test/sim/test_spawn_sensors.py +++ b/source/isaaclab/test/sim/test_spawn_sensors.py @@ -22,7 +22,7 @@ from isaaclab.sim.spawners.sensors.sensors import CUSTOM_FISHEYE_CAMERA_ATTRIBUTES, CUSTOM_PINHOLE_CAMERA_ATTRIBUTES from isaaclab.utils.string import to_camel_case -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_spawn_shapes.py b/source/isaaclab/test/sim/test_spawn_shapes.py index 532b29c2284..be59ea011d0 100644 --- a/source/isaaclab/test/sim/test_spawn_shapes.py +++ b/source/isaaclab/test/sim/test_spawn_shapes.py @@ -17,7 +17,7 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_spawn_wrappers.py b/source/isaaclab/test/sim/test_spawn_wrappers.py index 69ad8b10572..a0be9336a56 100644 --- a/source/isaaclab/test/sim/test_spawn_wrappers.py +++ b/source/isaaclab/test/sim/test_spawn_wrappers.py @@ -19,7 +19,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py new file mode 100644 index 00000000000..c7569081a16 --- /dev/null +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -0,0 +1,427 @@ +# 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 PhysxSchema, Sdf, Usd, UsdGeom + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + +pytestmark = pytest.mark.integration + + +def _new_sim(): + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + return sim_utils.get_current_stage() + + +def _make_prim_with_schemas(stage, path, schema_tokens): + """Define an Xform and stamp ``apiSchemas`` metadata with the given multi-instance tokens.""" + UsdGeom.Xform.Define(stage, path) + prim = stage.GetPrimAtPath(path) + token_op = Sdf.TokenListOp() + token_op.explicitItems = schema_tokens + prim.SetMetadata("apiSchemas", token_op) + return prim + + +def _make_xform(stage, path="/World/Tendon"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +def _make_fixed_tendon_prim(stage, path, instance="default"): + """Create a prim with a multi-instance PhysxTendonAxisRootAPI applied.""" + prim = _make_xform(stage, path) + PhysxSchema.PhysxTendonAxisRootAPI.Apply(prim, instance) + return prim + + +def _make_spatial_tendon_prim(stage, path, instance="default"): + """Create a prim with a multi-instance PhysxTendonAttachmentRootAPI applied.""" + prim = _make_xform(stage, path) + PhysxSchema.PhysxTendonAttachmentRootAPI.Apply(prim, instance) + return prim + + +def _tendon_attr_prefix(prim, schema_substr): + """Return the applied-schema name used by the writer as the authored-attribute prefix. + + The legacy writer authors ``f"{schema_name}:{camelCase(field)}"`` where ``schema_name`` is + the entry returned by ``prim.GetAppliedSchemas()`` (e.g. ``PhysxTendonAxisRootAPI:t0``). + """ + for schema_name in prim.GetAppliedSchemas(): + if schema_substr in schema_name: + return schema_name + raise AssertionError(f"no applied schema containing {schema_substr!r} on {prim.GetPath()}") + + +# ------------------------------------------------------------------------------------- +# Fixed-tendon marker + metadata defaults +# ------------------------------------------------------------------------------------- + + +def test_fixed_tendon_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + + from isaaclab.sim.schemas import FixedTendonFragment, SchemaFragment + + cfg = PhysxFixedTendonCfg(stiffness=1.0) + assert isinstance(cfg, FixedTendonFragment) and isinstance(cfg, SchemaFragment) + assert cfg.func == "isaaclab_physx.sim.schemas:apply_fixed_tendon" + assert cfg.stiffness == 1.0 and cfg.damping is None + + +def test_spatial_tendon_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg + + from isaaclab.sim.schemas import SchemaFragment, SpatialTendonFragment + + cfg = PhysxSpatialTendonCfg(stiffness=2.0) + assert isinstance(cfg, SpatialTendonFragment) and isinstance(cfg, SchemaFragment) + assert cfg.func == "isaaclab_physx.sim.schemas:apply_spatial_tendon" + assert cfg.stiffness == 2.0 and cfg.damping is None + + +# ------------------------------------------------------------------------------------- +# PhysxFixedTendonCfg writes the multi-instance namespace +# ------------------------------------------------------------------------------------- + + +def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_fixed_tendon_prim(stage, "/World/FT", instance="t0") + apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=3.0, damping=0.5), "/World/FT", stage) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 3.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:damping").Get() - 0.5) < 1e-6 + # the ``func`` plumbing field must not be authored as an attribute + assert not prim.HasAttribute(f"{prefix}:func") + + +# ------------------------------------------------------------------------------------- +# PhysxSpatialTendonCfg writes the multi-instance namespace +# ------------------------------------------------------------------------------------- + + +def test_apply_fixed_tendon_writes_all_instances(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_prim_with_schemas(stage, "/World/FTmulti", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"]) + assert apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=9.0), "/World/FTmulti", stage) is True + for inst in ("t0", "t1"): + assert abs(prim.GetAttribute(f"PhysxTendonAxisRootAPI:{inst}:stiffness").Get() - 9.0) < 1e-6 + + +def test_apply_fixed_tendon_descends_to_child_prims(): + # tendon schemas are authored on child joint prims, not the articulation root the spawner + # targets; applying at the root must descend to every descendant carrying the schema. + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/Robot") # root: no tendon schema + child = _make_fixed_tendon_prim(stage, "/World/Robot/joint", instance="t0") # child joint carries it + # apply at the ROOT, not the joint + assert apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=8.0), "/World/Robot", stage) is True + prefix = _tendon_attr_prefix(child, "PhysxTendonAxisRootAPI") + assert abs(child.GetAttribute(f"{prefix}:stiffness").Get() - 8.0) < 1e-6 + + +def test_apply_spatial_tendon_descends_to_child_prims(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/Robot2") # root: no tendon schema + child = _make_prim_with_schemas(stage, "/World/Robot2/joint", ["PhysxTendonAttachmentRootAPI:s0"]) + assert apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=5.0), "/World/Robot2", stage) is True + assert abs(child.GetAttribute("PhysxTendonAttachmentRootAPI:s0:stiffness").Get() - 5.0) < 1e-6 + + +def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_spatial_tendon_prim(stage, "/World/ST", instance="s0") + apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=4.0, limit_stiffness=0.25), "/World/ST", stage) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAttachmentRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 4.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:limitStiffness").Get() - 0.25) < 1e-6 + assert not prim.HasAttribute(f"{prefix}:func") + + +def test_apply_spatial_tendon_writes_all_instances(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_prim_with_schemas( + stage, + "/World/STmulti", + ["PhysxTendonAttachmentRootAPI:r0", "PhysxTendonAttachmentLeafAPI:l0"], + ) + assert apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=4.0), "/World/STmulti", stage) is True + assert abs(prim.GetAttribute("PhysxTendonAttachmentRootAPI:r0:stiffness").Get() - 4.0) < 1e-6 + assert abs(prim.GetAttribute("PhysxTendonAttachmentLeafAPI:l0:stiffness").Get() - 4.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# apply_fixed_tendon_properties dispatch (tune-not-apply, multi-fragment) +# ------------------------------------------------------------------------------------- + + +def test_apply_fixed_tendon_properties_dispatches_fragments(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_fixed_tendon_prim(stage, "/World/FT2", instance="t0") + apply_fixed_tendon_properties( + "/World/FT2", + [PhysxFixedTendonCfg(stiffness=5.0), PhysxFixedTendonCfg(damping=0.75)], + stage, + ) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 5.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:damping").Get() - 0.75) < 1e-6 + + +def test_apply_spatial_tendon_properties_dispatches_fragments(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg + + from isaaclab.sim.schemas import apply_spatial_tendon_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_spatial_tendon_prim(stage, "/World/ST2", instance="s0") + apply_spatial_tendon_properties( + "/World/ST2", + [PhysxSpatialTendonCfg(stiffness=6.0), PhysxSpatialTendonCfg(offset=0.1)], + stage, + ) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAttachmentRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 6.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:offset").Get() - 0.1) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# Public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_physx.sim.schemas import ( # noqa: F401 + PhysxFixedTendonCfg, + PhysxSpatialTendonCfg, + apply_fixed_tendon, + apply_spatial_tendon, + ) + + from isaaclab.sim.schemas import ( # noqa: F401 + FixedTendonFragment, + SpatialTendonFragment, + apply_fixed_tendon_properties, + apply_spatial_tendon_properties, + ) + + +# ------------------------------------------------------------------------------------- +# core writer parity: invalid-prim guard + aggregated return +# ------------------------------------------------------------------------------------- + + +def test_apply_fixed_tendon_raises_on_invalid_prim(): + from isaaclab.sim.schemas import apply_fixed_tendon_properties + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_fixed_tendon_properties("/World/DoesNotExist", [], stage) + + +def test_apply_spatial_tendon_raises_on_invalid_prim(): + from isaaclab.sim.schemas import apply_spatial_tendon_properties + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_spatial_tendon_properties("/World/DoesNotExist", [], stage) + + +def test_apply_fixed_tendon_aggregates_fragment_results(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_fixed_tendon_properties + + stage = _new_sim() + _make_prim_with_schemas(stage, "/World/Agg", ["PhysxTendonAxisRootAPI:inst0"]) + + # a fragment whose applier reports failure makes the aggregate False + failing = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_fixed_tendon_properties("/World/Agg", [failing], stage) is False + + ok = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + ok.func = lambda cfg, prim_path, stage=None: True + assert apply_fixed_tendon_properties("/World/Agg", [ok], stage) is True + + +def test_apply_fixed_tendon_raises_on_invalid_prim_backend(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=1.0), "/World/DoesNotExist", stage) + + +def test_apply_mujoco_fixed_tendon_raises_on_invalid_prim(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=1.0), "/World/DoesNotExist", stage) + + +# ------------------------------------------------------------------------------------- +# MujocoFixedTendonCfg — Newton fragment for the mjc: namespace +# ------------------------------------------------------------------------------------- + + +def test_mujoco_fixed_tendon_metadata(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg + + from isaaclab.sim.schemas import FixedTendonFragment + + cfg = MujocoFixedTendonCfg(stiffness=2.0) + assert isinstance(cfg, FixedTendonFragment) + # not namespace-driven: the custom applier writes mjc:* itself, so _usd_namespace stays None + assert type(cfg)._usd_namespace is None + assert cfg.func == "isaaclab_newton.sim.schemas:apply_mujoco_fixed_tendon" + assert not hasattr(cfg, "rest_length") and not hasattr(cfg, "limit_stiffness") + + +def test_apply_mujoco_fixed_tendon_writes_mjc_namespace(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + stage.DefinePrim("/World/MjcT", "MjcTendon") + assert apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=2.0, damping=0.25), "/World/MjcT", stage) is True + prim = stage.GetPrimAtPath("/World/MjcT") + assert abs(prim.GetAttribute("mjc:stiffness").Get() - 2.0) < 1e-6 + assert abs(prim.GetAttribute("mjc:damping").Get() - 0.25) < 1e-6 + assert not prim.HasAttribute("mjc:func") + + +def test_apply_mujoco_fixed_tendon_returns_false_on_non_mjc_prim(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/NotMjc") + assert apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=2.0), "/World/NotMjc", stage) is False + prim = stage.GetPrimAtPath("/World/NotMjc") + assert not prim.HasAttribute("mjc:stiffness") + + +# ------------------------------------------------------------------------------------- +# legacy-vs-fragment equivalence (the fragment API must be a behavioral no-op swap) +# ------------------------------------------------------------------------------------- + + +def test_legacy_and_fragment_fixed_tendon_produce_identical_attrs(): + """The fragment API must author the same tendon attributes as the legacy writer. + + Verified end-to-end on the Shadow Hand (the real tendon user, + ``FixedTendonPropertiesCfg(limit_stiffness=30.0, damping=0.1)``); replicated here on a synthetic + root + descendant-joint structure so it runs deterministically without asset-server access. Also + exercises the descend-to-child-prims behavior, since the schemas live on descendants of the + applied prim path (as they do on a real articulation). + """ + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, PhysxFixedTendonPropertiesCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties, modify_fixed_tendon_properties + + stage = _new_sim() + + def _build(root): + # tendon schemas on descendant joints (multi-instance), mirroring the Shadow Hand layout + UsdGeom.Xform.Define(stage, root) + _make_prim_with_schemas(stage, f"{root}/J0", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"]) + _make_prim_with_schemas(stage, f"{root}/nested/J1", ["PhysxTendonAxisRootAPI:t0"]) + + _build("/World/legacy") + _build("/World/fragment") + + # apply each path at the ROOT; both must descend to the child joints + modify_fixed_tendon_properties("/World/legacy", PhysxFixedTendonPropertiesCfg(limit_stiffness=30.0, damping=0.1)) + apply_fixed_tendon_properties("/World/fragment", [PhysxFixedTendonCfg(limit_stiffness=30.0, damping=0.1)]) + + def _collect(root): + attrs = {} + for prim in Usd.PrimRange(stage.GetPrimAtPath(root)): + for schema_name in prim.GetAppliedSchemas(): + if "PhysxTendonAxisRootAPI" not in schema_name: + continue + for suffix in ("limitStiffness", "damping"): + attr = prim.GetAttribute(f"{schema_name}:{suffix}") + if attr and attr.HasAuthoredValue(): + rel = prim.GetPath().pathString[len(root) :] # key relative to root so paths compare + attrs[f"{rel}|{schema_name}:{suffix}"] = attr.Get() + return attrs + + legacy = _collect("/World/legacy") + fragment = _collect("/World/fragment") + + assert legacy, "legacy writer authored no tendon attributes (test would be vacuous)" + assert legacy.keys() == fragment.keys() + for key, value in legacy.items(): + assert abs(fragment[key] - value) < 1e-6 + + +def test_spawn_from_file_with_empty_tendon_lists_is_noop(tmp_path): + # an empty tendon list is type-valid for the slot; the spawner shim must route it through the + # fragment path (a no-op) rather than handing [] to the legacy modify_*_tendon_properties writer. + asset = tmp_path / "mini.usda" + src = Usd.Stage.CreateNew(str(asset)) + UsdGeom.Xform.Define(src, "/Root") + src.SetDefaultPrim(src.GetPrimAtPath("/Root")) + src.GetRootLayer().Save() + del src + + _new_sim() + cfg = sim_utils.UsdFileCfg(usd_path=str(asset), fixed_tendons_props=[], spatial_tendons_props=[]) + cfg.func("/World/Asset", cfg) # must not raise + assert sim_utils.get_current_stage().GetPrimAtPath("/World/Asset").IsValid() diff --git a/source/isaaclab/test/sim/test_urdf_converter.py b/source/isaaclab/test/sim/test_urdf_converter.py index 7794546a21a..c2e060495d8 100644 --- a/source/isaaclab/test/sim/test_urdf_converter.py +++ b/source/isaaclab/test/sim/test_urdf_converter.py @@ -26,6 +26,8 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.sim.converters import UrdfConverter, UrdfConverterCfg +pytestmark = pytest.mark.integration + # Create a fixture for setup and teardown @pytest.fixture diff --git a/source/isaaclab/test/sim/test_utils_prims.py b/source/isaaclab/test/sim/test_utils_prims.py index 0d11afe22f4..d1b8f44cfb1 100644 --- a/source/isaaclab/test/sim/test_utils_prims.py +++ b/source/isaaclab/test/sim/test_utils_prims.py @@ -25,7 +25,7 @@ from isaaclab.sim.utils.prims import _to_tuple # type: ignore[reportPrivateUsage] from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR, retrieve_file_path -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture(autouse=True) diff --git a/source/isaaclab/test/sim/test_utils_queries.py b/source/isaaclab/test/sim/test_utils_queries.py index ef9b1d8c834..49801169a78 100644 --- a/source/isaaclab/test/sim/test_utils_queries.py +++ b/source/isaaclab/test/sim/test_utils_queries.py @@ -20,6 +20,8 @@ import isaaclab.sim as sim_utils from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +pytestmark = pytest.mark.integration + @pytest.fixture(autouse=True) def test_setup_teardown(): diff --git a/source/isaaclab/test/sim/test_utils_semantics.py b/source/isaaclab/test/sim/test_utils_semantics.py index fe8cbd37187..926a2d0d80a 100644 --- a/source/isaaclab/test/sim/test_utils_semantics.py +++ b/source/isaaclab/test/sim/test_utils_semantics.py @@ -17,6 +17,8 @@ import isaaclab.sim as sim_utils +pytestmark = pytest.mark.integration + @pytest.fixture(autouse=True) def test_setup_teardown(): diff --git a/source/isaaclab/test/sim/test_utils_stage.py b/source/isaaclab/test/sim/test_utils_stage.py index f6d73d51866..39a70a076f7 100644 --- a/source/isaaclab/test/sim/test_utils_stage.py +++ b/source/isaaclab/test/sim/test_utils_stage.py @@ -23,7 +23,7 @@ import isaaclab.sim as sim_utils -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] def test_create_new_stage(): @@ -203,6 +203,35 @@ def test_save_stage_invalid_path(): sim_utils.save_stage("/tmp/test.invalid") +@pytest.mark.parametrize( + "scheme, scheme_end", + [ + ("http://", "example.com/file.usd"), + ("https://", "example.com/assets/textures/sky.hdr"), + ("omniverse://", "path/to/file.usd"), + ("s3://", "bucket-name/path/to/asset.usd"), + ], +) +def test_save_stage_preserves_uri_asset_paths(scheme, scheme_end): + """Test saving stage preserves URI asset paths.""" + from pxr import UsdLux + + with tempfile.TemporaryDirectory() as temp_dir: + stage = sim_utils.create_new_stage() + dome_light = UsdLux.DomeLight.Define(stage, "/World/skyLight") + ref = scheme + scheme_end + dome_light.CreateTextureFileAttr().Set(ref) + + save_path = Path(temp_dir) / "test_stage.usda" + result = sim_utils.save_stage(str(save_path), save_and_reload_in_place=False) + + assert result is True + saved_stage = save_path.read_text(encoding="utf-8") + assert f"@{ref}@" in saved_stage + assert f"../{scheme}" not in saved_stage + assert rf"..\{scheme}" not in saved_stage + + def test_close_stage(): """Test closing the current stage.""" # Create a stage diff --git a/source/isaaclab/test/sim/test_utils_transforms.py b/source/isaaclab/test/sim/test_utils_transforms.py index 9cd43800b52..e7cc178b65d 100644 --- a/source/isaaclab/test/sim/test_utils_transforms.py +++ b/source/isaaclab/test/sim/test_utils_transforms.py @@ -23,6 +23,8 @@ import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils +pytestmark = pytest.mark.integration + @pytest.fixture(autouse=True) def test_setup_teardown(): diff --git a/source/isaaclab/test/sim/test_views_xform_prim.py b/source/isaaclab/test/sim/test_views_xform_prim.py index 64cd86a7466..c99593dff50 100644 --- a/source/isaaclab/test/sim/test_views_xform_prim.py +++ b/source/isaaclab/test/sim/test_views_xform_prim.py @@ -32,7 +32,7 @@ from isaaclab.sim.views import UsdFrameView as FrameView # noqa: E402 from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] PARENT_POS = (0.0, 0.0, 1.0) @@ -226,8 +226,10 @@ def test_nested_hierarchy_world_poses(device): frames_view = FrameView("/World/Frame_.*", device=device) targets_view = FrameView("/World/Frame_.*/Target", device=device) - frames_view.set_local_poses(translations=torch.tensor(frame_positions, device=device)) - targets_view.set_local_poses(translations=torch.tensor(target_positions, device=device)) + with frames_view.xform_local_space_writer() as w: + w.set_poses(positions=torch.tensor(frame_positions, device=device)) + with targets_view.xform_local_space_writer() as w: + w.set_poses(positions=torch.tensor(target_positions, device=device)) world_pos = targets_view.get_world_poses()[0].torch expected = torch.tensor( @@ -237,6 +239,67 @@ def test_nested_hierarchy_world_poses(device): torch.testing.assert_close(world_pos, expected, atol=1e-5, rtol=0) +# ================================================================== +# USD-only: Cross-space scale conversion under a scaled parent +# ================================================================== +# +# These exercise the USD-specific world<->local scale math that the shared +# contract suite cannot cover: the contract fixtures only expose a unit-scale +# parent, and Newton has no independent local scale (local == world), so the +# parent-aware conversions below are not universal invariants. OvPhysxFrameView +# inherits this behavior by delegating to UsdFrameView. + + +def _make_scaled_parent_child_view(device, parent_scale, child_scale=None): + """Build a 1-prim view with a scaled parent (and optional authored child scale).""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/Parent_0", "Xform", translation=PARENT_POS, scale=parent_scale, stage=stage) + child_kwargs = {} if child_scale is None else {"scale": child_scale} + sim_utils.create_prim("/World/Parent_0/Child", "Xform", translation=CHILD_OFFSET, stage=stage, **child_kwargs) + return FrameView("/World/Parent_.*/Child", device=device) + + +@pytest.mark.parametrize("device", ["cpu", "cuda"]) +def test_world_scale_composes_with_parent_scale(device): + """Under a scaled parent, ``get_world_scales`` returns ``parent_scale * local_scale``. + + Writes the child's local scale via the local-space writer and verifies + that reading the world scale composes with the parent's scale. + """ + if device == "cuda" and not torch.cuda.is_available(): + pytest.skip("CUDA not available") + + view = _make_scaled_parent_child_view(device, parent_scale=(2.0, 1.0, 1.0)) + local_scales = wp.array([wp.vec3f(3.0, 1.0, 1.0)], dtype=wp.vec3f, device=device) + with view.xform_local_space_writer() as w: + w.set_scales(local_scales) + + world_scales = view.get_world_scales().torch + expected = torch.tensor([[6.0, 1.0, 1.0]], dtype=torch.float32, device=device) + torch.testing.assert_close(world_scales, expected, atol=1e-5, rtol=0) + + +@pytest.mark.parametrize("device", ["cpu", "cuda"]) +def test_local_scale_inverts_parent_when_writing_world_scale(device): + """Writing a world scale derives ``local = world / parent_scale`` under a scaled parent. + + Writes the child's world scale via the world-space writer and verifies + that the derived local scale is the world scale divided by the + parent's scale. + """ + if device == "cuda" and not torch.cuda.is_available(): + pytest.skip("CUDA not available") + + view = _make_scaled_parent_child_view(device, parent_scale=(2.0, 1.0, 1.0)) + world_scales = wp.array([wp.vec3f(6.0, 1.0, 1.0)], dtype=wp.vec3f, device=device) + with view.xform_world_space_writer() as w: + w.set_scales(world_scales) + + local_scales = view.get_local_scales().torch + expected = torch.tensor([[3.0, 1.0, 1.0]], dtype=torch.float32, device=device) + torch.testing.assert_close(local_scales, expected, atol=1e-5, rtol=0) + + # ================================================================== # USD-only: Comparison with Isaac Sim # ================================================================== @@ -291,7 +354,8 @@ def test_with_franka_robots(device): new_pos = torch.tensor([[10.0, 10.0, 0.0], [-40.0, -40.0, 0.0]], device=device) new_quat = torch.tensor([[0.0, 0.0, 0.7071068, 0.7071068], [0.0, 0.0, -0.7071068, 0.7071068]], device=device) - view.set_world_poses(positions=new_pos, orientations=new_quat) + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos, orientations=new_quat) ret_pos = view.get_world_poses()[0].torch torch.testing.assert_close(ret_pos, new_pos, atol=1e-5, rtol=0) diff --git a/source/isaaclab/test/terrains/check_terrain_importer.py b/source/isaaclab/test/terrains/check_terrain_importer.py index a8229023f90..901cfc420be 100644 --- a/source/isaaclab/test/terrains/check_terrain_importer.py +++ b/source/isaaclab/test/terrains/check_terrain_importer.py @@ -159,7 +159,8 @@ def main(): ball_initial_positions = terrain_importer.env_origins.clone() ball_initial_positions[:, 2] += 5.0 # set initial poses (writes to USD before simulation) - xform_view.set_world_poses(positions=ball_initial_positions) + with xform_view.xform_world_space_writer() as w: + w.set_poses(positions=ball_initial_positions) # Play simulator sim.reset() diff --git a/source/isaaclab/test/terrains/test_terrain_generator.py b/source/isaaclab/test/terrains/test_terrain_generator.py index 56bee0b2546..476f5efaf74 100644 --- a/source/isaaclab/test/terrains/test_terrain_generator.py +++ b/source/isaaclab/test/terrains/test_terrain_generator.py @@ -14,6 +14,8 @@ from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG from isaaclab.utils.seed import configure_seed +pytestmark = pytest.mark.integration + @pytest.fixture def output_dir(): diff --git a/source/isaaclab/test/terrains/test_terrain_importer.py b/source/isaaclab/test/terrains/test_terrain_importer.py index 5234df4cae5..553f01f7923 100644 --- a/source/isaaclab/test/terrains/test_terrain_importer.py +++ b/source/isaaclab/test/terrains/test_terrain_importer.py @@ -30,6 +30,8 @@ from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("env_spacing", [1.0, 4.325, 8.0]) @@ -316,4 +318,5 @@ def _populate_scene(sim: SimulationContext, num_balls: int = 2048, geom_sphere: ball_initial_positions[:, 2] += 5.0 # set initial poses # note: setting here writes to USD :) - ball_view.set_world_poses(positions=wp.from_torch(ball_initial_positions)) + with ball_view.xform_world_space_writer() as w: + w.set_poses(positions=wp.from_torch(ball_initial_positions)) diff --git a/source/isaaclab/test/test_mock_interfaces/test_mock_assets.py b/source/isaaclab/test/test_mock_interfaces/test_mock_assets.py index a10fe5150ff..8ea17779950 100644 --- a/source/isaaclab/test/test_mock_interfaces/test_mock_assets.py +++ b/source/isaaclab/test/test_mock_interfaces/test_mock_assets.py @@ -20,6 +20,8 @@ ) from isaaclab.test.mock_interfaces.utils import MockArticulationBuilder +pytestmark = pytest.mark.unit + # ============================================================================== # MockArticulation Tests # ============================================================================== diff --git a/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py b/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py index b68bb8141e1..cb151f11ccf 100644 --- a/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py +++ b/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py @@ -20,6 +20,8 @@ MockPvaData, ) +pytestmark = pytest.mark.unit + # ============================================================================== # IMU Data Property Tests # ============================================================================== diff --git a/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py b/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py index be69316438f..5ffe9ef7211 100644 --- a/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py +++ b/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py @@ -21,6 +21,8 @@ ) from isaaclab.test.mock_interfaces.utils import MockSensorBuilder +pytestmark = pytest.mark.unit + # ============================================================================== # MockImu Tests # ============================================================================== 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 00000000000..098de86e5ed --- /dev/null +++ b/source/isaaclab/test/test_reinforcement_learning_common.py @@ -0,0 +1,237 @@ +# 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") +dispatch_library_entrypoint: Any = getattr(_rl_common, "dispatch_library_entrypoint") +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 + + +def test_dispatch_library_entrypoint_shows_help_without_library( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The unified dispatcher shows its help before requiring a library selection.""" + result = dispatch_library_entrypoint( + ["--help"], + {"rsl_rl": tmp_path / "bench_rsl_rl.py"}, + action="bench", + description="Benchmark training.", + library_help="Training library to benchmark.", + ) + + assert result == 0 + output = capsys.readouterr().out + assert "--rl_library {rsl_rl}" in output diff --git a/source/isaaclab/test/utils/test_assets.py b/source/isaaclab/test/utils/test_assets.py index 54120215132..90965eb92c0 100644 --- a/source/isaaclab/test/utils/test_assets.py +++ b/source/isaaclab/test/utils/test_assets.py @@ -13,6 +13,8 @@ import isaaclab.utils.assets as assets_utils +pytestmark = pytest.mark.unit + def test_nucleus_connection(): """Test checking the Nucleus connection.""" diff --git a/source/isaaclab/test/utils/test_circular_buffer.py b/source/isaaclab/test/utils/test_circular_buffer.py index 132a86bd664..805acc2c7a5 100644 --- a/source/isaaclab/test/utils/test_circular_buffer.py +++ b/source/isaaclab/test/utils/test_circular_buffer.py @@ -8,6 +8,8 @@ from isaaclab.utils import CircularBuffer +pytestmark = pytest.mark.unit + @pytest.fixture def circular_buffer(): diff --git a/source/isaaclab/test/utils/test_configclass.py b/source/isaaclab/test/utils/test_configclass.py index 1c2f13c1ef1..5916c1267c1 100644 --- a/source/isaaclab/test/utils/test_configclass.py +++ b/source/isaaclab/test/utils/test_configclass.py @@ -20,6 +20,8 @@ from isaaclab.utils.io import dump_yaml, load_yaml from isaaclab.utils.string import ResolvableString +pytestmark = pytest.mark.unit + """ Mock classes and functions. """ @@ -173,6 +175,27 @@ class InheritedNonTypeAnnotationOrderingDemoCfg(NonTypeAnnotationOrderingDemoCfg pass +@configclass +class MixedAnnotationOrderingDemoCfg: + """Config class with type annotations on only some attributes.""" + + plane = RobotDefaultStateCfg() + robot = RobotDefaultStateCfg() + peg: RobotDefaultStateCfg = RobotDefaultStateCfg() + hole: RobotDefaultStateCfg = RobotDefaultStateCfg() + camera = RobotDefaultStateCfg() + light = RobotDefaultStateCfg() + + +@configclass +class InheritedMixedAnnotationOrderingDemoCfg(MixedAnnotationOrderingDemoCfg): + """Inherited config class with type annotations on only some attributes.""" + + table = RobotDefaultStateCfg() + sensor: RobotDefaultStateCfg = RobotDefaultStateCfg() + marker = RobotDefaultStateCfg() + + """ Dummy configuration: Inheritance """ @@ -818,6 +841,26 @@ def test_configclass_type_ordering(): assert list(cfg_1.__dict__.keys()) == list(cfg_3.__dict__.keys()) +def test_configclass_mixed_type_annotations_ordering(): + """Checks that declaration order is preserved when only some attributes have type annotations. + + Reference: https://github.com/isaac-sim/IsaacLab/issues/1949 + """ + cfg = MixedAnnotationOrderingDemoCfg() + expected_order = ["plane", "robot", "peg", "hole", "camera", "light"] + + # check ordering of attributes and dictionary conversion + assert list(cfg.__dict__.keys()) == expected_order + assert list(cfg.to_dict().keys()) == expected_order + + # check ordering with inheritance: parent fields first, then child fields in declaration order + cfg_inherited = InheritedMixedAnnotationOrderingDemoCfg() + expected_inherited_order = expected_order + ["table", "sensor", "marker"] + + assert list(cfg_inherited.__dict__.keys()) == expected_inherited_order + assert list(cfg_inherited.to_dict().keys()) == expected_inherited_order + + def test_functions_config(): """Tests having functions as values in the configuration instance.""" cfg = FunctionsDemoCfg() diff --git a/source/isaaclab/test/utils/test_delay_buffer.py b/source/isaaclab/test/utils/test_delay_buffer.py index fce156b4a4c..354281c07a2 100644 --- a/source/isaaclab/test/utils/test_delay_buffer.py +++ b/source/isaaclab/test/utils/test_delay_buffer.py @@ -10,6 +10,8 @@ from isaaclab.utils import DelayBuffer +pytestmark = pytest.mark.unit + @pytest.fixture def delay_buffer(): diff --git a/source/isaaclab/test/utils/test_device.py b/source/isaaclab/test/utils/test_device.py new file mode 100644 index 00000000000..7bfeacec133 --- /dev/null +++ b/source/isaaclab/test/utils/test_device.py @@ -0,0 +1,37 @@ +# Copyright (c) 2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for compute-device selection utilities.""" + +import sys +from types import SimpleNamespace + +from isaaclab.utils._device import set_cuda_device + + +def test_set_cuda_device_sets_torch_before_warp(monkeypatch): + """Setting a CUDA device must update PyTorch before Warp.""" + calls = [] + torch = SimpleNamespace(cuda=SimpleNamespace(set_device=lambda device: calls.append(("torch", device)))) + warp = SimpleNamespace(set_device=lambda device: calls.append(("warp", device))) + monkeypatch.setitem(sys.modules, "torch", torch) + monkeypatch.setitem(sys.modules, "warp", warp) + + set_cuda_device("cuda:2") + + assert calls == [("torch", "cuda:2"), ("warp", "cuda:2")] + + +def test_set_cuda_device_normalizes_integer_for_warp(monkeypatch): + """An integer device index must be converted to a Warp CUDA alias.""" + calls = [] + torch = SimpleNamespace(cuda=SimpleNamespace(set_device=lambda device: calls.append(("torch", device)))) + warp = SimpleNamespace(set_device=lambda device: calls.append(("warp", device))) + monkeypatch.setitem(sys.modules, "torch", torch) + monkeypatch.setitem(sys.modules, "warp", warp) + + set_cuda_device(3) + + assert calls == [("torch", 3), ("warp", "cuda:3")] diff --git a/source/isaaclab/test/utils/test_dict.py b/source/isaaclab/test/utils/test_dict.py index b2cbd8bb0e6..7546736497f 100644 --- a/source/isaaclab/test/utils/test_dict.py +++ b/source/isaaclab/test/utils/test_dict.py @@ -10,6 +10,8 @@ import isaaclab.utils.dict as dict_utils import isaaclab.utils.string as string_utils +pytestmark = pytest.mark.unit + def _test_function(x): """Test function for string <-> callable conversion.""" diff --git a/source/isaaclab/test/utils/test_episode_data.py b/source/isaaclab/test/utils/test_episode_data.py index a2d570d9d6e..c767521ceb8 100644 --- a/source/isaaclab/test/utils/test_episode_data.py +++ b/source/isaaclab/test/utils/test_episode_data.py @@ -7,6 +7,8 @@ from isaaclab.utils.datasets import EpisodeData +pytestmark = pytest.mark.unit + @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_is_empty(device): diff --git a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py index 11e8a434b1a..f28b9277d81 100644 --- a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py +++ b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py @@ -12,6 +12,8 @@ from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler +pytestmark = pytest.mark.unit + def create_test_episode(device): """create a test episode with dummy data.""" diff --git a/source/isaaclab/test/utils/test_images.py b/source/isaaclab/test/utils/test_images.py index 92bb3e7de9d..c8a2ff3781e 100644 --- a/source/isaaclab/test/utils/test_images.py +++ b/source/isaaclab/test/utils/test_images.py @@ -11,6 +11,8 @@ import torch import warp as wp +pytestmark = pytest.mark.unit + wp.config.quiet = True wp.init() @@ -180,3 +182,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/test/utils/test_logger.py b/source/isaaclab/test/utils/test_logger.py index 4a6d30135d9..8bfad15df0a 100644 --- a/source/isaaclab/test/utils/test_logger.py +++ b/source/isaaclab/test/utils/test_logger.py @@ -15,6 +15,8 @@ from isaaclab.utils.logger import ColoredFormatter, RateLimitFilter, configure_logging +pytestmark = pytest.mark.unit + # Fixtures @pytest.fixture diff --git a/source/isaaclab/test/utils/test_math.py b/source/isaaclab/test/utils/test_math.py index c2e5b355081..90bba67cbda 100644 --- a/source/isaaclab/test/utils/test_math.py +++ b/source/isaaclab/test/utils/test_math.py @@ -14,6 +14,8 @@ import isaaclab.utils.math as math_utils +pytestmark = pytest.mark.unit + DECIMAL_PRECISION = 5 """Precision of the test. diff --git a/source/isaaclab/test/utils/test_modifiers.py b/source/isaaclab/test/utils/test_modifiers.py index 6e0e39820fd..a4f5212a9fd 100644 --- a/source/isaaclab/test/utils/test_modifiers.py +++ b/source/isaaclab/test/utils/test_modifiers.py @@ -11,6 +11,8 @@ import isaaclab.utils.modifiers as modifiers from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.unit + @configclass class ModifierTestCfg: diff --git a/source/isaaclab/test/utils/test_noise.py b/source/isaaclab/test/utils/test_noise.py index 45f9d2d6138..2d9b836cd42 100644 --- a/source/isaaclab/test/utils/test_noise.py +++ b/source/isaaclab/test/utils/test_noise.py @@ -8,6 +8,8 @@ import isaaclab.utils.noise as noise +pytestmark = pytest.mark.unit + @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) @pytest.mark.parametrize("noise_device", ["cpu", "cuda:0"]) diff --git a/source/isaaclab/test/utils/test_string.py b/source/isaaclab/test/utils/test_string.py index d946f178fdf..1137621e7db 100644 --- a/source/isaaclab/test/utils/test_string.py +++ b/source/isaaclab/test/utils/test_string.py @@ -11,6 +11,8 @@ import isaaclab.utils.string as string_utils from isaaclab.utils.string import _resolve_matching_names_impl +pytestmark = pytest.mark.unit + def test_resolvable_string_metadata_is_non_eager(): """Test metadata access on ResolvableString without triggering import/resolve.""" diff --git a/source/isaaclab/test/utils/test_timer.py b/source/isaaclab/test/utils/test_timer.py index 9337b201a67..f2748a3fdc1 100644 --- a/source/isaaclab/test/utils/test_timer.py +++ b/source/isaaclab/test/utils/test_timer.py @@ -12,6 +12,8 @@ from isaaclab.utils.timer import Timer, TimerError +pytestmark = pytest.mark.unit + # number of decimal places to check PRECISION_PLACES = 2 diff --git a/source/isaaclab/test/utils/test_version.py b/source/isaaclab/test/utils/test_version.py index ba737b53643..b692ce20719 100644 --- a/source/isaaclab/test/utils/test_version.py +++ b/source/isaaclab/test/utils/test_version.py @@ -19,6 +19,8 @@ from isaaclab.utils.version import compare_versions, get_isaac_sim_version +pytestmark = pytest.mark.integration + def test_get_isaac_sim_version(): """Test that get_isaac_sim_version returns cached Version object.""" diff --git a/source/isaaclab/test/utils/test_wrench_composer.py b/source/isaaclab/test/utils/test_wrench_composer.py index b711aaab44a..1d3da72b7c7 100644 --- a/source/isaaclab/test/utils/test_wrench_composer.py +++ b/source/isaaclab/test/utils/test_wrench_composer.py @@ -11,6 +11,8 @@ from isaaclab.test.mock_interfaces.assets import MockRigidObjectCollection from isaaclab.utils.wrench_composer import WrenchComposer +pytestmark = pytest.mark.unit + def create_mock_asset( num_envs: int, diff --git a/source/isaaclab/test/utils/test_wrench_composer_integration.py b/source/isaaclab/test/utils/test_wrench_composer_integration.py index acb1682bde0..49bf1782fbb 100644 --- a/source/isaaclab/test/utils/test_wrench_composer_integration.py +++ b/source/isaaclab/test/utils/test_wrench_composer_integration.py @@ -26,6 +26,8 @@ from isaaclab.sim import build_simulation_context from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +pytestmark = pytest.mark.integration + def generate_cubes_scene( num_cubes: int = 1, diff --git a/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py index ca8f22be437..122f80021e1 100644 --- a/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py +++ b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py @@ -31,6 +31,8 @@ from isaaclab.sim import build_simulation_context from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +pytestmark = pytest.mark.integration + def generate_dual_cube_scene( num_cubes: int = 1, diff --git a/source/isaaclab/test/utils/warp/test_image_ops.py b/source/isaaclab/test/utils/warp/test_image_ops.py index 2893b38d62c..377fab96066 100644 --- a/source/isaaclab/test/utils/warp/test_image_ops.py +++ b/source/isaaclab/test/utils/warp/test_image_ops.py @@ -11,6 +11,8 @@ import torch import warp as wp +pytestmark = pytest.mark.unit + wp.config.quiet = True wp.init() diff --git a/source/isaaclab/test/utils/warp/test_particle_mesh.py b/source/isaaclab/test/utils/warp/test_particle_mesh.py index 8ad7442cb66..8a79a35180f 100644 --- a/source/isaaclab/test/utils/warp/test_particle_mesh.py +++ b/source/isaaclab/test/utils/warp/test_particle_mesh.py @@ -13,6 +13,8 @@ from isaaclab.utils.warp import ParticleMeshCounter, make_box_region_mesh, make_frustum_region_mesh +pytestmark = pytest.mark.unit + @pytest.fixture(params=["cpu", "cuda:0"]) def device(request): diff --git a/source/isaaclab/test/utils/warp/test_proxy_array.py b/source/isaaclab/test/utils/warp/test_proxy_array.py index 08bdd4bb916..8ea8dddf2a9 100644 --- a/source/isaaclab/test/utils/warp/test_proxy_array.py +++ b/source/isaaclab/test/utils/warp/test_proxy_array.py @@ -11,6 +11,8 @@ import torch import warp as wp +pytestmark = pytest.mark.unit + wp.config.quiet = True wp.init() diff --git a/source/isaaclab/test/visualizers/test_visualizer.py b/source/isaaclab/test/visualizers/test_visualizer.py index 4f5cc9dbca4..5a25cdc3b00 100644 --- a/source/isaaclab/test/visualizers/test_visualizer.py +++ b/source/isaaclab/test/visualizers/test_visualizer.py @@ -18,6 +18,8 @@ from isaaclab.visualizers.visualizer import Visualizer from isaaclab.visualizers.visualizer_cfg import VisualizerCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # # Config factory # diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index 055e3a5ff2f..ea533ef3419 100644 --- a/source/isaaclab_assets/config/extension.toml +++ b/source/isaaclab_assets/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.3.4" +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 e9eda582221..04cc3850c7b 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,37 @@ 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) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :obj:`~isaaclab_assets.robots.so101.SO101_CFG` and + :obj:`~isaaclab_assets.robots.so101.SO101_HIGH_PD_CFG` configurations for the + TheRobotStudio SO-101 5-DOF follower arm. + + 0.3.4 (2026-05-12) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/isaaclab_assets/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/__init__.pyi index dc69e0b8c71..84f2c12f68e 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 1a91afa213a..246bfc05fb0 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", @@ -36,6 +37,8 @@ __all__ = [ "RIDGEBACK_FRANKA_PANDA_CFG", "SAWYER_CFG", "SHADOW_HAND_CFG", + "SO101_CFG", + "SO101_HIGH_PD_CFG", "joint_parameter_lookup", "SPOT_CFG", "GO1_ACTUATOR_CFG", @@ -75,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 @@ -85,6 +89,7 @@ from .quadcopter import CRAZYFLIE_CFG from .ridgeback_franka import RIDGEBACK_FRANKA_PANDA_CFG from .sawyer import SAWYER_CFG from .shadow_hand import SHADOW_HAND_CFG +from .so101 import SO101_CFG, SO101_HIGH_PD_CFG from .spot import joint_parameter_lookup, SPOT_CFG from .unitree import ( GO1_ACTUATOR_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 00000000000..5fd8d3acbe5 --- /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 00000000000..a6b3b266f24 --- /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/isaaclab_assets/robots/so101.py b/source/isaaclab_assets/isaaclab_assets/robots/so101.py new file mode 100644 index 00000000000..aba5a180a33 --- /dev/null +++ b/source/isaaclab_assets/isaaclab_assets/robots/so101.py @@ -0,0 +1,86 @@ +# 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 TheRobotStudio SO-101 follower arm. + +The following configurations are available: + +* :obj:`SO101_CFG`: SO-101 5-DOF arm with a single-jaw gripper. +* :obj:`SO101_HIGH_PD_CFG`: SO-101 with a stiffer PD controller for task-space (IK) tracking. + +The SO-101 is a low-cost 5-DOF arm (``shoulder_pan``, ``shoulder_lift``, ``elbow_flex``, +``wrist_flex``, ``wrist_roll``) plus a single revolute ``gripper`` jaw. Because the arm has +only 5 actuated DOF, it cannot achieve an arbitrary 6-DOF end-effector pose; task-space +controllers should command the full pose but soft-weight the orientation rows so position is +tracked exactly and orientation is best-effort (see the cube-stack IK-Abs task). + +Reference: https://github.com/TheRobotStudio/SO-ARM100 +Actuator gains follow the values tuned for simulation in LeIsaac. +""" + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets.articulation import ArticulationCfg +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + +## +# Configuration +## + +SO101_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/RobotStudio/so101_new_calib/so101_new_calib.usd", + activate_contact_sensors=False, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=5.0, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=True, + solver_position_iteration_count=8, + solver_velocity_iteration_count=0, + fix_root_link=True, + ), + ), + init_state=ArticulationCfg.InitialStateCfg( + joint_pos={ + "shoulder_pan": 0.0, + "shoulder_lift": 0.0, + "elbow_flex": 0.0, + "wrist_flex": 0.0, + "wrist_roll": 0.0, + "gripper": 0.0, + }, + ), + actuators={ + "arm": ImplicitActuatorCfg( + joint_names_expr=["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"], + effort_limit_sim=10.0, + velocity_limit_sim=10.0, + stiffness=17.8, + damping=0.60, + ), + "gripper": ImplicitActuatorCfg( + joint_names_expr=["gripper"], + effort_limit_sim=10.0, + velocity_limit_sim=10.0, + stiffness=17.8, + damping=0.60, + ), + }, + soft_joint_pos_limit_factor=1.0, +) +"""Configuration of the SO-101 follower arm with implicit actuators.""" + + +SO101_HIGH_PD_CFG = SO101_CFG.copy() +SO101_HIGH_PD_CFG.spawn.rigid_props.disable_gravity = True +SO101_HIGH_PD_CFG.actuators["arm"].stiffness = 400.0 +SO101_HIGH_PD_CFG.actuators["arm"].damping = 80.0 +"""Configuration of the SO-101 follower arm with stiffer PD control. + +This configuration is useful for task-space control using differential IK, where the +default low-stiffness gains track end-effector targets poorly. +""" diff --git a/source/isaaclab_assets/pyproject.toml b/source/isaaclab_assets/pyproject.toml index cce714d643d..c212565d63a 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.3.4" +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/config/extension.toml b/source/isaaclab_contrib/config/extension.toml index 63cf890ace8..733f344c3de 100644 --- a/source/isaaclab_contrib/config/extension.toml +++ b/source/isaaclab_contrib/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.4.5" +version = "0.4.6" # Description title = "Isaac Lab External Contributions" diff --git a/source/isaaclab_contrib/docs/CHANGELOG.rst b/source/isaaclab_contrib/docs/CHANGELOG.rst index 639fff17f6e..9ddd1a6d493 100644 --- a/source/isaaclab_contrib/docs/CHANGELOG.rst +++ b/source/isaaclab_contrib/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.4.6 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed two-way rigid-deformable contact reactions with Newton shape margins. + + 0.4.5 (2026-06-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py index b47371b4ec6..d12ac49a241 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py @@ -472,6 +472,7 @@ def _apply_reactions(cls, state: State, state_prev: State, dt: float) -> None: model.body_com, model.shape_body, model.shape_material_mu, + model.shape_margin, float(model.soft_contact_ke), float(model.soft_contact_kd), float(model.soft_contact_mu), diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py index 8faf468dee8..e42166acfbb 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py @@ -415,6 +415,7 @@ def _apply_reactions(cls, state: State, state_prev: State, dt: float) -> None: model.body_com, model.shape_body, model.shape_material_mu, + model.shape_margin, float(model.soft_contact_ke), float(model.soft_contact_kd), float(model.soft_contact_mu), diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py index 179f62e5336..54aaf2e57b5 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py @@ -300,6 +300,7 @@ def _kernel_body_particle_reaction( body_com: wp.array(dtype=wp.vec3), shape_body: wp.array(dtype=wp.int32), shape_material_mu: wp.array(dtype=wp.float32), + shape_margin: wp.array(dtype=wp.float32), soft_contact_ke: float, soft_contact_kd: float, soft_contact_mu: float, @@ -359,6 +360,7 @@ def _kernel_body_particle_reaction( contact_body_pos, contact_body_vel, contact_normal, + shape_margin, dt, ) diff --git a/source/isaaclab_contrib/pyproject.toml b/source/isaaclab_contrib/pyproject.toml index 00dc87b8d4b..7aa1c3deb42 100644 --- a/source/isaaclab_contrib/pyproject.toml +++ b/source/isaaclab_contrib/pyproject.toml @@ -9,33 +9,20 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_contrib" -version = "0.4.5" +version = "0.4.6" description = "An extension used to stage and integrate externally contributed features and implementations." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["kit", "robotics", "assets", "isaaclab"] requires-python = ">=3.12" +# Third-party requirements are centralized in the root pyproject.toml. dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" -[project.optional-dependencies] -rlinf = [ - "ray[default]>=2.47.0", - "av>=12.3.0", - "numpydantic>=1.7.0", - "albumentations>=1.4.18", - "decord2", - "dm_tree>=0.1.8", - "diffusers>=0.35.0", - "timm>=1.0.14", - "peft>=0.17.0", - "pandas", -] - [tool.setuptools] include-package-data = true 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 331e0dfc288..d9c6cbc67df 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")], @@ -247,9 +248,9 @@ def test_smoke_featherstone_kinematic(sim): assert free_cube.data.root_pos_w.torch[0, 2].item() < initial_z_free - 0.01 -def _run_lateral_rigid_cube_response(coupling_mode: str) -> float: +def _run_lateral_rigid_cube_response(coupling_mode: str, rigid_solver: str) -> float: """Run a compact lateral contact scene and return rigid cube X displacement.""" - with _coupled_sim_context(_make_coupled_cfg(coupling_mode)) as sim: + with _coupled_sim_context(_make_coupled_cfg(coupling_mode, rigid_solver)) as sim: sim._app_control_on_stop_handle = None rigid_cube, deformable_cube = generate_lateral_rigid_and_deformable_cubes() sim.reset() @@ -267,10 +268,11 @@ def _run_lateral_rigid_cube_response(coupling_mode: str) -> float: return rigid_cube.data.root_pos_w.torch[0, 0].item() - initial_rigid_x -def test_two_way_coupling_applies_reaction_to_rigid_body(): +@pytest.mark.parametrize("rigid_solver", ["mjwarp", "featherstone"]) +def test_two_way_coupling_applies_reaction_to_rigid_body(rigid_solver: str): """Test that two-way coupling laterally pushes a rigid body.""" - one_way_dx = _run_lateral_rigid_cube_response("one_way") - two_way_dx = _run_lateral_rigid_cube_response("two_way") + one_way_dx = _run_lateral_rigid_cube_response("one_way", rigid_solver) + two_way_dx = _run_lateral_rigid_cube_response("two_way", rigid_solver) assert abs(one_way_dx) < 1e-2 assert two_way_dx > one_way_dx + 1e-2 diff --git a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py index 4ba068fc8f5..00a29bc04af 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py @@ -126,7 +126,8 @@ def set_pose(self, pose: torch.Tensor): xform_prim = self._get_xform_view() position = pose[..., :3] orientation = pose[..., 3:] - xform_prim.set_world_poses(wp.from_torch(position.contiguous()), wp.from_torch(orientation.contiguous()), None) + with xform_prim.xform_world_space_writer() as writer: + writer.set_poses(wp.from_torch(position.contiguous()), wp.from_torch(orientation.contiguous()), None) class RelativePose(HasPose): diff --git a/source/isaaclab_mimic/pyproject.toml b/source/isaaclab_mimic/pyproject.toml index 6cefbe49ad4..b1384927c36 100644 --- a/source/isaaclab_mimic/pyproject.toml +++ b/source/isaaclab_mimic/pyproject.toml @@ -16,14 +16,9 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["extension", "template", "isaaclab"] requires-python = ">=3.12" -dependencies = [ - # jupyter notebook - "ipywidgets>=8.1.5", - # data collection - "h5py>=3.15.0", - # data augmentation (Linux only) - "robomimic @ git+https://github.com/ARISE-Initiative/robomimic.git@v0.4.0 ; sys_platform == 'linux'", -] +# Third-party requirements are centralized in the root pyproject.toml +# (installed via the ``mimic`` extra). +dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" diff --git a/source/isaaclab_newton/changelog.d/jmart-cartpole-rtx.minor.rst b/source/isaaclab_newton/changelog.d/jmart-cartpole-rtx.minor.rst deleted file mode 100644 index fc7a261e769..00000000000 --- a/source/isaaclab_newton/changelog.d/jmart-cartpole-rtx.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Overrode :meth:`provides_implicit_damping` on :class:`NewtonManager` to return ``False`` (its - symplectic integrator has no implicit damping) and :meth:`provides_temporal_camera_data` on - :class:`NewtonWarpRenderer` to return ``False`` (the rasterizer accumulates no temporal data), - so camera tasks can auto-enable frame stacking for the Newton combos that need it. diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst new file mode 100644 index 00000000000..85c12eb716b --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.NewtonArticulationCfg`, the ``newton:*`` + single-namespace articulation-root fragment (``newton:selfCollisionEnabled`` via + ``NewtonArticulationRootAPI``). It composes with + :class:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg` in an ``articulation_props`` fragment + list applied via :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index e634a251cfc..da8f9bdb5b3 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.0.4" +version = "1.7.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 1e92f80043e..7721bd9cbf3 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,272 @@ Changelog --------- +1.7.0 (2026-07-10) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added Newton BVH construction settings to :class:`~isaaclab_newton.physics.NewtonCfg` + and render traversal/tile settings to + :class:`~isaaclab_newton.renderers.NewtonWarpRendererCfg`. + +Fixed +^^^^^ + +* Fixed the initial actuator gain snapshot used by + :func:`~isaaclab.envs.mdp.events.randomize_actuator_gains` corrupting (or + crashing) for multi-environment floating-base articulations with Newton + actuators. The per-environment stride of the actuator DOF indices was + decoded with the articulation-local joint count instead of the whole + model's per-environment DOF count, so on a floating base the free-root DOFs + shifted every environment past the first to the wrong (or out-of-bounds) + snapshot rows, corrupting the ``stiffness`` / ``damping`` randomization + baseline. +* Fixed quadratic (``O(num_envs^2)``) startup scaling in + :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` when a frame resolves to a + per-environment body path (e.g. a body-mounted camera). Replicated body patterns are now + resolved against Newton body labels through an exact lookup instead of a full regex scan per + environment, reducing simulation-start time for camera-heavy scenes at high environment counts + (8192 environments dropped from ~29 min to seconds). + + +1.6.2 (2026-07-09) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Added :meth:`~isaaclab_newton.physics.NewtonManager._reset_solver_internals` + hook that clears per-world solver-internal scratch buffers before the + accumulated reset masks are consumed by + :meth:`~isaaclab_newton.physics.NewtonManager.step` or + :meth:`~isaaclab_newton.physics.NewtonManager.forward`. The default + implementation forwards to :meth:`SolverBase.reset` with ``flags=0``, + preserving the authored joint state — a no-op for solvers that do not + implement ``reset()``, and automatic coverage for any solver that does. + :class:`~isaaclab_newton.physics.NewtonMJWarpManager` specializes it to + gate the non-mask-aware CPU-MuJoCo path; + :class:`~isaaclab_newton.physics.NewtonKaminoManager` opts out because its + forward-kinematics delegate already routes through + :meth:`SolverKamino.reset`. + +Fixed +^^^^^ + +* Fixed NaN values in MJWarp solver-internal buffers (``qacc_warmstart``, + ``qfrc_applied``, ``xfrc_applied``, ``ctrl``, ``act``) persisting across + env reset and re-diverging on the next solve. + :class:`~isaaclab_newton.physics.NewtonMJWarpManager` now calls + :meth:`SolverMuJoCo.reset` with the accumulated per-world reset mask + whenever the reset masks are consumed (at the top of + :meth:`~isaaclab_newton.physics.NewtonManager.step` and in + :meth:`~isaaclab_newton.physics.NewtonManager.forward`), so a world that + produces a NaN can recover after :meth:`~isaaclab.envs.ManagerBasedEnv.reset`. + See https://github.com/newton-physics/newton/issues/1266 for the upstream + discussion. + + +1.6.1 (2026-07-08) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_local_scales` + and :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_world_scales` + for reading transform (xform) scales. Scale writes go through the writer + scope (see the ``xform-space-writer`` fragment). These transform-scale + APIs are intentionally separate from Newton collision shape geometry + sizes. +* Added the ``newton-usd-schemas`` dependency, required by Newton's USD parsing + since the new pin. + +Changed +^^^^^^^ + +* :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` now ships + pass-through ``FrameViewWorldSpaceWriter`` / ``FrameViewLocalSpaceWriter`` + implementations so writes follow the new + :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / + :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer` context API. + ``set_world_poses`` / ``set_local_poses`` shims still work (one-time + ``DeprecationWarning`` per class). The legacy ``set_scales`` / + ``get_scales`` paths continue to operate on Newton collision-shape + geometry sizes -- they are not routed through the writer because the + writer's ``set_scales`` writes the transform-scale state. +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``c7ae7c7648cd0717df39e5c94b95d5a02c997320``, which includes the experimental + coupled solver framework. + +Deprecated +^^^^^^^^^^ + +* Deprecated :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_scales` + and :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.set_scales` in favor + of the explicit transform-scale getters ``get_world_scales`` / + ``get_local_scales`` (and the writer scope's ``set_scales``). The + deprecated methods still work but emit a ``DeprecationWarning`` and + preserve Newton's legacy collision shape geometry-scale behavior. + +Fixed +^^^^^ + +* Fixed the cloner label renaming after Newton's removal of + ``ModelBuilder.equality_constraint_label`` by dropping the equality + constraint fallback; equality constraint labels are renamed through the + generic custom attribute handling. +* Fixed Newton physics failing to initialize on non-default CUDA devices + (``cuda:1`` and higher). + + +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) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton + separately should use this commit with ``warp-lang==1.15.0.dev20260626``. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.physics.NewtonKaminoManager` reset + compatibility with Newton's ``SolverKamino.ResetConfig`` API. + + +1.5.0 (2026-06-28) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the Newton mesh-collision cooking fragments: + :class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionCfg` (``newton:maxHullVertices`` via + ``NewtonMeshCollisionAPI``) and :class:`~isaaclab_newton.sim.schemas.NewtonSDFCollisionCfg` + (Newton SDF generation and hydroelastic-contact attributes via ``NewtonSDFCollisionAPI``). Each is + a single-namespace :class:`~isaaclab.sim.schemas.MeshCollisionFragment` dispatched via + :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. +* Added the :class:`~isaaclab_newton.sim.schemas.MujocoJointCfg` joint-drive fragment + (``mjc:*`` / ``MjcJointAPI``), carrying joint-level ``actuatorgravcomp``. Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. The from-files spawn site continues + to auto-enable body-level gravcomp for the fragment path. + + +1.4.0 (2026-06-27) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg` and its applier + :func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon` for tuning ``mjc:*`` + fixed-tendon attributes on ``MjcTendon`` prims, + splitting the Mujoco tune path out of the PhysX fixed-tendon applier. + + +1.3.0 (2026-06-26) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.NewtonCollisionCfg`, the ``newton:*`` + single-namespace collision fragment (``newton:contactMargin``, ``newton:contactGap`` via + ``NewtonCollisionAPI``). It composes with + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxCollisionCfg` in a ``collision_props`` fragment list. + + +1.2.0 (2026-06-25) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.MujocoRigidBodyCfg`, the ``mjc:*`` single-namespace + rigid-body fragment (``mjc:gravcomp``) for Newton's MuJoCo solver. It composes with + :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyCfg` in a ``rigid_props`` fragment list. + + +1.1.0 (2026-06-24) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Overrode :meth:`provides_implicit_damping` on :class:`NewtonManager` to return ``False`` (its + symplectic integrator has no implicit damping) and :meth:`provides_temporal_camera_data` on + :class:`NewtonWarpRenderer` to return ``False`` (the rasterizer accumulates no temporal data), + so camera tasks can auto-enable frame stacking for the Newton combos that need it. + +Fixed +^^^^^ + +* Fixed the ``newton[sim]`` dependency pin to use Newton commit + ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. + + 1.0.4 (2026-06-23) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py b/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py index 0d28cf576df..da1de70d408 100644 --- a/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py +++ b/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py @@ -237,6 +237,7 @@ def build_newton_actuator_defaults( num_envs: int, num_joints: int, dof_offset: int, + env_stride: int, device: str, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | slice]: """Snapshot the initial kp/kd of every Newton actuator owned by one articulation. @@ -255,6 +256,12 @@ def build_newton_actuator_defaults( num_joints: Articulation-local joint count. dof_offset: Offset of this articulation's DOFs in the env-major global index space (``0`` on PhysX, view-dependent on Newton). + env_stride: Whole-model per-env DOF count — the stride used to build + each actuator's env-major ``indices``. Equals ``num_joints`` on + PhysX, but exceeds it by the free-root DOFs on a floating-base + Newton articulation, so it must be passed explicitly rather than + assumed equal to ``num_joints``. The owning adapter's + :attr:`NewtonActuatorAdapter.num_joints` is exactly this value. device: Warp device string (e.g. ``"cuda:0"``). Returns: @@ -290,14 +297,14 @@ def build_newton_actuator_defaults( wp.launch( scatter_gain_kernel, dim=act.indices.shape[0], - inputs=[ctrl.kp, flat_stiffness, act.indices, dof_offset, num_joints], + inputs=[ctrl.kp, flat_stiffness, act.indices, dof_offset, num_joints, env_stride], device=wp_device, ) if hasattr(ctrl, "kd"): wp.launch( scatter_gain_kernel, dim=act.indices.shape[0], - inputs=[ctrl.kd, flat_damping, act.indices, dof_offset, num_joints], + inputs=[ctrl.kd, flat_damping, act.indices, dof_offset, num_joints, env_stride], device=wp_device, ) stiffness = wp.to_torch(flat_stiffness.reshape((num_envs, num_joints))) diff --git a/source/isaaclab_newton/isaaclab_newton/actuators/kernels.py b/source/isaaclab_newton/isaaclab_newton/actuators/kernels.py index 8205773b640..7c9312ede2a 100644 --- a/source/isaaclab_newton/isaaclab_newton/actuators/kernels.py +++ b/source/isaaclab_newton/isaaclab_newton/actuators/kernels.py @@ -63,6 +63,7 @@ def scatter_gain_kernel( indices: wp.array(dtype=wp.uint32), dof_offset: int, num_joints: int, + env_stride: int, ): """Scatter per-actuator ``src`` values into a flat per-env-per-DOF ``dst``. @@ -71,11 +72,30 @@ def scatter_gain_kernel( that ``randomize_actuator_gains`` reads as ``actuator.stiffness`` / ``.damping`` for its ``default_joint_stiffness`` / ``default_joint_damping`` baseline. + + The actuator's ``indices`` are global DOF ids laid out env-major with a + per-env stride of ``env_stride`` — the *whole model's* per-env DOF count, + which on a floating-base articulation exceeds ``num_joints`` (the + articulation-local, actuated joint count) by the free-root DOFs. The env + index must therefore be decoded with ``env_stride``, not ``num_joints``; + the articulation-local joint offset is what remains after removing the + env's block and lands in ``[0, num_joints)`` because ``indices`` only ever + holds this articulation's joints. + + Args: + src: Per-actuator parameter values (e.g. ``controller.kp``). + dst: Flat ``(num_envs * num_joints)`` articulation-local snapshot buffer. + indices: Actuator's flat env-major global DOF indices. + dof_offset: Offset of this articulation's DOFs in the env-major + global index space (``0`` on PhysX, view-dependent on Newton). + num_joints: Articulation-local joint count (``dst``'s inner stride). + env_stride: Whole-model per-env DOF count (the stride used to build + ``indices``). """ i = wp.tid() global_dof = int(indices[i]) - dof_offset - env = global_dof // num_joints - local_dof = global_dof % num_joints + env = global_dof // env_stride + local_dof = global_dof - env * env_stride dst[env * num_joints + local_dof] = src[i] @@ -97,6 +117,16 @@ def patch_actuator_param_kernel( ``dst[i]`` (the controller parameter) with ``values[e_pos, j_pos]``. Cells outside the sub-grid are left untouched. + Note: + This kernel is PhysX-only (the Newton backend patches gains via + :meth:`ArticulationView.set_actuator_parameter`). On PhysX every + joint's coordinate count equals its DOF count, so the per-env stride + used to build ``indices`` equals ``num_joints`` and the ``env`` / + ``joint`` split below is exact. Do not reuse this kernel on a layout + whose per-env DOF stride exceeds ``num_joints`` (e.g. a floating-base + Newton model) without threading the true stride, or the ``joint`` + split will alias across envs — see :func:`scatter_gain_kernel`. + Args: indices: Actuator's flat indices into the (env-major) DOF layout. env_id_pos: ``env_id_pos[env]`` gives the row in ``values`` for diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index 2fb62bb9a29..8d7d9e5ef4a 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -3745,6 +3745,7 @@ def _process_actuators_cfg(self): num_envs=self.num_instances, num_joints=self.num_joints, dof_offset=arti_start, + env_stride=adapter.num_joints, device=self.device, ) else: 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 eb0af1c1043..be4c5c92e18 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 976d51dd201..a2a3435f246 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, @@ -147,9 +148,6 @@ def _rename_pair(values, worlds, *, collect_body_bindings: bool = False): ): _rename_pair(labels, worlds, collect_body_bindings=collect_body_bindings) - if "mujoco:equality_constraint_label" not in builder.custom_attributes: - _rename_pair(builder.equality_constraint_label, builder.equality_constraint_world) - custom_attrs = builder.custom_attributes.values() worlds_by_freq = {attr.frequency: attr.values for attr in custom_attrs if attr.references == "world"} for attr in custom_attrs: diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 88894b3915a..f415a5b6a2d 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,87 +54,62 @@ class NewtonKaminoManager(NewtonManager): Kamino's internal collision detector handles contact generation. """ + # Annotate the concrete solver type. + _solver: SolverKamino + + @classmethod + 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 _forward_kamino(cls, world_mask: wp.array | None = None) -> None: - """Kamino-specific forward kinematics via ``solver.reset()``. + 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. - Kamino's ``joint_q`` / ``joint_u`` include coordinates for **all** joints - (including free joints), so we pass Newton's full state arrays directly. + 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( - cls._state_0, - joint_q=cls._state_0.joint_q, - joint_u=cls._state_0.joint_qd, - world_mask=world_mask, - ) + 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: + eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, fk_mask) + + # 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 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) - else: - with wp.ScopedDevice(device): - cls._simulate_physics_only() - if cls._usdrt_stage is not None: - cls._mark_transforms_dirty() + def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: + """Skip the generic solver reset. - # Launch solver-specific debug logging after stepping. - cls._log_solver_debug() + :meth:`_eval_fk_impl` already performs the masked + :meth:`SolverKamino.reset` with an explicit reset configuration. - PhysicsManager._sim_time += cls._solver_dt * cls._num_substeps + Args: + world_mask: Unused; accepted to match the base hook signature. + """ @classmethod def _build_solver(cls, model: Model, solver_cfg: KaminoSolverCfg) -> None: @@ -119,40 +118,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 e88e54e59ab..90fbb067aaf 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/mjwarp_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py index f475695f0d9..cf3685c4ed7 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py @@ -11,6 +11,7 @@ import logging import numpy as np +import warp as wp from newton import Contacts, Model from newton.solvers import SolverMuJoCo @@ -79,6 +80,40 @@ def _initialize_contacts(cls) -> None: requested_attributes=cls._model.get_requested_contact_attributes(), ) + @classmethod + def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: + """Clear MuJoCo Warp solver-internal state for flagged worlds. + + Specializes the base hook, whose :meth:`SolverBase.reset` call resolves + to :meth:`SolverMuJoCo.reset` here: with ``flags=0`` it zeroes only the + solver-owned buffers persisting across steps (``qacc_warmstart``, + ``qfrc_applied``, ``xfrc_applied``, ``ctrl``, ``act``) for the flagged + worlds, while the joint state IsaacLab authored during the env reset is + left untouched. Without this, a NaN produced in one solve persists + across :meth:`isaaclab.envs.ManagerBasedEnv.reset` because the next + solver substep warm-starts from the NaN — the world is then permanently + dead. See https://github.com/newton-physics/newton/issues/1266. + + With ``use_mujoco_cpu=True`` the solver owns a single global ``MjData`` + and its reset path is not mask-aware — it clears the buffers for every + world. Since this hook fires on every step/forward boundary (usually + with an all-``False`` mask), the CPU path is gated on at least one + world actually being flagged so warm-starting is not defeated on every + step. + + Args: + world_mask: Per-world bool mask of shape ``(world_count,)``; + ``True`` for worlds that need their MJWarp internals cleared. + ``None`` is treated as a no-op. + """ + if world_mask is None: + return + if cls._solver.use_mujoco_cpu and not world_mask.numpy().any(): + return + # flags=0 skips the joint-state reset to model defaults: IsaacLab owns + # joint_q/joint_qd and has already written the authored reset pose. + cls._solver.reset(cls._state_0, world_mask=world_mask, flags=0) + @classmethod def _log_solver_debug(cls) -> None: """Optionally log MuJoCo solver convergence at the end of step.""" diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 7d04c6e0ea5..2b6d4d24c94 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. @@ -199,7 +211,8 @@ class NewtonManager(PhysicsManager): Concrete subclasses (one per solver) implement :meth:`_build_solver` and may extend :meth:`_initialize_contacts`, :meth:`_prepare_builder_for_finalize`, :meth:`_step_solver`, :meth:`_supports_cuda_graph_capture`, - :meth:`_solver_specific_clear`, and :meth:`_log_solver_debug`. + :meth:`_reset_solver_internals`, :meth:`_solver_specific_clear`, and + :meth:`_log_solver_debug`. Subclasses are selected via :attr:`NewtonSolverCfg.class_type`, which :meth:`NewtonCfg.__post_init__` propagates onto :attr:`NewtonCfg.class_type` @@ -254,9 +267,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 @@ -363,17 +379,43 @@ def reset(cls, soft: bool = False) -> None: cls.start_simulation() cls.initialize_solver() + @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._reset_solver_internals(cls._world_reset_mask) + 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: @@ -637,6 +679,8 @@ def step(cls) -> None: if sim is None or not sim.is_playing(): return + cls._reset_solver_internals(cls._world_reset_mask) + # Notify solver of model changes if cls._model_changes: with wp.ScopedDevice(PhysicsManager._device): @@ -651,6 +695,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 +709,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 +810,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 = {} @@ -824,12 +876,19 @@ def create_builder(cls, up_axis: str | None = None, **kwargs) -> ModelBuilder: Returns: New builder with up-axis and per-shape defaults (gap, margin) applied. """ - builder = ModelBuilder(up_axis=up_axis or cls._up_axis, **kwargs) - cls._register_builder_attributes(builder) # Resolve which NewtonShapeCfg to apply: user override if active config # is NewtonCfg, else the wrapper's own defaults so callers from non-Newton # contexts (tests, early construction) still get the rough-terrain margin. cfg = PhysicsManager._cfg + + builder = ModelBuilder(up_axis=up_axis or cls._up_axis, **kwargs) + builder.default_bvh_cfg = ModelBuilder.BvhConfig( + mesh_constructor=cfg.bvh_constructor_geometry if isinstance(cfg, NewtonCfg) else None, + gaussian_constructor=cfg.bvh_constructor_gaussian if isinstance(cfg, NewtonCfg) else None, + shape_constructor=cfg.bvh_constructor_scene if isinstance(cfg, NewtonCfg) else None, + ) + + cls._register_builder_attributes(builder) shape_cfg = cfg.default_shape_cfg if isinstance(cfg, NewtonCfg) else NewtonShapeCfg() checked_apply(shape_cfg, builder.default_shape_cfg) return builder @@ -1126,7 +1185,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 +1196,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) @@ -1371,6 +1431,24 @@ def _log_solver_debug(cls) -> None: (e.g. constraint violations, contact forces, etc.) after stepping. """ + @classmethod + def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: + """Clear solver-internal state for environments reset since the last boundary. + + The hook runs immediately before reset masks are consumed by :meth:`step` + and :meth:`forward`. The base implementation delegates to + :meth:`SolverBase.reset` with ``flags=0``, preserving the joint state + authored by Isaac Lab while clearing solver-owned buffers. Solvers with + no reset implementation are unaffected. + + Args: + world_mask: Per-world reset mask, or ``None`` when no simulation + state is available. + """ + if world_mask is None: + return + cls._solver.reset(cls._state_0, world_mask=world_mask, flags=0) + # ----- Lifecycle orchestration ---------------------------------------- @classmethod @@ -1409,6 +1487,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() @@ -1476,7 +1565,7 @@ def _capture_or_defer_graph(cls) -> None: with Timer(name="newton_cuda_graph", msg="CUDA graph took:"): if cls._usdrt_stage is None: simulate = cls._simulate_full if cls._is_all_graphable() else cls._simulate_physics_only - with wp.ScopedCapture() as capture: + with wp.ScopedCapture(device=device) as capture: simulate() NewtonManager._graph = capture.graph logger.info("Newton CUDA graph captured (standard Warp mode)") @@ -1645,7 +1734,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/physics/newton_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py index fcf956cce4c..019b1b0bdce 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py @@ -8,7 +8,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from isaaclab.physics import PhysicsCfg from isaaclab.utils.configclass import configclass @@ -154,6 +154,38 @@ class NewtonCfg(PhysicsCfg): meshes are intentional, for example thin or hollow MPM colliders. """ + bvh_constructor_geometry: Literal["lbvh", "sah", "cubql"] = "cubql" + """BVH construction algorithm for mesh geometry colliders. + + Selects the bounding-volume-hierarchy builder Newton uses for the triangle + meshes of collision geometry, forwarded to :attr:`ModelBuilder.BvhConfig`. + Trades build time against query (traversal) quality: + + - ``"lbvh"``: linear BVH; fastest to build, lowest-quality tree. + - ``"sah"``: surface-area-heuristic BVH; slower build, tighter tree with + faster ray/overlap queries. + - ``"cubql"``: cuBQL GPU builder; balances fast construction with good tree + quality on the GPU (default). + """ + + bvh_constructor_scene: Literal["lbvh", "sah"] = "sah" + """BVH construction algorithm for the top-level scene (broad-phase) hierarchy. + + Selects the builder for the BVH over all colliders used during broad-phase + culling, forwarded to :attr:`ModelBuilder.BvhConfig`. See + :attr:`bvh_constructor_geometry` for the ``"lbvh"`` / ``"sah"`` trade-off; + ``"cubql"`` is not available for the scene hierarchy. + """ + + bvh_constructor_gaussian: Literal["lbvh", "sah", "cubql"] = "cubql" + """BVH construction algorithm for Gaussian-splat primitives. + + Selects the builder for the BVH over 3D Gaussian primitives (used by the + Gaussian renderer/collision path), forwarded to + :attr:`ModelBuilder.BvhConfig`. See :attr:`bvh_constructor_geometry` for the + ``"lbvh"`` / ``"sah"`` / ``"cubql"`` trade-off. + """ + def __post_init__(self): # NewtonCfg.class_type is auto-derived from solver_cfg.class_type. # Refuse a user-set value: setting both is ambiguous and was diff --git a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py index 103b38a7552..f18376ab2f1 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -246,9 +246,19 @@ def initialize(self) -> None: enable_ambient_lighting=self.cfg.enable_ambient_lighting, enable_backface_culling=self.cfg.enable_backface_culling, max_distance=self.cfg.max_distance, + render_order=newton.sensors.SensorTiledCamera.RenderOrder.TILED, + tile_width=self.cfg.tile_rendering_width, + tile_height=self.cfg.tile_rendering_height, ), ) + if self.cfg.render_order == "pixel_priority": + self.newton_sensor.render_config.render_order = newton.sensors.SensorTiledCamera.RenderOrder.PIXEL_PRIORITY + elif self.cfg.render_order == "view_priority": + self.newton_sensor.render_config.render_order = newton.sensors.SensorTiledCamera.RenderOrder.VIEW_PRIORITY + else: + self.newton_sensor.render_config.render_order = newton.sensors.SensorTiledCamera.RenderOrder.TILED + # Newton ``v1.2.0rc2`` made shape-BVH construction explicit; ``SensorTiledCamera.update`` # no longer auto-builds when a non-``None`` state is passed, and the underlying # ``RenderContext.render`` raises if ``build_bvh_shape`` was never called for the model. @@ -347,6 +357,7 @@ def render(self, render_data: RenderData): shape_index_image=render_data.outputs.instance_segmentation_image, # ARGB 93% gray to improve visibility of dark objects and align with RTX renderer background clear_data=newton.sensors.SensorTiledCamera.ClearData(clear_color=0xFFEEEEEE), + kernel_block_dim=self.cfg.kernel_block_dim, ) # Post-render PPISP: HDR scene-linear → LDR RGBA. Source/destination diff --git a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer_cfg.py b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer_cfg.py index 9249fbf4ee7..2a8f610df2e 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer_cfg.py @@ -5,6 +5,8 @@ """Configuration for Newton Warp Renderer.""" +from typing import Literal + from isaaclab.renderers.renderer_cfg import RendererCfg from isaaclab.utils.configclass import configclass @@ -36,3 +38,15 @@ class NewtonWarpRendererCfg(RendererCfg): colorize_instance_segmentation: bool = True """Expose ``instance_segmentation_fast`` as ``(N, H, W, 4) uint8`` if True, else ``(N, H, W, 1) int32``.""" + + render_order: Literal["pixel_priority", "view_priority", "tiled"] = "tiled" + """Render traversal order for the Newton tiled camera.""" + + tile_rendering_width: int = 8 + """Tile width [px] for tiled rendering.""" + + tile_rendering_height: int = 8 + """Tile height [px] for tiled rendering.""" + + kernel_block_dim: int = 64 + """Thread block dimension forwarded to Newton.""" diff --git a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi index 27677fbd6db..1e684304e81 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/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index e546498ee4a..33f53033317 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -4,27 +4,47 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "MujocoJointCfg", + "apply_mujoco_fixed_tendon", + "MujocoFixedTendonCfg", "MujocoJointDrivePropertiesCfg", + "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", + "NewtonArticulationCfg", "NewtonArticulationRootPropertiesCfg", + "NewtonCollisionCfg", "NewtonCollisionPropertiesCfg", "NewtonDeformableBodyPropertiesCfg", "NewtonJointDrivePropertiesCfg", "NewtonMaterialPropertiesCfg", + "NewtonMeshCollisionCfg", "NewtonMeshCollisionPropertiesCfg", "NewtonRigidBodyPropertiesCfg", + "NewtonSDFCollisionCfg", "NewtonSDFCollisionPropertiesCfg", + "apply_mujoco_joint", ] +from .schemas import ( + apply_mujoco_fixed_tendon, + apply_mujoco_joint, +) from .schemas_cfg import ( + MujocoFixedTendonCfg, + MujocoJointCfg, MujocoJointDrivePropertiesCfg, + MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, + NewtonArticulationCfg, NewtonArticulationRootPropertiesCfg, + NewtonCollisionCfg, NewtonCollisionPropertiesCfg, NewtonDeformableBodyPropertiesCfg, NewtonJointDrivePropertiesCfg, NewtonMaterialPropertiesCfg, + NewtonMeshCollisionCfg, NewtonMeshCollisionPropertiesCfg, NewtonRigidBodyPropertiesCfg, + NewtonSDFCollisionCfg, NewtonSDFCollisionPropertiesCfg, ) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py new file mode 100644 index 00000000000..a4e46b2e697 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py @@ -0,0 +1,105 @@ +# 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 + +"""Newton-specific schema-fragment appliers. + +Hosts the custom ``func`` overrides for Newton/MuJoCo fragments whose application needs backend +logic beyond the generic :func:`~isaaclab.sim.schemas.apply_namespaced` writer. Keeping these here +(rather than in the core spawner) means ``isaaclab`` never imports a backend. +""" + +from __future__ import annotations + +import dataclasses + +from pxr import Usd, UsdPhysics + +from isaaclab.sim.schemas.schemas import apply_namespaced +from isaaclab.sim.utils import safe_set_attribute_on_usd_prim +from isaaclab.sim.utils.stage import get_current_stage +from isaaclab.utils.string import to_camel_case + +from .schemas_cfg import MujocoFixedTendonCfg, MujocoJointCfg + +__all__ = ["apply_mujoco_fixed_tendon", "apply_mujoco_joint"] + + +def apply_mujoco_fixed_tendon(cfg: MujocoFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Write ``mjc:*`` fixed-tendon attributes on a ``MjcTendon`` prim. + + Custom ``func`` override for :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg`. + No-op (returns False) on any prim whose type is not ``MjcTendon``. + + Args: + cfg: The :class:`MujocoFixedTendonCfg` fragment to apply. + prim_path: The prim path of the ``MjcTendon`` prim. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if the prim is a ``MjcTendon`` and was tuned, False otherwise. + + Raises: + ValueError: If the prim at ``prim_path`` does not exist in the stage. + """ + if stage is None: + stage = get_current_stage() + root = stage.GetPrimAtPath(prim_path) + if not root.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + values = { + f.name: getattr(cfg, f.name) + for f in dataclasses.fields(cfg) + if f.name != "func" and getattr(cfg, f.name) is not None + } + # Descend the whole subtree (matching legacy apply_nested): ``MjcTendon`` prims may sit below + # the prim_path the spawner targets. Write ``mjc:*`` on every ``MjcTendon`` descendant. + found = False + for prim in Usd.PrimRange(root): + if prim.GetTypeName() != "MjcTendon": + continue + found = True + for attr_name, value in values.items(): + safe_set_attribute_on_usd_prim(prim, f"mjc:{to_camel_case(attr_name, 'cC')}", value, camel_case=False) + return found + + +def apply_mujoco_joint(cfg: MujocoJointCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Apply a :class:`MujocoJointCfg` fragment, including its body-level gravcomp coupling. + + Custom ``func`` override for :class:`MujocoJointCfg`. Writes the joint's ``mjc:*`` attributes via + :func:`~isaaclab.sim.schemas.apply_namespaced`, then enforces the MuJoCo coupling that joint-level + ``actuatorgravcomp`` requires body-level ``gravcomp``: in MuJoCo ``actuatorgravcomp`` is a per-joint + flag (``jnt_actgravcomp``) that routes the gravity-compensation force of the joint's actuated body + through the actuator, and it is inert unless that body's ``mjc:gravcomp`` is non-zero. So when + :attr:`~MujocoJointCfg.actuatorgravcomp` is requested, this enables ``mjc:gravcomp = 1.0`` on the + joint's child body (its ``physics:body1`` target) when the body has not authored it. An explicitly + authored body gravcomp is preserved. Keeping this coupling in the Newton applier (not the core + spawner) keeps the core package free of any backend dependency. + + Args: + cfg: The :class:`MujocoJointCfg` fragment to apply. + prim_path: The joint prim path to author on. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if the joint fragment was applied successfully. + """ + if stage is None: + stage = get_current_stage() + success = apply_namespaced(cfg, prim_path, stage) + # actuatorgravcomp is inert unless the actuated body has non-zero gravcomp; flip it on the joint's + # child body when requested and unset (per-joint dispatch covers every actuated body in the + # articulation; the non-actuated base has no parent joint and needs no compensation). + if cfg.actuatorgravcomp: + joint = UsdPhysics.Joint(stage.GetPrimAtPath(prim_path)) + targets = joint.GetBody1Rel().GetTargets() if joint else [] + for body_path in targets: + body = stage.GetPrimAtPath(body_path) + if not body.IsValid(): + continue + current = body.GetAttribute("mjc:gravcomp").Get() + if current is None or current == 0.0: + safe_set_attribute_on_usd_prim(body, "mjc:gravcomp", 1.0, camel_case=False) + return success 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 a7b0d008257..ae19d511fe2 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -5,15 +5,22 @@ from __future__ import annotations +from collections.abc import Callable from typing import ClassVar, Literal from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, CollisionBaseCfg, + CollisionFragment, DeformableBodyPropertiesBaseCfg, + FixedTendonFragment, JointDriveBaseCfg, + JointDriveFragment, MeshCollisionBaseCfg, + MeshCollisionFragment, RigidBodyBaseCfg, + RigidBodyFragment, ) from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg from isaaclab.utils.configclass import configclass @@ -84,6 +91,60 @@ class MujocoRigidBodyPropertiesCfg(NewtonRigidBodyPropertiesCfg): """ +@configclass +class MujocoRigidBodyCfg(RigidBodyFragment): + """``mjc:*`` rigid-body attributes for Newton's MuJoCo solver. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + body-level gravity compensation. The ``mjc`` namespace has no applied schema; the + ``UsdPhysics.RigidBodyAPI`` anchor is applied by + :func:`~isaaclab.sim.schemas.apply_rigid_body_properties`. + + .. note:: + A ``newton:*`` rigid-body fragment is reserved but currently empty (Newton has no native + ``newton:`` rigid-body attributes today). + """ + + _usd_namespace: ClassVar[str | None] = "mjc" + _usd_applied_schema: ClassVar[str | None] = None + + gravcomp: float | None = None + """Gravity compensation scale for the body [dimensionless]. + + ``0.0`` = no compensation; ``1.0`` = full compensation. Written to ``mjc:gravcomp``. Body-level + gravcomp must be set for joint-level ``actuatorgravcomp`` to have any effect. + """ + + +@configclass +class MujocoJointCfg(JointDriveFragment): + """``mjc:*`` joint attributes for Newton's MuJoCo solver from ``MjcJointAPI``. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + joint-level gravity compensation. Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. It overrides :attr:`func` with + :func:`~isaaclab_newton.sim.schemas.apply_mujoco_joint`, which writes the ``mjc:*`` attributes + and enforces the body-level gravcomp coupling that joint-level ``actuatorgravcomp`` requires. + """ + + _usd_namespace: ClassVar[str | None] = "mjc" + _usd_applied_schema: ClassVar[str | None] = "MjcJointAPI" + + # Custom applier: writes the mjc:* joint attrs and, when ``actuatorgravcomp`` is requested, flips + # body-level ``mjc:gravcomp`` on the joint's child body (the coupling lives in the backend applier + # so the core spawner stays backend-free). See :func:`~isaaclab_newton.sim.schemas.apply_mujoco_joint`. + func: Callable | str = "isaaclab_newton.sim.schemas:apply_mujoco_joint" + + actuatorgravcomp: bool | None = None + """Route gravity compensation forces through the actuator channel. + + When ``True``, compensation forces go to ``qfrc_actuator`` (subject to force limits). + Requires body-level :attr:`MujocoRigidBodyCfg.gravcomp`. Written to ``mjc:actuatorgravcomp`` + via ``MjcJointAPI``. + """ + + @configclass class NewtonJointDrivePropertiesCfg(JointDriveBaseCfg): """Newton-targeted joint drive properties. @@ -131,6 +192,41 @@ class MujocoJointDrivePropertiesCfg(NewtonJointDrivePropertiesCfg): """ +@configclass +class NewtonCollisionCfg(CollisionFragment): + """``newton:*`` collision attributes for Newton's contact pipeline. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + Newton-native contact-geometry attributes (``NewtonCollisionAPI``). Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` via + :func:`~isaaclab.sim.schemas.apply_collision_properties`. + + .. note:: + The contact / rest offsets live on :class:`~isaaclab_physx.sim.schemas.PhysxCollisionCfg` + as ``physxCollision:*`` fields; Newton reads them via its PhysX-bridge resolver, so they + are not duplicated here. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonCollisionAPI" + + contact_margin: float | None = None + """Outward inflation of the collision surface [m]. + + Extends the effective collision surface outward. Sum of both bodies' margins is used for + collision detection. Essential for thin shells and cloth. Written to ``newton:contactMargin`` + via ``NewtonCollisionAPI``. Range: [0, inf). + """ + + contact_gap: float | None = None + """Additional contact detection gap [m]. + + AABBs are expanded by this value; contacts are detected earlier to avoid tunneling. Written to + ``newton:contactGap`` via ``NewtonCollisionAPI``. Set to ``-inf`` to use Newton's builder + default. Range: [0, inf). + """ + + @configclass class NewtonCollisionPropertiesCfg(CollisionBaseCfg): """Newton-specific collision properties. @@ -263,6 +359,118 @@ class NewtonSDFCollisionPropertiesCfg(NewtonCollisionPropertiesCfg): """ +# ------------------------------------------------------------------------------------- +# Mesh-collision cooking fragments (single-namespace; Newton cooking add-on schemas). +# +# Each fragment owns the ``newton`` namespace + its applied schema, dispatched via ``apply_namespaced``. +# They author no ``mesh_approximation_name`` (the token is set by the PhysX/USD fragment in the same +# list), so they only tune Newton-native cooking attributes. +# ------------------------------------------------------------------------------------- + + +@configclass +class NewtonMeshCollisionCfg(MeshCollisionFragment): + """``newton:maxHullVertices`` mesh-cooking attribute from ``NewtonMeshCollisionAPI``. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + Newton's convex-hull vertex limit. Dispatched alongside the USD/PhysX mesh-collision fragments + via :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonMeshCollisionAPI" + + max_hull_vertices: int | None = None + """Maximum vertices in the convex hull approximation [dimensionless]. + + Only relevant when ``physics:approximation = "convexHull"``. + Written to ``newton:maxHullVertices`` via ``NewtonMeshCollisionAPI``. + Set to ``-1`` to use as many vertices as needed for a perfect hull. + """ + + +@configclass +class NewtonSDFCollisionCfg(MeshCollisionFragment): + """``newton:*`` SDF and hydroelastic mesh-cooking attributes from ``NewtonSDFCollisionAPI``. + + A single-namespace fragment carrying Newton SDF generation and hydroelastic-contact attributes + consumed by Newton's USD importer. Mirrors the legacy + :class:`NewtonSDFCollisionPropertiesCfg`. Dispatched alongside the USD/PhysX mesh-collision + fragments via :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + + .. note:: + These ``newton:sdf*`` / ``newton:hydroelastic*`` attributes are read by Newton's USD + importer starting in Newton 1.3.0 (which also detects the unregistered + ``NewtonSDFCollisionAPI`` token via the raw ``apiSchemas`` list-op). On older Newton builds + they are authored but inert. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + # ``NewtonSDFCollisionAPI`` is authored into the prim's ``apiSchemas`` listOp (matching the + # legacy ``NewtonSDFCollisionPropertiesCfg``). It is not a *registered* applied API schema in + # the current Newton build, so it does not appear in the composed ``GetAppliedSchemas()`` until + # the schema ships -- but it is authored, and Newton's importer reads the ``newton:*`` attrs. + _usd_applied_schema: ClassVar[str | None] = "NewtonSDFCollisionAPI" + + sdf_max_resolution: int | None = None + """Maximum SDF grid dimension [dimensionless]. + + Newton requires this value to be divisible by 8. If :attr:`sdf_target_voxel_size` is also + authored, Newton uses the target voxel size and ignores this resolution. + Written to ``newton:sdfMaxResolution`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_narrow_band_inner: float | None = None + """Inner narrow-band distance for SDF generation [m]. + + Written to ``newton:sdfNarrowBandInner`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_narrow_band_outer: float | None = None + """Outer narrow-band distance for SDF generation [m]. + + Written to ``newton:sdfNarrowBandOuter`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_target_voxel_size: float | None = None + """Target SDF voxel size [m]. + + Takes precedence over :attr:`sdf_max_resolution` in Newton's USD importer. + Written to ``newton:sdfTargetVoxelSize`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_texture_format: Literal["uint8", "uint16", "float32"] | None = None + """Subgrid texture storage format for generated SDFs. + + Written to ``newton:sdfTextureFormat`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_padding: float | None = None + """SDF AABB padding [m]. + + Written to ``newton:sdfPadding`` via ``NewtonSDFCollisionAPI``. + """ + + hydroelastic_enabled: bool | None = None + """Whether Newton should use SDF-based hydroelastic contacts for this shape. + + Both participating collision shapes must enable hydroelastic contacts for Newton to use this + path. Written to ``newton:hydroelasticEnabled`` via ``NewtonSDFCollisionAPI``. + """ + + hydroelastic_stiffness: float | None = None + """Hydroelastic contact stiffness. + + Written to ``newton:hydroelasticStiffness`` via ``NewtonSDFCollisionAPI``. + """ + + @configclass class NewtonMaterialPropertiesCfg(RigidBodyMaterialBaseCfg): """Newton-specific rigid body material properties. @@ -294,6 +502,63 @@ 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): + """``mjc:*`` fixed-tendon attributes for a ``MjcTendon`` prim. + + The Mujoco fixed-tendon fragment. Newton has no tendon solver; this models only the ``mjc:*`` + tune path the Newton/Mujoco importer reads from a ``MjcTendon`` prim, carrying only the fields + that path maps. Overrides :attr:`func` with a custom applier + (:func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon`) that gates on the ``MjcTendon`` + prim type. Can be combined with :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` in the same + fragment list passed to :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`, which + dispatches each fragment to its own applier independently. + """ + + # Not namespace-driven: the custom applier gates on the ``MjcTendon`` prim type and writes the + # ``mjc:*`` attributes itself, so ``_usd_namespace`` stays ``None`` -- this also guards against + # accidentally routing the fragment through the generic ``apply_namespaced``. + _usd_namespace: ClassVar[str | None] = None + _usd_applied_schema: ClassVar[str | None] = None + + func: Callable | str = "isaaclab_newton.sim.schemas:apply_mujoco_fixed_tendon" + + stiffness: float | None = None + """Spring stiffness term acting on the tendon's length [N/m].""" + + damping: float | None = None + """Damping term acting on the tendon length [NĀ·s/m].""" + @configclass class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): @@ -310,7 +575,6 @@ class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): _usd_namespace: ClassVar[str | None] = "newton" _usd_applied_schema: ClassVar[str | None] = "NewtonArticulationRootAPI" - _usd_field_exceptions: ClassVar[dict] = {} self_collision_enabled: bool | None = None """Whether self-collisions between bodies in this articulation are enabled. @@ -319,3 +583,29 @@ class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): Newton's resolver checks this native attribute first before falling back to ``physxArticulation:enabledSelfCollisions``. """ + + +@configclass +class NewtonArticulationCfg(ArticulationRootFragment): + """``newton:*`` articulation-root attributes for Newton. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + Newton-native self-collision control. It owns the ``NewtonArticulationRootAPI`` applied + schema. Composes with :class:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg` in an + ``articulation_props`` fragment list; the ``UsdPhysics.ArticulationRootAPI`` anchor is + applied by :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonArticulationRootAPI" + + self_collision_enabled: bool | None = None + """Whether self-collisions between bodies in this articulation are enabled. + + Written to ``newton:selfCollisionEnabled`` via ``NewtonArticulationRootAPI``. Newton's + resolver checks this native attribute first before falling back to the PhysX namespace + (:attr:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg.enabled_self_collisions`). + """ 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 c3f13216f78..5ee626b240b 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 f7b63a8701c..e0027a81553 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/isaaclab_newton/sim/views/newton_site_frame_view.py b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py index c7c31ae3692..3bc874e926a 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py @@ -17,6 +17,7 @@ from isaaclab.cloner.cloner_utils import get_suffix, iter_clone_plan_matches, split_clone_template from isaaclab.physics import PhysicsEvent from isaaclab.sim.views.base_frame_view import BaseFrameView +from isaaclab.sim.views.xform_space_writer import FrameViewLocalSpaceWriter, FrameViewWorldSpaceWriter from isaaclab.utils.string import resolve_matching_names from isaaclab.utils.warp import ProxyArray @@ -26,6 +27,16 @@ WORLD_BODY_INDEX = -1 +# Regex metacharacters that mark a body pattern as a genuine expression rather than a literal +# USD path. Patterns free of these can be resolved via an exact dict lookup instead of scanning +# every body label with a compiled regex. +_REGEX_TOKENS = frozenset(".*[]()+?|\\^$") + + +def _has_regex_tokens(pattern: str) -> bool: + """Return whether ``pattern`` contains regex metacharacters (i.e. is not a literal path).""" + return any(token in _REGEX_TOKENS for token in pattern) + @wp.kernel def _compute_site_world_transforms( @@ -104,7 +115,7 @@ def _write_site_local_from_local_poses( @wp.kernel -def _gather_scales( +def _gather_shape_scales( shape_scale: wp.array(dtype=wp.vec3f), shape_body: wp.array(dtype=wp.int32), site_body: wp.array(dtype=wp.int32), @@ -112,7 +123,7 @@ def _gather_scales( num_shapes: wp.int32, out_scales: wp.array(dtype=wp.vec3f), ): - """Gather per-site scales from collision shapes on the same body.""" + """Gather legacy per-site geometry scales from collision shapes on the same body.""" i = wp.tid() si = indices[i] bid = site_body[si] @@ -126,7 +137,7 @@ def _gather_scales( @wp.kernel -def _scatter_scales( +def _scatter_shape_scales( site_body: wp.array(dtype=wp.int32), indices: wp.array(dtype=wp.int32), new_scales: wp.array(dtype=wp.vec3f), @@ -134,7 +145,7 @@ def _scatter_scales( num_shapes: wp.int32, shape_scale: wp.array(dtype=wp.vec3f), ): - """Scatter per-site scales to collision shapes on the same body.""" + """Scatter legacy per-site geometry scales to collision shapes on the same body.""" i = wp.tid() si = indices[i] bid = site_body[si] @@ -143,6 +154,28 @@ def _scatter_scales( shape_scale[s] = new_scales[i] +@wp.kernel +def _gather_xform_scales( + site_xform_scale: wp.array(dtype=wp.vec3f), + indices: wp.array(dtype=wp.int32), + out_scales: wp.array(dtype=wp.vec3f), +): + """Gather per-site xform scales.""" + i = wp.tid() + out_scales[i] = site_xform_scale[indices[i]] + + +@wp.kernel +def _scatter_xform_scales( + indices: wp.array(dtype=wp.int32), + new_scales: wp.array(dtype=wp.vec3f), + site_xform_scale: wp.array(dtype=wp.vec3f), +): + """Scatter per-site xform scales.""" + i = wp.tid() + site_xform_scale[indices[i]] = new_scales[i] + + class NewtonSiteFrameView(BaseFrameView): """Batched Newton site view for non-physics frames. @@ -178,43 +211,51 @@ def __init__( stage = sim_utils.get_current_stage() if stage is None else stage self._site_specs = self._resolve_site_specs(stage, validate_xform_ops) self._site_labels: list[str] = [] + self._site_label_scales: list[tuple[float, float, float]] = [] self._site_body: wp.array | None = None self._site_local: wp.array | None = None + self._site_xform_scale: wp.array | None = None self._site_indices: wp.array | None = None self._pos_buf: wp.array | None = None self._quat_buf: wp.array | None = None self._local_pos_buf: wp.array | None = None self._local_quat_buf: wp.array | None = None + self._scale_buf: wp.array | None = None self._pos_ta: ProxyArray | None = None self._quat_ta: ProxyArray | None = None self._local_pos_ta: ProxyArray | None = None self._local_quat_ta: ProxyArray | None = None + self._scale_ta: ProxyArray | None = None self._count = 0 model = NewtonManager.get_model() if model is not None: self._initialize_from_specs(model) else: - for body_patterns, xform, per_world, _env_ids in self._site_specs: + for body_patterns, xform, scale, per_world, _env_ids in self._site_specs: if body_patterns is None: self._site_labels.append(NewtonManager.cl_register_site(None, xform, per_world=per_world)) + self._site_label_scales.append(scale) else: for body_pattern in body_patterns: self._site_labels.append(NewtonManager.cl_register_site(body_pattern, xform)) + self._site_label_scales.append(scale) self._physics_ready_handle = NewtonManager.register_callback( self._on_physics_ready, PhysicsEvent.PHYSICS_READY, name=f"site_view_{self._prim_path}" ) def _resolve_site_specs( self, stage, validate_xform_ops: bool - ) -> list[tuple[tuple[str, ...] | None, wp.transform, bool, tuple[int, ...] | None]]: + ) -> list[tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None]]: """Resolve source prims into Newton site registration specs.""" plan = sim_utils.SimulationContext.instance().get_clone_plan() model = NewtonManager.get_model() body_labels = list(model.body_label) if model is not None else () shape_labels = list(model.shape_label) if model is not None else () use_clone_body_pattern = model is None - specs: list[tuple[tuple[str, ...] | None, wp.transform, bool, tuple[int, ...] | None]] = [] + specs: list[ + tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None] + ] = [] for path_expr in self._prim_paths: if resolve_matching_names(path_expr, body_labels, raise_when_no_match=False)[1]: @@ -268,8 +309,8 @@ def _resolve_source_prim( env_ids: tuple[int, ...] | None, use_clone_body_pattern: bool, stage, - ) -> tuple[tuple[str, ...] | None, wp.transform, bool, tuple[int, ...] | None]: - """Resolve one source prim into body patterns and a local frame.""" + ) -> tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None]: + """Resolve one source prim into body patterns, local frame, and xform scale.""" prim_path = prim.GetPath().pathString if prim.HasAPI(UsdPhysics.RigidBodyAPI) or prim.HasAPI(UsdPhysics.ArticulationRootAPI): raise ValueError( @@ -281,6 +322,13 @@ def _resolve_source_prim( if not sim_utils.validate_standard_xform_ops(prim): raise ValueError(f"FrameView prim '{prim_path}' does not have standard xform ops.") + scale_attr = prim.GetAttribute("xformOp:scale") + scale = ( + tuple(float(v) for v in scale_attr.Get()) + if scale_attr and scale_attr.HasAuthoredValue() + else (1.0, 1.0, 1.0) + ) + body_prim = prim.GetParent() while body_prim and body_prim.IsValid(): if body_prim.HasAPI(UsdPhysics.RigidBodyAPI) or body_prim.HasAPI(UsdPhysics.ArticulationRootAPI): @@ -300,7 +348,7 @@ def _resolve_source_prim( raise RuntimeError( f"FrameView destination root '{destination_root}' does not end with '{suffix}'." ) - return (destination_root[: -len(suffix)],), wp.transform(pos, quat), False, env_ids + return (destination_root[: -len(suffix)],), wp.transform(pos, quat), scale, False, env_ids body_patterns = [] for env_id in env_ids: destination_root = destination_template.format(env_id) @@ -309,7 +357,7 @@ def _resolve_source_prim( f"FrameView destination root '{destination_root}' does not end with '{suffix}'." ) body_patterns.append(destination_root[: -len(suffix)]) - return tuple(body_patterns), wp.transform(pos, quat), False, env_ids + return tuple(body_patterns), wp.transform(pos, quat), scale, False, env_ids else: raise RuntimeError(f"FrameView source body '{body_path}' is not under '{source_root}'.") if use_clone_body_pattern: @@ -318,7 +366,7 @@ def _resolve_source_prim( body_patterns = tuple(destination_template.format(env_id) + suffix for env_id in env_ids) else: body_patterns = (body_path,) - return body_patterns, wp.transform(pos, quat), False, env_ids + return body_patterns, wp.transform(pos, quat), scale, False, env_ids body_prim = body_prim.GetParent() ref_path = source_root @@ -329,7 +377,7 @@ def _resolve_source_prim( ref_path = source_root[: -len(source_suffix)] if source_suffix else source_root ref_prim = stage.GetPrimAtPath(ref_path) if ref_path is not None else None pos, quat = sim_utils.resolve_prim_pose(prim, ref_prim if ref_prim and ref_prim.IsValid() else None) - return None, wp.transform(pos, quat), source_root is not None, env_ids + return None, wp.transform(pos, quat), scale, source_root is not None, env_ids def _on_physics_ready(self, _event) -> None: """Callback invoked when the Newton model becomes available.""" @@ -342,8 +390,9 @@ def _initialize_from_site_map(self, model) -> None: xform_t = wp.to_torch(model.shape_transform) site_bodies: list[int] = [] site_locals: list[list[float]] = [] + site_scales: list[tuple[float, float, float]] = [] - for site_label in self._site_labels: + for site_label, scale in zip(self._site_labels, self._site_label_scales, strict=True): global_idx, per_world = site_map[site_label] site_indices = ( [global_idx] if per_world is None else [site_idx for sites in per_world for site_idx in sites] @@ -351,16 +400,24 @@ def _initialize_from_site_map(self, model) -> None: for site_idx in site_indices: site_bodies.append(int(body_t[site_idx].item())) site_locals.append([float(v) for v in xform_t[site_idx].tolist()]) + site_scales.append(scale) - self._create_buffers(site_bodies, site_locals) + self._create_buffers(site_bodies, site_locals, site_scales) def _initialize_from_specs(self, model) -> None: """Initialize arrays directly from resolved specs and Newton body labels.""" body_labels = list(model.body_label) + # Exact label -> index map, built once. Replicated frames expand to one concrete + # body path per environment, so matching each against every label via regex is + # ``O(num_envs * num_bodies)`` (quadratic in ``num_envs``). Fast-pathing literal + # paths through this map keeps the common per-environment case linear; genuine + # regex patterns (e.g. the cloned ``.*`` pattern) still fall back to a full scan. + label_to_index = {label: idx for idx, label in enumerate(body_labels)} site_bodies: list[int] = [] site_locals: list[list[float]] = [] + site_scales: list[tuple[float, float, float]] = [] - for body_patterns, xform, per_world, env_ids in self._site_specs: + for body_patterns, xform, scale, per_world, env_ids in self._site_specs: if body_patterns is None: if per_world: if NewtonManager._world_xforms is None: @@ -370,13 +427,19 @@ def _initialize_from_specs(self, model) -> None: world_xform = NewtonManager._world_xforms[world_id] site_bodies.append(WORLD_BODY_INDEX) site_locals.append([float(v) for v in wp.transform_multiply(world_xform, xform)]) + site_scales.append(scale) else: site_bodies.append(WORLD_BODY_INDEX) site_locals.append([float(v) for v in xform]) + site_scales.append(scale) continue for body_pattern in body_patterns: - matched_indices, _ = resolve_matching_names(body_pattern, body_labels, raise_when_no_match=False) + exact_index = label_to_index.get(body_pattern) if not _has_regex_tokens(body_pattern) else None + if exact_index is not None: + matched_indices = [exact_index] + else: + matched_indices, _ = resolve_matching_names(body_pattern, body_labels, raise_when_no_match=False) if not matched_indices: raise ValueError( f"FrameView '{self._prim_path}' body pattern '{body_pattern}' matched no Newton bodies." @@ -385,24 +448,33 @@ def _initialize_from_specs(self, model) -> None: for body_idx in matched_indices: site_bodies.append(body_idx) site_locals.append([float(v) for v in xform]) + site_scales.append(scale) - self._create_buffers(site_bodies, site_locals) + self._create_buffers(site_bodies, site_locals, site_scales) - def _create_buffers(self, site_bodies: list[int], site_locals: list[list[float]]) -> None: + def _create_buffers( + self, + site_bodies: list[int], + site_locals: list[list[float]], + site_scales: list[tuple[float, float, float]], + ) -> None: """Allocate view buffers from body indices and local transforms.""" self._count = len(site_bodies) device = self._device self._site_body = wp.array(site_bodies, dtype=wp.int32, device=device) self._site_local = wp.array([wp.transform(*x) for x in site_locals], dtype=wp.transformf, device=device) + self._site_xform_scale = wp.array([wp.vec3f(*scale) for scale in site_scales], dtype=wp.vec3f, device=device) self._site_indices = wp.array(list(range(self._count)), dtype=wp.int32, device=device) self._pos_buf = wp.zeros(self._count, dtype=wp.vec3f, device=device) self._quat_buf = wp.zeros(self._count, dtype=wp.vec4f, device=device) self._local_pos_buf = wp.zeros(self._count, dtype=wp.vec3f, device=device) self._local_quat_buf = wp.zeros(self._count, dtype=wp.vec4f, device=device) + self._scale_buf = wp.zeros(self._count, dtype=wp.vec3f, device=device) self._pos_ta = ProxyArray(self._pos_buf) self._quat_ta = ProxyArray(self._quat_buf) self._local_pos_ta = ProxyArray(self._local_pos_buf) self._local_quat_ta = ProxyArray(self._local_quat_buf) + self._scale_ta = ProxyArray(self._site_xform_scale) @property def prims(self) -> list: @@ -422,7 +494,21 @@ def device(self) -> str: """Device where arrays are allocated.""" return self._device - def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + # ------------------------------------------------------------------ + # Writer factory hooks (pass-through; Newton has no separate Fabric storage) + # ------------------------------------------------------------------ + + def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: + return _NewtonWorldSpaceWriter(self) + + def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: + return _NewtonLocalSpaceWriter(self) + + # ------------------------------------------------------------------ + # Backend hooks + # ------------------------------------------------------------------ + + def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: """Get world-space positions and orientations.""" state = NewtonManager.get_state_0() site_indices = self._site_indices if indices is None else indices @@ -441,7 +527,7 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, return self._pos_ta, self._quat_ta return ProxyArray(pos_buf), ProxyArray(quat_buf) - def set_world_poses( + def _apply_world_pose_write( self, positions: wp.array | None = None, orientations: wp.array | None = None, @@ -453,7 +539,7 @@ def set_world_poses( state = NewtonManager.get_state_0() if positions is None or orientations is None: - cur_pos_ta, cur_quat_ta = self.get_world_poses(indices) + cur_pos_ta, cur_quat_ta = self._get_world_poses_impl(indices) if positions is None: positions = cur_pos_ta.warp if orientations is None: @@ -468,7 +554,7 @@ def set_world_poses( device=self._device, ) - def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: """Get body-local positions and orientations.""" site_indices = self._site_indices if indices is None else indices n = self.count if indices is None else len(indices) @@ -486,7 +572,7 @@ def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, return self._local_pos_ta, self._local_quat_ta return ProxyArray(pos_buf), ProxyArray(quat_buf) - def set_local_poses( + def _apply_local_pose_write( self, translations: wp.array | None = None, orientations: wp.array | None = None, @@ -497,7 +583,7 @@ def set_local_poses( return if translations is None or orientations is None: - cur_pos_ta, cur_quat_ta = self.get_local_poses(indices) + cur_pos_ta, cur_quat_ta = self._get_local_poses_impl(indices) if translations is None: translations = cur_pos_ta.warp if orientations is None: @@ -512,15 +598,70 @@ def set_local_poses( device=self._device, ) - def get_scales(self, indices: wp.array | None = None) -> ProxyArray: - """Get per-site scales by reading from the first collision shape on the same body.""" + # ------------------------------------------------------------------ + # Scales + # ------------------------------------------------------------------ + + def _get_world_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Get per-site world xform scales. + + These are transform scales, matching the USD FrameView scale API. They + are intentionally separate from Newton collision shape geometry sizes. + """ + if indices is None: + return self._scale_ta + n = len(indices) + out = wp.zeros(n, dtype=wp.vec3f, device=self._device) + wp.launch( + _gather_xform_scales, + dim=n, + inputs=[self._site_xform_scale, indices], + outputs=[out], + device=self._device, + ) + return ProxyArray(out) + + def _get_local_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Get per-site local xform scales. + + These are transform scales, matching the USD FrameView scale API. They + are intentionally separate from Newton collision shape geometry sizes. + """ + return self._get_world_scales_impl(indices) + + def _apply_world_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set per-site world xform scales. + + These update transform scale state only; use deprecated ``set_scales`` if + legacy Newton collision shape geometry-scale behavior is required. + """ + if indices is None: + indices = self._site_indices + n = self.count if indices is self._site_indices else len(indices) + wp.launch( + _scatter_xform_scales, + dim=n, + inputs=[indices, scales, self._site_xform_scale], + device=self._device, + ) + + def _apply_local_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set per-site local xform scales. + + These update transform scale state only; use deprecated ``set_scales`` if + legacy Newton collision shape geometry-scale behavior is required. + """ + self._apply_world_scale_write(scales, indices) + + def _get_legacy_shape_scales(self, indices: wp.array | None = None) -> ProxyArray: + """Get Newton legacy geometry scales from collision shapes.""" model = NewtonManager.get_model() num_shapes = model.shape_count site_indices = self._site_indices if indices is None else indices n = self.count if indices is None else len(indices) out = wp.zeros(n, dtype=wp.vec3f, device=self._device) wp.launch( - _gather_scales, + _gather_shape_scales, dim=n, inputs=[model.shape_scale, model.shape_body, self._site_body, site_indices, num_shapes], outputs=[out], @@ -528,15 +669,66 @@ def get_scales(self, indices: wp.array | None = None) -> ProxyArray: ) return ProxyArray(out) - def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: - """Set per-site scales by writing to all collision shapes on the same body.""" + def _set_legacy_shape_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set Newton legacy geometry scales on collision shapes.""" model = NewtonManager.get_model() num_shapes = model.shape_count site_indices = self._site_indices if indices is None else indices n = self.count if indices is None else len(indices) wp.launch( - _scatter_scales, + _scatter_shape_scales, dim=n, inputs=[self._site_body, site_indices, scales, model.shape_body, num_shapes, model.shape_scale], device=self._device, ) + + def _get_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Newton legacy: get_scales returns collision shape geometry scales.""" + return self._get_legacy_shape_scales(indices) + + def _set_scales_impl(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Newton legacy: deprecated set_scales writes collision shape geometry scales. + + Newton's legacy ``set_scales`` path is *not* routed through the + :class:`FrameViewSpaceWriterBase` API because it targets a different state + (collision-shape geometry sizes) than the transform-scale state that + the writer's :meth:`~FrameViewSpaceWriterBase.set_scales` operates on. + """ + self._set_legacy_shape_scales(scales, indices) + + +# ---------------------------------------------------------------------- +# Pass-through writer classes +# ---------------------------------------------------------------------- + + +class _NewtonWorldSpaceWriter(FrameViewWorldSpaceWriter): + """Newton world-space writer: pass-through to backend ``_apply_*`` hooks.""" + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_world_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_world_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_world_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_world_scales_impl(indices) # type: ignore[attr-defined] + + +class _NewtonLocalSpaceWriter(FrameViewLocalSpaceWriter): + """Newton local-space writer: pass-through to backend ``_apply_*`` hooks.""" + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_local_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_local_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_local_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_local_scales_impl(indices) # type: ignore[attr-defined] diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index 880c175855d..2ca6f4c5060 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,27 +9,20 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.0.4" +version = "1.7.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["robotics", "simulation", "newton"] requires-python = ">=3.12" +# Third-party requirements are centralized in the root pyproject.toml. dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" -[project.optional-dependencies] -all = [ - "prettytable>=3.3.0", - "PyOpenGL-accelerate>=3.1.0", - "pyglet>=2.1.6,<3", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@811968bfb7cc7ff4e37b9260a2ba56930a3e605e", -] - [tool.setuptools] include-package-data = true diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index 287c88762c5..16cb5b9d55d 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -28,6 +28,8 @@ from isaaclab_newton.physics import NewtonManager as SimulationManager from newton.solvers import SolverNotifyFlags +from pxr import UsdPhysics + import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils import isaaclab.utils.string as string_utils @@ -782,6 +784,29 @@ def test_initialization_hand_with_tendons(sim, num_articulations, device, articu articulation.update(sim.cfg.dt) +@pytest.mark.parametrize("device", ["cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.parametrize("articulation_type", ["anymal"]) +def test_fragment_fix_root_link_uses_base_manager(sim, device, add_ground_plane, articulation_type): + """Newton consumes the base manager's world joint without relocating the root API.""" + articulation_cfg = deepcopy(generate_articulation_cfg(articulation_type=articulation_type)) + articulation_cfg.spawn.articulation_props = [] + articulation_cfg.spawn.fix_root_link = True + articulation, _ = generate_articulation(articulation_cfg, num_articulations=1, device=device) + + root = sim_utils.get_first_matching_child_prim( + "/World/Env_0/Robot", + lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), + stage=sim.stage, + ) + assert root is not None and root.HasAPI(UsdPhysics.RigidBodyAPI) + assert sim_utils.find_global_fixed_joint_prim("/World/Env_0/Robot", stage=sim.stage) is not None + + sim.reset() + assert articulation.is_initialized + assert articulation.is_fixed_base + + @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) diff --git a/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py b/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py index 68531c27048..f5457690f9e 100644 --- a/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py +++ b/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py @@ -693,6 +693,64 @@ def test_two_articulations(self): torch.testing.assert_close(cp_kd_after[env_idx], cp_kd_before[env_idx]) +class TestNewtonActuatorGainSnapshotEnvStride(unittest.TestCase): + """Regression: the init-time kp/kd snapshot must be correct for every env. + + ``build_newton_actuator_defaults`` scatters each Newton actuator's + ``controller.kp`` / ``controller.kd`` into a per-articulation + ``(num_envs, num_joints)`` tensor (``newton_default_stiffness`` / + ``newton_default_damping``), which ``randomize_actuator_gains`` reads as + its DR baseline. On a floating-base articulation the actuator ``indices`` + are laid out env-major with a per-env stride equal to the *whole model's* + per-env DOF count (free-root DOFs + joints), which exceeds + ``articulation.num_joints``. If the scatter decodes the env with + ``num_joints`` instead of that stride, env 1's DOFs alias to the wrong + rows (and partly out of bounds), corrupting the snapshot for every env + past the first. + + ANYmal-C is floating base (6 free-root DOFs + 12 actuated joints -> a + per-env stride of 18 vs. ``num_joints == 12``), so the bug manifests here + with ``NUM_ENVS == 2``: without the fix, ``newton_default_stiffness[1]`` + is not uniformly the configured gain (its leading entries stay zero, as + they are never written). + """ + + def test_snapshot_matches_config_for_all_envs(self): + sim_cfg = SimulationCfg(dt=DT, physics=NEWTON_CFG, use_newton_actuators=True) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + art_cfg = ANYMAL_C_CFG.replace( + actuators=IDEAL_PD_ACTUATORS, + prim_path="/World/Env_.*/Robot", + ) + anymal = Articulation(art_cfg) + sim.reset() + assert anymal.is_initialized + + stiffness = anymal.newton_default_stiffness + damping = anymal.newton_default_damping + self.assertIsNotNone(stiffness, "expected a Newton kp snapshot with use_newton_actuators=True") + self.assertIsNotNone(damping, "expected a Newton kd snapshot with use_newton_actuators=True") + + n_j = anymal.num_joints + self.assertEqual(tuple(stiffness.shape), (NUM_ENVS, n_j)) + self.assertEqual(tuple(damping.shape), (NUM_ENVS, n_j)) + + # IDEAL_PD_ACTUATORS covers all 12 joints with constant gains, so + # every cell of both env rows must equal the configured value. + expected_kp = torch.full((NUM_ENVS, n_j), 40.0, device=anymal.device) + expected_kd = torch.full((NUM_ENVS, n_j), 5.0, device=anymal.device) + torch.testing.assert_close(stiffness, expected_kp) + torch.testing.assert_close(damping, expected_kd) + + # --------------------------------------------------------------------------- # DelayedPD equivalence: PD with actuator command delay # --------------------------------------------------------------------------- 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 28f116a68d7..b0fa0cb00ec 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -29,7 +29,6 @@ "shape_label": "Shape", "articulation_label": "Articulation", "constraint_mimic_label": "ConstraintMimic", - "equality_constraint_label": "EqualityConstraint", } _VIS_LABEL_ATTRS = tuple(_VIS_LABEL_SUFFIXES) @@ -334,6 +333,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 991a5f63da8..f47e3a3232f 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -426,6 +426,38 @@ def test_mpm_unsupported_cuda_graph_capture_uses_eager_execution(monkeypatch): assert NewtonManager._graph_capture_pending is False +def test_cuda_graph_capture_uses_simulation_device(monkeypatch): + """CUDA graph capture should use the simulation device instead of Warp's default device.""" + from isaaclab.physics import PhysicsManager + + captured_devices = [] + captured_graph = object() + + class FakeScopedCapture: + def __init__(self, device=None): + captured_devices.append(device) + self.graph = captured_graph + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return False + + monkeypatch.setattr(PhysicsManager, "_cfg", SimpleNamespace(use_cuda_graph=True), raising=False) + monkeypatch.setattr(PhysicsManager, "_device", "cuda:1", raising=False) + monkeypatch.setattr(NewtonManager, "_usdrt_stage", None, raising=False) + monkeypatch.setattr(NewtonManager, "_solver", None, raising=False) + monkeypatch.setattr(NewtonManager, "_is_all_graphable", classmethod(lambda cls: False)) + monkeypatch.setattr(NewtonManager, "_simulate_physics_only", classmethod(lambda cls: None)) + monkeypatch.setattr(wp, "ScopedCapture", FakeScopedCapture) + + NewtonManager._capture_or_defer_graph() + + assert captured_devices == ["cuda:1"] + assert NewtonManager._graph is captured_graph + + # --------------------------------------------------------------------------- # Manager class hierarchy and factory contracts # --------------------------------------------------------------------------- @@ -651,3 +683,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/physics/test_newton_solver_reset.py b/source/isaaclab_newton/test/physics/test_newton_solver_reset.py new file mode 100644 index 00000000000..2546c57cdf3 --- /dev/null +++ b/source/isaaclab_newton/test/physics/test_newton_solver_reset.py @@ -0,0 +1,130 @@ +# 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 solver-internal reset performed when env-reset masks are consumed.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +from unittest.mock import patch + +import pytest +import torch +import warp as wp +from isaaclab_newton.assets import Articulation +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import NewtonManager as SimulationManager +from newton.solvers import SolverMuJoCo + +import isaaclab.sim as sim_utils +from isaaclab.actuators import IdealPDActuatorCfg +from isaaclab.assets import ArticulationCfg +from isaaclab.sim import SimulationCfg, build_simulation_context +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + + +def _generate_single_joint_articulations(num_articulations: int, device: str) -> Articulation: + """Spawn ``num_articulations`` copies of the simple revolute articulation, one per env prim.""" + for i in range(num_articulations): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 2.5, 0.0, 0.0)) + articulation_cfg = ArticulationCfg( + prim_path="/World/Env_.*/Robot", + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_force=80.0, max_joint_velocity=5.0), + ), + actuators={ + "joint": IdealPDActuatorCfg( + joint_names_expr=[".*"], + effort_limit=400.0, + velocity_limit=100.0, + stiffness=0.0, + damping=10.0, + ), + }, + ) + return Articulation(articulation_cfg) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_env_reset_clears_selected_mjwarp_solver_internals(device): + """An env reset clears the flagged world's MuJoCo warm-start history and keeps the others. + + Regression test for NaN values persisting across env reset: MJWarp warm-starts each solve + from ``qacc_warmstart``, so solver-internal buffers must be cleared for reset worlds at both + boundaries that consume the accumulated reset masks (``forward()`` and ``step()``), while the + authored joint state and the untouched worlds' history are preserved. + """ + sim_cfg = SimulationCfg( + dt=1 / 120, + physics=NewtonCfg( + solver_cfg=MJWarpSolverCfg( + njmax=20, + nconmax=20, + integrator="implicitfast", + ), + num_substeps=1, + use_cuda_graph=False, + ), + ) + with build_simulation_context(sim_cfg=sim_cfg, device=device) as sim: + sim._app_control_on_stop_handle = None + articulation = _generate_single_joint_articulations(num_articulations=2, device=device) + sim.reset() + + solver = SimulationManager._solver + assert isinstance(solver, SolverMuJoCo) + warm_start = wp.to_torch(solver.mjw_data.qacc_warmstart) + assert warm_start.shape[0] == 2 + assert warm_start.shape[1] > 0 + + env_ids = torch.tensor([0], dtype=torch.int32, device=device) + joint_pos = articulation.data.default_joint_pos.torch[:1].clone() + 0.25 + joint_vel = torch.full_like(articulation.data.default_joint_vel.torch[:1], 0.5) + articulation.write_joint_state_to_sim_index( + position=joint_pos, + velocity=joint_vel, + env_ids=env_ids, + ) + + state = SimulationManager._state_0 + joint_q_before = wp.to_torch(state.joint_q).clone() + joint_qd_before = wp.to_torch(state.joint_qd).clone() + warm_start[0].fill_(13.0) + warm_start[1].fill_(17.0) + wp.synchronize_device(device) + + # The public, non-integrating forward boundary consumes the authored-write mask. + sim.forward() + wp.synchronize_device(device) + + torch.testing.assert_close(wp.to_torch(state.joint_q), joint_q_before) + torch.testing.assert_close(wp.to_torch(state.joint_qd), joint_qd_before) + assert torch.count_nonzero(warm_start[0]).item() == 0 + torch.testing.assert_close(warm_start[1], torch.full_like(warm_start[1], 17.0)) + + # The physics-step boundary also resets when no forward boundary consumed the mask first. + articulation.write_joint_state_to_sim_index( + position=joint_pos, + velocity=joint_vel, + env_ids=env_ids, + ) + warm_start[0].fill_(23.0) + warm_start[1].fill_(29.0) + wp.synchronize_device(device) + + with ( + patch.object(SimulationManager, "_simulate_full", classmethod(lambda cls: None)), + patch.object(SimulationManager, "_simulate_physics_only", classmethod(lambda cls: None)), + ): + sim.step(render=False) + wp.synchronize_device(device) + + torch.testing.assert_close(wp.to_torch(state.joint_q), joint_q_before) + torch.testing.assert_close(wp.to_torch(state.joint_qd), joint_qd_before) + assert torch.count_nonzero(warm_start[0]).item() == 0 + torch.testing.assert_close(warm_start[1], torch.full_like(warm_start[1], 29.0)) diff --git a/source/isaaclab_newton/test/sim/test_newton_schemas.py b/source/isaaclab_newton/test/sim/test_newton_schemas.py index 1774792d3d0..0f78c8e3950 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_newton/test/sim/test_views_xform_prim_newton.py b/source/isaaclab_newton/test/sim/test_views_xform_prim_newton.py index d114a1da2a8..174f0684fb5 100644 --- a/source/isaaclab_newton/test/sim/test_views_xform_prim_newton.py +++ b/source/isaaclab_newton/test/sim/test_views_xform_prim_newton.py @@ -215,7 +215,8 @@ def test_world_attached_set_world_roundtrip(device): new_pos = _wp_vec3f([[10.0, 20.0, 30.0]], device=device) new_quat = _wp_vec4f([[0.0, 0.0, 0.0, 1.0]], device=device) - view.set_world_poses(new_pos, new_quat) + with view.xform_world_space_writer() as w: + w.set_poses(new_pos, new_quat) ret_pos, ret_quat = view.get_world_poses() torch.testing.assert_close(ret_pos.torch, wp.to_torch(new_pos), atol=1e-5, rtol=0) diff --git a/source/isaaclab_ov/changelog.d/jmart-cartpole-rtx.minor.rst b/source/isaaclab_ov/changelog.d/jmart-cartpole-rtx.minor.rst deleted file mode 100644 index 86cfb672bf0..00000000000 --- a/source/isaaclab_ov/changelog.d/jmart-cartpole-rtx.minor.rst +++ /dev/null @@ -1,6 +0,0 @@ -Added -^^^^^ - -* Overrode :meth:`provides_temporal_camera_data` on :class:`OVRTXRenderer` to return ``True`` - only for the ``rgb``/``rgba`` beauty buffer (temporally accumulated by DLSS), matching Isaac RTX; - other AOVs return ``False``. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index e4ec845231d..5cdcc26e977 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.4.6" +version = "0.7.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 4307aa7707a..2293b257488 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,116 @@ Changelog --------- +0.7.0 (2026-07-09) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added semantic-segmentation label metadata to the OVRTX renderer. The + ``SemanticIdMap`` render var is now decoded into + ``camera.data.info["semantic_segmentation"]["idToLabels"]``, matching the + Isaac RTX / Replicator contract (keys are semantic IDs or RGBA colors, values + are ``{semantic_type: label}`` dicts, always including ``BACKGROUND`` and + ``UNLABELLED``). +* Added :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_semantic_segmentation` + to select between colorized RGBA (``uint8``) and raw ``int32`` semantic-ID + output, at parity with the Isaac RTX renderer. + +Fixed +^^^^^ + +* Fixed the OVRTX segmentation colorization hash to use 32-bit wraparound + arithmetic (it previously widened to ``uint64``, changing the hashed bits). + Colorized semantic and instance segmentation IDs now map to the same colors + as ``omni.replicator`` / the Isaac RTX renderer. + + +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) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the ``ISAAC_LAB_OVRTX_READ_GPU_TRANSFORMS`` environment variable to control whether + :class:`~isaaclab_ov.renderers.OVRTXRenderer` enables OVRTX GPU transform reads. + + +0.5.2 (2026-06-26) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``"normals"`` support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. The renderer now + declares :attr:`~isaaclab.renderers.RenderBufferKind.NORMALS` in + :meth:`~isaaclab_ov.renderers.OVRTXRenderer.supported_output_types` (3-channel ``float32``) and + extracts the ``NormalSD`` AOV from each rendered frame into the output buffer. + + +0.5.1 (2026-06-25) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed ``distance_to_camera`` incorrectly mapping to ``DistanceToImagePlaneSD`` in the OVRTX + renderer backend. It now correctly uses ``DistanceToCameraSD``, matching the intended semantics + of eye-space ray length versus perpendicular image-plane distance. + + +0.5.0 (2026-06-24) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Overrode :meth:`provides_temporal_camera_data` on :class:`OVRTXRenderer` to return ``True`` + only for the ``rgb``/``rgba`` beauty buffer (temporally accumulated by DLSS), matching Isaac RTX; + other AOVs return ``False``. + + 0.4.6 (2026-06-16) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_annotator_utils.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_annotator_utils.py new file mode 100644 index 00000000000..f0178eaa06e --- /dev/null +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_annotator_utils.py @@ -0,0 +1,150 @@ +# 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 + +"""Semantic annotator utilities for the OVRTX renderer. + +Decodes the OVRTX ``SemanticIdMap`` render var into an ``idToLabels`` mapping compatible with the +Isaac RTX / Replicator contract exposed through ``camera.data.info["semantic_segmentation"]``. +""" + +from __future__ import annotations + +import numpy as np +import warp as wp + +from .ovrtx_renderer_kernels import generate_random_colors_from_ids_kernel + +# Reserved semantic IDs shared by the OVRTX SemanticSegmentation AOV and Isaac RTX / Replicator: +# ID 0 is BACKGROUND (no prim), ID 1 is UNLABELLED (a prim with no matching semantic label). Entries +# with ID >= 2 are decoded from the SemanticIdMap render var. +SEMANTIC_ID_BACKGROUND = 0 +SEMANTIC_ID_UNLABELLED = 1 +RESERVED_SEMANTIC_LABELS: dict[int, dict[str, str]] = { + SEMANTIC_ID_BACKGROUND: {"class": "BACKGROUND"}, + SEMANTIC_ID_UNLABELLED: {"class": "UNLABELLED"}, +} + + +def parse_semantic_label(raw_label: str) -> dict[str, str]: + """Parse a raw OVRTX semantic label string into a ``{semantic_type: label}`` dict. + + OVRTX encodes labels as ``":