# Copyright (c) 2022-2024, The Isaac Lab Project Developers.
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
from typing import TYPE_CHECKING
from omni.isaac.lab.utils.math import (
apply_delta_pose,
combine_frame_transforms,
compute_pose_error,
matrix_from_quat,
subtract_frame_transforms,
)
if TYPE_CHECKING:
from .operational_space_cfg import OperationalSpaceControllerCfg
[docs]class OperationalSpaceController:
"""Operational-space controller.
Reference:
1. `A unified approach for motion and force control of robot manipulators: The operational space formulation <http://dx.doi.org/10.1109/JRA.1987.1087068>`_
by Oussama Khatib (Stanford University)
2. `Robot Dynamics Lecture Notes <https://ethz.ch/content/dam/ethz/special-interest/mavt/robotics-n-intelligent-systems/rsl-dam/documents/RobotDynamics2017/RD_HS2017script.pdf>`_
by Marco Hutter (ETH Zurich)
"""
[docs] def __init__(self, cfg: OperationalSpaceControllerCfg, num_envs: int, device: str):
"""Initialize operational-space controller.
Args:
cfg: The configuration for operational-space controller.
num_envs: The number of environments.
device: The device to use for computations.
Raises:
ValueError: When invalid control command is provided.
"""
# store inputs
self.cfg = cfg
self.num_envs = num_envs
self._device = device
# resolve tasks-pace target dimensions
self.target_list = list()
for command_type in self.cfg.target_types:
if command_type == "pose_rel":
self.target_list.append(6)
elif command_type == "pose_abs":
self.target_list.append(7)
elif command_type == "wrench_abs":
self.target_list.append(6)
else:
raise ValueError(f"Invalid control command: {command_type}.")
self.target_dim = sum(self.target_list)
# create buffers
# -- selection matrices, which might be defined in the task reference frame different from the root frame
self._selection_matrix_motion_task = torch.diag_embed(
torch.tensor(self.cfg.motion_control_axes_task, dtype=torch.float, device=self._device)
.unsqueeze(0)
.repeat(self.num_envs, 1)
)
self._selection_matrix_force_task = torch.diag_embed(
torch.tensor(self.cfg.contact_wrench_control_axes_task, dtype=torch.float, device=self._device)
.unsqueeze(0)
.repeat(self.num_envs, 1)
)
# -- selection matrices in root frame
self._selection_matrix_motion_b = torch.zeros_like(self._selection_matrix_motion_task)
self._selection_matrix_force_b = torch.zeros_like(self._selection_matrix_force_task)
# -- commands
self._task_space_target_task = torch.zeros(self.num_envs, self.target_dim, device=self._device)
# -- Placeholders for motion/force control
self.desired_ee_pose_task = None
self.desired_ee_pose_b = None
self.desired_ee_wrench_task = None
self.desired_ee_wrench_b = None
# -- buffer for operational space mass matrix
self._os_mass_matrix_b = torch.zeros(self.num_envs, 6, 6, device=self._device)
# -- Placeholder for the inverse of joint space mass matrix
self._mass_matrix_inv = None
# -- motion control gains
self._motion_p_gains_task = torch.diag_embed(
torch.ones(self.num_envs, 6, device=self._device)
* torch.tensor(self.cfg.motion_stiffness_task, dtype=torch.float, device=self._device)
)
# -- -- zero out the axes that are not motion controlled, as keeping them non-zero will cause other axes
# -- -- to act due to coupling
self._motion_p_gains_task[:] = self._selection_matrix_motion_task @ self._motion_p_gains_task[:]
self._motion_d_gains_task = torch.diag_embed(
2
* torch.diagonal(self._motion_p_gains_task, dim1=-2, dim2=-1).sqrt()
* torch.as_tensor(self.cfg.motion_damping_ratio_task, dtype=torch.float, device=self._device).reshape(1, -1)
)
# -- -- motion control gains in root frame
self._motion_p_gains_b = torch.zeros_like(self._motion_p_gains_task)
self._motion_d_gains_b = torch.zeros_like(self._motion_d_gains_task)
# -- force control gains
if self.cfg.contact_wrench_stiffness_task is not None:
self._contact_wrench_p_gains_task = torch.diag_embed(
torch.ones(self.num_envs, 6, device=self._device)
* torch.tensor(self.cfg.contact_wrench_stiffness_task, dtype=torch.float, device=self._device)
)
self._contact_wrench_p_gains_task[:] = (
self._selection_matrix_force_task @ self._contact_wrench_p_gains_task[:]
)
# -- -- force control gains in root frame
self._contact_wrench_p_gains_b = torch.zeros_like(self._contact_wrench_p_gains_task)
else:
self._contact_wrench_p_gains_task = None
self._contact_wrench_p_gains_b = None
# -- position gain limits
self._motion_p_gains_limits = torch.zeros(self.num_envs, 6, 2, device=self._device)
self._motion_p_gains_limits[..., 0], self._motion_p_gains_limits[..., 1] = (
self.cfg.motion_stiffness_limits_task[0],
self.cfg.motion_stiffness_limits_task[1],
)
# -- damping ratio limits
self._motion_damping_ratio_limits = torch.zeros_like(self._motion_p_gains_limits)
self._motion_damping_ratio_limits[..., 0], self._motion_damping_ratio_limits[..., 1] = (
self.cfg.motion_damping_ratio_limits_task[0],
self.cfg.motion_damping_ratio_limits_task[1],
)
# -- end-effector contact wrench
self._ee_contact_wrench_b = torch.zeros(self.num_envs, 6, device=self._device)
# -- buffers for null-space control gains
self._nullspace_p_gain = torch.tensor(self.cfg.nullspace_stiffness, dtype=torch.float, device=self._device)
self._nullspace_d_gain = (
2
* torch.sqrt(self._nullspace_p_gain)
* torch.tensor(self.cfg.nullspace_damping_ratio, dtype=torch.float, device=self._device)
)
"""
Properties.
"""
@property
def action_dim(self) -> int:
"""Dimension of the action space of controller."""
# impedance mode
if self.cfg.impedance_mode == "fixed":
# task-space targets
return self.target_dim
elif self.cfg.impedance_mode == "variable_kp":
# task-space targets + stiffness
return self.target_dim + 6
elif self.cfg.impedance_mode == "variable":
# task-space targets + stiffness + damping
return self.target_dim + 6 + 6
else:
raise ValueError(f"Invalid impedance mode: {self.cfg.impedance_mode}.")
"""
Operations.
"""
[docs] def reset(self):
"""Reset the internals."""
self.desired_ee_pose_b = None
self.desired_ee_pose_task = None
self.desired_ee_wrench_b = None
self.desired_ee_wrench_task = None
[docs] def set_command(
self,
command: torch.Tensor,
current_ee_pose_b: torch.Tensor | None = None,
current_task_frame_pose_b: torch.Tensor | None = None,
):
"""Set the task-space targets and impedance parameters.
Args:
command (torch.Tensor): A concatenated tensor of shape (``num_envs``, ``action_dim``) containing task-space
targets (i.e., pose/wrench) and impedance parameters.
current_ee_pose_b (torch.Tensor, optional): Current end-effector pose, in root frame, of shape
(``num_envs``, 7), containing position and quaternion ``(w, x, y, z)``. Required for relative
commands. Defaults to None.
current_task_frame_pose_b: Current pose of the task frame, in root frame, in which the targets and the
(motion/wrench) control axes are defined. It is a tensor of shape (``num_envs``, 7),
containing position and the quaternion ``(w, x, y, z)``. Defaults to None.
Format:
Task-space targets, ordered according to 'command_types':
Absolute pose: shape (``num_envs``, 7), containing position and quaternion ``(w, x, y, z)``.
Relative pose: shape (``num_envs``, 6), containing delta position and rotation in axis-angle form.
Absolute wrench: shape (``num_envs``, 6), containing force and torque.
Impedance parameters: stiffness for ``variable_kp``, or stiffness, followed by damping ratio for
``variable``:
Stiffness: shape (``num_envs``, 6)
Damping ratio: shape (``num_envs``, 6)
Raises:
ValueError: When the command dimensions are invalid.
ValueError: When an invalid impedance mode is provided.
ValueError: When the current end-effector pose is not provided for the ``pose_rel`` command.
ValueError: When an invalid control command is provided.
"""
# Check the input dimensions
if command.shape != (self.num_envs, self.action_dim):
raise ValueError(
f"Invalid command shape '{command.shape}'. Expected: '{(self.num_envs, self.action_dim)}'."
)
# Resolve the impedance parameters
if self.cfg.impedance_mode == "fixed":
# task space targets (i.e., pose/wrench)
self._task_space_target_task[:] = command
elif self.cfg.impedance_mode == "variable_kp":
# split input command
task_space_command, stiffness = torch.split(command, [self.target_dim, 6], dim=-1)
# format command
stiffness = stiffness.clip_(
min=self._motion_p_gains_limits[..., 0], max=self._motion_p_gains_limits[..., 1]
)
# task space targets + stiffness
self._task_space_target_task[:] = task_space_command.squeeze(dim=-1)
self._motion_p_gains_task[:] = torch.diag_embed(stiffness)
self._motion_p_gains_task[:] = self._selection_matrix_motion_task @ self._motion_p_gains_task[:]
self._motion_d_gains_task = torch.diag_embed(
2
* torch.diagonal(self._motion_p_gains_task, dim1=-2, dim2=-1).sqrt()
* torch.as_tensor(self.cfg.motion_damping_ratio_task, dtype=torch.float, device=self._device).reshape(
1, -1
)
)
elif self.cfg.impedance_mode == "variable":
# split input command
task_space_command, stiffness, damping_ratio = torch.split(command, [self.target_dim, 6, 6], dim=-1)
# format command
stiffness = stiffness.clip_(
min=self._motion_p_gains_limits[..., 0], max=self._motion_p_gains_limits[..., 1]
)
damping_ratio = damping_ratio.clip_(
min=self._motion_damping_ratio_limits[..., 0], max=self._motion_damping_ratio_limits[..., 1]
)
# task space targets + stiffness + damping
self._task_space_target_task[:] = task_space_command
self._motion_p_gains_task[:] = torch.diag_embed(stiffness)
self._motion_p_gains_task[:] = self._selection_matrix_motion_task @ self._motion_p_gains_task[:]
self._motion_d_gains_task[:] = torch.diag_embed(
2 * torch.diagonal(self._motion_p_gains_task, dim1=-2, dim2=-1).sqrt() * damping_ratio
)
else:
raise ValueError(f"Invalid impedance mode: {self.cfg.impedance_mode}.")
if current_task_frame_pose_b is None:
current_task_frame_pose_b = torch.tensor(
[[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]] * self.num_envs, device=self._device
)
# Resolve the target commands
target_groups = torch.split(self._task_space_target_task, self.target_list, dim=1)
for command_type, target in zip(self.cfg.target_types, target_groups):
if command_type == "pose_rel":
# check input is provided
if current_ee_pose_b is None:
raise ValueError("Current pose is required for 'pose_rel' command.")
# Transform the current pose from base/root frame to task frame
current_ee_pos_task, current_ee_rot_task = subtract_frame_transforms(
current_task_frame_pose_b[:, :3],
current_task_frame_pose_b[:, 3:],
current_ee_pose_b[:, :3],
current_ee_pose_b[:, 3:],
)
# compute targets in task frame
desired_ee_pos_task, desired_ee_rot_task = apply_delta_pose(
current_ee_pos_task, current_ee_rot_task, target
)
self.desired_ee_pose_task = torch.cat([desired_ee_pos_task, desired_ee_rot_task], dim=-1)
elif command_type == "pose_abs":
# compute targets
self.desired_ee_pose_task = target.clone()
elif command_type == "wrench_abs":
# compute targets
self.desired_ee_wrench_task = target.clone()
else:
raise ValueError(f"Invalid control command: {command_type}.")
# Rotation of task frame wrt root frame, converts a coordinate from task frame to root frame.
R_task_b = matrix_from_quat(current_task_frame_pose_b[:, 3:])
# Rotation of root frame wrt task frame, converts a coordinate from root frame to task frame.
R_b_task = R_task_b.mT
# Transform motion control stiffness gains from task frame to root frame
self._motion_p_gains_b[:, 0:3, 0:3] = R_task_b @ self._motion_p_gains_task[:, 0:3, 0:3] @ R_b_task
self._motion_p_gains_b[:, 3:6, 3:6] = R_task_b @ self._motion_p_gains_task[:, 3:6, 3:6] @ R_b_task
# Transform motion control damping gains from task frame to root frame
self._motion_d_gains_b[:, 0:3, 0:3] = R_task_b @ self._motion_d_gains_task[:, 0:3, 0:3] @ R_b_task
self._motion_d_gains_b[:, 3:6, 3:6] = R_task_b @ self._motion_d_gains_task[:, 3:6, 3:6] @ R_b_task
# Transform contact wrench gains from task frame to root frame (if applicable)
if self._contact_wrench_p_gains_task is not None and self._contact_wrench_p_gains_b is not None:
self._contact_wrench_p_gains_b[:, 0:3, 0:3] = (
R_task_b @ self._contact_wrench_p_gains_task[:, 0:3, 0:3] @ R_b_task
)
self._contact_wrench_p_gains_b[:, 3:6, 3:6] = (
R_task_b @ self._contact_wrench_p_gains_task[:, 3:6, 3:6] @ R_b_task
)
# Transform selection matrices from target frame to base frame
self._selection_matrix_motion_b[:, 0:3, 0:3] = (
R_task_b @ self._selection_matrix_motion_task[:, 0:3, 0:3] @ R_b_task
)
self._selection_matrix_motion_b[:, 3:6, 3:6] = (
R_task_b @ self._selection_matrix_motion_task[:, 3:6, 3:6] @ R_b_task
)
self._selection_matrix_force_b[:, 0:3, 0:3] = (
R_task_b @ self._selection_matrix_force_task[:, 0:3, 0:3] @ R_b_task
)
self._selection_matrix_force_b[:, 3:6, 3:6] = (
R_task_b @ self._selection_matrix_force_task[:, 3:6, 3:6] @ R_b_task
)
# Transform desired pose from task frame to root frame
if self.desired_ee_pose_task is not None:
self.desired_ee_pose_b = torch.zeros_like(self.desired_ee_pose_task)
self.desired_ee_pose_b[:, :3], self.desired_ee_pose_b[:, 3:] = combine_frame_transforms(
current_task_frame_pose_b[:, :3],
current_task_frame_pose_b[:, 3:],
self.desired_ee_pose_task[:, :3],
self.desired_ee_pose_task[:, 3:],
)
# Transform desired wrenches to root frame
if self.desired_ee_wrench_task is not None:
self.desired_ee_wrench_b = torch.zeros_like(self.desired_ee_wrench_task)
self.desired_ee_wrench_b[:, :3] = (R_task_b @ self.desired_ee_wrench_task[:, :3].unsqueeze(-1)).squeeze(-1)
self.desired_ee_wrench_b[:, 3:] = (R_task_b @ self.desired_ee_wrench_task[:, 3:].unsqueeze(-1)).squeeze(
-1
) + torch.cross(current_task_frame_pose_b[:, :3], self.desired_ee_wrench_b[:, :3], dim=-1)
[docs] def compute(
self,
jacobian_b: torch.Tensor,
current_ee_pose_b: torch.Tensor | None = None,
current_ee_vel_b: torch.Tensor | None = None,
current_ee_force_b: torch.Tensor | None = None,
mass_matrix: torch.Tensor | None = None,
gravity: torch.Tensor | None = None,
current_joint_pos: torch.Tensor | None = None,
current_joint_vel: torch.Tensor | None = None,
nullspace_joint_pos_target: torch.Tensor | None = None,
) -> torch.Tensor:
"""Performs inference with the controller.
Args:
jacobian_b: The Jacobian matrix of the end-effector in root frame. It is a tensor of shape
(``num_envs``, 6, ``num_DoF``).
current_ee_pose_b: The current end-effector pose in root frame. It is a tensor of shape
(``num_envs``, 7), which contains the position and quaternion ``(w, x, y, z)``. Defaults to ``None``.
current_ee_vel_b: The current end-effector velocity in root frame. It is a tensor of shape
(``num_envs``, 6), which contains the linear and angular velocities. Defaults to None.
current_ee_force_b: The current external force on the end-effector in root frame. It is a tensor of
shape (``num_envs``, 3), which contains the linear force. Defaults to ``None``.
mass_matrix: The joint-space mass/inertia matrix. It is a tensor of shape (``num_envs``, ``num_DoF``,
``num_DoF``). Defaults to ``None``.
gravity: The joint-space gravity vector. It is a tensor of shape (``num_envs``, ``num_DoF``). Defaults
to ``None``.
current_joint_pos: The current joint positions. It is a tensor of shape (``num_envs``, ``num_DoF``).
Defaults to ``None``.
current_joint_vel: The current joint velocities. It is a tensor of shape (``num_envs``, ``num_DoF``).
Defaults to ``None``.
nullspace_joint_pos_target: The target joint positions the null space controller is trying to enforce, when
possible. It is a tensor of shape (``num_envs``, ``num_DoF``).
Raises:
ValueError: When motion-control is enabled but the current end-effector pose or velocity is not provided.
ValueError: When inertial dynamics decoupling is enabled but the mass matrix is not provided.
ValueError: When the current end-effector pose is not provided for the ``pose_rel`` command.
ValueError: When closed-loop force control is enabled but the current end-effector force is not provided.
ValueError: When gravity compensation is enabled but the gravity vector is not provided.
ValueError: When null-space control is enabled but the system is not redundant.
ValueError: When dynamically consistent pseudo-inverse is enabled but the mass matrix inverse is not
provided.
ValueError: When null-space control is enabled but the current joint positions and velocities are not
provided.
ValueError: When target joint positions are provided for null-space control but their dimensions do not
match the current joint positions.
ValueError: When an invalid null-space control method is provided.
Returns:
Tensor: The joint efforts computed by the controller. It is a tensor of shape (``num_envs``, ``num_DoF``).
"""
# deduce number of DoF
num_DoF = jacobian_b.shape[2]
# create joint effort vector
joint_efforts = torch.zeros(self.num_envs, num_DoF, device=self._device)
# compute joint efforts for motion-control
if self.desired_ee_pose_b is not None:
# check input is provided
if current_ee_pose_b is None or current_ee_vel_b is None:
raise ValueError("Current end-effector pose and velocity are required for motion control.")
# -- end-effector tracking error
pose_error_b = torch.cat(
compute_pose_error(
current_ee_pose_b[:, :3],
current_ee_pose_b[:, 3:],
self.desired_ee_pose_b[:, :3],
self.desired_ee_pose_b[:, 3:],
rot_error_type="axis_angle",
),
dim=-1,
)
velocity_error_b = -current_ee_vel_b # zero target velocity. The target is assumed to be stationary.
# -- desired end-effector acceleration (spring-damper system)
des_ee_acc_b = self._motion_p_gains_b @ pose_error_b.unsqueeze(
-1
) + self._motion_d_gains_b @ velocity_error_b.unsqueeze(-1)
# -- Inertial dynamics decoupling
if self.cfg.inertial_dynamics_decoupling:
# check input is provided
if mass_matrix is None:
raise ValueError("Mass matrix is required for inertial decoupling.")
# Compute operational space mass matrix
self._mass_matrix_inv = torch.inverse(mass_matrix)
if self.cfg.partial_inertial_dynamics_decoupling:
# Fill in the translational and rotational parts of the inertia separately, ignoring their coupling
self._os_mass_matrix_b[:, 0:3, 0:3] = torch.inverse(
jacobian_b[:, 0:3] @ self._mass_matrix_inv @ jacobian_b[:, 0:3].mT
)
self._os_mass_matrix_b[:, 3:6, 3:6] = torch.inverse(
jacobian_b[:, 3:6] @ self._mass_matrix_inv @ jacobian_b[:, 3:6].mT
)
else:
# Calculate the operational space mass matrix fully accounting for the couplings
self._os_mass_matrix_b[:] = torch.inverse(jacobian_b @ self._mass_matrix_inv @ jacobian_b.mT)
# (Generalized) operational space command forces
# F = (J M^(-1) J^T)^(-1) * \ddot(x_des) = M_task * \ddot(x_des)
os_command_forces_b = self._os_mass_matrix_b @ des_ee_acc_b
else:
# Task-space impedance control: command forces = \ddot(x_des).
# Please note that the definition of task-space impedance control varies in literature.
# This implementation ignores the inertial term. For inertial decoupling,
# use inertial_dynamics_decoupling=True.
os_command_forces_b = des_ee_acc_b
# -- joint-space commands
joint_efforts += (jacobian_b.mT @ self._selection_matrix_motion_b @ os_command_forces_b).squeeze(-1)
# compute joint efforts for contact wrench/force control
if self.desired_ee_wrench_b is not None:
# -- task-space contact wrench
if self.cfg.contact_wrench_stiffness_task is not None:
# check input is provided
if current_ee_force_b is None:
raise ValueError("Current end-effector force is required for closed-loop force control.")
# We can only measure the force component at the contact, so only apply the feedback for only the force
# component, keep the control of moment components open loop
self._ee_contact_wrench_b[:, 0:3] = current_ee_force_b
self._ee_contact_wrench_b[:, 3:6] = self.desired_ee_wrench_b[:, 3:6]
# closed-loop control with feedforward term
os_contact_wrench_command_b = self.desired_ee_wrench_b.unsqueeze(
-1
) + self._contact_wrench_p_gains_b @ (self.desired_ee_wrench_b - self._ee_contact_wrench_b).unsqueeze(
-1
)
else:
# open-loop control
os_contact_wrench_command_b = self.desired_ee_wrench_b.unsqueeze(-1)
# -- joint-space commands
joint_efforts += (jacobian_b.mT @ self._selection_matrix_force_b @ os_contact_wrench_command_b).squeeze(-1)
# add gravity compensation (bias correction)
if self.cfg.gravity_compensation:
# check input is provided
if gravity is None:
raise ValueError("Gravity vector is required for gravity compensation.")
# add gravity compensation
joint_efforts += gravity
# Add null-space control
# -- Free null-space control
if self.cfg.nullspace_control == "none":
# No additional control is applied in the null space.
pass
else:
# Check if the system is redundant
if num_DoF <= 6:
raise ValueError("Null-space control is only applicable for redundant manipulators.")
# Calculate the pseudo-inverse of the Jacobian
if self.cfg.inertial_dynamics_decoupling and not self.cfg.partial_inertial_dynamics_decoupling:
# Dynamically consistent pseudo-inverse allows decoupling of null space and task space
if self._mass_matrix_inv is None or mass_matrix is None:
raise ValueError("Mass matrix inverse is required for dynamically consistent pseudo-inverse")
jacobian_pinv_transpose = self._os_mass_matrix_b @ jacobian_b @ self._mass_matrix_inv
else:
# Moore-Penrose pseudo-inverse if full inertia matrix is not available (e.g., no/partial decoupling)
jacobian_pinv_transpose = torch.pinverse(jacobian_b).mT
# Calculate the null-space projector
nullspace_jacobian_transpose = (
torch.eye(n=num_DoF, device=self._device) - jacobian_b.mT @ jacobian_pinv_transpose
)
# Null space position control
if self.cfg.nullspace_control == "position":
# Check if the current joint positions and velocities are provided
if current_joint_pos is None or current_joint_vel is None:
raise ValueError("Current joint positions and velocities are required for null-space control.")
# Calculate the joint errors for nullspace position control
if nullspace_joint_pos_target is None:
nullspace_joint_pos_target = torch.zeros_like(current_joint_pos)
# Check if the dimensions of the target nullspace joint positions match the current joint positions
elif nullspace_joint_pos_target.shape != current_joint_pos.shape:
raise ValueError(
f"The target nullspace joint positions shape '{nullspace_joint_pos_target.shape}' does not"
f"match the current joint positions shape '{current_joint_pos.shape}'."
)
joint_pos_error_nullspace = nullspace_joint_pos_target - current_joint_pos
joint_vel_error_nullspace = -current_joint_vel
# Calculate the desired joint accelerations
joint_acc_nullspace = (
self._nullspace_p_gain * joint_pos_error_nullspace
+ self._nullspace_d_gain * joint_vel_error_nullspace
).unsqueeze(-1)
# Calculate the projected torques in null-space
if mass_matrix is not None:
tau_null = (nullspace_jacobian_transpose @ mass_matrix @ joint_acc_nullspace).squeeze(-1)
else:
tau_null = nullspace_jacobian_transpose @ joint_acc_nullspace
# Add the null-space joint efforts to the total joint efforts
joint_efforts += tau_null
else:
raise ValueError(f"Invalid null-space control method: {self.cfg.nullspace_control}.")
return joint_efforts