Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
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.
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,20 @@ 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 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 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:
"""Return the SceneDataBackend for the central SceneDataProvider.
Expand Down
3 changes: 3 additions & 0 deletions source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ __all__ = [
"ContactSensor",
"ContactSensorCfg",
"ContactSensorData",
"Imu",
"ImuData",
]

from .contact_sensor import ContactSensor, ContactSensorCfg, ContactSensorData
from .imu import Imu, ImuData

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Missing imu/__init__.py and imu/__init__.pyi

The sensors/imu/ directory is missing its __init__.py (and the corresponding .pyi stub). Without __init__.py, sensors/imu/ is not a Python package, so the lazy_export() mechanism in sensors/__init__.py will fail when it tries to resolve from .imu import Imu, ImuData — Python will raise ModuleNotFoundError: No module named 'isaaclab_ovphysx.sensors.imu' on first access of either symbol.

Every peer package follows this exact pattern: sensors/contact_sensor/ has both __init__.py (calling lazy_export()) and __init__.pyi (declaring ContactSensor, ContactSensorCfg, ContactSensorData), and the PhysX sensors/imu/ has the same pair. The OVPhysX imu/ package needs matching files to be importable at runtime.

10 changes: 10 additions & 0 deletions source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
12 changes: 12 additions & 0 deletions source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/__init__.pyi
Original file line number Diff line number Diff line change
@@ -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
244 changes: 244 additions & 0 deletions source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
# 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

import isaaclab.utils.math as math_utils
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 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
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"\tbinding pattern : {self._rigid_parent_expr}\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("OvPhysxManager has not been initialized yet.")

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(".*", "*")

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 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."
)

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)

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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is clone strictly needed?

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)
Comment on lines +158 to +168

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the round trip feel a bit weird. Would adding warp kernel for doing this be beneficial? Broadcast seems savable. Or it can be potentially done in _initialize_buffers_impl.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This side is matching the PhysX sensor. I agree it's odd.


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 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)
# 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,
dim=self._num_envs,
inputs=[
env_mask,
self._transforms,
self._velocities,
self._coms_buffer,
self._offset_pos_b,
self._offset_quat_b,
self._gravity_bias_w,
1.0 / self._dt,
self._timestamp,
self._prev_lin_vel_w,
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)

# 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.ptr,
shape=self._pose_binding.shape,
dtype=wp.float32,
device=self._device,
copy=False,
)
self._velocities_view = wp.array(
ptr=self._velocities.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,
)
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)
53 changes: 53 additions & 0 deletions source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu_data.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading