From 3c485d9e23b08688bac3f4613dc3c3ef04e36f1a Mon Sep 17 00:00:00 2001 From: ooctipus Date: Thu, 2 Jul 2026 13:38:32 -0700 Subject: [PATCH 01/23] Fix depth-only cameras blacking out GUI viewport (#6304) # Description Depth-only RTX cameras enable the global `/rtx/sdg/force/disableColorRender` optimization. The renderer already disables that optimization when `/isaaclab/has_gui` is true, but a visualizer refactor removed the last writer of that GUI setting. As a result, the setting remained unset and adding a depth-only camera could black out the GUI viewport. This change publishes `/isaaclab/has_gui` from `AppLauncher` before `SimulationContext`, PhysX, and RTX renderer initialization. It preserves the historical semantics: a local window, livestream, or XR session counts as a GUI. Truly headless camera runs retain the depth-only rendering optimization. Regression coverage verifies the launcher state matrix and both GUI and headless depth-only renderer behavior. No additional dependencies are required. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots Not applicable. The behavior is covered by focused setting and renderer contract tests. ## Testing - `./isaaclab.sh -p -m pytest source/isaaclab/test/app/test_kwarg_launch.py::test_load_extensions_publishes_has_gui_setting source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py -q` (7 passed) - `pre-commit run --files source/isaaclab/test/app/test_kwarg_launch.py source/isaaclab/isaaclab/app/app_launcher.py source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip` - `python3 tools/changelog/cli.py check develop` ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks on every changed file - [x] Documentation changes are not required for this internal behavior fix - [x] My changes generate no new warnings - [x] I have added tests that prove the fix is effective - [x] I have added changelog fragments for every touched package - [x] My name is already present in `CONTRIBUTORS.md` --- .../zhengyuz-fix-depth-camera-viewport.rst | 5 ++ source/isaaclab/isaaclab/app/app_launcher.py | 4 ++ source/isaaclab/test/app/test_kwarg_launch.py | 35 ++++++++++++ ...depth-camera-viewport-color-rendering.skip | 1 + .../test_isaac_rtx_renderer_contract.py | 55 ++++++++++++++++++- 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst create mode 100644 source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip diff --git a/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst b/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst new file mode 100644 index 000000000000..9fc0987aa4a7 --- /dev/null +++ b/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed depth-only RTX cameras disabling color rendering in the viewport by restoring GUI-state publication from + :class:`~isaaclab.app.AppLauncher`. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 99215b696afd..1428e0f763f0 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -1263,6 +1263,10 @@ def _load_extensions(self): logging.getLogger().setLevel(logging.INFO) settings = get_settings_manager() + # Publish whether Kit has an interactive GUI (local window, livestream, or XR). + # SimulationContext and renderers consume this setting during their initialization. + settings.set_bool("/isaaclab/has_gui", not self._headless or self._livestream >= 1 or self._xr) + # set setting to indicate Isaac Lab's offscreen_render pipeline should be enabled settings.set_bool("/isaaclab/render/offscreen", self._offscreen_render) diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index 1eb5b59764ac..e5437619c8c7 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause import argparse +import logging import pytest @@ -54,6 +55,40 @@ def set_bool(self, path: str, value: bool) -> None: self.values[path] = value +@pytest.mark.parametrize( + ("headless", "livestream", "xr", "expected_has_gui"), + [ + pytest.param(False, 0, False, True, id="local-window"), + pytest.param(True, 0, False, False, id="headless"), + pytest.param(True, 1, False, True, id="livestream"), + pytest.param(True, 0, True, True, id="xr"), + ], +) +def test_load_extensions_publishes_has_gui_setting( + monkeypatch: pytest.MonkeyPatch, headless: bool, livestream: int, xr: bool, expected_has_gui: bool +): + """Publish the GUI state consumed by SimulationContext and RTX rendering.""" + launcher = AppLauncher.__new__(AppLauncher) + launcher._apply_rtx_determinism = False + launcher._python_logging_level = logging.ERROR + launcher._headless = headless + launcher._livestream = livestream + launcher._enable_cameras = False + launcher._offscreen_render = False + launcher._render_viewport = False + launcher._xr = xr + launcher._video_enabled = False + + settings = _DummySettings() + monkeypatch.setattr(app_launcher_module, "initialize_carb_settings", lambda: None) + monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) + monkeypatch.setattr(AppLauncher, "_apply_python_logging_level", lambda _level: None) + + launcher._load_extensions() + + assert settings.values["/isaaclab/has_gui"] is expected_has_gui + + def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): settings = _DummySettings() monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) diff --git a/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip b/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip new file mode 100644 index 000000000000..7c3719525336 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip @@ -0,0 +1 @@ +Test-only: added regression coverage for GUI and headless depth-only color-render behavior. diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index 7f16b2f80343..65cd093ce332 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -9,7 +9,8 @@ import sys import types -from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch import pytest import warp as wp @@ -52,3 +53,55 @@ def test_isaac_rtx_supported_output_types_include_rgb_hdr(monkeypatch): specs = renderer.supported_output_types() assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) + + +@pytest.mark.parametrize( + ("has_gui", "expected_disable_color_render"), + [ + pytest.param(True, False, id="gui-keeps-color-rendering"), + pytest.param(False, True, id="headless-uses-depth-only-rendering"), + ], +) +def test_depth_only_camera_color_render_setting(monkeypatch, has_gui, expected_disable_color_render): + """Depth-only cameras must not disable color rendering for an active GUI. + + ``disableColorRender`` is a global RTX setting, so enabling it for a depth-only + camera also blacks out the viewport. Headless execution should retain the + depth-only optimization. + """ + _, syntheticdata_module = _install_omni_stubs(monkeypatch) + monkeypatch.setattr(syntheticdata_module, "SyntheticData", MagicMock(), raising=False) + + import isaaclab_physx.renderers.isaac_rtx_renderer as rtx_renderer + from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import IsaacRtxRendererCfg + + import isaaclab.sim.utils.stage as stage_utils + + settings = MagicMock() + settings.get.return_value = has_gui + + # Camera validation terminates create_render_data immediately after the + # color-render setting is selected, keeping this a lightweight unit test. + stage = MagicMock() + stage.GetPrimAtPath.return_value.IsA.return_value = False + spec = SimpleNamespace( + camera_prim_paths=["/World/NotACamera"], + cfg=SimpleNamespace(data_types=["depth"]), + ) + renderer = rtx_renderer.IsaacRtxRenderer.__new__(rtx_renderer.IsaacRtxRenderer) + renderer.cfg = IsaacRtxRendererCfg() + + with ( + patch.object(rtx_renderer, "get_settings_manager", return_value=settings), + patch.object(rtx_renderer, "get_isaac_sim_version", return_value=version.parse("6.0")), + patch.object(stage_utils, "get_current_stage", return_value=stage), + pytest.raises(RuntimeError, match="is not a Camera"), + ): + renderer.create_render_data(spec) + + color_render_calls = [ + setting_call + for setting_call in settings.set_bool.call_args_list + if setting_call.args[0] == "/rtx/sdg/force/disableColorRender" + ] + assert color_render_calls[-1] == call("/rtx/sdg/force/disableColorRender", expected_disable_color_render) From 30a394ec0fbf0490170b41a7c60a6cdf593eedf7 Mon Sep 17 00:00:00 2001 From: Richard Lei Date: Fri, 3 Jul 2026 15:19:54 +1200 Subject: [PATCH 02/23] Consolidate OVRTX tile-extraction kernels (#6331) --- ...tx-consolidate-tile-extraction-kernels.rst | 5 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 118 ++++++--------- .../renderers/ovrtx_renderer_kernels.py | 140 ++++++----------- .../test/test_ovrtx_renderer_contract.py | 33 ++++ .../test/test_ovrtx_renderer_kernels.py | 141 +++++++++++++++--- 5 files changed, 247 insertions(+), 190 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst diff --git a/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst b/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst new file mode 100644 index 000000000000..af7a7e95b0ae --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Consolidated the OVRTX tile-extraction Warp kernels into a single generic + :func:`~isaaclab_ov.renderers.ovrtx_renderer_kernels.extract_all_tiles_kernel`. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 38ae6b41a1f2..61bf985646aa 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -60,11 +60,7 @@ from .ovrtx_renderer_cfg import OVRTXRendererCfg from .ovrtx_renderer_kernels import ( create_camera_transforms_kernel, - extract_all_depth_tiles_kernel, - extract_all_rgb_float_tiles_kernel, - extract_all_rgb_half_tiles_kernel, - extract_all_rgba_tiles_kernel, - extract_all_uint32_tiles_kernel, + extract_all_tiles_kernel, generate_random_colors_from_ids_kernel, sync_newton_transforms_kernel, ) @@ -728,18 +724,47 @@ def _process_id_segmentation_render_var( if data_torch.dim() == 2: data_torch = data_torch.unsqueeze(-1) tiled_data = wp.from_torch(data_torch, dtype=wp.uint32) - wp.launch( - kernel=extract_all_uint32_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_data, - output_buffers[buffer_key], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_data, output_buffers[buffer_key]) + + def _launch_extract_all_tiles( + self, render_data: OVRTXRenderData, tiled_buffer: wp.array, output_buffer: wp.array + ) -> None: + """Launch ``extract_all_tiles_kernel`` for one tiled/output buffer pair. + + This is the only place that should launch ``extract_all_tiles_kernel``: it validates that + ``output_buffer`` cannot read past the end of ``tiled_buffer`` (the kernel derives its per-thread + channel loop bound from ``output_buffer``'s last dimension) before every launch, so callers cannot + accidentally skip the check. + + Args: + render_data: OVRTX render data for the current frame. + tiled_buffer: 3D array of shape (H, W, C) holding all tiles packed into one buffer. + output_buffer: 4D array of shape (num_envs, H, W, C) to receive the per-env tiles, with C no + greater than ``tiled_buffer``'s channel count. + + Raises: + ValueError: If ``output_buffer``'s channel count exceeds ``tiled_buffer``'s. + """ + tiled_channels = tiled_buffer.shape[-1] + output_channels = output_buffer.shape[-1] + if output_channels > tiled_channels: + raise ValueError( + f"Output buffer has {output_channels} channels but the tiled buffer only has {tiled_channels};" + " extract_all_tiles_kernel would read out of bounds." + ) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(render_data.num_envs, render_data.height, render_data.width), + inputs=[ + tiled_buffer, + output_buffer, + render_data.num_cols, + render_data.width, + render_data.height, + ], + device=self._device, + ) def _extract_rgba_tiles( self, @@ -755,19 +780,7 @@ def _extract_rgba_tiles( if num_channels not in (3, 4): raise ValueError(f"Expected RGB (3 channels) or RGBA (4 channels), got {num_channels}") - wp.launch( - kernel=extract_all_rgba_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_data, - output_buffer, - render_data.num_cols, - render_data.width, - render_data.height, - num_channels, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_data, output_buffer) def _extract_depth_tiles( self, render_data: OVRTXRenderData, tiled_depth_data: wp.array, output_buffers: dict @@ -775,18 +788,7 @@ def _extract_depth_tiles( """Extract per-env depth tiles into output_buffers (single kernel launch).""" for depth_type in ["depth", "distance_to_image_plane", "distance_to_camera"]: if depth_type in output_buffers: - wp.launch( - kernel=extract_all_depth_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_depth_data, - output_buffers[depth_type], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_depth_data, output_buffers[depth_type]) def _extract_hdr_color_tiles( self, render_data: OVRTXRenderData, tiled_data: wp.array, output_buffers: dict @@ -794,24 +796,9 @@ def _extract_hdr_color_tiles( """Extract per-env HdrColor tiles into output_buffers.""" if "rgb_hdr" not in output_buffers: return - if tiled_data.dtype == wp.float16: - kernel = extract_all_rgb_half_tiles_kernel - elif tiled_data.dtype == wp.float32: - kernel = extract_all_rgb_float_tiles_kernel - else: + if tiled_data.dtype not in (wp.float16, wp.float32): raise TypeError(f"Unsupported OVRTX HdrColor dtype: {tiled_data.dtype}.") - wp.launch( - kernel=kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_data, - output_buffers["rgb_hdr"], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_data, output_buffers["rgb_hdr"]) def _prepare_ppisp_hdr_source( self, render_data: OVRTXRenderData, tiled_data: wp.array, output_buffers: dict @@ -905,18 +892,7 @@ def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buff if "NormalSD" in frame.render_vars and "normals" in output_buffers: with frame.render_vars["NormalSD"].map(device=Device.CUDA) as mapping: tiled_normals_data = wp.from_dlpack(mapping.tensor) - wp.launch( - kernel=extract_all_rgb_float_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_normals_data, - output_buffers["normals"], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_normals_data, output_buffers["normals"]) def render(self, render_data: OVRTXRenderData) -> None: """Render the scene into the provided RenderData.""" diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py index e7f56eff04e9..15037dd5d7cf 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py @@ -5,6 +5,8 @@ """Warp kernels for OVRTX rendering pipeline.""" +from typing import Any + import warp as wp @@ -72,92 +74,32 @@ def extract_tile_from_tiled_buffer_kernel( @wp.kernel -def extract_all_rgba_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.uint8, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.uint8, ndim=4), # type: ignore - num_cols: int, - tile_width: int, - tile_height: int, - num_channels: int, -): - """Extract ALL RGBA or RGB tiles from a tiled buffer in a single kernel launch. - - Args: - tiled_buffer: 3D uint8 array of shape (H, W, 4) for RGBA or (H, W, 3) for RGB. - output_buffer: 4D uint8 array of shape (num_envs, H, W, 4) for RGBA or (num_envs, H, W, 3) for RGB. - num_cols: number of columns in the tiled buffer. - tile_width: width of each tile. - tile_height: height of each tile. - num_channels: number of channels in the output buffer. Use 3 for RGB or 4 for RGBA. - If a value other than 3 or 4 is given, it will be treated as 3 (RGB). - """ - env_idx, y, x = wp.tid() - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - src_x = tile_x * tile_width + x - src_y = tile_y * tile_height + y - - # RGB - output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] - output_buffer[env_idx, y, x, 1] = tiled_buffer[src_y, src_x, 1] - output_buffer[env_idx, y, x, 2] = tiled_buffer[src_y, src_x, 2] - - # Alpha (if it is RGBA) - if num_channels == 4: - output_buffer[env_idx, y, x, 3] = tiled_buffer[src_y, src_x, 3] - - -@wp.kernel -def extract_all_rgb_float_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.float32, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore (num_envs, H, W, 3) - num_cols: int, - tile_width: int, - tile_height: int, -): - """Extract ALL RGB float tiles from a tiled buffer in a single kernel launch.""" - env_idx, y, x = wp.tid() - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - src_x = tile_x * tile_width + x - src_y = tile_y * tile_height + y - output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] - output_buffer[env_idx, y, x, 1] = tiled_buffer[src_y, src_x, 1] - output_buffer[env_idx, y, x, 2] = tiled_buffer[src_y, src_x, 2] - - -@wp.kernel -def extract_all_rgb_half_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.float16, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore (num_envs, H, W, 3) +def extract_all_tiles_kernel( + tiled_buffer: wp.array(dtype=Any, ndim=3), # type: ignore + output_buffer: wp.array(dtype=Any, ndim=4), # type: ignore num_cols: int, tile_width: int, tile_height: int, ): - """Extract ALL RGB half tiles into float32 output in a single kernel launch.""" - env_idx, y, x = wp.tid() - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - src_x = tile_x * tile_width + x - src_y = tile_y * tile_height + y - output_buffer[env_idx, y, x, 0] = wp.float32(tiled_buffer[src_y, src_x, 0]) - output_buffer[env_idx, y, x, 1] = wp.float32(tiled_buffer[src_y, src_x, 1]) - output_buffer[env_idx, y, x, 2] = wp.float32(tiled_buffer[src_y, src_x, 2]) + """Extract ALL tiles from a tiled buffer into per-env tiles in a single kernel launch. + Generic over the tile channel layout (RGB, RGBA, or single-channel depth) and dtype (e.g. uint8 color, + float32 depth/HDR color, or float16 HDR color widened to float32 on output). The channel count is taken + from ``output_buffer`` rather than passed explicitly, and each element is cast to the output dtype, so the + same kernel body serves every tile-extraction case; see the :func:`warp.overload` registrations below for + the concrete dtype pairs this is compiled for. -@wp.kernel -def extract_all_depth_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.float32, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore - num_cols: int, - tile_width: int, - tile_height: int, -): - """Extract all depth tiles from a tiled buffer in a single kernel launch. + Precondition: + ``output_buffer``'s channel count (last dimension) must not exceed ``tiled_buffer``'s (the per-thread + channel loop below indexes ``tiled_buffer`` up to that bound). In this package, always launch this + kernel through ``OVRTXRenderer._launch_extract_all_tiles``, which validates this before every launch + instead of relying on each call site to remember to check. Passing a wider output buffer than the + tiled input reads out of bounds on the GPU. Args: - tiled_buffer: 3D float32 array of shape (H, W, 1) for depth. - output_buffer: 4D float32 array of shape (num_envs, H, W, 1) for depth. + tiled_buffer: 3D array of shape (H, W, C) holding all tiles packed into one buffer. + output_buffer: 4D array of shape (num_envs, H, W, C) to receive the per-env tiles, with C no greater + than ``tiled_buffer``'s channel count. num_cols: number of columns in the tiled buffer. tile_width: width of each tile. tile_height: height of each tile. @@ -167,7 +109,30 @@ def extract_all_depth_tiles_kernel( tile_y = env_idx // num_cols src_x = tile_x * tile_width + x src_y = tile_y * tile_height + y - output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] + for channel in range(output_buffer.shape[3]): + output_buffer[env_idx, y, x, channel] = output_buffer.dtype(tiled_buffer[src_y, src_x, channel]) + + +# uint8 color tiles (e.g. RGB/RGBA, semantic segmentation). +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.uint8, ndim=3), wp.array(dtype=wp.uint8, ndim=4), int, int, int], +) +# float32 tiles (e.g. depth, normals, HDR color). +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.float32, ndim=3), wp.array(dtype=wp.float32, ndim=4), int, int, int], +) +# float16 tiles (e.g. HDR color), widened to a float32 output buffer. +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.float16, ndim=3), wp.array(dtype=wp.float32, ndim=4), int, int, int], +) +# uint32 tiles (e.g. raw instance segmentation IDs). +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.uint32, ndim=3), wp.array(dtype=wp.uint32, ndim=4), int, int, int], +) @wp.kernel @@ -294,23 +259,6 @@ def random_color_from_id(input_id: wp.uint32) -> wp.uint32: return color -@wp.kernel -def extract_all_uint32_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.uint32, ndim=3), # type: ignore (TH, TW, 1) - output_buffer: wp.array(dtype=wp.uint32, ndim=4), # type: ignore (num_envs, H, W, 1) - num_cols: int, - tile_width: int, - tile_height: int, -): - """Extract all single-channel uint32 tiles (e.g. raw instance segmentation IDs).""" - env_idx, y, x = wp.tid() - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - src_x = tile_x * tile_width + x - src_y = tile_y * tile_height + y - output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] - - @wp.kernel def generate_random_colors_from_ids_kernel( input_ids: wp.array(dtype=wp.uint32, ndim=3), # type: ignore diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 0c918f76ed36..3520a78bea38 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -210,6 +210,39 @@ def fake_clone(src, *, device): assert clone_calls == [(source, "cuda:0")] +class _FakeArray: + def __init__(self, shape): + self.shape = shape + + +def test_launch_extract_all_tiles_rejects_wider_output_channels(): + """An output wider than the tiled input would read out of bounds, so it must raise before launching.""" + renderer = _make_ovrtx_renderer_without_backend() + renderer._device = "cpu" + render_data = _make_ovrtx_render_data() + + with pytest.raises(ValueError, match="out of bounds"): + renderer._launch_extract_all_tiles(render_data, _FakeArray((8, 16, 3)), _FakeArray((2, 8, 16, 4))) + + +def test_launch_extract_all_tiles_launches_kernel_when_channels_are_compatible(monkeypatch): + """Equal or narrower output channel counts pass validation and reach the kernel launch.""" + renderer = _make_ovrtx_renderer_without_backend() + renderer._device = "cpu" + render_data = _make_ovrtx_render_data() + render_data.num_cols = 2 + + launch_calls = [] + monkeypatch.setattr(wp, "launch", lambda **kwargs: launch_calls.append(kwargs)) + + tiled_buffer = _FakeArray((8, 16, 4)) + output_buffer = _FakeArray((2, 8, 16, 3)) + renderer._launch_extract_all_tiles(render_data, tiled_buffer, output_buffer) + + assert len(launch_calls) == 1 + assert launch_calls[0]["inputs"][:2] == [tiled_buffer, output_buffer] + + def test_ovrtx_read_output_is_a_no_op_after_consolidation(): """OVRTXRenderer.read_output is a no-op once set_outputs wires up zero-copy.""" renderer = _make_ovrtx_renderer_without_backend() diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py index 301b7efa0c14..27099d25cd7c 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py @@ -11,10 +11,7 @@ import pytest import warp as wp from isaaclab_ov.renderers.ovrtx_renderer_kernels import ( - extract_all_depth_tiles_kernel, - extract_all_rgb_float_tiles_kernel, - extract_all_rgb_half_tiles_kernel, - extract_all_rgba_tiles_kernel, + extract_all_tiles_kernel, generate_random_colors_from_ids_kernel, ) @@ -93,6 +90,26 @@ def _reference_extract_all_depth_tiles( return out +def _reference_extract_all_uint32_tiles( + tiled_np: np.ndarray, + num_envs: int, + num_cols: int, + tile_width: int, + tile_height: int, +) -> np.ndarray: + """NumPy reference for the uint32 (e.g. raw instance segmentation ID) case of ``extract_all_tiles_kernel``.""" + out = np.zeros((num_envs, tile_height, tile_width, 1), dtype=np.uint32) + for env_idx in range(num_envs): + tile_x = env_idx % num_cols + tile_y = env_idx // num_cols + for y in range(tile_height): + for x in range(tile_width): + src_y = tile_y * tile_height + y + src_x = tile_x * tile_width + x + out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] + return out + + def _reference_extract_all_rgba_tiles( tiled_np: np.ndarray, num_envs: int, @@ -142,7 +159,7 @@ def _reference_extract_all_rgb_float_tiles( class TestExtractAllDepthTilesKernel: - """Tests for ``extract_all_depth_tiles_kernel``.""" + """Tests for the depth case of ``extract_all_tiles_kernel``.""" def test_two_by_two_tile_grid(self): num_cols = 2 @@ -160,7 +177,7 @@ def test_two_by_two_tile_grid(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_depth_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -181,7 +198,7 @@ def test_single_tile(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_depth_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -210,7 +227,7 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_depth_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -221,8 +238,86 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): np.testing.assert_allclose(output_wp.numpy(), expected, rtol=1e-6, atol=1e-6) +class TestExtractAllUint32TilesKernel: + """Tests for the uint32 (e.g. raw instance segmentation ID) case of ``extract_all_tiles_kernel``.""" + + def test_two_by_two_tile_grid(self): + num_cols = 2 + num_envs = 4 + tile_width = 2 + tile_height = 3 + tiled_h = (num_envs // num_cols) * tile_height + tiled_w = num_cols * tile_width + rng = np.random.default_rng(98765) + tiled_np = rng.integers(0, 2**31, size=(tiled_h, tiled_w, 1), dtype=np.uint32) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_array_equal(output_wp.numpy(), expected) + + def test_single_tile(self): + num_cols = 1 + num_envs = 1 + tile_width = 4 + tile_height = 4 + tiled_np = np.arange(tile_height * tile_width, dtype=np.uint32).reshape(tile_height, tile_width, 1) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_array_equal(output_wp.numpy(), expected) + + @pytest.mark.parametrize( + ("num_cols", "num_envs", "tile_width", "tile_height"), + [ + (3, 6, 2, 2), + (1, 3, 5, 1), + (4, 8, 1, 1), + ], + ) + def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): + num_rows = (num_envs + num_cols - 1) // num_cols + tiled_h = num_rows * tile_height + tiled_w = num_cols * tile_width + rng = np.random.default_rng(13579) + tiled_np = rng.integers(0, 2**31, size=(tiled_h, tiled_w, 1), dtype=np.uint32) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_array_equal(output_wp.numpy(), expected) + + class TestExtractAllRgbaTilesKernel: - """Tests for ``extract_all_rgba_tiles_kernel``.""" + """Tests for the RGB/RGBA case of ``extract_all_tiles_kernel``.""" def test_two_by_two_tile_grid_rgba(self): num_cols = 2 @@ -244,9 +339,9 @@ def test_two_by_two_tile_grid_rgba(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, num_channels), dtype=wp.uint8, device=DEVICE) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() @@ -268,9 +363,9 @@ def test_single_tile_rgb(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, num_channels), dtype=wp.uint8, device=DEVICE) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() @@ -280,8 +375,8 @@ def test_single_tile_rgb(self): ) np.testing.assert_array_equal(output_wp.numpy(), expected) - def test_num_channels_not_four_skips_alpha(self): - """Values other than 4 use the RGB-only path (same as RGB tiled input).""" + def test_three_channel_output_skips_alpha(self): + """A 3-channel output buffer only copies RGB, even if the tiled input has an alpha channel.""" num_cols = 1 num_envs = 1 tile_width = 2 @@ -298,14 +393,14 @@ def test_num_channels_not_four_skips_alpha(self): output_wp = wp.zeros(shape=(1, 2, 2, 3), dtype=wp.uint8, device=DEVICE) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(1, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, 2], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() - expected = _reference_extract_all_rgba_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 2) + expected = _reference_extract_all_rgba_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 3) np.testing.assert_array_equal(output_wp.numpy(), expected) @pytest.mark.parametrize( @@ -333,9 +428,9 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height, num_ ) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() @@ -347,7 +442,7 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height, num_ class TestExtractAllRgbFloatTilesKernel: - """Tests for ``extract_all_rgb_float_tiles_kernel`` used by OVRTX HdrColor.""" + """Tests for the HdrColor (float32/float16) case of ``extract_all_tiles_kernel``.""" def test_two_by_two_tile_grid(self): num_cols = 2 @@ -367,7 +462,7 @@ def test_two_by_two_tile_grid(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 3), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_rgb_float_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -395,7 +490,7 @@ def test_half_input_writes_float_output(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 3), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_rgb_half_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, From 0ede6da7d1e093a2aefbc754653056a5ccd50869 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:15:41 +0000 Subject: [PATCH 03/23] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 8.0.2 → 8.0.3 - isaaclab_ov: 0.5.3 → 0.5.4 - isaaclab_ovphysx: 5.0.1 → 6.0.0 - isaaclab_rl: 0.6.2 → 0.7.0 - isaaclab_tasks: 8.1.2 → 8.1.3 --- .../changelog.d/checkpoint-selectors.rst | 4 --- .../zhengyuz-fix-depth-camera-viewport.rst | 5 ---- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 15 +++++++++++ source/isaaclab/pyproject.toml | 2 +- ...tx-consolidate-tile-extraction-kernels.rst | 5 ---- .../ovrtx-instance-segmentation-fast.rst | 13 ---------- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 24 ++++++++++++++++++ source/isaaclab_ov/pyproject.toml | 2 +- ...physx_rigidobjectcollection_view.major.rst | 9 ------- .../ovphysx-current-runtime-compat.rst | 7 ------ source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 22 ++++++++++++++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- ...depth-camera-viewport-color-rendering.skip | 1 - .../changelog.d/mh-rslrl_bump.minor.rst | 14 ----------- source/isaaclab_rl/config/extension.toml | 2 +- source/isaaclab_rl/docs/CHANGELOG.rst | 19 ++++++++++++++ source/isaaclab_rl/pyproject.toml | 2 +- .../cartpole-state-mdp-alignment.rst | 14 ----------- .../ovrtx-instance-segmentation-fast.rst | 9 ------- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 25 +++++++++++++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 25 files changed, 115 insertions(+), 91 deletions(-) delete mode 100644 source/isaaclab/changelog.d/checkpoint-selectors.rst delete mode 100644 source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst delete mode 100644 source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst delete mode 100644 source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst delete mode 100644 source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip delete mode 100644 source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst delete mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst diff --git a/source/isaaclab/changelog.d/checkpoint-selectors.rst b/source/isaaclab/changelog.d/checkpoint-selectors.rst deleted file mode 100644 index 5313cdb8e7db..000000000000 --- a/source/isaaclab/changelog.d/checkpoint-selectors.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added ``latest`` and ``best`` checkpoint selectors to unified reinforcement learning training and play commands. diff --git a/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst b/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst deleted file mode 100644 index 9fc0987aa4a7..000000000000 --- a/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed depth-only RTX cameras disabling color rendering in the viewport by restoring GUI-state publication from - :class:`~isaaclab.app.AppLauncher`. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index f333a9b1cdc5..3617610fce21 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "8.0.2" +version = "8.0.3" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 0ec31d8f79dc..48f84fcad409 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,21 @@ Changelog --------- +8.0.3 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``latest`` and ``best`` checkpoint selectors to unified reinforcement learning training and play commands. + +Fixed +^^^^^ + +* Fixed depth-only RTX cameras disabling color rendering in the viewport by restoring GUI-state publication from + :class:`~isaaclab.app.AppLauncher`. + + 8.0.2 (2026-07-02) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 989aedb94379..3d1b04ea4ab5 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.0.2" +version = "8.0.3" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst b/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst deleted file mode 100644 index af7a7e95b0ae..000000000000 --- a/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Consolidated the OVRTX tile-extraction Warp kernels into a single generic - :func:`~isaaclab_ov.renderers.ovrtx_renderer_kernels.extract_all_tiles_kernel`. diff --git a/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst b/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst deleted file mode 100644 index 7a2d05faa43f..000000000000 --- a/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst +++ /dev/null @@ -1,13 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` and - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` config fields - to :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`. -* Added support for the ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` - data types in the OVRTX renderer, via the ``NonStableInstanceSegmentation`` and - ``InstanceSegmentationSD`` AOVs respectively. When the corresponding - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` / - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` flag is - ``True`` (default), instance IDs are colorized and returned as ``uint8`` RGBA; when ``False``, - raw ``uint32`` instance IDs are returned. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 5acd5995397f..316e8a798f91 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.5.3" +version = "0.5.4" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index 29a6576b9513..a288ea2cd356 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,30 @@ Changelog --------- +0.5.4 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` and + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` config fields + to :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`. +* Added support for the ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` + data types in the OVRTX renderer, via the ``NonStableInstanceSegmentation`` and + ``InstanceSegmentationSD`` AOVs respectively. When the corresponding + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` / + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` flag is + ``True`` (default), instance IDs are colorized and returned as ``uint8`` RGBA; when ``False``, + raw ``uint32`` instance IDs are returned. + +Changed +^^^^^^^ + +* Consolidated the OVRTX tile-extraction Warp kernels into a single generic + :func:`~isaaclab_ov.renderers.ovrtx_renderer_kernels.extract_all_tiles_kernel`. + + 0.5.3 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index 022cc51d7892..ecbefa31d229 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "0.5.3" +version = "0.5.4" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst deleted file mode 100644 index 9f467944463a..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst +++ /dev/null @@ -1,9 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObjectCollection.root_view` now returns an - :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw ``dict`` - mapping ``TensorType`` to ``TensorBinding``. The view wraps the fused multi-prim bindings - (``prim_paths=[...]``) and stores each under the collection's ``LINK_*``/``BODY_*`` data-class - key via ``key_aliases`` (mapped from the underlying ``RIGID_BODY_*`` type). Replace - ``root_view[tensor_type]`` with ``root_view.try_binding_for(tensor_type)``. diff --git a/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst b/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst deleted file mode 100644 index 35fe74f59781..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Updated :class:`~isaaclab_ovphysx.physics.OvPhysxManager` for the current - OVPhysX constructor, stage-reset, and synchronous-step APIs. -* Synchronized GPU-to-host property staging before OVPhysX consumes the pinned - host buffers. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index a3253f6b5fcc..9c879827eb19 100644 --- a/source/isaaclab_ovphysx/config/extension.toml +++ b/source/isaaclab_ovphysx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "5.0.1" +version = "6.0.0" # Description title = "OvPhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_ovphysx/docs/CHANGELOG.rst b/source/isaaclab_ovphysx/docs/CHANGELOG.rst index 9b8010163d1a..aed24188cee8 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,28 @@ Changelog --------- +6.0.0 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObjectCollection.root_view` now returns an + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw ``dict`` + mapping ``TensorType`` to ``TensorBinding``. The view wraps the fused multi-prim bindings + (``prim_paths=[...]``) and stores each under the collection's ``LINK_*``/``BODY_*`` data-class + key via ``key_aliases`` (mapped from the underlying ``RIGID_BODY_*`` type). Replace + ``root_view[tensor_type]`` with ``root_view.try_binding_for(tensor_type)``. + +Fixed +^^^^^ + +* Updated :class:`~isaaclab_ovphysx.physics.OvPhysxManager` for the current + OVPhysX constructor, stage-reset, and synchronous-step APIs. +* Synchronized GPU-to-host property staging before OVPhysX consumes the pinned + host buffers. + + 5.0.1 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index e39a2a01bd9a..3215f5c0dd83 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "5.0.1" +version = "6.0.0" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip b/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip deleted file mode 100644 index 7c3719525336..000000000000 --- a/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip +++ /dev/null @@ -1 +0,0 @@ -Test-only: added regression coverage for GUI and headless depth-only color-render behavior. diff --git a/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst b/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst deleted file mode 100644 index 7411f890bac5..000000000000 --- a/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst +++ /dev/null @@ -1,14 +0,0 @@ -Changed -^^^^^^^ - -* Bumped the ``rsl-rl-lib`` dependency to ``5.4.1``, which natively supports image-only policies - (observation sets with no 1D groups). -* Changed :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name` to default to rsl-rl's - ``CNNModel`` now that it supports image-only observations out of the box. - -Removed -^^^^^^^ - -* Removed the Isaac Lab ``CNNModel`` override of rsl-rl's ``CNNModel`` that previously added - image-only observation support. Use rsl-rl's ``CNNModel`` (the new default of - :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name`) instead. diff --git a/source/isaaclab_rl/config/extension.toml b/source/isaaclab_rl/config/extension.toml index 01fd9d01f402..5ce250dea3e2 100644 --- a/source/isaaclab_rl/config/extension.toml +++ b/source/isaaclab_rl/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.6.2" +version = "0.7.0" # Description title = "Isaac Lab RL" diff --git a/source/isaaclab_rl/docs/CHANGELOG.rst b/source/isaaclab_rl/docs/CHANGELOG.rst index b27c41f52949..8598ede916e4 100644 --- a/source/isaaclab_rl/docs/CHANGELOG.rst +++ b/source/isaaclab_rl/docs/CHANGELOG.rst @@ -1,6 +1,25 @@ Changelog --------- +0.7.0 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Bumped the ``rsl-rl-lib`` dependency to ``5.4.1``, which natively supports image-only policies + (observation sets with no 1D groups). +* Changed :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name` to default to rsl-rl's + ``CNNModel`` now that it supports image-only observations out of the box. + +Removed +^^^^^^^ + +* Removed the Isaac Lab ``CNNModel`` override of rsl-rl's ``CNNModel`` that previously added + image-only observation support. Use rsl-rl's ``CNNModel`` (the new default of + :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name`) instead. + + 0.6.2 (2026-06-28) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/pyproject.toml b/source/isaaclab_rl/pyproject.toml index 9fc5f43ba201..ae351b10958c 100644 --- a/source/isaaclab_rl/pyproject.toml +++ b/source/isaaclab_rl/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_rl" -version = "0.6.2" +version = "0.7.0" description = "Extension containing reinforcement learning related utilities." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst b/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst deleted file mode 100644 index 390b96365a9d..000000000000 --- a/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst +++ /dev/null @@ -1,14 +0,0 @@ -Added -^^^^^ - -* Added the ``ovphysx`` physics preset to the manager-based Cartpole tasks. - -Changed -^^^^^^^ - -* **Breaking:** Aligned the direct and manager-based Cartpole MDPs, including state observations, reset distributions, - termination conditions, episode horizon, reward convention, camera frame stacking, and camera lighting. Retrain - policies previously trained on the Cartpole tasks. - -* Changed all Cartpole camera renderer variants to stack two frames by default, and reduced the RSL-RL camera CNN - size while retaining reliable convergence. diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst b/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst deleted file mode 100644 index 7dba962376bd..000000000000 --- a/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` rendering test - presets to the rendering test utilities for Cartpole, ShadowHand, and Dexsuite environments, - covering both the OVRTX and Isaac RTX renderers. Because instance IDs are non-stable across - runs, golden images are saved with full RGBA colors but comparison is restricted to the alpha - channel only, which encodes the instance mask shape reliably. SSIM is also disabled for these - data types; only the per-pixel L2 gate is used to decide pass/fail. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index ccae571210e1..c93829e05c3c 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "8.1.2" +version = "8.1.3" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index a101818c1648..a22dd0a0c476 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,31 @@ Changelog --------- +8.1.3 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` rendering test + presets to the rendering test utilities for Cartpole, ShadowHand, and Dexsuite environments, + covering both the OVRTX and Isaac RTX renderers. Because instance IDs are non-stable across + runs, golden images are saved with full RGBA colors but comparison is restricted to the alpha + channel only, which encodes the instance mask shape reliably. SSIM is also disabled for these + data types; only the per-pixel L2 gate is used to decide pass/fail. +* Added the ``ovphysx`` physics preset to the manager-based Cartpole tasks. + +Changed +^^^^^^^ + +* **Breaking:** Aligned the direct and manager-based Cartpole MDPs, including state observations, reset distributions, + termination conditions, episode horizon, reward convention, camera frame stacking, and camera lighting. Retrain + policies previously trained on the Cartpole tasks. + +* Changed all Cartpole camera renderer variants to stack two frames by default, and reduced the RSL-RL camera CNN + size while retaining reliable convergence. + + 8.1.2 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index f09c5c86a8f0..4db1f1d78a37 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.2" +version = "8.1.3" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 861d343e24c329804911acdea80c353a516ed92e Mon Sep 17 00:00:00 2001 From: Agon Serifi <39410867+aserifi@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:20:57 +0200 Subject: [PATCH 04/23] [Kamino] Core (#5962) ## DR Legs closed-loop hold-pose and walk task Depends on https://github.com/newton-physics/newton/pull/3220 Simplified many things leaving the pure DR Legs task here. - Adds the Disney DR Legs closed-loop biped (Kamino solver) and a `Isaac-DrLegs-HoldPose-v0` hold-pose task as well as `Isaac-DrLegs-Walk-v0` walking task. - Uses the `ArticulationView` - Fixes Kamino environment resets directly in the kamion_manager - DR Legs USD is loaded from newton-assets --------- Co-authored-by: Ruben Grandia Co-authored-by: Cursor --- CONTRIBUTORS.md | 1 + .../changelog.d/aserifi-drlegs.rst | 5 + .../isaaclab_assets/robots/dr_legs.py | 116 ++++++++ .../changelog.d/rgrandia-fix-kamino-reset.rst | 27 ++ .../assets/articulation/articulation_data.py | 11 +- .../cloner/newton_clone_utils.py | 1 + .../isaaclab_newton/physics/kamino_manager.py | 204 ++++++------- .../physics/kamino_manager_cfg.py | 46 ++- .../isaaclab_newton/physics/newton_manager.py | 84 +++++- .../test/cloner/test_rename_builder_labels.py | 1 + .../test_newton_manager_abstraction.py | 69 +++++ .../changelog.d/aserifi-drlegs.rst | 14 + .../contrib/dr_legs/__init__.py | 30 ++ .../contrib/dr_legs/agents/__init__.py | 4 + .../contrib/dr_legs/agents/rsl_rl_ppo_cfg.py | 50 ++++ .../contrib/dr_legs/hold_pose_env_cfg.py | 279 ++++++++++++++++++ .../contrib/dr_legs/mdp/__init__.py | 14 + .../contrib/dr_legs/mdp/__init__.pyi | 38 +++ .../contrib/dr_legs/mdp/observations.py | 35 +++ .../contrib/dr_legs/mdp/rewards.py | 224 ++++++++++++++ .../contrib/dr_legs/walk_env_cfg.py | 151 ++++++++++ .../velocity/config/a1/flat_env_cfg.py | 3 +- .../velocity/config/anymal_b/flat_env_cfg.py | 3 +- .../velocity/config/anymal_c/flat_env_cfg.py | 3 +- .../velocity/config/go1/flat_env_cfg.py | 3 +- .../core/cabinet/cabinet_env_cfg.py | 7 +- .../core/reach/reach_env_cfg.py | 9 +- .../config/shadow_hand/shadow_hand_env_cfg.py | 7 +- .../velocity/config/cassie/flat_env_cfg.py | 3 +- .../core/velocity/config/g1/flat_env_cfg.py | 3 +- .../core/velocity/config/go2/flat_env_cfg.py | 3 +- .../core/velocity/config/h1/flat_env_cfg.py | 3 +- .../core/velocity/config/spot/flat_env_cfg.py | 10 +- .../core/velocity/velocity_env_cfg.py | 1 + 34 files changed, 1322 insertions(+), 140 deletions(-) create mode 100644 source/isaaclab_assets/changelog.d/aserifi-drlegs.rst create mode 100644 source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py create mode 100644 source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst create mode 100644 source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1aa1b57cc4eb..c70ed2d3faf6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -44,6 +44,7 @@ Guidelines for modifications: ## Contributors +* Agon Serifi * Alessandro Assirelli * Alex Omar * Alexander Millane diff --git a/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst b/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst new file mode 100644 index 000000000000..92397541b02a --- /dev/null +++ b/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added :data:`~isaaclab_assets.robots.dr_legs.DR_LEGS_IMPLICIT_PD_CFG` for the Disney DR Legs + closed-loop biped. diff --git a/source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py b/source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py new file mode 100644 index 000000000000..5fd8d3acbe5f --- /dev/null +++ b/source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py @@ -0,0 +1,116 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Configuration for the Disney DR Legs closed-loop biped. + +DR Legs is a parallel-linkage bipedal lower body with 30 joints (12 actuated + 18 passive +linkage DOFs) plus 6 loop-closing joints. The Kamino solver enforces the loop-closing joints +as bilateral constraints in maximal coordinates. + +The following configuration is available: + +* :data:`DR_LEGS_IMPLICIT_PD_CFG`: DR Legs with implicit (solver-side) PD on the + 12 actuated joints and zero-PD on the 18 passive linkage joints. +""" + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets.articulation import ArticulationCfg +from isaaclab.utils.assets import NEWTON_ASSET_DIR, retrieve_git_asset_path + +_DR_LEGS_USD_PATH = retrieve_git_asset_path(NEWTON_ASSET_DIR, "disneyresearch/dr_legs/usd/dr_legs.usda") + +DR_LEGS_JOINT_ORDER: list[str] = [ + "j1_l_i", + "j2_l_i", + "j3_l_i", + "j4_l_i", + "j6_l_i", + "j7_l_i", + "j8_l_i", + "j1_l_o", + "j2_l_o", + "j3_l_o", + "j4_l_o", + "j5_l_o", + "j6_l_o", + "j7_l_o", + "j8_l_o", + "j1_r_i", + "j2_r_i", + "j3_r_i", + "j4_r_i", + "j6_r_i", + "j7_r_i", + "j8_r_i", + "j1_r_o", + "j2_r_o", + "j3_r_o", + "j4_r_o", + "j5_r_o", + "j6_r_o", + "j7_r_o", + "j8_r_o", +] +"""Canonical ordering of the 30 (6 loop-closure joints are excluded) DR Legs joints.""" + +DR_LEGS_ACTUATED_JOINTS: list[str] = [ + "j1_l_i", + "j2_l_i", + "j6_l_i", + "j7_l_i", + "j2_l_o", + "j7_l_o", + "j1_r_i", + "j2_r_i", + "j6_r_i", + "j7_r_i", + "j2_r_o", + "j7_r_o", +] +"""The 12 servo-driven joints (6 per leg).""" + +DR_LEGS_PASSIVE_JOINTS: list[str] = [j for j in DR_LEGS_JOINT_ORDER if j not in DR_LEGS_ACTUATED_JOINTS] +"""The 18 closed-loop linkage DOFs that are not driven by an actuator on real hardware.""" + + +DR_LEGS_IMPLICIT_PD_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=_DR_LEGS_USD_PATH, + activate_contact_sensors=True, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=10.0, + enable_gyroscopic_forces=True, + ), + articulation_props=sim_utils.NewtonArticulationRootPropertiesCfg(self_collision_enabled=True), + copy_from_source=False, + ), + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 0.27), + # Closed-loop FK is only valid at the assembled reference (all joint coords zero). + joint_pos={".*": 0.0}, + joint_vel={".*": 0.0}, + ), + articulation_root_prim_path="/pelvis", + actuators={ + "driven_joints": ImplicitActuatorCfg( + joint_names_expr=DR_LEGS_ACTUATED_JOINTS, + stiffness=5.0, + damping=0.2, + effort_limit_sim=3.1, + ), + # Linkage DOFs are undriven: explicit zeros so the solver ignores USD drive defaults. + "passive_joints": ImplicitActuatorCfg( + joint_names_expr=DR_LEGS_PASSIVE_JOINTS, + stiffness=0.0, + damping=0.0, + armature=0.0, + friction=0.0, + effort_limit_sim=400.0, + ), + }, +) +"""DR Legs with implicit PD on the 12 actuated joints (Kamino solver, closed-loop).""" diff --git a/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst b/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst new file mode 100644 index 000000000000..fc9ba91c17fe --- /dev/null +++ b/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst @@ -0,0 +1,27 @@ +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``. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index eb0af1c10432..be4c5c92e181 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -125,11 +125,12 @@ def update(self, dt: float) -> None: def _ensure_fk_fresh(self) -> None: """Run forward kinematics if joint state has changed since the last FK update. - Newton's ``state.body_q`` (per-body world transforms) is updated by ``eval_fk``, - invoked here through ``SimulationManager.forward()``. After a manual joint or root - write that bypassed the sim step (``write_*_to_sim_*``), ``_fk_timestamp`` is set - to ``-1.0`` to force a refresh on the next read of any property that depends on - body poses (``body_link_pose_w``, the Jacobian properties, ``mass_matrix``). + Newton's ``state.body_q`` (per-body world transforms) is updated by the active + solver manager's ``forward()``, which calls a solver-specialized FK hook. + After a manual joint or root write that bypassed the sim step (``write_*_to_sim_*``), + ``_fk_timestamp`` is set to ``-1.0`` to force a refresh on the next read of any + property that depends on body poses (``body_link_pose_w``, the Jacobian properties, + ``mass_matrix``). """ if self._fk_timestamp < self._sim_timestamp: SimulationManager.forward() diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index 976d51dd201b..e73632d656f0 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -32,6 +32,7 @@ def build_source_builders( for source in sources: builder = create_builder() solvers.SolverMuJoCo.register_custom_attributes(builder) + solvers.SolverKamino.register_custom_attributes(builder) builder.add_usd( stage, root_path=source, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 292c2c047141..85bc166b2c33 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py @@ -14,7 +14,6 @@ from newton.solvers import SolverKamino from isaaclab.physics import PhysicsManager -from isaaclab.utils.timer import Timer from .kamino_manager_cfg import KaminoSolverCfg from .newton_manager import NewtonManager @@ -22,6 +21,31 @@ logger = logging.getLogger(__name__) +def _model_has_loop_closing_joints(model: Model) -> bool: + """Return whether ``model`` contains converted loop-closing articulation joints. + + Newton stores regular tree joints in ``[articulation_start[i], articulation_end[i])`` and + loop-closing joints in ``[articulation_end[i], articulation_start[i + 1])``. Loop closures + are present when the next articulation sentinel exceeds the tree joint end for any + articulation. + + Args: + model: Finalized Newton model to inspect. + + Returns: + ``True`` if at least one articulation has loop-closing joints. + """ + articulation_start = model.articulation_start + articulation_end = model.articulation_end + if articulation_start is None or articulation_end is None: + return False + articulation_start_np = articulation_start.numpy() + articulation_end_np = articulation_end.numpy() + if articulation_end_np.shape[0] == 0: + return False + return bool((articulation_start_np[1:] > articulation_end_np).any()) + + class NewtonKaminoManager(NewtonManager): """:class:`NewtonManager` specialization for the Kamino solver. @@ -30,86 +54,51 @@ class NewtonKaminoManager(NewtonManager): Kamino's internal collision detector handles contact generation. """ + # Annotate the concrete solver type. + _solver: SolverKamino + @classmethod - def _forward_kamino(cls, world_mask: wp.array | None = None) -> None: - """Kamino-specific forward kinematics via ``solver.reset()``. + def _get_kamino_solver_cfg(cls) -> KaminoSolverCfg: + cfg = PhysicsManager._cfg + if cfg is None: + raise RuntimeError("Physics manager is not initialized.") + solver_cfg = getattr(cfg, "solver_cfg", None) + if not isinstance(solver_cfg, KaminoSolverCfg): + raise TypeError(f"Expected KaminoSolverCfg, got {type(solver_cfg).__name__}.") + return solver_cfg + + @classmethod + def _eval_fk_impl(cls, world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: + """Update body states from joint coordinates. + + For the Kamino (maximal-coordinate) solver, body poses/velocities are the authoritative + simulation state. When :attr:`KaminoSolverCfg.use_fk_solver` is enabled, this calls + :meth:`SolverKamino.reset`, which runs Kamino's loop-closure forward kinematics: it reads + body poses/velocities from the joint coordinates (including the base body's pose/twist) + and writes back a consistent full joint and body state. - ``SolverKamino.ResetConfig.from_joints()`` consumes Newton's full - canonical state arrays directly, including free-joint coordinates. + When ``use_fk_solver`` is disabled, falls back to Newton's articulated ``eval_fk`` over + ``fk_mask``; the caller is then responsible for writing constraint-consistent joint values. Args: - world_mask: Per-world mask indicating which worlds to reset. - Shape ``(num_worlds,)``, dtype ``wp.bool``. If None, resets all worlds. + world_reset_mask: Per-world mask passed to :meth:`SolverKamino.reset` (``None`` means all). + fk_mask: Per-articulation mask of articulations to update (``None`` means all). """ - cls._solver.reset( - state=cls._state_0, - world_mask=world_mask, - config=SolverKamino.ResetConfig.from_joints(), - ) - - @classmethod - def step(cls) -> None: - """Step the physics simulation.""" - sim = PhysicsManager._sim - if sim is None or not sim.is_playing(): - return - - # Kamino: run solver.reset() with the accumulated world mask to reinitialise - # internal state (warm-start containers, constraint multipliers) for reset worlds. - # Note: runs every step. solver.reset() with an all-False world_mask is a no-op - # (kernels check mask per-world and skip). The cost of a no-op launch is negligible - # compared to the complexity of maintaining a separate boolean guard. - cls._forward_kamino(world_mask=cls._world_reset_mask) - - # Notify solver of model changes - if cls._model_changes: - with wp.ScopedDevice(PhysicsManager._device): - for change in cls._model_changes: - cls._solver.notify_model_changed(change) - NewtonManager._model_changes = set() - - # Lazy CUDA graph capture: deferred from initialize_solver() when RTX was active. - # By the time step() is first called, RTX has fully initialized (all cudaImportExternalMemory - # calls are done) and is idle between render frames — giving us a clean capture window. - cfg = PhysicsManager._cfg - device = PhysicsManager._device - if cls._graph_capture_pending and cfg is not None and cfg.use_cuda_graph and "cuda" in device: # type: ignore[union-attr] - NewtonManager._graph_capture_pending = False - NewtonManager._graph = cls._capture_relaxed_graph(device) - if cls._graph is not None: - # Kamino: StateKamino.from_newton() lazily allocates body_f_total, - # joint_q_prev, and joint_lambdas via wp.clone/wp.zeros during the - # first step() inside graph capture. Replay once to pin those - # memory-pool addresses before any eager solver.reset() call. - wp.capture_launch(cls._graph) - logger.info("Newton CUDA graph captured (deferred relaxed mode, RTX-compatible)") - else: - logger.warning("Newton deferred CUDA graph capture failed; using eager execution") - - # Ensure body_q is up-to-date before collision detection. - # After env resets, joint_q is written but body_q (used by - # broadphase/narrowphase) is stale until FK runs. - # Only runs FK for dirtied articulations via the accumulated mask. - if cls._needs_collision_pipeline: - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, cls._fk_reset_mask) - - # Zero both masks after consumption - NewtonManager._world_reset_mask.zero_() - NewtonManager._fk_reset_mask.zero_() - - # Step simulation (graphed or not; _graph is None when capture is disabled or failed) - if cfg is not None and cfg.use_cuda_graph and cls._graph is not None and "cuda" in device: # type: ignore[union-attr] - wp.capture_launch(cls._graph) + if cls._get_kamino_solver_cfg().use_fk_solver: + cls._solver.reset( + cls._state_0, + world_mask=world_reset_mask, + config=SolverKamino.ResetConfig.from_joints(), + ) else: - with wp.ScopedDevice(device): - cls._simulate_physics_only() - if cls._usdrt_stage is not None: - cls._mark_transforms_dirty() + eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, fk_mask) - # Launch solver-specific debug logging after stepping. - cls._log_solver_debug() - - PhysicsManager._sim_time += cls._solver_dt * cls._num_substeps + # Reset solver internals without performing Kamino's FK. + cls._solver.reset( + cls._state_0, + world_mask=world_reset_mask, + config=SolverKamino.ResetConfig.preserve(), + ) @classmethod def _build_solver(cls, model: Model, solver_cfg: KaminoSolverCfg) -> None: @@ -118,40 +107,41 @@ def _build_solver(cls, model: Model, solver_cfg: KaminoSolverCfg) -> None: Sets :attr:`NewtonManager._needs_collision_pipeline` to ``True`` only when ``use_collision_detector=False`` (Kamino's internal detector handles contacts otherwise). + + Sets :attr:`NewtonManager._needs_fk_before_step` because Kamino treats body state as + authoritative: reset worlds (written via joint coordinates) must be reconciled before each + step. The shared :attr:`NewtonManager._world_reset_mask` and + :attr:`NewtonManager._fk_reset_mask` restrict that pre-step reconcile and + :meth:`NewtonManager.forward` to reset worlds, so non-reset worlds keep their live, + authoritative body state through Kamino's :meth:`_eval_fk_impl` overwrite. + + Raises: + RuntimeError: If the model has more than one articulation per environment. The Kamino + interface in IsaacLab currently only supports one articulation per environment. """ + + # Set the max contacts per world if specified. + if solver_cfg.max_contacts_per_world is not None: + model.rigid_contact_max = int(solver_cfg.max_contacts_per_world) * model.world_count + logger.info( + "[KAMINO] Capping rigid_contact_max to %d (%d/world * %d worlds)", + model.rigid_contact_max, + solver_cfg.max_contacts_per_world, + model.world_count, + ) + + # Set the use_fk_solver flag based on the model's articulation structure if not specified by user. + if solver_cfg.use_fk_solver is None: + solver_cfg.use_fk_solver = _model_has_loop_closing_joints(model) + + if solver_cfg.use_fk_solver and model.articulation_count != model.world_count: + raise RuntimeError( + "The Kamino FK solver requires exactly one articulation per environment, but the model" + f" has {model.articulation_count} articulations across {model.world_count} environments." + " Multiple articulations per environment are not yet supported in Kamino's FK solver." + ) + NewtonManager._solver = SolverKamino(model, solver_cfg.to_solver_config()) NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = not solver_cfg.use_collision_detector - - @classmethod - def _capture_or_defer_cuda_graph(cls) -> None: - """Capture the physics CUDA graph, or defer if RTX is initializing.""" - cfg = PhysicsManager._cfg - device = PhysicsManager._device - use_cuda_graph = cfg is not None and cfg.use_cuda_graph and "cuda" in device # type: ignore[union-attr] - - with Timer(name="newton_cuda_graph", msg="CUDA graph took:"): - if not use_cuda_graph: - NewtonManager._graph = None - return - if cls._usdrt_stage is None: - # No RTX active — use standard Warp capture (cudaStreamCaptureModeGlobal). - with wp.ScopedCapture() as capture: - cls._simulate_physics_only() - NewtonManager._graph = capture.graph - logger.info("Newton CUDA graph captured (standard Warp mode)") - - # TODO: streamline this with base NewtonManager - # Kamino: StateKamino.from_newton() lazily allocates body_f_total, - # joint_q_prev, and joint_lambdas via wp.clone/wp.zeros during the - # first step() inside graph capture. Replay once to pin those - # memory-pool addresses before any eager solver.reset() call. - wp.capture_launch(cls._graph) - else: - # RTX is active during initialization — cudaImportExternalMemory and other - # non-capturable RTX ops run on background CUDA streams right now. - # Defer capture to the first step() call, after RTX is fully initialized - # and idle between render frames (clean capture window). - NewtonManager._graph = None - NewtonManager._graph_capture_pending = True - logger.info("Newton CUDA graph capture deferred until first step() (RTX active)") + NewtonManager._needs_fk_before_step = True diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py index e88e54e59ab3..90fbb067aaf0 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py @@ -48,13 +48,33 @@ class KaminoSolverCfg(NewtonSolverCfg): use_collision_detector: bool = False """Whether to use Kamino's internal collision detector instead of Newton's pipeline.""" - use_fk_solver: bool = True + use_fk_solver: bool | None = None """Whether to enable the forward kinematics solver for state resets. - Required for proper environment resets. The FK solver computes consistent body poses - from joint angles after state writes, which is essential for maximal-coordinate solvers. + When ``None``, Kamino will automatically determine whether to use the FK solver based on the model's + articulation structure. If the model has loop-closing joints, the FK solver will be used. + + When ``True``, :meth:`NewtonKaminoManager._eval_fk_impl` reconciles body state via + :meth:`SolverKamino.reset` with :class:`SolverKamino.ResetConfig.from_joints`. Kamino's FK + solver computes consistent body poses/velocities from the joint coordinates (including the + base joint for floating bases), resolves passive / loop-closure joints, and writes back a + consistent full joint state. Environment resets only need to write actuated DOFs in + ``joint_q``; passive values are filled in by FK. This is required for closed-loop systems. + + When ``False``, Newton's articulated ``eval_fk`` is used instead over the full + ``joint_q`` / ``joint_qd``. It is then up to the user to specify constraint-consistent + values. This is the faster option for purely articulated (tree-structured) systems. """ + fk_use_regularization: bool = True + """Whether to regularize the FK reset solve (Tikhonov term on body poses).""" + + fk_regularization_weight: float = 1e-5 + """Weight of the FK reset regularizer, used when :attr:`fk_use_regularization` is ``True``.""" + + fk_tolerance: float = 1e-5 + """Convergence tolerance of the FK reset solve.""" + sparse_jacobian: bool = False """Whether to use sparse Jacobian computation.""" @@ -145,6 +165,16 @@ class KaminoSolverCfg(NewtonSolverCfg): Only used when :attr:`use_collision_detector` is ``True``. If ``None``, Newton's default is used. """ + max_contacts_per_world: int | None = None + """Cap the per-world contact pre-allocation handed to Kamino. + + When ``None``, Kamino falls back to ``geoms.world_minimum_contacts`` derived from the + collision pipeline, which over-allocates dramatically for contact-rich assets. Set this + to bound GPU memory for multi-env training of contact-heavy tasks (e.g. legged + locomotion or manipulation). The total ``model.rigid_contact_max`` is computed as + ``max_contacts_per_world * model.world_count`` before solver construction. + """ + dynamics_preconditioning: bool = True """Whether to use preconditioning in the constrained dynamics solver. @@ -165,10 +195,15 @@ def to_solver_config(self) -> SolverKamino.Config: CollisionDetectorConfig, ConstrainedDynamicsConfig, ConstraintStabilizationConfig, + ForwardKinematicsSolverConfig, PADMMSolverConfig, ) from newton.solvers import SolverKamino + # Kamino Manager will set the automatic value before. This is a fallback to true if that mechanism was bypassed. + if self.use_fk_solver is None: + self.use_fk_solver = True + # Build collision detector config if using Kamino's internal detector collision_detector = None if self.use_collision_detector: @@ -190,6 +225,11 @@ def to_solver_config(self) -> SolverKamino.Config: collect_solver_info=self.collect_solver_info, compute_solution_metrics=self.compute_solution_metrics, collision_detector=collision_detector, + fk=ForwardKinematicsSolverConfig( + use_regularization=self.fk_use_regularization, + regularization_weight=self.fk_regularization_weight, + tolerance=self.fk_tolerance, + ), constraints=ConstraintStabilizationConfig( alpha=self.constraints_alpha, beta=self.constraints_beta, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 7d04c6e0ea5d..69fdc6fe1d07 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -190,6 +190,18 @@ def state(self) -> Model: return NewtonManager.get_state_0() +def _eval_fk_unbound(world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: + """Default :attr:`NewtonManager._eval_fk` value before a solver is initialized. + + Raises so a stray ``forward()`` / ``step()`` before ``initialize_solver()`` fails loudly + instead of silently running a wrong (or no) FK. + """ + raise RuntimeError( + "FK hook is not bound. NewtonManager.initialize_solver() must run " + "(via reset()) before forward()/step() can run forward kinematics." + ) + + class NewtonManager(PhysicsManager): """Abstract Newton physics manager for Isaac Lab. @@ -254,9 +266,12 @@ def provides_implicit_damping(cls) -> bool: _pending_extended_state_attributes: set[str] = set() _pending_extended_contact_attributes: set[str] = set() _report_contacts: bool = False - # Per-world reset masks (allocated in start_simulation, consumed in step) + + # Per-world reset masks (allocated in start_simulation, consumed in step/forward). _world_reset_mask: wp.array | None = None # (num_envs,) wp.bool — for SolverKamino.reset(world_mask=...) _fk_reset_mask: wp.array | None = None # (articulation_count,) wp.bool — for eval_fk(mask=...) + # Solver-specialized FK delegate. Bound in initialize_solver() to the active subclass's choice of FK implementation. + _eval_fk: Callable[[wp.array | None, wp.array | None], None] = _eval_fk_unbound # Newton actuator adapter (owns actuators and double-buffered states) _adapter: NewtonActuatorAdapter | None = None @@ -363,17 +378,42 @@ 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._eval_fk(cls._world_reset_mask, cls._fk_reset_mask) + if cls._fk_reset_mask is not None: + cls._fk_reset_mask.zero_() + if cls._world_reset_mask is not None: + cls._world_reset_mask.zero_() @classmethod def pre_render(cls) -> None: @@ -651,6 +691,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 +705,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 +806,7 @@ def clear(cls): NewtonManager._contacts = None NewtonManager._needs_collision_pipeline = False NewtonManager._needs_fk_before_step = False + NewtonManager._eval_fk = _eval_fk_unbound NewtonManager._collision_pipeline = None NewtonManager._collision_cfg = None NewtonManager._newton_contact_sensors = {} @@ -1126,7 +1174,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 +1185,7 @@ def start_simulation(cls) -> None: NewtonManager._adapter = None NewtonManager._use_newton_actuators_active = False - # Allocate per-world reset masks (used by all solvers for masked FK, and by Kamino for masked reset) + # Allocate per-world reset masks (used by all solvers for masked FK, and by Kamino for masked reset). NewtonManager._world_reset_mask = wp.zeros(cls._model.world_count, dtype=wp.bool, device=device) NewtonManager._fk_reset_mask = wp.zeros(cls._model.articulation_count, dtype=wp.bool, device=device) @@ -1409,6 +1458,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() @@ -1645,7 +1705,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/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index 28f116a68d73..2de4e556b288 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -334,6 +334,7 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) mock.patch.object(visualization_builder_module, "SchemaResolverNewton", lambda: object()), mock.patch.object(visualization_builder_module, "SchemaResolverPhysx", lambda: object()), mock.patch.object(newton_clone_utils_module.solvers.SolverMuJoCo, "register_custom_attributes"), + mock.patch.object(newton_clone_utils_module.solvers.SolverKamino, "register_custom_attributes"), ): builder = visualization_builder_module.build_visualization_builder_from_stage_envs( stage, env_paths, clone_plan diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 991a5f63da89..56bf4c33f1f1 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -651,3 +651,72 @@ def counting_collide(state, contacts): # Expect: 1 (top-of-tick) + expected_mid_loop_collides. assert calls["n"] == 1 + expected_mid_loop_collides + + +# --------------------------------------------------------------------------- +# Regression: an env reset written through the data layer must land in the +# manager's canonical _state_0 after an odd number of steps when CUDA graphs +# are disabled (the use_cuda_graph state-swap gating bug). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("num_steps", [1, 3]) +def test_reset_lands_in_state_0_after_odd_kamino_steps_without_cuda_graph(num_steps): + """An env reset written through the data-layer binding lands in ``_state_0``. + + Kamino is double-buffered (``_use_single_state=False``), so each substep + ping-pongs ``_state_0`` / ``_state_1``. With a single substep the loop must + copy the result back into ``_state_0`` instead of swapping, otherwise after + an *odd* number of steps the canonical ``_state_0`` ends up on the other + buffer. This copy-on-last was previously gated on ``use_cuda_graph``, so with + CUDA graphs disabled ``_state_0`` flipped buffers and env-reset writes landed + in the stale buffer. + + :class:`~isaaclab_newton.assets.ArticulationData` binds its joint-state write + target to ``_state_0.joint_q`` once at setup (``_sim_bind_joint_pos``) and + never re-binds on env resets, so a flipped ``_state_0`` makes reset writes + miss the live state. This test reproduces that contract without a full USD + articulation: it caches the same ``_state_0.joint_q`` binding, steps Kamino an + odd number of times, writes a sentinel through the cached binding (mimicking + the reset write), and asserts the manager's ``_state_0`` observes it. + + Without the fix the swap-on-last flips ``_state_0`` for odd ``num_steps`` and + the sentinel lands in ``_state_1`` instead, so the final assertion fails. + """ + sentinel = 1.2345 + sim_cfg = SimulationCfg( + dt=1.0 / 120.0, + device="cuda:0", + gravity=(0.0, 0.0, -9.81), + physics=NewtonCfg( + solver_cfg=KaminoSolverCfg(), + num_substeps=1, + use_cuda_graph=False, + ), + ) + + with build_simulation_context(sim_cfg=sim_cfg) as sim: + builder = NewtonManager.create_builder() + body = builder.add_body(mass=1.0) + builder.add_joint_revolute(parent=-1, child=body, axis=(0, 0, 1)) + NewtonManager.set_builder(builder) + sim.reset() + + # Kamino keeps separate input/output states; the bug only exists there. + assert NewtonManager._use_single_state is False + # The data layer binds its joint-state write target to _state_0 at setup. + reset_target = NewtonManager._state_0.joint_q + assert reset_target.shape[0] > 0 # guard against a vacuous assertion + + for _ in range(num_steps): + sim.step(render=False) + + # An env reset writes joint state through the (still bound) target. + reset_target.fill_(sentinel) + + # The reset must be visible in the manager's canonical _state_0; if the + # buffer flipped it landed in _state_1 instead. + canonical_joint_q = NewtonManager._state_0.joint_q.numpy() + assert np.allclose(canonical_joint_q, sentinel), ( + f"reset write did not land in _state_0 after {num_steps} steps: {canonical_joint_q}" + ) diff --git a/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst b/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst new file mode 100644 index 000000000000..65b7ea95e469 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst @@ -0,0 +1,14 @@ +Added +^^^^^ + +* Added ``Isaac-DrLegs-HoldPose-v0`` and ``Isaac-DrLegs-Walk-v0`` Kamino closed-loop locomotion + tasks via :class:`~isaaclab_tasks.contrib.dr_legs.hold_pose_env_cfg.DrLegsHoldPoseEnvCfg` and + :class:`~isaaclab_tasks.contrib.dr_legs.walk_env_cfg.DrLegsWalkEnvCfg`. + +* Added ``newton_kamino`` physics presets to core and contrib velocity, reach, cabinet, and Shadow + Hand environment configurations. + +Fixed +^^^^^ + +* Fixed reach task table spawn offset for Newton ``newton_mjwarp`` and ``newton_kamino`` presets. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.py new file mode 100644 index 000000000000..8cfe3a75c5dd --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""DR Legs closed-loop biped locomotion tasks (Kamino solver).""" + +import gymnasium as gym + +from . import agents + +gym.register( + id="Isaac-DrLegs-HoldPose-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.hold_pose_env_cfg:DrLegsHoldPoseEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:DrLegsHoldPosePPORunnerCfg", + }, +) + +gym.register( + id="Isaac-DrLegs-Walk-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.walk_env_cfg:DrLegsWalkEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:DrLegsWalkPPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py new file mode 100644 index 000000000000..460a30569089 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py new file mode 100644 index 000000000000..8fbc732f2569 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py @@ -0,0 +1,50 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.utils.configclass import configclass + +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg + + +@configclass +class DrLegsHoldPosePPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 24 + max_iterations = 3000 + save_interval = 100 + experiment_name = "dr_legs_hold_pose" + obs_groups = {"actor": ["policy"], "critic": ["policy", "critic"]} + actor = RslRlMLPModelCfg( + hidden_dims=[512, 512, 512], + activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 512, 512], + activation="elu", + obs_normalization=True, + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.001, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ) + + +@configclass +class DrLegsWalkPPORunnerCfg(DrLegsHoldPosePPORunnerCfg): + def __post_init__(self): + super().__post_init__() + self.experiment_name = "dr_legs_walk" + self.max_iterations = 3000 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py new file mode 100644 index 000000000000..e710e8c4a064 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.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 + +"""Hold-pose environment for the Disney DR Legs closed-loop biped (Kamino solver). + +The robot must keep its pelvis upright at a target height. +""" + +from isaaclab_newton.physics import KaminoSolverCfg, NewtonCfg, NewtonShapeCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sim import SimulationCfg +from isaaclab.utils.configclass import configclass + +import isaaclab_tasks.contrib.dr_legs.mdp as mdp +from isaaclab_tasks.utils import PresetCfg + +from isaaclab_assets.robots.dr_legs import DR_LEGS_ACTUATED_JOINTS, DR_LEGS_IMPLICIT_PD_CFG + +## +# Shared constants +## + +_NUM_ENVS = 4096 + +_ACTUATED_JOINT_CFG = SceneEntityCfg("robot", joint_names=DR_LEGS_ACTUATED_JOINTS, preserve_order=True) + +_PHYSICS_MATERIAL = sim_utils.RigidBodyMaterialCfg( + friction_combine_mode="average", + restitution_combine_mode="average", + static_friction=1.0, + dynamic_friction=0.8, + restitution=0.0, +) + + +def _kamino_newton_cfg() -> NewtonCfg: + """Kamino solver preset for DR Legs (closed-loop, implicit PD).""" + return NewtonCfg( + solver_cfg=KaminoSolverCfg( + integrator="moreau", + sparse_jacobian=True, + sparse_dynamics=False, + use_collision_detector=False, + collision_detector_pipeline="unified", + collision_detector_max_contacts_per_pair=8, + use_fk_solver=True, + constraints_alpha=0.1, + padmm_max_iterations=100, + padmm_primal_tolerance=1.0e-5, + padmm_dual_tolerance=1.0e-5, + padmm_compl_tolerance=1.0e-5, + padmm_rho_0=0.02, + padmm_eta=1.0e-5, + padmm_use_acceleration=True, + padmm_warmstart_mode="containers", + padmm_contact_warmstart_method="geom_pair_net_force", + padmm_use_graph_conditionals=False, + max_contacts_per_world=50, + ), + num_substeps=2, + use_cuda_graph=True, + default_shape_cfg=NewtonShapeCfg(margin=0.0, gap=0.001), + ) + + +@configclass +class DrLegsPhysicsCfg(PresetCfg): + """Physics backend preset (DR Legs runs only under the Kamino solver).""" + + default: NewtonCfg = _kamino_newton_cfg() + newton_kamino: NewtonCfg = _kamino_newton_cfg() + + +## +# Scene definition +## + + +@configclass +class HoldPoseSceneCfg(InteractiveSceneCfg): + ground = AssetBaseCfg( + prim_path="/World/ground", + spawn=sim_utils.GroundPlaneCfg(size=(100.0, 100.0), physics_material=_PHYSICS_MATERIAL), + ) + + robot = DR_LEGS_IMPLICIT_PD_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + dome_light = AssetBaseCfg( + prim_path="/World/DomeLight", + spawn=sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)), + ) + + +## +# MDP settings +## + + +@configclass +class ActionsCfg: + joint_pos = mdp.JointPositionActionCfg( + asset_name="robot", + joint_names=DR_LEGS_ACTUATED_JOINTS, + preserve_order=True, + scale=0.3, + use_default_offset=True, + ) + + +@configclass +class ObservationsCfg: + @configclass + class PolicyCfg(ObsGroup): + projected_gravity = ObsTerm(func=mdp.projected_gravity) + base_ang_vel = ObsTerm(func=mdp.base_ang_vel) + joint_pos = ObsTerm(func=mdp.joint_pos_rel, params={"asset_cfg": _ACTUATED_JOINT_CFG}) + setpoint_hist = ObsTerm( + func=mdp.joint_position_setpoints, + params={"action_term_name": "joint_pos"}, + history_length=2, + flatten_history_dim=True, + ) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + @configclass + class CriticCfg(ObsGroup): + base_lin_vel = ObsTerm(func=mdp.base_lin_vel) + joint_vel_rel = ObsTerm(func=mdp.joint_vel_rel, scale=0.2) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + policy: PolicyCfg = PolicyCfg() + critic: CriticCfg = CriticCfg() + + +@configclass +class EventCfg: + randomize_joint_params = EventTerm( + func=mdp.randomize_joint_parameters, + mode="startup", + params={ + "asset_cfg": _ACTUATED_JOINT_CFG, + "operation": "scale", + "distribution": "uniform", + "armature_distribution_params": (0.8, 1.2), + }, + ) + + randomize_actuator_gains = EventTerm( + func=mdp.randomize_actuator_gains, + mode="startup", + params={ + "asset_cfg": _ACTUATED_JOINT_CFG, + "operation": "scale", + "distribution": "uniform", + "stiffness_distribution_params": (0.8, 1.2), + "damping_distribution_params": (0.8, 1.2), + }, + ) + + physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=".*"), + "static_friction_range": (0.7, 1.2), + "dynamic_friction_range": (0.5, 1.0), + "restitution_range": (0.0, 0.0), + "num_buckets": 64, + "make_consistent": True, + }, + ) + + reset_base = EventTerm( + func=mdp.reset_root_state_uniform, + mode="reset", + params={ + "pose_range": { + "x": (-0.05, 0.05), + "y": (-0.05, 0.05), + "z": (-0.02, 0.02), + "roll": (-0.1, 0.1), + "pitch": (-0.1, 0.1), + "yaw": (-3.14159, 3.14159), + }, + "velocity_range": { + "x": (-0.2, 0.2), + "y": (-0.2, 0.2), + "z": (-0.1, 0.1), + "roll": (-0.2, 0.2), + "pitch": (-0.2, 0.2), + "yaw": (-0.2, 0.2), + }, + }, + ) + + reset_robot_joints = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "position_range": (-0.1, 0.1), + "velocity_range": (-0.05, 0.05), + }, + ) + + +@configclass +class RewardsCfg: + alive = RewTerm(func=mdp.is_alive, weight=5.0) + flat_orientation = RewTerm(func=mdp.flat_orientation_l2, weight=-1.0) + height = RewTerm(func=mdp.base_height_l2, weight=-1.0, params={"target_height": 0.265}) + lin_vel = RewTerm(func=mdp.base_lin_vel_l2, weight=-1.0) + ang_vel = RewTerm(func=mdp.base_ang_vel_l2, weight=-0.5) + joint_torque = RewTerm( + func=mdp.joint_pd_command_l2, + weight=-1.0e-5, + params={"asset_cfg": _ACTUATED_JOINT_CFG, "stiffness": 5.0, "damping": 0.2}, + ) + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-0.1) + action_rate2 = RewTerm(func=mdp.ActionRate2L2, weight=-0.01) + joint_pos_deviation = RewTerm( + func=mdp.joint_deviation_l1, + weight=-2.0, + params={"asset_cfg": _ACTUATED_JOINT_CFG}, + ) + + +@configclass +class TerminationsCfg: + time_out = DoneTerm(func=mdp.time_out, time_out=True) + root_height = DoneTerm(func=mdp.root_height_below_minimum, params={"minimum_height": 0.12}) + bad_orientation = DoneTerm(func=mdp.bad_orientation, params={"limit_angle": 1.0}) + + +## +# Environment configuration +## + + +@configclass +class DrLegsHoldPoseEnvCfg(ManagerBasedRLEnvCfg): + """DR Legs hold-pose environment (Newton/Kamino backend).""" + + scene: HoldPoseSceneCfg = HoldPoseSceneCfg(num_envs=_NUM_ENVS, env_spacing=2.0) + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + events: EventCfg = EventCfg() + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + sim: SimulationCfg = SimulationCfg( + dt=0.004, + render_interval=5, + physics=DrLegsPhysicsCfg(), + physics_material=_PHYSICS_MATERIAL, + ) + + def __post_init__(self) -> None: + self.decimation = 5 + self.episode_length_s = 10.0 + self.viewer.eye = (1.5, 0.5, 0.5) + self.viewer.lookat = (0.0, 0.0, 0.265) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py new file mode 100644 index 000000000000..1876a0b22bf0 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""MDP terms for the DR Legs tasks. + +Re-exports the stock Isaac Lab manager-based MDP terms and adds the few +DR-Legs-specific terms that have no stock equivalent. +""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi new file mode 100644 index 000000000000..8d178a6b5ed2 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi @@ -0,0 +1,38 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + # observations + "gait_phase", + "joint_position_setpoints", + # rewards + "ActionRate2L2", + "base_ang_vel_l2", + "base_lin_vel_l2", + "contact_matching", + "feet_flat", + "feet_parallel", + "feet_touchdown_vel", + "foot_clearance", + "joint_pd_command_l2", + "joint_pos_tracking_exp", + "root_orientation_exp", +] + +from .observations import gait_phase, joint_position_setpoints +from .rewards import ( + ActionRate2L2, + base_ang_vel_l2, + base_lin_vel_l2, + contact_matching, + feet_flat, + feet_parallel, + feet_touchdown_vel, + foot_clearance, + joint_pd_command_l2, + joint_pos_tracking_exp, + root_orientation_exp, +) +from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py new file mode 100644 index 000000000000..2c25c5a012ed --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py @@ -0,0 +1,35 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""DR-Legs-specific observation terms.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv, ManagerBasedRLEnv + +__all__ = ["gait_phase", "joint_position_setpoints"] + + +def joint_position_setpoints(env: ManagerBasedEnv, action_term_name: str = "joint_pos") -> torch.Tensor: + """Joint position setpoints (the action term's ``processed_actions``, in radians).""" + action_term = env.action_manager.get_term(action_term_name) + return action_term.processed_actions.clone() + + +def gait_phase(env: ManagerBasedRLEnv, period: float = 1.0) -> torch.Tensor: + """Cyclic gait clock encoded as ``(sin(2*pi*phi), cos(2*pi*phi))``. + + ``phi`` is the episode time wrapped to ``period`` and normalized to ``[0, 1)``. + """ + episode_time_s = env.episode_length_buf.float() * env.step_dt + phase = (episode_time_s % period) / period + two_pi = 2.0 * math.pi + return torch.stack([torch.sin(two_pi * phase), torch.cos(two_pi * phase)], dim=-1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py new file mode 100644 index 000000000000..9c0818af2f21 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py @@ -0,0 +1,224 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""DR-Legs-specific reward terms with no stock Isaac Lab equivalent. + +Includes the posture/effort terms used by the hold-pose task and the gait/contact/feet +terms used by the walk task. The exponential kernels follow the convention +``exp(-error / (2 * sigma**2))`` and the quaternion error is the squared norm of the +imaginary part of the relative quaternion (scalar-last ``(x, y, z, w)``, matching +:mod:`isaaclab.utils.math`). +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch + +import isaaclab.utils.math as math_utils +from isaaclab.managers import ManagerTermBase, RewardTermCfg, SceneEntityCfg + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + +__all__ = [ + "base_lin_vel_l2", + "base_ang_vel_l2", + "joint_pd_command_l2", + "joint_pos_tracking_exp", + "ActionRate2L2", + "contact_matching", + "foot_clearance", + "feet_parallel", + "feet_flat", + "feet_touchdown_vel", + "root_orientation_exp", +] + + +## +# Kernel and gait helpers (private). +## + + +def _exp_se(error: torch.Tensor, sigma: float) -> torch.Tensor: + return torch.exp(-error / (2.0 * sigma * sigma)) + + +def _quat_inv_mul(q1: torch.Tensor, q2: torch.Tensor) -> torch.Tensor: + return math_utils.quat_mul(math_utils.quat_conjugate(q1), q2) + + +def _exp_quat_err(q1: torch.Tensor, q2: torch.Tensor, sigma: float) -> torch.Tensor: + err_imag = _quat_inv_mul(q1, q2)[:, :3] + return _exp_se(torch.sum(torch.square(err_imag), dim=1), sigma) + + +def _gait_phase(env: ManagerBasedRLEnv, gait_period: float) -> torch.Tensor: + episode_time = env.episode_length_buf.float() * env.step_dt + return (episode_time % gait_period) / gait_period + + +def _reference_contacts(phase: torch.Tensor) -> torch.Tensor: + left = (phase < 0.5).float() + right = (phase >= 0.5).float() + return torch.stack([left, right], dim=1) + + +## +# Posture / effort terms. +## + + +def joint_pos_tracking_exp( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + std: float = 0.5, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + joint_ids = asset_cfg.joint_ids + error = asset.data.joint_pos[:, joint_ids] - asset.data.default_joint_pos[:, joint_ids] + return torch.exp(-torch.sum(torch.square(error), dim=1) / std**2) + + +def base_lin_vel_l2(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + return torch.sum(torch.square(asset.data.root_lin_vel_b[:, :3]), dim=1) + + +def base_ang_vel_l2(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + return torch.sum(torch.square(asset.data.root_ang_vel_b[:, :3]), dim=1) + + +def joint_pd_command_l2( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + stiffness: float = 5.0, + damping: float = 0.2, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + joint_ids = asset_cfg.joint_ids + joint_pos = asset.data.joint_pos[:, joint_ids] + joint_vel = asset.data.joint_vel[:, joint_ids] + joint_pos_target = asset.data.joint_pos_target[:, joint_ids] + pd_command = stiffness * (joint_pos_target - joint_pos) - damping * joint_vel + return torch.sum(torch.square(pd_command), dim=1) + + +class ActionRate2L2(ManagerTermBase): + def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + dim = env.action_manager.total_action_dim + self._prev_action = torch.zeros((env.num_envs, dim), device=env.device) + self._prev_prev_action = torch.zeros((env.num_envs, dim), device=env.device) + + def reset(self, env_ids: torch.Tensor | None = None): + if env_ids is None: + env_ids = slice(None) + self._prev_action[env_ids] = 0.0 + self._prev_prev_action[env_ids] = 0.0 + + def __call__(self, env: ManagerBasedRLEnv) -> torch.Tensor: + current_action = env.action_manager.action + jerk = current_action - 2.0 * self._prev_action + self._prev_prev_action + reward = torch.sum(torch.square(jerk), dim=1) + self._prev_prev_action[:] = self._prev_action + self._prev_action[:] = current_action + return reward + + +## +# Gait / feet / orientation terms (walk task). +## + + +def contact_matching( + env: ManagerBasedRLEnv, + sensor_cfg: SceneEntityCfg, + threshold: float = 1.0, + gait_period: float = 1.0, +) -> torch.Tensor: + contact_sensor = env.scene.sensors[sensor_cfg.name] + net_forces = contact_sensor.data.net_forces_w.torch[:, sensor_cfg.body_ids] + observed = torch.linalg.norm(net_forces, dim=-1) > threshold + reference = _reference_contacts(_gait_phase(env, gait_period)).bool() + return torch.sum(observed == reference, dim=1).float() - 1.0 + + +def foot_clearance( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + target_height: float = 0.065, + sigma: float = 0.01, + gait_period: float = 1.0, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + foot_ids = asset_cfg.body_ids + foot_z = asset.data.body_link_pos_w.torch[:, foot_ids, 2] + phase = _gait_phase(env, gait_period) + two_pi = 2.0 * math.pi + left_target = target_height * torch.clamp(torch.sin(two_pi * (phase - 0.5)), min=0.0) + right_target = target_height * torch.clamp(torch.sin(two_pi * phase), min=0.0) + left_swing = (left_target > 0.0).float() + right_swing = (right_target > 0.0).float() + left_reward = left_swing * _exp_se(torch.square(left_target - foot_z[:, 0]), sigma) + right_reward = right_swing * _exp_se(torch.square(right_target - foot_z[:, 1]), sigma) + return left_reward + right_reward + + +def feet_parallel( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + sigma: float = 0.2, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + foot_quat = asset.data.body_link_quat_w.torch[:, asset_cfg.body_ids] + left_yaw = math_utils.yaw_quat(foot_quat[:, 0]) + right_yaw = math_utils.yaw_quat(foot_quat[:, 1]) + return _exp_quat_err(left_yaw, right_yaw, sigma) + + +def feet_flat( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + sigma: float = 0.2, +) -> torch.Tensor: + """Reward keeping each foot level (small roll/pitch); foot yaw is ignored. Mean over feet.""" + asset = env.scene[asset_cfg.name] + foot_quat = asset.data.body_link_quat_w.torch[:, asset_cfg.body_ids] + num_envs, num_feet = foot_quat.shape[0], foot_quat.shape[1] + quat = foot_quat.reshape(-1, 4) + tilt = _quat_inv_mul(math_utils.yaw_quat(quat), quat) + tilt_sq = torch.sum(torch.square(tilt[:, :3]), dim=1).reshape(num_envs, num_feet) + return _exp_se(tilt_sq, sigma).mean(dim=1) + + +def feet_touchdown_vel( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + sensor_cfg: SceneEntityCfg, + threshold: float = 1.0, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + contact_sensor = env.scene.sensors[sensor_cfg.name] + foot_z_vel = asset.data.body_com_lin_vel_w.torch[:, asset_cfg.body_ids, 2] + net_forces = contact_sensor.data.net_forces_w.torch[:, sensor_cfg.body_ids] + contact = (torch.linalg.norm(net_forces, dim=-1) > threshold).float() + downward_vel = torch.clamp(-foot_z_vel, min=0.0) + return torch.sum(contact * downward_vel, dim=-1) + + +def root_orientation_exp( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + sigma: float = 0.2, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + root_quat = asset.data.root_link_quat_w.torch + tilt = _quat_inv_mul(math_utils.yaw_quat(root_quat), root_quat) + return _exp_se(torch.sum(torch.square(tilt[:, :3]), dim=1), sigma) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py new file mode 100644 index 000000000000..16382957aca6 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py @@ -0,0 +1,151 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Velocity-tracking walk environment for the Disney DR Legs closed-loop biped (Kamino). + +Extends the hold-pose task with a base-velocity command, a gait-phase clock, a foot +contact sensor, and gait / contact / foot-clearance rewards. +""" + +import math + +from isaaclab_newton.sensors import ContactSensorCfg as NewtonContactSensorCfg + +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils.configclass import configclass + +import isaaclab_tasks.contrib.dr_legs.mdp as mdp + +from isaaclab_assets.robots.dr_legs import DR_LEGS_ACTUATED_JOINTS + +from .hold_pose_env_cfg import DrLegsHoldPoseEnvCfg, HoldPoseSceneCfg, ObservationsCfg + +## +# Shared constants +## + +# Full left+right gait cycle [s]. Shared by the gait-phase observation and the rewards. +_GAIT_PERIOD = 1.0 + +# The 12 servo-driven joints (used by the implicit-PD effort proxy). +_ACTUATED_JOINT_CFG = SceneEntityCfg("robot", joint_names=DR_LEGS_ACTUATED_JOINTS, preserve_order=True) + +# Foot bodies in canonical [left, right] order for the kinematics and contact rewards. +_FOOT_ASSET_CFG = SceneEntityCfg("robot", body_names=["foot_l", "foot_r"], preserve_order=True) +_FOOT_SENSOR_CFG = SceneEntityCfg("contact_forces", body_names=["foot_l", "foot_r"], preserve_order=True) + + +@configclass +class WalkSceneCfg(HoldPoseSceneCfg): + contact_forces = NewtonContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/foot_.*", + history_length=3, + track_air_time=True, + ) + + +@configclass +class CommandsCfg: + base_velocity = mdp.UniformVelocityCommandCfg( + asset_name="robot", + resampling_time_range=(10.0, 10.0), + rel_standing_envs=0.1, + rel_heading_envs=1.0, + heading_command=True, + heading_control_stiffness=0.5, + debug_vis=False, + ranges=mdp.UniformVelocityCommandCfg.Ranges( + lin_vel_x=(-0.3, 0.3), + lin_vel_y=(-0.3, 0.3), + ang_vel_z=(-0.8, 0.8), + heading=(-math.pi, math.pi), + ), + ) + + +@configclass +class WalkObservationsCfg(ObservationsCfg): + @configclass + class WalkPolicyCfg(ObservationsCfg.PolicyCfg): + velocity_commands = ObsTerm(func=mdp.generated_commands, params={"command_name": "base_velocity"}) + gait_phase = ObsTerm(func=mdp.gait_phase, params={"period": _GAIT_PERIOD}) + + policy: WalkPolicyCfg = WalkPolicyCfg() + + +@configclass +class WalkRewardsCfg: + """Gait / contact / feet reward suite for velocity-tracking walking.""" + + # -- positive (task / gait) + survival = RewTerm(func=mdp.is_alive, weight=10.0) + contact_matching = RewTerm( + func=mdp.contact_matching, + weight=10.0, + params={"sensor_cfg": _FOOT_SENSOR_CFG, "threshold": 1.0, "gait_period": _GAIT_PERIOD}, + ) + track_lin_vel_xy = RewTerm( + func=mdp.track_lin_vel_xy_exp, + weight=5.0, + params={"command_name": "base_velocity", "std": math.sqrt(0.125)}, + ) + root_orientation = RewTerm( + func=mdp.root_orientation_exp, + weight=5.0, + params={"asset_cfg": SceneEntityCfg("robot"), "sigma": 0.2}, + ) + foot_clearance = RewTerm( + func=mdp.foot_clearance, + weight=5.0, + params={ + "asset_cfg": _FOOT_ASSET_CFG, + "target_height": 0.065, + "sigma": 0.01, + "gait_period": _GAIT_PERIOD, + }, + ) + track_ang_vel_z = RewTerm( + func=mdp.track_ang_vel_z_exp, + weight=2.0, + params={"command_name": "base_velocity", "std": math.sqrt(0.5)}, + ) + feet_parallel = RewTerm( + func=mdp.feet_parallel, + weight=2.0, + params={"asset_cfg": _FOOT_ASSET_CFG, "sigma": 0.2}, + ) + feet_flat = RewTerm( + func=mdp.feet_flat, + weight=2.0, + params={"asset_cfg": _FOOT_ASSET_CFG, "sigma": 0.2}, + ) + + # -- negative (penalties) + feet_touchdown_vel = RewTerm( + func=mdp.feet_touchdown_vel, + weight=-2.0, + params={"asset_cfg": _FOOT_ASSET_CFG, "sensor_cfg": _FOOT_SENSOR_CFG, "threshold": 1.0}, + ) + lin_vel_z = RewTerm(func=mdp.lin_vel_z_l2, weight=-0.5) + ang_vel_xy = RewTerm(func=mdp.ang_vel_xy_l2, weight=-0.5) + torques = RewTerm( + func=mdp.joint_pd_command_l2, + weight=-0.001, + params={"asset_cfg": _ACTUATED_JOINT_CFG, "stiffness": 5.0, "damping": 0.2}, + ) + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-0.001) + action_2nd_derivative = RewTerm(func=mdp.ActionRate2L2, weight=-5.0e-5) + + +@configclass +class DrLegsWalkEnvCfg(DrLegsHoldPoseEnvCfg): + """DR Legs velocity-tracking walk environment (Newton/Kamino backend).""" + + scene: WalkSceneCfg = WalkSceneCfg(num_envs=4096, env_spacing=2.0) + observations: WalkObservationsCfg = WalkObservationsCfg() + commands: CommandsCfg = CommandsCfg() + rewards: WalkRewardsCfg = WalkRewardsCfg() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py index c29ea2500da4..8436893db821 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py index 88799d6df62e..dd4765cbe737 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py index 65795ad8230a..09619985e329 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py index ff8a24d0217f..e3e1daa84676 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -28,6 +28,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py index a1502f7b2de8..3df3825a4404 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py @@ -6,7 +6,7 @@ from dataclasses import MISSING -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -73,6 +73,11 @@ class CabinetSimCfg(PresetCfg): debug_mode=False, ), ) + newton_kamino: SimulationCfg = SimulationCfg( + dt=1 / 600, + render_interval=1, + physics=NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=1), + ) ## diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py index cd226b543def..bed549290c05 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.envs.mdp as mdp @@ -43,6 +43,10 @@ class ReachPhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) + newton_kamino: NewtonCfg = NewtonCfg( + solver_cfg=KaminoSolverCfg(max_contacts_per_world=32), + num_substeps=2, + ) default = physx @@ -65,7 +69,7 @@ class TableCfg(PresetCfg): newton_mjwarp: ArticulationCfg = ArticulationCfg( prim_path="/World/envs/env_.*/Table", init_state=ArticulationCfg.InitialStateCfg( - pos=(0.5, 0.15, -0.5), rot=(0, 0, 0.707, 0.707), joint_pos={}, joint_vel={} + pos=(0.5, 0, -0.5), rot=(0, 0, 0.707, 0.707), joint_pos={}, joint_vel={} ), spawn=sim_utils.CuboidCfg( size=(0.9, 1.3, 1.00), @@ -76,6 +80,7 @@ class TableCfg(PresetCfg): articulation_root_prim_path="", ) + newton_kamino = newton_mjwarp default = physx diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py index c4d785115555..a77d33b9b521 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.envs.mdp as mdp @@ -135,6 +135,7 @@ class ShadowHandEventCfg(PresetCfg): physx = PhysxEventCfg() newton_mjwarp = NewtonEventCfg() default = physx + newton_kamino = newton_mjwarp @configclass @@ -207,6 +208,7 @@ class ShadowHandRobotCfg(PresetCfg): soft_joint_pos_limit_factor=1.0, ) default = physx + newton_kamino = newton_mjwarp @configclass @@ -246,6 +248,7 @@ class ObjectCfg(PresetCfg): articulation_root_prim_path="", ) default = physx + newton_kamino = newton_mjwarp @configclass @@ -263,6 +266,7 @@ class ShadowHandSceneCfg(PresetCfg): num_envs=8192, env_spacing=0.75, replicate_physics=True, clone_in_fabric=False ) default: InteractiveSceneCfg = physx + newton_kamino = newton_mjwarp @configclass @@ -287,6 +291,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) default = physx + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=128), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py index 02bae7147b9e..745c08526174 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py index 02a3b4d1252f..34ae1b080910 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.managers import SceneEntityCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py index bb6c63472336..ab145d6a4683 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py index 2b30240f5fbe..4e54676fa1a8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py index b89fa7ef126a..b3ac1fb949f9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py @@ -3,7 +3,13 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg, NewtonCollisionPipelineCfg, NewtonShapeCfg +from isaaclab_newton.physics import ( + KaminoSolverCfg, + MJWarpSolverCfg, + NewtonCfg, + NewtonCollisionPipelineCfg, + NewtonShapeCfg, +) from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -43,6 +49,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, default_shape_cfg=NewtonShapeCfg(margin=0.01), ) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) ## @@ -223,6 +230,7 @@ class SpotEventCfg(PresetCfg): default = SpotPhysxEventCfg() newton_mjwarp = SpotNewtonEventCfg() physx = default + newton_kamino = newton_mjwarp @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py index 16e4fb0d04d1..24e20b9622f8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py @@ -80,6 +80,7 @@ class RoughPhysicsCfg(PresetCfg): class VelocityEnvContactSensorCfg(PresetCfg): default = PhysXContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) newton_mjwarp = NewtonContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) + newton_kamino = newton_mjwarp physx = default ovphysx = OvPhysXContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) From 21ba048bd7dd4422305f8bdbdb1c6052a87f829e Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 3 Jul 2026 11:07:44 -0400 Subject: [PATCH 05/23] [IsaacLab 3.0.0 Multi-Backend] Visual Logging for Headless Runs (#6212) # Description REQ-12: Support capturing images from the visualizer when running headless such that we can capture the full or subset of environments during training to observe progress through frameworks such as tensorboard or wandb This feature exports per step frames from simulation from full or subset of the environments. Fixes # (OMPE-78691) https://github.com/isaac-sim/IsaacLab/blob/0316aea144ff1d159159db182a28319bf56be186/source/isaaclab_tasks/test/rendering_test_utils.py#L553 I save the images in the same way as the tests compare goldens moving this shared logic into utils/images.py ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots image ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Matthew Taylor Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- CONTRIBUTORS.md | 1 + docs/source/how-to/capture_sensor_frames.rst | 180 ++++++++++++++ docs/source/how-to/index.rst | 1 + docs/source/how-to/record_video.rst | 1 + .../rl_existing_scripts.rst | 4 + .../tutorials/03_envs/run_rl_training.rst | 4 + scripts/reinforcement_learning/common.py | 173 +++++++++++++- .../rl_games/train_rl_games.py | 4 +- .../rsl_rl/train_rsl_rl.py | 4 +- .../reinforcement_learning/sb3/train_sb3.py | 4 +- .../reinforcement_learning/skrl/train_skrl.py | 4 +- .../capture-env-sensors-tests.skip | 1 + source/isaaclab/isaaclab/utils/images.py | 25 ++ .../test_reinforcement_learning_common.py | 219 ++++++++++++++++++ source/isaaclab/test/utils/test_images.py | 39 ++++ .../changelog.d/mt-visual-sensor-logs.skip | 1 + .../test/rendering_test_utils.py | 30 +-- 17 files changed, 658 insertions(+), 37 deletions(-) create mode 100644 docs/source/how-to/capture_sensor_frames.rst create mode 100644 source/isaaclab/changelog.d/capture-env-sensors-tests.skip create mode 100644 source/isaaclab/test/test_reinforcement_learning_common.py create mode 100644 source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index c70ed2d3faf6..0cceced8f20e 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -128,6 +128,7 @@ Guidelines for modifications: * Marco Alesiani * Masoud Moghani * Mateo Guaman Castro +* Matthew Taylor * Maurice Rahme * Michael Gussert * Michael Lin diff --git a/docs/source/how-to/capture_sensor_frames.rst b/docs/source/how-to/capture_sensor_frames.rst new file mode 100644 index 000000000000..7988a8346b8b --- /dev/null +++ b/docs/source/how-to/capture_sensor_frames.rst @@ -0,0 +1,180 @@ +.. _how-to-capture-sensor-frames: + +Capturing sensor frames during training +======================================= + +Isaac Lab supports saving image-like outputs from scene sensors during training using the +``CaptureEnvSensors`` Gymnasium wrapper. When ``--capture_env_sensors`` is set to a positive value, +Isaac Lab iterates over the environment's :class:`~isaaclab.scene.InteractiveScene` sensors and +writes each sensor's ``data.output`` tensors (for example, ``rgb``, ``depth``, or ``normals`` from a +:class:`~isaaclab.sensors.Camera`). The flag value is the number of parallel environment views to +tile into each saved frame grid. This differs from :doc:`record_video`, which records a single +perspective viewport clip of the scene. + +The capture flags are registered on the shared training entrypoints for RSL-RL, RL-Games, Stable +Baselines3, and skrl. They are not available on ``play`` scripts. + +This feature can be enabled using the following command line arguments with +``./isaaclab.sh train``: + +* ``--capture_env_sensors``: number of parallel environments to include in each saved frame grid + (default: ``0``, which disables capture) +* ``--capture_env_sensors_length``: length of each captured sensor frame window in **per-episode** + steps (default: ``200``). Set to ``0`` to disable saving even when ``--capture_env_sensors`` is + positive. +* ``--capture_env_sensors_interval``: interval between captured sensor frame windows in **per-episode** + steps (default: ``2000``) +* ``--capture_env_sensors_format``: output format, either ``tensorboard`` (default) or ``file`` + +Enabling sensor capture automatically enables camera rendering during training, the same as +``--video``, which slows down both startup and runtime performance. Sensor capture can also be used +at the same time as ``--video`` when both per-sensor frames and a perspective viewport clip are +needed. + +Example usage: + +.. code-block:: shell + + ./isaaclab.sh train --rl_library rsl_rl --task=Isaac-Reorient-Cube-Shadow-Camera-Direct --capture_env_sensors 4 --capture_env_sensors_length 100 --capture_env_sensors_interval 2000 --capture_env_sensors_format file + + +The captured frames will be saved in the same directory as the training checkpoints, under +``logs////sensor_frames/train``. Here ```` is the training run +directory (the same timestamped folder used for checkpoints and ``run.json``), not the per-episode +index used in output tags and file names. + + +Overview +-------- + +The sensor capture feature wraps the training environment through ``wrap_training_capture`` in +``scripts/reinforcement_learning/common.py`` and saves frames on reset and step when the current +**per-episode** step falls inside a capture window. For each image-like scene sensor, the wrapper: + +* reads ``sensor.data.output`` and skips sensors whose output is not a dictionary of tensors +* resolves each output to a :class:`torch.Tensor`, including + :class:`~isaaclab.utils.warp.ProxyArray` buffers through their ``.torch`` accessor +* selects the first ``capture_num_envs`` parallel environments from each tensor +* replaces non-finite values with zero before display normalization +* normalizes each output for display using :func:`~isaaclab.utils.images.normalize_camera_output_for_display` + and tiles the selected views into a single image grid with + :func:`~isaaclab.utils.images.make_camera_output_grid` +* writes the grid to TensorBoard or to disk as a PNG, depending on ``--capture_env_sensors_format`` + +Sensors with ``None`` for a given data type are skipped. Only tasks that define scene sensors with +image-like outputs (such as camera-based RL environments) produce captured frames. + + +Command-line options +-------------------- + +The training entrypoints register the capture flags in ``add_common_train_args``: + +.. literalinclude:: ../../../scripts/reinforcement_learning/common.py + :language: python + :lines: 240-263 + + +Capture schedule +---------------- + +A frame is saved on every environment reset and after each step while the current **per-episode** +step is inside an active capture window. The episode step counter resets to ``0`` on every +environment reset. A step is inside the window when: + +.. code-block:: text + + step % capture_env_sensors_interval < capture_env_sensors_length + +For example, with ``--capture_env_sensors_length 100`` and ``--capture_env_sensors_interval 2000``, +the wrapper saves episode steps ``0-99`` after each reset. If an episode lasts longer than 2000 +steps, another capture window opens at episode steps ``2000-2099``, then ``4000-4099``, and so on. + +.. note:: + + This schedule uses a **per-episode** step counter. :doc:`record_video` instead keys off the + Gymnasium ``RecordVideo`` wrapper's **global** environment-step counter across episodes. + + +Episode indexing +^^^^^^^^^^^^^^^^ + +Each environment reset increments an episode index that starts at ``1`` on the first reset. Captured +frames are grouped by this index so consecutive episodes can be compared: + +* TensorBoard tags use ``//episode_`` with a five-digit zero-padded + episode index (for example, ``episode_00001``) +* File output uses ``episode__step_.png`` under each sensor and data-type directory, + with a five-digit episode index and an eight-digit step index (for example, + ``episode_00001_step_00000042.png``) + +The ```` value is the per-episode step counter (reset to ``0`` on every environment reset). +In TensorBoard, images are logged with ``global_step`` set to the total number of environment steps +since training started. + + +Image processing +---------------- + +Before tiling, each selected environment view is passed through +:func:`~isaaclab.utils.images.normalize_camera_output_for_display`, which maps common camera data +types to a ``[0, 1]`` float range suitable for PNG export: + +* RGB-like outputs are scaled by ``255`` +* Depth-like outputs (``depth``, ``distance_to_camera``, ``distance_to_image_plane``) are scaled by + their per-frame maximum +* ``albedo`` keeps the first three channels and scales by ``255`` +* ``normals`` are remapped from ``[-1, 1]`` to ``[0, 1]`` + +:func:`~isaaclab.utils.images.make_camera_output_grid` arranges the ``capture_num_envs`` views into a +roughly square grid (``nrow = round(sqrt(num_envs))``) before the image is written. + + +Output formats +-------------- + +**TensorBoard (default).** Each sensor output is logged as a separate image series per episode with +the tag ``//episode_``. View the captures alongside other training +metrics by pointing TensorBoard at the training log directory or the ``sensor_frames/train`` +subdirectory: + +.. code-block:: shell + + ./isaaclab.sh -p -m tensorboard.main --logdir logs/rsl_rl/Isaac-Reorient-Cube-Shadow-Camera-Direct//sensor_frames/train + +**File.** PNG images are written per sensor, data type, episode, and step: + +``//episode__step_.png`` + +Sensor and data-type names are sanitized for file output so they are safe as path components. For +example, a frame from the first episode at step ``42`` for sensor ``front/camera`` and data type +``rgb`` is written as ``front_camera/rgb/episode_00001_step_00000042.png``. + + +Summary +------- + +.. list-table:: + :widths: 30 35 35 + :header-rows: 1 + + * - ``--capture_env_sensors_format`` + - Output location + - How to view + * - ``tensorboard`` (default) + - ``sensor_frames/train`` event files + - TensorBoard image tab (tagged by sensor, data type, and episode index) + * - ``file`` + - ``sensor_frames/train///episode__step_.png`` + - Any image viewer or filesystem browser + + +See also +-------- + +* :doc:`record_video` - record a perspective viewport clip with ``--video`` +* :doc:`/source/overview/reinforcement-learning/rl_existing_scripts` - training entrypoints that + expose the capture flags +* :doc:`/source/overview/core-concepts/visualization` - visualizers and rendering during training +* :doc:`/source/overview/core-concepts/sensors/camera` - camera sensors and annotator data types +* :doc:`proxy_array` - dual CPU/GPU sensor buffers read through ``.torch`` diff --git a/docs/source/how-to/index.rst b/docs/source/how-to/index.rst index 7d4f37449e09..d0362c0cbf6f 100644 --- a/docs/source/how-to/index.rst +++ b/docs/source/how-to/index.rst @@ -146,6 +146,7 @@ This guide explains how to record an animation and video in Isaac Lab. record_animation record_video + capture_sensor_frames Dynamically Modifying Environment Parameters With CurriculumTerm diff --git a/docs/source/how-to/record_video.rst b/docs/source/how-to/record_video.rst index 28b89710546b..2490ab813283 100644 --- a/docs/source/how-to/record_video.rst +++ b/docs/source/how-to/record_video.rst @@ -177,4 +177,5 @@ Summary See also -------- +* :doc:`capture_sensor_frames` - save image-like scene sensor outputs during training * :doc:`/source/overview/core-concepts/visualization` - interactive visualizers diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index 24720d305874..9eaa3d032502 100644 --- a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst +++ b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst @@ -67,6 +67,10 @@ Other available presets for this environment: ``albedo``, ``simple_shading_full_mdl``. The ``depth`` preset is intended for benchmarking only (see the environment's config for details). +During training, image-like scene sensor outputs from camera tasks can be saved with +``--capture_env_sensors``. See :doc:`/source/how-to/capture_sensor_frames` for the full capture +schedule and output format details. + RL-Games -------- diff --git a/docs/source/tutorials/03_envs/run_rl_training.rst b/docs/source/tutorials/03_envs/run_rl_training.rst index 4723d4110779..22b853048e05 100644 --- a/docs/source/tutorials/03_envs/run_rl_training.rst +++ b/docs/source/tutorials/03_envs/run_rl_training.rst @@ -105,6 +105,10 @@ in the workflow and pass ``--video`` to record the agent behavior. The videos are saved to the ``logs/sb3/Isaac-Cartpole//videos/train`` directory. You can open these videos using any video player. +For tasks with on-scene cameras, you can also save the sensor image outputs directly during training +with ``--capture_env_sensors``. See :doc:`/source/how-to/capture_sensor_frames` for the available +options and output formats. + Interactive execution """"""""""""""""""""" diff --git a/scripts/reinforcement_learning/common.py b/scripts/reinforcement_learning/common.py index f99a75779520..e5591704ba5c 100644 --- a/scripts/reinforcement_learning/common.py +++ b/scripts/reinforcement_learning/common.py @@ -21,10 +21,13 @@ from typing import Any import gymnasium as gym +import torch +from PIL import Image from isaaclab.app import add_launcher_args from isaaclab.envs import DirectMARLEnvCfg, ManagerBasedRLEnvCfg from isaaclab.utils.dict import print_dict +from isaaclab.utils.images import make_camera_output_grid, normalize_camera_output_for_display from isaaclab.utils.io import dump_yaml RUN_MANIFEST_FILENAME = "run.json" @@ -32,6 +35,113 @@ CHECKPOINT_SELECTORS = frozenset({"latest", "best"}) +class CaptureEnvSensors(gym.Wrapper): + """Capture image-like environment sensor outputs during training.""" + + def __init__( + self, + env: gym.Env, + output_dir: str, + frame_count: int, + capture_num_envs: int, + interval: int, + output_format: str = "tensorboard", + ) -> None: + """Initialize the sensor capture wrapper. + + Args: + env: Gymnasium environment to wrap. + output_dir: Directory where captured frames are written. + frame_count: Number of frames to capture per interval. + capture_num_envs: Number of environment views to capture from each sensor. + interval: Number of environment steps between capture windows. + output_format: Output format. Can be ``"tensorboard"`` or ``"file"``. + """ + super().__init__(env) + self.output_dir = output_dir + os.makedirs(self.output_dir, exist_ok=True) + self.frame_count = max(frame_count, 0) + self.capture_num_envs = max(capture_num_envs, 0) + self.interval = max(interval, 1) + self._step_count = 0 + self._global_step_count = 0 + self._episode_index = 0 + self.writer = None + + if output_format not in {"tensorboard", "file"}: + raise ValueError(f"Unsupported sensor capture output format: {output_format}") + if output_format == "tensorboard": + from torch.utils.tensorboard import SummaryWriter + + self.writer = SummaryWriter(self.output_dir) + + def reset(self, **kwargs) -> Any: + """Reset the wrapped environment and capture the reset frame when scheduled.""" + result = self.env.reset(**kwargs) + self._step_count = 0 + self._episode_index += 1 + self._save_frame() + return result + + def step(self, action) -> Any: + """Step the wrapped environment and capture the resulting frame when scheduled.""" + result = self.env.step(action) + self._step_count += 1 + self._global_step_count += 1 + self._save_frame() + return result + + def close(self) -> None: + """Close the writer and wrapped environment.""" + if self.writer is not None: + self.writer.close() + super().close() + + def _save_frame(self) -> None: + """Write the current sensor outputs when the current step is inside a capture window.""" + if self.frame_count == 0: + return + if self._step_count % self.interval >= self.frame_count: + return + + sensors = getattr(getattr(self.unwrapped, "scene", None), "sensors", {}) + + for sensor_name, sensor in sensors.items(): + camera_outputs = getattr(getattr(sensor, "data", None), "output", None) + if not isinstance(camera_outputs, dict): + continue + + for data_type, output in camera_outputs.items(): + if output is None: + continue + tensor = output if isinstance(output, torch.Tensor) else output.torch + tensor = tensor[: self.capture_num_envs].detach().clone() + condition = torch.logical_or(torch.isinf(tensor), torch.isnan(tensor)) + corrected = torch.where(condition, torch.zeros_like(tensor), tensor) + normalized = normalize_camera_output_for_display(corrected, data_type) + grid = make_camera_output_grid(normalized) + ndarr = grid.mul(255).add_(0.5).clamp_(0, 255).permute(1, 2, 0).to("cpu", torch.uint8).numpy() + tag = f"{sensor_name}/{data_type}/episode_{self._episode_index:05d}" + + if self.writer is not None: + self.writer.add_image(tag, ndarr, global_step=self._global_step_count, dataformats="HWC") + else: + file_path = os.path.join( + self.output_dir, + self._safe_path_name(sensor_name), + self._safe_path_name(data_type), + f"episode_{self._episode_index:05d}_step_{self._step_count:08d}.png", + ) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + result_image = Image.fromarray(ndarr) + result_image.save(file_path) + + @staticmethod + def _safe_path_name(name: str) -> str: + """Return a filesystem-safe path component.""" + return "".join(character if character.isalnum() or character in "._-" else "_" for character in name) + + def dispatch_library_entrypoint( argv: list[str] | None, entrypoints: dict[str, Path], @@ -127,6 +237,31 @@ def add_common_train_args( help="Automatically configured by Ray integration, otherwise None.", ) + parser.add_argument( + "--capture_env_sensors", + type=int, + default=0, + help="Number of environment views to capture from each image-like scene sensor.", + ) + parser.add_argument( + "--capture_env_sensors_length", + type=int, + default=200, + help="Length of each captured sensor frame window (in steps).", + ) + parser.add_argument( + "--capture_env_sensors_interval", + type=int, + default=2000, + help="Interval between captured sensor frame windows (in steps).", + ) + parser.add_argument( + "--capture_env_sensors_format", + choices=["tensorboard", "file"], + default="tensorboard", + help="Format used to save the captured sensor frames.", + ) + def add_isaaclab_launcher_args(parser: argparse.ArgumentParser) -> None: """Add Isaac Lab simulation launcher arguments to a parser. @@ -138,12 +273,12 @@ def add_isaaclab_launcher_args(parser: argparse.ArgumentParser) -> None: def enable_cameras_for_video(args_cli: argparse.Namespace) -> None: - """Enable camera rendering when video recording is requested. + """Enable camera rendering when video recording or sensor capture is requested. Args: args_cli: Parsed command-line arguments. """ - if getattr(args_cli, "video", False): + if getattr(args_cli, "video", False) or getattr(args_cli, "capture_env_sensors", 0) > 0: args_cli.enable_cameras = True @@ -250,6 +385,33 @@ def create_isaaclab_env( return env +def wrap_sensor_capture(env: gym.Env, log_dir: str, args_cli: argparse.Namespace): + """Wrap an environment with sensor capture when requested. + + Args: + env: Gymnasium environment to wrap. + log_dir: Training log directory. + args_cli: Parsed command-line arguments. + + Returns: + The original or sensor-capture-wrapped environment. + """ + if args_cli.capture_env_sensors <= 0: + return env + + output_dir = os.path.join(log_dir, "sensor_frames", "train") + sensor_capture_kwargs = { + "output_dir": output_dir, + "frame_count": args_cli.capture_env_sensors_length, + "capture_num_envs": args_cli.capture_env_sensors, + "interval": args_cli.capture_env_sensors_interval, + "output_format": args_cli.capture_env_sensors_format, + } + print("[INFO] Capturing environment sensor frames during training.") + print_dict(sensor_capture_kwargs, nesting=4) + return CaptureEnvSensors(env, **sensor_capture_kwargs) + + def wrap_record_video(env, log_dir: str, args_cli: argparse.Namespace): """Wrap an environment with video recording when requested. @@ -275,6 +437,13 @@ def wrap_record_video(env, log_dir: str, args_cli: argparse.Namespace): return gym.wrappers.RecordVideo(env, **video_kwargs) +def wrap_training_capture(env: gym.Env, log_dir: str, args_cli: argparse.Namespace) -> gym.Env: + """Apply optional video and sensor capture wrappers for training.""" + env = wrap_record_video(env, log_dir, args_cli) + env = wrap_sensor_capture(env, log_dir, args_cli) + return env + + def dump_train_configs(log_dir: str, env_cfg: Any, agent_cfg: Any) -> None: """Dump training configuration files under a run log directory. diff --git a/scripts/reinforcement_learning/rl_games/train_rl_games.py b/scripts/reinforcement_learning/rl_games/train_rl_games.py index 7a487479609a..a863b9a424bf 100644 --- a/scripts/reinforcement_learning/rl_games/train_rl_games.py +++ b/scripts/reinforcement_learning/rl_games/train_rl_games.py @@ -30,7 +30,7 @@ resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) @@ -175,7 +175,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) - env = wrap_record_video(env, run_log_dir, args_cli) + env = wrap_training_capture(env, run_log_dir, args_cli) start_time = time.time() env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions, obs_groups, concate_obs_groups) diff --git a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py index 6d30f9faa68b..8f5c797cad40 100644 --- a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py +++ b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py @@ -30,7 +30,7 @@ resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) from packaging import version @@ -176,7 +176,7 @@ def run(argv: list[str]) -> None: else: resume_path = get_checkpoint_path(log_root_path, agent_cfg.load_run, agent_cfg.load_checkpoint) - env = wrap_record_video(env, log_dir, args_cli) + env = wrap_training_capture(env, log_dir, args_cli) start_time = time.time() env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) diff --git a/scripts/reinforcement_learning/sb3/train_sb3.py b/scripts/reinforcement_learning/sb3/train_sb3.py index 32032c9be8a6..52b73d75f383 100644 --- a/scripts/reinforcement_learning/sb3/train_sb3.py +++ b/scripts/reinforcement_learning/sb3/train_sb3.py @@ -29,7 +29,7 @@ enable_cameras_for_video, resolve_checkpoint_selector, set_hydra_args, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) @@ -140,7 +140,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) - env = wrap_record_video(env, log_dir, args_cli) + env = wrap_training_capture(env, log_dir, args_cli) start_time = time.time() env = Sb3VecEnvWrapper(env, fast_variant=not args_cli.keep_all_info) diff --git a/scripts/reinforcement_learning/skrl/train_skrl.py b/scripts/reinforcement_learning/skrl/train_skrl.py index 1be36b42e84d..7ca754f64938 100644 --- a/scripts/reinforcement_learning/skrl/train_skrl.py +++ b/scripts/reinforcement_learning/skrl/train_skrl.py @@ -27,7 +27,7 @@ resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) from packaging import version @@ -197,7 +197,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg) and algorithm in ["ppo"], ) - env = wrap_record_video(env, log_dir, args_cli) + env = wrap_training_capture(env, log_dir, args_cli) start_time = time.time() env = SkrlVecEnvWrapper(env, ml_framework=args_cli.ml_framework) diff --git a/source/isaaclab/changelog.d/capture-env-sensors-tests.skip b/source/isaaclab/changelog.d/capture-env-sensors-tests.skip new file mode 100644 index 000000000000..a34e75ee2c7e --- /dev/null +++ b/source/isaaclab/changelog.d/capture-env-sensors-tests.skip @@ -0,0 +1 @@ +Tests only. diff --git a/source/isaaclab/isaaclab/utils/images.py b/source/isaaclab/isaaclab/utils/images.py index f832af8381e2..19361e73b7ad 100644 --- a/source/isaaclab/isaaclab/utils/images.py +++ b/source/isaaclab/isaaclab/utils/images.py @@ -94,3 +94,28 @@ 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 + 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/test/test_reinforcement_learning_common.py b/source/isaaclab/test/test_reinforcement_learning_common.py new file mode 100644 index 000000000000..4f563e90ca74 --- /dev/null +++ b/source/isaaclab/test/test_reinforcement_learning_common.py @@ -0,0 +1,219 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for shared reinforcement learning script utilities.""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any + +import gymnasium as gym +import pytest +import torch + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _load_rl_common_module() -> ModuleType: + module_path = _repo_root() / "scripts" / "reinforcement_learning" / "common.py" + spec = importlib.util.spec_from_file_location("isaaclab_test_reinforcement_learning_common", module_path) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load reinforcement learning common module from {module_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +_rl_common = _load_rl_common_module() +CaptureEnvSensors: Any = getattr(_rl_common, "CaptureEnvSensors") +add_common_train_args: Any = getattr(_rl_common, "add_common_train_args") +enable_cameras_for_video: Any = getattr(_rl_common, "enable_cameras_for_video") +wrap_sensor_capture: Any = getattr(_rl_common, "wrap_sensor_capture") + + +class _FakeEnv(gym.Env): + """Minimal Gymnasium env exposing an IsaacLab-style scene sensor mapping.""" + + def __init__(self, sensors: dict[str, Any] | None = None) -> None: + self.scene = SimpleNamespace(sensors=sensors or {}) + self.closed = False + + def reset(self, **kwargs: Any) -> tuple[dict[str, torch.Tensor], dict[str, Any]]: + return {"obs": torch.zeros(1)}, {} + + def step(self, action: Any) -> tuple[dict[str, torch.Tensor], float, bool, bool, dict[str, Any]]: + return {"obs": torch.ones(1)}, 0.0, False, False, {} + + def close(self) -> None: + self.closed = True + + +def _make_sensor(output: dict[str, Any]) -> SimpleNamespace: + return SimpleNamespace(data=SimpleNamespace(output=output)) + + +def _make_capture_wrapper(tmp_path: Path, **kwargs: Any) -> Any: + defaults = { + "env": _FakeEnv(), + "output_dir": str(tmp_path), + "frame_count": 1, + "capture_num_envs": 1, + "interval": 1, + "output_format": "file", + } + defaults.update(kwargs) + return CaptureEnvSensors(**defaults) + + +def test_capture_env_sensors_saves_file_outputs_on_scheduled_steps( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """File capture writes image grids during the active capture window.""" + rgb = torch.tensor( + [ + [[[0, 127, 255, 9], [255, 0, 127, 9]]], + [[[42, 42, 42, 9], [43, 43, 43, 9]]], + ], + dtype=torch.uint8, + ) + env = _FakeEnv({"front/camera": _make_sensor({"rgb": rgb})}) + saved_images: list[Any] = [] + saved_paths: list[Path] = [] + + class _FakeImage: + def __init__(self, image: Any) -> None: + self.image = image + + def save(self, path: str) -> None: + saved_images.append(self.image.copy()) + saved_paths.append(Path(path)) + + monkeypatch.setattr(_rl_common.Image, "fromarray", _FakeImage) + wrapper = _make_capture_wrapper( + tmp_path, + env=env, + frame_count=2, + capture_num_envs=1, + interval=3, + ) + + wrapper.reset() + wrapper.step(None) + wrapper.step(None) + wrapper.step(None) + + relative_paths = [path.relative_to(tmp_path).as_posix() for path in saved_paths] + assert relative_paths == [ + "front_camera/rgb/episode_00001_step_00000000.png", + "front_camera/rgb/episode_00001_step_00000001.png", + "front_camera/rgb/episode_00001_step_00000003.png", + ] + assert all(image.shape == (1, 2, 4) for image in saved_images) + assert all((image == rgb[0].numpy()).all() for image in saved_images) + + +def test_capture_env_sensors_accepts_proxyarray_torch_buffers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ProxyArray-style buffers are read through their ``.torch`` accessor.""" + image_buffer = SimpleNamespace(torch=torch.ones((3, 2, 2, 4), dtype=torch.float32)) + env = _FakeEnv({"camera": _make_sensor({"rgb": image_buffer})}) + captured_tensors: list[torch.Tensor] = [] + + def fake_normalize(tensor: torch.Tensor, data_type: str) -> torch.Tensor: + captured_tensors.append(tensor.clone()) + return tensor + + monkeypatch.setattr(_rl_common, "normalize_camera_output_for_display", fake_normalize) + monkeypatch.setattr(_rl_common, "make_camera_output_grid", lambda images: torch.zeros((4, 1, 1))) + wrapper = _make_capture_wrapper(tmp_path, env=env, capture_num_envs=2) + + wrapper.reset() + + assert len(captured_tensors) == 1 + assert captured_tensors[0].shape == (2, 2, 2, 4) + + +def test_capture_env_sensors_skips_none_outputs(tmp_path: Path) -> None: + """Missing sensor outputs are skipped instead of being written.""" + env = _FakeEnv({"camera": _make_sensor({"rgb": None})}) + wrapper = _make_capture_wrapper(tmp_path, env=env) + + wrapper.reset() + + assert not any(tmp_path.rglob("*.png")) + + +def test_capture_env_sensors_rejects_unknown_output_format(tmp_path: Path) -> None: + """Only tensorboard and file output formats are supported.""" + with pytest.raises(ValueError, match="Unsupported sensor capture output format"): + _make_capture_wrapper(tmp_path, output_format="invalid") + + +def test_wrap_sensor_capture_uses_training_sensor_frame_directory(tmp_path: Path) -> None: + """The train helper wraps the env with the configured sensor capture output directory.""" + env = _FakeEnv() + args_cli = argparse.Namespace( + capture_env_sensors=2, + capture_env_sensors_length=5, + capture_env_sensors_interval=7, + capture_env_sensors_format="file", + ) + + wrapped_env = wrap_sensor_capture(env, str(tmp_path), args_cli) + + assert isinstance(wrapped_env, CaptureEnvSensors) + assert Path(wrapped_env.output_dir) == tmp_path / "sensor_frames" / "train" + assert wrapped_env.frame_count == 5 + assert wrapped_env.capture_num_envs == 2 + assert wrapped_env.interval == 7 + assert wrapped_env.env is env + + +def test_wrap_sensor_capture_returns_env_when_disabled(tmp_path: Path) -> None: + """The train helper leaves the env unwrapped when sensor capture is disabled.""" + env = _FakeEnv() + args_cli = argparse.Namespace(capture_env_sensors=0) + + assert wrap_sensor_capture(env, str(tmp_path), args_cli) is env + + +def test_common_train_args_include_sensor_capture_options() -> None: + """Common train parsers expose sensor capture CLI arguments.""" + parser = argparse.ArgumentParser() + add_common_train_args(parser, agent_default=None, agent_help="", include_agent=False) + + args_cli = parser.parse_args( + [ + "--capture_env_sensors", + "3", + "--capture_env_sensors_length", + "4", + "--capture_env_sensors_interval", + "5", + "--capture_env_sensors_format", + "file", + ] + ) + + assert args_cli.capture_env_sensors == 3 + assert args_cli.capture_env_sensors_length == 4 + assert args_cli.capture_env_sensors_interval == 5 + assert args_cli.capture_env_sensors_format == "file" + + +def test_enable_cameras_for_video_enables_cameras_for_sensor_capture() -> None: + """Sensor capture requires camera rendering even when normal video capture is disabled.""" + args_cli = argparse.Namespace(video=False, capture_env_sensors=1, enable_cameras=False) + + enable_cameras_for_video(args_cli) + + assert args_cli.enable_cameras diff --git a/source/isaaclab/test/utils/test_images.py b/source/isaaclab/test/utils/test_images.py index 92bb3e7de9df..881961b5d019 100644 --- a/source/isaaclab/test/utils/test_images.py +++ b/source/isaaclab/test/utils/test_images.py @@ -180,3 +180,42 @@ 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) + + +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_tasks/changelog.d/mt-visual-sensor-logs.skip b/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip new file mode 100644 index 000000000000..b408a2fb78f6 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip @@ -0,0 +1 @@ +Tests only: refactored rendering test utils to use shared camera image helpers. diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index a4424f645b4a..e5bce7ecebe7 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -14,6 +14,7 @@ import torch from PIL import Image, ImageChops +from isaaclab.utils.images import make_camera_output_grid, normalize_camera_output_for_display from isaaclab.utils.warp import ProxyArray # Directory containing golden images. @@ -246,24 +247,6 @@ def _physics_preset_name(physics_backend: str) -> str: return "newton_mjwarp" if physics_backend == "newton" else physics_backend -def _normalize_tensor(tensor: torch.Tensor, data_type: str) -> torch.Tensor: - """Convert camera output tensor to [0, 1] float32 for conversion to image.""" - normalized = tensor.float() - - if data_type in ["depth", "distance_to_camera", "distance_to_image_plane"]: - max_val = normalized.max() - if max_val > 0: - normalized = normalized / max_val - elif data_type in {"albedo"}: - normalized = normalized[..., :3] / 255.0 - elif data_type in {"normals"}: - normalized = (normalized + 1.0) * 0.5 - else: - normalized = normalized / 255.0 - - return normalized - - def _save_comparison_image(img: Image.Image, filename: str) -> str: """Save a PIL image under the comparison images directory.""" path = os.path.join(_COMPARISON_IMAGES_DIR, _COMPARISON_IMAGE_SUBDIR, filename) @@ -473,13 +456,6 @@ def _require_ovlibs_install(request, monkeypatch: pytest.MonkeyPatch): return _require_ovlibs_install -def _make_grid(images: torch.Tensor) -> torch.Tensor: - """Make a grid of images from a tensor of shape (B, H, W, C).""" - from torchvision.utils import make_grid - - return make_grid(torch.swapaxes(images.unsqueeze(1), 1, -1).squeeze(-1), nrow=round(images.shape[0] ** 0.5)) - - def _ssim(img1: torch.Tensor, img2: torch.Tensor, window_size: int = 11) -> float: """Compute mean SSIM between two (1, C, H, W) float tensors in [0, 1].""" c1 = 0.01**2 @@ -585,8 +561,8 @@ def validate_camera_outputs( failed_data_types[data_type] = f"Camera output '{data_type}' has no non-zero pixels." continue - normalized = _normalize_tensor(corrected, data_type) - grid = _make_grid(normalized) + normalized = normalize_camera_output_for_display(corrected, data_type) + grid = make_camera_output_grid(normalized) ndarr = grid.mul(255).add_(0.5).clamp_(0, 255).permute(1, 2, 0).to("cpu", torch.uint8).numpy() result_image = Image.fromarray(ndarr) From 1a9971ed58ba49de4b9cdc6da8c921799e1d5922 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 3 Jul 2026 21:19:08 -0400 Subject: [PATCH 06/23] [IsaacLab CI] Smoke test marker (#6347) # Description Adds the smoke test marker for annotating smoke tests which should always be run in the pipeline - Register a shared `smoke` pytest marker in the install-CI `pytest.ini` and `conftest.py`. - Tag existing installation, contrib-environment, and rigid/deformable coupling smoke tests with `@pytest.mark.smoke`. - Lets core installation, task, and RL smoke functionality checks be selected as a group via `-m smoke`. Tests named with `_smoke` have been marked: Other tests can be marked respectively if they should be classified as smoke. Fixes # (issue) ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- pyproject.toml | 1 + source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip | 3 +++ .../test/install_ci/cli/test_cli_install_in_globalenv_smoke.py | 1 + .../test/install_ci/cli/test_cli_install_in_uvenv_smoke.py | 1 + source/isaaclab/test/install_ci/conftest.py | 1 + .../isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py | 1 + source/isaaclab/test/install_ci/pytest.ini | 1 + ...est_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py | 1 + .../changelog.d/mataylor-smoke-test-marker.skip | 2 ++ .../test/deformable/test_rigid_deformable_coupling.py | 1 + .../isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip | 2 ++ .../test/contrib/test_contrib_environments_smoke.py | 1 + 12 files changed, 16 insertions(+) create mode 100644 source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip create mode 100644 source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip create mode 100644 source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip diff --git a/pyproject.toml b/pyproject.toml index f40737cf55ad..caf6b793a9f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,6 +199,7 @@ markers = [ "device_split: re-invoke this file once per device (CPU and GPU) in CI due to process-global device locks (e.g., ovphysx<=0.3.7 gap G5)", "windows_ci: mark test to run on Windows platforms in CI", "arm_ci: mark test to run on ARM platforms in CI (e.g. NVIDIA DGX Spark)", + "smoke: tests for core installation, task, and RL functionality", ] # Add pypi.nvidia.com so that `uv pip install isaaclab[isaacsim]` works without --extra-index-url. diff --git a/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip new file mode 100644 index 000000000000..8cc2b98fdf92 --- /dev/null +++ b/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip @@ -0,0 +1,3 @@ +Test-only: add a ``smoke`` pytest marker and register it, then tag the +install-CI smoke tests with it so quick installation smoke checks can be +selected as a group. diff --git a/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py b/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py index 1d39de3137ac..10d0ff5ff345 100644 --- a/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py +++ b/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py @@ -19,6 +19,7 @@ from utils import run_cmd +@pytest.mark.smoke class Test_Cli_Install_In_Globalenv_Smoke: """./isaaclab.sh -i with no uv/conda env active (system Python).""" diff --git a/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py b/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py index 322c4b29352a..7de51d7afc73 100644 --- a/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py +++ b/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py @@ -33,6 +33,7 @@ def _skip_if_isaacsim_unavailable() -> None: pytest.skip("isaacsim is not importable and _isaac_sim link not found, skipping") +@pytest.mark.smoke class Test_Cli_Install_In_Uvenv_Smoke(UV_Mixin): """./isaaclab.sh -u/-i smoke checks plus optional submodule (mimic) and feature (newton) installs.""" diff --git a/source/isaaclab/test/install_ci/conftest.py b/source/isaaclab/test/install_ci/conftest.py index 747330dfd849..d1dc50ab486d 100644 --- a/source/isaaclab/test/install_ci/conftest.py +++ b/source/isaaclab/test/install_ci/conftest.py @@ -100,6 +100,7 @@ def pytest_addoption(parser: pytest.Parser) -> None: def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "smoke: tests for core installation, task, and RL functionality") config.addinivalue_line("markers", "bug: bug-regression tests (use bug id as argument)") config.addinivalue_line("markers", "gpu: tests that require a GPU") config.addinivalue_line("markers", "docker: tests that only run inside Docker") diff --git a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py index e111b0fec3ce..fe79a3842284 100644 --- a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py +++ b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py @@ -33,6 +33,7 @@ from utils import UV_Mixin, run_cmd +@pytest.mark.smoke class Test_Wheel_Builder_Smoke(UV_Mixin): """Test building the isaaclab wheel and installing it in a uv environment.""" diff --git a/source/isaaclab/test/install_ci/pytest.ini b/source/isaaclab/test/install_ci/pytest.ini index ab7e9d50137a..93b6cec4183a 100644 --- a/source/isaaclab/test/install_ci/pytest.ini +++ b/source/isaaclab/test/install_ci/pytest.ini @@ -6,6 +6,7 @@ python_files = *_test.py *_tests.py markers = + smoke: tests for core installation, task, and RL functionality bug: regression tests (bug id as argument) gpu: tests that require a GPU docker: tests that ONLY run inside Docker diff --git a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py index eab7d5ba9372..7f1355840f3b 100644 --- a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py +++ b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py @@ -58,6 +58,7 @@ def setup_class(cls): pytest.skip("uv is not available") @pytest.mark.docker + @pytest.mark.smoke @pytest.mark.uv @pytest.mark.slow @pytest.mark.gpu diff --git a/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip new file mode 100644 index 000000000000..797f40bfd461 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip @@ -0,0 +1,2 @@ +Test-only: tag the rigid/deformable coupling smoke test with the shared +``smoke`` pytest marker. diff --git a/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py index d835f8fb4582..d9c6cbc67dff 100644 --- a/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py +++ b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py @@ -219,6 +219,7 @@ def generate_lateral_rigid_and_deformable_cubes( return rigid_cube, deformable_cube +@pytest.mark.smoke @pytest.mark.parametrize( "sim", [("featherstone", "kinematic")], diff --git a/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip new file mode 100644 index 000000000000..8487220a16ca --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip @@ -0,0 +1,2 @@ +Test-only: tag the contrib environments smoke test with the shared ``smoke`` +pytest marker. diff --git a/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py b/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py index ae0c8382f5ad..ea1ae57d9fc3 100644 --- a/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py +++ b/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py @@ -37,6 +37,7 @@ from env_test_utils import _run_environments, setup_environment # isort: skip +@pytest.mark.smoke @pytest.mark.parametrize("num_envs, device", [(2, "cuda")]) @pytest.mark.parametrize( "task_name", From 142541711356ad494e53884c21b40dc57328fbea Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Fri, 3 Jul 2026 21:57:11 -0700 Subject: [PATCH 07/23] Adding Rigid Body Material USD data classes and writers (#6287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Converts **rigid-body physics materials** to the single-namespace "fragment" model already used by the rigid-body / collision / mass / mesh / tendon / joint-drive / articulation families. Additive and backward-compatible — the legacy inheritance cfgs remain as deprecated shims. The one material-specific twist vs. the schema families: a physics material is **spawned as its own `UsdShade.Material` prim and bound** (not applied onto the body prim). So the family writer both spawns the prim + applies the anchor *and* dispatches the fragment list. ### Added - **Core** (`isaaclab.sim.spawners.materials`): - `RigidBodyMaterialFragment` — marker base typing the `physics_material` slot. - `UsdPhysicsRigidBodyMaterialCfg` — solver-common `physics:*` friction/restitution (anchor `UsdPhysics.MaterialAPI`). - `spawn_rigid_body_material_from_fragments(prim_path, fragments, stage)` — spawns the `UsdShade.Material` prim, applies the `MaterialAPI` anchor, dispatches each fragment's `func` (default `apply_namespaced`). - `spawn_physics_material(prim_path, material, stage)` — slot dispatcher: fragment list → fragment writer, else legacy cfg via its own `func`. - **PhysX** (`isaaclab_physx.sim.spawners.materials`): - `PhysxMaterialCfg` — single-namespace `physxMaterial:*` (`PhysxMaterialAPI`): compliant-contact spring + combine-mode tokens. ### Changed - Spawner `physics_material` slots (`shapes`, `meshes`, `from_files`) now accept `RigidBodyMaterialFragment | list[...]` in addition to the legacy material cfg; consume sites route through `spawn_physics_material`. **Legacy single-cfg path unchanged.** ### Scope - **Rigid-body materials only.** Deformable-body materials are deferred — they pair with the deferred deformable-body family (multi-inherited OmniPhysics + PhysX material APIs). ## Tests - New `test_material_fragments.py` (6): fragment metadata; spawn-from-fragments composes `physics:*` + `physxMaterial:*` with the `MaterialAPI` anchor + `PhysxMaterialAPI`; single-fragment; partial-update (None left unauthored); slot dispatcher handles both fragment and legacy forms. - Regression: existing `test_spawn_materials.py` (6) and `test_spawn_shapes.py` (12) pass — legacy path intact. ## Checklist - [x] Ran `./isaaclab.sh --format` - [x] Added changelog fragments (core + physx, minor) --------- Co-authored-by: Octi Zhang --- docs/source/api/lab/isaaclab.sim.spawners.rst | 22 + .../isaaclab_newton.sim.spawners.rst | 9 + .../lab_physx/isaaclab_physx.sim.spawners.rst | 15 + .../vidurv-schema-frag-materials.minor.rst | 45 ++ .../sim/spawners/from_files/from_files.py | 7 +- .../sim/spawners/from_files/from_files_cfg.py | 25 +- .../sim/spawners/materials/__init__.pyi | 13 +- .../spawners/materials/physics_materials.py | 136 ++++- .../materials/physics_materials_cfg.py | 62 ++- .../isaaclab/sim/spawners/meshes/meshes.py | 22 +- .../sim/spawners/meshes/meshes_cfg.py | 11 +- .../isaaclab/sim/spawners/shapes/shapes.py | 5 +- .../sim/spawners/shapes/shapes_cfg.py | 11 +- .../isaaclab/terrains/terrain_importer_cfg.py | 10 +- source/isaaclab/isaaclab/terrains/utils.py | 11 +- .../test/sim/test_material_fragments.py | 523 ++++++++++++++++++ .../vidurv-schema-frag-materials.minor.rst | 21 + .../isaaclab_newton/sim/__init__.pyi | 2 + .../sim/schemas/schemas_cfg.py | 29 + .../sim/spawners/materials/__init__.pyi | 2 + .../materials/physics_materials_cfg.py | 71 +++ .../test/sim/test_newton_schemas.py | 79 +++ .../vidurv-schema-frag-materials.minor.rst | 12 + .../sim/spawners/materials/__init__.pyi | 2 + .../materials/physics_materials_cfg.py | 88 ++- 25 files changed, 1200 insertions(+), 33 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst create mode 100644 source/isaaclab/test/sim/test_material_fragments.py create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst diff --git a/docs/source/api/lab/isaaclab.sim.spawners.rst b/docs/source/api/lab/isaaclab.sim.spawners.rst index d42fa13e524e..ce82c003e8e5 100644 --- a/docs/source/api/lab/isaaclab.sim.spawners.rst +++ b/docs/source/api/lab/isaaclab.sim.spawners.rst @@ -267,6 +267,9 @@ Materials MdlFileCfg GlassMdlCfg PhysicsMaterialCfg + RigidBodyMaterialBaseCfg + RigidBodyMaterialFragment + UsdPhysicsRigidBodyMaterialCfg RigidBodyMaterialCfg DeformableBodyMaterialBaseCfg SurfaceDeformableBodyMaterialBaseCfg @@ -303,8 +306,27 @@ Physical Materials :members: :exclude-members: __init__, func +.. autofunction:: spawn_physics_material + .. autofunction:: spawn_rigid_body_material +.. autoclass:: RigidBodyMaterialBaseCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autofunction:: spawn_rigid_body_material_from_fragments + +.. autoclass:: RigidBodyMaterialFragment + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: UsdPhysicsRigidBodyMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. autoclass:: RigidBodyMaterialCfg :members: :exclude-members: __init__, func diff --git a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst index f42cef3d8167..2db0a1622814 100644 --- a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst +++ b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst @@ -7,10 +7,19 @@ isaaclab_newton.sim.spawners .. autosummary:: + NewtonMaterialCfg NewtonDeformableBodyMaterialCfg NewtonDeformableMaterialCfg NewtonSurfaceDeformableBodyMaterialCfg +Rigid Materials +--------------- + +.. autoclass:: NewtonMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + Deformable Materials -------------------- diff --git a/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst b/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst index 7a2ace2a33ff..3e2355d141e2 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst @@ -7,12 +7,27 @@ isaaclab_physx.sim.spawners .. autosummary:: + PhysxRigidBodyMaterialCfg + PhysxMaterialCfg PhysxDeformableBodyMaterialCfg PhysxSurfaceDeformableBodyMaterialCfg PhysXDeformableMaterialCfg DeformableBodyMaterialCfg SurfaceDeformableBodyMaterialCfg +Rigid Materials +--------------- + +.. autoclass:: PhysxRigidBodyMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: PhysxMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + Deformable Materials -------------------- diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst new file mode 100644 index 000000000000..1cfe675cb0b3 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst @@ -0,0 +1,45 @@ +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. diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index d9f789b666f3..80b9f940e417 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -16,6 +16,7 @@ from isaaclab.sim import converters, schemas from isaaclab.sim.spawners.materials import SurfaceDeformableBodyMaterialBaseCfg +from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material from isaaclab.sim.utils import ( add_labels, bind_physics_material, @@ -211,7 +212,7 @@ def spawn_ground_plane( # Create physics material if cfg.physics_material is not None: - cfg.physics_material.func(f"{prim_path}/physicsMaterial", cfg.physics_material) + spawn_physics_material(f"{prim_path}/physicsMaterial", cfg.physics_material, stage=stage) # Apply physics material to ground plane collision_prim = get_first_matching_child_prim( prim_path, @@ -478,8 +479,8 @@ def _spawn_from_usd_file( material_path = f"{prim_path}/{cfg.physics_material_path}" else: material_path = cfg.physics_material_path - # create material - cfg.physics_material.func(material_path, cfg.physics_material) + # create material (accepts a legacy material cfg or rigid-body fragment(s)) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) # apply material bind_physics_material(prim_path, material_path, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py index ea2cfe9b6c9f..1c9c26e02525 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py @@ -106,9 +106,18 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): This parameter is ignored if `physics_material` is not None. """ - physics_material: materials.PhysicsMaterialCfg | None = None + physics_material: ( + materials.PhysicsMaterialCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + | None + ) = None """Physics material properties. + Accepts either a legacy material cfg, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such + single-namespace fragments. + Note: If None, then no custom physics material will be added. """ @@ -259,5 +268,15 @@ class GroundPlaneCfg(SpawnerCfg): size: tuple[float, float] = (100.0, 100.0) """The size of the ground plane. Defaults to 100 m x 100 m.""" - physics_material: materials.RigidBodyMaterialCfg = materials.RigidBodyMaterialCfg() - """Physics material properties. Defaults to the default rigid body material.""" + physics_material: ( + materials.RigidBodyMaterialBaseCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + ) = materials.RigidBodyMaterialCfg() + """Physics material properties. Defaults to the default rigid body material. + + The ground plane only spawns a collision plane, so this only accepts rigid-body materials: a + legacy :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such + single-namespace fragments. + """ diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi index 7ce7815955c2..305c980d2312 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi @@ -5,9 +5,13 @@ __all__ = [ "spawn_rigid_body_material", + "spawn_rigid_body_material_from_fragments", + "spawn_physics_material", "spawn_deformable_body_material", "PhysicsMaterialCfg", "RigidBodyMaterialBaseCfg", + "RigidBodyMaterialFragment", + "UsdPhysicsRigidBodyMaterialCfg", "DeformableBodyMaterialBaseCfg", "DeformableBodyMaterialCfg", "SurfaceDeformableBodyMaterialBaseCfg", @@ -20,10 +24,17 @@ __all__ = [ "VisualMaterialCfg", ] -from .physics_materials import spawn_rigid_body_material, spawn_deformable_body_material +from .physics_materials import ( + spawn_deformable_body_material, + spawn_physics_material, + spawn_rigid_body_material, + spawn_rigid_body_material_from_fragments, +) from .physics_materials_cfg import ( PhysicsMaterialCfg, RigidBodyMaterialBaseCfg, + RigidBodyMaterialFragment, + UsdPhysicsRigidBodyMaterialCfg, DeformableBodyMaterialBaseCfg, DeformableBodyMaterialCfg, SurfaceDeformableBodyMaterialBaseCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py index 8c12bee9442d..51f19226bb1e 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py @@ -12,10 +12,138 @@ from isaaclab.sim.schemas.schemas import _apply_namespaced_schemas from isaaclab.sim.utils import clone from isaaclab.sim.utils.stage import get_current_stage +from isaaclab.utils.string import string_to_callable from . import physics_materials_cfg +def spawn_rigid_body_material_from_fragments( + prim_path: str, + fragments: physics_materials_cfg.RigidBodyMaterialFragment + | list[physics_materials_cfg.RigidBodyMaterialFragment] + | tuple[physics_materials_cfg.RigidBodyMaterialFragment, ...], + stage: Usd.Stage | None = None, +) -> Usd.Prim: + """Spawn a rigid-body physics material from one or more single-namespace fragments. + + Creates (or reuses) the ``UsdShade.Material`` prim at ``prim_path``, applies the standard + ``UsdPhysics.MaterialAPI`` anchor, then dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func` to author its namespace onto the material prim. + Backend fragments carry backend-specific namespaces (e.g. PhysX ``physxMaterial:*``) without core + importing a backend. + + .. note:: + Unlike the ``@clone``-decorated :func:`spawn_rigid_body_material`, this writer expects a + concrete ``prim_path`` and does not resolve regex prim-path patterns. Physics materials are + spawned at a single derived path by :func:`spawn_physics_material` and the spawner internals, + so regex resolution does not apply here. + + Args: + prim_path: The prim path to spawn the material at. + fragments: A single :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, + or a list/tuple of them. + stage: The stage to spawn on. Defaults to None, in which case the current stage is used. + + Returns: + The spawned rigid body material prim. + + Raises: + ValueError: When ``fragments`` is empty. + ValueError: When a prim already exists at the path and is not a material. + TypeError: When an item is not a rigid-body material fragment. + """ + if not isinstance(fragments, (list, tuple)): + fragments = [fragments] + else: + fragments = list(fragments) + if not fragments: + raise ValueError(f"Cannot spawn a physics material at '{prim_path}' from an empty fragment collection.") + invalid_types = [ + type(fragment).__name__ + for fragment in fragments + if not isinstance(fragment, physics_materials_cfg.RigidBodyMaterialFragment) + ] + if invalid_types: + raise TypeError( + "A physics-material fragment collection must contain only RigidBodyMaterialFragment instances; got" + f" {invalid_types}." + ) + if stage is None: + stage = get_current_stage() + + # create the material prim if none exists yet + if not stage.GetPrimAtPath(prim_path).IsValid(): + UsdShade.Material.Define(stage, prim_path) + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsA(UsdShade.Material): + raise ValueError(f"A prim already exists at path: '{prim_path}' but is not a material.") + + # apply the standard UsdPhysics MaterialAPI anchor (the defining schema for a physics material) + if not UsdPhysics.MaterialAPI(prim): + UsdPhysics.MaterialAPI.Apply(prim) + + # dispatch each fragment's applier (writes its single namespace onto the material prim) + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, prim_path, stage) + return prim + + +def spawn_physics_material( + prim_path: str, + material: physics_materials_cfg.PhysicsMaterialCfg + | physics_materials_cfg.RigidBodyMaterialFragment + | list[physics_materials_cfg.RigidBodyMaterialFragment] + | tuple[physics_materials_cfg.RigidBodyMaterialFragment, ...], + stage: Usd.Stage | None = None, +) -> Usd.Prim: + """Spawn a physics material from a spawner ``physics_material`` slot value. + + Dispatches the two accepted slot forms: a single rigid-body fragment or list/tuple is spawned via + :func:`spawn_rigid_body_material_from_fragments`; otherwise the value is a legacy material cfg and is + spawned via its own :attr:`func`. Lets spawners accept both the fragment and the legacy interface + from one call site. + + Args: + prim_path: The prim path to spawn the material at. + material: A :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` (or list/tuple + of them), or a legacy material cfg carrying a :attr:`func`. + stage: The stage to spawn on. Defaults to None, in which case the current stage is used. A + legacy material cfg only supports the current stage (or None); passing a different + stage raises. + Returns: + The spawned material prim. + + Raises: + ValueError: When ``material`` is an empty fragment collection. + ValueError: When ``material`` is a legacy material cfg and ``stage`` is neither ``None`` + nor the current stage. + TypeError: When ``material`` is a fragment collection containing anything other than + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` instances. + TypeError: When ``material`` is neither a physics-material cfg nor a rigid-body fragment. + """ + # The family writer owns fragment validation so direct and dispatched calls have one contract. + if isinstance(material, (list, tuple, physics_materials_cfg.RigidBodyMaterialFragment)): + return spawn_rigid_body_material_from_fragments(prim_path, material, stage) + if not isinstance(material, physics_materials_cfg.PhysicsMaterialCfg): + raise TypeError( + "A physics material must be a PhysicsMaterialCfg or rigid-body material fragment; got" + f" '{type(material).__name__}'." + ) + # legacy single-cfg path (rigid or deformable material cfg with its own spawner ``func``). + # Legacy material funcs take only ``(prim_path, cfg)`` and resolve the stage internally via + # ``get_current_stage()`` (their path matching is also current-stage-bound), so an explicit + # different stage cannot be honored on this path -- reject it loudly rather than authoring on + # the wrong stage. The fragment path above supports explicit stages. + if stage is not None and stage != get_current_stage(): + raise ValueError( + f"Legacy material cfg '{type(material).__name__}' can only be spawned on the current" + " stage. Pass the current stage (or None), or use the fragment-based API for" + " explicit-stage authoring." + ) + return material.func(prim_path, material) + + @clone def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBodyMaterialBaseCfg) -> Usd.Prim: """Create material with rigid-body physics properties. @@ -26,10 +154,10 @@ def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBo PxMaterial `_. The writer is metadata-driven: it always applies the standard ``UsdPhysics.MaterialAPI`` and - writes the friction/restitution fields, then reads ``_usd_applied_schema``, ``_usd_namespace``, - and ``_usd_attr_name_map`` from the cfg to author solver-specific attributes. The applied - schema (e.g. ``PhysxMaterialAPI``) is added only when at least one solver-specific field has a - non-``None`` value at the instance level. + writes the friction/restitution/density fields, then reads ``_usd_applied_schema``, + ``_usd_namespace``, and ``_usd_attr_name_map`` from the cfg to author solver-specific attributes. + The applied schema (e.g. ``PhysxMaterialAPI``) is added only when at least one solver-specific + field has a non-``None`` value at the instance level. .. note:: This function is decorated with :func:`clone` that resolves prim path into list of paths diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py index 5c88731cf8d5..6dbe000e773f 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py @@ -9,6 +9,7 @@ from dataclasses import MISSING from typing import ClassVar +from isaaclab.sim.schemas.schemas_cfg import SchemaFragment from isaaclab.utils.configclass import configclass # Names that moved out of this submodule into ``isaaclab_physx.sim.spawners.materials.physics_materials_cfg``. @@ -60,9 +61,10 @@ class PhysicsMaterialCfg: class RigidBodyMaterialBaseCfg(PhysicsMaterialCfg): """Solver-common physics-material parameters for rigid bodies. - Contains the friction and restitution fields from the `UsdPhysics.MaterialAPI`_ that are common - across all simulation backends. For PhysX-only material properties (compliant-contact spring, - combine modes), use :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. + Contains the friction, restitution, and density fields from the `UsdPhysics.MaterialAPI`_ that + are common across all simulation backends. For properties in the ``physxMaterial`` namespace + (compliant-contact spring and combine modes), use + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. See :meth:`spawn_rigid_body_material` for more information. @@ -88,6 +90,60 @@ class RigidBodyMaterialBaseCfg(PhysicsMaterialCfg): restitution: float = 0.0 """The restitution coefficient. Defaults to 0.0.""" + density: float | None = None + """The material density [kg/m^3]. Defaults to None, in which case it is not authored. + + Writes ``physics:density``. It is a fallback for collision shapes bound to this material; + explicitly authored rigid-body mass or density takes precedence. + """ + + +@configclass +class RigidBodyMaterialFragment(SchemaFragment): + """Marker base for rigid-body physics-material fragments; types the ``physics_material`` slot. + + A rigid-body physics material is a single ``UsdShade.Material`` prim that carries one or more + physics-material schemas. The fragments author single namespaces onto that prim: the + solver-common ``physics:*`` friction/restitution/density + (:class:`UsdPhysicsRigidBodyMaterialCfg`) and any backend-specific namespace (e.g. PhysX + ``physxMaterial:*`` via :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`). + The defining ``UsdPhysics.MaterialAPI`` anchor is applied by the family writer + (:func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments`). + """ + + pass + + +@configclass +class UsdPhysicsRigidBodyMaterialCfg(RigidBodyMaterialFragment): + """``physics:*`` rigid-body material attributes from `UsdPhysics.MaterialAPI`_. + + The ``UsdPhysics.MaterialAPI`` schema is applied as the implicit anchor by the rigid-body material + family writer, so this fragment owns no applied schema of its own. ``None`` fields are left + unchanged on the material prim. + + .. _UsdPhysics.MaterialAPI: https://openusd.org/dev/api/class_usd_physics_material_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # MaterialAPI applied by the family writer + + static_friction: float | None = None + """The static friction coefficient. Writes ``physics:staticFriction``.""" + + dynamic_friction: float | None = None + """The dynamic friction coefficient. Writes ``physics:dynamicFriction``.""" + + restitution: float | None = None + """The restitution coefficient. Writes ``physics:restitution``.""" + + density: float | None = None + """The material density [kg/m^3]. Writes ``physics:density``. + + Participates in mass computation via material binding when no explicit rigid-body mass or + density takes precedence. + """ + @configclass class DeformableBodyMaterialBaseCfg(PhysicsMaterialCfg): diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index bf0df38db129..f46d06c68560 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -16,7 +16,13 @@ from isaaclab.sim import schemas from isaaclab.sim.utils import bind_physics_material, bind_visual_material, clone, create_prim, get_current_stage -from ..materials import DeformableBodyMaterialBaseCfg, RigidBodyMaterialCfg, SurfaceDeformableBodyMaterialBaseCfg +from ..materials import ( + DeformableBodyMaterialBaseCfg, + RigidBodyMaterialBaseCfg, + RigidBodyMaterialFragment, + SurfaceDeformableBodyMaterialBaseCfg, +) +from ..materials.physics_materials import spawn_physics_material if TYPE_CHECKING: from . import meshes_cfg @@ -367,7 +373,15 @@ def _spawn_mesh_geom_from_mesh( if not isinstance(cfg.physics_material, DeformableBodyMaterialBaseCfg): raise ValueError("Deformable properties require a deformable physics material.") if cfg.rigid_props is not None and cfg.physics_material is not None: - if not isinstance(cfg.physics_material, RigidBodyMaterialCfg): + # accept anything spawn_physics_material accepts for the rigid case: a legacy rigid-body + # material cfg, a single fragment, or a list/tuple of fragments + physics_material_frags = ( + cfg.physics_material if isinstance(cfg.physics_material, (list, tuple)) else [cfg.physics_material] + ) + is_rigid_material = isinstance(cfg.physics_material, RigidBodyMaterialBaseCfg) or all( + isinstance(frag, RigidBodyMaterialFragment) for frag in physics_material_frags + ) + if not is_rigid_material: raise ValueError("Rigid properties require a rigid physics material.") # create all the paths we need for clarity @@ -439,8 +453,8 @@ def _spawn_mesh_geom_from_mesh( material_path = f"{geom_prim_path}/{cfg.physics_material_path}" else: material_path = cfg.physics_material_path - # create material - cfg.physics_material.func(material_path, cfg.physics_material) + # create material (accepts a legacy material cfg or rigid-body fragment(s)) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) # apply material bind_physics_material(prim_path, material_path, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py index c6eb26507d3a..aafd4d3cd7e0 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py @@ -57,9 +57,18 @@ class MeshCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): This parameter is ignored if `physics_material` is not None. """ - physics_material: materials.PhysicsMaterialCfg | None = None + physics_material: ( + materials.PhysicsMaterialCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + | None + ) = None """Physics material properties. + Accepts either a legacy material cfg, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such + single-namespace fragments. + Note: If None, then no physics material will be added. """ diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 2d1b721e9567..2b8177423a88 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -10,6 +10,7 @@ from pxr import Usd from isaaclab.sim import schemas +from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material from isaaclab.sim.utils import bind_physics_material, bind_visual_material, clone, create_prim, get_current_stage if TYPE_CHECKING: @@ -316,8 +317,8 @@ def _spawn_geom_from_prim_type( material_path = f"{geom_prim_path}/{cfg.physics_material_path}" else: material_path = cfg.physics_material_path - # create material - cfg.physics_material.func(material_path, cfg.physics_material) + # create material (accepts a legacy material cfg or rigid-body fragment(s)) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) # apply material bind_physics_material(mesh_prim_path, material_path, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py index b111bdbb2bf3..04d0bc42a764 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py @@ -37,9 +37,18 @@ class ShapeCfg(RigidObjectSpawnerCfg): If the path is relative, then it will be relative to the prim's path. This parameter is ignored if `physics_material` is not None. """ - physics_material: materials.PhysicsMaterialCfg | None = None + physics_material: ( + materials.RigidBodyMaterialBaseCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + | None + ) = None """Physics material properties. + Since shapes are rigid-only spawners, this slot accepts the rigid material base class or + rigid-material fragments (single-namespace :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` + instances or lists thereof). + Note: If None, then no physics material will be added. """ diff --git a/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py b/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py index db34c9a17195..d457c1606c83 100644 --- a/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py +++ b/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Literal import isaaclab.sim as sim_utils +from isaaclab.sim.spawners import materials from isaaclab.utils.configclass import configclass if TYPE_CHECKING: @@ -87,13 +88,20 @@ class TerrainImporterCfg: to the grid color of the imported ground plane. """ - physics_material: sim_utils.RigidBodyMaterialCfg = sim_utils.RigidBodyMaterialCfg() + physics_material: ( + materials.RigidBodyMaterialBaseCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + ) = materials.RigidBodyMaterialCfg() """The physics material of the terrain. Defaults to a default physics material. The material is created at the path: ``{prim_path}/physicsMaterial``. .. note:: This parameter is used only when the ``terrain_type`` is "generator" or "plane". + + Accepts a legacy rigid material cfg, a single rigid-material fragment, or a list of + rigid-material fragments. """ max_init_terrain_level: int | None = None diff --git a/source/isaaclab/isaaclab/terrains/utils.py b/source/isaaclab/isaaclab/terrains/utils.py index 739f80940021..68498cfde402 100644 --- a/source/isaaclab/isaaclab/terrains/utils.py +++ b/source/isaaclab/isaaclab/terrains/utils.py @@ -14,6 +14,7 @@ from pxr import UsdGeom import isaaclab.sim as sim_utils +from isaaclab.sim.spawners.materials import spawn_physics_material from isaaclab.utils.warp import raycast_mesh @@ -80,7 +81,8 @@ def create_prim_from_mesh(prim_path: str, mesh: trimesh.Trimesh, **kwargs): translation: The translation of the terrain. Defaults to None. orientation: The orientation of the terrain. Defaults to None. visual_material: The visual material to apply. Defaults to None. - physics_material: The physics material to apply. Defaults to None. + physics_material: The physics material to apply. Defaults to None. Accepts a legacy rigid + material cfg, a single rigid-material fragment, or a list of fragments. """ # create parent prim sim_utils.create_prim(prim_path, "Xform") @@ -122,10 +124,9 @@ def create_prim_from_mesh(prim_path: str, mesh: trimesh.Trimesh, **kwargs): visual_material_cfg.func(f"{prim_path}/visualMaterial", visual_material_cfg) sim_utils.bind_visual_material(prim.GetPrimPath(), f"{prim_path}/visualMaterial") # create physics material - if kwargs.get("physics_material") is not None: - physics_material_cfg: sim_utils.RigidBodyMaterialCfg = kwargs.get("physics_material") - # spawn the material - physics_material_cfg.func(f"{prim_path}/physicsMaterial", physics_material_cfg) + physics_material = kwargs.get("physics_material") + if physics_material is not None: + spawn_physics_material(f"{prim_path}/physicsMaterial", physics_material) sim_utils.bind_physics_material(prim.GetPrimPath(), f"{prim_path}/physicsMaterial") diff --git a/source/isaaclab/test/sim/test_material_fragments.py b/source/isaaclab/test/sim/test_material_fragments.py new file mode 100644 index 000000000000..8caf9a99fd92 --- /dev/null +++ b/source/isaaclab/test/sim/test_material_fragments.py @@ -0,0 +1,523 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import pytest + +from pxr import UsdPhysics, UsdShade + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + +# ------------------------------------------------------------------------------------- +# RigidBodyMaterialFragment marker + metadata +# ------------------------------------------------------------------------------------- + + +def test_rigid_body_material_fragment_metadata_defaults(): + from isaaclab.sim.schemas import SchemaFragment + from isaaclab.sim.spawners.materials.physics_materials_cfg import ( + RigidBodyMaterialFragment, + UsdPhysicsRigidBodyMaterialCfg, + ) + + cfg = UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7) + assert isinstance(cfg, RigidBodyMaterialFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # MaterialAPI applied by the family writer + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.static_friction == 0.7 and cfg.dynamic_friction is None + + +def test_physx_material_fragment_metadata_defaults(): + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialFragment + + cfg = PhysxMaterialCfg(compliant_contact_stiffness=100.0) + assert isinstance(cfg, RigidBodyMaterialFragment) + assert type(cfg)._usd_namespace == "physxMaterial" + assert type(cfg)._usd_applied_schema == "PhysxMaterialAPI" + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + + +# ------------------------------------------------------------------------------------- +# spawn_rigid_body_material_from_fragments: spawn prim + anchor + multi-namespace compose +# ------------------------------------------------------------------------------------- + + +def test_spawn_rigid_body_material_from_fragments_composes_namespaces(): + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/Mat", + [ + UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7, dynamic_friction=0.6, restitution=0.1), + PhysxMaterialCfg(compliant_contact_stiffness=100.0, friction_combine_mode="max"), + ], + stage, + ) + assert prim.IsA(UsdShade.Material) + assert bool(UsdPhysics.MaterialAPI(prim)) # neutral anchor applied by the writer + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.7) + assert prim.GetAttribute("physics:dynamicFriction").Get() == pytest.approx(0.6) + assert prim.GetAttribute("physics:restitution").Get() == pytest.approx(0.1) + # the PhysX fragment applied its own schema and namespace + assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxMaterial:compliantContactStiffness").Get() == pytest.approx(100.0) + assert prim.GetAttribute("physxMaterial:frictionCombineMode").Get() == "max" + + +def test_spawn_rigid_body_material_from_fragments_accepts_single_fragment(): + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/Mat2", UsdPhysicsRigidBodyMaterialCfg(static_friction=0.3), stage + ) + assert prim.IsA(UsdShade.Material) + assert bool(UsdPhysics.MaterialAPI(prim)) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.3) + + +def test_spawn_physics_material_dispatches_fragments_and_legacy(): + """The shared dispatcher handles both a rigid-body fragment collection and a legacy material + cfg carrying its own ``func``.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + + # tuple form is accepted by the low-level dispatcher alongside the list form used by cfg slots + frag_prim = spawn_physics_material("/World/MaterialA", (UsdPhysicsRigidBodyMaterialCfg(static_friction=0.4),)) + assert bool(UsdPhysics.MaterialAPI(frag_prim)) + assert frag_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.4) + + # legacy single-cfg form (RigidBodyMaterialBaseCfg subclass with its own spawner func) + legacy_prim = spawn_physics_material("/World/MaterialB", PhysxRigidBodyMaterialCfg(static_friction=0.9)) + assert bool(UsdPhysics.MaterialAPI(legacy_prim)) + assert legacy_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.9) + + +def test_spawn_physics_material_rejects_non_current_stage_for_legacy(): + """The legacy path is current-stage-bound; an explicit different stage raises instead of + silently authoring on the current stage. Passing the current stage explicitly stays valid + (the in-tree spawners do so unconditionally).""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from pxr import Usd + + from isaaclab.sim.spawners.materials import spawn_physics_material + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + other = Usd.Stage.CreateInMemory() + with pytest.raises(ValueError, match="current stage"): + spawn_physics_material("/World/MatOther", PhysxRigidBodyMaterialCfg(), stage=other) + # nothing leaked onto the current stage + assert not sim_utils.get_current_stage().GetPrimAtPath("/World/MatOther").IsValid() + # explicit current stage remains supported + prim = spawn_physics_material("/World/MatCurrent", PhysxRigidBodyMaterialCfg(), stage=sim_utils.get_current_stage()) + assert prim.IsValid() + + +def test_fragment_writer_validates_inputs_before_authoring(): + """Direct and dispatched fragment calls share one validation contract and do not leave prims.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import ( + spawn_physics_material, + spawn_rigid_body_material_from_fragments, + ) + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + + with pytest.raises(ValueError): + spawn_rigid_body_material_from_fragments("/World/MatEmpty", [], stage) + # a list mixing a fragment with a legacy cfg is not a valid fragment list + with pytest.raises(TypeError): + spawn_physics_material( + "/World/MatMixed", + [UsdPhysicsRigidBodyMaterialCfg(static_friction=0.4), PhysxRigidBodyMaterialCfg(static_friction=0.9)], + ) + with pytest.raises(TypeError): + spawn_rigid_body_material_from_fragments("/World/MatLegacy", PhysxRigidBodyMaterialCfg(), stage) + with pytest.raises(TypeError): + spawn_physics_material("/World/MatInvalid", object()) + for path in ("MatEmpty", "MatMixed", "MatLegacy", "MatInvalid"): + assert not stage.GetPrimAtPath(f"/World/{path}").IsValid() + + +def test_spawn_rigid_body_material_from_fragments_leaves_none_fields_unwritten(): + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/Mat3", [UsdPhysicsRigidBodyMaterialCfg(static_friction=0.5)], stage + ) + # only the authored field is written; None fields are left unauthored (partial update) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.5) + assert not prim.GetAttribute("physics:dynamicFriction").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# UsdPhysicsRigidBodyMaterialCfg: density round-trip + physics:* schema parity +# ------------------------------------------------------------------------------------- + + +def test_usd_physics_rigid_body_material_density_round_trips(): + """``physics:density`` participates in mass computation via material binding; it must author + the same as the other ``UsdPhysics.MaterialAPI`` friction/restitution fields.""" + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/MatDensity", [UsdPhysicsRigidBodyMaterialCfg(density=1200.0)], stage + ) + assert prim.GetAttribute("physics:density").Get() == pytest.approx(1200.0) + + +def test_usd_physics_rigid_body_material_fragment_matches_material_api_schema(): + """Schema-parity guard: the set of ``physics:*`` attrs the neutral fragment can author must + equal the attribute set on ``UsdPhysics.MaterialAPI`` (4 attrs: static/dynamic friction, + restitution, density). Catches drift if the schema gains/loses an attribute.""" + import dataclasses + + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + from isaaclab.utils.string import to_camel_case + + fragment_fields = {f.name for f in dataclasses.fields(UsdPhysicsRigidBodyMaterialCfg) if f.name != "func"} + schema_attr_names = {name.split(":", 1)[1] for name in UsdPhysics.MaterialAPI.GetSchemaAttributeNames()} + fragment_attr_names = {to_camel_case(name, "cC") for name in fragment_fields} + assert fragment_attr_names == schema_attr_names + + +# ------------------------------------------------------------------------------------- +# PhysxMaterialCfg: damping-combine-mode + compliant-contact-acceleration-spring +# ------------------------------------------------------------------------------------- + + +def test_physx_material_fragment_authors_damping_combine_mode_and_acceleration_spring(): + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/MatPhysxExtra", + [PhysxMaterialCfg(damping_combine_mode="min", compliant_contact_acceleration_spring=True)], + stage, + ) + assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxMaterial:dampingCombineMode").Get() == "min" + assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True + + +# ------------------------------------------------------------------------------------- +# Finding 4: mesh spawner must accept a fragment list for a rigid physics_material +# ------------------------------------------------------------------------------------- + + +def test_spawn_mesh_with_rigid_props_accepts_fragment_list_physics_material(): + """Regression test: the rigid-vs-deformable material guard in the mesh spawner used to reject + a fragment / fragment-list ``physics_material`` outright. A rigid-body fragment list must spawn + and bind successfully.""" + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCuboidCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = MeshCuboidCfg( + size=(1.0, 1.0, 1.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=[UsdPhysicsRigidBodyMaterialCfg(static_friction=0.65, dynamic_friction=0.55)], + ) + prim = cfg.func("/World/MeshCubeFrag", cfg, stage=stage) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/MeshCubeFrag/geometry/material") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.65) + # material binding: the mesh prim carries a physics-purpose material binding + binding_api = UsdShade.MaterialBindingAPI(stage.GetPrimAtPath("/World/MeshCubeFrag/geometry/mesh")) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +# ------------------------------------------------------------------------------------- +# Finding 5: ground-plane spawner must accept a fragment-list physics_material +# ------------------------------------------------------------------------------------- + + +def test_spawn_ground_plane_accepts_fragment_list_physics_material(): + from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = GroundPlaneCfg(physics_material=[UsdPhysicsRigidBodyMaterialCfg(static_friction=0.42)]) + prim = cfg.func("/World/groundPlane", cfg) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/groundPlane/physicsMaterial") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.42) + # the collision (Plane) prim under the ground plane must bind to the spawned material + from isaaclab.sim.utils import get_first_matching_child_prim + + collision_prim = get_first_matching_child_prim( + "/World/groundPlane", predicate=lambda _prim: _prim.GetTypeName() == "Plane", stage=stage + ) + assert collision_prim is not None + binding_api = UsdShade.MaterialBindingAPI(collision_prim) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +# ------------------------------------------------------------------------------------- +# Regression: the mesh spawner's rigid-material guard must also accept legacy (non-fragment) +# rigid-body material cfgs, not just the deprecated ``RigidBodyMaterialCfg`` alias. +# ------------------------------------------------------------------------------------- + + +def test_spawn_mesh_with_rigid_props_accepts_legacy_physx_rigid_body_material(): + """Regression test: the mesh guard used to check ``isinstance(cfg.physics_material, + RigidBodyMaterialCfg)`` -- the deprecated PhysX leaf alias -- which rejected the canonical + legacy :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` even though + :func:`~isaaclab.sim.spawners.materials.spawn_physics_material` accepts it.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCuboidCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = MeshCuboidCfg( + size=(1.0, 1.0, 1.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=PhysxRigidBodyMaterialCfg(static_friction=0.65, dynamic_friction=0.55), + ) + prim = cfg.func("/World/MeshCubeLegacyPhysx", cfg, stage=stage) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/MeshCubeLegacyPhysx/geometry/material") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.65) + binding_api = UsdShade.MaterialBindingAPI(stage.GetPrimAtPath("/World/MeshCubeLegacyPhysx/geometry/mesh")) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +def test_spawn_mesh_with_rigid_props_accepts_legacy_newton_material(): + """Same regression as above for Newton's legacy rigid-body material cfg, which also derives + from :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` (not the deprecated + PhysX ``RigidBodyMaterialCfg`` alias) and must not be rejected by the mesh guard.""" + from isaaclab_newton.sim.schemas import NewtonMaterialPropertiesCfg + + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCuboidCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = MeshCuboidCfg( + size=(1.0, 1.0, 1.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=NewtonMaterialPropertiesCfg(torsional_friction=0.3, rolling_friction=0.001), + ) + prim = cfg.func("/World/MeshCubeLegacyNewton", cfg, stage=stage) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/MeshCubeLegacyNewton/geometry/material") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("newton:torsionalFriction").Get() == pytest.approx(0.3) + assert material_prim.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.001) + binding_api = UsdShade.MaterialBindingAPI(stage.GetPrimAtPath("/World/MeshCubeLegacyNewton/geometry/mesh")) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +# ------------------------------------------------------------------------------------- +# PhysxRigidBodyMaterialCfg (legacy): damping-combine-mode + compliant-contact-acceleration-spring +# ------------------------------------------------------------------------------------- + + +def test_legacy_physx_rigid_body_material_authors_damping_combine_mode_and_acceleration_spring(): + """The legacy :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` must + author the same two ``physxMaterial:*`` attributes as the + :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg` fragment, since the legacy + spawner is metadata-driven off the same ``physxMaterial`` namespace.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.materials import spawn_rigid_body_material + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = PhysxRigidBodyMaterialCfg(damping_combine_mode="min", compliant_contact_acceleration_spring=True) + prim = spawn_rigid_body_material("/World/MatLegacyPhysxExtra", cfg) + assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxMaterial:dampingCombineMode").Get() == "min" + assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True + + +# ------------------------------------------------------------------------------------- +# Generated-terrain routing: create_prim_from_mesh must accept a fragment-list physics_material +# ------------------------------------------------------------------------------------- + + +def test_create_prim_from_mesh_accepts_fragment_list(): + """Generated terrain routes its material through the dispatcher, so a fragment list works + end-to-end instead of crashing on the legacy-only spawn path.""" + import trimesh + from isaaclab_newton.sim.spawners.materials.physics_materials_cfg import NewtonMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + from isaaclab.terrains.utils import create_prim_from_mesh + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + mesh = trimesh.creation.box(extents=(1.0, 1.0, 0.2)) + create_prim_from_mesh( + "/World/terrainFrag", + mesh, + physics_material=[UsdPhysicsRigidBodyMaterialCfg(static_friction=0.9), NewtonMaterialCfg(rolling_friction=0.1)], + ) + stage = sim_utils.get_current_stage() + mat = stage.GetPrimAtPath("/World/terrainFrag/physicsMaterial") + assert mat.IsValid() + assert mat.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.9) + assert mat.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.1) + + +def test_legacy_base_cfg_authors_density(): + """The legacy rigid material base authors ``physics:density``, matching the USD fragment. + + ``UsdPhysics.MaterialAPI`` defines four properties; explicitly set values must be available + through both interfaces, including material density read by Newton's importer. + """ + from isaaclab.sim.spawners.materials import spawn_rigid_body_material + from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + prim = spawn_rigid_body_material("/World/LegacyDensity", RigidBodyMaterialBaseCfg(density=800.0)) + assert prim.GetAttribute("physics:density").Get() == pytest.approx(800.0) + # None default -> unauthored (backward compatible) + prim2 = spawn_rigid_body_material("/World/LegacyDensityNone", RigidBodyMaterialBaseCfg()) + assert not prim2.GetAttribute("physics:density").HasAuthoredValue() + + +def test_public_default_material_types_remain_backward_compatible(): + """Fragment support must not silently replace the released default config objects.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg + + from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg + from isaaclab.terrains.terrain_importer_cfg import TerrainImporterCfg + + defaults = ( + SimulationCfg().physics_material, + GroundPlaneCfg().physics_material, + TerrainImporterCfg(prim_path="/World/terrain").physics_material, + ) + assert all(type(material) is RigidBodyMaterialCfg for material in defaults) + + +def test_physx_fragment_and_legacy_cfg_match_material_api_schema(): + """Both PhysX interfaces cover the properties declared by ``PhysxMaterialAPI``.""" + import dataclasses + + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import ( + PhysxMaterialCfg, + PhysxRigidBodyMaterialCfg, + ) + + from pxr import PhysxSchema + + from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg + from isaaclab.utils.string import to_camel_case + + def fields(cls): + return {f.name for f in dataclasses.fields(cls) if f.name != "func"} + + base = fields(RigidBodyMaterialBaseCfg) + schema_attrs = {name.split(":", 1)[1] for name in PhysxSchema.PhysxMaterialAPI.GetSchemaAttributeNames()} + fragment_attrs = {to_camel_case(name, "cC") for name in fields(PhysxMaterialCfg)} + legacy_attrs = {to_camel_case(name, "cC") for name in fields(PhysxRigidBodyMaterialCfg) - base} + assert fragment_attrs == schema_attrs + assert legacy_attrs == schema_attrs + + +# ------------------------------------------------------------------------------------- +# Slot-typing contract +# ------------------------------------------------------------------------------------- + + +def test_material_slot_unions_match_spawner_kind(): + """Structural slot typing: rigid-only spawner slots admit the rigid base + fragments and + exclude the deformable-admitting root; mixed spawners keep the broad root. New slots added + to this list keep the contract enforced.""" + import sys + import typing + + from isaaclab.sim.spawners.from_files.from_files_cfg import FileCfg, GroundPlaneCfg + from isaaclab.sim.spawners.materials import physics_materials_cfg as mats + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCfg + from isaaclab.sim.spawners.shapes.shapes_cfg import ShapeCfg + from isaaclab.terrains.terrain_importer_cfg import TerrainImporterCfg + + def union_args(cls): + # typing.get_type_hints(cls) resolves annotations across the whole MRO, including the + # inherited ``func: Callable[..., Usd.Prim]`` from ``SpawnerCfg``, whose ``Usd`` import is + # TYPE_CHECKING-only and unresolvable at runtime. Evaluate the ``physics_material`` + # annotation directly against its declaring class's module globals instead -- each class + # in this list declares the field itself, so this never needs the base class's namespace. + annotation = cls.__dict__["__annotations__"]["physics_material"] + resolved = eval(annotation, vars(sys.modules[cls.__module__])) + return set(typing.get_args(resolved)) + + for cls in (ShapeCfg, GroundPlaneCfg, TerrainImporterCfg): + args = union_args(cls) + assert mats.RigidBodyMaterialBaseCfg in args, f"{cls.__name__} must admit the rigid base" + assert mats.RigidBodyMaterialFragment in args, f"{cls.__name__} must admit fragments" + assert mats.PhysicsMaterialCfg not in args, f"{cls.__name__} is rigid-only" + for cls in (FileCfg, MeshCfg): + args = union_args(cls) + assert mats.PhysicsMaterialCfg in args, f"{cls.__name__} spawns deformables too" + assert mats.RigidBodyMaterialFragment in args diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst new file mode 100644 index 000000000000..977d1590eca7 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst @@ -0,0 +1,21 @@ +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`. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi index 27677fbd6dbf..1e684304e810 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "NewtonDeformableBodyPropertiesCfg", "NewtonDeformableBodyMaterialCfg", "NewtonDeformableMaterialCfg", + "NewtonMaterialCfg", "NewtonSurfaceDeformableBodyMaterialCfg", "MPMGridCfg", "MPMParticleMaterialCfg", @@ -22,6 +23,7 @@ from .schemas import NewtonDeformableBodyPropertiesCfg from .spawners.materials import ( NewtonDeformableBodyMaterialCfg, NewtonDeformableMaterialCfg, + NewtonMaterialCfg, NewtonSurfaceDeformableBodyMaterialCfg, ) from .spawners.mpm import MPMGridCfg, MPMParticleMaterialCfg, MPMParticleSpawnerCfg, MPMPointsCfg diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py index 05d63921efde..86485af3aa7f 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -501,6 +501,35 @@ class NewtonMaterialPropertiesCfg(RigidBodyMaterialBaseCfg): Range: [0, inf). """ + contact_stiffness: float | None = None + """Contact normal-force stiffness [N/m]. + + Writes ``newton:contactStiffness``. Replaces the deprecated per-shape ``ke`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_damping: float | None = None + """Contact normal-force damping coefficient [N·s/m]. + + Writes ``newton:contactDamping``. Replaces the deprecated per-shape ``kd`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_friction_gain: float | None = None + """Friction-force stiffness gain used by the tangential (friction) contact response [N·s/m]. + + Writes ``newton:contactFrictionGain``. Replaces the deprecated per-shape ``kf`` contact + parameter; used by the SemiImplicit and Featherstone solvers. + """ + + contact_adhesion: float | None = None + """Contact adhesion distance: shapes closer than this threshold experience an attractive + (adhesive) force [m]. + + Writes ``newton:contactAdhesion``. Replaces the deprecated per-shape ``ka`` contact parameter; + used by the SemiImplicit and Featherstone solvers. + """ + @configclass class MujocoFixedTendonCfg(FixedTendonFragment): diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi index c3f13216f781..5ee626b240bd 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "spawn_deformable_body_material", "NewtonDeformableBodyMaterialCfg", "NewtonDeformableMaterialCfg", + "NewtonMaterialCfg", "NewtonSurfaceDeformableBodyMaterialCfg", ] @@ -14,5 +15,6 @@ from .physics_materials import spawn_deformable_body_material from .physics_materials_cfg import ( NewtonDeformableBodyMaterialCfg, NewtonDeformableMaterialCfg, + NewtonMaterialCfg, NewtonSurfaceDeformableBodyMaterialCfg, ) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py index f7b63a8701c6..e0027a815530 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py @@ -10,6 +10,7 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import ( DeformableBodyMaterialBaseCfg, + RigidBodyMaterialFragment, SurfaceDeformableBodyMaterialBaseCfg, ) from isaaclab.utils.configclass import configclass @@ -77,3 +78,73 @@ class NewtonSurfaceDeformableBodyMaterialCfg(SurfaceDeformableBodyMaterialBaseCf edge_kd: float = 1e-2 """Bending damping [N*m*s]. Used by Newton backend for cloth meshes.""" + + +@configclass +class NewtonMaterialCfg(RigidBodyMaterialFragment): + """``newton:*`` rigid-body material attributes read by Newton's USD material schema resolver. + + Single-namespace fragment (see + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`) for the Newton-only + friction knobs (torsional and rolling friction) and the per-material contact model (contact + stiffness/damping, friction gain, adhesion) that replaces the deprecated per-shape + ``ke``/``kd``/``kf``/``ka`` parameters. The ``NewtonMaterialAPI`` schema is applied (and the + ``newton:*`` attributes authored) by the generic :func:`~isaaclab.sim.schemas.apply_namespaced` + writer. ``None`` fields are left unchanged. + + .. note:: + The generated ``NewtonMaterialAPI`` USD schema currently only declares the two friction + attributes; the four contact attributes are still authored as raw ``newton:*`` USD + attributes and are read directly by Newton's schema resolver. + + Composes with other rigid-body material fragments (e.g. + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg`) in the same fragment + list passed to + :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments`. For the + legacy (non-fragment) equivalent, see + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg`. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonMaterialAPI" + + torsional_friction: float | None = None + """Torsional friction coefficient (resistance to spinning at a contact point) [dimensionless]. + + Writes ``newton:torsionalFriction``. Range: [0, inf). + """ + + rolling_friction: float | None = None + """Rolling friction coefficient (resistance to rolling motion) [dimensionless]. + + Writes ``newton:rollingFriction``. Range: [0, inf). + """ + + contact_stiffness: float | None = None + """Contact normal-force stiffness [N/m]. + + Writes ``newton:contactStiffness``. Replaces the deprecated per-shape ``ke`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_damping: float | None = None + """Contact normal-force damping coefficient [N·s/m]. + + Writes ``newton:contactDamping``. Replaces the deprecated per-shape ``kd`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_friction_gain: float | None = None + """Friction-force stiffness gain used by the tangential (friction) contact response [N·s/m]. + + Writes ``newton:contactFrictionGain``. Replaces the deprecated per-shape ``kf`` contact + parameter; used by the SemiImplicit and Featherstone solvers. + """ + + contact_adhesion: float | None = None + """Contact adhesion distance: shapes closer than this threshold experience an attractive + (adhesive) force [m]. + + Writes ``newton:contactAdhesion``. Replaces the deprecated per-shape ``ka`` contact parameter; + used by the SemiImplicit and Featherstone solvers. + """ diff --git a/source/isaaclab_newton/test/sim/test_newton_schemas.py b/source/isaaclab_newton/test/sim/test_newton_schemas.py index 1774792d3d03..0f78c8e39507 100644 --- a/source/isaaclab_newton/test/sim/test_newton_schemas.py +++ b/source/isaaclab_newton/test/sim/test_newton_schemas.py @@ -164,6 +164,72 @@ def test_newton_material_no_schema_when_none(setup_sim): assert "NewtonMaterialAPI" not in prim.GetAppliedSchemas() +@pytest.mark.isaacsim_ci +def test_newton_material_fragment_composes_with_usd_physics_fragment(setup_sim): + """NewtonMaterialCfg is a rigid-body material fragment (backend symmetry with the PhysX + fragment): it must compose in a fragment list with UsdPhysicsRigidBodyMaterialCfg and author + both the ``newton:*`` and solver-common ``physics:*`` namespaces on the same material prim.""" + from isaaclab_newton.sim.spawners.materials import NewtonMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + prim = spawn_rigid_body_material_from_fragments( + "/World/newton_mat_frag", + [ + UsdPhysicsRigidBodyMaterialCfg(static_friction=0.6, dynamic_friction=0.5), + NewtonMaterialCfg(torsional_friction=0.3, rolling_friction=0.001), + ], + ) + assert bool(UsdPhysics.MaterialAPI(prim)) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.6) + assert prim.GetAttribute("physics:dynamicFriction").Get() == pytest.approx(0.5) + assert "NewtonMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("newton:torsionalFriction").Get() == pytest.approx(0.3) + assert prim.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.001) + + +@pytest.mark.isaacsim_ci +def test_newton_material_fragment_authors_all_six_newton_attrs(setup_sim): + """Regression test: Newton's USD material schema resolver (``SchemaResolverNewton``) reads six + ``newton:*`` material attributes -- the two friction knobs plus four contact-model attributes + (``contactStiffness``/``contactDamping``/``contactFrictionGain``/``contactAdhesion``) that + replace the deprecated per-shape ``ke``/``kd``/``kf``/``ka`` parameters. All six must round-trip + through :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`, even though the + generated ``NewtonMaterialAPI`` schema currently only declares the two friction attributes.""" + from isaaclab_newton.sim.spawners.materials import NewtonMaterialCfg + from newton._src.usd.schema_resolver import PrimType + from newton._src.usd.schemas import SchemaResolverNewton + + from isaaclab.sim.spawners.materials import spawn_rigid_body_material_from_fragments + + prim = spawn_rigid_body_material_from_fragments( + "/World/newton_mat_contact", + NewtonMaterialCfg( + torsional_friction=0.3, + rolling_friction=0.001, + contact_stiffness=2500.0, + contact_damping=100.0, + contact_friction_gain=1000.0, + contact_adhesion=0.01, + ), + ) + expected = { + "mu_torsional": 0.3, + "mu_rolling": 0.001, + "ke": 2500.0, + "kd": 100.0, + "kf": 1000.0, + "ka": 0.01, + } + + assert "NewtonMaterialAPI" in prim.GetAppliedSchemas() + resolver = SchemaResolverNewton() + assert set(resolver.mapping[PrimType.MATERIAL]) == set(expected) + for key, value in expected.items(): + assert resolver.get_value(prim, PrimType.MATERIAL, key) == pytest.approx(value) + + # --------------------------------------------------------------------------- # Newton articulation root # --------------------------------------------------------------------------- @@ -347,3 +413,16 @@ def test_newton_mesh_collision_mixed_namespace_write(setup_sim): applied = prim.GetAppliedSchemas() assert "NewtonCollisionAPI" in applied assert "NewtonMeshCollisionAPI" in applied + + +@pytest.mark.isaacsim_ci +def test_newton_legacy_cfg_authors_contact_attrs(setup_sim): + """The legacy Newton material cfg authors all newton:* attributes the fragment authors.""" + mat_cfg = NewtonMaterialPropertiesCfg( + contact_stiffness=1.0e4, contact_damping=250.0, contact_friction_gain=40.0, contact_adhesion=0.02 + ) + prim = spawn_rigid_body_material("/World/newton_mat_contact", mat_cfg) + assert prim.GetAttribute("newton:contactStiffness").Get() == pytest.approx(1.0e4) + assert prim.GetAttribute("newton:contactDamping").Get() == pytest.approx(250.0) + assert prim.GetAttribute("newton:contactFrictionGain").Get() == pytest.approx(40.0) + assert prim.GetAttribute("newton:contactAdhesion").Get() == pytest.approx(0.02) diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst new file mode 100644 index 000000000000..b580d3ad6316 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst @@ -0,0 +1,12 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`, a single-namespace + ``physxMaterial`` rigid-body physics-material fragment (compliant-contact spring stiffness/damping + and the friction/restitution combine-mode tokens) backing ``PhysxMaterialAPI``. +* Added :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.damping_combine_mode` (writes + ``physxMaterial:dampingCombineMode``) and + :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.compliant_contact_acceleration_spring` + (writes ``physxMaterial:compliantContactAccelerationSpring``), completing the fragment's coverage + of ``PhysxMaterialAPI``. Also added the same two fields to the legacy + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi index 315458ea5d24..7a018a14ecd6 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "DeformableBodyMaterialCfg", "PhysXDeformableMaterialCfg", "PhysxDeformableBodyMaterialCfg", + "PhysxMaterialCfg", "PhysxRigidBodyMaterialCfg", "PhysxSurfaceDeformableBodyMaterialCfg", "RigidBodyMaterialCfg", @@ -17,6 +18,7 @@ from .physics_materials_cfg import ( DeformableBodyMaterialCfg, PhysXDeformableMaterialCfg, PhysxDeformableBodyMaterialCfg, + PhysxMaterialCfg, PhysxRigidBodyMaterialCfg, PhysxSurfaceDeformableBodyMaterialCfg, RigidBodyMaterialCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py index 345fadc88704..3560bc12bb3b 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py @@ -12,6 +12,7 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import ( DeformableBodyMaterialBaseCfg, RigidBodyMaterialBaseCfg, + RigidBodyMaterialFragment, SurfaceDeformableBodyMaterialBaseCfg, ) from isaaclab.utils.configclass import configclass @@ -154,12 +155,13 @@ class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): Extends :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` with the `PhysxMaterialAPI`_ schema fields: compliant-contact spring (stiffness/damping) and the - friction/restitution combine-mode tokens. None of these fields have a Newton consumer - today; they are PhysX-engine-only knobs. + friction/restitution combine-mode tokens. Newton's USD importer also recognizes stiffness and + damping as fallback contact parameters; the acceleration-spring flag and combine modes are + PhysX-only. See :meth:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material` for more information. - .. _PhysxMaterialAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_material_a_p_i.html + .. _PhysxMaterialAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_material_a_p_i.html """ # -- Class metadata (not dataclass fields) -- @@ -172,14 +174,23 @@ class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): """Spring stiffness for a compliant contact model using implicit springs. A higher stiffness results in behavior closer to a rigid contact. The compliant contact model - is only enabled if the stiffness is larger than 0. PhysX-only; not consumed by Newton. + is only enabled if the stiffness is larger than 0. Newton's USD importer also recognizes this + attribute as a fallback contact-stiffness value. """ compliant_contact_damping: float | None = None """Damping coefficient for a compliant contact model using implicit springs. Irrelevant if compliant contacts are disabled when :attr:`compliant_contact_stiffness` is set - to zero and rigid contacts are active. PhysX-only; not consumed by Newton. + to zero and rigid contacts are active. Newton's USD importer also recognizes this attribute as + a fallback contact-damping value. + """ + + compliant_contact_acceleration_spring: bool | None = None + """Whether the compliant contact spring is formulated in acceleration (rather than force) units. + + Only relevant when compliant contacts are enabled (:attr:`compliant_contact_stiffness` is + non-zero). PhysX-only; not consumed by Newton. """ friction_combine_mode: Literal["average", "min", "multiply", "max"] | None = None @@ -202,6 +213,73 @@ class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): `__. """ + damping_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """Determines the way the (compliant-contact) damping coefficient is combined during collisions. + + .. attention:: + + When two physics materials with different combine modes collide, the combine mode with + the higher priority will be used. The priority order is provided `here + `__. + """ + + +@configclass +class PhysxMaterialCfg(RigidBodyMaterialFragment): + """``physxMaterial:*`` rigid-body material attributes from `PhysxMaterialAPI`_. + + Single-namespace fragment (see :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`) + for the PhysX material namespace: compliant-contact spring (stiffness/damping), which Newton's + USD importer can read as fallback contact parameters, plus PhysX-only acceleration-spring and + combine-mode knobs. The ``PhysxMaterialAPI`` schema is applied (and the ``physxMaterial:*`` + attributes authored) by the generic :func:`~isaaclab.sim.schemas.apply_namespaced` writer. + ``None`` fields are left unchanged. + + .. _PhysxMaterialAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_material_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxMaterial" + _usd_applied_schema: ClassVar[str | None] = "PhysxMaterialAPI" + + compliant_contact_stiffness: float | None = None + """Spring stiffness for a compliant contact model using implicit springs. + + A higher stiffness results in behavior closer to a rigid contact. The compliant contact model is + only enabled if the stiffness is larger than 0. Writes ``physxMaterial:compliantContactStiffness``. + """ + + compliant_contact_damping: float | None = None + """Damping coefficient for a compliant contact model using implicit springs. + + Irrelevant if compliant contacts are disabled (``compliant_contact_stiffness`` is zero). Writes + ``physxMaterial:compliantContactDamping``. + """ + + compliant_contact_acceleration_spring: bool | None = None + """Whether the compliant contact spring is formulated in acceleration (rather than force) units. + + Writes ``physxMaterial:compliantContactAccelerationSpring``. Only relevant when compliant + contacts are enabled (:attr:`compliant_contact_stiffness` is non-zero). + """ + + friction_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """How friction is combined during collisions. Writes ``physxMaterial:frictionCombineMode``.""" + + restitution_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """How restitution is combined during collisions. Writes ``physxMaterial:restitutionCombineMode``.""" + + damping_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """How the (compliant-contact) damping coefficient is combined during collisions. + + Writes ``physxMaterial:dampingCombineMode``. + + .. attention:: + + When two physics materials with different combine modes collide, the combine mode with + the higher priority will be used. The priority order is provided `here + `__. + """ + @configclass class RigidBodyMaterialCfg(PhysxRigidBodyMaterialCfg): From e211621102f3c8044fe3bb3897d0b34e14270b84 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:06:27 +0000 Subject: [PATCH 08/23] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 8.0.3 → 8.1.0 - isaaclab_assets: 0.4.0 → 0.4.1 - isaaclab_newton: 1.5.1 → 1.6.0 - isaaclab_physx: 2.6.2 → 2.7.0 - isaaclab_tasks: 8.1.3 → 8.1.4 --- .../capture-env-sensors-tests.skip | 1 - .../mataylor-smoke-test-marker.skip | 3 -- .../vidurv-schema-frag-materials.minor.rst | 45 ---------------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 50 +++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/aserifi-drlegs.rst | 5 -- source/isaaclab_assets/config/extension.toml | 2 +- source/isaaclab_assets/docs/CHANGELOG.rst | 10 ++++ source/isaaclab_assets/pyproject.toml | 2 +- .../mataylor-smoke-test-marker.skip | 2 - .../changelog.d/rgrandia-fix-kamino-reset.rst | 27 ---------- .../vidurv-schema-frag-materials.minor.rst | 21 -------- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 54 +++++++++++++++++++ source/isaaclab_newton/pyproject.toml | 2 +- .../vidurv-schema-frag-materials.minor.rst | 12 ----- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 17 ++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../changelog.d/aserifi-drlegs.rst | 14 ----- .../mataylor-smoke-test-marker.skip | 2 - .../changelog.d/mt-visual-sensor-logs.skip | 1 - source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 19 +++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 26 files changed, 160 insertions(+), 143 deletions(-) delete mode 100644 source/isaaclab/changelog.d/capture-env-sensors-tests.skip delete mode 100644 source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip delete mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst delete mode 100644 source/isaaclab_assets/changelog.d/aserifi-drlegs.rst delete mode 100644 source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip delete mode 100644 source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst delete mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst delete mode 100644 source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip delete mode 100644 source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip diff --git a/source/isaaclab/changelog.d/capture-env-sensors-tests.skip b/source/isaaclab/changelog.d/capture-env-sensors-tests.skip deleted file mode 100644 index a34e75ee2c7e..000000000000 --- a/source/isaaclab/changelog.d/capture-env-sensors-tests.skip +++ /dev/null @@ -1 +0,0 @@ -Tests only. diff --git a/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip deleted file mode 100644 index 8cc2b98fdf92..000000000000 --- a/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip +++ /dev/null @@ -1,3 +0,0 @@ -Test-only: add a ``smoke`` pytest marker and register it, then tag the -install-CI smoke tests with it so quick installation smoke checks can be -selected as a group. diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst deleted file mode 100644 index 1cfe675cb0b3..000000000000 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst +++ /dev/null @@ -1,45 +0,0 @@ -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. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 3617610fce21..1debfe7754a6 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "8.0.3" +version = "8.1.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 48f84fcad409..05ec117ac453 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,56 @@ Changelog --------- +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) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 3d1b04ea4ab5..3f3616255b9b 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.0.3" +version = "8.1.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst b/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst deleted file mode 100644 index 92397541b02a..000000000000 --- a/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added :data:`~isaaclab_assets.robots.dr_legs.DR_LEGS_IMPLICIT_PD_CFG` for the Disney DR Legs - closed-loop biped. diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index 38e4707a8098..0a560242c288 100644 --- a/source/isaaclab_assets/config/extension.toml +++ b/source/isaaclab_assets/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.4.0" +version = "0.4.1" # Description title = "Isaac Lab Assets" diff --git a/source/isaaclab_assets/docs/CHANGELOG.rst b/source/isaaclab_assets/docs/CHANGELOG.rst index a11a177c97f2..8a2a4dd58bf6 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.4.1 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :data:`~isaaclab_assets.robots.dr_legs.DR_LEGS_IMPLICIT_PD_CFG` for the Disney DR Legs + closed-loop biped. + + 0.4.0 (2026-06-27) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/pyproject.toml b/source/isaaclab_assets/pyproject.toml index cbd4b3a156e2..b75ffb3e97ac 100644 --- a/source/isaaclab_assets/pyproject.toml +++ b/source/isaaclab_assets/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_assets" -version = "0.4.0" +version = "0.4.1" 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/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip deleted file mode 100644 index 797f40bfd461..000000000000 --- a/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip +++ /dev/null @@ -1,2 +0,0 @@ -Test-only: tag the rigid/deformable coupling smoke test with the shared -``smoke`` pytest marker. diff --git a/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst b/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst deleted file mode 100644 index fc9ba91c17fe..000000000000 --- a/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst +++ /dev/null @@ -1,27 +0,0 @@ -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``. diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst deleted file mode 100644 index 977d1590eca7..000000000000 --- a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst +++ /dev/null @@ -1,21 +0,0 @@ -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`. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index ebdec1167e11..4894b26e39b8 100644 --- a/source/isaaclab_newton/config/extension.toml +++ b/source/isaaclab_newton/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "1.5.1" +version = "1.6.0" # Description title = "Newton simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 8a642cda9394..d44e6d25e885 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,60 @@ Changelog --------- +1.6.0 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`, a single-namespace + ``newton`` rigid-body physics-material fragment (torsional and rolling friction) backing + ``NewtonMaterialAPI``. Composes with other rigid-body material fragments (e.g. + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg`) in a fragment list. +* Added :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_stiffness`, + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_damping`, + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_friction_gain`, and + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_adhesion` (writing + ``newton:contactStiffness``, ``newton:contactDamping``, ``newton:contactFrictionGain``, and + ``newton:contactAdhesion``), matching the per-material contact attributes Newton's USD schema + resolver reads in place of the deprecated per-shape ``ke``/``kd``/``kf``/``ka`` parameters. +* Added the contact attributes + (:attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_stiffness`, + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_damping`, + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_friction_gain`, and + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_adhesion`) to + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg`, matching + :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`. + +Changed +^^^^^^^ + +* **Breaking:** Changed :class:`~isaaclab_newton.physics.NewtonKaminoManager` to require exactly + one articulation per environment. :meth:`~isaaclab_newton.physics.NewtonKaminoManager._build_solver` + raises a ``RuntimeError`` at solver initialization when an environment contains multiple + articulations. Multiple articulations per environment are not yet supported in IsaacLab's Kamino integration. + +* Changed :class:`~isaaclab_newton.physics.NewtonManager` to route forward kinematics through a + solver-specialized hook bound during solver initialization. Kamino overrides this hook to call + :meth:`SolverKamino.reset` with :class:`SolverKamino.ResetConfig.from_joints` when + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.use_fk_solver` is enabled. Environment resets + now share a single per-articulation mask for both :meth:`~isaaclab_newton.physics.NewtonManager.forward` + and pre-step reconcile, replacing the separate per-world Kamino reset mask. + +Fixed +^^^^^ + +* Fixed environment resets writing updated state into the wrong double-buffered simulation + state when ``use_cuda_graph`` was disabled. With an odd number of substeps the canonical input + state buffer flipped each step while asset write paths kept targeting the original binding, so + reset environments stayed inconsistent for solvers with separate input/output states (e.g. + :class:`~isaaclab_newton.physics.NewtonKaminoManager`). + +* Fixed Kamino forward kinematics on environment resets leaving incorrect body poses for + closed-loop systems. Reset environments are now always updated through Kamino's loop-closure FK + solver instead of Newton's articulated ``eval_fk``. + + 1.5.1 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index c01a6cc1ebad..a855efb7ae63 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.5.1" +version = "1.6.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst deleted file mode 100644 index b580d3ad6316..000000000000 --- a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst +++ /dev/null @@ -1,12 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`, a single-namespace - ``physxMaterial`` rigid-body physics-material fragment (compliant-contact spring stiffness/damping - and the friction/restitution combine-mode tokens) backing ``PhysxMaterialAPI``. -* Added :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.damping_combine_mode` (writes - ``physxMaterial:dampingCombineMode``) and - :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.compliant_contact_acceleration_spring` - (writes ``physxMaterial:compliantContactAccelerationSpring``), completing the fragment's coverage - of ``PhysxMaterialAPI``. Also added the same two fields to the legacy - :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 9ea4a736d54c..3e148db6c69b 100644 --- a/source/isaaclab_physx/config/extension.toml +++ b/source/isaaclab_physx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "2.6.2" +version = "2.7.0" # Description title = "PhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index 88a48a0ce623..c4f44204ed27 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +2.7.0 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`, a single-namespace + ``physxMaterial`` rigid-body physics-material fragment (compliant-contact spring stiffness/damping + and the friction/restitution combine-mode tokens) backing ``PhysxMaterialAPI``. +* Added :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.damping_combine_mode` (writes + ``physxMaterial:dampingCombineMode``) and + :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.compliant_contact_acceleration_spring` + (writes ``physxMaterial:compliantContactAccelerationSpring``), completing the fragment's coverage + of ``PhysxMaterialAPI``. Also added the same two fields to the legacy + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. + + 2.6.2 (2026-07-02) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 63d0495a2a75..82a80f7e5324 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.6.2" +version = "2.7.0" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst b/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst deleted file mode 100644 index 65b7ea95e469..000000000000 --- a/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst +++ /dev/null @@ -1,14 +0,0 @@ -Added -^^^^^ - -* Added ``Isaac-DrLegs-HoldPose-v0`` and ``Isaac-DrLegs-Walk-v0`` Kamino closed-loop locomotion - tasks via :class:`~isaaclab_tasks.contrib.dr_legs.hold_pose_env_cfg.DrLegsHoldPoseEnvCfg` and - :class:`~isaaclab_tasks.contrib.dr_legs.walk_env_cfg.DrLegsWalkEnvCfg`. - -* Added ``newton_kamino`` physics presets to core and contrib velocity, reach, cabinet, and Shadow - Hand environment configurations. - -Fixed -^^^^^ - -* Fixed reach task table spawn offset for Newton ``newton_mjwarp`` and ``newton_kamino`` presets. diff --git a/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip deleted file mode 100644 index 8487220a16ca..000000000000 --- a/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip +++ /dev/null @@ -1,2 +0,0 @@ -Test-only: tag the contrib environments smoke test with the shared ``smoke`` -pytest marker. diff --git a/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip b/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip deleted file mode 100644 index b408a2fb78f6..000000000000 --- a/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip +++ /dev/null @@ -1 +0,0 @@ -Tests only: refactored rendering test utils to use shared camera image helpers. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index c93829e05c3c..be7ef714b7f8 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "8.1.3" +version = "8.1.4" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index a22dd0a0c476..7228be56533f 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,25 @@ Changelog --------- +8.1.4 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``Isaac-DrLegs-HoldPose-v0`` and ``Isaac-DrLegs-Walk-v0`` Kamino closed-loop locomotion + tasks via :class:`~isaaclab_tasks.contrib.dr_legs.hold_pose_env_cfg.DrLegsHoldPoseEnvCfg` and + :class:`~isaaclab_tasks.contrib.dr_legs.walk_env_cfg.DrLegsWalkEnvCfg`. + +* Added ``newton_kamino`` physics presets to core and contrib velocity, reach, cabinet, and Shadow + Hand environment configurations. + +Fixed +^^^^^ + +* Fixed reach task table spawn offset for Newton ``newton_mjwarp`` and ``newton_kamino`` presets. + + 8.1.3 (2026-07-03) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 4db1f1d78a37..e328a713bf07 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.3" +version = "8.1.4" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 17136c6fc2e16af098a6e08a9d75905dc53e09cc Mon Sep 17 00:00:00 2001 From: ooctipus Date: Sat, 4 Jul 2026 03:54:45 -0700 Subject: [PATCH 09/23] Fix contact sensor activation for nested rigid bodies (#6259) ## Summary - use `get_all_matching_child_prims` when activating PhysX contact report schemas - continue matching rigid descendants under nested rigid-body trees instead of stopping at the first rigid body - add regression coverage for nested rigid bodies receiving `PhysxContactReportAPI` ## Testing - ./isaaclab.sh -p -m pytest source/isaaclab/test/sim/test_schemas.py -k activate_contact_sensors_nested_rigid_bodies -q - ./isaaclab.sh -p -m py_compile source/isaaclab/isaaclab/sim/schemas/schemas.py source/isaaclab/test/sim/test_schemas.py ## Note - Attempted `./isaaclab.sh -p -m pytest source/isaaclab_physx/test/sensors/test_contact_sensor.py -k contact_sensor_threshold -q`, but collection failed because `flaky` is not installed in the local environment. --- ...te-contact-sensors-nested-rigid-bodies.rst | 4 ++ .../isaaclab/isaaclab/sim/schemas/schemas.py | 44 ++++++++----------- source/isaaclab/test/sim/test_schemas.py | 27 ++++++++++++ 3 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst diff --git a/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst b/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst new file mode 100644 index 000000000000..c6df8d9b9c91 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed contact sensor activation for rigid bodies nested below other rigid bodies. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index bdcc073d35cd..035f5509f4cf 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -933,33 +933,25 @@ def activate_contact_sensors(prim_path: str, threshold: float = 0.0, stage: Usd. # check if prim is valid if not prim.IsValid(): raise ValueError(f"Prim path '{prim_path}' is not valid.") - # iterate over all children - num_contact_sensors = 0 - all_prims = [prim] - while len(all_prims) > 0: - # get current prim - child_prim = all_prims.pop(0) - # check if prim is a rigid body - # nested rigid bodies are not allowed by SDK so we can safely assume that - # if a prim has a rigid body API, it is a rigid body and we don't need to - # check its children - if child_prim.HasAPI(UsdPhysics.RigidBodyAPI): - # set sleep threshold to zero - child_applied = child_prim.GetAppliedSchemas() - if "PhysxRigidBodyAPI" not in child_applied: - child_prim.AddAppliedSchema("PhysxRigidBodyAPI") - safe_set_attribute_on_usd_prim(child_prim, "physxRigidBody:sleepThreshold", 0.0, camel_case=False) - # add contact report API with threshold of zero - if "PhysxContactReportAPI" not in child_applied: - child_prim.AddAppliedSchema("PhysxContactReportAPI") - safe_set_attribute_on_usd_prim(child_prim, "physxContactReport:threshold", threshold, camel_case=False) - # increment number of contact sensors - num_contact_sensors += 1 - else: - # add all children to tree - all_prims += child_prim.GetChildren() + # collect all rigid bodies under the prim, including nested rigid-body trees + rigid_body_prims = get_all_matching_child_prims( + prim_path, + predicate=lambda child_prim: child_prim.HasAPI(UsdPhysics.RigidBodyAPI), + stage=stage, + traverse_instance_prims=False, + ) + for child_prim in rigid_body_prims: + # set sleep threshold to zero + child_applied = child_prim.GetAppliedSchemas() + if "PhysxRigidBodyAPI" not in child_applied: + child_prim.AddAppliedSchema("PhysxRigidBodyAPI") + safe_set_attribute_on_usd_prim(child_prim, "physxRigidBody:sleepThreshold", 0.0, camel_case=False) + # add contact report API with threshold of zero + if "PhysxContactReportAPI" not in child_applied: + child_prim.AddAppliedSchema("PhysxContactReportAPI") + safe_set_attribute_on_usd_prim(child_prim, "physxContactReport:threshold", threshold, camel_case=False) # check if no contact sensors were found - if num_contact_sensors == 0: + if not rigid_body_prims: descendant_count = 0 frontier = [prim] while frontier: diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 32143a8bb430..f22c7db4ad78 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -752,6 +752,33 @@ def test_modify_properties_on_articulation_usd(setup_simulation): _validate_articulation_properties_on_prim("/World/asset", arti_cfg, True) +@pytest.mark.isaacsim_ci +def test_activate_contact_sensors_nested_rigid_bodies(setup_simulation): + """Test contact-report schemas are applied to nested rigid-body trees.""" + stage = sim_utils.get_current_stage() + + rigid_body_paths = [ + "/World/Robot/Geometry/pelvis", + "/World/Robot/Geometry/pelvis/left_hip", + "/World/Robot/Geometry/pelvis/left_hip/left_knee", + ] + sim_utils.create_prim("/World/Robot", prim_type="Xform") + sim_utils.create_prim("/World/Robot/Geometry", prim_type="Xform") + for prim_path in rigid_body_paths: + sim_utils.create_prim(prim_path, prim_type="Xform") + UsdPhysics.RigidBodyAPI.Apply(stage.GetPrimAtPath(prim_path)) + + schemas.activate_contact_sensors("/World/Robot", threshold=2.5) + + for prim_path in rigid_body_paths: + prim = stage.GetPrimAtPath(prim_path) + applied_schemas = prim.GetAppliedSchemas() + assert "PhysxRigidBodyAPI" in applied_schemas + assert "PhysxContactReportAPI" in applied_schemas + assert prim.GetAttribute("physxRigidBody:sleepThreshold").Get() == pytest.approx(0.0) + assert prim.GetAttribute("physxContactReport:threshold").Get() == pytest.approx(2.5) + + @pytest.mark.isaacsim_ci def test_defining_rigid_body_properties_on_prim(setup_simulation): """Test defining rigid body properties on a prim.""" From 186f152e1f2448f5004bda413aa69c67345b1a34 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:21:08 +0000 Subject: [PATCH 10/23] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 8.1.0 → 8.1.1 --- .../fix-activate-contact-sensors-nested-rigid-bodies.rst | 4 ---- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 9 +++++++++ source/isaaclab/pyproject.toml | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) delete mode 100644 source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst diff --git a/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst b/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst deleted file mode 100644 index c6df8d9b9c91..000000000000 --- a/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed contact sensor activation for rigid bodies nested below other rigid bodies. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 1debfe7754a6..965ca56e7589 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "8.1.0" +version = "8.1.1" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 05ec117ac453..ee3515ac5628 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +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) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 3f3616255b9b..c5696f211009 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.1.0" +version = "8.1.1" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 5aafdb59626499d014e7007f48afae2db5c9c096 Mon Sep 17 00:00:00 2001 From: Richard Lei Date: Mon, 6 Jul 2026 13:54:18 +1200 Subject: [PATCH 11/23] Added 'motion_vectors' data type support to OVRTX renderer integration (#6317) # Description Added 'motion_vectors' data type support to OVRTX renderer integration Maps the "motion_vectors" data type to OVRTX's TargetMotionSD render var, mirroring the AOV used by Isaac RTX/Replicator's built-in motion_vectors annotator. Extracts only the first two (u, v) channels via a dedicated kernel. Also extends the shared rendering-correctness test utilities to cover motion_vectors across cartpole, shadow hand, and dexsuite kuka allegro for both kit (isaacsim_rtx) and kitless (ovrtx) renderers, including a normalization path for the 2-channel output and an optional zero/non-zero action step so motion vectors have real inter-frame motion to encode before the first captured frame. ## Type of change - New feature (non-breaking change which adds functionality) - Documentation update ## Screenshots See golden images as part of changes. ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../overview/core-concepts/sensors/camera.rst | 2 +- .../changelog.d/ovrtx-motion-vectors.rst | 8 ++ source/isaaclab/isaaclab/utils/images.py | 11 +++ source/isaaclab/test/utils/test_images.py | 10 ++ .../ovrtx-motion-vectors.minor.rst | 4 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 9 ++ .../isaaclab_ov/renderers/ovrtx_usd.py | 45 +++++++-- .../test/test_ovrtx_renderer_contract.py | 2 + .../test/test_ovrtx_renderer_kernels.py | 55 +++++++++++ source/isaaclab_ov/test/test_ovrtx_usd.py | 14 +++ .../ovrtx-motion-vectors.minor.rst | 7 ++ .../physics/ovphysx_manager.py | 16 +++- .../physics/ovphysx_manager_cfg.py | 15 +++ .../changelog.d/ovrtx-motion-vectors.rst | 12 +++ .../ovphysx-ovrtx_renderer-motion_vectors.png | 3 + ...x-isaacsim_rtx_renderer-motion_vectors.png | 3 + ...x-isaacsim_rtx_renderer-motion_vectors.png | 3 + ...x-isaacsim_rtx_renderer-motion_vectors.png | 3 + ...x-isaacsim_rtx_renderer-motion_vectors.png | 3 + .../test/rendering_test_utils.py | 94 ++++++++++++++++++- 20 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 source/isaaclab/changelog.d/ovrtx-motion-vectors.rst create mode 100644 source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst create mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png diff --git a/docs/source/overview/core-concepts/sensors/camera.rst b/docs/source/overview/core-concepts/sensors/camera.rst index 6928477f2a23..47ef069d107d 100644 --- a/docs/source/overview/core-concepts/sensors/camera.rst +++ b/docs/source/overview/core-concepts/sensors/camera.rst @@ -239,7 +239,7 @@ is :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`, and ``Newton Warp`` is - ✅ * - ``motion_vectors`` - ✅ - - ❌ + - ✅ - ❌ * - ``semantic_segmentation`` - ✅ diff --git a/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst b/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst new file mode 100644 index 000000000000..b71fc27c48bf --- /dev/null +++ b/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst @@ -0,0 +1,8 @@ +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. diff --git a/source/isaaclab/isaaclab/utils/images.py b/source/isaaclab/isaaclab/utils/images.py index 19361e73b7ad..bb02548d55cf 100644 --- a/source/isaaclab/isaaclab/utils/images.py +++ b/source/isaaclab/isaaclab/utils/images.py @@ -108,6 +108,17 @@ def normalize_camera_output_for_display(tensor: torch.Tensor, data_type: str) -> 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 diff --git a/source/isaaclab/test/utils/test_images.py b/source/isaaclab/test/utils/test_images.py index 881961b5d019..3cf5b9faac2a 100644 --- a/source/isaaclab/test/utils/test_images.py +++ b/source/isaaclab/test/utils/test_images.py @@ -209,6 +209,16 @@ def test_albedo_keeps_rgb_channels(self, device): 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.""" diff --git a/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst b/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst new file mode 100644 index 000000000000..44e9084afca9 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added ``motion_vectors`` data type support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 61bf985646aa..f802ba794500 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -265,6 +265,7 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: RenderBufferKind.DISTANCE_TO_IMAGE_PLANE: RenderBufferSpec(1, wp.float32), RenderBufferKind.DISTANCE_TO_CAMERA: RenderBufferSpec(1, wp.float32), RenderBufferKind.NORMALS: RenderBufferSpec(3, wp.float32), + RenderBufferKind.MOTION_VECTORS: RenderBufferSpec(2, wp.float32), } @property @@ -894,6 +895,14 @@ def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buff tiled_normals_data = wp.from_dlpack(mapping.tensor) self._launch_extract_all_tiles(render_data, tiled_normals_data, output_buffers["normals"]) + # For motion vectors, extract only the first two (u, v) channels from the tiled buffer. + # Note: mirrors the Isaac RTX renderer's handling of the "TargetMotionSD" AOV + # (check: https://github.com/isaac-sim/IsaacLab/issues/2003). + if "TargetMotionSD" in frame.render_vars and "motion_vectors" in output_buffers: + with frame.render_vars["TargetMotionSD"].map(device=Device.CUDA) as mapping: + tiled_motion_vectors_data = wp.from_dlpack(mapping.tensor) + self._launch_extract_all_tiles(render_data, tiled_motion_vectors_data, output_buffers["motion_vectors"]) + def render(self, render_data: OVRTXRenderData) -> None: """Render the scene into the provided RenderData.""" if not self._initialized_scene: diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index 443e0d21e4dd..d0e98aa6e0a5 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -26,20 +26,36 @@ def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: use_instance_seg = "instance_segmentation_fast" in data_types use_instance_id_seg = "instance_id_segmentation_fast" in data_types use_normals = "normals" in data_types + use_motion_vectors = "motion_vectors" in data_types use_rgb = any(dt in ["rgb", "rgba"] for dt in data_types) use_hdr = "rgb_hdr" in data_types if use_depth and not ( - use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_normals + use_rgb + or use_albedo + or use_semantic + or use_instance_seg + or use_instance_id_seg + or use_normals + or use_motion_vectors ): source = "DistanceToCameraSD" if use_distance_to_camera else "DistanceToImagePlaneSD" return "/Render/Vars/depth", "depth", source - if use_albedo and not (use_rgb or use_semantic or use_instance_seg or use_instance_id_seg or use_normals): + if use_albedo and not ( + use_rgb or use_semantic or use_instance_seg or use_instance_id_seg or use_normals or use_motion_vectors + ): return "/Render/Vars/albedo", "albedo", "DiffuseAlbedoSD" - if use_semantic and not (use_rgb or use_albedo or use_normals): + if use_semantic and not (use_rgb or use_albedo or use_normals or use_motion_vectors): return "/Render/Vars/semantic", "semantic", "SemanticSegmentation" if use_instance_seg and not ( - use_rgb or use_albedo or use_semantic or use_instance_id_seg or use_normals or use_depth or use_hdr + use_rgb + or use_albedo + or use_semantic + or use_instance_id_seg + or use_normals + or use_depth + or use_hdr + or use_motion_vectors ): return ( "/Render/Vars/NonStableInstanceSegmentation", @@ -47,13 +63,30 @@ def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: "NonStableInstanceSegmentation", ) if use_instance_id_seg and not ( - use_rgb or use_albedo or use_semantic or use_instance_seg or use_normals or use_depth or use_hdr + use_rgb + or use_albedo + or use_semantic + or use_instance_seg + or use_normals + or use_depth + or use_hdr + or use_motion_vectors ): return "/Render/Vars/InstanceSegmentationSD", "InstanceSegmentationSD", "InstanceSegmentationSD" if use_normals and not ( - use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_depth + use_rgb + or use_albedo + or use_semantic + or use_instance_seg + or use_instance_id_seg + or use_depth + or use_motion_vectors ): return "/Render/Vars/NormalSD", "NormalSD", "NormalSD" + if use_motion_vectors and not ( + use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_depth or use_normals + ): + return "/Render/Vars/TargetMotionSD", "TargetMotionSD", "TargetMotionSD" if use_hdr and not use_rgb: return "/Render/Vars/HdrColor", "HdrColor", "HdrColor" return "/Render/Vars/LdrColor", "LdrColor", "LdrColor" diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 3520a78bea38..a968baebee36 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -88,10 +88,12 @@ def test_ovrtx_supported_output_types_key_set(): RenderBufferKind.DISTANCE_TO_IMAGE_PLANE, RenderBufferKind.DISTANCE_TO_CAMERA, RenderBufferKind.NORMALS, + RenderBufferKind.MOTION_VECTORS, } assert specs[RenderBufferKind.RGBA] == RenderBufferSpec(4, wp.uint8) assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) assert specs[RenderBufferKind.DEPTH] == RenderBufferSpec(1, wp.float32) + assert specs[RenderBufferKind.MOTION_VECTORS] == RenderBufferSpec(2, wp.float32) def test_ovrtx_set_outputs_wraps_caller_torch_zero_copy(): diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py index 27099d25cd7c..31b35350a370 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py @@ -158,6 +158,61 @@ def _reference_extract_all_rgb_float_tiles( return out +def _reference_extract_all_motion_vector_tiles( + tiled_np: np.ndarray, + num_envs: int, + num_cols: int, + tile_width: int, + tile_height: int, +) -> np.ndarray: + """NumPy reference for the motion-vector case of ``extract_all_tiles_kernel``.""" + out = np.zeros((num_envs, tile_height, tile_width, 2), dtype=np.float32) + for env_idx in range(num_envs): + tile_x = env_idx % num_cols + tile_y = env_idx // num_cols + for y in range(tile_height): + for x in range(tile_width): + src_y = tile_y * tile_height + y + src_x = tile_x * tile_width + x + out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] + out[env_idx, y, x, 1] = tiled_np[src_y, src_x, 1] + return out + + +class TestExtractAllMotionVectorTilesKernel: + """Tests for the motion-vector case of ``extract_all_tiles_kernel`` used by OVRTX TargetMotionSD.""" + + def test_two_by_two_tile_grid_drops_extra_channels(self): + """The kernel reads only the first two channels, even if the source buffer has more (e.g. 4).""" + num_cols = 2 + num_envs = 4 + tile_width = 2 + tile_height = 3 + tiled_h = (num_envs // num_cols) * tile_height + tiled_w = num_cols * tile_width + tiled_np = np.zeros((tiled_h, tiled_w, 4), dtype=np.float32) + for h in range(tiled_h): + for w in range(tiled_w): + tiled_np[h, w, 0] = float(h * 1000 + w) + tiled_np[h, w, 1] = float(h * 1000 + w + 100) + tiled_np[h, w, 2] = float(h * 1000 + w + 200) + tiled_np[h, w, 3] = float(h * 1000 + w + 300) + + tiled_wp = wp.array(tiled_np, dtype=wp.float32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 2), dtype=wp.float32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_motion_vector_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_allclose(output_wp.numpy(), expected, rtol=0, atol=0) + + class TestExtractAllDepthTilesKernel: """Tests for the depth case of ``extract_all_tiles_kernel``.""" diff --git a/source/isaaclab_ov/test/test_ovrtx_usd.py b/source/isaaclab_ov/test/test_ovrtx_usd.py index d5fb78c38d09..41e002997f39 100644 --- a/source/isaaclab_ov/test/test_ovrtx_usd.py +++ b/source/isaaclab_ov/test/test_ovrtx_usd.py @@ -99,6 +99,20 @@ def test_ovrtx_instance_id_segmentation_fast_uses_instance_segmentation_sd_rende ) +def test_ovrtx_motion_vectors_uses_target_motion_render_var(): + """Requesting motion vectors from OVRTX selects the TargetMotionSD render variable.""" + assert get_render_var_config(["motion_vectors"]) == ( + "/Render/Vars/TargetMotionSD", + "TargetMotionSD", + "TargetMotionSD", + ) + + +def test_ovrtx_motion_vectors_with_rgb_falls_back_to_rgb(): + """OVRTX only supports one main AOV at a time; combining motion vectors with RGB keeps RGB.""" + assert get_render_var_config(["rgb", "motion_vectors"]) == ("/Render/Vars/LdrColor", "LdrColor", "LdrColor") + + def test_ovrtx_rgb_and_rgb_hdr_author_both_render_vars(): """Requesting LDR RGB and RGB_HDR keeps both OVRTX render variables.""" render_var_configs = get_render_var_configs(["rgb", "rgb_hdr"]) diff --git a/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst b/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst new file mode 100644 index 000000000000..7aa0b56a6c39 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_enhanced_determinism` and + :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_external_forces_every_iteration`, mirroring the + equivalent PhysX solver settings already exposed on + :class:`~isaaclab_physx.physics.PhysxCfg`. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 458f38f9b541..626d693c10bd 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -714,15 +714,15 @@ def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: so we write the apiSchemas list entry and scene attributes directly via raw Sdf metadata manipulation instead of using the high-level USD API. - The schema and scene-query-support attribute are applied regardless of - device. The GPU-specific dynamics/broadphase/capacity attributes are + The schema, scene-query-support, and solver-determinism/accuracy attributes are applied + regardless of device. The GPU-specific dynamics/broadphase/capacity attributes are applied only when ``device == "gpu"`` — without them PhysX defaults to CPU broadphase even when OVPhysX is configured for GPU execution. Args: scene_prim: The /World/PhysicsScene prim to configure. - cfg: The :class:`OvPhysxCfg` carrying GPU buffer-capacity values. - Only consulted when ``device == "gpu"``. + cfg: The :class:`OvPhysxCfg` carrying solver-determinism flags and GPU buffer-capacity + values. The GPU buffer-capacity values are only consulted when ``device == "gpu"``. device: Resolved physics device — one of ``"cpu"`` or ``"gpu"``. """ from pxr import Sdf @@ -741,6 +741,14 @@ def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: enable_sq = getattr(sim_cfg, "enable_scene_query_support", False) scene_prim.CreateAttribute("physxScene:enableSceneQuerySupport", Sdf.ValueTypeNames.Bool).Set(enable_sq) + if cfg is not None: + scene_prim.CreateAttribute("physxScene:enableEnhancedDeterminism", Sdf.ValueTypeNames.Bool).Set( + cfg.enable_enhanced_determinism + ) + scene_prim.CreateAttribute("physxScene:enableExternalForcesEveryIteration", Sdf.ValueTypeNames.Bool).Set( + cfg.enable_external_forces_every_iteration + ) + if device == "gpu": scene_prim.CreateAttribute("physxScene:enableGPUDynamics", Sdf.ValueTypeNames.Bool).Set(True) scene_prim.CreateAttribute("physxScene:broadphaseType", Sdf.ValueTypeNames.String).Set("GPU") diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py index 96047e46363d..5de6e902e440 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py @@ -22,6 +22,21 @@ class OvPhysxCfg(PhysicsCfg): class_type = "{DIR}.ovphysx_manager:OvPhysxManager" + enable_enhanced_determinism: bool = False + """Enable/disable improved determinism at the expense of performance. Defaults to False. + + For more information on PhysX determinism, please check `here`_. + + .. _here: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/docs/RigidBodyDynamics.html#enhanced-determinism + """ + + enable_external_forces_every_iteration: bool = False + """Enable/disable external forces every position iteration in the TGS solver. Default is False. + + This can help improve the accuracy of velocity updates. Consider enabling this flag if the + velocities generated by the simulation are noisy. + """ + gpu_max_rigid_contact_count: int = 2**23 """Size of the GPU rigid-body contact buffer.""" diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst b/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst new file mode 100644 index 000000000000..58dc39c804eb --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst @@ -0,0 +1,12 @@ +Added +^^^^^ + +* Added ``motion_vectors`` to the rendering correctness test matrix for cartpole, shadow hand, and + dexsuite kuka allegro lift environments. + +Fixed +^^^^^ + +* Fixed flaky ``motion_vectors`` golden-image comparisons on PhysX backends (``physx`` and + ``ovphysx``) by enabling enhanced determinism and per-iteration external forces on the PhysX + solver, which otherwise produces run-to-run noisy velocities that this AOV encodes directly. diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..4de42c3eb817 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15a6be1c6d16f00eee85812248ae0751d74809a1941c46199fc9d70da846b0a3 +size 20631 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..65c7eeb235cf --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e72c9c3b8052b67b26cc8553dd097915860af12ba997083a28e746babd322a28 +size 20584 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..926df161aa15 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f79522cbf6d3e84c61dcde71f23984971a645ceefece920418e4d837e1d5d7de +size 8670 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..147efe8d21da --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7f2cfee1e6415ec92374319331ea496299471f37ea1ee0f66ffe9618e82b604 +size 8629 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..6100a39069bf --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c387e14db0c8662374d37709da3c657cb4b2488f4329f686c8d84b6e56d2b995 +size 28482 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index e5bce7ecebe7..bf25f3e723a2 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -60,12 +60,15 @@ # outputs where the per-pixel value distribution is highly non-uniform after normalisation (e.g. depth, where we # divide by the max value so tiny absolute differences near the far plane dominate windowed variance). For these # data types we still compute SSIM for reporting, but only the per-pixel L2 gate is used to decide pass/fail. +# ``motion_vectors`` is included for the same reason: per-pixel (u, v) offsets are normalized by their max +# magnitude, so small absolute differences in near-static regions can dominate windowed variance. _SSIM_DISABLED_DATA_TYPES: set[str] = { "depth", "distance_to_camera", "distance_to_image_plane", "instance_segmentation_fast", "instance_id_segmentation_fast", + "motion_vectors", } # Directory for comparison images saved during the test session. @@ -95,6 +98,7 @@ "normals", "instance_segmentation_fast", "instance_id_segmentation_fast", + "motion_vectors", ) @@ -239,6 +243,44 @@ def _redirect_ovrtx_renderer_log_to_stdout(env_cfg: Any) -> None: renderer_cfg.log_file_path = "CON" if os.name == "nt" else "/dev/stdout" +def _maybe_enable_physx_determinism_for_motion(env_cfg: Any, physics_backend: str, data_type: str) -> None: + """Trade PhysX solver performance for determinism/accuracy when testing ``motion_vectors``. + + PhysX's default TGS solver settings produce noisy per-step velocities (see the "TGS solver ... may + cause noisy velocities" warning logged by ``physx_manager``), and ``motion_vectors`` encodes velocity + directly. That noise differs run-to-run, making golden-image comparisons for this AOV flaky on CI. + Applies to both PhysX backends (``"physx"`` and ``"ovphysx"``, which share the same underlying PhysX + solver). No-op for any other ``(physics_backend, data_type)`` combination. + + Args: + env_cfg: The resolved environment config, exposing ``sim.physics`` as a + :class:`~isaaclab_physx.physics.PhysxCfg` when ``physics_backend == "physx"``, or an + :class:`~isaaclab_ovphysx.physics.OvPhysxCfg` when ``physics_backend == "ovphysx"``. + physics_backend: The physics backend under test (``"physx"``, ``"newton"``, or ``"ovphysx"``). + data_type: The camera data type under test. + """ + if physics_backend not in ("physx", "ovphysx") or data_type != "motion_vectors": + return + + env_cfg.sim.physics.enable_enhanced_determinism = True + env_cfg.sim.physics.enable_external_forces_every_iteration = True + + +def _skip_if_newton_motion_vectors(physics_backend: str, data_type: str) -> None: + """Skip ``motion_vectors`` golden-image tests running on the Newton physics backend. + + Newton is not yet deterministic enough for the ``motion_vectors`` AOV: per-pixel (u, v) offsets + encode sub-step body motion, which varies run-to-run under the Newton solver, so golden-image + comparison is unreliable. No-op for any other ``(physics_backend, data_type)`` combination. + + Args: + physics_backend: The physics backend under test (``"physx"``, ``"newton"``, or ``"ovphysx"``). + data_type: The camera data type under test. + """ + if physics_backend == "newton" and data_type == "motion_vectors": + pytest.skip("Newton physics is not deterministic enough for motion_vectors golden-image testing.") + + def _physics_preset_name(physics_backend: str) -> str: """Translate the historical ``"newton"`` backend label (still used by golden-image filenames and ``pytest.param`` IDs) to the renamed Hydra preset @@ -638,6 +680,30 @@ def _alpha_only(img: Image.Image) -> Image.Image: pytest.fail(reason) +def maybe_step_env_for_motion(env: Any, data_type: str, num_steps: int = 2, action_value: float = 0.0) -> None: + """Step ``env`` so motion-vector AOVs have real inter-frame motion to encode. + + Motion vectors compare the current frame's transforms against the previous frame's; the first frame + rendered after a reset has no render history, so its motion vectors would otherwise be all zero. No-op + for any ``data_type`` other than ``"motion_vectors"``. + + Args: + env: The environment to step. Must expose ``action_space`` and ``step`` (``DirectRLEnv`` / + ``ManagerBasedRLEnv``). + data_type: The camera data type under test. + num_steps: Number of steps to take before capturing camera output. + action_value: Constant value applied to every action component on every step. Defaults to + ``0.0`` (motion then comes only from physics already in flight, e.g. gravity/initial velocity). + Pass a non-zero value to additionally induce actuated motion (e.g. cartpole cart translation). + """ + if data_type != "motion_vectors": + return + + action = torch.full(env.action_space.shape, action_value, device=env.device) + for _ in range(num_steps): + env.step(action) + + def rendering_test_shadow_hand( physics_backend: str, renderer: str, @@ -646,6 +712,7 @@ def rendering_test_shadow_hand( ) -> None: if physics_backend == "ovphysx": pytest.skip("ovphysx is not supported yet.") + _skip_if_newton_motion_vectors(physics_backend, data_type) from isaaclab.utils.configclass import configclass @@ -663,6 +730,7 @@ class _ShadowHandTiledCameraTestCfg(ShadowHandTiledCameraCfg): normals = _ShadowHandBaseTiledCameraCfg(data_types=["normals"]) instance_segmentation_fast = _ShadowHandBaseTiledCameraCfg(data_types=["instance_segmentation_fast"]) instance_id_segmentation_fast = _ShadowHandBaseTiledCameraCfg(data_types=["instance_id_segmentation_fast"]) + motion_vectors = _ShadowHandBaseTiledCameraCfg(data_types=["motion_vectors"]) @configclass class _ShadowHandCameraTestEnvCfg(ShadowHandCameraEnvCfg): @@ -678,14 +746,18 @@ class _ShadowHandCameraTestEnvCfg(ShadowHandCameraEnvCfg): if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) - if data_type in {"depth", "distance_to_camera", "distance_to_image_plane"}: - # Disable CNN forward pass as it cannot be meaningfully trained from depth alone and will raise a ValueError. + _maybe_enable_physx_determinism_for_motion(env_cfg, physics_backend, data_type) + + if data_type in {"depth", "distance_to_camera", "distance_to_image_plane", "motion_vectors"}: + # Disable CNN forward pass as it cannot be meaningfully trained from depth/motion vectors alone + # and will raise a ValueError. env_cfg.feature_extractor.enabled = False env = None try: env = ShadowHandCameraEnv(env_cfg) + maybe_step_env_for_motion(env, data_type) maybe_save_stage("shadow_hand", physics_backend, renderer, data_type) validate_camera_outputs( @@ -711,6 +783,8 @@ def rendering_test_cartpole( data_type: str, comparison_scores: list[dict], ) -> None: + _skip_if_newton_motion_vectors(physics_backend, data_type) + from isaaclab.utils.configclass import configclass from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env import CartpoleCameraEnv @@ -731,6 +805,7 @@ class _CartpoleTiledCameraTestCfg(CartpoleTiledCameraCfg): instance_id_segmentation_fast = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg( data_types=["instance_id_segmentation_fast"] ) + motion_vectors = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg(data_types=["motion_vectors"]) @configclass class _BaseCartpoleCameraEnvTestCfg(CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg): @@ -756,6 +831,9 @@ class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): instance_id_segmentation_fast = _BaseCartpoleCameraEnvTestCfg( observation_space=[4, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() ) + motion_vectors = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( + observation_space=[2, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() + ) env_cfg = _CartpoleCameraTestEnvCfg() env_cfg = _apply_overrides_to_env_cfg( @@ -767,10 +845,15 @@ class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) + _maybe_enable_physx_determinism_for_motion(env_cfg, physics_backend, data_type) + env = None try: env = CartpoleCameraEnv(env_cfg) + # Nudge the cart with a small constant force so motion vectors also capture cart translation, + # not just pole dynamics already in flight from the randomized reset. + maybe_step_env_for_motion(env, data_type, action_value=0.5) camera_outputs = env._tiled_camera.data.output if renderer == "ovrtx_renderer": # The first output access creates the selected OVRTX render-variable mapping. Give @@ -812,6 +895,7 @@ def rendering_test_dexsuite_kuka( ) -> None: if physics_backend == "ovphysx": pytest.skip("ovphysx is not supported yet.") + _skip_if_newton_motion_vectors(physics_backend, data_type) from isaaclab.envs import ManagerBasedRLEnv from isaaclab.sensors import CameraCfg @@ -864,6 +948,9 @@ class _DexsuiteBaseTiledCameraTestCfg(BaseTiledCameraCfg): instance_id_segmentation_fast256 = BASE_CAMERA_CFG.replace( data_types=["instance_id_segmentation_fast"], width=256, height=256 ) + motion_vectors64 = BASE_CAMERA_CFG.replace(data_types=["motion_vectors"], width=64, height=64) + motion_vectors128 = BASE_CAMERA_CFG.replace(data_types=["motion_vectors"], width=128, height=128) + motion_vectors256 = BASE_CAMERA_CFG.replace(data_types=["motion_vectors"], width=256, height=256) @configclass class _DexsuiteSingleCameraTestSceneCfg(SingleCameraSceneCfg): @@ -892,6 +979,8 @@ class _DexsuiteKukaAllegroLiftCameraTestEnvCfg(DexsuiteKukaAllegroLiftCameraEnvC if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) + _maybe_enable_physx_determinism_for_motion(env_cfg, physics_backend, data_type) + # Disable the observation point-cloud visualisation markers (/Visuals/ObservationPointCloud). # The underlying point sampling uses the global numpy/torch RNG, so marker positions shift # across processes and show up as random red dots in the rendered camera output. Since this @@ -913,6 +1002,7 @@ class _DexsuiteKukaAllegroLiftCameraTestEnvCfg(DexsuiteKukaAllegroLiftCameraEnvC try: env = ManagerBasedRLEnv(env_cfg) + maybe_step_env_for_motion(env, data_type) maybe_save_stage(test_name, physics_backend, renderer, data_type) validate_camera_outputs( test_name, From 1ca4a605f40fa52a2e232a6237b5e567199645eb Mon Sep 17 00:00:00 2001 From: Richard Lei Date: Mon, 6 Jul 2026 14:51:51 +1200 Subject: [PATCH 12/23] Raise instead of swallowing OVRTXRenderer failures (#6321) # Description Several methods in OVRTXRenderer (scene/camera/object binding setup, scene partition writes, and render) caught broad exceptions and only logged a warning, letting simulation or training continue on a renderer failure that should have stopped it. Let these propagate so callers can decide how to handle the failure, matching the existing pattern already used by _clone_environments. Object binding setup for Newton now explicitly skips when no SimulationContext is active yet, rather than crashing on an assertion deep in NewtonManager. ## Type of change - Refactor to fail hard on real errors ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Richard Lei Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../changelog.d/fix-ovrtx-broad-except.rst | 7 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 278 +++++++++--------- 2 files changed, 140 insertions(+), 145 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst diff --git a/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst b/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst new file mode 100644 index 000000000000..d3958d45ecf4 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst @@ -0,0 +1,7 @@ +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. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index f802ba794500..cbbcb9a06157 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -369,64 +369,59 @@ def _initialize_from_spec(self, spec: CameraRenderSpec): raise RuntimeError(f"Expected camera prim under '{env_0_prefix}', got '{first_cam_path}'") self._camera_rel_path = spec.camera_path_relative_to_env_0 - if self._exported_usd_string is not None: - logger.info("Injecting camera definitions...") - - render_product_string, render_product_path = build_render_product_as_string( - width=width, - height=height, - num_envs=num_envs, - data_types=data_types, - minimal_mode=_resolve_rtx_minimal_mode(data_types), - camera_rel_path=self._camera_rel_path, - ) - self._render_product_paths.append(render_product_path) + logger.info("Injecting camera definitions...") + + if self._exported_usd_string is None: + raise RuntimeError("Expected an exported USD string from stage") + + render_product_string, render_product_path = build_render_product_as_string( + width=width, + height=height, + num_envs=num_envs, + data_types=data_types, + minimal_mode=_resolve_rtx_minimal_mode(data_types), + camera_rel_path=self._camera_rel_path, + ) + self._render_product_paths.append(render_product_path) - combined_usd_string = self._exported_usd_string + "\n\n" + render_product_string - self._exported_usd_string = None # Free memory + combined_usd_string = self._exported_usd_string + "\n\n" + render_product_string + self._exported_usd_string = None # Free memory - # If temp_usd_dir is set, write the combined USD stage to a temporary file. - if self.cfg.temp_usd_dir is not None: - _write_file(Path(self.cfg.temp_usd_dir), "ovrtx_renderer_stage.usda", combined_usd_string) + # If temp_usd_dir is set, write the combined USD stage to a temporary file. + if self.cfg.temp_usd_dir is not None: + _write_file(Path(self.cfg.temp_usd_dir), "ovrtx_renderer_stage.usda", combined_usd_string) - logger.info("Loading USD into OvRTX...") - try: - self._renderer.open_usd_from_string(combined_usd_string) - logger.info("OVRTX loaded USD from string successfully") - except Exception as e: - logger.exception("Error loading USD: %s", e) - raise + logger.info("Loading USD into OvRTX...") + self._renderer.open_usd_from_string(combined_usd_string) + logger.info("OVRTX loaded USD from string successfully") - if num_envs > 1: - self._clone_sources_in_ovrtx() - self._update_scene_partitions_after_clone(num_envs) + if num_envs > 1: + self._clone_sources_in_ovrtx() + self._update_scene_partitions_after_clone(num_envs) - self._initialized_scene = True + self._initialized_scene = True - camera_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] - self._camera_binding = self._renderer.bind_attribute( - prim_paths=camera_paths, - attribute_name="omni:xform", - semantic=Semantic.XFORM_MAT4x4, - prim_mode=PrimMode.EXISTING_ONLY, - ) + camera_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] + self._camera_binding = self._renderer.bind_attribute( + prim_paths=camera_paths, + attribute_name="omni:xform", + semantic=Semantic.XFORM_MAT4x4, + prim_mode=PrimMode.EXISTING_ONLY, + ) - # OVRTX requires omni:resetXformStack on cameras for correct world transform binding - try: - self._renderer.write_attribute( - prim_paths=camera_paths, - attribute_name="omni:resetXformStack", - tensor=np.full(num_envs, True, dtype=np.bool_), - ) - except Exception as e: - logger.warning("Failed to write omni:resetXformStack: %s", e) + # OVRTX requires omni:resetXformStack on cameras for correct world transform binding + self._renderer.write_attribute( + prim_paths=camera_paths, + attribute_name="omni:resetXformStack", + tensor=np.full(num_envs, True, dtype=np.bool_), + ) - if self._camera_binding is not None: - logger.info("Camera binding created successfully") - else: - logger.warning("Camera binding is None") + if self._camera_binding is not None: + logger.info("Camera binding created successfully") + else: + raise RuntimeError("Camera binding is None — cannot render without a valid camera binding") - self._setup_object_bindings() + self._setup_newton_object_bindings() def _clone_sources_in_ovrtx(self): """Clone sources in OVRTX using the scene :class:`~isaaclab.cloner.ClonePlan`.""" @@ -469,76 +464,72 @@ def _update_scene_partitions_after_clone(self, num_envs: int): env_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] camera_prim_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] - try: - self._renderer.write_attribute( - env_prim_paths, - "primvars:omni:scenePartition", - partition_tokens, - semantic=Semantic.TOKEN_STRING, - ) - logger.info("Written primvars:omni:scenePartition to %d environments", num_envs) + self._renderer.write_attribute( + env_prim_paths, + "primvars:omni:scenePartition", + partition_tokens, + semantic=Semantic.TOKEN_STRING, + ) + logger.info("Written primvars:omni:scenePartition to %d environments", num_envs) - self._renderer.write_attribute( - camera_prim_paths, - "omni:scenePartition", - partition_tokens, - semantic=Semantic.TOKEN_STRING, - ) - logger.info("Written omni:scenePartition to %d cameras", num_envs) - except Exception as e: - logger.warning("Failed to write scene partitions: %s", e, exc_info=True) + self._renderer.write_attribute( + camera_prim_paths, + "omni:scenePartition", + partition_tokens, + semantic=Semantic.TOKEN_STRING, + ) + logger.info("Written omni:scenePartition to %d cameras", num_envs) - def _setup_object_bindings(self): + def _setup_newton_object_bindings(self): """Setup OVRTX bindings for scene objects to sync with Newton physics.""" try: from isaaclab_newton.physics import NewtonManager + except ImportError: + logger.debug("isaaclab_newton physics not available, skipping object bindings") + return - newton_model = NewtonManager.get_model() - if newton_model is None: - logger.info("Newton model not available, skipping object bindings") - return + if SimulationContext.instance() is None: + logger.info("No active simulation context, will not set up ovrtx object bindings for newton") + return - all_body_paths = getattr(newton_model, "body_label", None) - if all_body_paths is None: - logger.info("Newton model has no body_label, skipping object bindings") - return + newton_model = NewtonManager.get_model() + if newton_model is None: + logger.debug("Newton model not available, skipping object bindings") + return - object_paths = [] - newton_indices = [] - for idx, path in enumerate(all_body_paths): - if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: - object_paths.append(path) - newton_indices.append(idx) + all_body_paths = getattr(newton_model, "body_label", None) + if all_body_paths is None: + logger.info("Newton model has no body_label, skipping object bindings") + return - if len(object_paths) == 0: - logger.info("No dynamic objects found for binding") - return + object_paths = [] + newton_indices = [] + for idx, path in enumerate(all_body_paths): + if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: + object_paths.append(path) + newton_indices.append(idx) - self._object_binding = self._renderer.bind_attribute( - prim_paths=object_paths, - attribute_name="omni:xform", - semantic=Semantic.XFORM_MAT4x4, - prim_mode=PrimMode.EXISTING_ONLY, - ) + if len(object_paths) == 0: + logger.info("No dynamic objects found for binding") + return - try: - self._renderer.write_attribute( - prim_paths=object_paths, - attribute_name="omni:resetXformStack", - tensor=np.full(len(object_paths), True, dtype=np.bool_), - ) - except Exception as e: - logger.warning("Failed to write omni:resetXformStack on objects: %s", e) + self._object_binding = self._renderer.bind_attribute( + prim_paths=object_paths, + attribute_name="omni:xform", + semantic=Semantic.XFORM_MAT4x4, + prim_mode=PrimMode.EXISTING_ONLY, + ) - if self._object_binding is not None: - logger.info("Object binding created successfully") - self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) - else: - logger.warning("Object binding is None") - except ImportError: - logger.info("Newton not available, skipping object bindings") - except Exception as e: - logger.warning("Error setting up object bindings: %s", e) + self._renderer.write_attribute( + prim_paths=object_paths, + attribute_name="omni:resetXformStack", + tensor=np.full(len(object_paths), True, dtype=np.bool_), + ) + + if self._object_binding is None: + raise RuntimeError("Failed to create OVRTX object bindings") + + self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) def create_render_data(self, spec: CameraRenderSpec) -> OVRTXRenderData: """Create OVRTX-specific RenderData with GPU buffers. @@ -587,26 +578,26 @@ def update_transforms(self) -> None: if self._object_binding is None or self._object_newton_indices is None: return - try: - from isaaclab_newton.physics import NewtonManager + # If self._object_newton_indices is not None, then Newton's the current physics backend - newton_state = NewtonManager.get_state() - if newton_state is None: - return - body_q = getattr(newton_state, "body_q", None) - if body_q is None: - return + from isaaclab_newton.physics import NewtonManager - with self._object_binding.map(device=Device.CUDA, device_id=self._device_id) as attr_mapping: - ovrtx_transforms = wp.from_dlpack(attr_mapping.tensor, dtype=wp.mat44d) - wp.launch( - kernel=sync_newton_transforms_kernel, - dim=len(self._object_newton_indices), - inputs=[ovrtx_transforms, self._object_newton_indices, body_q], - device=self._device, - ) - except Exception as e: - logger.warning("Failed to update object transforms: %s", e) + newton_state = NewtonManager.get_state() + if newton_state is None: + raise RuntimeError("Newton state should not be None") + + body_q = getattr(newton_state, "body_q", None) + if body_q is None: + return + + with self._object_binding.map(device=Device.CUDA, device_id=self._device_id) as attr_mapping: + ovrtx_transforms = wp.from_dlpack(attr_mapping.tensor, dtype=wp.mat44d) + wp.launch( + kernel=sync_newton_transforms_kernel, + dim=len(self._object_newton_indices), + inputs=[ovrtx_transforms, self._object_newton_indices, body_q], + device=self._device, + ) def update_camera( self, @@ -909,28 +900,25 @@ def render(self, render_data: OVRTXRenderData) -> None: raise RuntimeError("Scene not initialized. Call initialize() first.") if self._renderer is None or len(self._render_product_paths) == 0: return - try: - products = self._renderer.step( - render_products=set(self._render_product_paths), - delta_time=1.0 / 60.0, + products = self._renderer.step( + render_products=set(self._render_product_paths), + delta_time=1.0 / 60.0, + ) + product_path = self._render_product_paths[0] + if product_path in products and len(products[product_path].frames) > 0: + self._process_render_frame( + render_data, + products[product_path].frames[0], + render_data.warp_buffers, ) - product_path = self._render_product_paths[0] - if product_path in products and len(products[product_path].frames) > 0: - self._process_render_frame( - render_data, - products[product_path].frames[0], - render_data.warp_buffers, - ) - # Post-render PPISP: HDR scene-linear → LDR RGBA. Source/destination - # buffers are the same warp buffer map used by extraction. - if render_data.ppisp_pipeline is not None: - render_data.ppisp_pipeline.apply( - render_data.warp_buffers[str(RenderBufferKind.RGB_HDR)], - render_data.warp_buffers[str(RenderBufferKind.RGBA)], - ) - except Exception as e: - logger.warning("OVRTX rendering failed: %s", e, exc_info=True) + # Post-render PPISP: HDR scene-linear → LDR RGBA. Source/destination + # buffers are the same warp buffer map used by extraction. + if render_data.ppisp_pipeline is not None: + render_data.ppisp_pipeline.apply( + render_data.warp_buffers[str(RenderBufferKind.RGB_HDR)], + render_data.warp_buffers[str(RenderBufferKind.RGBA)], + ) def cleanup(self, render_data: OVRTXRenderData | None) -> None: """Release renderer resources. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.cleanup`.""" From b2dd3cc37efa7c3fa346f3485fa789598e2dac75 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:34:33 +0000 Subject: [PATCH 13/23] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 8.1.1 → 8.1.2 - isaaclab_ov: 0.5.4 → 0.6.0 - isaaclab_ovphysx: 6.0.0 → 6.1.0 - isaaclab_tasks: 8.1.4 → 8.1.5 --- .../changelog.d/ovrtx-motion-vectors.rst | 8 -------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 13 +++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/fix-ovrtx-broad-except.rst | 7 ------- .../changelog.d/ovrtx-motion-vectors.minor.rst | 4 ---- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 17 +++++++++++++++++ source/isaaclab_ov/pyproject.toml | 2 +- .../changelog.d/ovrtx-motion-vectors.minor.rst | 7 ------- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 12 ++++++++++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- .../changelog.d/ovrtx-motion-vectors.rst | 12 ------------ source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 17 +++++++++++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 17 files changed, 67 insertions(+), 46 deletions(-) delete mode 100644 source/isaaclab/changelog.d/ovrtx-motion-vectors.rst delete mode 100644 source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst delete mode 100644 source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst diff --git a/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst b/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst deleted file mode 100644 index b71fc27c48bf..000000000000 --- a/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst +++ /dev/null @@ -1,8 +0,0 @@ -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. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 965ca56e7589..3207fd89b945 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "8.1.1" +version = "8.1.2" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index ee3515ac5628..3883905e87ba 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,19 @@ Changelog --------- +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) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index c5696f211009..66e349f1c517 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.1.1" +version = "8.1.2" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst b/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst deleted file mode 100644 index d3958d45ecf4..000000000000 --- a/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst +++ /dev/null @@ -1,7 +0,0 @@ -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. diff --git a/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst b/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst deleted file mode 100644 index 44e9084afca9..000000000000 --- a/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added ``motion_vectors`` data type support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 316e8a798f91..b097ac973a16 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.5.4" +version = "0.6.0" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index a288ea2cd356..fa12b2d081d9 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +0.6.0 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``motion_vectors`` data type support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. + +Fixed +^^^^^ + +* Removed overly broad ``except Exception`` handling in :class:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer` + that downgraded failures in scene initialization, camera and object binding setup, scene partition writes, + Newton transform syncing, and :meth:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer.render` to log + warnings and silently continue. These now propagate so callers can decide how to handle the failure. + + 0.5.4 (2026-07-03) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index ecbefa31d229..0c250169eb89 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "0.5.4" +version = "0.6.0" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst b/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst deleted file mode 100644 index 7aa0b56a6c39..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_enhanced_determinism` and - :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_external_forces_every_iteration`, mirroring the - equivalent PhysX solver settings already exposed on - :class:`~isaaclab_physx.physics.PhysxCfg`. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 9c879827eb19..c25e8b2f01ae 100644 --- a/source/isaaclab_ovphysx/config/extension.toml +++ b/source/isaaclab_ovphysx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "6.0.0" +version = "6.1.0" # Description title = "OvPhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_ovphysx/docs/CHANGELOG.rst b/source/isaaclab_ovphysx/docs/CHANGELOG.rst index aed24188cee8..96425f9de745 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +6.1.0 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_enhanced_determinism` and + :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_external_forces_every_iteration`, mirroring the + equivalent PhysX solver settings already exposed on + :class:`~isaaclab_physx.physics.PhysxCfg`. + + 6.0.0 (2026-07-03) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 3215f5c0dd83..aa89c97b0548 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "6.0.0" +version = "6.1.0" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst b/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst deleted file mode 100644 index 58dc39c804eb..000000000000 --- a/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst +++ /dev/null @@ -1,12 +0,0 @@ -Added -^^^^^ - -* Added ``motion_vectors`` to the rendering correctness test matrix for cartpole, shadow hand, and - dexsuite kuka allegro lift environments. - -Fixed -^^^^^ - -* Fixed flaky ``motion_vectors`` golden-image comparisons on PhysX backends (``physx`` and - ``ovphysx``) by enabling enhanced determinism and per-iteration external forces on the PhysX - solver, which otherwise produces run-to-run noisy velocities that this AOV encodes directly. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index be7ef714b7f8..026ae3a7c211 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "8.1.4" +version = "8.1.5" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 7228be56533f..8750611e4ab8 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +8.1.5 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``motion_vectors`` to the rendering correctness test matrix for cartpole, shadow hand, and + dexsuite kuka allegro lift environments. + +Fixed +^^^^^ + +* Fixed flaky ``motion_vectors`` golden-image comparisons on PhysX backends (``physx`` and + ``ovphysx``) by enabling enhanced determinism and per-iteration external forces on the PhysX + solver, which otherwise produces run-to-run noisy velocities that this AOV encodes directly. + + 8.1.4 (2026-07-04) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index e328a713bf07..96051ad002e8 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.4" +version = "8.1.5" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From f3c00080d95848d941c36a094a4f39147fe3ae0e Mon Sep 17 00:00:00 2001 From: ooctipus Date: Mon, 6 Jul 2026 00:14:54 -0700 Subject: [PATCH 14/23] Run OV test jobs on fork PRs without the OVPhysX wheelhouse (#6348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Since #6314 landed, `test-isaaclab-ov` and `test-rendering-correctness-kitless` are silently skipped on every fork PR: the job-level `if:` conditions require a same-repository PR (or `develop`) whenever `ovphysx_wheelhouse_resource` is configured — which it now always is. Fork contributors and team members working from forks have had zero OVRTX / kitless rendering CI coverage since then, with no visible signal (the jobs just show "skipped"). The NGC trust boundary is correct (the wheelhouse download needs `NGC_API_KEY`, which fork PRs cannot access), and this PR keeps it intact — the `USE_OVPHYSX_WHEELHOUSE` env var from #6314 already resolves trust per run, and the NGC download step is gated on `wheelhouse-resource != ''`, so it never executes without credentials. This PR's own CI (it is a fork PR, and PR runs use the merge-commit workflow, so it exercises its own fix) established exactly which coverage is recoverable on the public pip stack: - **All `isaaclab_ov` (ovrtx) tests pass** with pip-index `ovrtx` + `ovphysx` — 0 failures. - **All 562 `isaaclab_ovphysx` test failures are one error**: `AttributeError: type object 'PhysX' has no attribute 'set_cpu_mode'` — `ovphysx_manager` now requires ovphysx ≥ 0.5.1, which only exists in the NGC wheelhouse; the newest public wheel is 0.4.13. ovrtx does not depend on ovphysx, so the fallback separates them at the finest granularity each job allows: - `test-isaaclab-ov` **runs on fork PRs** via the pip fallback with `exclude-pattern: isaaclab_ovphysx` (file-level — the two packages live in separate test trees; the shared `filter-pattern: "isaaclab_ov"` only bundles them by prefix match). - `test-rendering-correctness-kitless` **runs on fork PRs** with a new `test-k-expr` input that reaches the per-file pytest subprocesses spawned by `tools/conftest.py` (param-level: `not ovphysx` — the golden-image files parametrize physics backends inside each file, so fork runs keep the `newton + ovrtx` combinations and deselect only the ovphysx-backed params). - Trusted runs (same-repo PRs to `develop`, post-merge `develop`) are byte-for-byte unchanged. Once ovphysx ≥ 0.5.1 is published to the public pip index, the exclude and the `-k` deselect can both be dropped and fork PRs regain full parity. cc @AntoineRichard — #6314's description says "Do not merge; this PR exists only for CI validation", so flagging in case the fork-PR skip wasn't meant to reach `develop` in this form at all. Fixes the coverage gap observed on #6308, where the OVRTX-variant jobs skip on every attempt. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .github/actions/run-package-tests/action.yml | 7 ++++++ .github/actions/run-tests/action.yml | 23 +++++++++++++++++++- .github/workflows/build.yaml | 19 ++++++++-------- tools/conftest.py | 5 +++++ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml index a88ec78c9046..9ee6dcddd9b8 100644 --- a/.github/actions/run-package-tests/action.yml +++ b/.github/actions/run-package-tests/action.yml @@ -41,6 +41,12 @@ inputs: skipped. Combines with filter-pattern (include + exclude). default: '' required: false + test-k-expr: + description: >- + Global pytest -k expression applied inside every per-file pytest run + spawned by tools/conftest.py (combined with device-split selectors). + default: '' + required: false shard-index: description: 'Zero-based shard index' default: '' @@ -286,6 +292,7 @@ runs: pytest-options: ${{ inputs.pytest-options }} filter-pattern: ${{ inputs.filter-pattern }} exclude-pattern: ${{ inputs.exclude-pattern }} + test-k-expr: ${{ inputs.test-k-expr }} shard-index: ${{ inputs.shard-index }} shard-count: ${{ inputs.shard-count }} curobo-only: ${{ inputs.curobo-only }} diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index c4a6859b6399..fbc7043cdf8f 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -41,6 +41,14 @@ inputs: excluded. Combines with filter-pattern (include + exclude). default: '' required: false + test-k-expr: + description: >- + Global pytest -k expression applied inside every per-file pytest run + spawned by tools/conftest.py (combined with device-split selectors). + Unlike pytest-options, this reaches the individual test processes, so it + can deselect parametrized cases (e.g. "not ovphysx"). + default: '' + required: false curobo-only: description: 'Run only cuRobo and SkillGen tests (requires the cuRobo Docker image)' default: 'false' @@ -94,6 +102,12 @@ runs: steps: - name: Run Tests in Docker Container shell: bash + env: + # Passed via env instead of inline ${{ }} interpolation: pytest options may + # contain spaces and quotes (e.g. -k "not ovphysx"), which would word-split + # the run_tests positional arguments if substituted textually. + PYTEST_OPTIONS: ${{ inputs.pytest-options }} + TEST_K_EXPR_INPUT: ${{ inputs.test-k-expr }} run: | # Function to run tests in Docker container run_tests() { @@ -116,6 +130,7 @@ runs: local test_node_ids_key="${17}" local wheelhouse_host_dir="${18}" local wheelhouse_packages="${19}" + local test_k_expr="${20}" local logs_pid="" local wait_pid="" local docker_wait_file="/tmp/.docker_exit_${container_name}" @@ -258,6 +273,12 @@ runs: docker_env_vars="$docker_env_vars -e TEST_EXTRA_PIP_PACKAGES" fi + if [ -n "$test_k_expr" ]; then + export TEST_K_EXPR="$test_k_expr" + docker_env_vars="$docker_env_vars -e TEST_K_EXPR" + echo "Setting per-file pytest -k expression: $test_k_expr" + fi + # Volume mount for deps-cache-hit mode: bind-mount the checked-out # source code over /workspace/isaaclab instead of baking it into the image. docker_volume_args="" @@ -491,7 +512,7 @@ runs: } # Call the function with provided parameters - run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "${{ inputs.pytest-options }}" "${{ inputs.filter-pattern }}" "${{ inputs.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" "${{ inputs.test-node-ids-file }}" "${{ inputs.test-node-ids-key }}" "${{ inputs.wheelhouse-host-dir }}" "${{ inputs.wheelhouse-packages }}" + run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "$PYTEST_OPTIONS" "${{ inputs.filter-pattern }}" "${{ inputs.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" "${{ inputs.test-node-ids-file }}" "${{ inputs.test-node-ids-key }}" "${{ inputs.wheelhouse-host-dir }}" "${{ inputs.wheelhouse-packages }}" "$TEST_K_EXPR_INPUT" - name: Kill container on cancellation if: cancelled() diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1bef75f6b010..39f9ffab1b2c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -596,10 +596,7 @@ jobs: needs: [build, config] if: >- github.event_name != 'push' && - needs.build.result == 'success' && - (needs.config.outputs.ovphysx_wheelhouse_resource == '' || - (github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name != 'pull_request' && github.ref_name == 'develop')) + needs.build.result == 'success' env: USE_OVPHYSX_WHEELHOUSE: ${{ needs.config.outputs.ovphysx_wheelhouse_resource != '' && ((github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref_name == 'develop')) }} steps: @@ -613,6 +610,10 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_ov" + # isaaclab_ovphysx requires ovphysx >= 0.5.1, which is only available from the + # NGC wheelhouse; untrusted runs (fork PRs) fall back to the public pip index + # and keep only the ovrtx-based isaaclab_ov coverage. + exclude-pattern: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && '' || 'isaaclab_ovphysx' }} extra-pip-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovrtx' || 'ovrtx ovphysx' }} wheelhouse-resource: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && needs.config.outputs.ovphysx_wheelhouse_resource || '' }} wheelhouse-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovphysx' || '' }} @@ -777,11 +778,7 @@ jobs: timeout-minutes: 120 continue-on-error: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} needs: [build, config] - if: >- - needs.build.result == 'success' && - (needs.config.outputs.ovphysx_wheelhouse_resource == '' || - (github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name != 'pull_request' && github.ref_name == 'develop')) + if: needs.build.result == 'success' env: USE_OVPHYSX_WHEELHOUSE: ${{ needs.config.outputs.ovphysx_wheelhouse_resource != '' && ((github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref_name == 'develop')) }} steps: @@ -795,6 +792,10 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" + # ovphysx-backed test params require ovphysx >= 0.5.1, which is only available + # from the NGC wheelhouse; untrusted runs (fork PRs) fall back to the public pip + # index and keep the newton/ovrtx kitless rendering coverage. + test-k-expr: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && '' || 'not ovphysx' }} extra-pip-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovrtx' || 'ovrtx ovphysx' }} wheelhouse-resource: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && needs.config.outputs.ovphysx_wheelhouse_resource || '' }} wheelhouse-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovphysx' || '' }} diff --git a/tools/conftest.py b/tools/conftest.py index a34812ddccab..963853194f3d 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -738,6 +738,9 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci, test_node_ids_ xml_reports = [] cold_cache_applied = False test_node_ids_by_file = test_node_ids_by_file or {} + global_k_expr = os.environ.get("TEST_K_EXPR", "").strip() or None + if global_k_expr is not None: + print(f"Applying global pytest -k expression to every test file: '{global_k_expr}'") for test_file in test_files: print(f"\n\n🚀 Running {test_file} independently...\n") @@ -786,6 +789,8 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci, test_node_ids_ merged_status: dict | None = None for suffix, k_expr in passes: + if global_k_expr is not None: + k_expr = f"({k_expr}) and ({global_k_expr})" if k_expr else global_k_expr report, status, was_failure = _run_one_pass(ctx, k_expr=k_expr, suffix=suffix) if report is not None: xml_reports.append(report) From b8ed8a9bf81484d8694a4cb08a28513ba89cd4a2 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Mon, 6 Jul 2026 10:49:50 +0200 Subject: [PATCH 15/23] Add backend-agnostic benchmark core (benchmark refactor, Part 1/5) (#6197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description **Part 1 of 5** of a series that splits the (oversized) benchmark refactor into reviewable, independently-mergeable PRs: - **Part 1/5 — this PR:** backend-agnostic benchmark *core* under `isaaclab.test.benchmark`. - **Part 2/5 (#6198):** unified `runtime.py` + `startup.py` entry scripts (depends on this PR). - **Part 3/5 (#6199):** unified `training.py` dispatcher + `rsl_rl`/`rl_games`/`skrl`/`sb3` adapters (depends on Parts 1–2). - **Part 4/5 (#6201):** play/inference benchmark. - **Part 5/5:** removal of the legacy `benchmark_*` / `run_*.sh` / `utils.py` scripts. Parts 1–4 are **purely additive**: they add the new suite alongside the existing scripts, which keep working unchanged. The legacy scripts are removed only in **Part 5/5**, so downstream consumers (OmniPerf ingestion, job runners) can migrate at their own pace. This PR adds the reusable core the entry scripts build on: - New submodules under `isaaclab.test.benchmark`: - `capture` — versions / hardware / resources / run-id capture from the benchmark recorders. - `metrics` — TensorBoard log parsing, convergence detection, EMA, mean/std/peak, success-rate tracking. - `builders` — assemble the schema-v1 `RuntimeBundle` / `TrainingBundle` / `StartupBundle`. - `stepping` — backend-agnostic random-action stepping loop. - `profiling` — `cProfile` stats parsing (own/cumulative time, call counts). - `backend_descriptor` — per-RL-library TensorBoard tag descriptors. - A new `schema` output backend that serializes a benchmark bundle through the existing `BaseIsaacLabBenchmark` metrics-backend system, plus multi-backend support (e.g. `--benchmark_backend schema,omniperf`) via a new `attach_bundle` hook. Single-backend behavior and filenames are unchanged, so existing benchmarks are unaffected. Note: the shared GPU/memory recorders gain additive peak rows (`GPU [i] Memory Used peak`, `System Memory RSS/VMS/USS peak`); every pre-existing KPI row is unchanged. This is the only change visible in the legacy scripts' OmniPerf/JSON output. Builds on the v1.0 benchmark schema merged in #5840. No entry scripts or docs change here — those land with Parts 2–3. Fixes # (n/a) ## Type of change - New feature (non-breaking change which adds functionality) ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation (user-facing benchmark docs land with the entry scripts in Parts 2–3) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/testing/benchmarks.rst | 86 +-- .../antoiner-benchmark-core.major.rst | 44 ++ source/isaaclab/isaaclab/app/sim_launcher.py | 7 +- .../isaaclab/test/benchmark/benchmark_core.py | 189 +++++- .../isaaclab/test/benchmark/builders.py | 303 ++++++++++ .../isaaclab/test/benchmark/capture.py | 449 +++++++++++++++ .../benchmark/{backends.py => formatters.py} | 150 +++-- .../isaaclab/test/benchmark/measurements.py | 2 +- .../isaaclab/test/benchmark/metrics.py | 278 +++++++++ .../isaaclab/test/benchmark/profiling.py | 128 +++++ .../recorders/record_version_info.py | 60 +- .../isaaclab/test/benchmark/schema.py | 16 +- .../isaaclab/test/benchmark/stepping.py | 85 +++ source/isaaclab/test/app/test_kwarg_launch.py | 37 ++ .../test/benchmark/test_benchmark_core.py | 536 ++++++++---------- .../isaaclab/test/benchmark/test_builders.py | 182 ++++++ .../isaaclab/test/benchmark/test_capture.py | 312 ++++++++++ .../test/benchmark/test_formatters.py | 143 +++++ .../isaaclab/test/benchmark/test_metrics.py | 178 ++++++ .../isaaclab/test/benchmark/test_profiling.py | 67 +++ .../isaaclab/test/benchmark/test_recorders.py | 140 ++++- .../isaaclab/test/benchmark/test_stepping.py | 75 +++ 22 files changed, 3043 insertions(+), 424 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst create mode 100644 source/isaaclab/isaaclab/test/benchmark/builders.py create mode 100644 source/isaaclab/isaaclab/test/benchmark/capture.py rename source/isaaclab/isaaclab/test/benchmark/{backends.py => formatters.py} (81%) create mode 100644 source/isaaclab/isaaclab/test/benchmark/metrics.py create mode 100644 source/isaaclab/isaaclab/test/benchmark/profiling.py create mode 100644 source/isaaclab/isaaclab/test/benchmark/stepping.py create mode 100644 source/isaaclab/test/benchmark/test_builders.py create mode 100644 source/isaaclab/test/benchmark/test_capture.py create mode 100644 source/isaaclab/test/benchmark/test_formatters.py create mode 100644 source/isaaclab/test/benchmark/test_metrics.py create mode 100644 source/isaaclab/test/benchmark/test_profiling.py create mode 100644 source/isaaclab/test/benchmark/test_stepping.py diff --git a/docs/source/testing/benchmarks.rst b/docs/source/testing/benchmarks.rst index f90ced1a78ca..4e40faeaef61 100644 --- a/docs/source/testing/benchmarks.rst +++ b/docs/source/testing/benchmarks.rst @@ -6,7 +6,7 @@ Benchmarking Framework Isaac Lab provides a comprehensive benchmarking framework for measuring the performance of simulations, training workflows, and system resources. The framework is designed to work without depending on Isaac Sim's benchmark services, enabling standalone benchmarking -with pluggable output backends. +with pluggable output formatters. Overview -------- @@ -23,9 +23,9 @@ The benchmarking framework consists of several key components: ┌───────────┼───────────┐ │ │ │ ▼ ▼ ▼ - ┌───────┐ ┌─────────┐ ┌──────────┐ - │Phases │ │Recorders│ │ Backends │ - └───────┘ └─────────┘ └──────────┘ + ┌───────┐ ┌─────────┐ ┌────────────┐ + │Phases │ │Recorders│ │ Formatters │ + └───────┘ └─────────┘ └────────────┘ **Key Components:** @@ -34,7 +34,7 @@ The benchmarking framework consists of several key components: - **Metadata**: Data classes for recording context (hardware, versions, parameters) - **TestPhase**: Container for organizing measurements into logical groups - **Recorders**: System information collectors (CPU, GPU, memory, versions) -- **Backends**: Output formatters (JSON, Osmo, OmniPerf, Summary) +- **Formatters**: Output formatters (JSON, Osmo, OmniPerf, Summary, Schema) .. seealso:: @@ -59,7 +59,7 @@ Basic usage with :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`: # Initialize benchmark benchmark = BaseIsaacLabBenchmark( benchmark_name="MyBenchmark", - backend_type="json", + formatter_type="json", output_path="./results", ) @@ -113,7 +113,7 @@ Measure environment stepping performance without training: --task Isaac-Cartpole \ --num_envs 4096 \ --num_frames 100 \ - --benchmark_backend json \ + --benchmark_formatter json \ --output_path ./results RL Training Benchmarks @@ -131,7 +131,7 @@ Measure training performance with RSL-RL: --task Isaac-Cartpole \ --num_envs 4096 \ --max_iterations 500 \ - --benchmark_backend json \ + --benchmark_formatter json \ --output_path ./results PhysX Micro-Benchmarks @@ -166,7 +166,7 @@ understanding where time is spent during initialization. ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ --task Isaac-Ant \ --num_envs 4096 \ - --benchmark_backend summary + --benchmark_formatter summary The script profiles five phases independently: @@ -204,7 +204,7 @@ Patterns use ``fnmatch`` syntax (``*`` and ``?`` wildcards): ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ --task Isaac-Ant \ --num_envs 4096 \ - --benchmark_backend omniperf \ + --benchmark_formatter omniperf \ --whitelist_config scripts/benchmarks/startup_whitelist.yaml Phases listed in the YAML use the whitelist; phases not listed fall back to @@ -233,9 +233,9 @@ A default whitelist is provided at ``scripts/benchmarks/startup_whitelist.yaml`` * - ``--whitelist_config`` - None - Path to YAML whitelist file - * - ``--benchmark_backend`` + * - ``--benchmark_formatter`` - ``omniperf`` - - Output backend (``json``, ``osmo``, ``omniperf``, ``summary``) + - Output formatter (``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema``) * - ``--output_path`` - ``.`` - Directory for output files @@ -253,9 +253,9 @@ Common Arguments * - Argument - Default - Description - * - ``--benchmark_backend`` + * - ``--benchmark_formatter`` - ``json`` - - Output backend: ``json``, ``osmo``, ``omniperf``, or ``summary`` + - Output formatter: ``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema`` * - ``--output_path`` - ``./`` - Directory for output files @@ -404,17 +404,17 @@ Example: benchmark.add_measurement("simulation", metadata=task_metadata) benchmark.add_measurement("training", measurement=reward_measurement) -Output Backends ---------------- +Output Formatters +----------------- -JSON Backend -~~~~~~~~~~~~ +JSON Formatter +~~~~~~~~~~~~~~ Full output with all phases, measurements, and metadata: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend json --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter json --output_path ./results Output structure: @@ -437,14 +437,14 @@ Output structure: } ] -Osmo Backend -~~~~~~~~~~~~ +Osmo Formatter +~~~~~~~~~~~~~~ Simplified key-value format for CI/CD integration: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend osmo --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter osmo --output_path ./results Output structure: @@ -457,14 +457,14 @@ Output structure: "task": "Isaac-Cartpole" } -OmniPerf Backend -~~~~~~~~~~~~~~~~ +OmniPerf Formatter +~~~~~~~~~~~~~~~~~~ Format for database upload and performance tracking: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend omniperf --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter omniperf --output_path ./results Output structure: @@ -479,8 +479,20 @@ Output structure: } } -Summary Backend -~~~~~~~~~~~~~~~ +Schema Formatter +~~~~~~~~~~~~~~~~ + +Writes a schema-v1 bundle attached with +:meth:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark.attach_bundle`. Use it +with a ``RuntimeBundle``, ``TrainingBundle``, or ``StartupBundle`` when a +typed, stable output contract is required. + +.. code-block:: bash + + ./isaaclab.sh -p ... --benchmark_formatter schema --output_path ./results + +Summary Formatter +~~~~~~~~~~~~~~~~~ Human-readable console report plus JSON file. Prints a formatted summary to the terminal while also writing the same data as JSON. Standard phases (runtime, @@ -491,7 +503,7 @@ entries. Use when you want a quick readout without opening the JSON: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend summary --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter summary --output_path ./results When ``summary`` is selected, frametime recorders are enabled automatically when running with Isaac Sim (Kit). @@ -508,7 +520,7 @@ monitoring during blocking operations like RL training loops: benchmark = BaseIsaacLabBenchmark( benchmark_name="TrainingBenchmark", - backend_type="json", + formatter_type="json", output_path="./results", ) @@ -564,13 +576,13 @@ Step 1: Initialize Benchmark from isaaclab.test.benchmark import BaseIsaacLabBenchmark parser = argparse.ArgumentParser() - parser.add_argument("--benchmark_backend", default="json") + parser.add_argument("--benchmark_formatter", default="json") parser.add_argument("--output_path", default="./") args = parser.parse_args() benchmark = BaseIsaacLabBenchmark( benchmark_name="CustomBenchmark", - backend_type=args.benchmark_backend, + formatter_type=args.benchmark_formatter, output_path=args.output_path, ) @@ -635,7 +647,7 @@ The shell scripts in ``scripts/benchmarks/`` are designed for CI/CD integration: name: benchmark-results path: ./benchmark_results/ -For Osmo integration, use the ``osmo`` backend: +For Osmo integration, use the ``osmo`` formatter: .. code-block:: bash @@ -682,15 +694,15 @@ Ensure ``_finalize_impl()`` is called before the script exits: finally: benchmark._finalize_impl() -Backend Not Recognized -~~~~~~~~~~~~~~~~~~~~~~ +Formatter Not Recognized +~~~~~~~~~~~~~~~~~~~~~~~~ -Valid backend types are: ``json``, ``osmo``, ``omniperf``, ``summary`` +Valid formatter types are: ``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema`` .. code-block:: bash # Correct - --benchmark_backend json + --benchmark_formatter json # Incorrect - --benchmark_backend JSON # Case sensitive + --benchmark_formatter JSON # Case sensitive diff --git a/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst b/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst new file mode 100644 index 000000000000..b33d2628bfb1 --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst @@ -0,0 +1,44 @@ +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. diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index 0a5ac08a1101..fc69dd3cdc97 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -473,16 +473,21 @@ def launch_simulation( {**base, "visualizer_explicit": True, "visualizer_disable_all": disable_all} ) + exit_code = 0 try: yield physics_cfg except Exception: + exit_code = 1 import traceback traceback.print_exc() raise finally: if close_fn is not None: - close_fn() + if exit_code: + close_fn(exit_code=exit_code) + else: + close_fn() def _ensure_isaac_sim_available() -> None: diff --git a/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py b/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py index 7daedbc132a8..659929cb821a 100644 --- a/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py +++ b/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py @@ -8,12 +8,33 @@ import time from collections.abc import Sequence from datetime import datetime - -from . import backends -from .backends import get_default_output_filename -from .interfaces import MeasurementDataRecorder -from .measurements import DictMetadata, FloatMetadata, IntMetadata, Measurement, MetadataBase, StringMetadata, TestPhase -from .recorders import CPUInfoRecorder, GPUInfoRecorder, MemoryInfoRecorder, VersionInfoRecorder +from typing import TYPE_CHECKING + +from isaaclab.test.benchmark import formatters +from isaaclab.test.benchmark.formatters import get_default_output_filename +from isaaclab.test.benchmark.interfaces import MeasurementDataRecorder +from isaaclab.test.benchmark.measurements import ( + DictMetadata, + FloatMetadata, + IntMetadata, + ListMeasurement, + Measurement, + MetadataBase, + SingleMeasurement, + StringMetadata, + TestPhase, +) +from isaaclab.test.benchmark.recorders import CPUInfoRecorder, GPUInfoRecorder, MemoryInfoRecorder, VersionInfoRecorder + +if TYPE_CHECKING: + from isaaclab.test.benchmark.schema import ( + LearningCurve, + MeanStd, + Runtime, + RuntimeBundle, + StartupBundle, + TrainingBundle, + ) logger = logging.getLogger(__name__) @@ -39,33 +60,136 @@ def _is_metadata_type(obj: object) -> bool: return type(obj).__name__ in _METADATA_CLASS_NAMES +def _stat_measurements(name: str, stats: "MeanStd", unit: str, scale: float = 1.0) -> list[Measurement]: + """Convert a schema aggregate to flat scalar measurements.""" + measurements: list[Measurement] = [ + SingleMeasurement(name=f"Mean {name}", value=stats.mean * scale, unit=unit), + SingleMeasurement(name=f"Std {name}", value=stats.std * scale, unit=unit), + ] + if stats.peak is not None: + measurements.append(SingleMeasurement(name=f"Max {name}", value=stats.peak * scale, unit=unit)) + return measurements + + +def _runtime_measurements(runtime: "Runtime") -> dict[str, list[Measurement]]: + """Convert schema runtime metrics to startup and runtime phases.""" + startup_fields = ( + ("app_launch", "App Launch Time"), + ("python_imports", "Python Imports Time"), + ("task_config", "Task Creation and Start Time"), + ("env_creation", "Scene Creation Time"), + ("first_step", "Simulation Start Time"), + ) + startup = [ + SingleMeasurement(name=label, value=value * 1000.0, unit="ms") + for field, label in startup_fields + if (value := getattr(runtime.startup_time_s, field)) is not None + ] + if startup: + startup.append( + SingleMeasurement( + name="Total Start Time (Launch to Train)", + value=sum(float(measurement.value) for measurement in startup), + unit="ms", + ) + ) + + runtime_metrics: list[Measurement] = [ + SingleMeasurement(name="Iterations Completed", value=runtime.iterations_completed, unit="count"), + SingleMeasurement(name="Total Wall Time", value=runtime.total_wall_time_s, unit="s"), + SingleMeasurement(name="Steps per Iteration", value=runtime.steps_per_iteration, unit="frames"), + ] + runtime_metrics.extend(_stat_measurements("Iteration Time", runtime.iteration_time_s, "ms", 1000.0)) + runtime_metrics.extend(_stat_measurements("Collection FPS", runtime.collection_fps, "FPS")) + runtime_metrics.extend(_stat_measurements("Total FPS", runtime.total_fps, "FPS")) + runtime_metrics.extend(_stat_measurements("Iterations per Second", runtime.iterations_per_s, "iterations/s")) + return {"startup": startup, "runtime": runtime_metrics} + + +def _curve_measurements(label: str, curve: "LearningCurve", ema_alpha: float) -> list[Measurement]: + """Convert one training curve to scalar and optional series measurements.""" + measurements: list[Measurement] = [ + SingleMeasurement(name=f"Last {label}", value=curve.final_raw, unit="float"), + SingleMeasurement(name=f"EMA {ema_alpha:g} {label}", value=curve.final_ema, unit="float"), + ] + if curve.series_per_iter is not None: + plural = "Rewards" if label == "Reward" else "Episode Lengths" + measurements.append(ListMeasurement(name=plural, value=curve.series_per_iter)) + if curve.series_per_iter: + measurements.append(SingleMeasurement(name=f"Max {plural}", value=max(curve.series_per_iter), unit="float")) + return measurements + + +def _measurements_from_bundle( + bundle: "RuntimeBundle | TrainingBundle | StartupBundle", +) -> dict[str, list[Measurement]]: + """Project a typed bundle into flat phases for non-schema formatters.""" + from isaaclab.test.benchmark.schema import StartupBundle, TrainingBundle + + if isinstance(bundle, StartupBundle): + projected: dict[str, list[Measurement]] = {} + for phase_name, phase in bundle.phases.items(): + measurements: list[Measurement] = [ + SingleMeasurement(name="Wall Clock Time", value=phase.total_time_s, unit="s") + ] + for function in phase.top_functions: + measurements.extend( + [ + SingleMeasurement(name=f"{function.name} Own Time", value=function.own_time_s, unit="s"), + SingleMeasurement(name=f"{function.name} Cumulative Time", value=function.cum_time_s, unit="s"), + SingleMeasurement(name=f"{function.name} Calls", value=function.calls, unit="count"), + ] + ) + projected[phase_name] = measurements + return projected + + projected = _runtime_measurements(bundle.runtime) + if isinstance(bundle, TrainingBundle): + train = _curve_measurements("Reward", bundle.learning.reward, bundle.learning.ema_alpha) + train.extend(_curve_measurements("Episode Length", bundle.learning.ep_length, bundle.learning.ema_alpha)) + if bundle.success_rate is not None: + train.append(SingleMeasurement(name="success_rate", value=bundle.success_rate, unit="float")) + projected["train"] = train + return projected + + class BaseIsaacLabBenchmark: """Base benchmark class for IsaacLab's benchmarks.""" def __init__( self, benchmark_name: str, - backend_type: str, - output_path: str, + formatter_type: str | list[str] | None = None, + output_path: str | None = None, use_recorders: bool = True, output_prefix: str | None = None, workflow_metadata: dict | None = None, frametime_recorders: bool = False, + backend_type: str | list[str] | None = None, ): """Initialize common benchmark state and recorders. Args: benchmark_name: Name of benchmark to use in outputs. - backend_type: Type of backend used to collect and print metrics. + formatter_type: Formatter(s) used to collect and print metrics. Accepts a single + type name, a list of type names, or a comma-separated string (e.g. + ``"schema,omniperf"``); each selected formatter writes its own output file. output_path: Path to output directory. use_recorders: Whether to use recorders to collect metrics. Defaults to True. - output_filename: Filename to use for the output file, defaults to None. + output_prefix: Prefix used to generate the output filename. Defaults to ``None``. workflow_metadata: Metadata describing benchmark, defaults to None. - frametime_recorders: Whether to use frametime recorders to collect metrics. Defaults to True. + frametime_recorders: Whether to use frametime recorders to collect metrics. Defaults to ``False``. + backend_type: Alias for :paramref:`formatter_type`. """ + if formatter_type is None: + formatter_type = backend_type or "omniperf" + elif backend_type is not None and backend_type != formatter_type: + raise ValueError("Specify either formatter_type or backend_type, not both.") + if output_path is None: + raise ValueError("output_path must be provided.") + self.benchmark_name = benchmark_name - # Resolve output path if not os.path.exists(output_path): try: os.makedirs(output_path) @@ -77,9 +201,12 @@ def __init__( logger.warning("No output prefix provided, using default prefix: benchmark") self.output_prefix = get_default_output_filename(output_prefix) - # Get metrics backend - logger.info("Using metrics backend = %s", backend_type) - self._metrics = backends.MetricsBackend.get_instance(instance_type=backend_type) + if isinstance(formatter_type, str): + formatter_type = [t.strip() for t in formatter_type.split(",") if t.strip()] or ["omniperf"] + formatter_type = list(dict.fromkeys(formatter_type)) + logger.info("Using metrics formatters = %s", formatter_type) + self._metrics = [(t, formatters.MetricsFormatter.get_instance(instance_type=t)) for t in formatter_type] + self._bundle = None self._phases: dict[str, TestPhase] = {} # Generate workflow-level metadata @@ -214,6 +341,17 @@ def _metadata_from_dict(self, metadata_dict: dict) -> list[MetadataBase]: metadata.append(curr_meta) return metadata + def attach_bundle(self, bundle: "RuntimeBundle | TrainingBundle | StartupBundle | None") -> None: + """Attach a typed bundle for schema serialization and flat-formatter projection. + + Args: + bundle: Runtime, training, or startup benchmark bundle. + """ + self._bundle = bundle + if bundle is not None: + for phase_name, measurements in _measurements_from_bundle(bundle).items(): + self.add_measurement(phase_name, measurement=measurements) + def update_manual_recorders(self) -> None: """Update manual recorders that don't depend on the kit timeline.""" @@ -239,32 +377,28 @@ def add_measurement( """ if phase_name not in self._phases: self._phases[phase_name] = TestPhase(phase_name=phase_name) - # Add required phase metadata for backends + # Add required phase metadata for formatters phase_metadata = StringMetadata(name="phase", data=phase_name) workflow_metadata = StringMetadata(name="workflow_name", data=self.benchmark_name) self._phases[phase_name].metadata.extend([phase_metadata, workflow_metadata]) if measurement: if isinstance(measurement, Sequence): - # Check that all the elements are of type Measurement for m in measurement: if not _is_measurement_type(m): raise ValueError(f"Measurement element {m} is not of type Measurement") self._phases[phase_name].measurements.extend(measurement) else: - # Check that the element is of type Measurement if not _is_measurement_type(measurement): raise ValueError(f"Measurement element {measurement} is not of type Measurement") self._phases[phase_name].measurements.append(measurement) if metadata: if isinstance(metadata, Sequence): - # Check that all the elements are of type MetadataBase for m in metadata: if not _is_metadata_type(m): raise ValueError(f"Metadata element {m} is not of type MetadataBase") self._phases[phase_name].metadata.extend(metadata) else: - # Check that the element is of type MetadataBase if not _is_metadata_type(metadata): raise ValueError(f"Metadata element {metadata} is not of type MetadataBase") self._phases[phase_name].metadata.append(metadata) @@ -293,15 +427,18 @@ def _finalize_impl(self) -> None: if data.measurements: self.add_measurement("frametime", measurement=data.measurements) - # Check that there are phases to write. if not self._phases: logger.warning("No phases collected.No metrics will be written.") return - # Add the phases to the metrics backend. - for phase in self._phases.values(): - self._metrics.add_metrics(phase) - - self._metrics.finalize(self.output_path, self.output_prefix) + # Add the phases to each metrics formatter and write its output file. When more than one + # formatter is selected, suffix the filename with the formatter key so they don't collide on + # the shared ".json" extension. + multi = len(self._metrics) > 1 + for formatter_key, metrics in self._metrics: + for phase in self._phases.values(): + metrics.add_metrics(phase) + filename = f"{self.output_prefix}_{formatter_key}" if multi else self.output_prefix + metrics.finalize(self.output_path, filename, bundle=self._bundle) self._manual_recorders = None self._frametime_recorders = None diff --git a/source/isaaclab/isaaclab/test/benchmark/builders.py b/source/isaaclab/isaaclab/test/benchmark/builders.py new file mode 100644 index 000000000000..93bfe3674e51 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/builders.py @@ -0,0 +1,303 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Pure assembly functions for benchmark bundle dataclasses. + +Converts already-extracted per-iteration series and scalar measurements into +the frozen :mod:`~isaaclab.test.benchmark.schema` dataclasses that are then +serialised by :func:`~isaaclab.test.benchmark.serialize.write_bundle_file`. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime + +from isaaclab.test.benchmark.metrics import ema, mean_std_peak +from isaaclab.test.benchmark.schema import ( + Hardware, + Learning, + LearningCurve, + Resources, + RunConfig, + RunIdentity, + Runtime, + RuntimeBundle, + StartupBundle, + StartupConfig, + StartupPhase, + StartupTime, + TrainingBundle, + Versions, +) + + +def build_run_config( + physics_backend: str, + rendering_backend: str = "none", + presets: Sequence[str] | None = None, +) -> RunConfig: + """Assemble a :class:`~isaaclab.test.benchmark.schema.RunConfig`. + + Args: + physics_backend: Physics solver preset used by the run + (e.g. ``"physx"``, ``"newton_mjwarp"``). + rendering_backend: Rendering backend, or ``"none"`` for headless runs + with no camera sensors. + presets: Active Hydra preset tokens (e.g. ``["rgb"]``). ``None`` + is treated as an empty list. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.RunConfig`. + """ + return RunConfig( + physics_backend=physics_backend, + rendering_backend=rendering_backend, + presets=list(presets) if presets else [], + ) + + +def build_run_identity( + *, + run_id: str, + framework: str | None, + config: RunConfig, + task: str, + seed: int, + start_utc: str, + end_utc: str, + status: str = "completed", + num_envs: int | None = None, + max_iterations: int | None = None, +) -> RunIdentity: + """Assemble a :class:`~isaaclab.test.benchmark.schema.RunIdentity`. + + The wall-clock duration is derived from the ISO-8601 timestamps; it is + clamped to zero so clock skew cannot produce a negative value. + + Args: + run_id: Stable identifier for the run. + framework: RL library (e.g. ``"rsl_rl"``), or ``None`` for non-learning + runs. + config: Physics/rendering/sensor configuration. + task: Gym task id. + seed: Environment/agent seed. + start_utc: ISO-8601 UTC start timestamp. + end_utc: ISO-8601 UTC end timestamp. + status: Terminal status of the run. + num_envs: Number of parallel environments, or ``None`` for startup runs. + max_iterations: Training iteration budget, or ``None`` for non-training + runs. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.RunIdentity` with + ``duration_s`` computed from the timestamps [s]. + """ + duration_s = max( + 0.0, + (datetime.fromisoformat(end_utc) - datetime.fromisoformat(start_utc)).total_seconds(), + ) + return RunIdentity( + run_id=run_id, + framework=framework, + config=config, + task=task, + seed=seed, + start_time_utc=start_utc, + end_time_utc=end_utc, + duration_s=duration_s, + status=status, + num_envs=num_envs, + max_iterations=max_iterations, + ) + + +def build_runtime( + *, + startup_time_s: StartupTime, + iteration_times_s: Sequence[float], + collection_fps: Sequence[float], + total_fps: Sequence[float], + steps_per_iteration: int, +) -> Runtime: + """Assemble a :class:`~isaaclab.test.benchmark.schema.Runtime` from raw series. + + Args: + startup_time_s: Per-phase startup wall-clock durations [s]. + iteration_times_s: Per-iteration wall-clock time [s]. + collection_fps: Per-iteration environment-stepping throughput + [frames/s]. + total_fps: Per-iteration end-to-end throughput [frames/s]. + steps_per_iteration: Environment steps collected per iteration. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.Runtime` with + aggregated :class:`~isaaclab.test.benchmark.schema.MeanStd` fields. + """ + iter_times = list(iteration_times_s) + iter_per_s = [1.0 / t for t in iter_times if t > 0] + return Runtime( + startup_time_s=startup_time_s, + iterations_completed=len(iter_times), + total_wall_time_s=float(sum(iter_times)), + steps_per_iteration=steps_per_iteration, + iteration_time_s=mean_std_peak(iter_times), + collection_fps=mean_std_peak(collection_fps), + total_fps=mean_std_peak(total_fps), + iterations_per_s=mean_std_peak(iter_per_s), + ) + + +def build_learning( + *, + reward_series: Sequence[float], + ep_length_series: Sequence[float], + ema_alpha: float, + keep_series: bool = True, +) -> Learning: + """Assemble a :class:`~isaaclab.test.benchmark.schema.Learning` from raw curves. + + Args: + reward_series: Per-iteration mean reward values. + ep_length_series: Per-iteration mean episode-length values. + ema_alpha: EMA smoothing factor in ``[0, 1]``; higher values weight + recent observations more. + keep_series: When ``True`` (default) the full per-iteration series is + embedded in the bundle; set to ``False`` to reduce file size. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.Learning`. + """ + rewards = list(reward_series) + ep_lengths = list(ep_length_series) + + reward_curve = LearningCurve( + final_raw=float(rewards[-1]) if rewards else 0.0, + final_ema=ema(rewards, ema_alpha), + series_per_iter=rewards if keep_series else None, + ) + ep_length_curve = LearningCurve( + final_raw=float(ep_lengths[-1]) if ep_lengths else 0.0, + final_ema=ema(ep_lengths, ema_alpha), + series_per_iter=ep_lengths if keep_series else None, + ) + return Learning(ema_alpha=ema_alpha, reward=reward_curve, ep_length=ep_length_curve) + + +def build_runtime_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + runtime: Runtime, + resources: Resources, + extra: dict | None = None, +) -> RuntimeBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.RuntimeBundle`. + + Args: + run: Run identity metadata. + versions: Software versions snapshot. + hardware: Host hardware snapshot. + runtime: Aggregated runtime metrics. + resources: Aggregated resource-utilisation metrics. + extra: Optional free-form scalar values not covered by the stable + schema. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.RuntimeBundle`. + """ + return RuntimeBundle( + run=run, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + extra=extra, + ) + + +def build_training_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + runtime: Runtime, + resources: Resources, + learning: Learning, + success_rate: float | None = None, + checkpoint_path: str | None = None, + video_path: str | None = None, + extra: dict | None = None, +) -> TrainingBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.TrainingBundle`. + + Args: + run: Run identity metadata. + versions: Software versions snapshot. + hardware: Host hardware snapshot. + runtime: Aggregated runtime metrics. + resources: Aggregated resource-utilisation metrics. + learning: Aggregated learning curves. + success_rate: Final success rate ``[0..1]``, or ``None`` when the task + does not track one. + checkpoint_path: Path to the final saved policy checkpoint, if any. + video_path: Path to a recorded rollout video/gif, if any. + extra: Optional free-form scalar values not covered by the stable + schema. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.TrainingBundle`. + """ + return TrainingBundle( + run=run, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + learning=learning, + success_rate=success_rate, + checkpoint_path=checkpoint_path, + video_path=video_path, + extra=extra, + ) + + +def build_startup_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + phases: dict[str, StartupPhase], + top_n: int, + whitelist: str | None, + extra: dict | None = None, +) -> StartupBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.StartupBundle`. + + Args: + run: Run identity metadata (``framework``, ``num_envs``, and + ``max_iterations`` are typically ``None`` for startup profiles). + versions: Software versions snapshot. + hardware: Host hardware snapshot. + phases: Per-phase timing and cProfile data, keyed by phase name. + top_n: Number of top cProfile functions retained per phase. + whitelist: Optional cProfile name-filter pattern; ``None`` means no + filtering. + extra: Optional free-form scalar values not covered by the stable + schema. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.StartupBundle`. + """ + config = StartupConfig(top_n=top_n, whitelist=whitelist) + return StartupBundle( + run=run, + versions=versions, + hardware=hardware, + phases=phases, + config=config, + extra=extra, + ) diff --git a/source/isaaclab/isaaclab/test/benchmark/capture.py b/source/isaaclab/isaaclab/test/benchmark/capture.py new file mode 100644 index 000000000000..5f93c70f3ef9 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/capture.py @@ -0,0 +1,449 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Capture helpers for benchmark data extraction. + +Reads recorder data off a ``BaseIsaacLabBenchmark``-like object and maps the +raw measurements and metadata to the typed schema dataclasses +:class:`~isaaclab.test.benchmark.schema.Versions`, +:class:`~isaaclab.test.benchmark.schema.Hardware`, and +:class:`~isaaclab.test.benchmark.schema.Resources`. + +Import-time dependencies stay light: no torch, isaacsim, or RL-library +imports. Preset metadata is imported lazily when run_config_from_presets() +needs it. The benchmark object is accepted at call time; its recorder classes +are never imported here. +""" + +from __future__ import annotations + +import socket +from collections.abc import Sequence +from datetime import datetime, timezone +from typing import Any, get_args + +from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + PhysicsBackend, + Resources, + RunConfig, + Versions, +) + + +def _find_value(measurements: Any, name: str, default: float = 0.0) -> float: + """Scan a measurement list for a :class:`~.measurements.SingleMeasurement` by name. + + Args: + measurements: Sequence of measurement objects, each with ``.name`` and + ``.value`` attributes. + name: Exact measurement name to look up. + default: Value to return when the name is not found. Defaults to ``0.0``. + + Returns: + The ``float`` value of the first matching measurement, or *default*. + """ + if not measurements: + return default + for m in measurements: + if m.name == name: + return float(m.value) + return default + + +def _get_recorder_data(bm: Any, key: str) -> Any | None: + """Safely retrieve :meth:`get_data` output from a named recorder. + + Args: + bm: Benchmark-like object with a ``_manual_recorders`` attribute. + key: Recorder key (e.g. ``"VersionInfo"``). + + Returns: + The :class:`~.interfaces.MeasurementData` returned by the recorder, or + ``None`` when the recorders dict is absent or the key is missing. + """ + recorders = getattr(bm, "_manual_recorders", None) + if recorders is None: + return None + rec = recorders.get(key) + if rec is None: + return None + return rec.get_data() + + +def now_utc_iso() -> str: + """Return the current UTC time as an ISO-8601 string. + + Returns: + ISO-8601 formatted current UTC timestamp. + """ + return datetime.now(timezone.utc).isoformat() + + +def synth_run_id( + framework: str | None, + physics_backend: str, + task: str, + seed: int, + stamp: str, +) -> str: + """Synthesise a stable run identifier from run parameters. + + Args: + framework: RL framework name, or ``None`` for non-learning runs + (substituted with ``"runtime"``). + physics_backend: Physics backend preset string. + task: Gym task id. + seed: Environment/agent seed. + stamp: Timestamp string (e.g. ``"20260612-150000"``). + + Returns: + Underscore-joined run identifier string. + """ + fw = framework or "runtime" + return f"{fw}_{physics_backend}_{task}_{stamp}_seed{seed}" + + +def capture_versions(bm: Any) -> Versions: + """Read software version metadata from a benchmark object. + + Reads ``bm._manual_recorders["VersionInfo"].get_data().metadata`` and maps + the ``*_version`` :class:`~.measurements.StringMetadata` entries plus the + ``dev`` :class:`~.measurements.DictMetadata` to a :class:`~.schema.Versions` + instance. All fields default gracefully when the recorder is absent. + + Args: + bm: Benchmark-like object exposing ``._manual_recorders``. + + Returns: + Populated :class:`~.schema.Versions` dataclass; never raises. + """ + data = _get_recorder_data(bm, "VersionInfo") + if data is None: + return Versions( + isaaclab="unknown", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="unknown", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ) + + md = {m.name: m.data for m in data.metadata or []} + dev: dict[str, Any] = md.get("dev") or {} + + return Versions( + isaaclab=md.get("isaaclab_version", "unknown"), + isaacsim=md.get("isaacsim_version"), + kit=md.get("kit_version"), + newton=md.get("newton_version"), + warp=md.get("warp_version"), + mjwarp=md.get("mujoco_warp_version"), + torch=md.get("torch_version", "unknown"), + rsl_rl=md.get("rsl_rl_version"), + rl_games=md.get("rl_games_version"), + skrl=md.get("skrl_version"), + sb3=md.get("stable_baselines3_version"), + git_commit=dev.get("commit_hash"), + git_branch=dev.get("branch"), + git_dirty=dev.get("dirty", False), + numpy=md.get("numpy_version"), + isaaclab_newton=md.get("isaaclab_newton_version"), + isaaclab_physx=md.get("isaaclab_physx_version"), + isaaclab_ov=md.get("isaaclab_ov_version"), + isaaclab_tasks=md.get("isaaclab_tasks_version"), + isaaclab_rl=md.get("isaaclab_rl_version"), + ovrtx=md.get("ovrtx_version"), + ovphysx=md.get("ovphysx_version"), + mujoco=md.get("mujoco_version"), + cuda_bindings=md.get("cuda_bindings_version"), + usd_core=md.get("usd_core_version"), + isaaclab_release=md.get("isaaclab_release_version"), + ) + + +def capture_hardware(bm: Any) -> Hardware: + """Read hardware metadata from a benchmark object. + + Reads GPU, CPU, and memory recorder metadata to populate a + :class:`~.schema.Hardware` instance. All fields default gracefully when + recorders are absent. + + Args: + bm: Benchmark-like object exposing ``._manual_recorders``. + + Returns: + Populated :class:`~.schema.Hardware` dataclass; never raises. + """ + gpu_data = _get_recorder_data(bm, "GPUInfo") + cpu_data = _get_recorder_data(bm, "CPUInfo") + mem_data = _get_recorder_data(bm, "MemoryInfo") + + # GPU devices + gpu_devices: list[GpuDeviceInfo] = [] + if gpu_data is not None: + gpu_md = {m.name: m.data for m in gpu_data.metadata or []} + raw_devices: dict[str, Any] = gpu_md.get("gpu_devices") or {} + for idx_str in sorted(raw_devices.keys(), key=lambda k: int(k)): + d = raw_devices[idx_str] + gpu_devices.append( + GpuDeviceInfo( + name=d["name"], + mem_gb=float(d["total_memory_gb"]), + compute_cap=str(d["compute_capability"]), + ) + ) + + # CPU + cpu_name = "unknown" + cpu_count = 0 + if cpu_data is not None: + cpu_md = {m.name: m.data for m in cpu_data.metadata or []} + cpu_name = cpu_md.get("cpu_name", "unknown") + cpu_count = int(cpu_md.get("physical_cores", 0)) + + # RAM + ram_gb = 0.0 + if mem_data is not None: + mem_md = {m.name: m.data for m in mem_data.metadata or []} + ram_gb = float(mem_md.get("total_ram_gb", 0.0)) + + return Hardware( + hostname=socket.gethostname(), + gpu_devices=gpu_devices, + cpu_name=cpu_name, + cpu_count=cpu_count, + ram_gb=ram_gb, + ) + + +def _preset_target_metadata() -> tuple[str, str, str, dict[str, str]]: + """Return preset selector labels and physics aliases from the preset CLI layer.""" + from isaaclab_tasks.utils.preset_target import PresetTarget + + return ( + PresetTarget.PHYSICS.value, + PresetTarget.RENDERER.value, + PresetTarget.DOMAIN.value, + dict(PresetTarget.PHYSICS.legacy_aliases), + ) + + +def _physics_backend_names() -> set[str]: + """Return physics backend names accepted by the benchmark schema.""" + return set(get_args(PhysicsBackend)) + + +def _rendering_backend_by_preset() -> dict[str, str]: + """Return renderer preset names mapped to benchmark rendering backend names.""" + try: + from isaaclab_tasks.utils.hydra import _preset_fields + from isaaclab_tasks.utils.presets import MultiBackendRendererCfg + except ImportError: + return {} + + return { + name: name.removesuffix("_renderer") for name in _preset_fields(MultiBackendRendererCfg()) if name != "default" + } + + +def _backend_defaults_from_env_cfg(env_cfg: object) -> tuple[str | None, str | None]: + """Return active backend names from a resolved environment configuration.""" + physics_cfg = getattr(getattr(env_cfg, "sim", None), "physics", None) + physics_descriptor = ( + f"{type(physics_cfg).__module__}.{type(physics_cfg).__name__} {getattr(physics_cfg, 'class_type', '')}" + ).lower() + physics = next( + ( + name + for marker, name in ( + ("ovphysx", "ovphysx"), + ("kamino", "newton_kamino"), + ("mjwarp", "newton_mjwarp"), + ("physx", "physx"), + ) + if marker in physics_descriptor + ), + None, + ) + + renderer_names = {"isaac_rtx": "isaacsim_rtx", "ovrtx": "ovrtx", "newton_warp": "newton"} + rendering = None + stack = [env_cfg] + visited: set[int] = set() + while stack and rendering is None: + node = stack.pop() + if id(node) in visited: + continue + visited.add(id(node)) + rendering = renderer_names.get(getattr(node, "renderer_type", None)) + if isinstance(node, dict): + children = node.values() + elif isinstance(node, (list, tuple)): + children = node + else: + try: + children = vars(node).values() + except TypeError: + continue + stack.extend( + child + for child in children + if child is not None and not isinstance(child, (str, bytes, int, float, bool, type)) + ) + return physics, rendering + + +def _expand_preset_tokens(tokens: Sequence[str]) -> list[tuple[str | None, str]]: + """Expand Hydra-style preset tokens into ``(selector, value)`` pairs.""" + physics_label, renderer_label, domain_label, _ = _preset_target_metadata() + expanded: list[tuple[str | None, str]] = [] + for token in tokens: + selector, has_value, raw_value = token.partition("=") + if not has_value: + value = token.strip() + if value: + expanded.append((None, value)) + continue + + selector = selector.strip() + if selector == domain_label: + expanded.extend((selector, value) for value in (v.strip() for v in raw_value.split(",")) if value) + elif selector in (physics_label, renderer_label): + value = raw_value.strip() + if value: + expanded.append((selector, value)) + else: + value = token.strip() + if value: + expanded.append((None, value)) + return expanded + + +def run_config_from_presets(tokens: Sequence[str], *, env_cfg: object | None = None) -> RunConfig: + """Build a :class:`~isaaclab.test.benchmark.RunConfig` from presets and resolved task config. + + Picks backend defaults from ``env_cfg`` when provided, then applies recognised + preset tokens. Without a resolved config, physics defaults to ``"physx"`` and + rendering to ``"none"``. Accepts bare preset names as well as Hydra-style + ``physics=...``, ``renderer=...``, and ``presets=...`` tokens. + + Args: + tokens: Active preset tokens (e.g. ``["newton_mjwarp", "rgb"]``). + env_cfg: Optional resolved task environment configuration. Its active physics + and renderer configurations take precedence over token inference. + + Returns: + Populated :class:`~isaaclab.test.benchmark.RunConfig`. + """ + if not tokens and env_cfg is None: + return RunConfig(physics_backend="physx", rendering_backend="none", presets=[]) + + physics = "physx" + rendering = "none" + expanded_tokens = _expand_preset_tokens(tokens) + physics_label, renderer_label, _, physics_aliases = _preset_target_metadata() + physics_backends = _physics_backend_names() + rendering_backends: dict[str, str] | None = None + + def rendering_backend_for(preset_name: str) -> str | None: + nonlocal rendering_backends + if rendering_backends is None: + rendering_backends = _rendering_backend_by_preset() + if preset_name in rendering_backends: + return rendering_backends[preset_name] + if preset_name.endswith("_renderer"): + return preset_name.removesuffix("_renderer") + return None + + for selector, token in expanded_tokens: + if selector == physics_label: + physics = physics_aliases.get(token, token) + elif selector == renderer_label: + rendering = rendering_backend_for(token) or token + else: + canonical = physics_aliases.get(token, token) + if canonical in physics_backends: + physics = canonical + elif token.endswith("_renderer"): + renderer = rendering_backend_for(token) + if renderer is not None: + rendering = renderer + + if env_cfg is not None: + active_physics, active_rendering = _backend_defaults_from_env_cfg(env_cfg) + physics = active_physics or physics + rendering = active_rendering or rendering + + return RunConfig( + physics_backend=physics, + rendering_backend=rendering, + presets=[token for _, token in expanded_tokens], + ) + + +def capture_resources(bm: Any) -> Resources: + """Read resource-utilisation measurements from a benchmark object. + + Reads GPU utilisation/memory, CPU utilisation, and system RAM from the + corresponding recorders and maps them to :class:`~.schema.Resources`. + + Utilisation fields leave ``peak`` as ``None``; memory fields populate it. + + Args: + bm: Benchmark-like object exposing ``._manual_recorders``. + + Returns: + Populated :class:`~.schema.Resources` dataclass; never raises. + """ + gpu_data = _get_recorder_data(bm, "GPUInfo") + cpu_data = _get_recorder_data(bm, "CPUInfo") + mem_data = _get_recorder_data(bm, "MemoryInfo") + + # --- GPU --- + gpu_meas = gpu_data.measurements if gpu_data is not None else [] + gpu_metadata = {m.name: m.data for m in gpu_data.metadata or []} if gpu_data is not None else {} + gpu_prefix = ( + f"GPU {gpu_metadata.get('gpu_current_device', 0)} " if gpu_metadata.get("gpu_device_count", 1) > 1 else "GPU " + ) + + gpu_util_mean = _find_value(gpu_meas, f"{gpu_prefix}Utilization") + gpu_util_std = _find_value(gpu_meas, f"{gpu_prefix}Utilization std") + + gpu_mem_mean = _find_value(gpu_meas, f"{gpu_prefix}Memory Used") + gpu_mem_std = _find_value(gpu_meas, f"{gpu_prefix}Memory Used std") + _gpu_mem_peak_raw = _find_value(gpu_meas, f"{gpu_prefix}Memory Used peak", default=0.0) + gpu_mem_peak = max(gpu_mem_mean, _gpu_mem_peak_raw) + + # --- CPU --- + cpu_meas = cpu_data.measurements if cpu_data is not None else [] + + cpu_util_mean = _find_value(cpu_meas, "CPU Utilization") + cpu_util_std = _find_value(cpu_meas, "CPU Utilization std") + + # --- Memory --- + mem_meas = mem_data.measurements if mem_data is not None else [] + + ram_mean = _find_value(mem_meas, "System Memory RSS") + ram_std = _find_value(mem_meas, "System Memory RSS std") + _ram_peak_raw = _find_value(mem_meas, "System Memory RSS peak", default=0.0) + ram_peak = max(ram_mean, _ram_peak_raw) + + return Resources( + gpu_util_pct=MeanStd(mean=gpu_util_mean, std=gpu_util_std, peak=None), + gpu_mem_gb=MeanStd(mean=gpu_mem_mean, std=gpu_mem_std, peak=gpu_mem_peak), + cpu_util_pct=MeanStd(mean=cpu_util_mean, std=cpu_util_std, peak=None), + ram_gb=MeanStd(mean=ram_mean, std=ram_std, peak=ram_peak), + ) diff --git a/source/isaaclab/isaaclab/test/benchmark/backends.py b/source/isaaclab/isaaclab/test/benchmark/formatters.py similarity index 81% rename from source/isaaclab/isaaclab/test/benchmark/backends.py rename to source/isaaclab/isaaclab/test/benchmark/formatters.py index 6b6c0acacb10..201892cf7a54 100644 --- a/source/isaaclab/isaaclab/test/benchmark/backends.py +++ b/source/isaaclab/isaaclab/test/benchmark/formatters.py @@ -10,9 +10,12 @@ import textwrap from abc import ABC, abstractmethod from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any -from .measurements import SingleMeasurement, StatisticalMeasurement, TestPhase, TestPhaseEncoder +from isaaclab.test.benchmark.measurements import SingleMeasurement, StatisticalMeasurement, TestPhase, TestPhaseEncoder + +if TYPE_CHECKING: + from isaaclab.test.benchmark.schema import RuntimeBundle, StartupBundle, TrainingBundle logger = logging.getLogger(__name__) @@ -30,8 +33,8 @@ def get_default_output_filename(prefix: str = "benchmark") -> str: return f"{prefix}_{datetime_str}" -class MetricsBackendInterface(ABC): - """Abstract base class for metrics backends.""" +class MetricsFormatterInterface(ABC): + """Abstract base class for metrics Formatters.""" @abstractmethod def add_metrics(self, test_phase: TestPhase) -> None: @@ -48,48 +51,49 @@ def finalize(self, output_path: str, **kwargs) -> None: Args: output_path: Path to write output file(s). - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. """ pass -class MetricsBackend: - """Factory for creating metrics backend instances.""" +class MetricsFormatter: + """Factory for creating metrics formatter instances.""" - _instances: dict[str, MetricsBackendInterface] = {} + _instances: dict[str, MetricsFormatterInterface] = {} @classmethod - def get_instance(cls, instance_type: str) -> MetricsBackendInterface: - """Get or create a backend instance by type name. + def get_instance(cls, instance_type: str) -> MetricsFormatterInterface: + """Get or create a formatter instance by type name. Args: - instance_type: Type of backend to create ("json", "osmo", or "omniperf"). + instance_type: Type of formatter to create ("json", "osmo", "omniperf", "summary", or "schema"). Returns: - Backend instance of the requested type. + Formatter instance of the requested type. Raises: ValueError: If the instance_type is not recognized. """ if instance_type not in cls._instances: - backend_map = { + formatter_map = { "json": JSONFileMetrics, "osmo": OsmoKPIFile, "omniperf": OmniPerfKPIFile, "summary": SummaryMetrics, + "schema": SchemaBundleFile, } - if instance_type not in backend_map: - raise ValueError(f"Unknown backend type: {instance_type}. Available: {list(backend_map.keys())}") - cls._instances[instance_type] = backend_map[instance_type]() + if instance_type not in formatter_map: + raise ValueError(f"Unknown formatter type: {instance_type}. Available: {list(formatter_map.keys())}") + cls._instances[instance_type] = formatter_map[instance_type]() return cls._instances[instance_type] @classmethod def reset_instances(cls) -> None: - """Reset all cached backend instances. Useful for testing.""" + """Reset all cached formatter instances. Useful for testing.""" cls._instances.clear() -class JSONFileMetrics(MetricsBackendInterface): +class JSONFileMetrics(MetricsFormatterInterface): """Write metrics to a JSON file at the end of a session.""" def __init__(self) -> None: @@ -106,7 +110,7 @@ def add_metrics(self, test_phase: TestPhase) -> None: .. code-block:: python - backend.add_metrics(test_phase) + formatter.add_metrics(test_phase) """ self.data.append(copy.deepcopy(test_phase)) @@ -116,13 +120,13 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: Args: output_path: Output path in which metrics file will be stored. output_filename: Output filename. - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. Example: .. code-block:: python - backend.finalize("/tmp/metrics", "metrics") + formatter.finalize("/tmp/metrics", "metrics") """ if not self.data: logger.warning("No test data to write. Skipping metrics file generation.") @@ -157,23 +161,23 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: self.data.clear() -class SummaryMetrics(MetricsBackendInterface): +class SummaryMetrics(MetricsFormatterInterface): """Print a human-readable summary and write JSON metrics.""" def __init__(self) -> None: - """Initialize internal phase storage and JSON backend.""" + """Initialize internal phase storage and JSON formatter.""" self._phases: list[TestPhase] = [] - self._json_backend = JSONFileMetrics() + self._json_formatter = JSONFileMetrics() self._report_width = 86 def add_metrics(self, test_phase: TestPhase) -> None: - """Add metrics from a test phase; store for summary and forward to JSON backend. + """Add metrics from a test phase; store for summary and forward to JSON formatter. Args: test_phase: Test phase containing measurements and metadata. """ self._phases.append(copy.deepcopy(test_phase)) - self._json_backend.add_metrics(test_phase) + self._json_formatter.add_metrics(test_phase) def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: """Write JSON output and print human-readable summary to console. @@ -181,9 +185,9 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: Args: output_path: Path to write output file(s). output_filename: Base filename for the JSON file. - **kwargs: Additional options passed to the JSON backend. + **kwargs: Additional options passed to the JSON formatter. """ - self._json_backend.finalize(output_path, output_filename, **kwargs) + self._json_formatter.finalize(output_path, output_filename, **kwargs) if self._phases: self._print_summary() self._phases.clear() @@ -394,7 +398,7 @@ def _get_single_measurement(self, phase: TestPhase, name: str) -> SingleMeasurem return None def _summarize_runtime_metrics(self, measurements: list) -> list[str]: - """Build min/mean/max summary rows from SingleMeasurement runtime metrics. + """Build summary rows from scalar runtime statistics. Args: measurements: List of measurements (typically from the runtime phase). @@ -424,6 +428,10 @@ def _summarize_runtime_metrics(self, measurements: list) -> list[str]: base = name[len("Mean ") :] series.setdefault(base, {})["mean"] = float(value) units.setdefault(base, unit) + elif name.startswith("Std "): + base = name[len("Std ") :] + series.setdefault(base, {})["std"] = float(value) + units.setdefault(base, unit) category_order = ["Collection", "Learning", "Step Times", "Throughput", "Other"] categorized: dict[str, list[str]] = {key: [] for key in category_order} @@ -431,10 +439,11 @@ def _summarize_runtime_metrics(self, measurements: list) -> list[str]: raw_unit = units.get(base) unit = (raw_unit or "").strip() if isinstance(raw_unit, str) else "" unit_suffix = f" {unit}" if unit else "" - min_val = self._format_scalar(stats.get("min", 0.0)) - mean_val = self._format_scalar(stats.get("mean", 0.0)) - max_val = self._format_scalar(stats.get("max", 0.0)) - row = f"{base} (min/mean/max): {min_val} / {mean_val} / {max_val}{unit_suffix}" + statistic_order = ("min", "mean", "std", "max") + available = [statistic for statistic in statistic_order if statistic in stats] + values = " / ".join(self._format_scalar(stats[statistic]) for statistic in available) + labels = "/".join(available) + row = f"{base} ({labels}): {values}{unit_suffix}" if "Collection" in base: categorized["Collection"].append(row) @@ -485,7 +494,7 @@ def _format_scalar(self, value: float | int) -> str: return str(value) -class OsmoKPIFile(MetricsBackendInterface): +class OsmoKPIFile(MetricsFormatterInterface): """Write per-phase KPI documents for Osmo ingestion. Only SingleMeasurement metrics and metadata are written as key-value pairs. @@ -504,27 +513,28 @@ def add_metrics(self, test_phase: TestPhase) -> None: .. code-block:: python - backend.add_metrics(test_phase) + formatter.add_metrics(test_phase) """ self._test_phases.append(test_phase) def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: """Write metrics to output file(s). - Each test phase's SingleMeasurement metrics and metadata are written to an output JSON file, at path - `[output_path]/[output_filename].json`. + A single phase is written to ``[output_path]/[output_filename].json``. Multiple phases + are written separately with the phase name appended to each filename. Args: output_path: Output path in which metrics files will be stored. output_filename: Output filename. - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. Example: .. code-block:: python - backend.finalize("/tmp/metrics", "kpis") + formatter.finalize("/tmp/metrics", "kpis") """ + multi_phase = len(self._test_phases) > 1 for test_phase in self._test_phases: # Retrieve useful metadata from test_phase phase_name = test_phase.get_metadata_field("phase") @@ -540,16 +550,17 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: if isinstance(measurement, SingleMeasurement): osmo_kpis[measurement.name] = measurement.value log_statements.append(f"{measurement.name}: {measurement.value} {measurement.unit}") - # Generate the output filename with timestamp - metrics_path = os.path.join(output_path, f"{output_filename}.json") + filename = f"{output_filename}_{phase_name}" if multi_phase else output_filename + metrics_path = os.path.join(output_path, f"{filename}.json") # Dump key-value pairs (fields) to the JSON document json_data = json.dumps(osmo_kpis, indent=4) with open(metrics_path, "w") as f: f.write(json_data) print(f"Results written to: {metrics_path}") + self._test_phases.clear() -class OmniPerfKPIFile(MetricsBackendInterface): +class OmniPerfKPIFile(MetricsFormatterInterface): """Write KPI metrics for upload to a PostgreSQL database.""" def __init__(self) -> None: @@ -565,7 +576,7 @@ def add_metrics(self, test_phase: TestPhase) -> None: .. code-block:: python - backend.add_metrics(test_phase) + formatter.add_metrics(test_phase) """ self._test_phases.append(test_phase) @@ -578,13 +589,13 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: Args: output_path: Output path in which metrics file will be stored. output_filename: Output filename. - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. Example: .. code-block:: python - backend.finalize("/tmp/metrics", "omniperf") + formatter.finalize("/tmp/metrics", "omniperf") """ if not self._test_phases: logger.warning("No test phases to write. Skipping metrics file generation.") @@ -623,3 +634,52 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: with open(metrics_path, "w") as f: f.write(json_data) print(f"Results written to: {metrics_path}") + self._test_phases.clear() + + +class SchemaBundleFile(MetricsFormatterInterface): + """Serialize a typed benchmark bundle to schema-v1 JSON. + + Unlike the other formatters, this one does not consume the flat measurement + phases collected during a run. Instead it serializes the typed bundle + attached via :meth:`~isaaclab.test.benchmark.benchmark_core.BaseIsaacLabBenchmark.attach_bundle`. + """ + + def add_metrics(self, test_phase: TestPhase) -> None: + """Ignore the provided test phase. + + This formatter serializes the typed bundle attached via + :meth:`~isaaclab.test.benchmark.benchmark_core.BaseIsaacLabBenchmark.attach_bundle`, + not the flat measurement phases, so accumulated phases are ignored by design. + + Args: + test_phase: Test phase to ignore. + """ + pass + + def finalize( + self, + output_path: str, + output_filename: str, + bundle: "RuntimeBundle | TrainingBundle | StartupBundle | None" = None, + **kwargs, + ) -> None: + """Write the attached bundle to a schema-v1 JSON file. + + Args: + output_path: Output path in which the schema file will be stored. + output_filename: Output filename (without extension). + bundle: Typed benchmark bundle to serialize. When ``None``, no file + is written. + **kwargs: Additional formatter-specific options (ignored). + """ + if bundle is None: + logger.warning("SchemaBundleFile selected but no bundle was attached; skipping schema file.") + return + + # Lazy import keeps formatters.py free of the schema layer at module import time. + from isaaclab.test.benchmark.serialize import write_bundle_file + + path = os.path.join(output_path, f"{output_filename}.json") + write_bundle_file(bundle, path) + logger.info("Wrote schema bundle to %s", path) diff --git a/source/isaaclab/isaaclab/test/benchmark/measurements.py b/source/isaaclab/isaaclab/test/benchmark/measurements.py index 54c14b120618..5004a694277c 100644 --- a/source/isaaclab/isaaclab/test/benchmark/measurements.py +++ b/source/isaaclab/isaaclab/test/benchmark/measurements.py @@ -140,7 +140,7 @@ class StringMetadata(MetadataBase): type: Metadata type label. Defaults to "string". """ - data: str + data: str | None type: str = "string" diff --git a/source/isaaclab/isaaclab/test/benchmark/metrics.py b/source/isaaclab/isaaclab/test/benchmark/metrics.py new file mode 100644 index 000000000000..368fd9dd5518 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/metrics.py @@ -0,0 +1,278 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Isaac-Sim-free metric helpers for benchmark bundles: TensorBoard parsing, convergence, EMA, MeanStd aggregation, +and success-rate tracking.""" + +from __future__ import annotations + +import glob +import logging +import os +import statistics +from collections.abc import Sequence +from dataclasses import dataclass + +from tensorboard.backend.event_processing import event_accumulator + +from isaaclab.test.benchmark.schema import Framework, MeanStd + +SUCCESS_RATE_LOG_TAGS = ("Metrics/success_rate", "Episode/Metrics/success_rate") + + +@dataclass(frozen=True) +class RLLibraryDescriptor: + """TensorBoard locations and tags for one RL library benchmark integration. + + Attributes: + framework: Schema framework id. + tfevents_pattern: Glob relative to the run log directory matching TensorBoard event files. + reward_tag: TensorBoard scalar tag for mean reward per iteration. + ep_length_tag: TensorBoard scalar tag for mean episode length per iteration. + """ + + framework: Framework + tfevents_pattern: str + reward_tag: str + ep_length_tag: str + + +RL_LIBRARY_DESCRIPTORS: dict[Framework, RLLibraryDescriptor] = { + "rsl_rl": RLLibraryDescriptor( + framework="rsl_rl", + tfevents_pattern="events*", + reward_tag="Train/mean_reward", + ep_length_tag="Train/mean_episode_length", + ), + "rl_games": RLLibraryDescriptor( + framework="rl_games", + tfevents_pattern="summaries/events*", + reward_tag="rewards/iter", + ep_length_tag="episode_lengths/iter", + ), + "skrl": RLLibraryDescriptor( + framework="skrl", + tfevents_pattern="events*", + reward_tag="Reward / Total reward (mean)", + ep_length_tag="Episode / Total timesteps (mean)", + ), + "sb3": RLLibraryDescriptor( + framework="sb3", + tfevents_pattern="PPO_*/events*", + reward_tag="rollout/ep_rew_mean", + ep_length_tag="rollout/ep_len_mean", + ), +} + + +def parse_tf_logs(log_dir: str, pattern: str = "events*") -> dict[str, list[float]]: + """Load the latest TensorBoard events file under *log_dir* into ``{tag: [values]}``. + + Args: + log_dir: Directory (glob root) to search for event files. + pattern: Glob relative to *log_dir* matching the event files, e.g. + ``"events*"`` (root), ``"summaries/events*"`` (rl_games), or + ``"PPO_*/events*"`` (sb3). + + Returns: + Mapping of each scalar tag to its per-iteration value list; empty when no event file matched. + """ + list_of_files = glob.glob(os.path.join(log_dir, pattern)) + if not list_of_files: + logging.getLogger(__name__).warning( + "No TensorBoard event files matched %r under %r; returning empty log data.", pattern, log_dir + ) + return {} + latest_file = max(list_of_files, key=os.path.getmtime) + ea = event_accumulator.EventAccumulator(latest_file) + ea.Reload() + log_data: dict[str, list[float]] = {} + for tag in ea.Tags()["scalars"]: + log_data[tag] = [event.value for event in ea.Scalars(tag)] + return log_data + + +def get_success_rate_log(log_data: dict[str, list[float]]) -> list[float] | None: + """Return the per-iteration success-rate series from parsed TensorBoard *log_data*. + + Looks up the first present tag from :data:`SUCCESS_RATE_LOG_TAGS` in *log_data* (the + ``{tag: [values]}`` mapping produced by :func:`parse_tf_logs`) and returns its value + series, or ``None`` when no success tag was logged. + + Args: + log_data: Parsed TensorBoard scalars mapping each tag to its per-iteration list. + + Returns: + The per-iteration success-rate values, or ``None`` if no success tag is present. + """ + for tag in SUCCESS_RATE_LOG_TAGS: + if tag in log_data: + return log_data[tag] + return None + + +def success_rate_step_value(extras_log: dict) -> float | None: + """Return the scalar success-rate value for one env step from a live ``extras['log']`` dict. + + Unlike :func:`get_success_rate_log` (which returns a per-iteration *series* from parsed + TensorBoard logs), this reads the single scalar logged per step in the live environment + ``extras['log']`` mapping, coercing a tensor to ``float`` via ``.item()``. + + Args: + extras_log: The ``extras['log']`` mapping from one environment step. + + Returns: + The step's success-rate value as a ``float``, or ``None`` if no success tag is present. + """ + for tag in SUCCESS_RATE_LOG_TAGS: + if tag in extras_log: + val = extras_log[tag] + return float(val.item()) if hasattr(val, "item") else float(val) + return None + + +def check_convergence( + rewards: list[float], + threshold: float, + window_pct: float = 0.2, + cv_threshold: float = 20.0, +) -> dict[str, float | bool]: + """Check whether training rewards have converged. + + Passes when the trailing window mean exceeds *threshold* and the + coefficient of variation (CV) is below *cv_threshold*. + + Args: + rewards: Per-iteration mean reward values. + threshold: Minimum reward to pass. + window_pct: Fraction of iterations for the trailing window. + cv_threshold: Maximum CV (%) for stable convergence. + + Returns: + Dict with ``tail_mean``, ``cv``, and ``passed``. + """ + if not rewards: + return {"tail_mean": 0.0, "cv": 999.9, "passed": False} + window = max(1, int(len(rewards) * window_pct)) + tail = rewards[-window:] + tail_mean = statistics.mean(tail) + tail_std = statistics.stdev(tail) if len(tail) > 1 else 0.0 + cv = (tail_std / abs(tail_mean) * 100) if tail_mean != 0 else 999.9 + passed = tail_mean >= threshold and cv <= cv_threshold + return {"tail_mean": round(tail_mean, 2), "cv": round(cv, 1), "passed": passed} + + +class SuccessRateTracker: + """Accumulates a per-iteration success-rate metric and checks trailing-window convergence. + + Args: + threshold: Minimum value to consider a pass. + window: Consecutive iterations above *threshold* to trigger convergence. + num_steps_per_env: Steps per RL iteration (for boundary detection). + """ + + def __init__(self, threshold: float, window: int, num_steps_per_env: int): + self.threshold = threshold + self.window = window + self.num_steps_per_env = num_steps_per_env + + self.history: list[float] = [] + self._step_count = 0 + self._iter_sum = 0.0 + self._iter_count = 0 + + def record_step(self, extras: dict) -> None: + """Record one env step.""" + val = success_rate_step_value(extras.get("log", {})) + if val is not None: + self._iter_sum += val + self._iter_count += 1 + self._step_count += 1 + + def end_iteration(self) -> float | None: + """Finalize the current iteration. Returns mean metric, or ``None`` if no data.""" + if self._iter_count == 0: + return None + mean = self._iter_sum / self._iter_count + self.history.append(mean) + self._iter_sum = 0.0 + self._iter_count = 0 + return mean + + @property + def at_iteration_boundary(self) -> bool: + """Whether the tracker has seen exactly a full iteration's worth of steps. + + Assumes :meth:`record_step` is called exactly once per env step. This property is + used only by the rsl_rl wrapper (whose patched ``env.step`` calls + :meth:`record_step` once per env step); the rl_games observer ends iterations + directly via ``after_steps`` and does not use this property. + Integrations that call :meth:`record_step` more or fewer times per env step will + break iteration accounting. + """ + return self.num_steps_per_env > 0 and self._step_count % self.num_steps_per_env == 0 + + @property + def converged(self) -> bool: + if len(self.history) < self.window: + return False + return all(v >= self.threshold for v in self.history[-self.window :]) + + @property + def current_iteration(self) -> int: + return len(self.history) + + @property + def tail_mean(self) -> float: + if not self.history: + return 0.0 + tail = self.history[-self.window :] if len(self.history) >= self.window else self.history + return statistics.mean(tail) + + +def mean_std_peak(values: Sequence[float]) -> MeanStd: + """Aggregate *values* into a :class:`MeanStd` with ``peak`` = max. Empty -> all zeros. + + Args: + values: Per-sample values [same unit as the field being aggregated]. + """ + vals = list(values) + if not vals: + return MeanStd(mean=0.0, std=0.0, peak=0.0) + mean = statistics.mean(vals) + std = statistics.stdev(vals) if len(vals) > 1 else 0.0 + return MeanStd(mean=mean, std=std, peak=max(vals)) + + +def mean_std(values: Sequence[float]) -> MeanStd: + """Aggregate *values* into a :class:`MeanStd` with ``peak`` omitted (``None``). + + Use for quantities where a peak is uninformative (e.g. utilisation). Empty -> zeros. + + Args: + values: Per-sample values. + """ + vals = list(values) + if not vals: + return MeanStd(mean=0.0, std=0.0, peak=None) + mean = statistics.mean(vals) + std = statistics.stdev(vals) if len(vals) > 1 else 0.0 + return MeanStd(mean=mean, std=std, peak=None) + + +def ema(series: Sequence[float], alpha: float) -> float: + """Exponential-moving-average final value of *series* (``e_0 = x_0``). Empty -> ``0.0``. + + Args: + series: Per-iteration values. + alpha: Smoothing factor in [0, 1]; higher weights recent values more. + """ + vals = list(series) + if not vals: + return 0.0 + e = float(vals[0]) + for x in vals[1:]: + e = alpha * float(x) + (1.0 - alpha) * e + return e diff --git a/source/isaaclab/isaaclab/test/benchmark/profiling.py b/source/isaaclab/isaaclab/test/benchmark/profiling.py new file mode 100644 index 000000000000..d108dcfe3bac --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/profiling.py @@ -0,0 +1,128 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Isaac-Sim-free cProfile parsing for startup benchmarks. + +Filter to IsaacLab + first-level external calls and return per-function +own/cum time and call counts. +""" + +from __future__ import annotations + +import cProfile +import fnmatch +import io +import logging +import os +import pstats + +logger = logging.getLogger(__name__) + + +def parse_cprofile_stats( + profile: cProfile.Profile, + isaaclab_prefixes: list[str], + top_n: int = 30, + whitelist: list[str] | None = None, +) -> list[tuple[str, float, float, int]]: + """Parse cProfile stats, filtering to IsaacLab + first-level external calls. + + Walks the pstats data and keeps functions that are either (a) inside an + IsaacLab source directory, or (b) directly called by an IsaacLab function. + Results are sorted by own-time (tottime) descending. + + When *whitelist* is provided, only functions whose labels match at least one + ``fnmatch`` pattern are returned. Patterns that match no profiled function + emit a ``(pattern, 0.0, 0.0, 0)`` placeholder so dashboards always receive + consistent keys. The *top_n* parameter is ignored in whitelist mode. + + Args: + profile: A completed cProfile.Profile instance (after .disable()). + isaaclab_prefixes: Absolute file path prefixes identifying IsaacLab source + (e.g. ["/home/user/IsaacLab/source/isaaclab", ...]). + top_n: Maximum number of functions to return. Ignored when + *whitelist* is provided. + whitelist: Optional list of ``fnmatch`` patterns to select specific + functions (e.g. ``["isaaclab.cloner.*:usd_replicate"]``). + + Returns: + List of (label, tottime_ms, cumtime_ms, ncalls) tuples sorted by + tottime descending. + """ + stats = pstats.Stats(profile, stream=io.StringIO()) + + def _is_isaaclab(filename: str) -> bool: + return any(filename.startswith(prefix) for prefix in isaaclab_prefixes) + + def _make_label(filename: str, funcname: str) -> str: + # For builtins/C-extensions the filename is something like "~" or "" + if not filename or filename.startswith("<") or filename == "~": + return funcname + # Convert absolute path to dotted module-style label + for prefix in sorted(isaaclab_prefixes, key=len, reverse=True): + if filename.startswith(prefix): + rel = os.path.relpath(filename, prefix) + # Strip .py, replace os.sep with dot + rel = rel.replace(os.sep, ".").removesuffix(".py") + return f"{rel}:{funcname}" + # External function — try to find the top-level package name + # e.g. ".../site-packages/torch/nn/modules/linear.py" -> "torch.nn.modules.linear" + parts = filename.replace(os.sep, "/").removesuffix(".py").split("/") + # Find "site-packages" anchor or fall back to last 3 components + try: + sp_idx = parts.index("site-packages") + short = ".".join(parts[sp_idx + 1 :]) + except ValueError: + short = ".".join(parts[-3:]) if len(parts) >= 3 else ".".join(parts) + return f"{short}:{funcname}" + + # NOTE: stats.stats is an internal CPython dict, not part of the public pstats API. + # The public get_stats_profile() (Python 3.9+) doesn't expose caller info, which + # we need for the first-level external call filter. If a future Python release + # breaks this, switch to get_stats_profile() and drop the caller-based filtering. + results = [] + for func_key, (_pcalls, ncalls, tottime, cumtime, callers) in stats.stats.items(): + filename, _, funcname = func_key + if _is_isaaclab(filename): + label = _make_label(filename, funcname) + results.append((label, tottime * 1000.0, cumtime * 1000.0, ncalls)) + else: + # Check if any direct caller is an IsaacLab function + for caller_key in callers: + caller_filename = caller_key[0] + if _is_isaaclab(caller_filename): + label = _make_label(filename, funcname) + results.append((label, tottime * 1000.0, cumtime * 1000.0, ncalls)) + break + + # Sort by tottime (own-time) descending + results.sort(key=lambda x: x[1], reverse=True) + + if whitelist is None: + return results[:top_n] + + # Whitelist mode: filter by fnmatch patterns, emit placeholders for unmatched patterns + matched: dict[str, tuple[str, float, float, int]] = {} + matched_patterns: set[str] = set() + for label, tottime, cumtime, ncalls in results: + for pattern in whitelist: + if fnmatch.fnmatch(label, pattern): + if label not in matched: + matched[label] = (label, tottime, cumtime, ncalls) + matched_patterns.add(pattern) + + # Add 0 placeholders for patterns that matched nothing + for pattern in whitelist: + if pattern not in matched_patterns: + logger.warning( + "Whitelist pattern '%s' matched no profiled functions. " + "Check for typos or verify the function ran during this phase.", + pattern, + ) + matched[pattern] = (pattern, 0.0, 0.0, 0) + + filtered = list(matched.values()) + filtered.sort(key=lambda x: x[1], reverse=True) + return filtered diff --git a/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py b/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py index a4396371b3d3..66ca064554a0 100644 --- a/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py +++ b/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py @@ -6,6 +6,7 @@ import importlib.metadata import os import subprocess +import sys from isaaclab.test.benchmark.interfaces import MeasurementData, MeasurementDataRecorder from isaaclab.test.benchmark.measurements import DictMetadata, StringMetadata @@ -16,7 +17,7 @@ class VersionInfoRecorder(MeasurementDataRecorder): def __init__(self): - self._version_info = {} + self._version_info: dict[str, str | None] = {} self._dev_info = {} self._get_version_info() self._get_git_info() @@ -47,9 +48,45 @@ def _get_pkg_version(self, pip_name: str) -> str | None: except Exception: return None - def _record(self, key: str, version: str | None) -> None: - """Store a version entry only if version is non-empty.""" - if version: + def _get_kit_version(self) -> str | None: + """Get the version from the active Kit application.""" + app_module = sys.modules.get("omni.kit.app") + if app_module is None: + return None + try: + app = app_module.get_app() + get_version = getattr(app, "get_kit_version", None) + if not callable(get_version): + get_version = getattr(app, "get_build_version", None) + return str(get_version()) if callable(get_version) else None + except Exception: + return None + + def _get_isaacsim_version(self) -> str | None: + """Get the Isaac Sim version from an install or active Kit runtime.""" + try: + with open(os.path.join(os.environ["ISAAC_PATH"], "VERSION")) as file: + return file.read().strip() + except Exception: + pass + try: + from isaacsim.core.version import get_version + + core, prerelease, _, _, _, _, _, buildtag = get_version() + if core: + version = str(core) + if prerelease: + version += f"-{prerelease}" + if buildtag: + version += f"+{buildtag}" + return version + except Exception: + pass + return self._get_pkg_version("isaacsim") + + def _record(self, key: str, version: str | None, *, nullable: bool = False) -> None: + """Store a version entry, preserving null for explicitly nullable keys.""" + if version or nullable: self._version_info[key] = version def _get_version_info(self) -> None: @@ -60,14 +97,10 @@ def _get_version_info(self) -> None: version = self._get_version("warp", "config.version") or self._get_version("warp") self._record("warp", version) - # isaacsim - self._record("isaacsim", self._get_version("isaacsim")) - - # kit (from omni.kit if available) - version = self._get_version("omni.kit", "app.get_app().get_build_version") - if not version: - version = self._get_version("carb", "settings.get_settings().get('/app/version')") - self._record("kit", version) + # Kit and Isaac Sim are meaningful only for an active Kit runtime. + version = self._get_kit_version() + self._record("kit", version, nullable=True) + self._record("isaacsim", self._get_isaacsim_version() if version else None, nullable=True) # torch self._record("torch", self._get_version("torch")) @@ -83,7 +116,8 @@ def _get_version_info(self) -> None: self._record("isaaclab_rl", self._get_pkg_version("isaaclab_rl")) # Renderers & physics engines - self._record("ovrtx", self._get_pkg_version("ovrtx")) + self._record("ovrtx", self._get_pkg_version("ovrtx"), nullable=True) + self._record("ovphysx", self._get_pkg_version("isaaclab_ovphysx"), nullable=True) self._record("newton", self._get_pkg_version("newton")) self._record("mujoco", self._get_pkg_version("mujoco")) self._record("mujoco_warp", self._get_pkg_version("mujoco-warp")) diff --git a/source/isaaclab/isaaclab/test/benchmark/schema.py b/source/isaaclab/isaaclab/test/benchmark/schema.py index 7a0e2d733fd3..84e6e07f0f2d 100644 --- a/source/isaaclab/isaaclab/test/benchmark/schema.py +++ b/source/isaaclab/isaaclab/test/benchmark/schema.py @@ -94,8 +94,8 @@ class Hardware: class Versions: """Software versions captured at run time. - Framework-specific fields (``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``) are - ``None`` when the corresponding framework is not used by the run. + Version fields are ``None`` when the corresponding runtime or package is + unavailable. """ isaaclab: str @@ -112,6 +112,18 @@ class Versions: git_commit: str | None git_branch: str | None git_dirty: bool + numpy: str | None = None + isaaclab_newton: str | None = None + isaaclab_physx: str | None = None + isaaclab_ov: str | None = None + isaaclab_tasks: str | None = None + isaaclab_rl: str | None = None + ovrtx: str | None = None + ovphysx: str | None = None + mujoco: str | None = None + cuda_bindings: str | None = None + usd_core: str | None = None + isaaclab_release: str | None = None @dataclass(frozen=True) diff --git a/source/isaaclab/isaaclab/test/benchmark/stepping.py b/source/isaaclab/isaaclab/test/benchmark/stepping.py new file mode 100644 index 000000000000..969ce6cd1b55 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/stepping.py @@ -0,0 +1,85 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Backend-agnostic random-action stepping helpers for benchmarks. + +This module is intentionally lightweight: ``torch`` and ``numpy`` are +imported lazily inside each function so that importing this module has +no heavy-weight side effects. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import torch + + +def sample_random_actions(env) -> torch.Tensor | dict[str, torch.Tensor]: + """Sample random actions for a single-agent or multi-agent environment. + + For multi-agent environments (those where ``env.unwrapped`` exposes an + ``action_spaces`` attribute), one batch of actions is sampled per agent + using that agent's action space. For single-agent environments a uniform + sample in [-1, 1] is returned. + + Args: + env: A Gym-compatible environment wrapper. ``env.unwrapped`` must + expose ``num_envs`` and ``device``, plus either ``action_spaces`` + (multi-agent) or ``single_action_space`` (single-agent). + + Returns: + A ``torch.Tensor`` of shape ``(num_envs, action_dim)`` for + single-agent environments, or a ``dict`` mapping agent name to a + tensor of the same shape for multi-agent environments. + """ + import numpy as np # noqa: PLC0415 + import torch # noqa: PLC0415 + + u = env.unwrapped + + if hasattr(u, "action_spaces"): + # Multi-agent: sample each agent's action space independently. + return { + agent: torch.as_tensor( + np.stack([space.sample() for _ in range(u.num_envs)]), + dtype=torch.float32, + device=u.device, + ) + for agent, space in u.action_spaces.items() + } + else: + # Single-agent: uniform random actions in [-1, 1]. + return 2.0 * torch.rand(u.num_envs, u.single_action_space.shape[0], device=u.device) - 1.0 + + +def run_runtime_loop(env, num_frames: int) -> list[float]: + """Step the environment ``num_frames`` times and record per-step wall times [s]. + + Calls ``env.reset()`` once before the loop, then on each frame samples + random actions via :func:`sample_random_actions`, steps the environment, + and records the elapsed wall-clock time for that step. + + Args: + env: A Gym-compatible environment. + num_frames: Number of environment steps to run. + + Returns: + A list of length ``num_frames`` containing per-step wall times [s]. + """ + env.reset() + + step_times: list[float] = [] + + for _ in range(num_frames): + actions = sample_random_actions(env) + t0 = time.perf_counter_ns() + env.step(actions) + t1 = time.perf_counter_ns() + step_times.append((t1 - t0) / 1e9) + + return step_times diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index e5437619c8c7..8c27e9ad4379 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -8,7 +8,10 @@ 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 @@ -41,6 +44,40 @@ def test_livestream_rejects_disabled_visualizers(): _ensure_livestream_kit_visualizer(args) +def test_launch_simulation_preserves_failure_exit_code(monkeypatch: pytest.MonkeyPatch): + close_args = {} + + class _FakeApp: + def close(self, *, exit_code: int = 0) -> None: + close_args["exit_code"] = exit_code + + class _FakeAppLauncher: + def __init__(self, _launcher_args): + self.app = _FakeApp() + + scan = sim_launcher.Scan( + resolved_physics_cfg=None, + effective_cfg=object(), + visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + has_ovrtx=False, + has_kit_camera=False, + has_kit_physics=True, + has_kitless_physics=False, + has_ovphysx_physics=False, + needs_kit=True, + ) + monkeypatch.setattr(sim_launcher, "scan", lambda cfg, physics: scan) + monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) + monkeypatch.setattr(app_module, "AppLauncher", _FakeAppLauncher) + monkeypatch.setattr(utils_module, "has_kit", lambda: False) + + with pytest.raises(RuntimeError, match="sentinel"): + with sim_launcher.launch_simulation(object(), argparse.Namespace()): + raise RuntimeError("sentinel") + + assert close_args == {"exit_code": 1} + + class _DummySettings: def __init__(self): self.values = {} diff --git a/source/isaaclab/test/benchmark/test_benchmark_core.py b/source/isaaclab/test/benchmark/test_benchmark_core.py index d679880d82ce..03bba5bcb0e8 100644 --- a/source/isaaclab/test/benchmark/test_benchmark_core.py +++ b/source/isaaclab/test/benchmark/test_benchmark_core.py @@ -3,321 +3,269 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for BaseIsaacLabBenchmark class.""" +"""Tests for BaseIsaacLabBenchmark.""" import json import os -import tempfile +from dataclasses import replace import pytest -from isaaclab.test.benchmark import backends +from isaaclab.test.benchmark import formatters from isaaclab.test.benchmark.benchmark_core import BaseIsaacLabBenchmark from isaaclab.test.benchmark.measurements import SingleMeasurement, StringMetadata -# ============================================================================== -# BaseIsaacLabBenchmark Tests -# ============================================================================== +@pytest.fixture(autouse=True) +def reset_formatters(): + formatters.MetricsFormatter.reset_instances() + yield + formatters.MetricsFormatter.reset_instances() + + +def _minimal_runtime_bundle(): + from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + Resources, + RunConfig, + RunIdentity, + Runtime, + RuntimeBundle, + StartupTime, + Versions, + ) + + return RuntimeBundle( + run=RunIdentity( + run_id="runtime_newton_mjwarp_Isaac-Ant-Direct-v0_20260422-131500_seed42", + framework=None, + config=RunConfig(physics_backend="newton_mjwarp", rendering_backend="none"), + task="Isaac-Ant-Direct-v0", + seed=42, + start_time_utc="2026-04-22T13:15:00Z", + end_time_utc="2026-04-22T13:15:10Z", + duration_s=10.0, + status="completed", + num_envs=16, + ), + versions=Versions( + isaaclab="4.6.8", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="2.5.1", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ), + hardware=Hardware( + hostname="benchmark-host", + gpu_devices=[GpuDeviceInfo(name="NVIDIA H100 80GB", mem_gb=80.0, compute_cap="9.0")], + cpu_name="AMD EPYC 7763", + cpu_count=64, + ram_gb=512.0, + ), + runtime=Runtime( + startup_time_s=StartupTime(app_launch=1.0, env_creation=2.0, first_step=0.5), + iterations_completed=1, + total_wall_time_s=4.0, + steps_per_iteration=24, + iteration_time_s=MeanStd(mean=1.0, std=0.0), + collection_fps=MeanStd(mean=100.0, std=0.0), + total_fps=MeanStd(mean=100.0, std=0.0), + iterations_per_s=MeanStd(mean=1.0, std=0.0), + ), + resources=Resources( + gpu_util_pct=MeanStd(mean=80.0, std=5.0), + gpu_mem_gb=MeanStd(mean=10.0, std=0.5, peak=12.0), + cpu_util_pct=MeanStd(mean=30.0, std=4.0), + ram_gb=MeanStd(mean=20.0, std=1.0, peak=24.0), + ), + ) + + +def _minimal_training_bundle(): + from isaaclab.test.benchmark.schema import Learning, LearningCurve, TrainingBundle + + bundle = _minimal_runtime_bundle() + return TrainingBundle( + run=replace(bundle.run, framework="rsl_rl", max_iterations=1), + versions=bundle.versions, + hardware=bundle.hardware, + runtime=bundle.runtime, + resources=bundle.resources, + learning=Learning( + ema_alpha=0.95, + reward=LearningCurve(final_raw=3.0, final_ema=2.5, series_per_iter=[1.0, 3.0]), + ep_length=LearningCurve(final_raw=20.0, final_ema=18.0, series_per_iter=[10.0, 20.0]), + ), + success_rate=0.75, + ) + + +def _minimal_startup_bundle(): + from isaaclab.test.benchmark.schema import CProfileFunction, StartupBundle, StartupConfig, StartupPhase + + bundle = _minimal_runtime_bundle() + return StartupBundle( + run=replace(bundle.run, num_envs=None), + versions=bundle.versions, + hardware=bundle.hardware, + phases={ + "python_imports": StartupPhase( + total_time_s=0.25, + top_functions=[ + CProfileFunction( + name="isaaclab_tasks.utils:import_packages", own_time_s=0.1, cum_time_s=0.2, calls=2 + ) + ], + ) + }, + config=StartupConfig(top_n=1, whitelist=None), + ) -class TestBaseIsaacLabBenchmark: - """Tests for BaseIsaacLabBenchmark.""" - @pytest.fixture - def temp_output_dir(self): - """Create a temporary output directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield tmpdir +def _formatter_keys(benchmark: BaseIsaacLabBenchmark) -> list[str]: + return [key for key, _ in benchmark._metrics] - @pytest.fixture(autouse=True) - def reset_backends(self): - """Reset backend instances before each test.""" - backends.MetricsBackend.reset_instances() - yield - backends.MetricsBackend.reset_instances() - def test_initialization(self, temp_output_dir): - """Test benchmark initializes correctly.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - output_prefix="test", - ) - assert benchmark.benchmark_name == "test_benchmark" - assert benchmark.output_path == temp_output_dir - assert "test_" in benchmark.output_prefix - - def test_initialization_creates_output_dir(self): - """Test that initialization creates output directory if it doesn't exist.""" - with tempfile.TemporaryDirectory() as tmpdir: - output_path = os.path.join(tmpdir, "nested", "output") - _benchmark = BaseIsaacLabBenchmark( # noqa: F841 - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=output_path, - use_recorders=False, - ) - assert os.path.exists(output_path) +def test_benchmark_collects_metadata_measurements_and_writes_json(tmp_path): + output_path = tmp_path / "nested" / "output" + benchmark = BaseIsaacLabBenchmark( + benchmark_name="my_workflow", + formatter_type="omniperf", + output_path=str(output_path), + use_recorders=False, + output_prefix="test", + ) - def test_initialization_with_recorders(self, temp_output_dir): - """Test benchmark initializes with recorders enabled.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - ) - assert benchmark._use_recorders is True - assert "CPUInfo" in benchmark._manual_recorders - assert "GPUInfo" in benchmark._manual_recorders - assert "MemoryInfo" in benchmark._manual_recorders - assert "VersionInfo" in benchmark._manual_recorders - - def test_initialization_without_recorders(self, temp_output_dir): - """Test benchmark initializes with recorders disabled.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - assert benchmark._use_recorders is False - assert not hasattr(benchmark, "_manual_recorders") or benchmark._manual_recorders is None - - def test_add_measurement(self, temp_output_dir): - """Test adding measurements to phases.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - measurement = SingleMeasurement(name="test_metric", value=42.0, unit="ms") - benchmark.add_measurement("test_phase", measurement=measurement) - assert "test_phase" in benchmark._phases - assert len(benchmark._phases["test_phase"].measurements) == 1 - assert benchmark._phases["test_phase"].measurements[0].name == "test_metric" - - def test_add_multiple_measurements(self, temp_output_dir): - """Test adding multiple measurements to a phase.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - measurements = [ + benchmark.add_measurement( + "runtime", + measurement=[ SingleMeasurement(name="metric1", value=10.0, unit="ms"), SingleMeasurement(name="metric2", value=20.0, unit="ms"), - ] - benchmark.add_measurement("test_phase", measurement=measurements) - assert len(benchmark._phases["test_phase"].measurements) == 2 - - def test_add_metadata(self, temp_output_dir): - """Test adding metadata to phases.""" + ], + ) + benchmark.add_measurement("runtime", metadata=StringMetadata(name="custom", data="value")) + benchmark._finalize_impl() + + with open(benchmark.output_file_path) as f: + data = json.load(f) + + assert output_path.exists() + assert benchmark.benchmark_name == "my_workflow" + assert benchmark._use_recorders is False + assert not hasattr(benchmark, "_manual_recorders") or benchmark._manual_recorders is None + assert data["benchmark_info"]["workflow_name"] == "my_workflow" + assert "timestamp" in data["benchmark_info"] + assert data["runtime"]["metric1"] == 10.0 + assert data["runtime"]["metric2"] == 20.0 + assert data["runtime"]["custom"] == "value" + + +def test_benchmark_updates_recorders_and_cleans_up(tmp_path): + benchmark = BaseIsaacLabBenchmark( + benchmark_name="test_benchmark", + formatter_type="omniperf", + output_path=str(tmp_path), + use_recorders=True, + output_prefix="test", + ) + + benchmark.add_measurement("runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms")) + benchmark.update_manual_recorders() + assert benchmark._manual_recorders["CPUInfo"]._n >= 1 + assert benchmark._manual_recorders["MemoryInfo"]._rss_n >= 1 + + benchmark._finalize_impl() + assert os.path.exists(benchmark.output_file_path) + assert benchmark._manual_recorders is None + assert benchmark._frametime_recorders is None + + +def test_formatter_selection_and_output_filenames(tmp_path): + default_benchmark = BaseIsaacLabBenchmark( + "default", formatter_type=" ", output_path=str(tmp_path), use_recorders=False + ) + assert _formatter_keys(default_benchmark) == ["omniperf"] + + single = BaseIsaacLabBenchmark("single", formatter_type="json", output_path=str(tmp_path), use_recorders=False) + single.add_measurement("runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms")) + single._finalize_impl() + assert os.path.exists(os.path.join(str(tmp_path), f"{single.output_prefix}.json")) + assert not os.path.exists(os.path.join(str(tmp_path), f"{single.output_prefix}_json.json")) + + multi = BaseIsaacLabBenchmark( + "multi", + formatter_type="schema,json,json", + output_path=str(tmp_path), + use_recorders=False, + output_prefix="test", + ) + assert _formatter_keys(multi) == ["schema", "json"] + multi.attach_bundle(_minimal_runtime_bundle()) + multi.add_measurement("runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms")) + multi._finalize_impl() + + schema_path = os.path.join(str(tmp_path), f"{multi.output_prefix}_schema.json") + json_path = os.path.join(str(tmp_path), f"{multi.output_prefix}_json.json") + assert os.path.exists(schema_path) + assert os.path.exists(json_path) + + with open(schema_path) as f: + schema_data = json.load(f) + with open(json_path) as f: + json_data = json.load(f) + assert schema_data != json_data + assert schema_data["run"]["task"] == "Isaac-Ant-Direct-v0" + + +def test_attached_bundles_are_projected_to_flat_formatters(tmp_path): + cases = [ + (_minimal_runtime_bundle(), "runtime", "Mean Total FPS", 100.0), + (_minimal_training_bundle(), "train", "Last Reward", 3.0), + (_minimal_startup_bundle(), "python_imports", "Wall Clock Time", 0.25), + ] + + for index, (bundle, phase, metric, expected) in enumerate(cases): benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, + f"bundle_{index}", + formatter_type="omniperf", + output_path=str(tmp_path), use_recorders=False, + output_prefix=f"bundle_{index}", ) - metadata = StringMetadata(name="test_key", data="test_value") - benchmark.add_measurement("test_phase", metadata=metadata) - assert "test_phase" in benchmark._phases - # Phase metadata includes automatic "phase" and "workflow_name" entries plus our custom one - assert len(benchmark._phases["test_phase"].metadata) == 3 - metadata_names = [m.name for m in benchmark._phases["test_phase"].metadata] - assert "test_key" in metadata_names - assert "phase" in metadata_names - assert "workflow_name" in metadata_names - - def test_update_manual_recorders(self, temp_output_dir): - """Test updating manual recorders.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - ) - # Should not raise - benchmark.update_manual_recorders() - # Check recorders were updated - CPUInfoRecorder has _n attribute - assert benchmark._manual_recorders["CPUInfo"]._n >= 1 - assert benchmark._manual_recorders["MemoryInfo"]._rss_n >= 1 - - def test_update_manual_recorders_disabled(self, temp_output_dir): - """Test that update_manual_recorders is a no-op when recorders are disabled.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - # Should not raise - benchmark.update_manual_recorders() - - def test_finalize_generates_output(self, temp_output_dir): - """Test that finalize creates output file.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - output_prefix="test", - ) - benchmark.add_measurement( - "runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms") - ) - benchmark.update_manual_recorders() + benchmark.attach_bundle(bundle) benchmark._finalize_impl() - # Check output file exists - assert os.path.exists(benchmark.output_file_path) - - def test_finalize_output_contains_measurements(self, temp_output_dir): - """Test that finalized output contains added measurements.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - output_prefix="test", - ) - benchmark.add_measurement( - "runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms") - ) - benchmark._finalize_impl() - - # Read and verify output with open(benchmark.output_file_path) as f: data = json.load(f) - - # Check that runtime phase is present with our measurement - assert "runtime" in data - assert "execution_time" in data["runtime"] - assert data["runtime"]["execution_time"] == 100.5 - - def test_finalize_cleans_up_recorders(self, temp_output_dir): - """Test that finalize cleans up recorders.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - output_prefix="test", - ) - benchmark.add_measurement( - "runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms") - ) - benchmark.update_manual_recorders() - benchmark._finalize_impl() - - # Recorders should be set to None - assert benchmark._manual_recorders is None - assert benchmark._frametime_recorders is None - - def test_workflow_metadata_in_output(self, temp_output_dir): - """Test that workflow name and timestamp metadata are in output.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="my_workflow", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - output_prefix="test", - ) - benchmark._finalize_impl() - - with open(benchmark.output_file_path) as f: - data = json.load(f) - - # Check benchmark_info phase has workflow metadata - assert "benchmark_info" in data - assert "workflow_name" in data["benchmark_info"] - assert data["benchmark_info"]["workflow_name"] == "my_workflow" - assert "timestamp" in data["benchmark_info"] - - -# ============================================================================== -# MetricsBackend Factory Tests -# ============================================================================== - - -class TestMetricsBackendFactory: - """Tests for MetricsBackend factory class.""" - - @pytest.fixture - def temp_output_dir(self): - """Create a temporary output directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield tmpdir - - @pytest.fixture(autouse=True) - def reset_backends(self): - """Reset backend instances before each test.""" - backends.MetricsBackend.reset_instances() - yield - backends.MetricsBackend.reset_instances() - - def test_get_json_backend(self): - """Test getting JSON backend instance.""" - backend = backends.MetricsBackend.get_instance("json") - assert isinstance(backend, backends.JSONFileMetrics) - - def test_get_osmo_backend(self): - """Test getting Osmo backend instance.""" - backend = backends.MetricsBackend.get_instance("osmo") - assert isinstance(backend, backends.OsmoKPIFile) - - def test_get_omniperf_backend(self): - """Test getting OmniPerf backend instance.""" - backend = backends.MetricsBackend.get_instance("omniperf") - assert isinstance(backend, backends.OmniPerfKPIFile) - - def test_get_summary_backend(self): - """Test getting Summary backend instance.""" - backend = backends.MetricsBackend.get_instance("summary") - assert isinstance(backend, backends.SummaryMetrics) - - def test_summary_backend_finalize_writes_json(self, temp_output_dir): - """Test that SummaryMetrics finalize writes JSON output (and does not raise).""" - backend = backends.MetricsBackend.get_instance("summary") - from isaaclab.test.benchmark.measurements import StringMetadata, TestPhase - - phase = TestPhase(phase_name="runtime") - phase.measurements.append(SingleMeasurement(name="Test FPS", value=60.0, unit="FPS")) - phase.metadata.append(StringMetadata(name="runtime workflow_name", data="summary_test")) - phase.metadata.append(StringMetadata(name="runtime phase", data="runtime")) - backend.add_metrics(phase) - output_path = temp_output_dir - output_filename = "summary_test" - backend.finalize(output_path, output_filename) - expected_path = os.path.join(output_path, f"{output_filename}.json") - assert os.path.exists(expected_path) - with open(expected_path) as f: - data = json.load(f) - assert isinstance(data, list) and len(data) >= 1 - assert any(p.get("phase_name") == "runtime" for p in data) - - def test_invalid_backend_type_raises_error(self): - """Test that invalid backend type raises ValueError.""" - with pytest.raises(ValueError, match="Unknown backend type"): - backends.MetricsBackend.get_instance("invalid_type") - - def test_backend_instance_is_cached(self): - """Test that backend instances are cached and reused.""" - backend1 = backends.MetricsBackend.get_instance("omniperf") - backend2 = backends.MetricsBackend.get_instance("omniperf") - assert backend1 is backend2 - - def test_reset_instances(self): - """Test that reset_instances clears the cache.""" - backend1 = backends.MetricsBackend.get_instance("omniperf") - backends.MetricsBackend.reset_instances() - backend2 = backends.MetricsBackend.get_instance("omniperf") - assert backend1 is not backend2 - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--maxfail=1"]) + assert data[phase][metric] == expected + + +def test_metrics_formatter_factory_registration_cache_and_errors(): + expected = { + "json": formatters.JSONFileMetrics, + "osmo": formatters.OsmoKPIFile, + "omniperf": formatters.OmniPerfKPIFile, + "summary": formatters.SummaryMetrics, + "schema": formatters.SchemaBundleFile, + } + for key, cls in expected.items(): + assert isinstance(formatters.MetricsFormatter.get_instance(key), cls) + + formatter = formatters.MetricsFormatter.get_instance("omniperf") + assert formatter is formatters.MetricsFormatter.get_instance("omniperf") + formatters.MetricsFormatter.reset_instances() + assert formatter is not formatters.MetricsFormatter.get_instance("omniperf") + + with pytest.raises(ValueError, match="Unknown formatter type"): + formatters.MetricsFormatter.get_instance("invalid_type") diff --git a/source/isaaclab/test/benchmark/test_builders.py b/source/isaaclab/test/benchmark/test_builders.py new file mode 100644 index 000000000000..1555c55e5652 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_builders.py @@ -0,0 +1,182 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for benchmark bundle builders (Isaac-Sim-free).""" + +import json +import os + +import pytest + +from isaaclab.test.benchmark import builders +from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + Resources, + RuntimeBundle, + StartupTime, + TrainingBundle, + Versions, +) +from isaaclab.test.benchmark.serialize import write_bundle_file + + +def _versions(): + return Versions( + isaaclab="4.6.8", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="2.5.1", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ) + + +def _hardware(): + return Hardware( + hostname="h", + gpu_devices=[GpuDeviceInfo(name="g", mem_gb=80.0, compute_cap="9.0")], + cpu_name="c", + cpu_count=64, + ram_gb=512.0, + ) + + +def _resources(): + return Resources( + gpu_util_pct=MeanStd(80.0, 5.0), + gpu_mem_gb=MeanStd(10.0, 0.5, 12.0), + cpu_util_pct=MeanStd(30.0, 4.0), + ram_gb=MeanStd(20.0, 1.0, 24.0), + ) + + +def test_run_config_presets_default_empty(): + assert builders.build_run_config("physx").presets == [] + assert builders.build_run_config("newton_mjwarp", presets=["rgb"]).presets == ["rgb"] + + +def test_run_identity_computes_duration(): + run = builders.build_run_identity( + run_id="x", + framework="rsl_rl", + config=builders.build_run_config("newton_mjwarp"), + task="t", + seed=0, + start_utc="2026-04-22T13:15:00+00:00", + end_utc="2026-04-22T13:15:30+00:00", + num_envs=4096, + max_iterations=500, + ) + assert run.duration_s == pytest.approx(30.0) + + +def test_build_runtime_aggregates(): + rt = builders.build_runtime( + startup_time_s=StartupTime(app_launch=1.0, env_creation=2.0, first_step=0.5), + iteration_times_s=[1.0, 1.0, 2.0], + collection_fps=[100.0, 110.0], + total_fps=[90.0, 95.0], + steps_per_iteration=24, + ) + assert rt.iterations_completed == 3 + assert rt.total_wall_time_s == pytest.approx(4.0) + assert rt.total_fps.peak == pytest.approx(95.0) + assert rt.iterations_per_s.mean > 0 + + +def test_build_training_bundle_round_trips(tmp_path): + run = builders.build_run_identity( + run_id="x", + framework="rsl_rl", + config=builders.build_run_config("physx"), + task="t", + seed=0, + start_utc="2026-04-22T13:15:00+00:00", + end_utc="2026-04-22T13:16:00+00:00", + num_envs=16, + max_iterations=5, + ) + rt = builders.build_runtime( + startup_time_s=StartupTime(1.0, 2.0, 0.5), + iteration_times_s=[1.0, 1.0], + collection_fps=[100.0], + total_fps=[90.0], + steps_per_iteration=24, + ) + learning = builders.build_learning(reward_series=[1.0, 2.0, 3.0], ep_length_series=[10.0, 12.0], ema_alpha=0.1) + b = builders.build_training_bundle( + run=run, + versions=_versions(), + hardware=_hardware(), + runtime=rt, + resources=_resources(), + learning=learning, + success_rate=0.9, + checkpoint_path="m.pt", + ) + assert isinstance(b, TrainingBundle) + assert b.learning.reward.final_raw == pytest.approx(3.0) + p = os.path.join(tmp_path, "training.json") + write_bundle_file(b, p) + with open(p) as fh: + data = json.load(fh) + assert data["success_rate"] == pytest.approx(0.9) + assert data["runtime"]["total_fps"]["mean"] == pytest.approx(90.0) + + +def test_build_learning_empty_series(): + learning = builders.build_learning(reward_series=[], ep_length_series=[], ema_alpha=0.1) + assert learning.reward.final_raw == pytest.approx(0.0) + assert learning.reward.final_ema == pytest.approx(0.0) + assert learning.reward.series_per_iter == [] + assert learning.ep_length.final_raw == pytest.approx(0.0) + assert learning.ep_length.final_ema == pytest.approx(0.0) + assert learning.ep_length.series_per_iter == [] + + +def test_build_learning_keep_series_false(): + learning = builders.build_learning( + reward_series=[1.0, 2.0], ep_length_series=[10.0], ema_alpha=0.1, keep_series=False + ) + assert learning.reward.series_per_iter is None + assert learning.ep_length.series_per_iter is None + + +def test_build_runtime_bundle_no_learning(tmp_path): + run = builders.build_run_identity( + run_id="x", + framework=None, + config=builders.build_run_config("newton_mjwarp"), + task="t", + seed=0, + start_utc="2026-04-22T13:15:00+00:00", + end_utc="2026-04-22T13:15:10+00:00", + num_envs=16, + ) + rt = builders.build_runtime( + startup_time_s=StartupTime(1.0, 2.0, 0.5), + iteration_times_s=[1.0], + collection_fps=[100.0], + total_fps=[100.0], + steps_per_iteration=24, + ) + b = builders.build_runtime_bundle( + run=run, versions=_versions(), hardware=_hardware(), runtime=rt, resources=_resources() + ) + assert isinstance(b, RuntimeBundle) + p = os.path.join(tmp_path, "runtime.json") + write_bundle_file(b, p) + with open(p) as fh: + assert json.load(fh)["run"]["framework"] is None diff --git a/source/isaaclab/test/benchmark/test_capture.py b/source/isaaclab/test/benchmark/test_capture.py new file mode 100644 index 000000000000..01c3ba794a7e --- /dev/null +++ b/source/isaaclab/test/benchmark/test_capture.py @@ -0,0 +1,312 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for benchmark capture helpers (Isaac-Sim-free, fake recorders).""" + +from types import SimpleNamespace +from typing import Literal + +import pytest + +import isaaclab.test.benchmark.capture as capture +from isaaclab.test.benchmark.capture import ( + capture_hardware, + capture_resources, + capture_versions, + run_config_from_presets, + synth_run_id, +) +from isaaclab.test.benchmark.interfaces import MeasurementData +from isaaclab.test.benchmark.measurements import ( + DictMetadata, + FloatMetadata, + IntMetadata, + SingleMeasurement, + StringMetadata, +) +from isaaclab.test.benchmark.schema import Hardware, Resources, Versions + + +class _Rec: + def __init__(self, data): + self._data = data + + def get_data(self): + return self._data + + +class _Bm: + def __init__(self, recorders): + self._manual_recorders = recorders + + +def test_capture_versions_renames_and_defaults(): + md = [ + StringMetadata(name="isaaclab_version", data="4.6.8"), + StringMetadata(name="torch_version", data="2.5.1"), + StringMetadata(name="mujoco_warp_version", data="0.0.4"), + StringMetadata(name="stable_baselines3_version", data="2.3.0"), + DictMetadata(name="dev", data={"commit_hash": "abc123", "branch": "develop", "dirty": True}), + ] + bm = _Bm({"VersionInfo": _Rec(MeasurementData(measurements=[], metadata=md, artefacts=[]))}) + v = capture_versions(bm) + assert isinstance(v, Versions) + assert v.isaaclab == "4.6.8" and v.torch == "2.5.1" + assert v.mjwarp == "0.0.4" + assert v.sb3 == "2.3.0" + assert v.git_commit == "abc123" and v.git_branch == "develop" and v.git_dirty is True + assert v.isaacsim is None + + +def test_capture_versions_preserves_runtime_packages(): + md = [ + StringMetadata(name="numpy_version", data="2.4.4"), + StringMetadata(name="isaaclab_newton_version", data="1.0.2"), + StringMetadata(name="isaaclab_physx_version", data="2.0.1"), + StringMetadata(name="isaaclab_ov_version", data="0.4.6"), + StringMetadata(name="isaaclab_tasks_version", data="8.0.1"), + StringMetadata(name="isaaclab_rl_version", data="0.6.1"), + StringMetadata(name="ovrtx_version", data=None), + StringMetadata(name="ovphysx_version", data="3.0.5"), + StringMetadata(name="mujoco_version", data="3.8.1"), + StringMetadata(name="cuda_bindings_version", data="12.9.4"), + StringMetadata(name="usd_core_version", data="25.11"), + StringMetadata(name="isaaclab_release_version", data="3.0.0"), + ] + bm = _Bm({"VersionInfo": _Rec(MeasurementData(measurements=[], metadata=md, artefacts=[]))}) + + versions = capture_versions(bm) + + assert versions.numpy == "2.4.4" + assert versions.isaaclab_newton == "1.0.2" + assert versions.isaaclab_physx == "2.0.1" + assert versions.isaaclab_ov == "0.4.6" + assert versions.isaaclab_tasks == "8.0.1" + assert versions.isaaclab_rl == "0.6.1" + assert versions.ovrtx is None + assert versions.ovphysx == "3.0.5" + assert versions.mujoco == "3.8.1" + assert versions.cuda_bindings == "12.9.4" + assert versions.usd_core == "25.11" + assert versions.isaaclab_release == "3.0.0" + + +def test_capture_resources_peaks(): + gpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="GPU Utilization", value=80.0, unit="%"), + SingleMeasurement(name="GPU Utilization std", value=5.0, unit="%"), + SingleMeasurement(name="GPU Memory Used", value=10.0, unit="GB"), + SingleMeasurement(name="GPU Memory Used std", value=0.5, unit="GB"), + SingleMeasurement(name="GPU Memory Used peak", value=12.0, unit="GB"), + ], + metadata=[], + artefacts=[], + ) + ) + cpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="CPU Utilization", value=30.0, unit="%"), + SingleMeasurement(name="CPU Utilization std", value=4.0, unit="%"), + ], + metadata=[], + artefacts=[], + ) + ) + mem = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="System Memory RSS", value=20.0, unit="GB"), + SingleMeasurement(name="System Memory RSS std", value=1.0, unit="GB"), + SingleMeasurement(name="System Memory RSS peak", value=24.0, unit="GB"), + ], + metadata=[], + artefacts=[], + ) + ) + r = capture_resources(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) + assert isinstance(r, Resources) + assert r.gpu_util_pct.peak is None + assert r.gpu_mem_gb.peak == pytest.approx(12.0) + assert r.ram_gb.peak == pytest.approx(24.0) + assert r.cpu_util_pct.peak is None + + +def test_capture_resources_uses_current_gpu_for_multiple_devices(): + gpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="GPU 1 Utilization", value=80.0, unit="%"), + SingleMeasurement(name="GPU 1 Utilization std", value=5.0, unit="%"), + SingleMeasurement(name="GPU 1 Memory Used", value=10.0, unit="GB"), + SingleMeasurement(name="GPU 1 Memory Used std", value=0.5, unit="GB"), + SingleMeasurement(name="GPU 1 Memory Used peak", value=12.0, unit="GB"), + ], + metadata=[ + IntMetadata(name="gpu_device_count", data=2), + IntMetadata(name="gpu_current_device", data=1), + ], + artefacts=[], + ) + ) + resources = capture_resources(_Bm({"GPUInfo": gpu})) + + assert resources.gpu_util_pct.mean == pytest.approx(80.0) + assert resources.gpu_mem_gb.peak == pytest.approx(12.0) + + +def test_capture_hardware(): + gpu = _Rec( + MeasurementData( + measurements=[], + metadata=[ + DictMetadata( + name="gpu_devices", + data={"0": {"name": "H100", "total_memory_gb": 80.0, "compute_capability": "9.0"}}, + ), + ], + artefacts=[], + ) + ) + cpu = _Rec( + MeasurementData( + measurements=[], + metadata=[ + StringMetadata(name="cpu_name", data="EPYC"), + IntMetadata(name="physical_cores", data=64), + ], + artefacts=[], + ) + ) + mem = _Rec( + MeasurementData( + measurements=[], + metadata=[FloatMetadata(name="total_ram_gb", data=512.0)], + artefacts=[], + ) + ) + h = capture_hardware(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) + assert isinstance(h, Hardware) + assert h.gpu_devices[0].name == "H100" and h.gpu_devices[0].mem_gb == pytest.approx(80.0) + assert h.gpu_devices[0].compute_cap == "9.0" + assert h.cpu_name == "EPYC" and h.cpu_count == 64 and h.ram_gb == pytest.approx(512.0) + assert isinstance(h.hostname, str) and h.hostname + + +def test_capture_handles_missing_recorders(): + bm = _Bm(None) + assert isinstance(capture_versions(bm), Versions) + assert isinstance(capture_hardware(bm), Hardware) + assert isinstance(capture_resources(bm), Resources) + + +def test_synth_run_id(): + rid = synth_run_id("rsl_rl", "physx", "Isaac-Ant-Direct-v0", 42, "20260612-150000") + assert "rsl_rl" in rid and "physx" in rid and "42" in rid + + +def test_run_config_from_presets_resolves_backend_configuration(monkeypatch): + cases = [ + ([], "physx", "none", []), + ( + ["newton_mjwarp", "ovrtx_renderer", "rgb"], + "newton_mjwarp", + "ovrtx", + ["newton_mjwarp", "ovrtx_renderer", "rgb"], + ), + (["newton"], "newton_mjwarp", "none", ["newton"]), + ( + ["physics=newton_mjwarp", "renderer=ovrtx_renderer", "presets=rgb,depth"], + "newton_mjwarp", + "ovrtx", + ["newton_mjwarp", "ovrtx_renderer", "rgb", "depth"], + ), + ] + for tokens, physics, rendering, presets in cases: + cfg = run_config_from_presets(tokens) + assert cfg.physics_backend == physics + assert cfg.rendering_backend == rendering + assert cfg.presets == presets + + monkeypatch.setattr(capture, "PhysicsBackend", Literal["physx", "newton_mjwarp_vbd"], raising=False) + assert run_config_from_presets(["newton_mjwarp_vbd"]).physics_backend == "newton_mjwarp_vbd" + + env_cfg = SimpleNamespace( + sim=SimpleNamespace(physics=SimpleNamespace(class_type="isaaclab_newton.physics:NewtonMJWarpManager")), + camera=SimpleNamespace(renderer_cfg=SimpleNamespace(renderer_type="isaac_rtx")), + ) + cfg = run_config_from_presets([], env_cfg=env_cfg) + assert cfg.physics_backend == "newton_mjwarp" + assert cfg.rendering_backend == "isaacsim_rtx" + + +def test_capture_resources_peak_clamped_to_mean_when_peak_row_absent(): + # Build a recorder that has mean/std rows but no peak rows. + # capture_resources must clamp peak to mean rather than leaving it at 0.0 + # (which would violate MeanStd.__post_init__ since peak < mean). + gpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="GPU Utilization", value=5.0, unit="%"), + SingleMeasurement(name="GPU Utilization std", value=1.0, unit="%"), + SingleMeasurement(name="GPU Memory Used", value=10.0, unit="GB"), + SingleMeasurement(name="GPU Memory Used std", value=0.5, unit="GB"), + # No "GPU Memory Used peak" row — peak defaults to 0.0 before clamping. + ], + metadata=[], + artefacts=[], + ) + ) + mem = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="System Memory RSS", value=10.0, unit="GB"), + SingleMeasurement(name="System Memory RSS std", value=0.2, unit="GB"), + # No "System Memory RSS peak" row. + ], + metadata=[], + artefacts=[], + ) + ) + cpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="CPU Utilization", value=20.0, unit="%"), + SingleMeasurement(name="CPU Utilization std", value=2.0, unit="%"), + ], + metadata=[], + artefacts=[], + ) + ) + # Must not raise ValueError from MeanStd.__post_init__. + r = capture_resources(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) + assert r.gpu_mem_gb.peak == pytest.approx(10.0) + assert r.ram_gb.peak == pytest.approx(10.0) + + +def test_capture_hardware_gpu_devices_sorted_by_numeric_index(): + # Keys "10", "2", "0" should be returned in numeric order 0, 2, 10 — not lexical "0","10","2". + gpu = _Rec( + MeasurementData( + measurements=[], + metadata=[ + DictMetadata( + name="gpu_devices", + data={ + "10": {"name": "H100-10", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + "2": {"name": "H100-2", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + "0": {"name": "H100-0", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + }, + ), + ], + artefacts=[], + ) + ) + bm = _Bm({"GPUInfo": gpu}) + h = capture_hardware(bm) + names = [d.name for d in h.gpu_devices] + assert names == ["H100-0", "H100-2", "H100-10"] diff --git a/source/isaaclab/test/benchmark/test_formatters.py b/source/isaaclab/test/benchmark/test_formatters.py new file mode 100644 index 000000000000..c1bc3f860a2a --- /dev/null +++ b/source/isaaclab/test/benchmark/test_formatters.py @@ -0,0 +1,143 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for metrics formatters.""" + +import json +import logging +import os + +import pytest + +from isaaclab.test.benchmark import formatters +from isaaclab.test.benchmark.measurements import SingleMeasurement, StringMetadata, TestPhase +from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + Resources, + RunConfig, + RunIdentity, + Runtime, + RuntimeBundle, + StartupTime, + Versions, +) + + +def _minimal_runtime_bundle() -> RuntimeBundle: + return RuntimeBundle( + run=RunIdentity( + run_id="runtime_newton_mjwarp_Isaac-Ant-Direct-v0_20260422-131500_seed42", + framework=None, + config=RunConfig(physics_backend="newton_mjwarp", rendering_backend="none"), + task="Isaac-Ant-Direct-v0", + seed=42, + start_time_utc="2026-04-22T13:15:00Z", + end_time_utc="2026-04-22T13:15:10Z", + duration_s=10.0, + status="completed", + num_envs=16, + ), + versions=Versions( + isaaclab="4.6.8", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="2.5.1", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ), + hardware=Hardware( + hostname="benchmark-host", + gpu_devices=[GpuDeviceInfo(name="NVIDIA H100 80GB", mem_gb=80.0, compute_cap="9.0")], + cpu_name="AMD EPYC 7763", + cpu_count=64, + ram_gb=512.0, + ), + runtime=Runtime( + startup_time_s=StartupTime(app_launch=1.0, env_creation=2.0, first_step=0.5), + iterations_completed=1, + total_wall_time_s=4.0, + steps_per_iteration=24, + iteration_time_s=MeanStd(mean=1.0, std=0.0), + collection_fps=MeanStd(mean=100.0, std=0.0), + total_fps=MeanStd(mean=100.0, std=0.0), + iterations_per_s=MeanStd(mean=1.0, std=0.0), + ), + resources=Resources( + gpu_util_pct=MeanStd(mean=80.0, std=5.0), + gpu_mem_gb=MeanStd(mean=10.0, std=0.5, peak=12.0), + cpu_util_pct=MeanStd(mean=30.0, std=4.0), + ram_gb=MeanStd(mean=20.0, std=1.0, peak=24.0), + ), + ) + + +@pytest.fixture(autouse=True) +def reset_formatters(): + formatters.MetricsFormatter.reset_instances() + yield + formatters.MetricsFormatter.reset_instances() + + +def test_schema_bundle_file_serializes_bundle_and_handles_missing_bundle(tmp_path, caplog): + formatter = formatters.MetricsFormatter.get_instance("schema") + phase = TestPhase(phase_name="runtime") + phase.measurements.append(SingleMeasurement(name="Test FPS", value=60.0, unit="FPS")) + formatter.add_metrics(phase) + formatter.finalize(str(tmp_path), "runtime", bundle=_minimal_runtime_bundle()) + + with open(os.path.join(str(tmp_path), "runtime.json")) as f: + data = json.load(f) + assert isinstance(formatter, formatters.SchemaBundleFile) + assert data["run"]["task"] == "Isaac-Ant-Direct-v0" + assert data["run"]["framework"] is None + assert data["runtime"]["total_fps"]["mean"] == pytest.approx(100.0) + assert data["resources"]["gpu_mem_gb"]["peak"] == pytest.approx(12.0) + assert data["schema_version"] + assert "Test FPS" not in json.dumps(data) + + with caplog.at_level(logging.WARNING, logger="isaaclab.test.benchmark.formatters"): + formatter.finalize(str(tmp_path), "missing", bundle=None) + assert not os.path.exists(os.path.join(str(tmp_path), "missing.json")) + assert any("no bundle" in record.message.lower() for record in caplog.records) + + +@pytest.mark.parametrize("formatter_cls", [formatters.OsmoKPIFile, formatters.OmniPerfKPIFile]) +def test_kpi_formatters_clear_phases_after_finalize(tmp_path, formatter_cls): + formatter = formatter_cls() + phase = TestPhase(phase_name="runtime") + phase.metadata.append(StringMetadata(name="phase", data="runtime")) + phase.measurements.append(SingleMeasurement(name="FPS", value=60.0, unit="FPS")) + + formatter.add_metrics(phase) + formatter.finalize(str(tmp_path), "first") + formatter.finalize(str(tmp_path), "second") + + assert os.path.exists(os.path.join(str(tmp_path), "first.json")) + assert not os.path.exists(os.path.join(str(tmp_path), "second.json")) + + +def test_osmo_writes_one_file_per_phase(tmp_path): + formatter = formatters.OsmoKPIFile() + for phase_name, value in (("startup", 1.0), ("runtime", 60.0)): + phase = TestPhase(phase_name=phase_name) + phase.metadata.append(StringMetadata(name="phase", data=phase_name)) + phase.measurements.append(SingleMeasurement(name="FPS", value=value, unit="FPS")) + formatter.add_metrics(phase) + + formatter.finalize(str(tmp_path), "metrics") + + assert sorted(path.name for path in tmp_path.glob("*.json")) == ["metrics_runtime.json", "metrics_startup.json"] + with open(tmp_path / "metrics_runtime.json") as f: + assert json.load(f)["FPS"] == 60.0 diff --git a/source/isaaclab/test/benchmark/test_metrics.py b/source/isaaclab/test/benchmark/test_metrics.py new file mode 100644 index 000000000000..53f14dff701f --- /dev/null +++ b/source/isaaclab/test/benchmark/test_metrics.py @@ -0,0 +1,178 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for the benchmark metrics helpers (Isaac-Sim-free).""" + +import pytest + +from isaaclab.test.benchmark.metrics import ( + RL_LIBRARY_DESCRIPTORS, + SUCCESS_RATE_LOG_TAGS, + SuccessRateTracker, + check_convergence, + ema, + get_success_rate_log, + mean_std, + mean_std_peak, + parse_tf_logs, + success_rate_step_value, +) +from isaaclab.test.benchmark.schema import MeanStd + + +@pytest.mark.parametrize( + ("framework", "tfevents_pattern", "reward_tag", "ep_length_tag"), + [ + ("rsl_rl", "events*", "Train/mean_reward", "Train/mean_episode_length"), + ("rl_games", "summaries/events*", "rewards/iter", "episode_lengths/iter"), + ("skrl", "events*", "Reward / Total reward (mean)", "Episode / Total timesteps (mean)"), + ("sb3", "PPO_*/events*", "rollout/ep_rew_mean", "rollout/ep_len_mean"), + ], +) +def test_rl_library_descriptors( + framework: str, + tfevents_pattern: str, + reward_tag: str, + ep_length_tag: str, +): + descriptor = RL_LIBRARY_DESCRIPTORS[framework] + + assert descriptor.framework == framework + assert descriptor.tfevents_pattern == tfevents_pattern + assert descriptor.reward_tag == reward_tag + assert descriptor.ep_length_tag == ep_length_tag + + +def test_mean_std_peak_computes_peak(): + ms = mean_std_peak([1.0, 2.0, 3.0]) + assert isinstance(ms, MeanStd) + assert ms.mean == pytest.approx(2.0) + assert ms.std == pytest.approx(1.0) + assert ms.peak == pytest.approx(3.0) + + +def test_mean_std_omits_peak(): + ms = mean_std([10.0, 20.0]) + assert ms.peak is None + assert ms.mean == pytest.approx(15.0) + + +def test_mean_std_empty_is_zero(): + ms = mean_std_peak([]) + assert ms.mean == 0.0 and ms.std == 0.0 and ms.peak == 0.0 + + +def test_ema_matches_manual(): + series = [0.0, 10.0, 10.0] + a = 0.5 + e = series[0] + for x in series[1:]: + e = a * x + (1 - a) * e + assert ema(series, a) == pytest.approx(e) + + +def test_ema_empty_is_zero(): + assert ema([], 0.1) == 0.0 + + +def test_check_convergence_passes_on_stable_high_rewards(): + res = check_convergence([100.0] * 10, threshold=50.0) + assert res["passed"] is True + assert res["tail_mean"] == pytest.approx(100.0) + + +def test_check_convergence_fails_when_below_threshold(): + res = check_convergence([1.0] * 10, threshold=50.0) + assert res["passed"] is False + + +def test_get_success_rate_log_prefers_first_tag(): + data = {"Episode/Metrics/success_rate": [0.5], "Metrics/success_rate": [0.9]} + assert get_success_rate_log(data) == [0.9] + assert get_success_rate_log({}) is None + + +def test_success_rate_tracker_convergence(): + t = SuccessRateTracker(threshold=0.5, window=2, num_steps_per_env=1) + for v in (0.6, 0.7): + t.record_step({"log": {"Metrics/success_rate": v}}) + t.end_iteration() + assert t.converged is True + assert t.tail_mean == pytest.approx(0.65) + + +def test_check_convergence_high_cv_fails_despite_mean_above_threshold(): + # Series whose tail mean clears the threshold but CV is too high to pass. + # With window_pct=1.0, the whole series is the tail. + # [1, 1000] -> tail_mean ~500.5, threshold=2.0, cv >> 20 -> passed False. + rewards = [1.0, 1000.0] + res = check_convergence(rewards, threshold=2.0, window_pct=1.0) + assert res["passed"] is False + assert res["cv"] > 20.0 + assert res["tail_mean"] >= 2.0 + + +def test_check_convergence_empty_rewards(): + res = check_convergence([], threshold=1.0) + assert res == {"tail_mean": 0.0, "cv": 999.9, "passed": False} + + +def test_success_rate_tracker_multi_step_boundary(): + # num_steps_per_env=3: boundary fires after step 3, not after 1 or 2. + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=3) + t.record_step({"log": {"Metrics/success_rate": 0.6}}) + assert t.at_iteration_boundary is False + t.record_step({"log": {"Metrics/success_rate": 0.6}}) + assert t.at_iteration_boundary is False + t.record_step({"log": {"Metrics/success_rate": 0.6}}) + assert t.at_iteration_boundary is True + t.end_iteration() + assert len(t.history) == 1 + + +def test_success_rate_tracker_item_tensor_path(): + class _T: + def item(self): + return 0.7 + + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=1) + t.record_step({"log": {"Metrics/success_rate": _T()}}) + mean = t.end_iteration() + assert mean == pytest.approx(0.7) + assert t.history == [pytest.approx(0.7)] + + +def test_success_rate_tracker_no_data_end_iteration_returns_none(): + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=1) + t.record_step({"log": {}}) + assert t._step_count == 1 + result = t.end_iteration() + assert result is None + assert t.history == [] + + +def test_parse_tf_logs_empty_dir_returns_empty(tmp_path, caplog): + import logging + + with caplog.at_level(logging.WARNING): + result = parse_tf_logs(str(tmp_path)) + assert result == {} + assert any("No TensorBoard event files" in r.getMessage() for r in caplog.records) + + +def test_check_convergence_zero_mean_returns_high_cv(): + result = check_convergence([0.0, 0.0, 0.0, 0.0, 0.0], threshold=0.3) + assert result["tail_mean"] == 0.0 + assert result["cv"] == 999.9 + assert result["passed"] is False + + +def test_success_rate_step_value_reads_scalar(): + import torch + + tag = SUCCESS_RATE_LOG_TAGS[0] + assert success_rate_step_value({tag: torch.tensor(0.75)}) == pytest.approx(0.75) + assert success_rate_step_value({tag: 0.5}) == pytest.approx(0.5) + assert success_rate_step_value({"other/metric": 1.0}) is None diff --git a/source/isaaclab/test/benchmark/test_profiling.py b/source/isaaclab/test/benchmark/test_profiling.py new file mode 100644 index 000000000000..f8396b112536 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_profiling.py @@ -0,0 +1,67 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for benchmark cProfile parsing (Isaac-Sim-free).""" + +import cProfile +import os + +from isaaclab.test.benchmark.profiling import parse_cprofile_stats + + +def _profiled(): + def inner(n): + return sum(range(n)) + + p = cProfile.Profile() + p.enable() + for _ in range(3): + inner(1000) + p.disable() + return p + + +def test_parse_cprofile_returns_4_tuples(): + p = _profiled() + # Treat this test file's directory as an "isaaclab prefix" so functions + # defined here are captured by the filter. + prefixes = [os.path.dirname(os.path.abspath(__file__))] + rows = parse_cprofile_stats(p, prefixes, top_n=10) + assert rows, "expected at least one profiled function" + for row in rows: + assert len(row) == 4 + label, own_ms, cum_ms, calls = row + assert isinstance(label, str) + assert isinstance(own_ms, float) and isinstance(cum_ms, float) + assert isinstance(calls, int) and calls >= 1 + + +def test_whitelist_placeholder_is_4_tuple(): + p = _profiled() + prefixes = [os.path.dirname(os.path.abspath(__file__))] + rows = parse_cprofile_stats(p, prefixes, whitelist=["nonexistent.module:does_not_exist"]) + # unmatched pattern yields a placeholder row, still a 4-tuple + assert any(r[0] == "nonexistent.module:does_not_exist" and r[3] == 0 for r in rows) + + +def test_parse_cprofile_prefers_most_specific_source_root(): + namespace = {} + exec( + compile( + "def profile_target():\n return sum(range(10))", + "/tmp/repro/source/isaaclab_tasks/isaaclab_tasks/example.py", + "exec", + ), + namespace, + ) + profile = cProfile.Profile() + profile.runcall(namespace["profile_target"]) + + rows = parse_cprofile_stats( + profile, + ["/tmp/repro/source/isaaclab", "/tmp/repro/source/isaaclab_tasks"], + ) + + assert any(label == "isaaclab_tasks.example:profile_target" for label, *_ in rows) diff --git a/source/isaaclab/test/benchmark/test_recorders.py b/source/isaaclab/test/benchmark/test_recorders.py index b708035067bb..003cfc66fbf1 100644 --- a/source/isaaclab/test/benchmark/test_recorders.py +++ b/source/isaaclab/test/benchmark/test_recorders.py @@ -5,6 +5,10 @@ """Unit tests for benchmark recorder classes.""" +import builtins +import sys +import types + import pytest from isaaclab.test.benchmark.interfaces import MeasurementData @@ -596,12 +600,138 @@ def test_captures_core_versions(self, recorder): assert "numpy" in versions assert "isaaclab" in versions - def test_version_values_are_strings(self, recorder): - """Test that version values are strings.""" + def test_captures_renderer_runtime_versions(self, monkeypatch): + versions_by_distribution = {"ovrtx": "0.3.1", "isaaclab_ovphysx": "3.0.5"} + monkeypatch.setattr( + VersionInfoRecorder, + "_get_pkg_version", + lambda _self, distribution: versions_by_distribution.get(distribution), + ) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + assert versions["ovrtx"] == "0.3.1" + assert versions["ovphysx"] == "3.0.5" + + def test_captures_active_kit_versions(self, monkeypatch, tmp_path): + """Test that versions are captured from an active Kit runtime.""" + isaac_path = tmp_path / "isaacsim" + isaac_path.mkdir() + (isaac_path / "VERSION").write_text("6.0.0-test") + monkeypatch.setenv("ISAAC_PATH", str(isaac_path)) + + omni = types.ModuleType("omni") + kit = types.ModuleType("omni.kit") + app = types.ModuleType("omni.kit.app") + app.get_app = lambda: types.SimpleNamespace(get_build_version=lambda: "110.1.1-test") + omni.kit = kit + kit.app = app + monkeypatch.setitem(sys.modules, "omni", omni) + monkeypatch.setitem(sys.modules, "omni.kit", kit) + monkeypatch.setitem(sys.modules, "omni.kit.app", app) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert versions["kit"] == "110.1.1-test" + assert versions["isaacsim"] == "6.0.0-test" + + def test_captures_isaacsim_version_without_isaac_path(self, monkeypatch): + """Test that an active Kit runtime provides the Isaac Sim application version.""" + monkeypatch.delenv("ISAAC_PATH", raising=False) + + omni = types.ModuleType("omni") + kit = types.ModuleType("omni.kit") + app = types.ModuleType("omni.kit.app") + app.get_app = lambda: types.SimpleNamespace(get_kit_version=lambda: "110.1.1-test") + omni.kit = kit + kit.app = app + isaacsim = types.ModuleType("isaacsim") + core = types.ModuleType("isaacsim.core") + version = types.ModuleType("isaacsim.core.version") + version.get_version = lambda: ("6.0.0", "rc.59", "6", "0", "0", "rc", "59", "main.0.test") + isaacsim.core = core + core.version = version + monkeypatch.setitem(sys.modules, "omni", omni) + monkeypatch.setitem(sys.modules, "omni.kit", kit) + monkeypatch.setitem(sys.modules, "omni.kit.app", app) + monkeypatch.setitem(sys.modules, "isaacsim", isaacsim) + monkeypatch.setitem(sys.modules, "isaacsim.core", core) + monkeypatch.setitem(sys.modules, "isaacsim.core.version", version) + monkeypatch.setitem(sys.modules, "carb", types.ModuleType("carb")) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert versions["isaacsim"] == "6.0.0-rc.59+main.0.test" + + def test_records_null_kit_versions_without_active_kit(self, monkeypatch): + """Test that Kit versions are null when no Kit runtime is active.""" + monkeypatch.delenv("ISAAC_PATH", raising=False) + + omni = types.ModuleType("omni") + kit = types.ModuleType("omni.kit") + app = types.ModuleType("omni.kit.app") + app.get_app = lambda: None + omni.kit = kit + kit.app = app + isaacsim = types.ModuleType("isaacsim") + isaacsim.__version__ = "should-not-be-recorded" + carb = types.ModuleType("carb") + monkeypatch.setitem(sys.modules, "omni", omni) + monkeypatch.setitem(sys.modules, "isaacsim", isaacsim) + monkeypatch.setitem(sys.modules, "carb", carb) + monkeypatch.setitem(sys.modules, "omni.kit", kit) + monkeypatch.setitem(sys.modules, "omni.kit.app", app) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert versions["kit"] is None + assert versions["isaacsim"] is None + + def test_records_null_kit_versions_without_importing_kit(self, monkeypatch): + """Test that Kitless runs do not import the Kit application module.""" + monkeypatch.delenv("ISAAC_PATH", raising=False) + for module_name in ("omni.kit.app", "omni.kit", "omni"): + monkeypatch.delitem(sys.modules, module_name, raising=False) + + kit_imports = [] + original_import = builtins.__import__ + + def import_without_kit(name, globals=None, locals=None, fromlist=(), level=0): + if name == "omni.kit.app": + kit_imports.append(name) + raise AssertionError("Kit must not be imported for Kitless runs") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", import_without_kit) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert kit_imports == [] + assert versions["kit"] is None + assert versions["isaacsim"] is None + + def test_records_null_renderer_runtime_versions_when_unavailable(self, monkeypatch): + """Test that unavailable renderer runtime versions are null.""" + monkeypatch.setattr(VersionInfoRecorder, "_get_pkg_version", lambda _self, _distribution: None) + + recorder = VersionInfoRecorder() + versions = recorder.get_initial_data()["version_metadata"] + metadata = {entry.name: entry.data for entry in recorder.get_data().metadata} + + assert versions["ovrtx"] is None + assert versions["ovphysx"] is None + assert metadata["ovrtx_version"] is None + assert metadata["ovphysx_version"] is None + + def test_version_values_are_strings_or_null(self, recorder): + """Test that version values are strings or null for runtime packages.""" data = recorder.get_initial_data() - for version in data["version_metadata"].values(): - assert isinstance(version, str) - assert len(version) > 0 + nullable_versions = {"kit", "isaacsim", "ovrtx", "ovphysx"} + for name, version in data["version_metadata"].items(): + if name in nullable_versions: + assert version is None or isinstance(version, str) + else: + assert isinstance(version, str) + assert len(version) > 0 def test_git_info_structure(self, recorder): """Test that git info has expected fields when available.""" diff --git a/source/isaaclab/test/benchmark/test_stepping.py b/source/isaaclab/test/benchmark/test_stepping.py new file mode 100644 index 000000000000..d2c53117230c --- /dev/null +++ b/source/isaaclab/test/benchmark/test_stepping.py @@ -0,0 +1,75 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for the runtime stepping helpers.""" + +import numpy as np +import torch + +from isaaclab.test.benchmark.stepping import run_runtime_loop, sample_random_actions + + +class _Space: + def __init__(self, n): + self.shape = (n,) + + +class _Env: + class _U: + num_envs = 4 + device = "cpu" + single_action_space = _Space(3) + + def __init__(self): + self.unwrapped = _Env._U() + self.reset_called = False + self.steps = 0 + + def reset(self): + self.reset_called = True + + def step(self, actions): + self.steps += 1 + return (None, None, None, {}) + + +def test_sample_single_agent_shape_and_range(): + a = sample_random_actions(_Env()) + assert isinstance(a, torch.Tensor) + assert tuple(a.shape) == (4, 3) + assert float(a.min()) >= -1.0 - 1e-6 and float(a.max()) <= 1.0 + 1e-6 + + +def test_run_runtime_loop_steps_and_times(): + env = _Env() + times = run_runtime_loop(env, num_frames=5) + assert env.reset_called and env.steps == 5 + assert len(times) == 5 and all(t >= 0.0 for t in times) + + +class _MASpace: + def __init__(self, n): + self._n = n + + def sample(self): + return np.zeros(self._n, dtype=np.float32) + + +class _MAEnv: + class _U: + num_envs = 4 + device = "cpu" + action_spaces = {"a0": _MASpace(3), "a1": _MASpace(2)} + + def __init__(self): + self.unwrapped = _MAEnv._U() + + +def test_sample_multi_agent_returns_dict_per_agent(): + actions = sample_random_actions(_MAEnv()) + assert isinstance(actions, dict) + assert set(actions) == {"a0", "a1"} + assert tuple(actions["a0"].shape) == (4, 3) + assert tuple(actions["a1"].shape) == (4, 2) From 029fcb4f44e9bc8345d5e880eb9135ee7188f26c Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Mon, 6 Jul 2026 11:16:28 +0200 Subject: [PATCH 16/23] Fix OVPhysX runtime pin (#6333) ## Summary - pin the OVPhysX optional runtime to `0.5.2+head.f62c22207c` - align the daily compatibility job with the tested runtime artifact - add an OVPhysX changelog fragment ## Testing - `./isaaclab.sh -p -c "..."` (verified both explicit version constraints) - `./isaaclab.sh -p -m pytest tools/changelog/test/test_validate.py` - `./isaaclab.sh -p tools/changelog/cli.py check develop` - `./isaaclab.sh -f` --- .github/workflows/daily-compatibility.yml | 2 +- .github/workflows/license-check.yaml | 138 ++++++++++++++++++ .../antoiner-pin-ovphysx-0-5-2.rst | 5 + source/isaaclab_ovphysx/pyproject.toml | 2 +- 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst diff --git a/.github/workflows/daily-compatibility.yml b/.github/workflows/daily-compatibility.yml index 472b07300a5b..a92a9e6ccf07 100644 --- a/.github/workflows/daily-compatibility.yml +++ b/.github/workflows/daily-compatibility.yml @@ -111,7 +111,7 @@ jobs: image-tag: ${{ env.DOCKER_IMAGE_TAG }} pytest-options: "" filter-pattern: "isaaclab_tasks" - extra-pip-packages: "ovrtx ovphysx==0.4.13" + extra-pip-packages: "ovrtx ovphysx==0.5.2+head.f62c22207c" - name: Copy All Test Results from IsaacLab Tasks Container run: | diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml index fb82761f0b9b..45c2befc2dc5 100644 --- a/.github/workflows/license-check.yaml +++ b/.github/workflows/license-check.yaml @@ -15,7 +15,12 @@ concurrency: jobs: license-check: + # Fork PRs cannot access NGC_API_KEY, so they cannot resolve the immutable + # OVPhysX wheel required by the development extras. + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-24.04 + env: + NGC_API_KEY: ${{ secrets.NGC_API_KEY }} steps: - name: Checkout code @@ -23,6 +28,132 @@ jobs: with: filter: tree:0 + - name: Load OVPhysX wheelhouse configuration + id: config + shell: bash + run: | + set -euo pipefail + resource=$(yq -r .ovphysx_wheelhouse_resource .github/workflows/config.yaml) + echo "wheelhouse_resource=${resource}" >> "$GITHUB_OUTPUT" + + - name: Restore NGC CLI cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/ngc-cli-cache/ngccli_linux.zip + key: ngc-cli-${{ runner.os }}-${{ runner.arch }}-4.20.0-5cf084c88998c58ad8abf7849d2d1b41d578423886eb03018df10194e341d35b + + - name: Extract OVPhysX wheelhouse + id: extract-wheelhouse + shell: bash + env: + WHEELHOUSE_RESOURCE: ${{ steps.config.outputs.wheelhouse_resource }} + run: | + set -euo pipefail + + if [ -z "${NGC_API_KEY:-}" ]; then + echo "::error::NGC_API_KEY is unavailable; cannot download the configured OVPhysX wheelhouse" + exit 1 + fi + + NGC_CLI_VERSION="4.20.0" + NGC_CLI_SHA256="5cf084c88998c58ad8abf7849d2d1b41d578423886eb03018df10194e341d35b" + NGC_CLI_URL="https://api.ngc.nvidia.com/v2/resources/nvidia/ngc-apps/ngc_cli/versions/${NGC_CLI_VERSION}/files/ngccli_linux.zip" + + wheelhouse_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-wheelhouse.XXXXXX")" + download_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-wheelhouse-download.XXXXXX")" + ngc_home="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-ngc-home.XXXXXX")" + ngc_unpack_dir="" + preserve_wheelhouse_root=false + cleanup() { + if [ "$preserve_wheelhouse_root" != "true" ]; then + rm -rf "$wheelhouse_root" + fi + rm -rf "$download_root" "$ngc_home" + if [ -n "$ngc_unpack_dir" ]; then + rm -rf "$ngc_unpack_dir" + fi + } + trap cleanup EXIT + + cache_dir="${RUNNER_TEMP:-/tmp}/ngc-cli-cache" + ngc_zip="${cache_dir}/ngccli_linux.zip" + mkdir -p "$cache_dir" + if [ ! -f "$ngc_zip" ]; then + echo "Downloading NGC CLI ${NGC_CLI_VERSION}" + curl -fsSL -o "$ngc_zip" "$NGC_CLI_URL" + fi + echo "${NGC_CLI_SHA256} ${ngc_zip}" | sha256sum -c - + ngc_unpack_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ngc-cli.XXXXXX")" + unzip -q "$ngc_zip" -d "$ngc_unpack_dir" + export PATH="${ngc_unpack_dir}/ngc-cli:${PATH}" + + export NGC_CLI_API_KEY="$NGC_API_KEY" + export NGC_CLI_ORG=nvidian + export NGC_CLI_TEAM=no-team + export NGC_CLI_HOME="$ngc_home" + + ngc --version + echo "Downloading wheelhouse resource: $WHEELHOUSE_RESOURCE" + ngc registry resource download-version "$WHEELHOUSE_RESOURCE" --dest "$download_root" + + mapfile -t manifests < <(find "$download_root" -type f -name manifest.json) + if [ "${#manifests[@]}" -ne 1 ]; then + echo "::error::expected exactly one manifest.json in downloaded wheelhouse resource, found ${#manifests[@]}" + printf '%s\n' "${manifests[@]}" + exit 1 + fi + + payload_dir="$(dirname "${manifests[0]}")" + if [ ! -d "${payload_dir}/wheelhouse" ]; then + echo "::error::downloaded wheelhouse resource is missing wheelhouse/ next to manifest.json" + exit 1 + fi + + mkdir -p "$wheelhouse_root/wheelhouse" + cp "${payload_dir}/manifest.json" "$wheelhouse_root/manifest.json" + cp "${payload_dir}/wheelhouse/"*.whl "$wheelhouse_root/wheelhouse/" + + python3 - <<'PY' "$wheelhouse_root/manifest.json" "$wheelhouse_root/wheelhouse" + import hashlib + import json + import pathlib + import sys + + manifest_path = pathlib.Path(sys.argv[1]) + wheelhouse_dir = pathlib.Path(sys.argv[2]) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + expected = { + "artifact": "ovphysx-wheelhouse", + "platform": "manylinux_2_35_x86_64", + } + for key, value in expected.items(): + actual = manifest.get(key) + if actual != value: + raise SystemExit(f"manifest {key!r} mismatch: expected {value!r}, got {actual!r}") + + wheels = manifest.get("wheels") + if not isinstance(wheels, list) or not wheels: + raise SystemExit("manifest has no wheels list") + + for wheel in wheels: + filename = wheel.get("file") + expected_sha = wheel.get("sha256") + if not filename or not expected_sha: + raise SystemExit(f"invalid wheel manifest entry: {wheel!r}") + wheel_path = wheelhouse_dir / filename + if not wheel_path.is_file(): + raise SystemExit(f"manifest wheel is missing from wheelhouse: {filename}") + actual_sha = hashlib.sha256(wheel_path.read_bytes()).hexdigest() + if actual_sha != expected_sha: + raise SystemExit(f"sha256 mismatch for {filename}: expected {expected_sha}, got {actual_sha}") + + print(f"Validated {len(wheels)} wheelhouse wheels from {manifest_path}") + PY + + echo "wheelhouse_root=$wheelhouse_root" >> "$GITHUB_OUTPUT" + echo "wheelhouse_dir=$wheelhouse_root/wheelhouse" >> "$GITHUB_OUTPUT" + # - name: Install jq # run: sudo apt-get update && sudo apt-get install -y jq @@ -60,6 +191,7 @@ jobs: OMNI_KIT_ACCEPT_EULA: yes ACCEPT_EULA: Y ISAACSIM_ACCEPT_EULA: YES + UV_FIND_LINKS: ${{ steps.extract-wheelhouse.outputs.wheelhouse_dir }} run: | uv sync --extra all # Isaac Sim isn't in the dev pyproject; sync first so it isn't pruned. @@ -153,3 +285,9 @@ jobs: else echo "All packages were checked." fi + + - name: Clean up OVPhysX wheelhouse + if: always() + shell: bash + run: | + rm -rf "${{ steps.extract-wheelhouse.outputs.wheelhouse_root }}" diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst new file mode 100644 index 000000000000..55367b600eaa --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed the OVPhysX optional runtime dependency to install + ``ovphysx==0.5.2+head.f62c22207c``, matching the supported runtime wheel. diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index aa89c97b0548..75755790e384 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -23,7 +23,7 @@ Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" [project.optional-dependencies] -ovphysx = ["ovphysx==0.4.13"] +ovphysx = ["ovphysx==0.5.2+head.f62c22207c"] [tool.setuptools] include-package-data = true From 447d8c5d02c5db39cfd5e27e871a2dd822cbc15f Mon Sep 17 00:00:00 2001 From: Agon Serifi <39410867+aserifi@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:32:18 -0600 Subject: [PATCH 17/23] [Kamino] Small Cleanup (#6359) Revisited the solver parameters. Now, the environments consistently use num_substeps=1. Tuned also the settings for the DR Legs task, making training 3x faster. ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../changelog.d/aserifi-kamino_cleanup.rst | 12 ++++++++++++ .../contrib/dr_legs/hold_pose_env_cfg.py | 11 +++++------ .../contrib/velocity/config/a1/flat_env_cfg.py | 2 +- .../contrib/velocity/config/anymal_b/flat_env_cfg.py | 2 +- .../contrib/velocity/config/anymal_c/flat_env_cfg.py | 2 +- .../contrib/velocity/config/go1/flat_env_cfg.py | 2 +- .../isaaclab_tasks/core/cabinet/cabinet_env_cfg.py | 2 +- .../core/cartpole/cartpole_direct_env_cfg.py | 1 - .../core/cartpole/cartpole_manager_env_cfg.py | 1 - .../core/locomotion/ant/ant_direct_env_cfg.py | 1 - .../core/locomotion/ant/ant_manager_env_cfg.py | 1 - .../isaaclab_tasks/core/reach/reach_env_cfg.py | 1 - .../config/shadow_hand/shadow_hand_env_cfg.py | 2 +- .../core/velocity/config/cassie/flat_env_cfg.py | 2 +- .../core/velocity/config/g1/flat_env_cfg.py | 2 +- .../core/velocity/config/go2/flat_env_cfg.py | 2 +- .../core/velocity/config/h1/flat_env_cfg.py | 2 +- .../core/velocity/config/spot/flat_env_cfg.py | 2 +- 18 files changed, 28 insertions(+), 22 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst diff --git a/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst b/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst new file mode 100644 index 000000000000..d200dbd19e49 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst @@ -0,0 +1,12 @@ +Changed +^^^^^^^ + +* Changed the Disney DR Legs walk / hold-pose Kamino stepping to ``sim.dt = 1/150`` + with ``decimation = 3`` (previously ``0.004`` / ``5``). This keeps the 50 Hz control + rate while reducing the number of Kamino solver calls per control step from 5 to 3, + for roughly 1.6x faster simulation with equivalent task performance. +* Changed the ``newton_kamino`` presets of the velocity (A1, AnymalB, AnymalC, Go1, Go2, + Cassie, G1, H1, Spot), cabinet, and shadow-hand reorient tasks to use the default + single physics substep (removed the ``num_substeps=2`` override), and dropped the + redundant explicit ``num_substeps`` overrides from the cartpole, ant, and reach Kamino + presets. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py index e710e8c4a064..a2c15669aefa 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py @@ -67,9 +67,8 @@ def _kamino_newton_cfg() -> NewtonCfg: padmm_warmstart_mode="containers", padmm_contact_warmstart_method="geom_pair_net_force", padmm_use_graph_conditionals=False, - max_contacts_per_world=50, + max_contacts_per_world=32, ), - num_substeps=2, use_cuda_graph=True, default_shape_cfg=NewtonShapeCfg(margin=0.0, gap=0.001), ) @@ -195,7 +194,7 @@ class EventCfg: "pose_range": { "x": (-0.05, 0.05), "y": (-0.05, 0.05), - "z": (-0.02, 0.02), + "z": (0.01, 0.015), "roll": (-0.1, 0.1), "pitch": (-0.1, 0.1), "yaw": (-3.14159, 3.14159), @@ -266,14 +265,14 @@ class DrLegsHoldPoseEnvCfg(ManagerBasedRLEnvCfg): rewards: RewardsCfg = RewardsCfg() terminations: TerminationsCfg = TerminationsCfg() sim: SimulationCfg = SimulationCfg( - dt=0.004, - render_interval=5, + dt=1 / 150, + render_interval=3, physics=DrLegsPhysicsCfg(), physics_material=_PHYSICS_MATERIAL, ) def __post_init__(self) -> None: - self.decimation = 5 + self.decimation = 3 self.episode_length_s = 10.0 self.viewer.eye = (1.5, 0.5, 0.5) self.viewer.lookat = (0.0, 0.0, 0.265) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py index 8436893db821..c794ef52fbeb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py index dd4765cbe737..49eb5234cac1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py index 09619985e329..dfe215c777ab 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py index e3e1daa84676..736b83784a94 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py @@ -28,7 +28,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py index 3df3825a4404..870825b2db37 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py @@ -76,7 +76,7 @@ class CabinetSimCfg(PresetCfg): newton_kamino: SimulationCfg = SimulationCfg( dt=1 / 600, render_interval=1, - physics=NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=1), + physics=NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)), ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py index 7637e42ab7d1..b4b418ab4e76 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py @@ -57,7 +57,6 @@ class CartpolePhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=1, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py index 73f59e6885b0..2454f4890fac 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py @@ -67,7 +67,6 @@ class CartpolePhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=1, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py index 510526a59981..3e47bbb896ba 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py @@ -56,7 +56,6 @@ class AntPhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=2, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py index e89988dd7bc3..84d09abeb542 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py @@ -63,7 +63,6 @@ class AntPhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=2, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py index bed549290c05..26b40be6cc85 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py @@ -45,7 +45,6 @@ class ReachPhysicsCfg(PresetCfg): ) newton_kamino: NewtonCfg = NewtonCfg( solver_cfg=KaminoSolverCfg(max_contacts_per_world=32), - num_substeps=2, ) default = physx diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py index a77d33b9b521..f6bdae2bd0d8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py @@ -291,7 +291,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) default = physx - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=128), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=128)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py index 745c08526174..684594ca217a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py index 34ae1b080910..ed735096f3b0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py index ab145d6a4683..11c6aa509d5a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py index 4e54676fa1a8..ab636aac9c38 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py index b3ac1fb949f9..671aa659cd22 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py @@ -49,7 +49,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, default_shape_cfg=NewtonShapeCfg(margin=0.01), ) - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) ## From 6216fe71a6071ae73341ceffb1ca3c10b77054d1 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Mon, 6 Jul 2026 15:21:54 +0200 Subject: [PATCH 18/23] Fix pre-launch runtime imports (#6275) ## Summary - Deferred Kit and USD imports from modules loaded during task and configuration resolution until the runtime functionality requires them. - Prevented overlapping standalone USD package providers on x86 by restricting `usd-exchange` to ARM, where `usd-core` is not installed. - Added a metadata regression test that keeps the standalone USD providers platform-disjoint. ## Root Cause The failing installation-training environments loaded incompatible USD Python bindings in one process, with traces showing both `pxrInternal_v0_25_5` and `pxrInternal_v0_25_11`. On x86, `usd-core` and `usd-exchange` both supplied USD/`pxr` components. Their coexistence made native binding initialization depend on installation and import order, producing wrapper and converter registration failures. ## Test Plan - `./isaaclab.sh -p -m pytest source/isaaclab/test/cli/test_source_package_metadata.py` - `./isaaclab.sh -f` - Installation Tests: fresh successful run plus 10 successful reruns, each passing on x86 and ARM. ## CI Validation - Fresh run: x86 `84271139083`, ARM `84271139099` - Reruns 1-5: x86 `84278649544`, `84284392796`, `84297138420`, `84306527331`, `84317891132`; ARM `84278649480`, `84284392767`, `84297138393`, `84306527439`, `84317891100` - Reruns 6-10: x86 `84343375232`, `84356535895`, `84361942197`, `84373650598`, `84379046431`; ARM `84343375249`, `84356535827`, `84361942203`, `84373650527`, `84379046460` --------- Signed-off-by: Antoine RICHARD Co-authored-by: hujc --- .../fix-rl-train-prelaunch-imports.rst | 4 +++ .../isaaclab/scene/interactive_scene.py | 4 ++- .../scene_data/scene_data_provider.py | 4 +-- .../isaaclab/isaaclab/sensors/sensor_base.py | 6 ++-- source/isaaclab/isaaclab/sim/utils/queries.py | 3 +- source/isaaclab/isaaclab/sim/utils/stage.py | 29 ++++++++++++++----- source/isaaclab/pyproject.toml | 2 +- .../test/cli/test_source_package_metadata.py | 12 ++++++++ 8 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst diff --git a/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst b/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst new file mode 100644 index 000000000000..533762833f0e --- /dev/null +++ b/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed intermittent kitless Newton training startup failures by avoiding overlapping standalone USD package providers on x86. diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 11df307b2356..055002076634 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -13,6 +13,7 @@ from isaaclab_physx.assets import SurfaceGripper from isaaclab.renderers.base_renderer import BaseRenderer + from isaaclab.terrains.terrain_importer import TerrainImporter import torch import warp as wp @@ -35,7 +36,6 @@ from isaaclab.sim import SimulationContext from isaaclab.sim.utils.stage import get_current_stage, get_current_stage_id from isaaclab.sim.views import FrameView -from isaaclab.terrains import TerrainImporter, TerrainImporterCfg # Note: This is a temporary import for the VisuoTactileSensorCfg class. # It will be removed once the VisuoTactileSensor class is added to the core Isaac Lab framework. @@ -741,6 +741,8 @@ def _add_entities_from_cfg(self): # noqa: C901 """Add scene entities from the config.""" from isaaclab_physx.assets import SurfaceGripperCfg # noqa: PLC0415 + from isaaclab.terrains.terrain_importer_cfg import TerrainImporterCfg # noqa: PLC0415 + # store paths that are in global collision filter self._global_prim_paths = list() # Resolve the env-namespace convention from the cloner cfg once for this pass. diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index ba4c051920e1..8d770a3da570 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -13,8 +13,6 @@ import numpy as np import warp as wp -from pxr import UsdGeom - import isaaclab.sim as sim_utils from .scene_data_backend import SceneDataBackend, SceneDataFormat @@ -441,6 +439,8 @@ def _walk_camera_prims(stage: Usd.Stage | None) -> dict[str, Any] | None: if stage is None: return None + from pxr import UsdGeom # noqa: PLC0415 + shared_paths: list[str] = [] instances: dict[str, list[tuple[int, str]]] = {} num_envs = -1 diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index 8b885a91d148..ee4e5885d78d 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -20,10 +20,7 @@ import warp as wp -from pxr import UsdPhysics - import isaaclab.sim as sim_utils -from isaaclab.cloner.cloner_utils import iter_clone_plan_matches from isaaclab.physics import PhysicsEvent, PhysicsManager from isaaclab.sim.utils.queries import get_first_matching_ancestor_prim from isaaclab.sim.utils.transforms import resolve_prim_pose @@ -231,6 +228,8 @@ def _initialize_impl(self): clone_plan = self._clone_plan clone_plan_matches = () if clone_plan is not None: + from isaaclab.cloner.cloner_utils import iter_clone_plan_matches # noqa: PLC0415 + clone_plan_matches = tuple(iter_clone_plan_matches(clone_plan, self.cfg.prim_path)) if clone_plan_matches: self._parent_prims = [] @@ -457,6 +456,7 @@ def _resolve_rigid_body_ancestor_expr( mounted directly at the body origin. """ prim, target_expr = sim_utils.resolve_matching_prims_from_source(self.cfg.prim_path)[0] + from pxr import UsdPhysics # noqa: PLC0415 ancestor_prim = get_first_matching_ancestor_prim( prim.GetPath(), predicate=lambda _prim: _prim.HasAPI(UsdPhysics.RigidBodyAPI) diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index 4d5dcb3ac024..1b38f0aae475 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -12,7 +12,6 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from isaaclab.cloner.cloner_utils import resolve_clone_plan_source from isaaclab.sim.simulation_context import SimulationContext from .stage import get_current_stage @@ -418,6 +417,8 @@ def resolve_matching_prims_from_source( RuntimeError: If no prim matches ``path_expr`` and ``raise_if_no_matches`` is True. """ plan = SimulationContext.instance().get_clone_plan() + from isaaclab.cloner.cloner_utils import resolve_clone_plan_source # noqa: PLC0415 + resolved = resolve_clone_plan_source(path_expr, plan) if plan is not None else None if resolved is not None: source_path, dest_glob, asset_suffix = resolved diff --git a/source/isaaclab/isaaclab/sim/utils/stage.py b/source/isaaclab/isaaclab/sim/utils/stage.py index d4c87f4c2e75..b7eaff26937f 100644 --- a/source/isaaclab/isaaclab/sim/utils/stage.py +++ b/source/isaaclab/isaaclab/sim/utils/stage.py @@ -136,15 +136,24 @@ def _modify_path(asset_path: str) -> str: # ############################################################################## -try: - # _context is a singleton design in isaacsim and for that reason - # until we fully replace all modules that references the singleton(such as XformPrim, Prim ....), we have to point - # that singleton to this _context - from isaacsim.core.experimental.utils import stage as sim_stage +_isaacsim_stage_context_synced = False + +def _sync_isaacsim_stage_context() -> None: + """Point Isaac Sim's stage helper at Isaac Lab's thread-local stage context.""" + global _isaacsim_stage_context_synced + + if _isaacsim_stage_context_synced or not has_kit(): + return + + try: + from isaacsim.core.experimental.utils import stage as sim_stage # noqa: PLC0415 + except ImportError: + return + + # Isaac Sim stage helpers read this singleton context. sim_stage._context = _context # type: ignore -except ImportError: - pass + _isaacsim_stage_context_synced = True def create_new_stage() -> Usd.Stage: @@ -167,6 +176,8 @@ def create_new_stage() -> Usd.Stage: sessionLayer=Sdf.Find('anon:0x7fba6c01c5c0:World7-session.usda'), pathResolverContext=) """ + _sync_isaacsim_stage_context() + from pxr import Usd, UsdUtils # noqa: PLC0415 stage: Usd.Stage = Usd.Stage.CreateInMemory() @@ -226,6 +237,8 @@ def open_stage(usd_path: str) -> Usd.Stage: ValueError: When input path is not a supported file type by USD. RuntimeError: When failed to open the stage. """ + _sync_isaacsim_stage_context() + from pxr import Usd # noqa: PLC0415 if not Usd.Stage.IsSupportedFile(usd_path): @@ -523,6 +536,8 @@ def get_current_stage(fabric: bool = False) -> Usd.Stage: sessionLayer=Sdf.Find('anon:0x7fba6c01c5c0:World7-session.usda'), pathResolverContext=) """ + _sync_isaacsim_stage_context() + # First check thread-local context for an in-memory stage stage = getattr(_context, "stage", None) if stage is not None: diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 66e349f1c517..e3c8cbd2c74e 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -55,7 +55,7 @@ dependencies = [ "daqp==0.8.5 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", # OpenUSD (kit-less mode) "usd-core>=25.11,<26.0 ; platform_machine in 'x86_64 AMD64'", - "usd-exchange>=2.2 ; platform_machine in 'x86_64 AMD64 aarch64 arm64'", + "usd-exchange>=2.2 ; platform_machine in 'aarch64 arm64'", # avoid broken hf-xet pre-release cached on NVIDIA Artifactory "hf-xet>=1.4.1,<2.0.0 ; platform_machine in 'x86_64 AMD64 aarch64 arm64'", ] diff --git a/source/isaaclab/test/cli/test_source_package_metadata.py b/source/isaaclab/test/cli/test_source_package_metadata.py index ec2b1c354613..ceeec3e6d2be 100644 --- a/source/isaaclab/test/cli/test_source_package_metadata.py +++ b/source/isaaclab/test/cli/test_source_package_metadata.py @@ -30,3 +30,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() / "source/isaaclab/pyproject.toml").open("rb") as f: + pyproject = tomllib.load(f) + + usd_exchange_dependencies = [ + dependency for dependency in pyproject["project"]["dependencies"] if dependency.startswith("usd-exchange") + ] + + assert usd_exchange_dependencies == ["usd-exchange>=2.2 ; platform_machine in 'aarch64 arm64'"] From 5f2db9902baa724427b7aa654835aa559fce64e8 Mon Sep 17 00:00:00 2001 From: Ruben Grandia Date: Mon, 6 Jul 2026 16:48:22 +0200 Subject: [PATCH 19/23] [Kamino] fourbar-pole swingup task (#6327) # Description Fourbar-pole swingup RL example that uses Kamino. Builds on the core Kamino changes in https://github.com/isaac-sim/IsaacLab/pull/5962 The USD model is added to `newton-assets` in https://github.com/newton-physics/newton-assets/pull/44 See video for a trained policy. 5s episode -> reset -> 5s episode. https://github.com/user-attachments/assets/9deb0597-6653-4614-b05f-5f3a03f0ff74 ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Agon Serifi Co-authored-by: Cursor Co-authored-by: Antoine RICHARD --- .../changelog.d/rgrandia-fourbar-pole.rst | 5 + .../isaaclab_assets/__init__.pyi | 2 + .../isaaclab_assets/robots/__init__.pyi | 2 + .../isaaclab_assets/robots/fourbar_pole.py | 64 +++++ .../changelog.d/fourbar-pole-swingup.rst | 5 + .../core/fourbar_pole/__init__.py | 24 ++ .../core/fourbar_pole/agents/__init__.py | 4 + .../agents/rsl_rl_manager_ppo_cfg.py | 46 ++++ .../fourbar_pole_manager_env_cfg.py | 256 ++++++++++++++++++ .../core/fourbar_pole/mdp/__init__.py | 10 + .../core/fourbar_pole/mdp/__init__.pyi | 21 ++ .../core/fourbar_pole/mdp/rewards.py | 91 +++++++ 12 files changed, 530 insertions(+) create mode 100644 source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst create mode 100644 source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py create mode 100644 source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py diff --git a/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst b/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst new file mode 100644 index 000000000000..30f654eedda0 --- /dev/null +++ b/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst @@ -0,0 +1,5 @@ +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. diff --git a/source/isaaclab_assets/isaaclab_assets/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/__init__.pyi index dc69e0b8c71c..84f2c12f68e9 100644 --- a/source/isaaclab_assets/isaaclab_assets/__init__.pyi +++ b/source/isaaclab_assets/isaaclab_assets/__init__.pyi @@ -24,6 +24,7 @@ __all__ = [ "FRANKA_PANDA_CFG", "FRANKA_PANDA_HIGH_PD_CFG", "FRANKA_ROBOTIQ_GRIPPER_CFG", + "FOURBAR_POLE_CFG", "GALBOT_ONE_CHARLIE_CFG", "HUMANOID_CFG", "HUMANOID_28_CFG", @@ -87,6 +88,7 @@ from .robots import ( FRANKA_PANDA_CFG, FRANKA_PANDA_HIGH_PD_CFG, FRANKA_ROBOTIQ_GRIPPER_CFG, + FOURBAR_POLE_CFG, GALBOT_ONE_CHARLIE_CFG, HUMANOID_CFG, HUMANOID_28_CFG, diff --git a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi index 2440b5d8a795..246bfc05fb07 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi +++ b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi @@ -24,6 +24,7 @@ __all__ = [ "FRANKA_PANDA_CFG", "FRANKA_PANDA_HIGH_PD_CFG", "FRANKA_ROBOTIQ_GRIPPER_CFG", + "FOURBAR_POLE_CFG", "GALBOT_ONE_CHARLIE_CFG", "HUMANOID_CFG", "HUMANOID_28_CFG", @@ -77,6 +78,7 @@ from .cartpole import CARTPOLE_CFG from .cassie import CASSIE_CFG from .fourier import GR1T2_CFG, GR1T2_HIGH_PD_CFG from .franka import FRANKA_PANDA_CFG, FRANKA_PANDA_HIGH_PD_CFG, FRANKA_ROBOTIQ_GRIPPER_CFG +from .fourbar_pole import FOURBAR_POLE_CFG from .galbot import GALBOT_ONE_CHARLIE_CFG from .humanoid import HUMANOID_CFG from .humanoid_28 import HUMANOID_28_CFG diff --git a/source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py b/source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py new file mode 100644 index 000000000000..a6b3b266f24f --- /dev/null +++ b/source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py @@ -0,0 +1,64 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Configuration for a parallel four-bar linkage with an inverted pendulum pole on the coupler.""" + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets import ArticulationCfg +from isaaclab.utils.assets import NEWTON_ASSET_DIR, retrieve_git_asset_path + +_FOURBAR_POLE_USD = retrieve_git_asset_path(NEWTON_ASSET_DIR, "fourbar_pole/usd/fourbar_pole.usda") + +## +# Configuration +## + +FOURBAR_POLE_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=_FOURBAR_POLE_USD, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + rigid_body_enabled=True, + max_linear_velocity=1000.0, + max_angular_velocity=1000.0, + max_depenetration_velocity=100.0, + enable_gyroscopic_forces=True, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=False, + solver_position_iteration_count=4, + solver_velocity_iteration_count=0, + sleep_threshold=0.005, + stabilization_threshold=0.001, + ), + ), + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 1.5), + joint_pos={ + "ground_to_crank": 0.0, + "crank_to_coupler": 0.0, + "coupler_to_rocker": 0.0, + "coupler_to_pole": 0.0, + }, + ), + actuators={ + "fourbar_actuator": ImplicitActuatorCfg( + joint_names_expr=["ground_to_crank"], + effort_limit_sim=400.0, + stiffness=0.0, + damping=10.0, + ), + # Add an actuator to the pole even though it is not actuated by the user. + # This makes the Kamino forward kinematics solver treat the pole as + # an actuated joint and preserve its user-specified joint position on resets. + "pole_actuator": ImplicitActuatorCfg( + joint_names_expr=["coupler_to_pole"], + effort_limit_sim=400.0, + stiffness=0.0, + damping=0.0, + ), + }, +) +"""Configuration for a parallel four-bar linkage with a pole on the coupler midpoint.""" diff --git a/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst b/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst new file mode 100644 index 000000000000..0bd1928b8f82 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_tasks.core.fourbar_pole.fourbar_pole_manager_env_cfg.FourbarPoleSwingupEnvCfg` + as the ``Isaac-Fourbar-Pole-Swingup`` Gym environment for four-bar pole swing-up with the Kamino solver. diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py new file mode 100644 index 000000000000..b30913f7e058 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py @@ -0,0 +1,24 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Fourbar-pole swing-up environment.""" + +import gymnasium as gym + +from . import agents + +## +# Register Gym environments -- manager-based workflow. +## + +gym.register( + id="Isaac-Fourbar-Pole-Swingup", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.fourbar_pole_manager_env_cfg:FourbarPoleSwingupEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_manager_ppo_cfg:FourbarPolePPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py new file mode 100644 index 000000000000..460a30569089 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py new file mode 100644 index 000000000000..c37510ea4b0c --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py @@ -0,0 +1,46 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.utils.configclass import configclass + +from isaaclab_rl.rsl_rl import ( + RslRlMLPModelCfg, + RslRlOnPolicyRunnerCfg, + RslRlPpoAlgorithmCfg, +) + + +@configclass +class FourbarPolePPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 16 + max_iterations = 300 + save_interval = 50 + experiment_name = "fourbar_pole" + obs_groups = {"actor": ["policy"], "critic": ["policy"]} + actor = RslRlMLPModelCfg( + hidden_dims=[64, 64], + activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[64, 64], + activation="elu", + obs_normalization=False, + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.001, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py new file mode 100644 index 000000000000..4708f24746f8 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py @@ -0,0 +1,256 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import copy +import math +from dataclasses import MISSING + +from isaaclab_newton.physics import KaminoSolverCfg, NewtonCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.utils.configclass import configclass +from isaaclab.visualizers import VisualizerCfg + +import isaaclab_tasks.core.fourbar_pole.mdp as mdp +from isaaclab_tasks.core.fourbar_pole.mdp.rewards import UprightSuccessRateCommandCfg +from isaaclab_tasks.utils import PresetCfg + +## +# Pre-defined configs +## +from isaaclab_assets.robots.fourbar_pole import FOURBAR_POLE_CFG # isort:skip + + +## +# Physics backend presets +## + + +@configclass +class FourbarPolePhysicsCfg(PresetCfg): + """Physics backends for the fourbar-pole task. + + Only the kamino (Newton constraint) solver is wired up for now; the closed + four-bar kinematic loop is resolved as an equality constraint by kamino. The + ``default`` field mirrors ``newton_kamino`` so the task uses it without any + ``physics=`` selector. Additional backends will be added later. + """ + + default: NewtonCfg = MISSING + newton_kamino: NewtonCfg = NewtonCfg( + solver_cfg=KaminoSolverCfg( + integrator="euler", + use_fk_solver=True, + sparse_jacobian=True, + constraints_alpha=0.1, + padmm_max_iterations=100, + padmm_rho_0=0.1, + padmm_warmstart_mode="containers", + ), + num_substeps=1, + debug_mode=False, + use_cuda_graph=True, + ) + + def __post_init__(self): + if not isinstance(self.default, NewtonCfg): + self.default = copy.deepcopy(self.newton_kamino) + + +## +# Scene definition +## + + +@configclass +class FourbarPoleSceneCfg(InteractiveSceneCfg): + """Configuration for a fourbar-pole scene.""" + + # ground plane + ground = AssetBaseCfg( + prim_path="/World/ground", + spawn=sim_utils.GroundPlaneCfg(size=(100.0, 100.0)), + ) + + # fourbar-pole + robot: ArticulationCfg = FOURBAR_POLE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # lights + dome_light = AssetBaseCfg( + prim_path="/World/DomeLight", + spawn=sim_utils.DomeLightCfg(color=(0.9, 0.9, 0.9), intensity=500.0), + ) + + +## +# MDP settings +## + + +@configclass +class CommandsCfg: + """Command specifications for the MDP.""" + + # Command term to track pole upright success rate + upright = UprightSuccessRateCommandCfg( + asset_cfg=SceneEntityCfg("robot", joint_names=["coupler_to_pole"]), + threshold=0.95, + ) + + +@configclass +class ActionsCfg: + """Action specifications for the MDP.""" + + joint_effort = mdp.JointEffortActionCfg(asset_name="robot", joint_names=["ground_to_crank"], scale=50.0) + + +@configclass +class ObservationsCfg: + """Observation specifications for the MDP.""" + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group.""" + + # pole encoded as (cos, sin, vel) to avoid the +-pi wrap discontinuity during swing-up + pole_cos = ObsTerm( + func=mdp.joint_pos_cos, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + pole_sin = ObsTerm( + func=mdp.joint_pos_sin, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + pole_vel = ObsTerm( + func=mdp.joint_vel_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + + # crank encoded with (pos, vel). No wrapping possible due to joint limits. + crank_pos = ObsTerm( + func=mdp.joint_pos_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"])}, + ) + crank_vel = ObsTerm( + func=mdp.joint_vel_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"])}, + ) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + # observation groups + policy: PolicyCfg = PolicyCfg() + + +@configclass +class EventCfg: + """Configuration for events.""" + + # reset crank around its default (0) so the cart starts at varied positions + reset_crank_position = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"]), + "position_range": (-0.25 * math.pi, 0.25 * math.pi), + "velocity_range": (-0.5, 0.5), + }, + ) + + # reset pole around its default (0) with a small perturbation + reset_pole_position = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"]), + "position_range": (-math.pi, math.pi), + "velocity_range": (-0.5, 0.5), + }, + ) + + +@configclass +class RewardsCfg: + """Reward terms for the MDP.""" + + # (1) Primary task: swing the pole up and keep it upright (cos is maximal upright) + pole_upright = RewTerm( + func=mdp.pole_upright, + weight=1.0, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + # (2) Shaping: damp the pole angular velocity to settle at the top + pole_vel = RewTerm( + func=mdp.joint_vel_l1, + weight=-0.01, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + # (3) Shaping: discourage excessive crank motion + crank_vel = RewTerm( + func=mdp.joint_vel_l1, + weight=-0.05, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"])}, + ) + # (4) Shaping: penalize control effort + effort = RewTerm( + func=mdp.action_l2, + weight=-0.01, + ) + + +@configclass +class TerminationsCfg: + """Termination terms for the MDP.""" + + # Time out only -- the pole must be free to hang and the parallelogram is geometrically bounded. + time_out = DoneTerm(func=mdp.time_out, time_out=True) + + +## +# Environment configuration +## + + +@configclass +class FourbarPoleSwingupEnvCfg(ManagerBasedRLEnvCfg): + """Configuration for the fourbar-pole swing-up environment.""" + + # Scene settings + scene: FourbarPoleSceneCfg = FourbarPoleSceneCfg(num_envs=4096, env_spacing=4.0, clone_in_fabric=True) + # Basic settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + commands: CommandsCfg = CommandsCfg() + events: EventCfg = EventCfg() + # MDP settings + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + + # Post initialization + def __post_init__(self) -> None: + """Post initialization.""" + # general settings + self.decimation = 2 + self.episode_length_s = 5 + # viewer settings + self.viewer.eye = (12.0, 0.0, 4.0) + # Match Newton GL / --video camera to the task viewport when --viz newton creates the visualizer. + self.sim.default_visualizer_cfg = VisualizerCfg(eye=self.viewer.eye, lookat=self.viewer.lookat) + # simulation settings + self.sim.dt = 1 / 120 + self.sim.render_interval = self.decimation + self.sim.physics = FourbarPolePhysicsCfg() diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.py new file mode 100644 index 000000000000..20219001d70e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""This sub-module contains the functions that are specific to the fourbar-pole environments.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi new file mode 100644 index 000000000000..1b852382866f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi @@ -0,0 +1,21 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "UprightSuccessRateCommand", + "UprightSuccessRateCommandCfg", + "joint_pos_cos", + "joint_pos_sin", + "pole_upright", +] + +from .rewards import ( + UprightSuccessRateCommand, + UprightSuccessRateCommandCfg, + joint_pos_cos, + joint_pos_sin, + pole_upright, +) +from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py new file mode 100644 index 000000000000..f03243c6ae45 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py @@ -0,0 +1,91 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Functions specific to the fourbar-pole swing-up environments. + +This module holds both observation helpers (``joint_pos_cos`` / ``joint_pos_sin``) +that encode an angle without the ``+-pi`` wrap discontinuity, and the swing-up +reward / success-metric terms. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import CommandTerm, CommandTermCfg, SceneEntityCfg +from isaaclab.utils.configclass import configclass + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +def joint_pos_cos(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: + """Cosine of the selected joint positions. + + Encodes the angle without the wrap-around discontinuity at ``+-pi`` so the + policy sees a smooth signal as the pole swings through the bottom. + """ + asset: Articulation = env.scene[asset_cfg.name] + return torch.cos(asset.data.joint_pos.torch[:, asset_cfg.joint_ids]) + + +def joint_pos_sin(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: + """Sine of the selected joint positions (companion to :func:`joint_pos_cos`).""" + asset: Articulation = env.scene[asset_cfg.name] + return torch.sin(asset.data.joint_pos.torch[:, asset_cfg.joint_ids]) + + +def pole_upright(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: + """Uprightness reward for the pole, maximal (``+1``) when fully upright. + + The pole joint is zero when upright and ``+-pi`` when hanging, so ``cos`` gives + a dense shaping signal in ``[-1, 1]`` that drives the swing-up. + """ + asset: Articulation = env.scene[asset_cfg.name] + return torch.sum(torch.cos(asset.data.joint_pos.torch[:, asset_cfg.joint_ids]), dim=1) + + +class UprightSuccessRateCommand(CommandTerm): + """Command term that tracks pole-upright terminal success as a metric.""" + + cfg: UprightSuccessRateCommandCfg + + def __init__(self, cfg: UprightSuccessRateCommandCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + self._asset_cfg = cfg.asset_cfg + self._asset_cfg.resolve(env.scene) + self._asset: Articulation = env.scene[self._asset_cfg.name] + + self._command = torch.zeros((self.num_envs, 1), device=self.device) + self.metrics["success_rate"] = torch.zeros(self.num_envs, device=self.device) + + @property + def command(self) -> torch.Tensor: + return self._command + + def _update_metrics(self): + pole_pos = self._asset.data.joint_pos.torch[:, self._asset_cfg.joint_ids] + self.metrics["success_rate"] = (torch.cos(pole_pos) > self.cfg.threshold).all(dim=1).float() + + def _resample_command(self, env_ids: Sequence[int]): + pass + + def _update_command(self): + pass + + +@configclass +class UprightSuccessRateCommandCfg(CommandTermCfg): + """Configuration for :class:`UprightSuccessRateCommand`.""" + + class_type: type[UprightSuccessRateCommand] | str = "{DIR}.rewards:UprightSuccessRateCommand" + resampling_time_range: tuple[float, float] = (1e6, 1e6) + + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", joint_names=["coupler_to_pole"]) + threshold: float = 0.95 From c67c6811e25dc83290a86c41aa56619563e0b392 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:25:08 +0000 Subject: [PATCH 20/23] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 8.1.2 → 9.0.0 - isaaclab_assets: 0.4.1 → 0.4.2 - isaaclab_ovphysx: 6.1.0 → 6.1.1 - isaaclab_tasks: 8.1.5 → 8.1.6 --- .../antoiner-benchmark-core.major.rst | 44 ---------------- .../fix-rl-train-prelaunch-imports.rst | 4 -- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 50 +++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/rgrandia-fourbar-pole.rst | 5 -- source/isaaclab_assets/config/extension.toml | 2 +- source/isaaclab_assets/docs/CHANGELOG.rst | 10 ++++ source/isaaclab_assets/pyproject.toml | 2 +- .../antoiner-pin-ovphysx-0-5-2.rst | 5 -- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 10 ++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- .../changelog.d/aserifi-kamino_cleanup.rst | 12 ----- .../changelog.d/fourbar-pole-swingup.rst | 5 -- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 23 +++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 18 files changed, 101 insertions(+), 83 deletions(-) delete mode 100644 source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst delete mode 100644 source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst delete mode 100644 source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst delete mode 100644 source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst delete mode 100644 source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst diff --git a/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst b/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst deleted file mode 100644 index b33d2628bfb1..000000000000 --- a/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst +++ /dev/null @@ -1,44 +0,0 @@ -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. diff --git a/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst b/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst deleted file mode 100644 index 533762833f0e..000000000000 --- a/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed intermittent kitless Newton training startup failures by avoiding overlapping standalone USD package providers on x86. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 3207fd89b945..ec207c8f819e 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "8.1.2" +version = "9.0.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 3883905e87ba..818078ed1c53 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,56 @@ Changelog --------- +9.0.0 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added a backend-agnostic benchmark core under :mod:`isaaclab.test.benchmark`, + including the ``capture``, ``metrics``, ``builders``, ``stepping``, + and ``profiling`` submodules, for assembling and emitting schema-v1 benchmark + bundles (``RuntimeBundle`` / ``TrainingBundle`` / + ``StartupBundle``). +* Added a ``schema`` output formatter that serializes a benchmark bundle through + :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`, and taught + ``BaseIsaacLabBenchmark`` to emit several formatters in one run from a + comma-separated formatter selection and a new ``attach_bundle`` hook. +* Added runtime and package version metadata to schema benchmark bundles, + including IsaacLab extensions, OVRTX, OVPhysX, MuJoCo, CUDA bindings, and + USD Core. + +Changed +^^^^^^^ + +* **Breaking:** Renamed the benchmark metrics-formatter module + ``isaaclab.test.benchmark.backends`` to ``isaaclab.test.benchmark.formatters``, and the + ``MetricsBackend`` / ``MetricsBackendInterface`` classes to ``MetricsFormatter`` / + ``MetricsFormatterInterface``. The output formatter classes (``JSONFileMetrics``, + ``SummaryMetrics``, ``OsmoKPIFile``, ``OmniPerfKPIFile``) are unchanged but now live in the + ``formatters`` module — update imports from ``isaaclab.test.benchmark.backends`` to + ``isaaclab.test.benchmark.formatters``. The + :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark` constructor keeps ``backend_type`` as + an alias for the new ``formatter_type`` argument, so callers that pass ``backend_type=`` + continue to work unchanged. + +Fixed +^^^^^ + +* Fixed multi-phase :class:`~isaaclab.test.benchmark.OsmoKPIFile` output + overwriting earlier phases by writing one phase-suffixed JSON file per phase. +* Fixed benchmark run metadata to use resolved task defaults for physics and + rendering backends. +* Fixed simulation launch failures being reported with a zero process exit + status during Kit fast shutdown. +* Fixed benchmark metadata so Kit-full runs now report Kit and Isaac Sim + versions while Kitless runs report null. +* Fixed benchmark metadata to report the installed OVPhysX runtime version. +* Fixed benchmark metadata to preserve null values for unavailable OVRTX and + OVPhysX runtimes. +* Fixed intermittent kitless Newton training startup failures by avoiding overlapping standalone USD package providers on x86. + + 8.1.2 (2026-07-06) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index e3c8cbd2c74e..ab47be147f54 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.1.2" +version = "9.0.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst b/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst deleted file mode 100644 index 30f654eedda0..000000000000 --- a/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst +++ /dev/null @@ -1,5 +0,0 @@ -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. diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index 0a560242c288..ea533ef3419a 100644 --- a/source/isaaclab_assets/config/extension.toml +++ b/source/isaaclab_assets/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.4.1" +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 8a2a4dd58bf6..04cc3850c7bd 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ 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) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/pyproject.toml b/source/isaaclab_assets/pyproject.toml index b75ffb3e97ac..c212565d63a6 100644 --- a/source/isaaclab_assets/pyproject.toml +++ b/source/isaaclab_assets/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_assets" -version = "0.4.1" +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_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst deleted file mode 100644 index 55367b600eaa..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed the OVPhysX optional runtime dependency to install - ``ovphysx==0.5.2+head.f62c22207c``, matching the supported runtime wheel. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index c25e8b2f01ae..17482a81446f 100644 --- a/source/isaaclab_ovphysx/config/extension.toml +++ b/source/isaaclab_ovphysx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "6.1.0" +version = "6.1.1" # Description title = "OvPhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_ovphysx/docs/CHANGELOG.rst b/source/isaaclab_ovphysx/docs/CHANGELOG.rst index 96425f9de745..1f62c943719e 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +6.1.1 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed the OVPhysX optional runtime dependency to install + ``ovphysx==0.5.2+head.f62c22207c``, matching the supported runtime wheel. + + 6.1.0 (2026-07-06) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 75755790e384..8d85e5154407 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "6.1.0" +version = "6.1.1" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst b/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst deleted file mode 100644 index d200dbd19e49..000000000000 --- a/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst +++ /dev/null @@ -1,12 +0,0 @@ -Changed -^^^^^^^ - -* Changed the Disney DR Legs walk / hold-pose Kamino stepping to ``sim.dt = 1/150`` - with ``decimation = 3`` (previously ``0.004`` / ``5``). This keeps the 50 Hz control - rate while reducing the number of Kamino solver calls per control step from 5 to 3, - for roughly 1.6x faster simulation with equivalent task performance. -* Changed the ``newton_kamino`` presets of the velocity (A1, AnymalB, AnymalC, Go1, Go2, - Cassie, G1, H1, Spot), cabinet, and shadow-hand reorient tasks to use the default - single physics substep (removed the ``num_substeps=2`` override), and dropped the - redundant explicit ``num_substeps`` overrides from the cartpole, ant, and reach Kamino - presets. diff --git a/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst b/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst deleted file mode 100644 index 0bd1928b8f82..000000000000 --- a/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_tasks.core.fourbar_pole.fourbar_pole_manager_env_cfg.FourbarPoleSwingupEnvCfg` - as the ``Isaac-Fourbar-Pole-Swingup`` Gym environment for four-bar pole swing-up with the Kamino solver. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 026ae3a7c211..2fdf98600c1a 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "8.1.5" +version = "8.1.6" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 8750611e4ab8..7ad8be7ffe9f 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,29 @@ Changelog --------- +8.1.6 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_tasks.core.fourbar_pole.fourbar_pole_manager_env_cfg.FourbarPoleSwingupEnvCfg` + as the ``Isaac-Fourbar-Pole-Swingup`` Gym environment for four-bar pole swing-up with the Kamino solver. + +Changed +^^^^^^^ + +* Changed the Disney DR Legs walk / hold-pose Kamino stepping to ``sim.dt = 1/150`` + with ``decimation = 3`` (previously ``0.004`` / ``5``). This keeps the 50 Hz control + rate while reducing the number of Kamino solver calls per control step from 5 to 3, + for roughly 1.6x faster simulation with equivalent task performance. +* Changed the ``newton_kamino`` presets of the velocity (A1, AnymalB, AnymalC, Go1, Go2, + Cassie, G1, H1, Spot), cabinet, and shadow-hand reorient tasks to use the default + single physics substep (removed the ``num_substeps=2`` override), and dropped the + redundant explicit ``num_substeps`` overrides from the cartpole, ant, and reach Kamino + presets. + + 8.1.5 (2026-07-06) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 96051ad002e8..47db04587cfc 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.5" +version = "8.1.6" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 569fcaae47aaaa967d415f3f37274853a9547e07 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Tue, 7 Jul 2026 09:54:58 +0200 Subject: [PATCH 21/23] Articulation Reordering Part 1/8: Extract rigid object interface test utilities (#6334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Moves the rigid object and rigid object collection interface test factories into shared underscore-prefixed utility modules so later parts can reuse them. Pure code movement with no behavior change and no ordering content. Part 1/8 of the series splitting #6248 into independently reviewable units. #6248 remains open, untouched, as the reference implementation; this stack was cut from its final tree after a `develop` sync (Kamino Core, Newton bump, OVPhysX 0.5.1). ## Stack - **Part 1/8 (this PR)**: #6334 — Extract rigid object interface test utilities - Part 2/8: #6335 — Core ordering foundation - Part 3/8: #6336 — Newton backend - Part 4/8: #6337 — PhysX backend - Part 5/8: #6338 — Extract articulation interface test utilities - Part 6/8: #6339 — OVPhysX backend - Part 7/8: #6340 — Cross-backend ordering interface tests - Part 8/8: #6341 — Events, consumers, and documentation ## Type of change - Test/documentation/refactor change ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] My name exists in `CONTRIBUTORS.md` ## Test Plan - [x] `./isaaclab.sh -p -m pytest source/isaaclab/test/assets/test_rigid_object_iface.py` -> 1478 passed, 12 xfailed - [x] `./isaaclab.sh -p -m pytest source/isaaclab/test/assets/test_rigid_object_collection_iface.py` -> 1902 passed; the 7 failures + 636 errors are pre-existing on `develop` in the local environment (OVPhysX 0.4.13 wheel vs the 0.5.x binding API from #6314) — fail-set identical to the `develop` baseline - [x] `./isaaclab.sh -f` -> all hooks pass --------- Signed-off-by: Antoine RICHARD Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../articulation-reordering-p1.skip | 0 ...igid_object_collection_iface_test_utils.py | 321 ++++++++++++++++++ .../assets/_rigid_object_iface_test_utils.py | 313 +++++++++++++++++ .../test_rigid_object_collection_iface.py | 321 +----------------- .../test/assets/test_rigid_object_iface.py | 309 +---------------- 5 files changed, 636 insertions(+), 628 deletions(-) create mode 100644 source/isaaclab/changelog.d/articulation-reordering-p1.skip create mode 100644 source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py create mode 100644 source/isaaclab/test/assets/_rigid_object_iface_test_utils.py diff --git a/source/isaaclab/changelog.d/articulation-reordering-p1.skip b/source/isaaclab/changelog.d/articulation-reordering-p1.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py new file mode 100644 index 000000000000..426c43f0f7fb --- /dev/null +++ b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py @@ -0,0 +1,321 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + +"""Shared mocked rigid-object-collection backend factories for interface tests.""" + +import os +import sys +import importlib.util +from unittest.mock import MagicMock + +# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher +# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets +# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless +# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is +# set (bare Python, no Kit at all). +_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( + os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ +) + +if not _kitless: + from isaaclab.app import AppLauncher + + simulation_app = AppLauncher(headless=True).app +else: + simulation_app = None + # Stub out the Kit/Omniverse modules that are not present under + # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # ``omni`` is a real namespace package, so missing submodules also need + # to be installed as attributes on it -- ``sys.modules`` alone is not + # enough because attribute access on the real ``omni`` won't fall + # through to ``sys.modules``. + import omni as _omni + + for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): + _stub = MagicMock() + sys.modules[f"omni.{_mod}"] = _stub + # Bind the leaf attribute so that ``omni.`` resolves. + setattr(_omni, _mod.split(".", 1)[0], _stub) + for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): + sys.modules.setdefault(_mod, MagicMock()) + +import numpy as np +import warp as wp + +from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg +from isaaclab.assets.rigid_object_collection.rigid_object_collection_cfg import RigidObjectCollectionCfg +from isaaclab.test.mock_interfaces.utils import MockWrenchComposer + +# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity +_mock_physics_sim_view = MagicMock() +_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) + +from isaaclab_physx.physics import PhysxManager as SimulationManager + +SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) + +BACKENDS = ["Mock"] # Mock backend is always available. + +if importlib.util.find_spec("isaaclab_physx") is not None: + from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection import ( + RigidObjectCollection as PhysXRigidObjectCollection, + ) + from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData as PhysXRigidObjectCollectionData, + ) + from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp + + BACKENDS.append("physx") + +if importlib.util.find_spec("isaaclab_newton") is not None: + from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection import ( + RigidObjectCollection as NewtonRigidObjectCollection, + ) + from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData as NewtonRigidObjectCollectionData, + ) + from isaaclab_newton.test.mock_interfaces.mock_newton import MockWrenchComposer as NewtonMockWrenchComposer + from isaaclab_newton.test.mock_interfaces.views import MockNewtonCollectionView as NewtonMockCollectionView + + BACKENDS.append("newton") + +if ( + importlib.util.find_spec("isaaclab_ovphysx") is not None + and importlib.util.find_spec("ovphysx") is not None +): + from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection import ( + RigidObjectCollection as OvPhysxRigidObjectCollection, + ) + from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData as OvPhysxRigidObjectCollectionData, + ) + from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet + + if hasattr(OvPhysxRigidObjectCollection, "_create_buffers"): + BACKENDS.append("ovphysx") + + +def create_physx_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test RigidObjectCollection instance with mocked dependencies.""" + collection = object.__new__(PhysXRigidObjectCollection) + + rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} + collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + + # View count = num_instances * num_bodies (body-major view order) + mock_view = PhysXMockRigidBodyViewWarp( + count=num_instances * num_bodies, + device=device, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + object.__setattr__(collection, "_root_view", mock_view) + object.__setattr__(collection, "_device", device) + object.__setattr__(collection, "_num_bodies", num_bodies) + object.__setattr__(collection, "_num_instances", num_instances) + object.__setattr__(collection, "_body_names_list", [f"object_{i}" for i in range(num_bodies)]) + + # Create RigidObjectCollectionData instance + data = PhysXRigidObjectCollectionData(mock_view, num_bodies, device) + object.__setattr__(collection, "_data", data) + data.body_names = [f"object_{i}" for i in range(num_bodies)] + + # Create mock wrench composers + mock_inst_wrench = MockWrenchComposer(collection) + mock_perm_wrench = MockWrenchComposer(collection) + object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(collection, "_initialize_handle", None) + object.__setattr__(collection, "_invalidate_initialize_handle", None) + object.__setattr__(collection, "_prim_deletion_handle", None) + object.__setattr__(collection, "_debug_vis_handle", None) + + # Set up index arrays + object.__setattr__( + collection, "_ALL_ENV_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device) + ) + object.__setattr__(collection, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device)) + + return collection, mock_view + + +def create_newton_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test Newton RigidObjectCollection instance with mocked dependencies.""" + import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection as newton_coll_module + import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data as newton_data_module + + body_names = [f"object_{i}" for i in range(num_bodies)] + + # Create collection-specific mock view with (N, B) root shapes + mock_view = NewtonMockCollectionView( + num_envs=num_instances, + num_bodies=num_bodies, + device=device, + body_names=body_names, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + # Mock NewtonManager (aliased as SimulationManager in Newton modules) + mock_model = MagicMock() + mock_model.gravity = wp.array(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), dtype=wp.vec3f, device=device) + mock_state = MagicMock() + mock_control = MagicMock() + + mock_manager = MagicMock() + mock_manager.get_model.return_value = mock_model + mock_manager.get_state_0.return_value = mock_state + mock_manager.get_state_1.return_value = mock_state + mock_manager.get_control.return_value = mock_control + + # Patch SimulationManager in both data and collection modules + original_data_manager = newton_data_module.SimulationManager + original_coll_manager = newton_coll_module.SimulationManager + newton_data_module.SimulationManager = mock_manager + newton_coll_module.SimulationManager = mock_manager + + try: + data = NewtonRigidObjectCollectionData(mock_view, num_bodies, device) + finally: + newton_data_module.SimulationManager = original_data_manager + newton_coll_module.SimulationManager = original_coll_manager + + # Create collection shell (bypass __init__) + collection = object.__new__(NewtonRigidObjectCollection) + + rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} + collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + + object.__setattr__(collection, "_root_view", mock_view) + object.__setattr__(collection, "_device", device) + object.__setattr__(collection, "_num_bodies", num_bodies) + object.__setattr__(collection, "_num_instances", num_instances) + object.__setattr__(collection, "_body_names_list", body_names) + object.__setattr__(collection, "_data", data) + data.body_names = body_names + + # Mock wrench composers (Newton-specific) + mock_inst_wrench = NewtonMockWrenchComposer(collection) + mock_perm_wrench = NewtonMockWrenchComposer(collection) + object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(collection, "_initialize_handle", None) + object.__setattr__(collection, "_invalidate_initialize_handle", None) + object.__setattr__(collection, "_prim_deletion_handle", None) + object.__setattr__(collection, "_debug_vis_handle", None) + + # Index arrays (warp) + object.__setattr__( + collection, "_ALL_ENV_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device) + ) + object.__setattr__(collection, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device)) + object.__setattr__(collection, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) + object.__setattr__(collection, "_ALL_BODY_MASK", wp.ones((num_bodies,), dtype=wp.bool, device=device)) + + return collection, mock_view + + +def create_ovphysx_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test OVPhysX RigidObjectCollection instance with mocked tensor bindings.""" + body_names = [f"object_{i}" for i in range(num_bodies)] + + collection = object.__new__(OvPhysxRigidObjectCollection) + + rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} + collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + + # Use articulation-mode bindings with num_joints=0 to get (N, B, ...) shaped tensors. + mock_bindings = MockOvPhysxBindingSet( + num_instances=num_instances, + num_joints=0, + num_bodies=num_bodies, + body_names=body_names, + asset_kind="articulation", + ) + mock_bindings.set_random_data() + + object.__setattr__(collection, "_device", device) + object.__setattr__(collection, "_ovphysx", MagicMock()) + object.__setattr__(collection, "_bindings", mock_bindings.bindings) + object.__setattr__(collection, "_num_instances", num_instances) + object.__setattr__(collection, "_num_bodies", num_bodies) + object.__setattr__(collection, "_body_names_list", body_names) + + # Create RigidObjectCollectionData + data = OvPhysxRigidObjectCollectionData(mock_bindings.bindings, num_bodies, device) + data.num_instances = num_instances + data.num_bodies = num_bodies + data._is_primed = True + object.__setattr__(collection, "_data", data) + + # Allocate the buffers that RigidObjectCollection normally allocates in _initialize_impl. + collection._create_buffers() + + # Replace the real wrench composers with mocks for iface coverage. + mock_inst_wrench = MockWrenchComposer(collection) + mock_perm_wrench = MockWrenchComposer(collection) + object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising + object.__setattr__(collection, "_initialize_handle", None) + object.__setattr__(collection, "_invalidate_initialize_handle", None) + object.__setattr__(collection, "_prim_deletion_handle", None) + object.__setattr__(collection, "_debug_vis_handle", None) + + return collection, mock_bindings + + +def create_mock_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + from isaaclab.test.mock_interfaces.assets.mock_rigid_object_collection import MockRigidObjectCollection + + obj = MockRigidObjectCollection( + num_instances=num_instances, + num_bodies=num_bodies, + device=device, + ) + return obj, None + + +def get_rigid_object_collection( + backend: str, + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + if backend == "physx": + return create_physx_rigid_object_collection(num_instances, num_bodies, device) + elif backend == "ovphysx": + return create_ovphysx_rigid_object_collection(num_instances, num_bodies, device) + elif backend == "newton": + return create_newton_rigid_object_collection(num_instances, num_bodies, device) + elif backend.lower() == "mock": + return create_mock_rigid_object_collection(num_instances, num_bodies, device) + else: + raise ValueError(f"Invalid backend: {backend}") diff --git a/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py new file mode 100644 index 000000000000..1004d3db0480 --- /dev/null +++ b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py @@ -0,0 +1,313 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + +"""Shared mocked rigid-object backend factories for interface tests.""" + +import os +import sys +import importlib.util +from unittest.mock import MagicMock + +# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher +# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets +# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless +# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is +# set (bare Python, no Kit at all). +_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( + os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ +) + +if not _kitless: + from isaaclab.app import AppLauncher + + simulation_app = AppLauncher(headless=True).app +else: + simulation_app = None + # Stub out the Kit/Omniverse modules that are not present under + # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # ``omni`` is a real namespace package, so missing submodules also need + # to be installed as attributes on it -- ``sys.modules`` alone is not + # enough because attribute access on the real ``omni`` won't fall + # through to ``sys.modules``. + import omni as _omni + + for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): + _stub = MagicMock() + sys.modules[f"omni.{_mod}"] = _stub + # Bind the leaf attribute so that ``omni.`` resolves. + setattr(_omni, _mod.split(".", 1)[0], _stub) + for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): + sys.modules.setdefault(_mod, MagicMock()) + +import numpy as np +import warp as wp + +from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg +from isaaclab.test.mock_interfaces.utils import MockWrenchComposer + +# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity +# This is needed because the Data classes call SimulationManager.get_physics_sim_view().get_gravity() +# but there's no actual physics scene when running unit tests +_mock_physics_sim_view = MagicMock() +_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) + +from isaaclab_physx.physics import PhysxManager as SimulationManager + +SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) + +BACKENDS = ["Mock"] # Mock backend is always available. + +if importlib.util.find_spec("isaaclab_physx") is not None: + from isaaclab_physx.assets.rigid_object.rigid_object import RigidObject as PhysXRigidObject + from isaaclab_physx.assets.rigid_object.rigid_object_data import RigidObjectData as PhysXRigidObjectData + from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp + + BACKENDS.append("physx") + +if importlib.util.find_spec("isaaclab_newton") is not None: + from isaaclab_newton.assets.rigid_object.rigid_object import RigidObject as NewtonRigidObject + from isaaclab_newton.assets.rigid_object.rigid_object_data import RigidObjectData as NewtonRigidObjectData + from isaaclab_newton.test.mock_interfaces.views import MockNewtonArticulationView as NewtonMockArticulationView + + BACKENDS.append("newton") + +if ( + importlib.util.find_spec("isaaclab_ovphysx") is not None + and importlib.util.find_spec("ovphysx") is not None +): + from isaaclab_ovphysx.assets.rigid_object.rigid_object import RigidObject as OvPhysxRigidObject + from isaaclab_ovphysx.assets.rigid_object.rigid_object_data import RigidObjectData as OvPhysxRigidObjectData + from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet + + BACKENDS.append("ovphysx") + + +def create_physx_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + """Create a test RigidObject instance with mocked dependencies.""" + body_names = ["body_0"] + + rigid_object = object.__new__(PhysXRigidObject) + + rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") + + # Create PhysX mock view + mock_view = PhysXMockRigidBodyViewWarp( + count=num_instances, + device=device, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + object.__setattr__(rigid_object, "_root_view", mock_view) + object.__setattr__(rigid_object, "_device", device) + + # Create RigidObjectData instance (SimulationManager already mocked at module level) + data = PhysXRigidObjectData(mock_view, device) + object.__setattr__(rigid_object, "_data", data) + + # Set body names on data + data.body_names = body_names + + # Create mock wrench composers + mock_inst_wrench = MockWrenchComposer(rigid_object) + mock_perm_wrench = MockWrenchComposer(rigid_object) + object.__setattr__(rigid_object, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(rigid_object, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(rigid_object, "_initialize_handle", None) + object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) + object.__setattr__(rigid_object, "_prim_deletion_handle", None) + object.__setattr__(rigid_object, "_debug_vis_handle", None) + + # Set up index arrays (warp arrays for rigid object) + object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) + object.__setattr__(rigid_object, "_ALL_BODY_INDICES", wp.array(np.array([0], dtype=np.int32), device=device)) + + # Cached .view(wp.float32) wrappers + object.__setattr__(rigid_object, "_root_link_pose_w_f32", None) + object.__setattr__(rigid_object, "_root_com_vel_w_f32", None) + object.__setattr__(rigid_object, "_inst_wrench_force_f32", None) + object.__setattr__(rigid_object, "_inst_wrench_torque_f32", None) + object.__setattr__(rigid_object, "_perm_wrench_force_f32", None) + object.__setattr__(rigid_object, "_perm_wrench_torque_f32", None) + + # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes + N, B = num_instances, 1 # rigid object has 1 body + cpu_env_ids = wp.array(np.arange(N, dtype=np.int32), device="cpu") + object.__setattr__(rigid_object, "_cpu_env_ids_all", cpu_env_ids) + object.__setattr__(rigid_object, "_cpu_body_mass", wp.zeros((N, B), dtype=wp.float32, device="cpu")) + object.__setattr__(rigid_object, "_cpu_body_coms", wp.zeros((N, B, 7), dtype=wp.float32, device="cpu")) + object.__setattr__(rigid_object, "_cpu_body_inertia", wp.zeros((N, B, 9), dtype=wp.float32, device="cpu")) + + return rigid_object, mock_view + + +def create_newton_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + """Create a test Newton RigidObject instance with mocked dependencies.""" + import isaaclab_newton.assets.rigid_object.rigid_object_data as newton_data_module + + body_names = ["body_0"] + + # Create Newton mock view (uses ArticulationView with num_bodies=1 for rigid objects) + mock_view = NewtonMockArticulationView( + num_instances=num_instances, + num_bodies=1, + num_joints=0, + device=device, + is_fixed_base=False, + joint_names=[], + body_names=body_names, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + # Mock NewtonManager (aliased as SimulationManager in Newton modules) + mock_model = MagicMock() + mock_model.gravity = wp.array(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), dtype=wp.vec3f, device=device) + mock_state = MagicMock() + mock_control = MagicMock() + + mock_manager = MagicMock() + mock_manager.get_model.return_value = mock_model + mock_manager.get_state_0.return_value = mock_state + mock_manager.get_state_1.return_value = mock_state + mock_manager.get_control.return_value = mock_control + + # Patch SimulationManager in the Newton data module + original_sim_manager = newton_data_module.SimulationManager + newton_data_module.SimulationManager = mock_manager + + try: + data = NewtonRigidObjectData(mock_view, device) + finally: + newton_data_module.SimulationManager = original_sim_manager + + # Create RigidObject shell (bypass __init__) + rigid_object = object.__new__(NewtonRigidObject) + + rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") + + object.__setattr__(rigid_object, "_root_view", mock_view) + object.__setattr__(rigid_object, "_device", device) + object.__setattr__(rigid_object, "_data", data) + + # Mock wrench composers + mock_inst_wrench = MockWrenchComposer(rigid_object) + mock_perm_wrench = MockWrenchComposer(rigid_object) + object.__setattr__(rigid_object, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(rigid_object, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(rigid_object, "_initialize_handle", None) + object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) + object.__setattr__(rigid_object, "_prim_deletion_handle", None) + object.__setattr__(rigid_object, "_debug_vis_handle", None) + + # Newton uses wp.array for indices + object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) + object.__setattr__(rigid_object, "_ALL_BODY_INDICES", wp.array(np.array([0], dtype=np.int32), device=device)) + + # Newton uses wp.bool masks + object.__setattr__(rigid_object, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) + object.__setattr__(rigid_object, "_ALL_BODY_MASK", wp.ones((1,), dtype=wp.bool, device=device)) + + return rigid_object, mock_view + + +def create_ovphysx_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + """Create a test OvPhysX RigidObject instance with mocked tensor bindings.""" + body_names = ["base_link"] + + obj = object.__new__(OvPhysxRigidObject) + + obj.cfg = RigidObjectCfg(prim_path="/World/object") + + # Create mock binding set + mock_bindings = MockOvPhysxBindingSet( + num_instances=num_instances, + num_joints=0, + num_bodies=1, + body_names=body_names, + asset_kind="rigid_object", + ) + mock_bindings.set_random_data() + + object.__setattr__(obj, "_device", device) + object.__setattr__(obj, "_ovphysx", MagicMock()) + object.__setattr__(obj, "_root_view", mock_bindings.view) + object.__setattr__(obj, "_bindings", mock_bindings.bindings) + object.__setattr__(obj, "_num_instances", num_instances) + object.__setattr__(obj, "_num_bodies", 1) + object.__setattr__(obj, "_body_names", body_names) + + # Create RigidObjectData + data = OvPhysxRigidObjectData(mock_bindings.view, device) + data.num_instances = num_instances + data.num_bodies = 1 + data._is_primed = True + object.__setattr__(obj, "_data", data) + + # Build the buffers RigidObject normally allocates in _initialize_impl + # (_ALL_INDICES, _ALL_*_MASK, pinned CPU staging buffers, wrench buf). + # _create_buffers also instantiates real WrenchComposers; those get + # replaced with mocks just below. + obj._create_buffers() + + # Replace the real wrench composers with mocks for iface coverage. + mock_inst_wrench = MockWrenchComposer(obj) + mock_perm_wrench = MockWrenchComposer(obj) + object.__setattr__(obj, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(obj, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising + object.__setattr__(obj, "_initialize_handle", None) + object.__setattr__(obj, "_invalidate_initialize_handle", None) + object.__setattr__(obj, "_prim_deletion_handle", None) + object.__setattr__(obj, "_debug_vis_handle", None) + + return obj, mock_bindings + + +def create_mock_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + from isaaclab.test.mock_interfaces.assets.mock_rigid_object import MockRigidObject + + obj = MockRigidObject( + num_instances=num_instances, + device=device, + ) + return obj, None # No view for mock backend + + +def get_rigid_object( + backend: str, + num_instances: int = 2, + device: str = "cuda:0", +): + if backend == "physx": + return create_physx_rigid_object(num_instances, device) + elif backend == "ovphysx": + return create_ovphysx_rigid_object(num_instances, device) + elif backend == "newton": + return create_newton_rigid_object(num_instances, device) + elif backend.lower() == "mock": + return create_mock_rigid_object(num_instances, device) + else: + raise ValueError(f"Invalid backend: {backend}") diff --git a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py index e5753943bcb4..ee5f24df8d21 100644 --- a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py @@ -14,330 +14,11 @@ The setup is a bit convoluted so that we can run these tests without requiring Isaac Sim or GPU simulation. """ -"""Launch Isaac Sim Simulator first (when available).""" - -import os -import sys -from unittest.mock import MagicMock - -# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher -# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets -# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless -# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is -# set (bare Python, no Kit at all). -_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( - os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ -) - -if not _kitless: - from isaaclab.app import AppLauncher - - simulation_app = AppLauncher(headless=True).app -else: - simulation_app = None - # Stub out the Kit/Omniverse modules that are not present under - # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). - # ``omni`` is a real namespace package, so missing submodules also need - # to be installed as attributes on it -- ``sys.modules`` alone is not - # enough because attribute access on the real ``omni`` won't fall - # through to ``sys.modules``. - import omni as _omni - - for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): - _stub = MagicMock() - sys.modules[f"omni.{_mod}"] = _stub - # Bind the leaf attribute so that ``omni.`` resolves. - setattr(_omni, _mod.split(".", 1)[0], _stub) - for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): - sys.modules.setdefault(_mod, MagicMock()) - import numpy as np import pytest import torch import warp as wp - -from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg -from isaaclab.assets.rigid_object_collection.rigid_object_collection_cfg import RigidObjectCollectionCfg -from isaaclab.test.mock_interfaces.utils import MockWrenchComposer - -# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity -_mock_physics_sim_view = MagicMock() -_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) - -from isaaclab_physx.physics import PhysxManager as SimulationManager - -SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) - -""" -Check which backends are available. -""" - -BACKENDS = ["Mock"] # Mock backend is always available. - -try: - from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection import ( - RigidObjectCollection as PhysXRigidObjectCollection, - ) - from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection_data import ( - RigidObjectCollectionData as PhysXRigidObjectCollectionData, - ) - from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp - - BACKENDS.append("physx") -except ImportError: - pass - -try: - from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection import ( - RigidObjectCollection as NewtonRigidObjectCollection, - ) - from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data import ( - RigidObjectCollectionData as NewtonRigidObjectCollectionData, - ) - from isaaclab_newton.test.mock_interfaces.mock_newton import MockWrenchComposer as NewtonMockWrenchComposer - from isaaclab_newton.test.mock_interfaces.views import MockNewtonCollectionView as NewtonMockCollectionView - - BACKENDS.append("newton") -except ImportError: - pass - -try: - from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection import ( - RigidObjectCollection as OvPhysxRigidObjectCollection, - ) - from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection_data import ( - RigidObjectCollectionData as OvPhysxRigidObjectCollectionData, - ) - from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet - - # Guard against stub implementations (not yet functional). - if not hasattr(OvPhysxRigidObjectCollection, "_create_buffers"): - raise AttributeError("OvPhysxRigidObjectCollection is a stub; skipping ovphysx backend") - - BACKENDS.append("ovphysx") -except (ImportError, AttributeError): - pass - - -def create_physx_rigid_object_collection( - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - """Create a test RigidObjectCollection instance with mocked dependencies.""" - collection = object.__new__(PhysXRigidObjectCollection) - - rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} - collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) - - # View count = num_instances * num_bodies (body-major view order) - mock_view = PhysXMockRigidBodyViewWarp( - count=num_instances * num_bodies, - device=device, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - object.__setattr__(collection, "_root_view", mock_view) - object.__setattr__(collection, "_device", device) - object.__setattr__(collection, "_num_bodies", num_bodies) - object.__setattr__(collection, "_num_instances", num_instances) - object.__setattr__(collection, "_body_names_list", [f"object_{i}" for i in range(num_bodies)]) - - # Create RigidObjectCollectionData instance - data = PhysXRigidObjectCollectionData(mock_view, num_bodies, device) - object.__setattr__(collection, "_data", data) - data.body_names = [f"object_{i}" for i in range(num_bodies)] - - # Create mock wrench composers - mock_inst_wrench = MockWrenchComposer(collection) - mock_perm_wrench = MockWrenchComposer(collection) - object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(collection, "_initialize_handle", None) - object.__setattr__(collection, "_invalidate_initialize_handle", None) - object.__setattr__(collection, "_prim_deletion_handle", None) - object.__setattr__(collection, "_debug_vis_handle", None) - - # Set up index arrays - object.__setattr__( - collection, "_ALL_ENV_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device) - ) - object.__setattr__(collection, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device)) - - return collection, mock_view - - -def create_newton_rigid_object_collection( - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - """Create a test Newton RigidObjectCollection instance with mocked dependencies.""" - import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection as newton_coll_module - import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data as newton_data_module - - body_names = [f"object_{i}" for i in range(num_bodies)] - - # Create collection-specific mock view with (N, B) root shapes - mock_view = NewtonMockCollectionView( - num_envs=num_instances, - num_bodies=num_bodies, - device=device, - body_names=body_names, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - # Mock NewtonManager (aliased as SimulationManager in Newton modules) - mock_model = MagicMock() - mock_model.gravity = wp.array(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), dtype=wp.vec3f, device=device) - mock_state = MagicMock() - mock_control = MagicMock() - - mock_manager = MagicMock() - mock_manager.get_model.return_value = mock_model - mock_manager.get_state_0.return_value = mock_state - mock_manager.get_state_1.return_value = mock_state - mock_manager.get_control.return_value = mock_control - - # Patch SimulationManager in both data and collection modules - original_data_manager = newton_data_module.SimulationManager - original_coll_manager = newton_coll_module.SimulationManager - newton_data_module.SimulationManager = mock_manager - newton_coll_module.SimulationManager = mock_manager - - try: - data = NewtonRigidObjectCollectionData(mock_view, num_bodies, device) - finally: - newton_data_module.SimulationManager = original_data_manager - newton_coll_module.SimulationManager = original_coll_manager - - # Create collection shell (bypass __init__) - collection = object.__new__(NewtonRigidObjectCollection) - - rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} - collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) - - object.__setattr__(collection, "_root_view", mock_view) - object.__setattr__(collection, "_device", device) - object.__setattr__(collection, "_num_bodies", num_bodies) - object.__setattr__(collection, "_num_instances", num_instances) - object.__setattr__(collection, "_body_names_list", body_names) - object.__setattr__(collection, "_data", data) - data.body_names = body_names - - # Mock wrench composers (Newton-specific) - mock_inst_wrench = NewtonMockWrenchComposer(collection) - mock_perm_wrench = NewtonMockWrenchComposer(collection) - object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(collection, "_initialize_handle", None) - object.__setattr__(collection, "_invalidate_initialize_handle", None) - object.__setattr__(collection, "_prim_deletion_handle", None) - object.__setattr__(collection, "_debug_vis_handle", None) - - # Index arrays (warp) - object.__setattr__( - collection, "_ALL_ENV_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device) - ) - object.__setattr__(collection, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device)) - object.__setattr__(collection, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) - object.__setattr__(collection, "_ALL_BODY_MASK", wp.ones((num_bodies,), dtype=wp.bool, device=device)) - - return collection, mock_view - - -def create_ovphysx_rigid_object_collection( - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - """Create a test OVPhysX RigidObjectCollection instance with mocked tensor bindings.""" - body_names = [f"object_{i}" for i in range(num_bodies)] - - collection = object.__new__(OvPhysxRigidObjectCollection) - - rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} - collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) - - # Use articulation-mode bindings with num_joints=0 to get (N, B, ...) shaped tensors. - mock_bindings = MockOvPhysxBindingSet( - num_instances=num_instances, - num_joints=0, - num_bodies=num_bodies, - body_names=body_names, - asset_kind="articulation", - ) - mock_bindings.set_random_data() - - object.__setattr__(collection, "_device", device) - object.__setattr__(collection, "_ovphysx", MagicMock()) - object.__setattr__(collection, "_bindings", mock_bindings.bindings) - object.__setattr__(collection, "_num_instances", num_instances) - object.__setattr__(collection, "_num_bodies", num_bodies) - object.__setattr__(collection, "_body_names_list", body_names) - - # Create RigidObjectCollectionData - data = OvPhysxRigidObjectCollectionData(mock_bindings.bindings, num_bodies, device) - data.num_instances = num_instances - data.num_bodies = num_bodies - data._is_primed = True - object.__setattr__(collection, "_data", data) - - # Allocate the buffers that RigidObjectCollection normally allocates in _initialize_impl. - collection._create_buffers() - - # Replace the real wrench composers with mocks for iface coverage. - mock_inst_wrench = MockWrenchComposer(collection) - mock_perm_wrench = MockWrenchComposer(collection) - object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising - object.__setattr__(collection, "_initialize_handle", None) - object.__setattr__(collection, "_invalidate_initialize_handle", None) - object.__setattr__(collection, "_prim_deletion_handle", None) - object.__setattr__(collection, "_debug_vis_handle", None) - - return collection, mock_bindings - - -def create_mock_rigid_object_collection( - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - from isaaclab.test.mock_interfaces.assets.mock_rigid_object_collection import MockRigidObjectCollection - - obj = MockRigidObjectCollection( - num_instances=num_instances, - num_bodies=num_bodies, - device=device, - ) - return obj, None - - -def get_rigid_object_collection( - backend: str, - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - if backend == "physx": - return create_physx_rigid_object_collection(num_instances, num_bodies, device) - elif backend == "ovphysx": - return create_ovphysx_rigid_object_collection(num_instances, num_bodies, device) - elif backend == "newton": - return create_newton_rigid_object_collection(num_instances, num_bodies, device) - elif backend.lower() == "mock": - return create_mock_rigid_object_collection(num_instances, num_bodies, device) - else: - raise ValueError(f"Invalid backend: {backend}") +from _rigid_object_collection_iface_test_utils import BACKENDS, get_rigid_object_collection @pytest.fixture diff --git a/source/isaaclab/test/assets/test_rigid_object_iface.py b/source/isaaclab/test/assets/test_rigid_object_iface.py index 772130149ee3..c934def4bf36 100644 --- a/source/isaaclab/test/assets/test_rigid_object_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_iface.py @@ -13,318 +13,11 @@ The setup is a bit convoluted so that we can run these tests without requiring Isaac Sim or GPU simulation. """ -"""Launch Isaac Sim Simulator first (when available).""" - -import os -import sys -from unittest.mock import MagicMock - -# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher -# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets -# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless -# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is -# set (bare Python, no Kit at all). -_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( - os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ -) - -if not _kitless: - from isaaclab.app import AppLauncher - - simulation_app = AppLauncher(headless=True).app -else: - simulation_app = None - # Stub out the Kit/Omniverse modules that are not present under - # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). - # ``omni`` is a real namespace package, so missing submodules also need - # to be installed as attributes on it -- ``sys.modules`` alone is not - # enough because attribute access on the real ``omni`` won't fall - # through to ``sys.modules``. - import omni as _omni - - for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): - _stub = MagicMock() - sys.modules[f"omni.{_mod}"] = _stub - # Bind the leaf attribute so that ``omni.`` resolves. - setattr(_omni, _mod.split(".", 1)[0], _stub) - for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): - sys.modules.setdefault(_mod, MagicMock()) - import numpy as np import pytest import torch import warp as wp - -from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg -from isaaclab.test.mock_interfaces.utils import MockWrenchComposer - -# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity -# This is needed because the Data classes call SimulationManager.get_physics_sim_view().get_gravity() -# but there's no actual physics scene when running unit tests -_mock_physics_sim_view = MagicMock() -_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) - -from isaaclab_physx.physics import PhysxManager as SimulationManager - -SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) - -""" -Check which backends are available. -""" - -BACKENDS = ["Mock"] # Mock backend is always available. - -try: - from isaaclab_physx.assets.rigid_object.rigid_object import RigidObject as PhysXRigidObject - from isaaclab_physx.assets.rigid_object.rigid_object_data import RigidObjectData as PhysXRigidObjectData - from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp - - BACKENDS.append("physx") -except ImportError: - pass - -try: - from isaaclab_newton.assets.rigid_object.rigid_object import RigidObject as NewtonRigidObject - from isaaclab_newton.assets.rigid_object.rigid_object_data import RigidObjectData as NewtonRigidObjectData - from isaaclab_newton.test.mock_interfaces.views import MockNewtonArticulationView as NewtonMockArticulationView - - BACKENDS.append("newton") -except ImportError: - pass - -try: - from isaaclab_ovphysx.assets.rigid_object.rigid_object import RigidObject as OvPhysxRigidObject - from isaaclab_ovphysx.assets.rigid_object.rigid_object_data import RigidObjectData as OvPhysxRigidObjectData - from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet - - BACKENDS.append("ovphysx") -except (ImportError, AttributeError): - pass - - -def create_physx_rigid_object( - num_instances: int = 2, - device: str = "cuda:0", -): - """Create a test RigidObject instance with mocked dependencies.""" - body_names = ["body_0"] - - rigid_object = object.__new__(PhysXRigidObject) - - rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") - - # Create PhysX mock view - mock_view = PhysXMockRigidBodyViewWarp( - count=num_instances, - device=device, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - object.__setattr__(rigid_object, "_root_view", mock_view) - object.__setattr__(rigid_object, "_device", device) - - # Create RigidObjectData instance (SimulationManager already mocked at module level) - data = PhysXRigidObjectData(mock_view, device) - object.__setattr__(rigid_object, "_data", data) - - # Set body names on data - data.body_names = body_names - - # Create mock wrench composers - mock_inst_wrench = MockWrenchComposer(rigid_object) - mock_perm_wrench = MockWrenchComposer(rigid_object) - object.__setattr__(rigid_object, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(rigid_object, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(rigid_object, "_initialize_handle", None) - object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) - object.__setattr__(rigid_object, "_prim_deletion_handle", None) - object.__setattr__(rigid_object, "_debug_vis_handle", None) - - # Set up index arrays (warp arrays for rigid object) - object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) - object.__setattr__(rigid_object, "_ALL_BODY_INDICES", wp.array(np.array([0], dtype=np.int32), device=device)) - - # Cached .view(wp.float32) wrappers - object.__setattr__(rigid_object, "_root_link_pose_w_f32", None) - object.__setattr__(rigid_object, "_root_com_vel_w_f32", None) - object.__setattr__(rigid_object, "_inst_wrench_force_f32", None) - object.__setattr__(rigid_object, "_inst_wrench_torque_f32", None) - object.__setattr__(rigid_object, "_perm_wrench_force_f32", None) - object.__setattr__(rigid_object, "_perm_wrench_torque_f32", None) - - # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes - N, B = num_instances, 1 # rigid object has 1 body - cpu_env_ids = wp.array(np.arange(N, dtype=np.int32), device="cpu") - object.__setattr__(rigid_object, "_cpu_env_ids_all", cpu_env_ids) - object.__setattr__(rigid_object, "_cpu_body_mass", wp.zeros((N, B), dtype=wp.float32, device="cpu")) - object.__setattr__(rigid_object, "_cpu_body_coms", wp.zeros((N, B, 7), dtype=wp.float32, device="cpu")) - object.__setattr__(rigid_object, "_cpu_body_inertia", wp.zeros((N, B, 9), dtype=wp.float32, device="cpu")) - - return rigid_object, mock_view - - -def create_newton_rigid_object( - num_instances: int = 2, - device: str = "cuda:0", -): - """Create a test Newton RigidObject instance with mocked dependencies.""" - import isaaclab_newton.assets.rigid_object.rigid_object_data as newton_data_module - - body_names = ["body_0"] - - # Create Newton mock view (uses ArticulationView with num_bodies=1 for rigid objects) - mock_view = NewtonMockArticulationView( - num_instances=num_instances, - num_bodies=1, - num_joints=0, - device=device, - is_fixed_base=False, - joint_names=[], - body_names=body_names, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - # Mock NewtonManager (aliased as SimulationManager in Newton modules) - mock_model = MagicMock() - mock_model.gravity = wp.array(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), dtype=wp.vec3f, device=device) - mock_state = MagicMock() - mock_control = MagicMock() - - mock_manager = MagicMock() - mock_manager.get_model.return_value = mock_model - mock_manager.get_state_0.return_value = mock_state - mock_manager.get_state_1.return_value = mock_state - mock_manager.get_control.return_value = mock_control - - # Patch SimulationManager in the Newton data module - original_sim_manager = newton_data_module.SimulationManager - newton_data_module.SimulationManager = mock_manager - - try: - data = NewtonRigidObjectData(mock_view, device) - finally: - newton_data_module.SimulationManager = original_sim_manager - - # Create RigidObject shell (bypass __init__) - rigid_object = object.__new__(NewtonRigidObject) - - rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") - - object.__setattr__(rigid_object, "_root_view", mock_view) - object.__setattr__(rigid_object, "_device", device) - object.__setattr__(rigid_object, "_data", data) - - # Mock wrench composers - mock_inst_wrench = MockWrenchComposer(rigid_object) - mock_perm_wrench = MockWrenchComposer(rigid_object) - object.__setattr__(rigid_object, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(rigid_object, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(rigid_object, "_initialize_handle", None) - object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) - object.__setattr__(rigid_object, "_prim_deletion_handle", None) - object.__setattr__(rigid_object, "_debug_vis_handle", None) - - # Newton uses wp.array for indices - object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) - object.__setattr__(rigid_object, "_ALL_BODY_INDICES", wp.array(np.array([0], dtype=np.int32), device=device)) - - # Newton uses wp.bool masks - object.__setattr__(rigid_object, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) - object.__setattr__(rigid_object, "_ALL_BODY_MASK", wp.ones((1,), dtype=wp.bool, device=device)) - - return rigid_object, mock_view - - -def create_ovphysx_rigid_object( - num_instances: int = 2, - device: str = "cuda:0", -): - """Create a test OvPhysX RigidObject instance with mocked tensor bindings.""" - body_names = ["base_link"] - - obj = object.__new__(OvPhysxRigidObject) - - obj.cfg = RigidObjectCfg(prim_path="/World/object") - - # Create mock binding set - mock_bindings = MockOvPhysxBindingSet( - num_instances=num_instances, - num_joints=0, - num_bodies=1, - body_names=body_names, - asset_kind="rigid_object", - ) - mock_bindings.set_random_data() - - object.__setattr__(obj, "_device", device) - object.__setattr__(obj, "_ovphysx", MagicMock()) - object.__setattr__(obj, "_bindings", mock_bindings.bindings) - object.__setattr__(obj, "_num_instances", num_instances) - object.__setattr__(obj, "_num_bodies", 1) - object.__setattr__(obj, "_body_names", body_names) - - # Create RigidObjectData - data = OvPhysxRigidObjectData(mock_bindings.bindings, device) - data.num_instances = num_instances - data.num_bodies = 1 - data._is_primed = True - object.__setattr__(obj, "_data", data) - - # Build the buffers RigidObject normally allocates in _initialize_impl - # (_ALL_INDICES, _ALL_*_MASK, pinned CPU staging buffers, wrench buf). - # _create_buffers also instantiates real WrenchComposers; those get - # replaced with mocks just below. - obj._create_buffers() - - # Replace the real wrench composers with mocks for iface coverage. - mock_inst_wrench = MockWrenchComposer(obj) - mock_perm_wrench = MockWrenchComposer(obj) - object.__setattr__(obj, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(obj, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising - object.__setattr__(obj, "_initialize_handle", None) - object.__setattr__(obj, "_invalidate_initialize_handle", None) - object.__setattr__(obj, "_prim_deletion_handle", None) - object.__setattr__(obj, "_debug_vis_handle", None) - - return obj, mock_bindings - - -def create_mock_rigid_object( - num_instances: int = 2, - device: str = "cuda:0", -): - from isaaclab.test.mock_interfaces.assets.mock_rigid_object import MockRigidObject - - obj = MockRigidObject( - num_instances=num_instances, - device=device, - ) - return obj, None # No view for mock backend - - -def get_rigid_object( - backend: str, - num_instances: int = 2, - device: str = "cuda:0", -): - if backend == "physx": - return create_physx_rigid_object(num_instances, device) - elif backend == "ovphysx": - return create_ovphysx_rigid_object(num_instances, device) - elif backend == "newton": - return create_newton_rigid_object(num_instances, device) - elif backend.lower() == "mock": - return create_mock_rigid_object(num_instances, device) - else: - raise ValueError(f"Invalid backend: {backend}") +from _rigid_object_iface_test_utils import BACKENDS, get_rigid_object @pytest.fixture From 63c4bff093aa4649f82aa1f1c74281a074b3288d Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Tue, 30 Jun 2026 12:23:52 +0000 Subject: [PATCH 22/23] fix(deps): bump usd-core to >=26.5 for multithreaded collider crash fix OpenUSD 26.05 fixes a race in UsdPhysicsParsingUtility that could corrupt the heap (malloc_consolidate(): invalid chunk size / double free) when parsing a single rigid body with many mesh colliders beneath it, before the first simulation step (OpenUSD PR #4002 / commit 060715f). Bumps the kit-less usd-core pin from >=25.11,<26.0 to >=26.5,<27.0 and updates the metadata guard test (renamed from the USD 25 ABI assertion). --- ...verswyvelen-usd-2605-collider-crash-fix.major.rst | 10 ++++++++++ source/isaaclab/pyproject.toml | 2 +- .../test/cli/test_source_package_metadata.py | 12 +++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 source/isaaclab/changelog.d/pverswyvelen-usd-2605-collider-crash-fix.major.rst diff --git a/source/isaaclab/changelog.d/pverswyvelen-usd-2605-collider-crash-fix.major.rst b/source/isaaclab/changelog.d/pverswyvelen-usd-2605-collider-crash-fix.major.rst new file mode 100644 index 000000000000..041696a230a6 --- /dev/null +++ b/source/isaaclab/changelog.d/pverswyvelen-usd-2605-collider-crash-fix.major.rst @@ -0,0 +1,10 @@ +Changed +^^^^^^^ + +* **Breaking:** Bumped the kit-less ``usd-core`` pin from ``>=25.11,<26.0`` to + ``>=26.5,<27.0`` (OpenUSD 26.05 ABI). OpenUSD 26.05 includes the fix for a + multithreaded crash in ``UsdPhysicsParsingUtility`` that could corrupt the heap + when parsing a single rigid body with many mesh colliders beneath it + (OpenUSD PR #4002 / commit ``060715f``). Versions ``< 26.5`` race during USD + physics parsing and can abort with ``malloc_consolidate(): invalid chunk size`` + / ``double free`` before the first simulation step. diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index ab47be147f54..4077d164141a 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ "pin-pink==3.1.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", "daqp==0.8.5 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", # OpenUSD (kit-less mode) - "usd-core>=25.11,<26.0 ; platform_machine in 'x86_64 AMD64'", + "usd-core>=26.5,<27.0 ; platform_machine in 'x86_64 AMD64'", "usd-exchange>=2.2 ; platform_machine in 'aarch64 arm64'", # avoid broken hf-xet pre-release cached on NVIDIA Artifactory "hf-xet>=1.4.1,<2.0.0 ; platform_machine in 'x86_64 AMD64 aarch64 arm64'", diff --git a/source/isaaclab/test/cli/test_source_package_metadata.py b/source/isaaclab/test/cli/test_source_package_metadata.py index ceeec3e6d2be..11e62204c9a6 100644 --- a/source/isaaclab/test/cli/test_source_package_metadata.py +++ b/source/isaaclab/test/cli/test_source_package_metadata.py @@ -20,8 +20,14 @@ def _repo_root() -> Path: raise RuntimeError("Could not find Isaac Lab repository root.") -def test_isaaclab_usd_core_pin_stays_on_isaacsim_compatible_usd25_abi(): - """The kit-less USD package must stay on the Isaac Sim compatible USD 25 ABI.""" +def test_isaaclab_usd_core_pin_includes_multithreaded_collider_crash_fix(): + """The kit-less USD package must include the OpenUSD 26.05 fix for the multithreaded + UsdPhysicsParsingUtility crash (one rigid body with many mesh colliders). + + See OpenUSD PR #4002 / commit 060715f ("[usdPhysics] fix for a multithreaded crash if + one rigidbody has multiple colliders beneath"), first released in OpenUSD 26.05 + (usd-core 26.5). Versions < 26.5 race and can corrupt the heap during USD physics parsing. + """ with (_repo_root() / "source/isaaclab/pyproject.toml").open("rb") as f: pyproject = tomllib.load(f) @@ -29,7 +35,7 @@ def test_isaaclab_usd_core_pin_stays_on_isaacsim_compatible_usd25_abi(): dependency for dependency in pyproject["project"]["dependencies"] if dependency.startswith("usd-core") ] - assert usd_core_dependencies == ["usd-core>=25.11,<26.0 ; platform_machine in 'x86_64 AMD64'"] + assert usd_core_dependencies == ["usd-core>=26.5,<27.0 ; platform_machine in 'x86_64 AMD64'"] def test_isaaclab_standalone_usd_providers_are_platform_disjoint(): From e6040a2b3a5943fa064304826155ef74dfad0e9c Mon Sep 17 00:00:00 2001 From: Horde Date: Mon, 6 Jul 2026 23:14:44 +0000 Subject: [PATCH 23/23] fix(newton): rebuild collision pipeline on hard reset to fix CUDA 700 NewtonManager.reset(soft=False) re-finalizes the model and rebuilds the solver/state, but _initialize_contacts only constructs a new CollisionPipeline when cls._collision_pipeline is None -- and reset() never nulled it. The stale pipeline (and its _contacts), still bound to the freed old-model shape_* / broad-phase candidate_pair device buffers, was then reused against the freshly-finalized model. Its first eager narrow-phase launch (narrow_phase_kernel_gjk_mpr) reads freed/mismatched memory, producing CUDA error 700 (illegal memory access) outside any CUDA graph. Null _collision_pipeline and _contacts in reset() so initialize_solver() rebuilds them against the new model. Also invalidate any captured CUDA graph and defer re-capture to the first step() idle window, so a stale graph can never be replayed against freed buffers. Validated on the H2 tablecloth workload (env.sim.reset() poison sequence): CUDA 700 eliminated, all steps pass, exit 0. --- .../isaaclab_newton/physics/newton_manager.py | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 69fdc6fe1d07..0a953d434eeb 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -283,6 +283,10 @@ def provides_implicit_damping(cls) -> bool: # CUDA graphing _graph = None _graph_capture_pending: bool = False + # When True, the next _capture_or_defer_graph() (e.g. after a hard reset) + # defers capture to the first step() instead of capturing synchronously, + # so it runs on a fully-idle device against freshly-rebuilt buffers. + _defer_capture_next: bool = False # USD/Fabric sync _newton_stage_path = None @@ -375,9 +379,75 @@ def reset(cls, soft: bool = False) -> None: soft: If True, skip full reinitialization. """ if not soft: + # A full (hard) reset reallocates the Newton model/state/control, + # solver, collision pipeline and contact buffers (see + # ``start_simulation`` / ``initialize_solver``). Any CUDA graph we + # previously captured baked in the OLD device pointers of those + # buffers, so replaying it after this reset would dereference freed + # memory (CUDA error 700 in ``wp_cuda_graph_launch``). + # + # We CANNOT re-capture synchronously here: back-to-back capture of + # the contact+actuator pipeline immediately after a rebuild is + # unstable. Instead we: + # 1. Hard-invalidate the graph up front so ``step()`` falls back + # to eager execution and can never launch a stale graph. + # 2. Schedule a DEFERRED re-capture; ``step()`` re-captures against + # the freshly allocated buffers in a clean idle window. + had_graph = cls._graph is not None or cls._graph_capture_pending + NewtonManager._graph = None + NewtonManager._graph_capture_pending = False + # Force the capture inside initialize_solver() to defer to the first + # step() (idle device) instead of re-capturing synchronously on a + # model whose rebuild just freed the buffers the previous capture and + # its cached graph-exec/module state referenced. + NewtonManager._defer_capture_next = True + + # ROOT-CAUSE FIX: NewtonManager.reset() re-finalizes the model + rebuilds + # the solver/state, but ``_initialize_contacts`` only builds a NEW + # CollisionPipeline ``if cls._collision_pipeline is None`` -- and reset() + # historically never nulled it. So the STALE CollisionPipeline (and its + # ``_contacts``), still bound to the OLD (now-freed) model's shape_* / + # broad-phase candidate_pair device buffers, was reused against the + # freshly-finalized model. Its first (eager) narrow-phase launch + # (``narrow_phase_kernel_gjk_mpr``) then reads freed/mismatched memory -> + # CUDA 700, entirely outside any CUDA graph. Null them here so + # ``initialize_solver()`` rebuilds a fresh pipeline + contacts on the new + # model. + if cls._collision_pipeline is not None or cls._contacts is not None: + logger.debug( + "NewtonManager.reset() releasing stale CollisionPipeline + contacts " + "so they are rebuilt against the re-finalized model." + ) + NewtonManager._collision_pipeline = None + NewtonManager._contacts = None + cls.start_simulation() cls.initialize_solver() + cfg = PhysicsManager._cfg + device = PhysicsManager._device + wants_graph = ( + cfg is not None + and getattr(cfg, "use_cuda_graph", False) + and device is not None + and "cuda" in device + and cls._supports_cuda_graph_capture() + ) + if wants_graph: + # Drop stale Warp graph-exec / module state carried across the + # rebuild, then make sure all rebuild allocations/copies have + # completed before the deferred capture launches. + with contextlib.suppress(Exception): + wp.synchronize_device(device) + NewtonManager._graph_capture_pending = True + if had_graph: + logger.debug( + "NewtonManager.reset() invalidated an active CUDA graph; it will be " + "re-captured on the next step() against the freshly-allocated buffers." + ) + else: + NewtonManager._defer_capture_next = False + @classmethod def _eval_fk_impl(cls, world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: """Update body states from joint coordinates. @@ -1534,7 +1604,7 @@ def _capture_or_defer_graph(cls) -> None: if use_cuda_graph: with Timer(name="newton_cuda_graph", msg="CUDA graph took:"): - if cls._usdrt_stage is None: + if cls._usdrt_stage is None and not cls._defer_capture_next: simulate = cls._simulate_full if cls._is_all_graphable() else cls._simulate_physics_only with wp.ScopedCapture() as capture: simulate() @@ -1547,6 +1617,15 @@ def _capture_or_defer_graph(cls) -> None: # memory-pool addresses before any eager solver.reset() call. if isinstance(cls._solver, SolverKamino): wp.capture_launch(cls._graph) + elif cls._defer_capture_next: + # Reset path: defer capture to the first step() so it happens on a + # fully-idle device against the freshly-rebuilt buffers (avoids the + # second-capture fault seen when re-capturing synchronously right + # after a model/solver/contact rebuild). + NewtonManager._graph = None + NewtonManager._graph_capture_pending = True + NewtonManager._defer_capture_next = False + logger.info("Newton CUDA graph capture deferred (post-reset, idle-window)") else: # RTX is active during initialization — cudaImportExternalMemory and other # non-capturable RTX ops run on background CUDA streams right now.