# 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 logging
import warnings
from collections.abc import Sequence
from typing import TYPE_CHECKING, ClassVar
import torch
from isaaclab.utils import DelayBuffer, LinearInterpolation
from isaaclab.utils.types import ArticulationActions
from ._compat import _limits_equal
from .actuator_base import ActuatorBase, resolve_joint_parameter
if TYPE_CHECKING:
from .actuator_control import ActuatorControl
from .actuator_pd_cfg import (
DCMotorCfg,
DelayedPDActuatorCfg,
IdealPDActuatorCfg,
ImplicitActuatorCfg,
RemotizedPDActuatorCfg,
)
# import logger
logger = logging.getLogger(__name__)
"""
Implicit Actuator Models.
"""
[docs]
class ImplicitActuator(ActuatorBase):
"""Implicit actuator model that is handled by the simulation.
The articulation writes the configured gains and solver limits to the
backend, whose discrete solver applies the joint drive. This model also
computes approximate effort telemetry from the current state because the
solver does not expose the applied joint effort on every backend.
"""
cfg: ImplicitActuatorCfg
"""The configuration for the actuator model."""
is_implicit_model: ClassVar[bool] = True
[docs]
def __init__(
self,
cfg: ImplicitActuatorCfg,
joint_names: list[str],
joint_ids: slice | torch.Tensor,
num_envs: int,
device: str,
stiffness: torch.Tensor | float = 0.0,
damping: torch.Tensor | float = 0.0,
joint_effort_limit: torch.Tensor | float | None = None,
actuator_velocity_limit: torch.Tensor | float | None = None,
effort_limit: torch.Tensor | float | None = None, # TODO: Deprecated. Remove in 4.0.
velocity_limit: torch.Tensor | float | None = None, # TODO: Deprecated. Remove in 4.0.
):
"""Initialize the implicit actuator.
Args:
cfg: The configuration of the actuator model.
joint_names: The joint names in the articulation.
joint_ids: The joint indices in the articulation. If :obj:`slice(None)`, then all
the joints in the articulation are part of the group.
num_envs: Number of articulations in the view.
device: Device used for processing.
stiffness: Default joint stiffness [N/m or N·m/rad, depending on joint type].
damping: Default joint damping [N·s/m or N·m·s/rad, depending on joint type].
joint_effort_limit: Default solver joint effort limit [N or N·m, depending on
joint type]. Defaults to infinity.
actuator_velocity_limit: Default actuator velocity limit [m/s or rad/s,
depending on joint type]. Defaults to infinity.
effort_limit: Deprecated alias for :paramref:`joint_effort_limit`.
velocity_limit: Deprecated alias for :paramref:`actuator_velocity_limit`.
"""
# TODO: Deprecated. Remove in 4.0.
if effort_limit is not None:
warnings.warn(
"The effort_limit constructor argument is deprecated. Use joint_effort_limit instead; "
"effort_limit will be removed in 4.0.",
DeprecationWarning,
stacklevel=2,
)
if joint_effort_limit is not None and not _limits_equal(joint_effort_limit, effort_limit):
raise ValueError(
"Received conflicting joint_effort_limit and deprecated effort_limit constructor arguments."
)
joint_effort_limit = effort_limit
# the base class resolves deprecated configuration aliases and skips the actuator effort
# limit, which implicit models expose as a live projection of the articulation limit.
super().__init__(
cfg, joint_names, joint_ids, num_envs, device, None, actuator_velocity_limit, None, velocity_limit
)
# construction values, used until the group is bound to articulation storage.
self._construction_stiffness = resolve_joint_parameter(
self.cfg.stiffness, stiffness, joint_names, num_envs, device
)
self._construction_damping = resolve_joint_parameter(self.cfg.damping, damping, joint_names, num_envs, device)
self._construction_joint_effort_limit = resolve_joint_parameter(
self.cfg.joint_effort_limit,
joint_effort_limit if joint_effort_limit is not None else torch.inf,
joint_names,
num_envs,
device,
)
# optional rated (model-facing) effort limit, kept separate from the solver clamp.
# When unset, :attr:`actuator_effort_limit` tracks the live solver limit instead.
if self.cfg.actuator_effort_limit is not None:
self._rated_effort_limit: torch.Tensor | None = resolve_joint_parameter(
self.cfg.actuator_effort_limit, None, joint_names, num_envs, device
)
else:
self._rated_effort_limit = None
# full articulation-order joint property tensors, set by :meth:`_bind_actuator_parameters`.
self._stiffness: torch.Tensor | None = None
self._damping: torch.Tensor | None = None
self._joint_effort_limit: torch.Tensor | None = None
"""
Properties.
"""
@property
def stiffness(self) -> torch.Tensor:
"""Current joint stiffness values [N/m or N·m/rad, depending on joint type]."""
if self._stiffness is None:
return self._construction_stiffness
return self._stiffness[:, self.joint_indices]
@stiffness.setter
def stiffness(self, value: torch.Tensor) -> None:
self._warn_articulation_owned_write("stiffness", "write_joint_stiffness_to_sim_index")
@property
def damping(self) -> torch.Tensor:
"""Current joint damping values [N·s/m or N·m·s/rad, depending on joint type]."""
if self._damping is None:
return self._construction_damping
return self._damping[:, self.joint_indices]
@damping.setter
def damping(self, value: torch.Tensor) -> None:
self._warn_articulation_owned_write("damping", "write_joint_damping_to_sim_index")
@property
def joint_effort_limit(self) -> torch.Tensor:
"""Current joint effort limits [N or N·m, depending on joint type]."""
if self._joint_effort_limit is None:
return self._construction_joint_effort_limit
return self._joint_effort_limit[:, self.joint_indices]
@joint_effort_limit.setter
def joint_effort_limit(self, value: torch.Tensor) -> None:
self._warn_articulation_owned_write("joint_effort_limit", "write_joint_effort_limit_to_sim_index")
@property
def actuator_effort_limit(self) -> torch.Tensor:
"""Actuator effort limit [N or N·m, depending on joint type].
The actuator's rated force/torque reflected at the joint when configured through
:attr:`~isaaclab.actuators.ActuatorBaseCfg.actuator_effort_limit`; it clips the
effort telemetry but is not pushed to the solver. When unset, it tracks the live
articulation joint effort limit (:attr:`joint_effort_limit`).
"""
if self._rated_effort_limit is not None:
return self._rated_effort_limit
return self.joint_effort_limit
@actuator_effort_limit.setter
def actuator_effort_limit(self, value: torch.Tensor) -> None:
self._warn_articulation_owned_write("joint_effort_limit", "write_joint_effort_limit_to_sim_index")
def _warn_articulation_owned_write(self, name: str, writer_name: str) -> None:
"""Warn that an articulation-owned joint property assignment is ignored."""
warnings.warn(
f"ImplicitActuator.{name} is articulation-owned and the assignment is ignored. Use "
f"Articulation.{writer_name}() or randomize_actuator_gains() to update it.",
UserWarning,
stacklevel=3,
)
"""
Operations.
"""
[docs]
def reset(self, env_ids: Sequence[int] | None = None):
# This is a no-op. There is no state to reset for implicit actuators.
pass
[docs]
def compute(
self, control_action: ArticulationActions, joint_pos: torch.Tensor, joint_vel: torch.Tensor
) -> ArticulationActions:
"""Process the actuator group actions and compute the articulation actions.
For an implicit actuator, the desired control action is returned unchanged because the
physics solver applies the PD drive. This method still computes approximate computed and
applied effort telemetry from the current joint state. That telemetry may differ from the
effort applied internally by the solver.
Args:
control_action: Desired joint positions [m or rad, depending on joint type], velocities [m/s or rad/s,
depending on joint type], and feed-forward efforts [N or N·m, depending on joint type].
joint_pos: Current joint positions [m or rad, depending on joint type], shape
``(num_envs, num_joints)``.
joint_vel: Current joint velocities [m/s or rad/s, depending on joint type], shape
``(num_envs, num_joints)``.
Returns:
Desired joint positions [m or rad, depending on joint type], velocities [m/s or rad/s, depending on
joint type], and efforts [N or N·m, depending on joint type].
"""
# store approximate torques for reward computation
error_pos = control_action.joint_positions - joint_pos
error_vel = control_action.joint_velocities - joint_vel
self.computed_effort = self.stiffness * error_pos + self.damping * error_vel + control_action.joint_efforts
# clip the torques based on the motor limits
self.applied_effort = self._clip_effort(self.computed_effort)
return control_action
def _clip_effort(self, effort: torch.Tensor) -> torch.Tensor:
"""Clip telemetry using the articulation joint effort limit."""
joint_effort_limit = self.joint_effort_limit
return torch.clip(effort, min=-joint_effort_limit, max=joint_effort_limit)
def _bind_actuator_parameters(self, control: ActuatorControl) -> None:
"""Bind joint-property reads to live articulation-owned storage.
Stores references to the full articulation-order tensors instead of per-group
slices: with non-contiguous joint indices, slicing would materialize a copy and
the reference to the articulation storage would be lost. Property reads index
the full tensors on access, so they always observe the articulation's current
values.
"""
self._stiffness = control.joint_stiffness.torch
self._damping = control.joint_damping.torch
self._joint_effort_limit = control.joint_effort_limits.torch
"""
Explicit Actuator Models.
"""
[docs]
class IdealPDActuator(ActuatorBase):
r"""Ideal torque-controlled actuator model with a simple saturation model.
It employs the following model for computing torques for the actuated joint :math:`j`:
.. math::
\tau_{j, computed} = k_p * (q_{des} - q) + k_d * (\dot{q}_{des} - \dot{q}) + \tau_{ff}
where, :math:`k_p` and :math:`k_d` are joint stiffness and damping gains, :math:`q` and :math:`\dot{q}`
are the current joint positions and velocities, :math:`q_{des}`, :math:`\dot{q}_{des}` and :math:`\tau_{ff}`
are the desired joint positions, velocities and torques commands.
The model clips the resulting joint effort directly to
:attr:`actuator_effort_limit`:
.. math::
\tau_{j, applied} = clip(\tau_{j, computed}, -\tau_{max}, \tau_{max})
where :math:`\tau_{max}` is the configured joint-side effort limit [N or
N·m, depending on joint type].
"""
cfg: IdealPDActuatorCfg
"""The configuration for the actuator model."""
actuator_effort_limit: torch.Tensor
"""Actuator-model effort clipping limit [N or N·m, depending on joint type].
Shape is (num_envs, num_joints).
"""
[docs]
def __init__(
self,
cfg: IdealPDActuatorCfg,
joint_names: list[str],
joint_ids: slice | torch.Tensor,
num_envs: int,
device: str,
stiffness: torch.Tensor | float = 0.0,
damping: torch.Tensor | float = 0.0,
actuator_effort_limit: torch.Tensor | float | None = None,
actuator_velocity_limit: torch.Tensor | float | None = None,
effort_limit: torch.Tensor | float | None = None, # TODO: Deprecated. Remove in 4.0.
velocity_limit: torch.Tensor | float | None = None, # TODO: Deprecated. Remove in 4.0.
):
super().__init__(
cfg,
joint_names,
joint_ids,
num_envs,
device,
actuator_effort_limit,
actuator_velocity_limit,
effort_limit, # TODO: Deprecated. Remove in 4.0.
velocity_limit, # TODO: Deprecated. Remove in 4.0.
)
self.stiffness = resolve_joint_parameter(self.cfg.stiffness, stiffness, joint_names, num_envs, device)
self.damping = resolve_joint_parameter(self.cfg.damping, damping, joint_names, num_envs, device)
"""
Operations.
"""
[docs]
def reset(self, env_ids: Sequence[int]):
pass
[docs]
def compute(
self, control_action: ArticulationActions, joint_pos: torch.Tensor, joint_vel: torch.Tensor
) -> ArticulationActions:
# compute errors
error_pos = control_action.joint_positions - joint_pos
error_vel = control_action.joint_velocities - joint_vel
# calculate the desired joint torques
self.computed_effort = self.stiffness * error_pos + self.damping * error_vel + control_action.joint_efforts
# clip the torques based on the motor limits
self.applied_effort = self._clip_effort(self.computed_effort)
# set the computed actions back into the control action
control_action.joint_efforts = self.applied_effort
control_action.joint_positions = None
control_action.joint_velocities = None
return control_action
[docs]
class DCMotor(IdealPDActuator):
r"""Direct control (DC) motor actuator model with velocity-based saturation model.
It uses the same model as the :class:`IdealPDActuator` for computing the torques from input commands.
However, it implements a saturation model defined by a linear four quadrant DC motor torque-speed curve.
A DC motor is a type of electric motor that is powered by direct current electricity. In most cases,
the motor is connected to a constant source of voltage supply, and the current is controlled by a rheostat.
Depending on various design factors such as windings and materials, the motor can draw a limited maximum power
from the electronic source, which limits the produced motor torque and speed.
A DC motor characteristics are defined by the following parameters:
* No-load speed (:math:`\dot{q}_{motor, max}`) [m/s or rad/s, depending on
joint type]: The maximum-rated speed of the motor at zero torque
(:attr:`actuator_velocity_limit`).
* Stall torque (:math:`\tau_{motor, stall}`): The maximum-rated torque produced at
zero speed [N or N·m, depending on joint type] (:attr:`saturation_effort`).
* Continuous torque (:math:`\tau_{motor, con}`) [N or N·m, depending on
joint type]: The maximum torque that can be outputted for a short period.
This is often enforced on the current drives for a DC motor to limit
overheating, prevent mechanical damage, or enforced by electrical
limitations (:attr:`actuator_effort_limit`).
* Corner velocity (:math:`V_{c}`) [m/s or rad/s, depending on joint type]:
The velocity where the torque-speed curve intersects with continuous torque.
Based on these parameters, the instantaneous minimum and maximum torques for velocities between corner velocities
(where torque-speed curve intersects with continuous torque) are defined as follows:
.. math::
\tau_{j, max}(\dot{q}) & = clip \left (\tau_{j, stall} \times \left(1 -
\frac{\dot{q}}{\dot{q}_{j, max}}\right), -∞, \tau_{j, con} \right) \\
\tau_{j, min}(\dot{q}) & = clip \left (\tau_{j, stall} \times \left( -1 -
\frac{\dot{q}}{\dot{q}_{j, max}}\right), - \tau_{j, con}, ∞ \right)
where :math:`\gamma` is the gear ratio of the gear box connecting the motor and the actuated joint ends,
:math:`\dot{q}_{j, max} = \gamma^{-1} \times \dot{q}_{motor, max}`, :math:`\tau_{j, con} =
\gamma \times \tau_{motor, con}` and :math:`\tau_{j, stall} = \gamma \times \tau_{motor, stall}`
are the maximum joint velocity, continuous joint torque and stall torque, respectively. These parameters
are read from the configuration instance passed to the class.
Using these values, the computed torques are clipped to the minimum and maximum values based on the
instantaneous joint velocity:
.. math::
\tau_{j, applied} = clip(\tau_{computed}, \tau_{j, min}(\dot{q}), \tau_{j, max}(\dot{q}))
If the velocity of the joint is outside corner velocities (this would be due to external forces) the
applied output torque will be driven to the continuous torque
(:attr:`actuator_effort_limit`).
The figure below demonstrates the clipping action for example (velocity, torque) pairs.
.. figure:: ../../_static/actuator-group/dc_motor_clipping.jpg
:align: center
:figwidth: 100%
:alt: The effort clipping as a function of joint velocity for a linear DC Motor.
"""
cfg: DCMotorCfg
"""The configuration for the actuator model."""
[docs]
def __init__(self, cfg: DCMotorCfg, *args, **kwargs):
super().__init__(cfg, *args, **kwargs)
# parse configuration
if self.cfg.saturation_effort is None:
raise ValueError("The saturation_effort must be provided for the DC motor actuator model.")
self._saturation_effort = self.cfg.saturation_effort
# check that quantities are provided
if self.cfg.actuator_velocity_limit is None:
raise ValueError("The velocity limit must be provided for the DC motor actuator model.")
# Find the velocity where the torque-speed curve intersects actuator_effort_limit.
self._vel_at_effort_lim = self.actuator_velocity_limit * (
1 + self.actuator_effort_limit / self._saturation_effort
)
# prepare joint vel buffer for max effort computation
self._joint_vel = torch.zeros_like(self.computed_effort)
# create buffer for zeros effort
self._zeros_effort = torch.zeros_like(self.computed_effort)
"""
Operations.
"""
[docs]
def compute(
self, control_action: ArticulationActions, joint_pos: torch.Tensor, joint_vel: torch.Tensor
) -> ArticulationActions:
# save current joint vel
self._joint_vel[:] = joint_vel
# calculate the desired joint torques
return super().compute(control_action, joint_pos, joint_vel)
"""
Helper functions.
"""
def _clip_effort(self, effort: torch.Tensor) -> torch.Tensor:
# save current joint vel
self._joint_vel[:] = torch.clip(self._joint_vel, min=-self._vel_at_effort_lim, max=self._vel_at_effort_lim)
# compute torque limits
torque_speed_top = self._saturation_effort * (1.0 - self._joint_vel / self.actuator_velocity_limit)
torque_speed_bottom = self._saturation_effort * (-1.0 - self._joint_vel / self.actuator_velocity_limit)
# -- max limit
max_effort = torch.clip(torque_speed_top, max=self.actuator_effort_limit)
# -- min limit
min_effort = torch.clip(torque_speed_bottom, min=-self.actuator_effort_limit)
# clip the torques based on the motor limits
clamped = torch.clip(effort, min=min_effort, max=max_effort)
return clamped
[docs]
class DelayedPDActuator(IdealPDActuator):
"""Ideal PD actuator with delayed command application.
This class extends the :class:`IdealPDActuator` class by adding a delay to the actuator commands. The delay
is implemented using a circular buffer that stores the actuator commands for a certain number of physics steps.
The most recent actuation value is pushed to the buffer at every physics step, but the final actuation value
applied to the simulation is lagged by a certain number of physics steps.
The amount of time lag is configurable and can be set to a random value between the minimum and maximum time
lag bounds at every reset. The minimum and maximum time lag values are set in the configuration instance passed
to the class.
"""
cfg: DelayedPDActuatorCfg
"""The configuration for the actuator model."""
[docs]
def __init__(self, cfg: DelayedPDActuatorCfg, *args, **kwargs):
super().__init__(cfg, *args, **kwargs)
# instantiate the delay buffers
self.positions_delay_buffer = DelayBuffer(cfg.max_delay, self._num_envs, device=self._device)
self.velocities_delay_buffer = DelayBuffer(cfg.max_delay, self._num_envs, device=self._device)
self.efforts_delay_buffer = DelayBuffer(cfg.max_delay, self._num_envs, device=self._device)
# all of the envs
self._ALL_INDICES = torch.arange(self._num_envs, dtype=torch.long, device=self._device)
[docs]
def reset(self, env_ids: Sequence[int]):
super().reset(env_ids)
# number of environments (since env_ids can be a slice)
if env_ids is None or env_ids == slice(None):
num_envs = self._num_envs
else:
num_envs = len(env_ids)
# set a new random delay for environments in env_ids
time_lags = torch.randint(
low=self.cfg.min_delay,
high=self.cfg.max_delay + 1,
size=(num_envs,),
dtype=torch.int,
device=self._device,
)
# set delays
self.positions_delay_buffer.set_time_lag(time_lags, env_ids)
self.velocities_delay_buffer.set_time_lag(time_lags, env_ids)
self.efforts_delay_buffer.set_time_lag(time_lags, env_ids)
# reset buffers
self.positions_delay_buffer.reset(env_ids)
self.velocities_delay_buffer.reset(env_ids)
self.efforts_delay_buffer.reset(env_ids)
[docs]
def compute(
self, control_action: ArticulationActions, joint_pos: torch.Tensor, joint_vel: torch.Tensor
) -> ArticulationActions:
# apply delay based on the delay the model for all the setpoints
control_action.joint_positions = self.positions_delay_buffer.compute(control_action.joint_positions)
control_action.joint_velocities = self.velocities_delay_buffer.compute(control_action.joint_velocities)
control_action.joint_efforts = self.efforts_delay_buffer.compute(control_action.joint_efforts)
# compte actuator model
return super().compute(control_action, joint_pos, joint_vel)
[docs]
class RemotizedPDActuator(DelayedPDActuator):
"""Ideal PD actuator with angle-dependent torque limits.
This class extends :class:`DelayedPDActuator` with angle-dependent effort
limits [N or N·m, depending on joint type]. The limits are applied by
querying a lookup table describing the relationship between joint angle
[m or rad, depending on joint type] and maximum output effort [N or N·m,
depending on joint type]. The lookup table is provided in the configuration
instance passed to the class.
The torque limits are interpolated based on the current joint positions and applied to the actuator commands.
"""
[docs]
def __init__(
self,
cfg: RemotizedPDActuatorCfg,
joint_names: list[str],
joint_ids: slice | torch.Tensor,
num_envs: int,
device: str,
stiffness: torch.Tensor | float = 0.0,
damping: torch.Tensor | float = 0.0,
actuator_effort_limit: torch.Tensor | float | None = None,
actuator_velocity_limit: torch.Tensor | float | None = None,
effort_limit: torch.Tensor | float | None = None, # TODO: Deprecated. Remove in 4.0.
velocity_limit: torch.Tensor | float | None = None, # TODO: Deprecated. Remove in 4.0.
):
super().__init__(
cfg,
joint_names,
joint_ids,
num_envs,
device,
stiffness,
damping,
actuator_effort_limit,
actuator_velocity_limit,
effort_limit, # TODO: Deprecated. Remove in 4.0.
velocity_limit, # TODO: Deprecated. Remove in 4.0.
)
# This model has no box constraints: the angle-dependent lookup table below governs
# effort clipping, so the parsed actuator limits are replaced with infinity.
self.actuator_effort_limit = torch.full_like(self.actuator_effort_limit, torch.inf)
self.actuator_velocity_limit = torch.full_like(self.actuator_velocity_limit, torch.inf)
self._joint_parameter_lookup = torch.tensor(cfg.joint_parameter_lookup, device=device)
# define remotized joint torque limit
self._torque_limit = LinearInterpolation(self.angle_samples, self.max_torque_samples, device=device)
"""
Properties.
"""
@property
def angle_samples(self) -> torch.Tensor:
"""Lookup joint positions [m or rad, depending on joint type]."""
return self._joint_parameter_lookup[:, 0]
@property
def transmission_ratio_samples(self) -> torch.Tensor:
"""Dimensionless lookup transmission ratios."""
return self._joint_parameter_lookup[:, 1]
@property
def max_torque_samples(self) -> torch.Tensor:
"""Lookup effort limits [N or N·m, depending on joint type]."""
return self._joint_parameter_lookup[:, 2]
"""
Operations.
"""
[docs]
def compute(
self, control_action: ArticulationActions, joint_pos: torch.Tensor, joint_vel: torch.Tensor
) -> ArticulationActions:
# call the base method
control_action = super().compute(control_action, joint_pos, joint_vel)
# compute the absolute torque limits for the current joint positions
abs_torque_limits = self._torque_limit.compute(joint_pos)
# apply the limits
control_action.joint_efforts = torch.clamp(
control_action.joint_efforts, min=-abs_torque_limits, max=abs_torque_limits
)
self.applied_effort = control_action.joint_efforts
return control_action