diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-newton-env-tests-kitless.skip b/source/isaaclab_tasks/changelog.d/jichuanh-newton-env-tests-kitless.skip new file mode 100644 index 000000000000..cfba57f0bb31 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/jichuanh-newton-env-tests-kitless.skip @@ -0,0 +1,3 @@ +Test-only: ran the Newton environment smoke tests kit-less, split the Kit-requiring tasks into +their own file, and limited OpenUSD's work-thread pool to work around the usd-core<26.5 physics +parser race that aborted the process during the Newton USD import. diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/parse_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/utils/parse_cfg.py index a5da603ae3f7..7f7d996c815e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/parse_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/parse_cfg.py @@ -13,6 +13,7 @@ import os import re import warnings +from collections.abc import Sequence from typing import TYPE_CHECKING import gymnasium as gym @@ -144,7 +145,11 @@ def load_cfg_from_registry(task_name: str, entry_point_key: str) -> dict | objec def parse_env_cfg( - task_name: str, device: str = "cuda:0", num_envs: int | None = None, use_fabric: bool | None = None + task_name: str, + device: str = "cuda:0", + num_envs: int | None = None, + use_fabric: bool | None = None, + presets: Sequence[str] = (), ) -> ManagerBasedRLEnvCfg | DirectRLEnvCfg: """Parse configuration for an environment and override based on inputs. @@ -155,6 +160,10 @@ def parse_env_cfg( use_fabric: Whether to enable/disable fabric interface. If false, all read/write operations go through USD. This slows down the simulation but allows seeing the changes in the USD through the USD stage. Defaults to None, in which case it is left unchanged. + presets: Preset names to select while resolving :class:`PresetCfg` wrappers, mirroring the + Hydra ``presets=`` CLI tokens. Defaults to an empty selection, which resolves to each + wrapper's default. A selection must be passed here rather than applied afterwards, + because the returned config no longer carries the wrappers to choose from. Returns: The parsed configuration object. @@ -171,12 +180,12 @@ def parse_env_cfg( if isinstance(cfg, dict): raise RuntimeError(f"Configuration for the task: '{task_name}' is not a class. Please provide a class.") - # Resolve any PresetCfg wrappers to their default preset so the config - # is usable without a Hydra CLI override (e.g. in tests). + # Resolve any PresetCfg wrappers, honoring the requested presets and otherwise falling back to + # each wrapper's default so the config is usable without a Hydra CLI override (e.g. in tests). # Must happen BEFORE attribute overrides, otherwise overrides on PresetCfg wrapper # fields (e.g. cfg.scene when scene is a PresetCfg) get discarded when the wrapper # is replaced by its .default. - cfg = resolve_presets(cfg) + cfg = resolve_presets(cfg, presets) # simulation device cfg.sim.device = device diff --git a/source/isaaclab_tasks/test/core/test_environments_newton.py b/source/isaaclab_tasks/test/core/test_environments_newton.py index f0ea231cb989..44f055136ecc 100644 --- a/source/isaaclab_tasks/test/core/test_environments_newton.py +++ b/source/isaaclab_tasks/test/core/test_environments_newton.py @@ -3,16 +3,22 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" +"""Newton environment smoke tests. -from isaaclab.app import AppLauncher +The MJWarp preset is a kit-less backend, so this suite runs without ``AppLauncher``. Tasks carrying +Kit camera sensors still need the Kit runtime and are filtered out; ``test_environments.py`` already +smoke-tests each of them on its default backend. +""" -# launch the simulator -app_launcher = AppLauncher(headless=True, enable_cameras=True) -simulation_app = app_launcher.app +import os - -"""Rest everything follows.""" +# Limit OpenUSD's work-thread pool to one thread to avoid a race condition in usd-core<26.5, which +# corrupts native state while collecting collider descriptors and aborts the process during the +# Newton USD import. Set at module scope rather than in a fixture, unlike the same workaround in +# rendering_test_utils.py: kit-less, OpenUSD builds its task arena while tests are collected, and +# WorkSetConcurrencyLimit does not shrink an arena that already exists. +# TODO: Remove once usd-core>=26.5 is the minimum - that release fixes the race condition. +os.environ.setdefault("PXR_WORK_THREAD_LIMIT", "1") import pytest @@ -30,6 +36,8 @@ multi_agent=False, newton_mjwarp_envs=True, tier="core", + needs_kit=False, + physics_preset_name="newton_mjwarp", ), ) @pytest.mark.newton_ci diff --git a/source/isaaclab_tasks/test/core/test_parse_env_cfg_presets.py b/source/isaaclab_tasks/test/core/test_parse_env_cfg_presets.py new file mode 100644 index 000000000000..ec2caa6c8d5f --- /dev/null +++ b/source/isaaclab_tasks/test/core/test_parse_env_cfg_presets.py @@ -0,0 +1,47 @@ +# 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 that :func:`parse_env_cfg` honors a requested preset selection. + +A selection has to be supplied while the ``PresetCfg`` wrappers are being resolved: the returned +config no longer carries the alternatives, so applying a preset afterwards silently keeps the +default. These cases use tasks whose default backend is *not* the requested one, so a dropped +selection fails instead of coincidentally matching. +""" + +import pytest +from isaaclab_newton.physics import NewtonCfg +from isaaclab_physx.physics import PhysxCfg + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils.parse_cfg import parse_env_cfg + +pytestmark = pytest.mark.unit + +# Defaults to PhysX and offers a newton_mjwarp preset, so the selection has to do real work. +_PHYSX_DEFAULT_TASK = "Isaac-Velocity-Flat-G1" + + +def test_parse_env_cfg_defaults_to_the_preset_default() -> None: + """Without a selection, a wrapper still resolves to its default.""" + env_cfg = parse_env_cfg(_PHYSX_DEFAULT_TASK) + + assert isinstance(env_cfg.sim.physics, PhysxCfg) + + +def test_parse_env_cfg_applies_requested_physics_preset() -> None: + """A requested preset must win over the task's default backend.""" + env_cfg = parse_env_cfg(_PHYSX_DEFAULT_TASK, presets=("newton_mjwarp",)) + + assert isinstance(env_cfg.sim.physics, NewtonCfg) + + +def test_parse_env_cfg_applies_preset_alongside_other_overrides() -> None: + """Selecting a preset must not discard the device and num_envs overrides.""" + env_cfg = parse_env_cfg(_PHYSX_DEFAULT_TASK, device="cuda:0", num_envs=3, presets=("newton_mjwarp",)) + + assert isinstance(env_cfg.sim.physics, NewtonCfg) + assert env_cfg.sim.device == "cuda:0" + assert env_cfg.scene.num_envs == 3 diff --git a/source/isaaclab_tasks/test/env_test_utils.py b/source/isaaclab_tasks/test/env_test_utils.py index 5e6405d343b6..5f8f1bd7a2a2 100644 --- a/source/isaaclab_tasks/test/env_test_utils.py +++ b/source/isaaclab_tasks/test/env_test_utils.py @@ -19,7 +19,6 @@ from isaaclab.sim import SimulationContext from isaaclab.utils.version import get_isaac_sim_version -from isaaclab_tasks.utils.hydra import apply_overrides, collect_presets from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry, parse_env_cfg # Map of task IDs to the reason for marking the corresponding parametrized @@ -103,6 +102,26 @@ def _has_physics_preset(raw_cfg, preset_name: str) -> bool: return physics is not None and hasattr(physics, preset_name) +def _task_needs_kit(task_id: str, physics_preset_name: str | None = None) -> bool: + """Check whether a task's resolved config requires the Isaac Sim Kit runtime. + + Derived from the same scan :func:`~isaaclab.app.launch_simulation` performs, so the + kit-less split follows the launcher instead of a hand-maintained task list. + + Args: + task_id: Registered task ID. + physics_preset_name: Physics preset applied before scanning, since the backend + selection decides whether Kit is needed. + + Returns: + True if the task needs the Kit runtime. + """ + from isaaclab.app import sim_launcher + + env_cfg = parse_env_cfg(task_id, presets=(physics_preset_name,) if physics_preset_name else ()) + return bool(sim_launcher.scan(env_cfg, {}).needs_kit) + + def setup_environment( include_play: bool = False, factory_envs: bool | None = None, @@ -112,6 +131,8 @@ def setup_environment( pickplace_stack_envs: bool | None = None, newton_mjwarp_envs: bool | None = None, tier: str | None = None, + needs_kit: bool | None = None, + physics_preset_name: str | None = None, ) -> list[str]: """ Acquire all registered Isaac environment task IDs with optional filters. @@ -146,6 +167,12 @@ def setup_environment( - "core": include only core environments (registered under ``isaaclab_tasks.core``). - "contrib": include only contributed environments (registered under ``isaaclab_tasks.contrib``). - None: include all environments regardless of tier. + needs_kit: + - True: include only environments whose resolved config needs the Kit runtime. + - False: include only environments that run kit-less. + - None: include all environments regardless of Kit requirement. + physics_preset_name: Physics preset applied before deciding the Kit requirement, since the + backend selection determines whether Kit is needed. Only used with ``needs_kit``. Returns: A sorted list of task IDs matching the selected filters. @@ -222,6 +249,11 @@ def setup_environment( continue # if None: no filter + # apply Kit-runtime filter + if needs_kit is not None and _task_needs_kit(task_spec.id, physics_preset_name) != needs_kit: + continue + # if None: no filter + registered_tasks.append(task_spec.id) # sort environments alphabetically @@ -290,8 +322,10 @@ def _run_environments( If None, uses the environment's default physics. """ - # skip test if stage in memory is not supported - if get_isaac_sim_version().major < 5 and create_stage_in_memory: + # skip test if stage in memory is not supported. Only query the Isaac Sim version when the + # answer can matter: get_isaac_sim_version() imports isaacsim, which a kit-less caller does + # not have. + if create_stage_in_memory and get_isaac_sim_version().major < 5: pytest.skip("Stage in memory is not supported in this version of Isaac Sim") # skip suction gripper environments as they require CPU simulation and cannot be run with GPU simulation @@ -370,20 +404,13 @@ def _check_random_actions( get_settings_manager().set_bool("/isaaclab/render/rtx_sensors", False) env = None try: - # parse config - env_cfg = parse_env_cfg(task_name, device=device, num_envs=num_envs) - # apply physics preset override before creating the environment - if physics_preset_name is not None: - # parse_env_cfg already resolved PresetCfg wrappers to their default, - # so we load the raw config to retrieve preset alternatives. - raw_cfg = load_cfg_from_registry(task_name, "env_cfg_entry_point") - presets = {"env": collect_presets(raw_cfg), "agent": {}} - hydra_cfg = {"env": env_cfg.to_dict(), "agent": None} - apply_overrides(env_cfg, None, hydra_cfg, [physics_preset_name], [], [], presets) - # Re-apply num_envs since apply_overrides may have replaced - # the scene config with the preset's default num_envs. - if num_envs is not None: - env_cfg.scene.num_envs = num_envs + # parse config, selecting the requested physics preset as it is resolved + env_cfg = parse_env_cfg( + task_name, + device=device, + num_envs=num_envs, + presets=(physics_preset_name,) if physics_preset_name else (), + ) # set config args env_cfg.sim.create_stage_in_memory = create_stage_in_memory if disable_clone_in_fabric: