Source code for isaaclab_contrib.assets.multirotor.multirotor

# 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

# Flag for pyright to ignore type errors in this file.
# pyright: reportPrivateUsage=false

from __future__ import annotations

import logging
from collections.abc import Sequence
from typing import TYPE_CHECKING

import torch

import isaaclab.utils.string as string_utils
from isaaclab.assets.articulation import Articulation

from isaaclab_contrib.actuators import Thruster
from isaaclab_contrib.utils.types import MultiRotorActions

from .multirotor_data import MultirotorData

if TYPE_CHECKING:
    from .multirotor_cfg import MultirotorCfg

# import logger
logger = logging.getLogger(__name__)


[docs]class Multirotor(Articulation): """A multirotor articulation asset class. This class extends the base :class:`~isaaclab.assets.Articulation` class to support multirotor vehicles (such as quadcopters, hexacopters, and octocopters) with thruster actuators that apply forces at specific body locations. It is based on the implementation from :cite:t:`kulkarni2025aerialgym`. Unlike standard articulations that use joint-based control, multirotors are controlled through thrust forces generated by individual rotors/propellers. This class provides specialized functionality for managing multiple thruster actuators, computing combined wrenches from individual thrusts, and applying them to the multirotor's base link. Key Features: - **Thruster-based control**: Uses :class:`~isaaclab_contrib.actuators.Thruster` actuators instead of joint actuators for realistic rotor dynamics simulation. - **Force allocation**: Supports allocation matrices to convert individual thruster forces into combined body wrenches (forces and torques). - **Asymmetric dynamics**: Thruster actuators can model asymmetric rise/fall dynamics that reflect real motor behavior. - **Flexible configuration**: Supports arbitrary numbers and arrangements of thrusters through regex-based thruster naming patterns. Usage Example: .. code-block:: python import isaaclab.sim as sim_utils from isaaclab_contrib.assets import MultirotorCfg from isaaclab_contrib.actuators import ThrusterCfg # Define thruster actuator configuration thruster_cfg = ThrusterCfg( thruster_names_expr=["rotor_[0-3]"], # Match rotors 0-3 thrust_range=(0.0, 10.0), # Min and max thrust in Newtons rise_time_constant=0.1, # Time constant for thrust increase fall_time_constant=0.2, # Time constant for thrust decrease ) # Create multirotor configuration multirotor_cfg = MultirotorCfg( prim_path="/World/envs/env_.*/Robot", spawn=sim_utils.UsdFileCfg(usd_path="path/to/quadcopter.usd"), actuators={"thrusters": thruster_cfg}, allocation_matrix=[ # 6x4 matrix for quadcopter (6 DOF, 4 thrusters) [1.0, 1.0, 1.0, 1.0], # Total vertical force [0.0, 0.0, 0.0, 0.0], # Lateral force (x) [0.0, 0.0, 0.0, 0.0], # Lateral force (y) [0.0, 0.1, 0.0, -0.1], # Roll torque [-0.1, 0.0, 0.1, 0.0], # Pitch torque [0.01, -0.01, 0.01, -0.01], # Yaw torque ], ) # Create the multirotor instance multirotor = multirotor_cfg.class_type(multirotor_cfg) .. note:: The allocation matrix maps individual thruster forces to a 6D wrench (3 forces + 3 torques) applied to the base link. The matrix dimensions should be (6, num_thrusters). .. seealso:: - :class:`~isaaclab.assets.Articulation`: Base articulation class - :class:`MultirotorCfg`: Configuration class for multirotors - :class:`MultirotorData`: Data container for multirotor state - :class:`~isaaclab_contrib.actuators.Thruster`: Thruster actuator model """ cfg: MultirotorCfg """Configuration instance for the multirotor.""" actuators: dict[str, Thruster] """Dictionary of thruster actuator instances for the multirotor. The keys are the actuator names and the values are the actuator instances. The actuator instances are initialized based on the actuator configurations specified in the :attr:`MultirotorCfg.actuators` attribute. They are used to compute the thruster commands during the :meth:`write_data_to_sim` function. """
[docs] def __init__(self, cfg: MultirotorCfg): """Initialize the multirotor articulation. Args: cfg: A configuration instance. """ super().__init__(cfg)
""" Properties """ @property def thruster_names(self) -> list[str]: """Ordered names of thrusters in the multirotor. This property aggregates thruster names from all thruster actuator groups configured for the multirotor. The names are ordered according to their array indices, which is important for setting thrust targets and interpreting thruster data. Returns: A list of thruster names in order. Returns an empty list if actuators are not yet initialized. Raises: ValueError: If a non-thruster actuator is found in the multirotor actuators. """ if not hasattr(self, "actuators") or not self.actuators: return [] thruster_names = [] for actuator in self.actuators.values(): if hasattr(actuator, "thruster_names"): thruster_names.extend(actuator.thruster_names) else: raise ValueError("Non thruster actuator found in multirotor actuators. Not supported at the moment.") return thruster_names @property def num_thrusters(self) -> int: """Number of thrusters in the multirotor. Returns: Total number of thrusters across all actuator groups. """ return len(self.thruster_names) @property def allocation_matrix(self) -> torch.Tensor: """Allocation matrix for control allocation. The allocation matrix maps individual thruster forces to a 6D wrench vector (3 forces + 3 torques) applied to the base link. This allows converting per-thruster commands into the resulting body-frame forces and moments. The matrix has shape (6, num_thrusters), where: - Rows 0-2: Force contributions in body frame (Fx, Fy, Fz) - Rows 3-5: Torque contributions in body frame (Tx, Ty, Tz) Returns: Allocation matrix as a torch tensor on the device. """ return torch.tensor(self.cfg.allocation_matrix, device=self.device, dtype=torch.float32) """ Operations """
[docs] def set_thrust_target( self, target: torch.Tensor, thruster_ids: Sequence[int] | slice | None = None, env_ids: Sequence[int] | None = None, ): """Set target thrust values for thrusters. This method sets the desired thrust values for specific thrusters in specific environments. The thrust targets are stored and later processed by the thruster actuator models during the :meth:`write_data_to_sim` call. The actuator models may apply dynamics (rise/fall times) and constraints (thrust limits) to these targets. Args: target: Target thrust values. Shape is (num_envs, num_thrusters) or (num_envs,). The values are typically in the same units as configured in the thruster actuator (e.g., Newtons for force, or revolutions per second for RPS). thruster_ids: Indices of thrusters to set. Defaults to None (all thrusters). Can be a sequence of integers, a slice, or None. env_ids: Environment indices to set. Defaults to None (all environments). Can be a sequence of integers or None. Example: .. code-block:: python # Set thrust for all thrusters in all environments multirotor.set_thrust_target(torch.ones(num_envs, 4) * 5.0) # Set thrust for specific thrusters multirotor.set_thrust_target( torch.tensor([[5.0, 6.0]]), # Different thrust for 2 thrusters thruster_ids=[0, 2], # Apply to thrusters 0 and 2 env_ids=[0], # Only in environment 0 ) """ # resolve indices if env_ids is None: env_ids = slice(None) if thruster_ids is None: thruster_ids = slice(None) # broadcast env_ids if needed to allow double indexing if env_ids != slice(None) and thruster_ids != slice(None): env_ids = env_ids[:, None] # set targets self._data.thrust_target[env_ids, thruster_ids] = target
[docs] def reset(self, env_ids: Sequence[int] | None = None): """Reset the multirotor to default state. This method resets both the base articulation state (pose, velocities) and multirotor-specific state (thruster targets) to their default values as specified in the configuration. Args: env_ids: Environment indices to reset. Defaults to None (all environments). Can be a sequence of integers or None. Note: The default thruster state is set via the :attr:`MultirotorCfg.init_state.rps` configuration parameter. """ # call parent reset super().reset(env_ids) # reset multirotor-specific data if env_ids is None: env_ids = self._ALL_INDICES elif not isinstance(env_ids, torch.Tensor): env_ids = torch.tensor(env_ids, dtype=torch.long, device=self.device) # reset thruster targets to default values if self._data.thrust_target is not None and self._data.default_thruster_rps is not None: self._data.thrust_target[env_ids] = self._data.default_thruster_rps[env_ids]
[docs] def write_data_to_sim(self): """Write thrust and torque commands to the simulation. This method performs the following operations in sequence: 1. **Apply actuator models**: Process thrust targets through thruster actuator models to compute actual thrust values considering dynamics (rise/fall times) and constraints (thrust limits). 2. **Combine thrusts into wrench**: Use the allocation matrix to convert individual thruster forces into a combined 6D wrench (force + torque) vector. 3. **Apply to simulation**: Apply the combined wrench to the base link of the multirotor in the PhysX simulation. This method should be called after setting thrust targets with :meth:`set_thrust_target` and before stepping the simulation. Note: This method overrides the base class implementation because multirotors use thrust-based control rather than joint-based control. """ self._apply_actuator_model() # apply thruster forces at individual locations self._apply_combined_wrench()
""" Internal methods """ def _initialize_impl(self): """Initialize the multirotor implementation.""" # call parent initialization super()._initialize_impl() # Replace data container with MultirotorData self._data = MultirotorData(self.root_physx_view, self.device) # Create thruster buffers with correct size (SINGLE PHASE) self._create_thruster_buffers() # Process thruster configuration self._process_thruster_cfg() # Process configuration self._process_cfg() # Update the robot data self.update(0.0) # Log multirotor information self._log_multirotor_info() def _create_thruster_buffers(self): """Create thruster buffers with correct size.""" num_instances = self.num_instances num_thrusters = self._count_thrusters_from_config() # Create thruster data tensors with correct size self._data.default_thruster_rps = torch.zeros(num_instances, num_thrusters, device=self.device) # thrust after controller and allocation is applied self._data.thrust_target = torch.zeros(num_instances, num_thrusters, device=self.device) self._data.computed_thrust = torch.zeros(num_instances, num_thrusters, device=self.device) self._data.applied_thrust = torch.zeros(num_instances, num_thrusters, device=self.device) # Combined wrench buffers self._thrust_target_sim = torch.zeros_like(self._data.thrust_target) # thrust after actuator model is applied # wrench target for combined mode self._internal_wrench_target_sim = torch.zeros(num_instances, 6, device=self.device) # internal force/torque targets per body for combined mode self._internal_force_target_sim = torch.zeros(num_instances, self.num_bodies, 3, device=self.device) self._internal_torque_target_sim = torch.zeros(num_instances, self.num_bodies, 3, device=self.device) # Placeholder thruster names (will be filled during actuator creation) self._data.thruster_names = [f"thruster_{i}" for i in range(num_thrusters)] def _count_thrusters_from_config(self) -> int: """Count total number of thrusters from actuator configuration. This method parses all actuator configurations to determine the total number of thrusters before they are initialized. It uses the thruster name expressions to find matching bodies in the USD prim. Returns: Total number of thrusters across all actuator groups. Raises: ValueError: If no thrusters are found in the configuration. """ total_thrusters = 0 for actuator_name, actuator_cfg in self.cfg.actuators.items(): if not hasattr(actuator_cfg, "thruster_names_expr"): continue # Use find_bodies to count thrusters for this actuator body_indices, thruster_names = self.find_bodies(actuator_cfg.thruster_names_expr) total_thrusters += len(body_indices) if total_thrusters == 0: raise ValueError( "No thrusters found in actuator configuration. " "Please check 'thruster_names_expr' in the provided 'MultirotorCfg.actuators' configuration." ) return total_thrusters def _process_actuators_cfg(self): """Override parent method to do nothing - we handle thrusters separately.""" # Do nothing - we handle thruster processing in _process_thruster_cfg() otherwise this # gives issues with joint name expressions pass def _process_cfg(self): """Post processing of multirotor configuration parameters.""" # Handle root state (like parent does) default_root_state = ( tuple(self.cfg.init_state.pos) + tuple(self.cfg.init_state.rot) + tuple(self.cfg.init_state.lin_vel) + tuple(self.cfg.init_state.ang_vel) ) default_root_state = torch.tensor(default_root_state, dtype=torch.float, device=self.device) self._data.default_root_state = default_root_state.repeat(self.num_instances, 1) # Handle thruster-specific initial state if hasattr(self._data, "default_thruster_rps") and hasattr(self.cfg.init_state, "rps"): # Match against thruster names indices_list, _, values_list = string_utils.resolve_matching_names_values( self.cfg.init_state.rps, self.thruster_names ) if indices_list: rps_values = torch.tensor(values_list, device=self.device) self._data.default_thruster_rps[:, indices_list] = rps_values self._data.thrust_target[:, indices_list] = rps_values def _process_thruster_cfg(self): """Process and apply multirotor thruster properties.""" # create actuators self.actuators = dict() self._has_implicit_actuators = False # Check for mixed configurations (same as before) has_thrusters = False has_joints = False for actuator_name, actuator_cfg in self.cfg.actuators.items(): if hasattr(actuator_cfg, "thruster_names_expr"): has_thrusters = True elif hasattr(actuator_cfg, "joint_names_expr"): has_joints = True if has_thrusters and has_joints: raise ValueError("Mixed configurations with both thrusters and regular joints are not supported.") if has_joints: raise ValueError("Regular joint actuators are not supported in Multirotor class.") # Store the body-to-thruster mapping self._thruster_body_mapping = {} # Track thruster names as we create actuators all_thruster_names = [] for actuator_name, actuator_cfg in self.cfg.actuators.items(): body_indices, thruster_names = self.find_bodies(actuator_cfg.thruster_names_expr) # Create 0-based thruster array indices starting from current count start_idx = len(all_thruster_names) thruster_array_indices = list(range(start_idx, start_idx + len(body_indices))) # Track all thruster names all_thruster_names.extend(thruster_names) # Store the mapping self._thruster_body_mapping[actuator_name] = { "body_indices": body_indices, "array_indices": thruster_array_indices, "thruster_names": thruster_names, } # Create thruster actuator actuator: Thruster = actuator_cfg.class_type( cfg=actuator_cfg, thruster_names=thruster_names, thruster_ids=thruster_array_indices, num_envs=self.num_instances, device=self.device, init_thruster_rps=self._data.default_thruster_rps[:, thruster_array_indices], ) # Store actuator self.actuators[actuator_name] = actuator # Log information logger.info( f"Thruster actuator: {actuator_name} with model '{actuator_cfg.class_type.__name__}'" f" (thruster names: {thruster_names} [{body_indices}])." ) # Update thruster names in data container self._data.thruster_names = all_thruster_names # Log summary logger.info(f"Initialized {len(self.actuators)} thruster actuator(s) for multirotor.") def _apply_actuator_model(self): """Processes thruster commands for the multirotor by forwarding them to the actuators. This internal method iterates through all thruster actuator groups and applies their respective actuator models to the thrust targets. The actuator models simulate realistic motor dynamics including: - Rise/fall time constants for asymmetric response - Thrust saturation and clipping to physical limits - Integration of motor dynamics over time The computed thrust values are stored in internal buffers for subsequent wrench computation. Note: This method updates: - :attr:`_thrust_target_sim`: Processed thrust values after actuator model - :attr:`_data.computed_thrust`: Thrust before saturation - :attr:`_data.applied_thrust`: Final thrust after saturation """ # process thruster actions per group for actuator in self.actuators.values(): if not isinstance(actuator, Thruster): continue # prepare input for actuator model based on cached data control_action = MultiRotorActions( thrusts=self._data.thrust_target[:, actuator.thruster_indices], thruster_indices=actuator.thruster_indices, ) # compute thruster command from the actuator model control_action = actuator.compute(control_action) # update targets (these are set into the simulation) if control_action.thrusts is not None: self._thrust_target_sim[:, actuator.thruster_indices] = control_action.thrusts # update state of the actuator model self._data.computed_thrust[:, actuator.thruster_indices] = actuator.computed_thrust self._data.applied_thrust[:, actuator.thruster_indices] = actuator.applied_thrust def _apply_combined_wrench(self): """Apply combined wrench to the base link. This internal method applies the 6D wrench (computed by :meth:`_combine_thrusts`) to the base link of the multirotor. The wrench is applied at the center of mass of the base link in the local body frame. The forces and torques are applied through PhysX's force/torque API, which integrates them during the physics step to produce accelerations and velocities. """ # Combine individual thrusts into a wrench vector self._combine_thrusts() self.root_physx_view.apply_forces_and_torques_at_position( force_data=self._internal_force_target_sim.view(-1, 3), # Shape: (num_envs * num_bodies, 3) torque_data=self._internal_torque_target_sim.view(-1, 3), # Shape: (num_envs * num_bodies, 3) position_data=None, # Apply at center of mass indices=self._ALL_INDICES, is_global=False, # Forces are in local frame ) def _combine_thrusts(self): """Combine individual thrusts into a wrench vector. This internal method uses the allocation matrix to convert individual thruster forces into a 6D wrench vector (3D force + 3D torque) in the body frame. The wrench is then assigned to the base link (body index 0) for application to the simulation. The allocation matrix encodes the geometric configuration of the thrusters, including their positions and orientations relative to the center of mass. Mathematical operation: wrench = allocation_matrix @ thrusts where wrench = [Fx, Fy, Fz, Tx, Ty, Tz]^T """ thrusts = self._thrust_target_sim self._internal_wrench_target_sim = (self.allocation_matrix @ thrusts.T).T # Apply forces to base link (body index 0) only self._internal_force_target_sim[:, 0, :] = self._internal_wrench_target_sim[:, :3] self._internal_torque_target_sim[:, 0, :] = self._internal_wrench_target_sim[:, 3:] def _validate_cfg(self): """Validate the multirotor configuration after processing. Note: This function should be called only after the configuration has been processed and the buffers have been created. Otherwise, some settings that are altered during processing may not be validated. """ # Only validate if actuators have been created if hasattr(self, "actuators") and self.actuators: # Validate thruster-specific configuration for actuator_name in self.actuators: if isinstance(self.actuators[actuator_name], Thruster): initial_thrust = self.actuators[actuator_name].curr_thrust # check that the initial thrust is within the limits thrust_limits = self.actuators[actuator_name].cfg.thrust_range if torch.any(initial_thrust < thrust_limits[0]) or torch.any(initial_thrust > thrust_limits[1]): raise ValueError( f"Initial thrust for actuator '{actuator_name}' is out of bounds: " f"{initial_thrust} not in {thrust_limits}" ) def _log_multirotor_info(self): """Log multirotor-specific information.""" logger.info(f"Multirotor initialized with {self.num_thrusters} thrusters") logger.info(f"Thruster names: {self.thruster_names}") logger.info(f"Thruster force direction: {self.cfg.thruster_force_direction}")