Skip to content

Commit a319c45

Browse files
committed
Fix SpatialForce/SpatialMomentum SE(3) transform to use the coadjoint
A spatial force/wrench is dual to spatial motion, so under an SE(3) transform it must map by the coadjoint Ad^-T (inverse-transpose of the adjoint), not the adjoint transpose Ad^T. Because the SE(3) adjoint is non-orthogonal whenever the translation is nonzero, Ad^T != Ad^-T, so SpatialForce/SpatialMomentum were transformed incorrectly for any pose with translation (correct only for a pure rotation, where Ad is orthogonal). For example T = SE3(1, 0, 0) acting on a unit z-force and a y-angular-velocity breaks the invariance of instantaneous power (f . v), which must be frame-independent: the current code gives 0 in one frame and 2 in the other. Use np.linalg.inv(Ad).T in SpatialForce.__rmul__ and in the force/momentum branch of the general SpatialVector.__rmul__. Motion (velocity/acceleration), which correctly uses Ad, is unchanged. Add a regression test to the previously-empty test_products stub asserting the power invariance and the explicit transformed wrench/momentum. Fixes #169.
1 parent fff7009 commit a319c45

2 files changed

Lines changed: 30 additions & 2 deletions

File tree

‎spatialmath/spatialvector.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,10 @@ def __rmul__(
287287
if isinstance(right, SpatialM6):
288288
return right.__class__(X @ right.A)
289289
else:
290-
return right.__class__(X.T @ right.A)
290+
# Force/momentum are dual to motion and transform by the
291+
# coadjoint (inverse-transpose of the adjoint), not the
292+
# adjoint transpose. These are only equal for a pure rotation.
293+
return right.__class__(np.linalg.inv(X).T @ right.A)
291294
else:
292295
raise TypeError("left operand of * must be SE3 or Twist3")
293296

@@ -487,7 +490,9 @@ def __rmul__(
487490
right, left
488491
): # lgtm[py/not-named-self] pylint: disable=no-self-argument
489492
# Twist * SpatialForce -> SpatialForce
490-
return SpatialForce(left.Ad().T @ right.A)
493+
# A force is dual to motion, so it transforms by the coadjoint
494+
# (inverse-transpose of the adjoint), not the adjoint transpose.
495+
return SpatialForce(np.linalg.inv(left.Ad()).T @ right.A)
491496

492497

493498
# ------------------------------------------------------------------------- #

‎tests/test_spatialvector.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,29 @@ def test_products(self):
228228
# twist x v, twist x a, twist x F
229229
pass
230230

231+
def test_spatial_transform_duality(self):
232+
# A pose transform acts on motion (velocity/acceleration) by the
233+
# adjoint Ad, and on force/momentum by the coadjoint Ad^-T, because
234+
# force space is dual to motion space (Featherstone, "Rigid Body
235+
# Dynamics Algorithms"). With a nonzero translation the SE(3) adjoint
236+
# is not orthogonal, so Ad^-T differs from the adjoint transpose Ad^T.
237+
T = SE3(1, 0, 0)
238+
# ordering is [v(0:3); omega(3:6)] (see SE3.Ad docstring)
239+
v = SpatialVelocity([0, 0, 0, 0, 1, 0])
240+
f = SpatialForce([0, 0, 1, 0, 0, 0])
241+
v2, f2 = T * v, T * f
242+
243+
# Instantaneous power f . v is frame-invariant; this is the self-oracle
244+
# and is independent of the [v; omega] vs [omega; v] convention.
245+
nt.assert_almost_equal(f2.A @ v2.A, f.A @ v.A)
246+
247+
# Explicit transformed wrench: the moment component flips sign.
248+
nt.assert_almost_equal(f2.A, np.r_[0, 0, 1, 0, -1, 0])
249+
250+
# SpatialMomentum exercises the general SpatialVector.__rmul__ path.
251+
m2 = T * SpatialMomentum([0, 0, 1, 0, 0, 0])
252+
nt.assert_almost_equal(m2.A, np.r_[0, 0, 1, 0, -1, 0])
253+
231254

232255
# ---------------------------------------------------------------------------------------#
233256
if __name__ == "__main__":

0 commit comments

Comments
 (0)