From a2b4f4bc04258d4f8a4a4385be09e4fa4fbcb5b9 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 20 May 2026 07:58:23 +0200 Subject: [PATCH 01/15] Add OVPhysX IMU sensor Mirror the PhysX backend (source/isaaclab_physx/isaaclab_physx/sensors/imu/) with three substantive divergences: - Use OvPhysxManager.get_physx_instance() + create_tensor_binding(...) with RIGID_BODY_POSE / RIGID_BODY_VELOCITY / RIGID_BODY_COM_POSE instead of physics_sim_view.create_rigid_body_view(...). - Read scene gravity from PhysicsManager._sim.cfg.gravity (3-tuple); the OvPhysx manager has no get_gravity(). - Pre-allocate structured-dtype buffers (wp.transformf, wp.spatial_vectorf) and use ovphysx's destination-as-argument binding.read(dst) API. Warp kernels (imu_update_kernel, imu_reset_kernel) are mathematically identical to the PhysX ones; only the data source comment differs. Add Imu / ImuData to the sensors package __init__.pyi. Refs #5318. --- .../isaaclab_ovphysx/sensors/__init__.pyi | 3 + .../isaaclab_ovphysx/sensors/imu/imu.py | 269 ++++++++++++++++++ .../isaaclab_ovphysx/sensors/imu/imu_data.py | 53 ++++ .../isaaclab_ovphysx/sensors/imu/kernels.py | 67 +++++ 4 files changed, 392 insertions(+) create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu_data.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.pyi index e00fc97cbbc6..7d41b088a908 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.pyi +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.pyi @@ -7,6 +7,9 @@ __all__ = [ "ContactSensor", "ContactSensorCfg", "ContactSensorData", + "Imu", + "ImuData", ] from .contact_sensor import ContactSensor, ContactSensorCfg, ContactSensorData +from .imu import Imu, ImuData diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py new file mode 100644 index 000000000000..8b79fa844c93 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py @@ -0,0 +1,269 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch +import warp as wp + +from pxr import UsdPhysics + +import isaaclab.sim as sim_utils +import isaaclab.utils.math as math_utils +from isaaclab.physics import PhysicsManager +from isaaclab.sensors.imu import BaseImu + +import isaaclab_ovphysx.tensor_types as TT +from isaaclab_ovphysx.physics import OvPhysxManager as SimulationManager + +from .imu_data import ImuData +from .kernels import imu_reset_kernel, imu_update_kernel + +if TYPE_CHECKING: + from isaaclab.sensors.imu import ImuCfg + + +class Imu(BaseImu): + """The OVPhysX Inertial Measurement Unit (IMU) sensor. + + This sensor models a real IMU that measures angular velocity (gyroscope) and + linear acceleration (accelerometer) in the sensor's body frame. Unlike the PVA + sensor, it does not provide pose, linear velocity, angular acceleration, or + projected gravity. + + Like a real accelerometer, the linear acceleration readings always include the + contribution of gravity. The gravity vector is read from the simulation + configuration at initialization. + + The sensor can be attached to any prim path with a rigid ancestor in its tree. + If the provided path is not a rigid body, the closest rigid-body ancestor is + used for simulation queries. The fixed transform from that ancestor to the + target prim is computed once during initialization and composed with the + configured sensor offset. + + .. note:: + + Linear acceleration is computed using numerical differentiation from + velocities. Consequently, the IMU sensor accuracy depends on the chosen + physics timestep. For sufficient accuracy, we recommend keeping the + timestep at least 200 Hz. + """ + + cfg: ImuCfg + """The configuration parameters.""" + + __backend_name__: str = "ovphysx" + """The name of the backend for the IMU sensor.""" + + def __init__(self, cfg: ImuCfg): + """Initializes the IMU sensor. + + Args: + cfg: The configuration parameters. + """ + super().__init__(cfg) + self._data = ImuData() + self._rigid_parent_expr: str | None = None + + def __str__(self) -> str: + """Returns: A string containing information about the instance.""" + return ( + f"Imu sensor @ '{self.cfg.prim_path}': \n" + f"\tbackend : ovphysx\n" + f"\tupdate period (s) : {self.cfg.update_period}\n" + f"\tnumber of sensors : {self._num_bodies}\n" + ) + + """ + Properties + """ + + @property + def data(self) -> ImuData: + self._update_outdated_buffers() + return self._data + + @property + def num_instances(self) -> int: + return self._num_bodies + + """ + Operations + """ + + def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None): + env_mask = self._resolve_indices_and_mask(env_ids, env_mask) + super().reset(None, env_mask) + + wp.launch( + imu_reset_kernel, + dim=self._num_envs, + inputs=[ + env_mask, + self._data._ang_vel_b, + self._data._lin_acc_b, + self._prev_lin_vel_w, + ], + device=self._device, + ) + + def update(self, dt: float, force_recompute: bool = False): + self._dt = dt + super().update(dt, force_recompute) + + """ + Implementation. + """ + + def _initialize_impl(self): + """Initializes the sensor handles and internal buffers. + + - If the target prim path is a rigid body, bind directly to it. + - Otherwise find the closest rigid-body ancestor, cache the fixed transform + from that ancestor to the target prim, and bind to the ancestor pattern. + """ + super()._initialize_impl() + + physx_instance = SimulationManager.get_physx_instance() + if physx_instance is None: + raise RuntimeError( + "OVPhysX Imu: physics instance is not initialized. " + "Ensure SimulationContext.reset() has been called before sensor initialization." + ) + + prim = sim_utils.find_first_matching_prim(self.cfg.prim_path) + if prim is None: + raise RuntimeError(f"Failed to find a prim at path expression: {self.cfg.prim_path}") + + ancestor_prim = sim_utils.get_first_matching_ancestor_prim( + prim.GetPath(), predicate=lambda _prim: _prim.HasAPI(UsdPhysics.RigidBodyAPI) + ) + if ancestor_prim is None: + raise RuntimeError(f"Failed to find a rigid body ancestor prim at path expression: {self.cfg.prim_path}") + + if ancestor_prim == prim: + self._rigid_parent_expr = self.cfg.prim_path + fixed_pos_b, fixed_quat_b = None, None + else: + relative_path = prim.GetPath().MakeRelativePath(ancestor_prim.GetPath()).pathString + self._rigid_parent_expr = self.cfg.prim_path.replace("/" + relative_path, "") + fixed_pos_b, fixed_quat_b = sim_utils.resolve_prim_pose(prim, ancestor_prim) + + # Translate the user-facing path expression (".*" / "{ENV_REGEX_NS}") to an ovphysx glob. + pattern = self._rigid_parent_expr.replace(".*", "*") + + # Open the three pattern-bound tensor bindings on the rigid-body ancestor. + self._pose_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_POSE) + self._vel_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_VELOCITY) + self._com_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_COM_POSE) + self._num_bodies = self._pose_binding.count + + if self._num_bodies != self._num_envs: + raise RuntimeError( + f"OVPhysX Imu: pattern '{pattern}' matched {self._num_bodies} rigid bodies but the scene has " + f"{self._num_envs} environments. The IMU expects exactly one body per environment." + ) + + # Read scene gravity from the simulation cfg (OvPhysxManager has no get_gravity()). + gravity = PhysicsManager._sim.cfg.gravity + gravity_bias = torch.tensor((-gravity[0], -gravity[1], -gravity[2]), device=self._device) + gravity_bias_torch = gravity_bias.repeat(self._num_bodies, 1) + self._gravity_bias_w = wp.from_torch(gravity_bias_torch.contiguous(), dtype=wp.vec3f) + + self._initialize_buffers_impl() + + # Compose the configured offset with the fixed ancestor->target transform (done once). + if fixed_pos_b is not None and fixed_quat_b is not None: + fixed_p = torch.tensor(fixed_pos_b, device=self._device).repeat(self._num_bodies, 1) + fixed_q = torch.tensor(fixed_quat_b, device=self._device).repeat(self._num_bodies, 1) + + cfg_p = wp.to_torch(self._offset_pos_b).clone() + cfg_q = wp.to_torch(self._offset_quat_b).clone() + + composed_p = fixed_p + math_utils.quat_apply(fixed_q, cfg_p) + composed_q = math_utils.quat_mul(fixed_q, cfg_q) + + self._offset_pos_b = wp.from_torch(composed_p.contiguous(), dtype=wp.vec3f) + self._offset_quat_b = wp.from_torch(composed_q.contiguous(), dtype=wp.quatf) + + def _update_buffers_impl(self, env_mask: wp.array | None = None): + """Fills the buffers of the sensor data.""" + env_mask = self._resolve_indices_and_mask(None, env_mask) + + # ovphysx binding.read(dst) writes into a pre-allocated dst buffer. + # For structured-dtype targets (transformf, spatial_vectorf) we pass a + # zero-copy flat float32 view of the same memory; the kernel then reads + # the structured buffer directly. + self._pose_binding.read(self._transforms_view) + self._vel_binding.read(self._velocities_view) + + # COM is CPU-resident in ovphysx; read into the CPU scratch, then copy into the GPU buffer. + self._com_binding.read(self._coms_cpu_view) + wp.copy(self._coms_gpu_view, self._coms_cpu_view) + + wp.launch( + imu_update_kernel, + dim=self._num_envs, + inputs=[ + env_mask, + self._transforms_buf, + self._velocities_buf, + self._coms_buffer, + self._offset_pos_b, + self._offset_quat_b, + self._gravity_bias_w, + self._prev_lin_vel_w, + 1.0 / self._dt, + self._data._ang_vel_b, + self._data._lin_acc_b, + ], + device=self._device, + ) + + def _initialize_buffers_impl(self): + """Create buffers for storing data.""" + self._data.create_buffers(num_envs=self._num_bodies, device=self._device) + + self._prev_lin_vel_w = wp.zeros(self._num_bodies, dtype=wp.vec3f, device=self._device) + + offset_pos_torch = torch.tensor(list(self.cfg.offset.pos), device=self._device).repeat(self._num_bodies, 1) + offset_quat_torch = torch.tensor(list(self.cfg.offset.rot), device=self._device).repeat(self._num_bodies, 1) + self._offset_pos_b = wp.from_torch(offset_pos_torch.contiguous(), dtype=wp.vec3f) + self._offset_quat_b = wp.from_torch(offset_quat_torch.contiguous(), dtype=wp.quatf) + + # Persistent destination buffers for ovphysx tensor reads. + # The kernel consumes the structured-dtype buffers; the *_view aliases are + # flat float32 reinterpretations of the same memory used as ovphysx + # binding.read() destinations. + self._transforms_buf = wp.zeros(self._num_bodies, dtype=wp.transformf, device=self._device) + self._velocities_buf = wp.zeros(self._num_bodies, dtype=wp.spatial_vectorf, device=self._device) + self._coms_buffer = wp.zeros(self._num_bodies, dtype=wp.transformf, device=self._device) + + self._transforms_view = wp.array( + ptr=self._transforms_buf.ptr, + shape=self._pose_binding.shape, + dtype=wp.float32, + device=self._device, + copy=False, + ) + self._velocities_view = wp.array( + ptr=self._velocities_buf.ptr, + shape=self._vel_binding.shape, + dtype=wp.float32, + device=self._device, + copy=False, + ) + self._coms_gpu_view = wp.array( + ptr=self._coms_buffer.ptr, + shape=self._com_binding.shape, + dtype=wp.float32, + device=self._device, + copy=False, + ) + # COM binding lives on CPU, so its read destination must also be CPU. + self._coms_cpu_view = wp.zeros(self._com_binding.shape, dtype=wp.float32, device="cpu") diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu_data.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu_data.py new file mode 100644 index 000000000000..131e7bca9c45 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu_data.py @@ -0,0 +1,53 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import warp as wp + +from isaaclab.sensors.imu import BaseImuData +from isaaclab.utils.warp import ProxyArray + + +class ImuData(BaseImuData): + """Data container for the OVPhysX IMU sensor.""" + + @property + def ang_vel_b(self) -> ProxyArray: + """IMU frame angular velocity relative to the world expressed in IMU frame [rad/s]. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + """ + if self._ang_vel_b_ta is None: + self._ang_vel_b_ta = ProxyArray(self._ang_vel_b) + return self._ang_vel_b_ta + + @property + def lin_acc_b(self) -> ProxyArray: + """Linear acceleration (proper) in the IMU frame [m/s^2]. + + Zero in freefall, +g upward at rest. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + """ + if self._lin_acc_b_ta is None: + self._lin_acc_b_ta = ProxyArray(self._lin_acc_b) + return self._lin_acc_b_ta + + def create_buffers(self, num_envs: int, device: str) -> None: + """Create internal buffers for sensor data. + + Args: + num_envs: Number of environments. + device: Device for tensor storage. + """ + self._num_envs = num_envs + self._device = device + self._ang_vel_b = wp.zeros(num_envs, dtype=wp.vec3f, device=device) + self._lin_acc_b = wp.zeros(num_envs, dtype=wp.vec3f, device=device) + + # -- Pinned ProxyArray cache (one per read property, lazily created on first access) + self._ang_vel_b_ta: ProxyArray | None = None + self._lin_acc_b_ta: ProxyArray | None = None diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py new file mode 100644 index 000000000000..36cb1a8c56c5 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.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 + +import warp as wp + + +@wp.kernel +def imu_update_kernel( + # indexing + env_mask: wp.array(dtype=wp.bool), + # OVPhysX binding data + transforms: wp.array(dtype=wp.transformf), + velocities: wp.array(dtype=wp.spatial_vectorf), + coms: wp.array(dtype=wp.transformf), + # sensor config (per-env) + offset_pos_b: wp.array(dtype=wp.vec3f), + offset_quat_b: wp.array(dtype=wp.quatf), + gravity_bias_w: wp.array(dtype=wp.vec3f), + # previous velocities (read + write) + prev_lin_vel_w: wp.array(dtype=wp.vec3f), + # scalar + inv_dt: wp.float32, + # outputs (written in-place) + out_ang_vel_b: wp.array(dtype=wp.vec3f), + out_lin_acc_b: wp.array(dtype=wp.vec3f), +): + idx = wp.tid() + if not env_mask[idx]: + return + + body_quat = wp.transform_get_rotation(transforms[idx]) + sensor_quat = body_quat * offset_quat_b[idx] + + lin_vel_w = wp.spatial_top(velocities[idx]) + ang_vel_w = wp.spatial_bottom(velocities[idx]) + + com_pos_b = wp.transform_get_translation(coms[idx]) + lever_arm = wp.quat_rotate(body_quat, offset_pos_b[idx] - com_pos_b) + lin_vel_w = lin_vel_w + wp.cross(ang_vel_w, lever_arm) + + lin_acc_w = (lin_vel_w - prev_lin_vel_w[idx]) * inv_dt + gravity_bias_w[idx] + + ang_vel_b = wp.quat_rotate_inv(sensor_quat, ang_vel_w) + lin_acc_b = wp.quat_rotate_inv(sensor_quat, lin_acc_w) + + out_ang_vel_b[idx] = ang_vel_b + out_lin_acc_b[idx] = lin_acc_b + + prev_lin_vel_w[idx] = lin_vel_w + + +@wp.kernel +def imu_reset_kernel( + env_mask: wp.array(dtype=wp.bool), + out_ang_vel_b: wp.array(dtype=wp.vec3f), + out_lin_acc_b: wp.array(dtype=wp.vec3f), + prev_lin_vel_w: wp.array(dtype=wp.vec3f), +): + idx = wp.tid() + if not env_mask[idx]: + return + + out_ang_vel_b[idx] = wp.vec3f(0.0, 0.0, 0.0) + out_lin_acc_b[idx] = wp.vec3f(0.0, 0.0, 0.0) + prev_lin_vel_w[idx] = wp.vec3f(0.0, 0.0, 0.0) From d4296000f8a8c981ec61d8f14c6e6af93a150510 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 20 May 2026 07:58:23 +0200 Subject: [PATCH 02/15] Add kitless OVPhysX IMU pytest suite + check script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the structure of source/isaaclab_physx/test/sensors/test_imu.py under the kitless launcher pattern established by the rigid-object test: - Wheel gate (importorskip ovphysx.types + RIGID_BODY_POSE hasattr check) before AppLauncher; no AppLauncher needed at runtime. - SimulationContext built directly via build_simulation_context. - Imperative scene-builder helpers (_spawn_envs / _spawn_balls / _spawn_cubes / _spawn_anymal); RigidObject and Articulation do the per-env spawn themselves once /World/env_ Xform containers exist (one Xform layer between /World and the wildcard — ovphysx's pattern-matching does not propagate wildcards through deeper nesting). - Six tests run on real Nucleus / procedural USD assets: test_constant_velocity, test_constant_acceleration, test_offset_calculation, test_env_ids_propagation, test_attachment_validity, test_sensor_print. - Two URDF-dependent tests (test_single_dof_pendulum, test_indirect_attachment) are skipped pending a USD version of simple_2_link.urdf — URDF→USD conversion requires the Kit URDF importer extension. imu.update(dt) is called with force_recompute=True so the kernel fires each step (without InteractiveScene's lazy_sensor_update plumbing, the buffer would otherwise only refresh on imu.data access, leaving prev_lin_vel unseeded on iterations where the test skips data reads). check_imu.py is a kitless smoke-test script (free-falling sphere + IMU, prints readings for visual inspection). Changelog fragment added under source/isaaclab_ovphysx/changelog.d/ per the new fragment-based workflow (do not edit CHANGELOG.rst directly). Refs #5318. --- .../antoiner-feat-ovphysx_imu.minor.rst | 12 + .../test/sensors/check_imu.py | 79 ++++ .../isaaclab_ovphysx/test/sensors/test_imu.py | 416 ++++++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu.minor.rst create mode 100644 source/isaaclab_ovphysx/test/sensors/check_imu.py create mode 100644 source/isaaclab_ovphysx/test/sensors/test_imu.py diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu.minor.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu.minor.rst new file mode 100644 index 000000000000..6cdd6d3577d0 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu.minor.rst @@ -0,0 +1,12 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.sensors.Imu` and + :class:`~isaaclab_ovphysx.sensors.ImuData` implementing the + :class:`~isaaclab.sensors.imu.BaseImu` / + :class:`~isaaclab.sensors.imu.BaseImuData` contracts on the OVPhysX + backend. Reports angular velocity and proper linear acceleration in + the sensor body frame using ovphysx tensor bindings on the rigid-body + ancestor of the sensor prim path. Linear acceleration is computed via + numerical differentiation of body velocity (matching the PhysX + backend) and includes a positive gravity bias by default. diff --git a/source/isaaclab_ovphysx/test/sensors/check_imu.py b/source/isaaclab_ovphysx/test/sensors/check_imu.py new file mode 100644 index 000000000000..992211e2b6f7 --- /dev/null +++ b/source/isaaclab_ovphysx/test/sensors/check_imu.py @@ -0,0 +1,79 @@ +# 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 + +"""Standalone validation script for the OVPhysX IMU sensor. + +Run with: + ./scripts/run_ovphysx.sh source/isaaclab_ovphysx/test/sensors/check_imu.py + +Spawns a free-falling sphere with an IMU attached, steps the simulation for +100 steps, and prints the angular velocity and linear acceleration in the +IMU body frame. In freefall the proper acceleration is zero (only gravity +acts on the body), so the IMU reading should be approximately ``[0, 0, 0]`` +— the finite-difference world-frame acceleration ``(-g)`` cancels the IMU's +gravity bias ``(+g)``. + +Runs kitless under ``run_ovphysx.sh`` — no AppLauncher needed. +""" + +from __future__ import annotations + +import torch +from isaaclab_ovphysx.physics import OvPhysxCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import RigidObject, RigidObjectCfg +from isaaclab.sensors.imu import Imu, ImuCfg +from isaaclab.sim import SimulationCfg, build_simulation_context + + +def main() -> None: + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), device="cpu", dt=0.005) + with build_simulation_context(sim_cfg=sim_cfg) as sim: + # Spawn one ball per env at /World/env_/ball. + spawn_cfg = sim_utils.SphereCfg( + radius=0.25, + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=0.5), + collision_props=sim_utils.CollisionPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 1.0)), + ) + # /World/env_ Xforms are siblings under /World — no envs container needed + num_envs = 2 + for i in range(num_envs): + sim_utils.create_prim(f"/World/env_{i}", "Xform", translation=(i * 5.0, 0.0, 0.0)) + spawn_cfg.func(f"/World/env_{i}/ball", spawn_cfg, translation=(0.0, 0.0, 1.0)) + + balls = RigidObject( + RigidObjectCfg( + prim_path="/World/env_*/ball", + spawn=None, + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), + ) + ) + imu = Imu(ImuCfg(prim_path="/World/env_*/ball")) + sim.reset() + + dt = sim.get_physics_dt() + for step in range(100): + balls.write_data_to_sim() + sim.step() + balls.update(dt) + imu.update(dt, force_recompute=True) + + if step in (0, 10, 50, 99): + ang = imu.data.ang_vel_b.torch + lin = imu.data.lin_acc_b.torch + print(f"step={step:3d} ang_vel_b={ang[0].cpu().numpy()} lin_acc_b={lin[0].cpu().numpy()}") + + # Final sanity check: in freefall the proper acceleration is zero + # (gravity cancels the bias). The norm should be near 0. + final_lin = imu.data.lin_acc_b.torch + magnitude = torch.linalg.norm(final_lin, dim=1).cpu().numpy() + print(f"final |lin_acc_b| per env = {magnitude}") + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab_ovphysx/test/sensors/test_imu.py b/source/isaaclab_ovphysx/test/sensors/test_imu.py new file mode 100644 index 000000000000..760c324bdb9e --- /dev/null +++ b/source/isaaclab_ovphysx/test/sensors/test_imu.py @@ -0,0 +1,416 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# pyright: reportPrivateUsage=none + +"""Real-backend tests for the OVPhysX IMU sensor. + +Mirrors the structure of source/isaaclab_physx/test/sensors/test_imu.py +but runs kitless under ./scripts/run_ovphysx.sh — no AppLauncher needed. +SimulationContext is instantiated directly (it does not require Kit), and +UsdFileCfg(usd_path=ISAAC_NUCLEUS_DIR/...) downloads Nucleus assets via +omni.client (which works standalone in Kit's Python). + +Tests that depend on URDF→USD conversion are skipped pending a USD +version of source/isaaclab_ovphysx/test/sensors/urdfs/simple_2_link.urdf. +The URDF importer is a Kit extension and is not loaded under the +kitless launcher. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Wheel gate: skip the whole file if the ovphysx wheel is missing or too old. +# --------------------------------------------------------------------------- +import pytest + +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") +_TT_module = pytest.importorskip( + "isaaclab_ovphysx.tensor_types", + reason="isaaclab_ovphysx.tensor_types not importable", +) +if not hasattr(_TT_module, "RIGID_BODY_POSE"): + pytest.skip( + "ovphysx wheel does not yet expose RIGID_BODY_POSE / RIGID_BODY_VELOCITY", + allow_module_level=True, + ) + +# --------------------------------------------------------------------------- +# Imports (after wheel gate) +# --------------------------------------------------------------------------- +import torch # noqa: E402 +import warp as wp # noqa: E402 +from isaaclab_ovphysx.physics import OvPhysxCfg # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 +import isaaclab.utils.math as math_utils # noqa: E402 +from isaaclab.assets import Articulation, RigidObject, RigidObjectCfg # noqa: E402 +from isaaclab.sensors.imu import Imu, ImuCfg # noqa: E402 +from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 + +from isaaclab_assets.robots.anymal import ANYMAL_C_CFG # noqa: E402 + +wp.init() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +NUM_ENVS = 2 +"""Number of environment instances spawned in each test scene.""" + +# offset of imu_link from base_link on anymal_c +POS_OFFSET = (0.2488, 0.00835, 0.04628) +ROT_OFFSET = (0, 0, 0.7071068, 0.7071068) + + +# --------------------------------------------------------------------------- +# Scene-builder helpers (real backend, Nucleus / procedural USD assets) +# --------------------------------------------------------------------------- + + +def _spawn_envs(num_envs: int) -> None: + """Create per-env Xform containers at ``/World/env_``. + + These match the prim-path layout the IMU's attachment-validity test + expects, and provide a parent for per-env asset spawns. + """ + # /World/env_ Xforms are siblings under /World — no envs container needed + for i in range(num_envs): + sim_utils.create_prim(f"/World/env_{i}", "Xform", translation=(i * 5.0, 0.0, 0.0)) + + +def _spawn_balls(num_envs: int, height: float = 0.5) -> RigidObject: + """Spawn a sphere rigid body at ``/World/env_/ball`` for each env. + + Returns the :class:`RigidObject` whose binding pattern matches all spawned + instances. The :class:`RigidObject` does the per-env spawning itself when + ``spawn`` is set; we only have to create the env Xform containers first + (handled by :func:`_spawn_envs`). Note the ovphysx pattern uses an + fnmatch glob (``env_*``), not a regex. + """ + spawn_cfg = sim_utils.SphereCfg( + radius=0.25, + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=0.5), + collision_props=sim_utils.CollisionPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 1.0)), + ) + cfg = RigidObjectCfg( + prim_path="/World/env_*/ball", + spawn=spawn_cfg, + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, height)), + ) + return RigidObject(cfg) + + +def _spawn_cubes(num_envs: int, height: float = 0.5) -> RigidObject: + """Spawn a cube rigid body at ``/World/env_/cube`` for each env.""" + spawn_cfg = sim_utils.CuboidCfg( + size=(0.25, 0.25, 0.25), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=0.5), + collision_props=sim_utils.CollisionPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 1.0)), + ) + cfg = RigidObjectCfg( + prim_path="/World/env_*/cube", + spawn=spawn_cfg, + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, -2.0, height)), + ) + return RigidObject(cfg) + + +def _spawn_anymal(num_envs: int) -> Articulation: + """Spawn the Anymal-C articulation at ``/World/env_/robot`` for each env. + + Uses :data:`~isaaclab_assets.robots.anymal.ANYMAL_C_CFG` directly so the + actuator and init-state configuration matches the PhysX reference test. + The :class:`Articulation` performs the per-env spawn itself once the env + Xform containers exist; :func:`_spawn_envs` must be called first. + """ + cfg = ANYMAL_C_CFG.replace(prim_path="/World/env_.*/robot") + cfg.init_state.pos = (0.0, 2.0, 1.0) + # bump solver iteration counts to match the PhysX test's scene cfg + cfg.spawn.articulation_props.solver_position_iteration_count = 32 + cfg.spawn.articulation_props.solver_velocity_iteration_count = 32 + return Articulation(cfg) + + +def _make_imu(prim_path: str, offset: ImuCfg.OffsetCfg | None = None) -> Imu: + """Create an :class:`Imu` with the given prim path and optional offset.""" + cfg = ImuCfg(prim_path=prim_path) + if offset is not None: + cfg.offset = offset + return Imu(cfg) + + +# --------------------------------------------------------------------------- +# Sim context fixture (real OVPhysX backend, CPU) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sim_ctx_cpu(): + """Build an OVPhysX-backed :class:`SimulationContext` on CPU. + + Yields: + The simulation context, set up with a small fixed timestep matching + the PhysX reference test (``dt=0.001``) for IMU numerical-differentiation + accuracy. + """ + with build_simulation_context( + sim_cfg=SimulationCfg(physics=OvPhysxCfg(), device="cpu", dt=0.001), + ) as sim: + yield sim + + +# =========================================================================== +# Constant-velocity / constant-acceleration tests (rigid bodies) +# =========================================================================== + + +def test_constant_velocity(sim_ctx_cpu): + """Test the IMU sensor with a constant velocity. + + Expected behavior is that the linear acceleration is approximately the + same at every time step: in each step we set the same velocity, so the + finite-difference derivative settles to zero (plus the gravity bias). + """ + _spawn_envs(NUM_ENVS) + balls = _spawn_balls(NUM_ENVS) + cubes = _spawn_cubes(NUM_ENVS) + imu_ball = _make_imu("/World/env_*/ball") + imu_cube = _make_imu("/World/env_*/cube") + sim_ctx_cpu.reset() + + device = sim_ctx_cpu.device + prev_lin_acc_ball = torch.zeros((NUM_ENVS, 3), dtype=torch.float32, device=device) + prev_lin_acc_cube = torch.zeros((NUM_ENVS, 3), dtype=torch.float32, device=device) + + for idx in range(200): + # set velocity + velocity = torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat( + NUM_ENVS, 1 + ) + balls.write_root_velocity_to_sim(velocity) + cubes.write_root_velocity_to_sim(velocity) + # write data to sim + balls.write_data_to_sim() + cubes.write_data_to_sim() + # perform step + sim_ctx_cpu.step() + # read data from sim + dt = sim_ctx_cpu.get_physics_dt() + balls.update(dt) + cubes.update(dt) + imu_ball.update(dt, force_recompute=True) + imu_cube.update(dt, force_recompute=True) + + if idx > 1: + # check the imu accelerations + torch.testing.assert_close( + imu_ball.data.lin_acc_b.torch, + prev_lin_acc_ball, + rtol=1e-3, + atol=1e-3, + ) + torch.testing.assert_close( + imu_cube.data.lin_acc_b.torch, + prev_lin_acc_cube, + rtol=1e-3, + atol=1e-3, + ) + + # update previous values + prev_lin_acc_ball = imu_ball.data.lin_acc_b.torch.clone() + prev_lin_acc_cube = imu_cube.data.lin_acc_b.torch.clone() + + +def test_constant_acceleration(sim_ctx_cpu): + """Test the IMU sensor with a constant acceleration.""" + _spawn_envs(NUM_ENVS) + balls = _spawn_balls(NUM_ENVS) + imu_ball = _make_imu("/World/env_*/ball") + sim_ctx_cpu.reset() + + device = sim_ctx_cpu.device + dt = sim_ctx_cpu.get_physics_dt() + + for idx in range(100): + # set acceleration via increasing velocity per step + velocity = torch.tensor([[0.1, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat( + NUM_ENVS, 1 + ) * (idx + 1) + balls.write_root_velocity_to_sim(velocity) + balls.write_data_to_sim() + sim_ctx_cpu.step() + balls.update(dt) + imu_ball.update(dt, force_recompute=True) + + # skip first step where initial velocity is zero + if idx < 1: + continue + + # check the imu linear acceleration data (includes gravity) + torch.testing.assert_close( + imu_ball.data.lin_acc_b.torch, + math_utils.quat_apply_inverse( + balls.data.root_quat_w.torch, + torch.tensor([[0.1, 0.0, 0.0]], dtype=torch.float32, device=device).repeat(NUM_ENVS, 1) / dt + + torch.tensor([[0.0, 0.0, 9.81]], dtype=torch.float32, device=device).repeat(NUM_ENVS, 1), + ), + rtol=1e-4, + atol=1e-4, + ) + + # check the angular velocity + torch.testing.assert_close( + imu_ball.data.ang_vel_b.torch, + balls.data.root_ang_vel_b.torch, + rtol=1e-4, + atol=1e-4, + ) + + +# =========================================================================== +# Articulation tests (anymal-C, USD asset from Nucleus) +# =========================================================================== + + +def test_offset_calculation(sim_ctx_cpu): + """Test offset configuration argument. + + Two IMUs on the anymal-C robot — one at ``base`` with a configured offset + matching the location of ``imu_link``, and one directly at ``imu_link`` + — should produce identical readings. + """ + _spawn_envs(NUM_ENVS) + robot = _spawn_anymal(NUM_ENVS) + imu_robot_imu_link = _make_imu("/World/env_*/robot/base/imu_link") + imu_robot_base = _make_imu( + "/World/env_*/robot/base", + offset=ImuCfg.OffsetCfg(pos=POS_OFFSET, rot=ROT_OFFSET), + ) + sim_ctx_cpu.reset() + + device = sim_ctx_cpu.device + dt = sim_ctx_cpu.get_physics_dt() + + for idx in range(500): + # apply increasing root velocity + velocity = torch.tensor([[0.05, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat( + NUM_ENVS, 1 + ) * (idx + 1) + robot.write_root_velocity_to_sim(velocity) + robot.write_data_to_sim() + sim_ctx_cpu.step() + robot.update(dt) + imu_robot_imu_link.update(dt, force_recompute=True) + imu_robot_base.update(dt, force_recompute=True) + + # skip first step where initial velocity is zero + if idx < 1: + continue + + torch.testing.assert_close( + imu_robot_base.data.lin_acc_b.torch, + imu_robot_imu_link.data.lin_acc_b.torch, + rtol=1e-4, + atol=1e-4, + ) + torch.testing.assert_close( + imu_robot_base.data.ang_vel_b.torch, + imu_robot_imu_link.data.ang_vel_b.torch, + rtol=1e-4, + atol=1e-4, + ) + + +def test_env_ids_propagation(sim_ctx_cpu): + """Test that ``env_ids`` argument propagates through update and reset methods.""" + _spawn_envs(NUM_ENVS) + robot = _spawn_anymal(NUM_ENVS) + imu_robot_imu_link = _make_imu("/World/env_*/robot/base/imu_link") + sim_ctx_cpu.reset() + + device = sim_ctx_cpu.device + dt = sim_ctx_cpu.get_physics_dt() + + for idx in range(10): + velocity = torch.tensor([[0.5, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat( + NUM_ENVS, 1 + ) * (idx + 1) + robot.write_root_velocity_to_sim(velocity) + robot.write_data_to_sim() + sim_ctx_cpu.step() + robot.update(dt) + imu_robot_imu_link.update(dt, force_recompute=True) + + # reset only env 1 + imu_robot_imu_link.reset(env_ids=[1]) + imu_robot_imu_link.update(dt, force_recompute=True) + sim_ctx_cpu.step() + imu_robot_imu_link.update(dt, force_recompute=True) + + +# =========================================================================== +# Validation tests (no asset state required) +# =========================================================================== + + +def test_attachment_validity(sim_ctx_cpu): + """Test invalid IMU attachment. + + An IMU cannot be attached directly to the world Xform — it must have a + rigid-body ancestor in its prim tree. + """ + _spawn_envs(NUM_ENVS) + sim_ctx_cpu.reset() + + imu_world_cfg = ImuCfg(prim_path="/World/env_0") + with pytest.raises(RuntimeError) as exc_info: + imu_world = Imu(imu_world_cfg) + imu_world._initialize_impl() + assert exc_info.type is RuntimeError and "find a rigid body ancestor prim" in str(exc_info.value) + + +def test_sensor_print(sim_ctx_cpu): + """Test that ``__str__`` is implemented and runs without error.""" + _spawn_envs(NUM_ENVS) + _spawn_balls(NUM_ENVS) + imu_ball = _make_imu("/World/env_*/ball") + sim_ctx_cpu.reset() + + print(imu_ball) + + +# =========================================================================== +# URDF-dependent tests — skipped pending USD pendulum asset +# =========================================================================== + + +@pytest.mark.skip( + reason=( + "Blocked on USD version of source/isaaclab_ovphysx/test/sensors/urdfs/simple_2_link.urdf." + " URDF→USD conversion requires the Kit URDF importer extension, which is not loaded under" + " the kitless ./scripts/run_ovphysx.sh launcher. Re-enable once a pre-converted USD" + " pendulum asset is available." + ) +) +def test_single_dof_pendulum(): + """Test imu against analytical pendulum problem.""" + + +@pytest.mark.skip( + reason=( + "Blocked on USD version of source/isaaclab_ovphysx/test/sensors/urdfs/simple_2_link.urdf." + " URDF→USD conversion requires the Kit URDF importer extension, which is not loaded under" + " the kitless ./scripts/run_ovphysx.sh launcher. Re-enable once a pre-converted USD" + " pendulum asset is available." + ) +) +def test_indirect_attachment(): + """Test attaching the IMU through an Xform primitive offset chain.""" From a4a4cd4d2b8ce04f3037f1d277b53ba5fe7f812e Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 20 May 2026 08:12:14 +0200 Subject: [PATCH 03/15] Sync IMU kernels.py byte-for-byte with PhysX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OVPhysX IMU kernels are mathematically identical to the PhysX ones — only the data source differs, and that lives one layer above in imu.py. Drop the trivial section-comment delta and restore the PhysX step-numbered labels so a reviewer can diff PhysX vs OVPhysX kernels.py and get zero changes. This also closes the kernel-comment-density divergence noted in the PR review. --- .../isaaclab_ovphysx/sensors/imu/kernels.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py index 36cb1a8c56c5..b22912ec0faa 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py @@ -10,7 +10,7 @@ def imu_update_kernel( # indexing env_mask: wp.array(dtype=wp.bool), - # OVPhysX binding data + # PhysX view data transforms: wp.array(dtype=wp.transformf), velocities: wp.array(dtype=wp.spatial_vectorf), coms: wp.array(dtype=wp.transformf), @@ -30,24 +30,33 @@ def imu_update_kernel( if not env_mask[idx]: return + # 1. Extract body orientation body_quat = wp.transform_get_rotation(transforms[idx]) + + # 2. Apply sensor offset to get sensor orientation sensor_quat = body_quat * offset_quat_b[idx] + # 3. Extract lin/ang velocity lin_vel_w = wp.spatial_top(velocities[idx]) ang_vel_w = wp.spatial_bottom(velocities[idx]) + # 4. COM correction: lin_vel += cross(ang_vel, quat_rotate(body_quat, offset_pos - com_pos)) com_pos_b = wp.transform_get_translation(coms[idx]) lever_arm = wp.quat_rotate(body_quat, offset_pos_b[idx] - com_pos_b) lin_vel_w = lin_vel_w + wp.cross(ang_vel_w, lever_arm) + # 5. Numerical differentiation for linear acceleration (world frame) lin_acc_w = (lin_vel_w - prev_lin_vel_w[idx]) * inv_dt + gravity_bias_w[idx] + # 6. Rotate world -> body using sensor orientation ang_vel_b = wp.quat_rotate_inv(sensor_quat, ang_vel_w) lin_acc_b = wp.quat_rotate_inv(sensor_quat, lin_acc_w) + # 7. Store results out_ang_vel_b[idx] = ang_vel_b out_lin_acc_b[idx] = lin_acc_b + # Update previous velocity prev_lin_vel_w[idx] = lin_vel_w From 34b99ef3d71fa4a27b151444d7b8dc798913bfc8 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 20 May 2026 08:38:23 +0200 Subject: [PATCH 04/15] Align OVPhysX IMU with PhysX/Newton conventions Five small cleanups from the cross-backend review to bring the IMU closer to the PhysX reference and the rest of isaaclab_ovphysx: * Add OvPhysxManager.get_gravity() classmethod (mirrors the PhysX physics_sim_view.get_gravity() public API) and use it from the IMU in place of the private PhysicsManager._sim.cfg.gravity reach-in. * Drop the redundant ``_buf`` suffix from the structured-dtype buffers (``_transforms_buf`` -> ``_transforms``, ``_velocities_buf`` -> ``_velocities``). ``_coms_buffer`` keeps its name to match PhysX. * Canonical OvPhysx ``OvPhysxManager has not been initialized yet.`` error message for the ``physx_instance is None`` guard, matching the contact-sensor, rigid-object, and articulation modules. * Body-count guard now raises ``ValueError`` (configuration error) to match the Newton IMU; reworded to point at the binding pattern. * ``__str__`` prints the binding pattern instead of the redundant ``backend : ovphysx`` line. * Consolidate the duplicate inline comments that explained the ``binding.read(dst)`` flat-float32 view aliasing twice. --- .../physics/ovphysx_manager.py | 12 +++++ .../isaaclab_ovphysx/sensors/imu/imu.py | 48 +++++++------------ 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 160f6a12abc8..2dbf9ba9a90b 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -390,6 +390,18 @@ def get_physx_instance(cls) -> Any: """Return the underlying ovphysx.PhysX instance (or None if not yet created).""" return cls._physx + @classmethod + def get_gravity(cls) -> tuple[float, float, float]: + """Return the world-frame gravity vector [m/s^2] from the active simulation cfg. + + Mirrors :meth:`isaaclab_physx.physics.PhysxManager.get_physics_sim_view().get_gravity` + so backend-agnostic sensor code can read gravity through one classmethod. + Falls back to the IsaacLab default ``(0, 0, -9.81)`` when no simulation is active. + """ + if cls._sim is not None and hasattr(cls._sim, "cfg"): + return cls._sim.cfg.gravity + return (0.0, 0.0, -9.81) + @classmethod def get_scene_data_backend(cls) -> SceneDataBackend: """Return the SceneDataBackend for the central SceneDataProvider. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py index 8b79fa844c93..325e3f61711f 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py @@ -15,7 +15,6 @@ import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils -from isaaclab.physics import PhysicsManager from isaaclab.sensors.imu import BaseImu import isaaclab_ovphysx.tensor_types as TT @@ -74,7 +73,7 @@ def __str__(self) -> str: """Returns: A string containing information about the instance.""" return ( f"Imu sensor @ '{self.cfg.prim_path}': \n" - f"\tbackend : ovphysx\n" + f"\tbinding pattern : {self._rigid_parent_expr}\n" f"\tupdate period (s) : {self.cfg.update_period}\n" f"\tnumber of sensors : {self._num_bodies}\n" ) @@ -131,10 +130,7 @@ def _initialize_impl(self): physx_instance = SimulationManager.get_physx_instance() if physx_instance is None: - raise RuntimeError( - "OVPhysX Imu: physics instance is not initialized. " - "Ensure SimulationContext.reset() has been called before sensor initialization." - ) + raise RuntimeError("OvPhysxManager has not been initialized yet.") prim = sim_utils.find_first_matching_prim(self.cfg.prim_path) if prim is None: @@ -154,23 +150,22 @@ def _initialize_impl(self): self._rigid_parent_expr = self.cfg.prim_path.replace("/" + relative_path, "") fixed_pos_b, fixed_quat_b = sim_utils.resolve_prim_pose(prim, ancestor_prim) - # Translate the user-facing path expression (".*" / "{ENV_REGEX_NS}") to an ovphysx glob. + # Translate the regex-style path expression to an ovphysx fnmatch glob. pattern = self._rigid_parent_expr.replace(".*", "*") - # Open the three pattern-bound tensor bindings on the rigid-body ancestor. self._pose_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_POSE) self._vel_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_VELOCITY) self._com_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_COM_POSE) self._num_bodies = self._pose_binding.count if self._num_bodies != self._num_envs: - raise RuntimeError( - f"OVPhysX Imu: pattern '{pattern}' matched {self._num_bodies} rigid bodies but the scene has " - f"{self._num_envs} environments. The IMU expects exactly one body per environment." + raise ValueError( + f"OvPhysx Imu: pattern '{pattern}' matched {self._num_bodies} rigid bodies; expected exactly one" + f" body per environment (num_envs={self._num_envs}). Check that the prim path or its rigid-body" + " ancestor is unique per env." ) - # Read scene gravity from the simulation cfg (OvPhysxManager has no get_gravity()). - gravity = PhysicsManager._sim.cfg.gravity + gravity = SimulationManager.get_gravity() gravity_bias = torch.tensor((-gravity[0], -gravity[1], -gravity[2]), device=self._device) gravity_bias_torch = gravity_bias.repeat(self._num_bodies, 1) self._gravity_bias_w = wp.from_torch(gravity_bias_torch.contiguous(), dtype=wp.vec3f) @@ -195,14 +190,11 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None): """Fills the buffers of the sensor data.""" env_mask = self._resolve_indices_and_mask(None, env_mask) - # ovphysx binding.read(dst) writes into a pre-allocated dst buffer. - # For structured-dtype targets (transformf, spatial_vectorf) we pass a - # zero-copy flat float32 view of the same memory; the kernel then reads - # the structured buffer directly. + # ovphysx ``binding.read(dst)`` writes into the pre-allocated dst buffer; + # ``_*_view`` are float32 aliases of the structured-dtype buffers below. self._pose_binding.read(self._transforms_view) self._vel_binding.read(self._velocities_view) - - # COM is CPU-resident in ovphysx; read into the CPU scratch, then copy into the GPU buffer. + # COM is CPU-resident in ovphysx; stage on CPU, then copy into the GPU buffer. self._com_binding.read(self._coms_cpu_view) wp.copy(self._coms_gpu_view, self._coms_cpu_view) @@ -211,8 +203,8 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None): dim=self._num_envs, inputs=[ env_mask, - self._transforms_buf, - self._velocities_buf, + self._transforms, + self._velocities, self._coms_buffer, self._offset_pos_b, self._offset_quat_b, @@ -236,23 +228,20 @@ def _initialize_buffers_impl(self): self._offset_pos_b = wp.from_torch(offset_pos_torch.contiguous(), dtype=wp.vec3f) self._offset_quat_b = wp.from_torch(offset_quat_torch.contiguous(), dtype=wp.quatf) - # Persistent destination buffers for ovphysx tensor reads. - # The kernel consumes the structured-dtype buffers; the *_view aliases are - # flat float32 reinterpretations of the same memory used as ovphysx - # binding.read() destinations. - self._transforms_buf = wp.zeros(self._num_bodies, dtype=wp.transformf, device=self._device) - self._velocities_buf = wp.zeros(self._num_bodies, dtype=wp.spatial_vectorf, device=self._device) + # Structured-dtype buffers consumed by the kernel. + self._transforms = wp.zeros(self._num_bodies, dtype=wp.transformf, device=self._device) + self._velocities = wp.zeros(self._num_bodies, dtype=wp.spatial_vectorf, device=self._device) self._coms_buffer = wp.zeros(self._num_bodies, dtype=wp.transformf, device=self._device) self._transforms_view = wp.array( - ptr=self._transforms_buf.ptr, + ptr=self._transforms.ptr, shape=self._pose_binding.shape, dtype=wp.float32, device=self._device, copy=False, ) self._velocities_view = wp.array( - ptr=self._velocities_buf.ptr, + ptr=self._velocities.ptr, shape=self._vel_binding.shape, dtype=wp.float32, device=self._device, @@ -265,5 +254,4 @@ def _initialize_buffers_impl(self): device=self._device, copy=False, ) - # COM binding lives on CPU, so its read destination must also be CPU. self._coms_cpu_view = wp.zeros(self._com_binding.shape, dtype=wp.float32, device="cpu") From 9683bdb4b0f7165eb92c46d04bc6199d490f5f51 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 20 May 2026 08:38:39 +0200 Subject: [PATCH 05/15] Strengthen OVPhysX IMU test suite parity with PhysX/Newton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-side polish from the cross-backend review: * Port the Newton physics-correctness suite — ``test_sensor_initialization``, ``test_gravity_at_rest``, ``test_freefall_acceleration``, ``test_reset``, and a USD-only ``test_indirect_attachment_usd`` (the URDF-blocked PhysX ``test_indirect_attachment`` is reachable kitless via an Xform child under a rigid body). * Adopt the contact-sensor / rigid-object autouse ``_ovphysx_skip_other_device`` device-lock fixture and parametrize every test on ``device in ["cpu", "cuda:0"]`` to unblock GPU coverage through split-invocation runs. * Tighten ``test_sensor_print`` to assert the prim path and ``binding pattern`` line surface in ``str(imu)``. * Document the procedural-vs-Nucleus split-invocation requirement in the module docstring (mirrors the rigid-object header). * Clarify the URDF skip-reason strings so they don't suggest the ``simple_2_link.urdf`` file already lives in the ovphysx test tree. --- .../isaaclab_ovphysx/test/sensors/test_imu.py | 325 +++++++++++++++--- 1 file changed, 282 insertions(+), 43 deletions(-) diff --git a/source/isaaclab_ovphysx/test/sensors/test_imu.py b/source/isaaclab_ovphysx/test/sensors/test_imu.py index 760c324bdb9e..2e9682d475d0 100644 --- a/source/isaaclab_ovphysx/test/sensors/test_imu.py +++ b/source/isaaclab_ovphysx/test/sensors/test_imu.py @@ -13,10 +13,24 @@ UsdFileCfg(usd_path=ISAAC_NUCLEUS_DIR/...) downloads Nucleus assets via omni.client (which works standalone in Kit's Python). -Tests that depend on URDF→USD conversion are skipped pending a USD -version of source/isaaclab_ovphysx/test/sensors/urdfs/simple_2_link.urdf. -The URDF importer is a Kit extension and is not loaded under the -kitless launcher. +Tests that load the PhysX pendulum URDF (``test_single_dof_pendulum`` and +``test_indirect_attachment``) are skipped pending a USD-converted pendulum +asset. URDF→USD conversion requires the Kit URDF importer extension, which +is not loaded under the kitless launcher. + +Process-global wheel state: like the rigid-object test, this file mixes +procedural USD assets (``test_constant_velocity``, ``test_constant_acceleration``, +``test_attachment_validity``, ``test_sensor_print``) with Nucleus assets +(``test_offset_calculation``, ``test_env_ids_propagation``). The ovphysx +wheel keeps some loader state across :class:`SimulationContext` instances +in the same process, so running both categories in a single pytest +invocation can fail with an ``omni.client`` symbol-resolution error after +the first Nucleus-asset test. Run them in two passes if needed: + +* ``pytest ... -k 'velocity or acceleration or attachment or print'`` +* ``pytest ... -k 'offset or env_ids'`` + +Each test passes individually and within its own category. """ from __future__ import annotations @@ -148,13 +162,40 @@ def _make_imu(prim_path: str, offset: ImuCfg.OffsetCfg | None = None) -> Imu: # --------------------------------------------------------------------------- -# Sim context fixture (real OVPhysX backend, CPU) +# Process-global device-mode lock (matches the rigid-object and contact-sensor +# tests). The ovphysx wheel can only run one device per process; parametrized +# tests skip on the unlocked device so single-device runs finish cleanly. +# --------------------------------------------------------------------------- + +_LOCKED_DEVICE: list[str | None] = [None] + + +@pytest.fixture(autouse=True) +def _ovphysx_skip_other_device(request): + """Skip parametrized tests on the device the session is not pinned to.""" + callspec = getattr(request.node, "callspec", None) + device = callspec.params.get("device") if callspec is not None else None + if device is None: + return + locked = _LOCKED_DEVICE[0] + if locked is None: + _LOCKED_DEVICE[0] = device + return + if device != locked: + pytest.skip( + f"ovphysx process-global device lock is held by '{locked}'; cannot run '{device}' " + "tests in the same session. Run pytest twice (once per device) for full coverage." + ) + + +# --------------------------------------------------------------------------- +# Sim context fixture (real OVPhysX backend, device-parametrized) # --------------------------------------------------------------------------- @pytest.fixture -def sim_ctx_cpu(): - """Build an OVPhysX-backed :class:`SimulationContext` on CPU. +def sim_ctx(device: str): + """Build an OVPhysX-backed :class:`SimulationContext` on the requested device. Yields: The simulation context, set up with a small fixed timestep matching @@ -162,17 +203,21 @@ def sim_ctx_cpu(): accuracy. """ with build_simulation_context( - sim_cfg=SimulationCfg(physics=OvPhysxCfg(), device="cpu", dt=0.001), + sim_cfg=SimulationCfg(physics=OvPhysxCfg(), device=device, dt=0.001), ) as sim: yield sim +_DEVICES = ["cpu", "cuda:0"] + + # =========================================================================== # Constant-velocity / constant-acceleration tests (rigid bodies) # =========================================================================== -def test_constant_velocity(sim_ctx_cpu): +@pytest.mark.parametrize("device", _DEVICES) +def test_constant_velocity(sim_ctx, device): """Test the IMU sensor with a constant velocity. Expected behavior is that the linear acceleration is approximately the @@ -184,9 +229,8 @@ def test_constant_velocity(sim_ctx_cpu): cubes = _spawn_cubes(NUM_ENVS) imu_ball = _make_imu("/World/env_*/ball") imu_cube = _make_imu("/World/env_*/cube") - sim_ctx_cpu.reset() + sim_ctx.reset() - device = sim_ctx_cpu.device prev_lin_acc_ball = torch.zeros((NUM_ENVS, 3), dtype=torch.float32, device=device) prev_lin_acc_cube = torch.zeros((NUM_ENVS, 3), dtype=torch.float32, device=device) @@ -201,9 +245,9 @@ def test_constant_velocity(sim_ctx_cpu): balls.write_data_to_sim() cubes.write_data_to_sim() # perform step - sim_ctx_cpu.step() + sim_ctx.step() # read data from sim - dt = sim_ctx_cpu.get_physics_dt() + dt = sim_ctx.get_physics_dt() balls.update(dt) cubes.update(dt) imu_ball.update(dt, force_recompute=True) @@ -229,15 +273,15 @@ def test_constant_velocity(sim_ctx_cpu): prev_lin_acc_cube = imu_cube.data.lin_acc_b.torch.clone() -def test_constant_acceleration(sim_ctx_cpu): +@pytest.mark.parametrize("device", _DEVICES) +def test_constant_acceleration(sim_ctx, device): """Test the IMU sensor with a constant acceleration.""" _spawn_envs(NUM_ENVS) balls = _spawn_balls(NUM_ENVS) imu_ball = _make_imu("/World/env_*/ball") - sim_ctx_cpu.reset() + sim_ctx.reset() - device = sim_ctx_cpu.device - dt = sim_ctx_cpu.get_physics_dt() + dt = sim_ctx.get_physics_dt() for idx in range(100): # set acceleration via increasing velocity per step @@ -246,7 +290,7 @@ def test_constant_acceleration(sim_ctx_cpu): ) * (idx + 1) balls.write_root_velocity_to_sim(velocity) balls.write_data_to_sim() - sim_ctx_cpu.step() + sim_ctx.step() balls.update(dt) imu_ball.update(dt, force_recompute=True) @@ -280,7 +324,8 @@ def test_constant_acceleration(sim_ctx_cpu): # =========================================================================== -def test_offset_calculation(sim_ctx_cpu): +@pytest.mark.parametrize("device", _DEVICES) +def test_offset_calculation(sim_ctx, device): """Test offset configuration argument. Two IMUs on the anymal-C robot — one at ``base`` with a configured offset @@ -294,10 +339,9 @@ def test_offset_calculation(sim_ctx_cpu): "/World/env_*/robot/base", offset=ImuCfg.OffsetCfg(pos=POS_OFFSET, rot=ROT_OFFSET), ) - sim_ctx_cpu.reset() + sim_ctx.reset() - device = sim_ctx_cpu.device - dt = sim_ctx_cpu.get_physics_dt() + dt = sim_ctx.get_physics_dt() for idx in range(500): # apply increasing root velocity @@ -306,7 +350,7 @@ def test_offset_calculation(sim_ctx_cpu): ) * (idx + 1) robot.write_root_velocity_to_sim(velocity) robot.write_data_to_sim() - sim_ctx_cpu.step() + sim_ctx.step() robot.update(dt) imu_robot_imu_link.update(dt, force_recompute=True) imu_robot_base.update(dt, force_recompute=True) @@ -329,15 +373,15 @@ def test_offset_calculation(sim_ctx_cpu): ) -def test_env_ids_propagation(sim_ctx_cpu): +@pytest.mark.parametrize("device", _DEVICES) +def test_env_ids_propagation(sim_ctx, device): """Test that ``env_ids`` argument propagates through update and reset methods.""" _spawn_envs(NUM_ENVS) robot = _spawn_anymal(NUM_ENVS) imu_robot_imu_link = _make_imu("/World/env_*/robot/base/imu_link") - sim_ctx_cpu.reset() + sim_ctx.reset() - device = sim_ctx_cpu.device - dt = sim_ctx_cpu.get_physics_dt() + dt = sim_ctx.get_physics_dt() for idx in range(10): velocity = torch.tensor([[0.5, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat( @@ -345,30 +389,219 @@ def test_env_ids_propagation(sim_ctx_cpu): ) * (idx + 1) robot.write_root_velocity_to_sim(velocity) robot.write_data_to_sim() - sim_ctx_cpu.step() + sim_ctx.step() robot.update(dt) imu_robot_imu_link.update(dt, force_recompute=True) # reset only env 1 imu_robot_imu_link.reset(env_ids=[1]) imu_robot_imu_link.update(dt, force_recompute=True) - sim_ctx_cpu.step() + sim_ctx.step() imu_robot_imu_link.update(dt, force_recompute=True) +# =========================================================================== +# Physics-correctness sanity tests (ported from Newton) +# =========================================================================== + + +@pytest.mark.parametrize("device", _DEVICES) +def test_sensor_initialization(sim_ctx, device): + """Test that the OVPhysX IMU sensor initializes correctly.""" + _spawn_envs(NUM_ENVS) + _spawn_balls(NUM_ENVS) + imu_ball = _make_imu("/World/env_*/ball") + sim_ctx.reset() + + assert imu_ball.num_instances == NUM_ENVS + # Inspect the raw warp buffers directly — accessing ``imu.data`` triggers a + # lazy FD-acceleration recompute that needs ``_dt`` (set by ``update``). + assert imu_ball._data._ang_vel_b.shape == (NUM_ENVS,) + assert imu_ball._data._lin_acc_b.shape == (NUM_ENVS,) + assert imu_ball._data._ang_vel_b.dtype == wp.vec3f + assert imu_ball._data._lin_acc_b.dtype == wp.vec3f + + +@pytest.mark.parametrize("device", _DEVICES) +def test_gravity_at_rest(sim_ctx, device): + """Test that an IMU at rest measures gravity (~9.81 m/s^2 upward). + + Without InteractiveScene's terrain plumbing the ball falls forever, so we + drive it kinematically: hold zero velocity for enough steps that the + finite-difference acceleration of the *applied* velocity converges to zero + and only the +g gravity bias remains. + """ + _spawn_envs(NUM_ENVS) + balls = _spawn_balls(NUM_ENVS) + imu_ball = _make_imu("/World/env_*/ball") + sim_ctx.reset() + + dt = sim_ctx.get_physics_dt() + zero_vel = torch.zeros((NUM_ENVS, 6), dtype=torch.float32, device=device) + for _ in range(5): + balls.write_root_velocity_to_sim(zero_vel) + balls.write_data_to_sim() + sim_ctx.step() + balls.update(dt) + imu_ball.update(dt, force_recompute=True) + + lin_acc = imu_ball.data.lin_acc_b.torch + torch.testing.assert_close( + lin_acc[:, 2], + torch.full((NUM_ENVS,), 9.81, dtype=lin_acc.dtype, device=lin_acc.device), + atol=0.5, + rtol=0.0, + ) + torch.testing.assert_close( + lin_acc[:, :2], + torch.zeros(NUM_ENVS, 2, dtype=lin_acc.dtype, device=lin_acc.device), + atol=0.5, + rtol=0.0, + ) + + +@pytest.mark.parametrize("device", _DEVICES) +def test_freefall_acceleration(sim_ctx, device): + """Test that a freefalling IMU measures near-zero proper acceleration. + + In freefall the finite-difference world-frame acceleration (~``-g``) cancels + the IMU's gravity bias (``+g``), so the reading converges to ``[0, 0, 0]``. + """ + _spawn_envs(NUM_ENVS) + balls = _spawn_balls(NUM_ENVS, height=5.0) + imu_ball = _make_imu("/World/env_*/ball") + sim_ctx.reset() + + dt = sim_ctx.get_physics_dt() + # Let physics integrate gravity for a few steps with no external velocity write. + for _ in range(10): + balls.write_data_to_sim() + sim_ctx.step() + balls.update(dt) + imu_ball.update(dt, force_recompute=True) + + lin_acc = imu_ball.data.lin_acc_b.torch + acc_magnitude = torch.linalg.norm(lin_acc, dim=-1) + torch.testing.assert_close( + acc_magnitude, + torch.zeros_like(acc_magnitude), + atol=0.5, + rtol=0.0, + ) + + +@pytest.mark.parametrize("device", _DEVICES) +def test_reset(sim_ctx, device): + """Test that ``reset`` zeroes out the IMU output and previous-velocity buffers. + + Mirrors the Newton ``test_reset`` parity check: drive the IMU until its + buffers hold non-zero data, then ``reset()`` and assert the raw warp + buffers are zero. We read the raw warp arrays directly because accessing + ``imu.data`` triggers a lazy re-fill that masks reset bugs. + """ + _spawn_envs(NUM_ENVS) + balls = _spawn_balls(NUM_ENVS) + imu_ball = _make_imu("/World/env_*/ball") + sim_ctx.reset() + + dt = sim_ctx.get_physics_dt() + nonzero_vel = torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat(NUM_ENVS, 1) + for _ in range(5): + balls.write_root_velocity_to_sim(nonzero_vel) + balls.write_data_to_sim() + sim_ctx.step() + balls.update(dt) + imu_ball.update(dt, force_recompute=True) + + # Buffers should hold non-zero gravity-bias + numerical-diff state before reset. + assert torch.any(wp.to_torch(imu_ball._data._lin_acc_b) != 0), "expected non-zero data before reset" + + imu_ball.reset() + + # Read raw warp buffers directly — ``imu.data`` would trigger a lazy re-fill that + # bypasses the reset. + ang_vel_after = wp.to_torch(imu_ball._data._ang_vel_b) + lin_acc_after = wp.to_torch(imu_ball._data._lin_acc_b) + prev_vel_after = wp.to_torch(imu_ball._prev_lin_vel_w) + torch.testing.assert_close(ang_vel_after, torch.zeros_like(ang_vel_after)) + torch.testing.assert_close(lin_acc_after, torch.zeros_like(lin_acc_after)) + torch.testing.assert_close(prev_vel_after, torch.zeros_like(prev_vel_after)) + + +@pytest.mark.parametrize("device", _DEVICES) +def test_indirect_attachment_usd(sim_ctx, device): + """Test that an IMU attached to a non-physics Xform under a rigid ancestor matches a direct attachment. + + USD-only port of PhysX's ``test_indirect_attachment``: the URDF pendulum is + not available kitless, but the indirect-attachment code path is reachable + by attaching a non-physics Xform child to a rigid ball and pointing the + IMU at it. The composed offset should match the directly-configured + offset; ``ang_vel_b`` and ``lin_acc_b`` should agree. + """ + _spawn_envs(NUM_ENVS) + balls = _spawn_balls(NUM_ENVS) + # Add a non-physics Xform child under each ball at a known offset; the IMU + # must resolve the rigid-body ancestor (the ball) and recover the offset. + sub_pos = (0.4, 0.0, 0.1) + sub_rot = (0.5, 0.5, 0.5, 0.5) + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/env_{i}/ball/imu_sub", "Xform", translation=sub_pos, orientation=sub_rot) + imu_indirect = _make_imu("/World/env_*/ball/imu_sub") + imu_direct = _make_imu("/World/env_*/ball", offset=ImuCfg.OffsetCfg(pos=sub_pos, rot=sub_rot)) + sim_ctx.reset() + + torch.testing.assert_close( + wp.to_torch(imu_indirect._offset_pos_b), + wp.to_torch(imu_direct._offset_pos_b), + ) + torch.testing.assert_close( + wp.to_torch(imu_indirect._offset_quat_b), + wp.to_torch(imu_direct._offset_quat_b), + rtol=1e-4, + atol=1e-4, + ) + + dt = sim_ctx.get_physics_dt() + drive_vel = torch.tensor([[0.05, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat(NUM_ENVS, 1) + for idx in range(50): + balls.write_root_velocity_to_sim(drive_vel * (idx + 1)) + balls.write_data_to_sim() + sim_ctx.step() + balls.update(dt) + imu_indirect.update(dt, force_recompute=True) + imu_direct.update(dt, force_recompute=True) + + if idx < 2: + continue + + torch.testing.assert_close( + imu_indirect.data.ang_vel_b.torch, + imu_direct.data.ang_vel_b.torch, + rtol=1e-4, + atol=1e-4, + ) + torch.testing.assert_close( + imu_indirect.data.lin_acc_b.torch, + imu_direct.data.lin_acc_b.torch, + rtol=1e-4, + atol=1e-4, + ) + + # =========================================================================== # Validation tests (no asset state required) # =========================================================================== -def test_attachment_validity(sim_ctx_cpu): +@pytest.mark.parametrize("device", _DEVICES) +def test_attachment_validity(sim_ctx, device): """Test invalid IMU attachment. An IMU cannot be attached directly to the world Xform — it must have a rigid-body ancestor in its prim tree. """ _spawn_envs(NUM_ENVS) - sim_ctx_cpu.reset() + sim_ctx.reset() imu_world_cfg = ImuCfg(prim_path="/World/env_0") with pytest.raises(RuntimeError) as exc_info: @@ -377,14 +610,20 @@ def test_attachment_validity(sim_ctx_cpu): assert exc_info.type is RuntimeError and "find a rigid body ancestor prim" in str(exc_info.value) -def test_sensor_print(sim_ctx_cpu): - """Test that ``__str__`` is implemented and runs without error.""" +@pytest.mark.parametrize("device", _DEVICES) +def test_sensor_print(sim_ctx, device): + """Test ``__str__`` is implemented and exposes the prim path and binding pattern.""" _spawn_envs(NUM_ENVS) _spawn_balls(NUM_ENVS) imu_ball = _make_imu("/World/env_*/ball") - sim_ctx_cpu.reset() + sim_ctx.reset() - print(imu_ball) + s = str(imu_ball) + print(s) + assert "Imu sensor @ '/World/env_*/ball'" in s + assert "binding pattern" in s + assert "/World/env_*/ball" in s + assert "number of sensors : 2" in s # =========================================================================== @@ -394,10 +633,10 @@ def test_sensor_print(sim_ctx_cpu): @pytest.mark.skip( reason=( - "Blocked on USD version of source/isaaclab_ovphysx/test/sensors/urdfs/simple_2_link.urdf." - " URDF→USD conversion requires the Kit URDF importer extension, which is not loaded under" - " the kitless ./scripts/run_ovphysx.sh launcher. Re-enable once a pre-converted USD" - " pendulum asset is available." + "Blocked on a USD-converted pendulum asset (the PhysX test loads" + " source/isaaclab_physx/test/sensors/urdfs/simple_2_link.urdf via the Kit URDF importer," + " which is not loaded under the kitless ./scripts/run_ovphysx.sh launcher). Re-enable" + " once a pre-converted USD pendulum is available." ) ) def test_single_dof_pendulum(): @@ -406,10 +645,10 @@ def test_single_dof_pendulum(): @pytest.mark.skip( reason=( - "Blocked on USD version of source/isaaclab_ovphysx/test/sensors/urdfs/simple_2_link.urdf." - " URDF→USD conversion requires the Kit URDF importer extension, which is not loaded under" - " the kitless ./scripts/run_ovphysx.sh launcher. Re-enable once a pre-converted USD" - " pendulum asset is available." + "Blocked on a USD-converted pendulum asset (the PhysX test loads" + " source/isaaclab_physx/test/sensors/urdfs/simple_2_link.urdf via the Kit URDF importer," + " which is not loaded under the kitless ./scripts/run_ovphysx.sh launcher). Re-enable" + " once a pre-converted USD pendulum is available." ) ) def test_indirect_attachment(): From 3ce199182716b90ae04f041e0a2577c1c161856a Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 20 May 2026 08:59:26 +0200 Subject: [PATCH 06/15] Address IMU review feedback * OvPhysxManager.get_gravity() now raises RuntimeError when the simulation is not initialized, instead of silently returning the IsaacLab default (0, 0, -9.81). The silent fallback would mask non-Earth gravity scenes (Moon, Mars, tilt tests) and hide init-order bugs. Fix the chained-call Sphinx role in the same docstring. * Match PhysX wording in the Imu class docstring ("queried from the simulation" instead of "read from the simulation configuration") for cross-backend homogeneity. * Drop the stale "PhysX view data" banner from the OVPhysX kernels file in favor of the backend-neutral "rigid-body view data". * Trim the changelog fragment's implementation-detail sentence per the "no internal details users wouldn't understand" rule. --- .../antoiner-feat-ovphysx_imu.minor.rst | 4 +--- .../isaaclab_ovphysx/physics/ovphysx_manager.py | 14 ++++++++------ .../isaaclab_ovphysx/sensors/imu/imu.py | 4 ++-- .../isaaclab_ovphysx/sensors/imu/kernels.py | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu.minor.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu.minor.rst index 6cdd6d3577d0..953e9acc8a10 100644 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu.minor.rst +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu.minor.rst @@ -7,6 +7,4 @@ Added :class:`~isaaclab.sensors.imu.BaseImuData` contracts on the OVPhysX backend. Reports angular velocity and proper linear acceleration in the sensor body frame using ovphysx tensor bindings on the rigid-body - ancestor of the sensor prim path. Linear acceleration is computed via - numerical differentiation of body velocity (matching the PhysX - backend) and includes a positive gravity bias by default. + ancestor of the sensor prim path. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 2dbf9ba9a90b..01803d51a769 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -394,13 +394,15 @@ def get_physx_instance(cls) -> Any: def get_gravity(cls) -> tuple[float, float, float]: """Return the world-frame gravity vector [m/s^2] from the active simulation cfg. - Mirrors :meth:`isaaclab_physx.physics.PhysxManager.get_physics_sim_view().get_gravity` - so backend-agnostic sensor code can read gravity through one classmethod. - Falls back to the IsaacLab default ``(0, 0, -9.81)`` when no simulation is active. + Mirrors PhysX's ``SimulationView.get_gravity()`` so backend-agnostic sensor code + can read gravity through one classmethod. + + Raises: + RuntimeError: If no simulation is active. Call :meth:`initialize` first. """ - if cls._sim is not None and hasattr(cls._sim, "cfg"): - return cls._sim.cfg.gravity - return (0.0, 0.0, -9.81) + if cls._sim is None or not hasattr(cls._sim, "cfg"): + raise RuntimeError("OvPhysxManager has not been initialized yet.") + return cls._sim.cfg.gravity @classmethod def get_scene_data_backend(cls) -> SceneDataBackend: diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py index 325e3f61711f..6e97810174cb 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py @@ -36,8 +36,8 @@ class Imu(BaseImu): projected gravity. Like a real accelerometer, the linear acceleration readings always include the - contribution of gravity. The gravity vector is read from the simulation - configuration at initialization. + contribution of gravity. The gravity vector is queried from the simulation at + initialization. The sensor can be attached to any prim path with a rigid ancestor in its tree. If the provided path is not a rigid body, the closest rigid-body ancestor is diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py index b22912ec0faa..1026afa70675 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py @@ -10,7 +10,7 @@ def imu_update_kernel( # indexing env_mask: wp.array(dtype=wp.bool), - # PhysX view data + # rigid-body view data transforms: wp.array(dtype=wp.transformf), velocities: wp.array(dtype=wp.spatial_vectorf), coms: wp.array(dtype=wp.transformf), From 8d9e4bd0ece42df645246fd24746cb3e9706b86a Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 20 May 2026 09:31:48 +0200 Subject: [PATCH 07/15] Add missing __init__.py for OVPhysX IMU package The imu/ subpackage was committed without its __init__.py and __init__.pyi files because the project gitignore rule **/__* silently filters them out. Without these, importing Imu or ImuData from isaaclab_ovphysx.sensors raises ModuleNotFoundError at runtime and ruff misclassifies isaaclab_ovphysx imports in imu.py as third-party (causing pre-commit to fail on CI). Force-added with git add -f. The broader gitignore rule should be tightened in a separate change so other new packages don't hit the same trap. --- .../isaaclab_ovphysx/sensors/imu/__init__.py | 10 ++++++++++ .../isaaclab_ovphysx/sensors/imu/__init__.pyi | 12 ++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.pyi diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.py new file mode 100644 index 000000000000..56104ed5830b --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Sub-module for OVPhysX IMU sensor.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.pyi new file mode 100644 index 000000000000..7513020a3ac8 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.pyi @@ -0,0 +1,12 @@ +# 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__ = [ + "Imu", + "ImuData", +] + +from .imu import Imu +from .imu_data import ImuData From e2047bb2db27db61a4f458f37b7da1673e979ee1 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 20 May 2026 10:58:57 +0200 Subject: [PATCH 08/15] Trigger PR sync From 162e702451adb1616d32ae23ca71430eca19c321 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Tue, 9 Jun 2026 11:15:41 +0200 Subject: [PATCH 09/15] Fix OVPhysX IMU stale reset reads --- .../isaaclab_ovphysx/sensors/imu/imu.py | 14 +++-- .../isaaclab_ovphysx/sensors/imu/kernels.py | 13 +++-- .../test/sensors/check_imu.py | 4 +- .../isaaclab_ovphysx/test/sensors/test_imu.py | 55 +++++++++++++++++-- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py index 6e97810174cb..9678c1fbd4cc 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py @@ -194,9 +194,11 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None): # ``_*_view`` are float32 aliases of the structured-dtype buffers below. self._pose_binding.read(self._transforms_view) self._vel_binding.read(self._velocities_view) - # COM is CPU-resident in ovphysx; stage on CPU, then copy into the GPU buffer. - self._com_binding.read(self._coms_cpu_view) - wp.copy(self._coms_gpu_view, self._coms_cpu_view) + # RIGID_BODY_COM_POSE is a CPU tensor type in the OVPhysX wheel. + # For GPU simulations, stage on CPU then copy into the kernel buffer. + self._com_binding.read(self._coms_read_view) + if self._coms_read_view is not self._coms_gpu_view: + wp.copy(self._coms_gpu_view, self._coms_read_view) wp.launch( imu_update_kernel, @@ -211,6 +213,7 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None): self._gravity_bias_w, self._prev_lin_vel_w, 1.0 / self._dt, + self._timestamp, self._data._ang_vel_b, self._data._lin_acc_b, ], @@ -254,4 +257,7 @@ def _initialize_buffers_impl(self): device=self._device, copy=False, ) - self._coms_cpu_view = wp.zeros(self._com_binding.shape, dtype=wp.float32, device="cpu") + if self._device == "cpu": + self._coms_read_view = self._coms_gpu_view + else: + self._coms_read_view = wp.zeros(self._com_binding.shape, dtype=wp.float32, device="cpu", pinned=True) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py index 1026afa70675..2b60ee9c7c3e 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py @@ -8,13 +8,11 @@ @wp.kernel def imu_update_kernel( - # indexing + # inputs env_mask: wp.array(dtype=wp.bool), - # rigid-body view data transforms: wp.array(dtype=wp.transformf), velocities: wp.array(dtype=wp.spatial_vectorf), coms: wp.array(dtype=wp.transformf), - # sensor config (per-env) offset_pos_b: wp.array(dtype=wp.vec3f), offset_quat_b: wp.array(dtype=wp.quatf), gravity_bias_w: wp.array(dtype=wp.vec3f), @@ -22,7 +20,9 @@ def imu_update_kernel( prev_lin_vel_w: wp.array(dtype=wp.vec3f), # scalar inv_dt: wp.float32, - # outputs (written in-place) + # timestamp guard (see #4970): skip envs not stepped since reset. + timestamp: wp.array(dtype=wp.float32), + # outputs out_ang_vel_b: wp.array(dtype=wp.vec3f), out_lin_acc_b: wp.array(dtype=wp.vec3f), ): @@ -30,6 +30,11 @@ def imu_update_kernel( if not env_mask[idx]: return + # Skip envs that have not been stepped since their last reset: OVPhysX velocities still + # hold pre-reset values, so the finite-difference acceleration would be spurious (#4970). + if timestamp[idx] == 0.0: + return + # 1. Extract body orientation body_quat = wp.transform_get_rotation(transforms[idx]) diff --git a/source/isaaclab_ovphysx/test/sensors/check_imu.py b/source/isaaclab_ovphysx/test/sensors/check_imu.py index 992211e2b6f7..6a382cc13a3d 100644 --- a/source/isaaclab_ovphysx/test/sensors/check_imu.py +++ b/source/isaaclab_ovphysx/test/sensors/check_imu.py @@ -6,7 +6,7 @@ """Standalone validation script for the OVPhysX IMU sensor. Run with: - ./scripts/run_ovphysx.sh source/isaaclab_ovphysx/test/sensors/check_imu.py + ./isaaclab.sh -p source/isaaclab_ovphysx/test/sensors/check_imu.py Spawns a free-falling sphere with an IMU attached, steps the simulation for 100 steps, and prints the angular velocity and linear acceleration in the @@ -15,7 +15,7 @@ — the finite-difference world-frame acceleration ``(-g)`` cancels the IMU's gravity bias ``(+g)``. -Runs kitless under ``run_ovphysx.sh`` — no AppLauncher needed. +Runs kitless under ``./isaaclab.sh -p`` — no AppLauncher needed. """ from __future__ import annotations diff --git a/source/isaaclab_ovphysx/test/sensors/test_imu.py b/source/isaaclab_ovphysx/test/sensors/test_imu.py index 2e9682d475d0..9a17aede8ef6 100644 --- a/source/isaaclab_ovphysx/test/sensors/test_imu.py +++ b/source/isaaclab_ovphysx/test/sensors/test_imu.py @@ -8,7 +8,7 @@ """Real-backend tests for the OVPhysX IMU sensor. Mirrors the structure of source/isaaclab_physx/test/sensors/test_imu.py -but runs kitless under ./scripts/run_ovphysx.sh — no AppLauncher needed. +but runs kitless under ./isaaclab.sh -p -m pytest — no AppLauncher needed. SimulationContext is instantiated directly (it does not require Kit), and UsdFileCfg(usd_path=ISAAC_NUCLEUS_DIR/...) downloads Nucleus assets via omni.client (which works standalone in Kit's Python). @@ -16,7 +16,7 @@ Tests that load the PhysX pendulum URDF (``test_single_dof_pendulum`` and ``test_indirect_attachment``) are skipped pending a USD-converted pendulum asset. URDF→USD conversion requires the Kit URDF importer extension, which -is not loaded under the kitless launcher. +is not loaded under the direct ./isaaclab.sh -p runner. Process-global wheel state: like the rigid-object test, this file mixes procedural USD assets (``test_constant_velocity``, ``test_constant_acceleration``, @@ -61,8 +61,10 @@ import isaaclab.sim as sim_utils # noqa: E402 import isaaclab.utils.math as math_utils # noqa: E402 from isaaclab.assets import Articulation, RigidObject, RigidObjectCfg # noqa: E402 +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg # noqa: E402 from isaaclab.sensors.imu import Imu, ImuCfg # noqa: E402 from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 +from isaaclab.utils.configclass import configclass # noqa: E402 from isaaclab_assets.robots.anymal import ANYMAL_C_CFG # noqa: E402 @@ -161,6 +163,23 @@ def _make_imu(prim_path: str, offset: ImuCfg.OffsetCfg | None = None) -> Imu: return Imu(cfg) +@configclass +class _StaleResetSceneCfg(InteractiveSceneCfg): + """Minimal scene for the post-reset staleness regression test.""" + + cube = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/cube", + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 2.0)), + spawn=sim_utils.CuboidCfg( + size=(0.25, 0.25, 0.25), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=0.5), + collision_props=sim_utils.CollisionPropertiesCfg(), + ), + ) + imu_cube: ImuCfg = ImuCfg(prim_path="{ENV_REGEX_NS}/cube") + + # --------------------------------------------------------------------------- # Process-global device-mode lock (matches the rigid-object and contact-sensor # tests). The ovphysx wheel can only run one device per process; parametrized @@ -528,6 +547,34 @@ def test_reset(sim_ctx, device): torch.testing.assert_close(prev_vel_after, torch.zeros_like(prev_vel_after)) +@pytest.mark.parametrize("device", _DEVICES) +def test_no_stale_data_after_scene_reset(sim_ctx, device): + """Test ``scene.reset(env_ids)`` does not expose stale native velocity through ``imu.data``.""" + scene_cfg = _StaleResetSceneCfg(num_envs=1, env_spacing=2.0, lazy_sensor_update=False) + scene = InteractiveScene(scene_cfg) + sim_ctx.reset() + scene.reset() + + sensor: Imu = scene["imu_cube"] + + # Let the cube fall so the native rigid-body velocity buffer becomes non-zero. + for _ in range(30): + scene.write_data_to_sim() + sim_ctx.step() + scene.update(dt=sim_ctx.get_physics_dt()) + + assert torch.any(sensor.data.lin_acc_b.torch != 0), "expected non-zero data before reset" + + # Reset without another physics step. The public accessor must keep reset outputs + # instead of lazy-refetching stale native velocity. + scene.reset(env_ids=torch.tensor([0], device=device)) + + post_reset_lin_acc = sensor.data.lin_acc_b.torch + post_reset_ang_vel = sensor.data.ang_vel_b.torch + torch.testing.assert_close(post_reset_lin_acc, torch.zeros_like(post_reset_lin_acc)) + torch.testing.assert_close(post_reset_ang_vel, torch.zeros_like(post_reset_ang_vel)) + + @pytest.mark.parametrize("device", _DEVICES) def test_indirect_attachment_usd(sim_ctx, device): """Test that an IMU attached to a non-physics Xform under a rigid ancestor matches a direct attachment. @@ -635,7 +682,7 @@ def test_sensor_print(sim_ctx, device): reason=( "Blocked on a USD-converted pendulum asset (the PhysX test loads" " source/isaaclab_physx/test/sensors/urdfs/simple_2_link.urdf via the Kit URDF importer," - " which is not loaded under the kitless ./scripts/run_ovphysx.sh launcher). Re-enable" + " which is not loaded under the direct ./isaaclab.sh -p runner). Re-enable" " once a pre-converted USD pendulum is available." ) ) @@ -647,7 +694,7 @@ def test_single_dof_pendulum(): reason=( "Blocked on a USD-converted pendulum asset (the PhysX test loads" " source/isaaclab_physx/test/sensors/urdfs/simple_2_link.urdf via the Kit URDF importer," - " which is not loaded under the kitless ./scripts/run_ovphysx.sh launcher). Re-enable" + " which is not loaded under the direct ./isaaclab.sh -p runner). Re-enable" " once a pre-converted USD pendulum is available." ) ) From 00a8e55cd1659a7c55711479dd16732b354ac30f Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Tue, 9 Jun 2026 11:30:21 +0200 Subject: [PATCH 10/15] Use shared IMU ancestor resolution Reuse SensorBase rigid-body ancestor resolution in the OVPhysX IMU path so clone-plan source handling stays aligned with the PhysX implementation. Also make the shared suffix trimming path-end-only and cover repeated child suffixes with a regression. --- .../isaaclab_ovphysx/sensors/imu/imu.py | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py index 9678c1fbd4cc..8c38237b22dc 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py @@ -11,9 +11,6 @@ import torch import warp as wp -from pxr import UsdPhysics - -import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils from isaaclab.sensors.imu import BaseImu @@ -132,23 +129,7 @@ def _initialize_impl(self): if physx_instance is None: raise RuntimeError("OvPhysxManager has not been initialized yet.") - prim = sim_utils.find_first_matching_prim(self.cfg.prim_path) - if prim is None: - raise RuntimeError(f"Failed to find a prim at path expression: {self.cfg.prim_path}") - - ancestor_prim = sim_utils.get_first_matching_ancestor_prim( - prim.GetPath(), predicate=lambda _prim: _prim.HasAPI(UsdPhysics.RigidBodyAPI) - ) - if ancestor_prim is None: - raise RuntimeError(f"Failed to find a rigid body ancestor prim at path expression: {self.cfg.prim_path}") - - if ancestor_prim == prim: - self._rigid_parent_expr = self.cfg.prim_path - fixed_pos_b, fixed_quat_b = None, None - else: - relative_path = prim.GetPath().MakeRelativePath(ancestor_prim.GetPath()).pathString - self._rigid_parent_expr = self.cfg.prim_path.replace("/" + relative_path, "") - fixed_pos_b, fixed_quat_b = sim_utils.resolve_prim_pose(prim, ancestor_prim) + self._rigid_parent_expr, fixed_pos_b, fixed_quat_b = self._resolve_rigid_body_ancestor_expr() # Translate the regex-style path expression to an ovphysx fnmatch glob. pattern = self._rigid_parent_expr.replace(".*", "*") From a55662ba89e53d7146945ce700ba79125680beef Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Tue, 9 Jun 2026 11:55:17 +0200 Subject: [PATCH 11/15] Mark OVPhysX IMU tests for device split Add the device_split module marker so the CI runner from the OVPhysX default-install work can invoke the IMU test file once per device. --- source/isaaclab_ovphysx/test/sensors/test_imu.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/isaaclab_ovphysx/test/sensors/test_imu.py b/source/isaaclab_ovphysx/test/sensors/test_imu.py index 9a17aede8ef6..60e7e5884747 100644 --- a/source/isaaclab_ovphysx/test/sensors/test_imu.py +++ b/source/isaaclab_ovphysx/test/sensors/test_imu.py @@ -70,6 +70,8 @@ wp.init() +pytestmark = pytest.mark.device_split + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- From 9b9709ec8ed5973df6e60369513c18d5800c6bb6 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Tue, 9 Jun 2026 18:12:37 +0200 Subject: [PATCH 12/15] Fix OVPhysX IMU stale reset test Drive a native velocity sample before reset and assert the sensor cached velocity became non-zero. This keeps the regression focused on stale native data instead of relying on freefall to produce non-zero proper acceleration. --- source/isaaclab_ovphysx/test/sensors/test_imu.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/source/isaaclab_ovphysx/test/sensors/test_imu.py b/source/isaaclab_ovphysx/test/sensors/test_imu.py index 60e7e5884747..e9bb03f0e55f 100644 --- a/source/isaaclab_ovphysx/test/sensors/test_imu.py +++ b/source/isaaclab_ovphysx/test/sensors/test_imu.py @@ -559,13 +559,16 @@ def test_no_stale_data_after_scene_reset(sim_ctx, device): sensor: Imu = scene["imu_cube"] - # Let the cube fall so the native rigid-body velocity buffer becomes non-zero. - for _ in range(30): - scene.write_data_to_sim() - sim_ctx.step() - scene.update(dt=sim_ctx.get_physics_dt()) + # Drive the native rigid-body velocity buffer non-zero. A freely falling body + # can read zero proper acceleration, so assert the cached velocity instead. + cube: RigidObject = scene["cube"] + nonzero_vel = torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device) + cube.write_root_velocity_to_sim_index(root_velocity=nonzero_vel) + scene.write_data_to_sim() + sim_ctx.step() + scene.update(dt=sim_ctx.get_physics_dt()) - assert torch.any(sensor.data.lin_acc_b.torch != 0), "expected non-zero data before reset" + assert torch.any(wp.to_torch(sensor._prev_lin_vel_w) != 0), "expected non-zero cached velocity before reset" # Reset without another physics step. The public accessor must keep reset outputs # instead of lazy-refetching stale native velocity. From 59d78431a26e6881e3231200933ed8910954b283 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 10 Jun 2026 10:44:36 +0200 Subject: [PATCH 13/15] Tidy IMU kernels Align the PhysX and OVPhysX IMU kernel signatures and docstrings after the kernel cleanup. --- .../isaaclab_ovphysx/sensors/imu/imu.py | 2 +- .../isaaclab_ovphysx/sensors/imu/kernels.py | 50 +++++++++------- .../isaaclab_physx/sensors/imu/imu.py | 2 +- .../isaaclab_physx/sensors/imu/kernels.py | 58 +++++++++++-------- 4 files changed, 65 insertions(+), 47 deletions(-) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py index 8c38237b22dc..0ce9539bf8ed 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py @@ -192,9 +192,9 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None): self._offset_pos_b, self._offset_quat_b, self._gravity_bias_w, - self._prev_lin_vel_w, 1.0 / self._dt, self._timestamp, + self._prev_lin_vel_w, self._data._ang_vel_b, self._data._lin_acc_b, ], diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py index 2b60ee9c7c3e..abc6888d827f 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/kernels.py @@ -16,50 +16,52 @@ def imu_update_kernel( offset_pos_b: wp.array(dtype=wp.vec3f), offset_quat_b: wp.array(dtype=wp.quatf), gravity_bias_w: wp.array(dtype=wp.vec3f), - # previous velocities (read + write) - prev_lin_vel_w: wp.array(dtype=wp.vec3f), - # scalar inv_dt: wp.float32, - # timestamp guard (see #4970): skip envs not stepped since reset. timestamp: wp.array(dtype=wp.float32), + # inputs / outputs + prev_lin_vel_w: wp.array(dtype=wp.vec3f), # outputs out_ang_vel_b: wp.array(dtype=wp.vec3f), out_lin_acc_b: wp.array(dtype=wp.vec3f), ): + """Update the IMU sensor data. + + Args: + env_mask: Mask of environments to update. + transforms: Transforms of the bodies. + velocities: Velocities of the bodies. + coms: COMs of the bodies. + offset_pos_b: Offset positions of the sensors. + offset_quat_b: Offset quaternions of the sensors. + gravity_bias_w: Gravity bias in the world frame. + inv_dt: Inverse of the time step. + timestamp: Timestamp of the environment. + prev_lin_vel_w: Previous linear velocity in the world frame. + out_ang_vel_b: Output angular velocity in the body frame. + out_lin_acc_b: Output linear acceleration in the body frame. + """ idx = wp.tid() if not env_mask[idx]: return # Skip envs that have not been stepped since their last reset: OVPhysX velocities still - # hold pre-reset values, so the finite-difference acceleration would be spurious (#4970). + # hold pre-reset values, so the finite-difference acceleration would be spurious. if timestamp[idx] == 0.0: return - # 1. Extract body orientation body_quat = wp.transform_get_rotation(transforms[idx]) - # 2. Apply sensor offset to get sensor orientation - sensor_quat = body_quat * offset_quat_b[idx] - - # 3. Extract lin/ang velocity lin_vel_w = wp.spatial_top(velocities[idx]) ang_vel_w = wp.spatial_bottom(velocities[idx]) - # 4. COM correction: lin_vel += cross(ang_vel, quat_rotate(body_quat, offset_pos - com_pos)) com_pos_b = wp.transform_get_translation(coms[idx]) lever_arm = wp.quat_rotate(body_quat, offset_pos_b[idx] - com_pos_b) lin_vel_w = lin_vel_w + wp.cross(ang_vel_w, lever_arm) - - # 5. Numerical differentiation for linear acceleration (world frame) lin_acc_w = (lin_vel_w - prev_lin_vel_w[idx]) * inv_dt + gravity_bias_w[idx] - # 6. Rotate world -> body using sensor orientation - ang_vel_b = wp.quat_rotate_inv(sensor_quat, ang_vel_w) - lin_acc_b = wp.quat_rotate_inv(sensor_quat, lin_acc_w) - - # 7. Store results - out_ang_vel_b[idx] = ang_vel_b - out_lin_acc_b[idx] = lin_acc_b + sensor_quat = body_quat * offset_quat_b[idx] + out_ang_vel_b[idx] = wp.quat_rotate_inv(sensor_quat, ang_vel_w) + out_lin_acc_b[idx] = wp.quat_rotate_inv(sensor_quat, lin_acc_w) # Update previous velocity prev_lin_vel_w[idx] = lin_vel_w @@ -72,6 +74,14 @@ def imu_reset_kernel( out_lin_acc_b: wp.array(dtype=wp.vec3f), prev_lin_vel_w: wp.array(dtype=wp.vec3f), ): + """Reset the IMU sensor data. + + Args: + env_mask: Mask of environments to reset. + out_ang_vel_b: Output angular velocity in the body frame. + out_lin_acc_b: Output linear acceleration in the body frame. + prev_lin_vel_w: Previous linear velocity in the world frame. + """ idx = wp.tid() if not env_mask[idx]: return diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/imu/imu.py b/source/isaaclab_physx/isaaclab_physx/sensors/imu/imu.py index 21820ecb5f0b..ce7724fbdf6f 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/imu/imu.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/imu/imu.py @@ -167,9 +167,9 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None): self._offset_pos_b, self._offset_quat_b, self._gravity_bias_w, - self._prev_lin_vel_w, 1.0 / self._dt, self._timestamp, + self._prev_lin_vel_w, self._data._ang_vel_b, self._data._lin_acc_b, ], diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/imu/kernels.py b/source/isaaclab_physx/isaaclab_physx/sensors/imu/kernels.py index f3c0c54d5992..90111579857b 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/imu/kernels.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/imu/kernels.py @@ -8,60 +8,60 @@ @wp.kernel def imu_update_kernel( - # indexing + # inputs env_mask: wp.array(dtype=wp.bool), - # PhysX view data transforms: wp.array(dtype=wp.transformf), velocities: wp.array(dtype=wp.spatial_vectorf), coms: wp.array(dtype=wp.transformf), - # sensor config (per-env) offset_pos_b: wp.array(dtype=wp.vec3f), offset_quat_b: wp.array(dtype=wp.quatf), gravity_bias_w: wp.array(dtype=wp.vec3f), - # previous velocities (read + write) - prev_lin_vel_w: wp.array(dtype=wp.vec3f), - # scalar inv_dt: wp.float32, - # timestamp guard (see #4970): skip envs not stepped since reset. timestamp: wp.array(dtype=wp.float32), - # outputs (written in-place) + # inputs / outputs + prev_lin_vel_w: wp.array(dtype=wp.vec3f), + # outputs out_ang_vel_b: wp.array(dtype=wp.vec3f), out_lin_acc_b: wp.array(dtype=wp.vec3f), ): + """Update the IMU sensor data. + + Args: + env_mask: Mask of environments to update. + transforms: Transforms of the bodies. + velocities: Velocities of the bodies. + coms: COMs of the bodies. + offset_pos_b: Offset positions of the sensors. + offset_quat_b: Offset quaternions of the sensors. + gravity_bias_w: Gravity bias in the world frame. + inv_dt: Inverse of the time step. + timestamp: Timestamp of the environment. + prev_lin_vel_w: Previous linear velocity in the world frame. + out_ang_vel_b: Output angular velocity in the body frame. + out_lin_acc_b: Output linear acceleration in the body frame. + """ idx = wp.tid() if not env_mask[idx]: return - # Skip envs that have not been stepped since their last reset: PhysX's velocities still - # hold pre-reset values, so the finite-difference acceleration would be spurious (#4970). + # Skip envs that have not been stepped since their last reset: PhysX velocities still + # hold pre-reset values, so the finite-difference acceleration would be spurious. if timestamp[idx] == 0.0: return - # 1. Extract body orientation body_quat = wp.transform_get_rotation(transforms[idx]) - # 2. Apply sensor offset to get sensor orientation - sensor_quat = body_quat * offset_quat_b[idx] - - # 3. Extract lin/ang velocity lin_vel_w = wp.spatial_top(velocities[idx]) ang_vel_w = wp.spatial_bottom(velocities[idx]) - # 4. COM correction: lin_vel += cross(ang_vel, quat_rotate(body_quat, offset_pos - com_pos)) com_pos_b = wp.transform_get_translation(coms[idx]) lever_arm = wp.quat_rotate(body_quat, offset_pos_b[idx] - com_pos_b) lin_vel_w = lin_vel_w + wp.cross(ang_vel_w, lever_arm) - - # 5. Numerical differentiation for linear acceleration (world frame) lin_acc_w = (lin_vel_w - prev_lin_vel_w[idx]) * inv_dt + gravity_bias_w[idx] - # 6. Rotate world -> body using sensor orientation - ang_vel_b = wp.quat_rotate_inv(sensor_quat, ang_vel_w) - lin_acc_b = wp.quat_rotate_inv(sensor_quat, lin_acc_w) - - # 7. Store results - out_ang_vel_b[idx] = ang_vel_b - out_lin_acc_b[idx] = lin_acc_b + sensor_quat = body_quat * offset_quat_b[idx] + out_ang_vel_b[idx] = wp.quat_rotate_inv(sensor_quat, ang_vel_w) + out_lin_acc_b[idx] = wp.quat_rotate_inv(sensor_quat, lin_acc_w) # Update previous velocity prev_lin_vel_w[idx] = lin_vel_w @@ -74,6 +74,14 @@ def imu_reset_kernel( out_lin_acc_b: wp.array(dtype=wp.vec3f), prev_lin_vel_w: wp.array(dtype=wp.vec3f), ): + """Reset the IMU sensor data. + + Args: + env_mask: Mask of environments to reset. + out_ang_vel_b: Output angular velocity in the body frame. + out_lin_acc_b: Output linear acceleration in the body frame. + prev_lin_vel_w: Previous linear velocity in the world frame. + """ idx = wp.tid() if not env_mask[idx]: return From 4efc6c0108d63d02b365c846574c7a7e121da520 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Wed, 10 Jun 2026 11:02:31 +0200 Subject: [PATCH 14/15] Add PhysX changelog skip for IMU cleanup Add a no-bump changelog fragment for the PhysX-only IMU kernel tidy so the changelog checker accepts the PR. --- source/isaaclab_physx/changelog.d/antoiner-feat-ovphysx_imu.skip | 1 + 1 file changed, 1 insertion(+) create mode 100644 source/isaaclab_physx/changelog.d/antoiner-feat-ovphysx_imu.skip diff --git a/source/isaaclab_physx/changelog.d/antoiner-feat-ovphysx_imu.skip b/source/isaaclab_physx/changelog.d/antoiner-feat-ovphysx_imu.skip new file mode 100644 index 000000000000..83f984303002 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/antoiner-feat-ovphysx_imu.skip @@ -0,0 +1 @@ +Internal-only: tidied IMU kernel comments and docstrings to match OVPhysX. From 788ccbaadb9dda2c70322dc68db3e47469a0d9a6 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Mon, 15 Jun 2026 11:29:58 +0200 Subject: [PATCH 15/15] Preload omni.client in OVPhysX IMU tests --- .../isaaclab_ovphysx/test/sensors/test_imu.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/source/isaaclab_ovphysx/test/sensors/test_imu.py b/source/isaaclab_ovphysx/test/sensors/test_imu.py index e9bb03f0e55f..307522b4d61a 100644 --- a/source/isaaclab_ovphysx/test/sensors/test_imu.py +++ b/source/isaaclab_ovphysx/test/sensors/test_imu.py @@ -21,16 +21,9 @@ Process-global wheel state: like the rigid-object test, this file mixes procedural USD assets (``test_constant_velocity``, ``test_constant_acceleration``, ``test_attachment_validity``, ``test_sensor_print``) with Nucleus assets -(``test_offset_calculation``, ``test_env_ids_propagation``). The ovphysx -wheel keeps some loader state across :class:`SimulationContext` instances -in the same process, so running both categories in a single pytest -invocation can fail with an ``omni.client`` symbol-resolution error after -the first Nucleus-asset test. Run them in two passes if needed: - -* ``pytest ... -k 'velocity or acceleration or attachment or print'`` -* ``pytest ... -k 'offset or env_ids'`` - -Each test passes individually and within its own category. +(``test_offset_calculation``, ``test_env_ids_propagation``). ``omni.client`` +must be loaded before the first OVPhysX scene is torn down; otherwise a later +first import can fail native symbol resolution after ``ovphysx.reset()``. """ from __future__ import annotations @@ -58,6 +51,11 @@ import warp as wp # noqa: E402 from isaaclab_ovphysx.physics import OvPhysxCfg # noqa: E402 +# Preload Omni Client while Kit's native libraries are still in a clean loader +# state. Importing it for the first time after an OVPhysX reset can fail with an +# undefined symbol from omni.client's native extension. +import omni.client # noqa: E402,F401 + import isaaclab.sim as sim_utils # noqa: E402 import isaaclab.utils.math as math_utils # noqa: E402 from isaaclab.assets import Articulation, RigidObject, RigidObjectCfg # noqa: E402