DH parameters and product of exponentials cheat sheet
Standard DH, modified DH and the product-of-exponentials formula side by side, applied to the same three-joint arm, with NumPy code that shows all three give the same end-effector pose.
This sheet is for anyone who has a DH table from a datasheet and a screw-axis model from a textbook, and needs to know they describe the same arm. It puts the three common forward kinematics conventions side by side, applies each to one three-joint arm, and checks numerically that they agree. Background is in forward kinematics. Glossary: DH parameters, product of exponentials, homogeneous transformation.
The three conventions
| Standard DH | Modified DH | Product of exponentials (space frame) | |
|---|---|---|---|
| Parameters per joint | theta_i, d_i, a_i, alpha_i | alpha_(i-1), a_(i-1), theta_i, d_i | Screw axis S_i = (omega_i, v_i), 6 numbers |
| Link transform | Rz(theta) Tz(d) Tx(a) Rx(alpha) | Rx(alpha) Tx(a) Rz(theta) Tz(d) | exp([S_i] theta_i) |
| Frame i sits on | The distal end of link i, on joint axis i+1 | The proximal end of link i, on joint axis i | No link frames. Only base and tool |
| Joint axis is | z of frame i-1 | z of frame i | omega_i, written in the base frame at the home pose |
| Tool pose | Last row of the table, or an extra fixed transform | Needs an extra fixed transform after the last joint | M, the tool pose with all joints at zero |
| Full chain | T = A_1 A_2 … A_n | T = T_1 T_2 … T_n T_tool | T = exp([S_1] q_1) … exp([S_n] q_n) M |
| Parallel or near-parallel axes | Common normal ill-defined, small errors in axes give large parameter jumps | Same problem | No special case |
| Prismatic joint | d_i is the variable | d_i is the variable | omega = 0, v = unit direction |
Read the table header of any DH table you are given before you use it. A modified table run through standard code produces a plausible but wrong arm.
The example arm
Three revolute joints. Joint 1 turns about the vertical z0 axis at the base. Joints 2 and 3 are parallel and turn about the horizontal axis -y0 at the home pose, so positive angles lift the arm. Shoulder height D1 = 0.30 m, upper arm A2 = 0.40 m, forearm A3 = 0.35 m. At the home pose (all joints at zero) the arm points straight along x0.
| Joint | Standard DH: theta, d, a, alpha | Modified DH: alpha_(i-1), a_(i-1), theta, d | Space screw axis: omega, point q on axis, v = -omega x q |
|---|---|---|---|
| 1 | q1, 0.30, 0, +pi/2 | 0, 0, q1, 0.30 | (0, 0, 1), (0, 0, 0), (0, 0, 0) |
| 2 | q2, 0, 0.40, 0 | +pi/2, 0, q2, 0 | (0, -1, 0), (0, 0, 0.30), (0.30, 0, 0) |
| 3 | q3, 0, 0.35, 0 | 0, 0.40, q3, 0 | (0, -1, 0), (0.40, 0, 0.30), (0.30, 0, -0.40) |
| Tool | Included in row 3 | Fixed Tx(0.35) after joint 3 | M: rotation Rx(+pi/2), position (0.75, 0, 0.30) |
The standard table ends at the tool because a_3 carries the forearm length. The modified table ends at joint 3, so the forearm length has to go into a separate tool transform. That asymmetry is the most common reason two tables for the same arm disagree in the last row.
Standard and modified DH in NumPy
import numpy as np
D1, A2, A3 = 0.30, 0.40, 0.35 # base height and link lengths (m)
def rot_x(a: float) -> np.ndarray:
c, s = np.cos(a), np.sin(a)
return np.array([[1, 0, 0, 0], [0, c, -s, 0], [0, s, c, 0], [0, 0, 0, 1.0]])
def rot_z(a: float) -> np.ndarray:
c, s = np.cos(a), np.sin(a)
return np.array([[c, -s, 0, 0], [s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1.0]])
def trans(x: float = 0.0, y: float = 0.0, z: float = 0.0) -> np.ndarray:
t = np.eye(4)
t[:3, 3] = [x, y, z]
return t
def dh_standard(theta: float, d: float, a: float, alpha: float) -> np.ndarray:
return rot_z(theta) @ trans(z=d) @ trans(x=a) @ rot_x(alpha)
def dh_modified(alpha_prev: float, a_prev: float, theta: float, d: float) -> np.ndarray:
return rot_x(alpha_prev) @ trans(x=a_prev) @ rot_z(theta) @ trans(z=d)
def fk_standard(q: np.ndarray) -> np.ndarray:
table = [(q[0], D1, 0.0, np.pi / 2), (q[1], 0.0, A2, 0.0), (q[2], 0.0, A3, 0.0)]
t = np.eye(4)
for row in table:
t = t @ dh_standard(*row)
return t
def fk_modified(q: np.ndarray) -> np.ndarray:
table = [(0.0, 0.0, q[0], D1), (np.pi / 2, 0.0, q[1], 0.0), (0.0, A2, q[2], 0.0)]
t = np.eye(4)
for row in table:
t = t @ dh_modified(*row)
return t @ trans(x=A3) # fixed tool offset after the last joint
q = np.array([0.4, 0.7, -1.1])
print(np.round(fk_standard(q), 4))
print("standard == modified:", np.allclose(fk_standard(q), fk_modified(q)))
Product of exponentials in NumPy
The closed-form exponential of a unit revolute screw comes from Rodrigues’ formula for the rotation, plus a matching term for the translation. The block checks it against scipy.linalg.expm.
exp([S] t) = [ R(t) G(t) v ] R(t) = I + sin(t) W + (1 - cos(t)) W^2
[ 0 1 ] G(t) = I t + (1 - cos(t)) W + (t - sin(t)) W^2
W = skew(omega), |omega| = 1
import numpy as np
from scipy.linalg import expm
D1, A2, A3 = 0.30, 0.40, 0.35 # same arm as the DH block (m)
def skew(w: np.ndarray) -> np.ndarray:
return np.array([[0, -w[2], w[1]], [w[2], 0, -w[0]], [-w[1], w[0], 0.0]])
def twist_matrix(s: np.ndarray) -> np.ndarray:
m = np.zeros((4, 4))
m[:3, :3] = skew(s[:3])
m[:3, 3] = s[3:]
return m
def exp_twist(s: np.ndarray, theta: float) -> np.ndarray:
"""Closed-form exponential of a unit revolute screw (omega, v)."""
w, v = s[:3], s[3:]
k = skew(w)
rot = np.eye(3) + np.sin(theta) * k + (1 - np.cos(theta)) * k @ k
g = np.eye(3) * theta + (1 - np.cos(theta)) * k + (theta - np.sin(theta)) * k @ k
t = np.eye(4)
t[:3, :3], t[:3, 3] = rot, g @ v
return t
def revolute_screw(w: list[float], q_point: list[float]) -> np.ndarray:
w, q_point = np.array(w, float), np.array(q_point, float)
return np.concatenate([w, -np.cross(w, q_point)])
# Space-frame screw axes at the home pose (all joint angles zero)
S = [revolute_screw([0, 0, 1], [0, 0, 0]),
revolute_screw([0, -1, 0], [0, 0, D1]),
revolute_screw([0, -1, 0], [A2, 0, D1])]
M = np.array([[1, 0, 0, A2 + A3], [0, 0, -1, 0], [0, 1, 0, D1], [0, 0, 0, 1.0]]) # tool pose at home
def fk_poe_space(q: np.ndarray) -> np.ndarray:
t = np.eye(4)
for s_i, q_i in zip(S, q):
t = t @ exp_twist(s_i, q_i)
return t @ M
q = np.array([0.4, 0.7, -1.1])
t = fk_poe_space(q)
print(np.round(t, 4))
print("closed form == expm:", np.allclose(exp_twist(S[1], 0.7), expm(twist_matrix(S[1]) * 0.7)))
Both blocks print the same matrix for q = (0.4, 0.7, -1.1) rad.
An independent check over 500 poses
This block uses the Robotics Toolbox for Python’s RevoluteDH and RevoluteMDH classes for the two DH conventions. It compares both against a PoE chain built with expm.
import numpy as np
import roboticstoolbox as rtb
from scipy.linalg import expm
D1, A2, A3 = 0.30, 0.40, 0.35
std = rtb.DHRobot([rtb.RevoluteDH(d=D1, alpha=np.pi / 2), rtb.RevoluteDH(a=A2), rtb.RevoluteDH(a=A3)], name="std")
mod = rtb.DHRobot([rtb.RevoluteMDH(d=D1), rtb.RevoluteMDH(alpha=np.pi / 2), rtb.RevoluteMDH(a=A2)], name="mod")
mod.tool = np.array([[1, 0, 0, A3], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1.0]])
def hat(s: np.ndarray) -> np.ndarray:
w, v = s[:3], s[3:]
return np.array([[0, -w[2], w[1], v[0]], [w[2], 0, -w[0], v[1]], [-w[1], w[0], 0, v[2]], [0, 0, 0, 0.0]])
S = np.array([[0, 0, 1, 0, 0, 0], [0, -1, 0, D1, 0, 0], [0, -1, 0, D1, 0, -A2]], float) # (omega, v)
M = np.array([[1, 0, 0, A2 + A3], [0, 0, -1, 0], [0, 1, 0, D1], [0, 0, 0, 1.0]])
def poe(q: np.ndarray) -> np.ndarray:
t = np.eye(4)
for s, qi in zip(S, q):
t = t @ expm(hat(s) * qi)
return t @ M
rng = np.random.default_rng(0)
worst = 0.0
for q in rng.uniform(-np.pi, np.pi, size=(500, 3)):
t_poe = poe(q)
worst = max(worst, np.abs(std.fkine(q).A - t_poe).max(), np.abs(mod.fkine(q).A - t_poe).max())
print(f"max difference over 500 random poses: {worst:.2e}")
Body-frame PoE
The same arm can be written with screw axes B_i expressed in the tool frame at the home pose. Then T = M exp([B_1] q_1) … exp([B_n] q_n). The two sets are related by B_i = Ad(M^-1) S_i. Use the space form when you know where the axes are in the world. Use the body form when the tool frame is your reference, as in many Jacobian-based IK loops.
Converting a DH table to screw axes
- Set every joint to zero and compute each DH frame with the table.
- For joint i, take omega_i as the z axis of the frame the joint turns about (frame i-1 for standard DH, frame i for modified), expressed in the base frame.
- Take q_i as that frame’s origin, then v_i = -omega_i x q_i.
- M is the full DH chain at zero, including any tool transform.
The reverse direction is not unique. A PoE model does not fix where link frames sit, so many DH tables match one set of screws.
Pitfalls
- A datasheet may use a joint zero different from the DH zero. The table then has a theta offset, which you must add before you evaluate the chain.
- Units: DH tables in datasheets often give lengths in mm and angles in degrees. Convert both before use.
- Two parallel axes (joints 2 and 3 here) make the DH common normal arbitrary along the axis. Two consecutive tables can both be right and still differ in d.
- A screw axis has unit omega for a revolute joint. A non-unit omega silently scales the joint angle.
- Check any hand-written model against a second source (a toolbox, MuJoCo or the vendor’s FK) at several random poses, as the last block does. One matching pose proves little.
Further reading
- K. M. Lynch and F. C. Park, Modern Robotics: Mechanics, Planning, and Control, Cambridge University Press, 2017, doi:10.1017/9781316661239. Chapter 4 covers PoE forward kinematics, and Appendix C covers DH parameters.
- Rotation representations for the rotation half of each transform.
- Inverse kinematics and the kinematics hub.