#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Utilities Module - ManipulaPy
This module contains essential utility functions for working with rigid body motions,
transformations, and related operations in robotic manipulation. Functions cover
screw theory, matrix operations, Lie algebra computations, time scaling, and
coordinate transformations for kinematics and dynamics calculations.
The functions in this module support:
- Extracting and manipulating screw vectors and twists
- Computing transformation matrices from twists and joint angles
- Matrix logarithms and exponentials for SE(3) and SO(3)
- Converting between rotation matrices and Euler angles
- Skew-symmetric matrix operations
- Time scaling functions for trajectory generation
Copyright (c) 2025 Mohamed Aboelnasr
This file is part of ManipulaPy.
ManipulaPy is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
ManipulaPy is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with ManipulaPy. If not, see <https://www.gnu.org/licenses/>.
"""
from typing import Optional, Tuple
import numpy as np
from numpy.typing import NDArray
from scipy.linalg import expm
[docs]
def NearZero(z: float) -> bool:
"""
Determines if a given number is near zero.
Parameters:
z (float): The number to check.
Returns:
bool: True if the number is near zero, False otherwise.
"""
return abs(z) < 1e-6
[docs]
def skew_symmetric(v) -> NDArray[np.float64]:
"""
Returns the skew symmetric matrix of a 3D vector.
Parameters:
v (array-like): A 3D vector.
Returns:
np.ndarray: The corresponding skew symmetric matrix.
"""
return np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]])
[docs]
def logm(T) -> NDArray[np.float64]:
"""
Computes the logarithm of a transformation matrix.
Parameters:
T (np.ndarray): A 4x4 transformation matrix.
Returns:
np.ndarray: The logarithm of the transformation matrix.
"""
R = T[0:3, 0:3]
p = T[0:3, 3]
omega, theta = rotation_logm(R)
if theta < 1e-6:
return np.hstack((np.zeros(3), p))
G_inv = (
1 / theta * np.eye(3)
- 0.5 * skew_symmetric(omega)
+ (1 / theta - 0.5 / np.tan(theta / 2))
* np.dot(skew_symmetric(omega), skew_symmetric(omega))
)
v = theta * np.dot(G_inv, p)
return np.hstack((omega * theta, v))
[docs]
def rotation_logm(R) -> Tuple[NDArray[np.float64], float]:
"""
Computes the logarithm of a rotation matrix.
Parameters:
R (np.ndarray): A 3x3 rotation matrix.
Returns:
tuple: A tuple containing the rotation vector and the angle.
"""
# Validate input shape
R = np.asarray(R)
if R.shape != (3, 3):
raise ValueError(
f"rotation_logm requires a 3x3 rotation matrix, got shape {R.shape}. "
f"Matrix:\n{R}"
)
# Ensure we're computing with scalar values
trace_val = np.trace(R)
# Clamp to [-1, 1] to avoid numerical issues with arccos
cos_theta = np.clip((trace_val - 1) / 2, -1.0, 1.0)
theta = np.arccos(cos_theta)
# Convert to Python scalar (handles numpy scalars, 0-d arrays, and 1-d arrays)
try:
theta_scalar = float(theta)
except (TypeError, ValueError):
# Handle cases where theta is an array
theta_arr = np.asarray(theta).flatten()
if theta_arr.size == 0:
theta_scalar = 0.0
else:
theta_scalar = float(theta_arr[0])
# Check if rotation is very small (identity or near-identity)
if theta_scalar < 1e-6:
return np.zeros(3), theta_scalar
elif theta_scalar > np.pi - 1e-6:
# theta ~ pi: R is (near-)symmetric so R - R^T -> 0 while 1/(2 sin theta)
# blows up, collapsing the generic formula to the zero vector. Extract
# the axis from the most positive diagonal term instead (mirrors
# MatrixLog3 / Modern Robotics). The threshold is kept tight (1e-6):
# the generic formula stays accurate until extremely close to pi, and
# the diagonal extraction assumes exactly theta = pi, so widening the
# band would trade a smaller floating-point error for a larger
# axis-approximation error.
if not NearZero(1 + R[2, 2]):
omega = (1.0 / np.sqrt(2 * (1 + R[2, 2]))) * np.array(
[R[0, 2], R[1, 2], 1 + R[2, 2]]
)
elif not NearZero(1 + R[1, 1]):
omega = (1.0 / np.sqrt(2 * (1 + R[1, 1]))) * np.array(
[R[0, 1], 1 + R[1, 1], R[2, 1]]
)
else:
omega = (1.0 / np.sqrt(2 * (1 + R[0, 0]))) * np.array(
[1 + R[0, 0], R[1, 0], R[2, 0]]
)
return omega, theta_scalar
else:
omega = (
1
/ (2 * np.sin(theta_scalar))
* np.array([R[2, 1] - R[1, 2], R[0, 2] - R[2, 0], R[1, 0] - R[0, 1]])
)
return omega, theta_scalar
[docs]
def logm_to_twist(logm) -> NDArray[np.float64]:
"""
Convert the logarithm of a transformation matrix to a twist vector.
Parameters:
logm (np.ndarray): The logarithm of a transformation matrix.
Returns:
np.ndarray: The corresponding twist vector.
"""
if logm.shape != (4, 4):
raise ValueError("logm must be a 4x4 matrix.")
omega_matrix = logm[0:3, 0:3]
omega = skew_symmetric_to_vector(omega_matrix)
v = logm[0:3, 3]
return np.hstack((omega, v))
[docs]
def skew_symmetric_to_vector(skew_symmetric) -> NDArray[np.float64]:
"""
Convert a skew-symmetric matrix to a vector.
Parameters:
skew_symmetric (np.ndarray): A 3x3 skew-symmetric matrix.
Returns:
np.ndarray: The corresponding vector.
"""
return np.array([skew_symmetric[2, 1], skew_symmetric[0, 2], skew_symmetric[1, 0]])
[docs]
def se3ToVec(se3_matrix) -> NDArray[np.float64]:
"""
Convert an se(3) matrix to a twist vector.
Parameters:
se3_matrix (np.ndarray): A 4x4 matrix from the se(3) Lie algebra.
Returns:
np.ndarray: A 6-dimensional twist vector.
"""
if se3_matrix.shape != (4, 4):
raise ValueError("Input matrix must be a 4x4 matrix.")
omega = np.array([se3_matrix[2, 1], se3_matrix[0, 2], se3_matrix[1, 0]])
v = se3_matrix[0:3, 3]
return np.hstack((omega, v))
[docs]
def TransToRp(T) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
"""
Converts a homogeneous transformation matrix into a rotation matrix and position vector.
Parameters:
T (np.ndarray): A 4x4 transformation matrix.
Returns:
tuple: A tuple containing the rotation matrix and position vector.
"""
R = T[0:3, 0:3]
p = T[0:3, 3]
return R, p
[docs]
def TransInv(T) -> NDArray[np.float64]:
"""
Inverts a homogeneous transformation matrix.
Parameters:
T (np.ndarray): A 4x4 transformation matrix.
Returns:
np.ndarray: The inverse of the transformation matrix.
"""
R, p = TransToRp(T)
Rt = R.T
return np.vstack((np.hstack((Rt, -Rt @ p.reshape(-1, 1))), [0, 0, 0, 1]))
[docs]
def MatrixLog6(T) -> NDArray[np.float64]:
"""
Compute the matrix logarithm of a given transformation matrix T.
Parameters:
T (np.ndarray): The transformation matrix of shape (4, 4).
Returns:
np.ndarray: The matrix logarithm of T, with shape (4, 4).
"""
R, p = TransToRp(T)
omega, theta = rotation_logm(R)
# Pure translation or extremely small rotation
if abs(theta) < 1e-6:
return np.vstack(
(np.hstack((np.zeros((3, 3)), p.reshape(-1, 1))), [0, 0, 0, 0])
)
# Use the standard G^{-1}(theta) from Modern Robotics (Eq. 3.88):
# G_inv = I/theta - 0.5*w_hat + (1/theta - 0.5*cot(theta/2)) * w_hat^2
w_hat = skew_symmetric(omega)
G_inv = (
(np.eye(3) / theta)
- 0.5 * w_hat
+ (1 / theta - 0.5 / np.tan(theta / 2)) * (w_hat @ w_hat)
)
# se(3) log has w_hat*theta in the rotation block
omega_mat_scaled = theta * w_hat
v = theta * (G_inv @ p)
return np.vstack((np.hstack((omega_mat_scaled, v.reshape(-1, 1))), [0, 0, 0, 0]))
[docs]
def MatrixExp6(se3mat) -> NDArray[np.float64]:
"""
Computes the matrix exponential of a matrix in se(3).
Parameters:
se3mat (np.ndarray): A 4x4 matrix representing a twist in se(3).
Returns:
np.ndarray: The corresponding 4x4 transformation matrix in SE(3).
"""
if se3mat.shape != (4, 4):
raise ValueError("Input matrix must be of shape (4, 4)")
# Extract rotation (so(3)) and translation components
omega_theta_vec = np.array([se3mat[2, 1], se3mat[0, 2], se3mat[1, 0]])
v = se3mat[0:3, 3]
theta = np.linalg.norm(omega_theta_vec)
# Pure translation case
if theta < 1e-6:
T = np.eye(4)
T[:3, 3] = v
return T
# Unit rotation axis hat matrix
omega_hat = se3mat[0:3, 0:3] / theta
# Rotation component
R = MatrixExp3(se3mat[0:3, 0:3])
# Compute the matrix G(theta) that maps body velocity to translation
G = (
np.eye(3) * theta
+ (1 - np.cos(theta)) * omega_hat
+ (theta - np.sin(theta)) * (omega_hat @ omega_hat)
)
T = np.eye(4)
T[:3, :3] = R
T[:3, 3] = (G @ v) / theta
return T
[docs]
def MatrixLog3(R) -> NDArray[np.float64]:
"""
Computes the matrix logarithm of a rotation matrix.
Parameters:
R (np.ndarray): A 3x3 rotation matrix.
Returns:
np.ndarray: The matrix logarithm of the rotation matrix.
"""
acosinput = (np.trace(R) - 1) / 2.0
if acosinput >= 1:
return np.zeros((3, 3))
elif acosinput <= -1:
if not NearZero(1 + R[2][2]):
omega = (1.0 / np.sqrt(2 * (1 + R[2][2]))) * np.array(
[R[0][2], R[1][2], 1 + R[2][2]]
)
elif not NearZero(1 + R[1][1]):
omega = (1.0 / np.sqrt(2 * (1 + R[1][1]))) * np.array(
[R[0][1], 1 + R[1][1], R[2][1]]
)
else:
omega = (1.0 / np.sqrt(2 * (1 + R[0][0]))) * np.array(
[1 + R[0][0], R[1][0], R[2][0]]
)
return VecToso3(np.pi * omega)
else:
theta = np.arccos(acosinput)
return theta / 2.0 / np.sin(theta) * (R - np.array(R).T)
[docs]
def VecToso3(omega) -> NDArray[np.float64]:
"""
Converts a 3D angular velocity vector to a skew-symmetric matrix.
Parameters:
omega (array-like): A 3D angular velocity vector.
Returns:
np.ndarray: The corresponding skew-symmetric matrix.
"""
return np.array(
[[0, -omega[2], omega[1]], [omega[2], 0, -omega[0]], [-omega[1], omega[0], 0]]
)
[docs]
def VecTose3(V) -> NDArray[np.float64]:
"""
Converts a spatial velocity vector to an se(3) matrix.
Parameters:
V (array-like): A 6D spatial velocity vector.
Returns:
np.ndarray: The corresponding 4x4 matrix in se(3).
"""
return np.r_[np.c_[VecToso3(V[:3]), V[3:].reshape(3, 1)], np.zeros((1, 4))]
[docs]
def MatrixExp3(so3mat) -> NDArray[np.float64]:
"""
Computes the matrix exponential of a matrix in so(3).
Parameters:
so3mat (np.ndarray): A 3x3 skew-symmetric matrix.
Returns:
np.ndarray: The corresponding 3x3 rotation matrix in SO(3).
"""
return expm(so3mat)
[docs]
def CubicTimeScaling(Tf: float, t: float) -> float:
"""
Compute the cubic time scaling factor.
Parameters:
Tf (float): The total time of the motion.
t (float): The current time.
Returns:
float: The cubic time scaling factor.
"""
return 3 * (t / Tf) ** 2 - 2 * (t / Tf) ** 3
[docs]
def QuinticTimeScaling(Tf: float, t: float) -> float:
"""
Compute the quintic time scaling factor.
Parameters:
Tf (float): The total time of the motion.
t (float): The current time.
Returns:
float: The quintic time scaling factor.
"""
return 10 * (t / Tf) ** 3 - 15 * (t / Tf) ** 4 + 6 * (t / Tf) ** 5
[docs]
def rotation_matrix_to_euler_angles(R) -> NDArray[np.float64]:
"""
Convert a rotation matrix to Euler angles (roll, pitch, yaw).
Parameters:
R (numpy.ndarray): A 3x3 rotation matrix.
Returns:
numpy.ndarray: A 3-element array representing the Euler angles (roll, pitch, yaw).
"""
assert R.shape == (3, 3), f"Expected 3x3 rotation matrix, got shape {R.shape}"
sy = np.sqrt(R[0, 0] ** 2 + R[1, 0] ** 2)
singular = sy < 1e-6
if not singular:
x = np.arctan2(R[2, 1], R[2, 2])
y = np.arctan2(-R[2, 0], sy)
z = np.arctan2(R[1, 0], R[0, 0])
else:
x = np.arctan2(-R[1, 2], R[1, 1])
y = np.arctan2(-R[2, 0], sy)
z = 0
return np.array([x, y, z])
[docs]
def euler_to_rotation_matrix(euler_deg) -> NDArray[np.float64]:
"""
Convert Euler angles (roll_deg, pitch_deg, yaw_deg) in degrees
to a 3x3 rotation matrix.
ZYX convention is typical in robotics: yaw -> pitch -> roll,
but adapt as needed.
Parameters:
euler_deg (array-like): [roll_deg, pitch_deg, yaw_deg]
Returns:
np.ndarray: A 3x3 rotation matrix (float64 by default).
"""
roll_deg, pitch_deg, yaw_deg = euler_deg
# Convert degrees to radians
roll = np.radians(roll_deg)
pitch = np.radians(pitch_deg)
yaw = np.radians(yaw_deg)
# Example Z-Y-X convention (yaw→pitch→roll).
# If your code uses X→Y→Z or another sequence, adapt these multiplications.
Rz = np.array(
[[np.cos(yaw), -np.sin(yaw), 0], [np.sin(yaw), np.cos(yaw), 0], [0, 0, 1]],
dtype=np.float64,
)
Ry = np.array(
[
[np.cos(pitch), 0, np.sin(pitch)],
[0, 1, 0],
[-np.sin(pitch), 0, np.cos(pitch)],
],
dtype=np.float64,
)
Rx = np.array(
[[1, 0, 0], [0, np.cos(roll), -np.sin(roll)], [0, np.sin(roll), np.cos(roll)]],
dtype=np.float64,
)
# Multiply in the correct order for your convention.
R = Rz @ Ry @ Rx
return R