Source code for isaaclab_ov.sensors.joint_wrench.joint_wrench_sensor

# 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=false

from __future__ import annotations

import logging
import re
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any

import numpy as np
import warp as wp

from pxr import Usd, UsdPhysics

from isaaclab.sensors.joint_wrench import BaseJointWrenchSensor
from isaaclab.sim.utils.queries import find_first_matching_prim, get_all_matching_child_prims, path_expr_to_glob

import isaaclab_ov.tensor_types as TT
from isaaclab_ov.physics import OvPhysxManager
from isaaclab_ov.sim.views.ovphysx_view import OvPhysxView

from .joint_wrench_sensor_data import JointWrenchSensorData
from .kernels import joint_wrench_reset_kernel, joint_wrench_split_kernel

if TYPE_CHECKING:
    from isaaclab.sensors.joint_wrench import JointWrenchSensorCfg

logger = logging.getLogger(__name__)


[docs] class JointWrenchSensor(BaseJointWrenchSensor): """OVPhysX joint reaction wrench sensor. The sensor reads OVPhysX's incoming joint wrench (a PhysX-backed tensor binding of type :data:`~isaaclab_ov.tensor_types.LINK_INCOMING_JOINT_FORCE`) for every articulation link and exposes the linear force [N] and angular torque [N·m] components in the child-side joint frame, with torque referenced at the child-side joint anchor. The root body's entry is included. :attr:`~isaaclab.sensors.SensorBaseCfg.prim_path` must point at either the articulation root prim or a parent prim containing a single articulation root in every environment. """ cfg: JointWrenchSensorCfg """The configuration parameters.""" __backend_name__: str = "ovphysx" """The name of the backend for the joint wrench sensor."""
[docs] def __init__(self, cfg: JointWrenchSensorCfg): """Initialize the OVPhysX joint-wrench sensor. Args: cfg: The configuration parameters. """ super().__init__(cfg) self._data = JointWrenchSensorData() self._physx_instance: Any = None self._root_view: OvPhysxView | None = None self._wrench_binding: Any = None self._wrench_buf: wp.array | None = None self._joint_pos_b: wp.array | None = None self._joint_quat_b: wp.array | None = None self._num_bodies: int = 0
def __str__(self) -> str: """String representation of the sensor instance.""" return ( f"Joint wrench sensor @ '{self.cfg.prim_path}': \n" f"\tbackend : ovphysx\n" f"\tupdate period (s) : {self.cfg.update_period}\n" f"\tnumber of bodies : {self._num_bodies}\n" f"\tbody names : {self.body_names}\n" ) """ Properties """ @property def body_names(self) -> list[str]: """Ordered names of the bodies whose incoming joint wrench is reported.""" return self._data._body_names @property def data(self) -> JointWrenchSensorData: """The joint-wrench sensor data.""" self._update_outdated_buffers() return self._data """ Operations """ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None) -> None: """Reset the sensor buffers for the given environments. Args: env_ids: The environment ids to reset. env_mask: The mask used to reset the environments. Shape is ``(num_envs,)``. """ if self._data._force is None or self._data._torque is None: return env_mask = self._resolve_indices_and_mask(env_ids, env_mask) super().reset(None, env_mask) wp.launch( joint_wrench_reset_kernel, dim=(self._num_envs, self._num_bodies), inputs=[env_mask, self._data._force, self._data._torque], device=self._device, ) """ Implementation """ def _initialize_impl(self) -> None: """PHYSICS_READY callback: builds the tensor binding and allocates buffers.""" super()._initialize_impl() physx_instance = OvPhysxManager.get_physx_instance() if physx_instance is None: raise RuntimeError("OvPhysxManager has not been initialized yet.") self._physx_instance = physx_instance # Resolve the articulation root and translate to an fnmatch glob. root_prim_path_expr = self._resolve_articulation_root_prim_path() pattern = re.sub(r"\{ENV_REGEX_NS\}", "*", root_prim_path_expr) pattern = path_expr_to_glob(pattern) self._root_view = OvPhysxView(physx_instance, pattern=pattern, device=self._device) self._wrench_binding = self._root_view.binding_for(TT.LINK_INCOMING_JOINT_FORCE) if self._wrench_binding.body_count == 0 or self._wrench_binding.count == 0: raise RuntimeError(f"Joint wrench sensor matched zero bodies at '{self.cfg.prim_path}'.") self._num_bodies = self._wrench_binding.body_count self._data._body_names = list(self._wrench_binding.body_names) # OVPhysX clone_usd=False means SensorBase's USD-glob count only saw env_0; # the binding's ``count`` reports the true number of articulation instances # (one per env). Mirrors how OVPhysX Articulation reads ``sample.count`` # directly as ``num_instances``. binding_num_envs = self._wrench_binding.count if binding_num_envs != self._num_envs: self._num_envs = binding_num_envs self._ALL_ENV_MASK = wp.ones((self._num_envs,), dtype=wp.bool, device=self._device) self._reset_mask = wp.zeros((self._num_envs,), dtype=wp.bool, device=self._device) self._reset_mask_torch = wp.to_torch(self._reset_mask) self._is_outdated = wp.ones(self._num_envs, dtype=wp.bool, device=self._device) self._timestamp = wp.zeros(self._num_envs, dtype=wp.float32, device=self._device) self._timestamp_last_update = wp.zeros_like(self._timestamp) self._create_joint_frame_buffers() # Wrench storage as (N, L) spatial_vectorf, read each step via the view. The view # reinterprets this structured buffer off the binding's flat float32 shape and caches # that reinterpret per destination buffer, so no manual float32 alias is needed here. self._wrench_buf = wp.zeros((self._num_envs, self._num_bodies), dtype=wp.spatial_vectorf, device=self._device) self._data.create_buffers(num_envs=self._num_envs, num_bodies=self._num_bodies, device=self._device) logger.info(f"Joint wrench sensor initialized: {self._num_envs} envs, {self._num_bodies} bodies") def _resolve_articulation_root_prim_path(self) -> str: """Resolve the articulation root prim path expression from the configured asset prim path.""" first_env_matching_prim = find_first_matching_prim(self.cfg.prim_path) if first_env_matching_prim is None: raise RuntimeError(f"Failed to find prim for expression: '{self.cfg.prim_path}'.") first_env_matching_prim_path = first_env_matching_prim.GetPath().pathString first_env_root_prims = get_all_matching_child_prims( first_env_matching_prim_path, predicate=lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI) and prim.GetAttribute("physxArticulation:articulationEnabled").Get() is not False, traverse_instance_prims=False, ) if len(first_env_root_prims) == 0: raise RuntimeError( f"Failed to find an articulation when resolving '{first_env_matching_prim_path}'." " Please ensure that the prim has 'USD ArticulationRootAPI' applied." ) if len(first_env_root_prims) > 1: raise RuntimeError( f"Failed to find a single articulation when resolving '{first_env_matching_prim_path}'." f" Found multiple '{first_env_root_prims}' under '{first_env_matching_prim_path}'." " Please ensure that there is only one articulation in the prim path tree." ) first_env_root_prim_path = first_env_root_prims[0].GetPath().pathString root_prim_path_relative_to_prim_path = first_env_root_prim_path[len(first_env_matching_prim_path) :] return self.cfg.prim_path + root_prim_path_relative_to_prim_path def _create_joint_frame_buffers(self) -> None: """Create child-side joint frame transforms indexed by OVPhysX link order.""" joint_pos_b = np.zeros((self._num_bodies, 3), dtype=np.float32) joint_quat_b = np.zeros((self._num_bodies, 4), dtype=np.float32) joint_quat_b[:, 3] = 1.0 first_env_matching_prim = find_first_matching_prim(self.cfg.prim_path) if first_env_matching_prim is None: raise RuntimeError(f"Failed to find prim for expression: '{self.cfg.prim_path}'.") link_name_to_index = {name: index for index, name in enumerate(self._data._body_names)} for prim in Usd.PrimRange(first_env_matching_prim): joint = UsdPhysics.Joint(prim) if not joint or joint.GetJointEnabledAttr().Get() is False: continue body1_targets = joint.GetBody1Rel().GetTargets() if len(body1_targets) == 0: continue body_index = link_name_to_index.get(body1_targets[0].name) if body_index is None: continue local_pos1 = joint.GetLocalPos1Attr().Get() if local_pos1 is not None: joint_pos_b[body_index] = (float(local_pos1[0]), float(local_pos1[1]), float(local_pos1[2])) local_rot1 = joint.GetLocalRot1Attr().Get() if local_rot1 is not None: local_rot1_imag = local_rot1.GetImaginary() joint_quat_b[body_index] = ( float(local_rot1_imag[0]), float(local_rot1_imag[1]), float(local_rot1_imag[2]), float(local_rot1.GetReal()), ) self._joint_pos_b = wp.array(joint_pos_b, dtype=wp.vec3f, device=self._device) self._joint_quat_b = wp.array(joint_quat_b, dtype=wp.quatf, device=self._device) def _update_buffers_impl(self, env_mask: wp.array) -> None: """Read OVPhysX incoming joint wrenches and split them into force / torque buffers. Args: env_mask: A mask containing which environments need to be updated. Shape is ``(num_envs,)``. """ if self._root_view is None or self._wrench_buf is None: raise RuntimeError( f"Joint wrench sensor '{self.cfg.prim_path}': not initialized." " Access sensor data only after sim.reset() has been called." ) if self._joint_pos_b is None or self._joint_quat_b is None: raise RuntimeError(f"Joint wrench sensor '{self.cfg.prim_path}': joint frame buffers are not initialized.") self._root_view.read_into(TT.LINK_INCOMING_JOINT_FORCE, self._wrench_buf) wp.launch( joint_wrench_split_kernel, dim=(self._num_envs, self._num_bodies), inputs=[ env_mask, self._wrench_buf, self._joint_pos_b, self._joint_quat_b, self._timestamp, self._data._force, self._data._torque, ], device=self._device, ) def _invalidate_initialize_callback(self, event) -> None: """Drop binding, cached sizes, and buffers when physics stops. Args: event: An invalidate event. """ super()._invalidate_initialize_callback(event) # Drop the view (and the binding it owns) before the wrench buffer it reads into: # the view caches a float32 reinterpret of that buffer, so releasing it first keeps # Warp's array deallocator from aborting on a freed-but-still-referenced allocation. self._root_view = None self._wrench_binding = None self._physx_instance = None self._wrench_buf = None self._joint_pos_b = None self._joint_quat_b = None self._num_bodies = 0 self._data._force = None self._data._torque = None self._data._body_names = [] self._data._force_ta = None self._data._torque_ta = None