Repository navigation
Fix SpatialForce/SpatialMomentum SE(3) transform to use the coadjoint (Ad^-T) - #207
Conversation
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 rai-opensource#169.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| return SpatialForce(left.Ad().T @ right.A) | ||
| # A force is dual to motion, so it transforms by the coadjoint | ||
| # (inverse-transpose of the adjoint), not the adjoint transpose. | ||
| return SpatialForce(np.linalg.inv(left.Ad()).T @ right.A) |
There was a problem hiding this comment.
A modest (8%) speed increase if you compute adjoint of inv(T).
petercorke
left a comment
There was a problem hiding this comment.
Thanks for picking this up. Clear description, and a clear fix. 2 minor things which you might want to tweak:
- fix the table lines 274-293 to match your change
- consider the speedup in the code comment
|
Nice catch, and a well-constructed fix. Verified the physics independently rather than just checking your one case: instantaneous power f·v must be frame-invariant, so I ran it across 500 random SE3.Rand() transforms (not just the pure-translation case above) — before this fix that invariant is violated, after it the worst error over all 500 is 8.9e-16, i.e. exact to machine precision. Full test suite passes too (344 tests, no regressions). Good instinct using the power invariant as the test oracle rather than a hand-computed expected value — that's the right way to pin this down, and citing Featherstone directly makes the reasoning easy to check. Appreciate the disclosure on AI assistance + your own review too. Left two non-blocking notes inline — a docs accuracy nit (the class docstring's own table still says "adjoint transpose product" for Force/Momentum, which is now stale) and an optional computation-path suggestion for the coadjoint. Neither affects correctness of what's here. Approving — this is good to merge as-is; the inline notes are take-it-or-leave-it. |
…nt path The class and module tables still described the SpatialForce/SpatialMomentum product as an "adjoint transpose product", which the previous commit made inaccurate; they now say coadjoint, and the __rmul__ docstring names it as the inverse-transpose of the adjoint. Ad(X)^-1 == Ad(X^-1), so the coadjoint is now built from the adjoint of the inverse pose rather than by inverting the 6x6 adjoint. Measured identical to the previous expression to 1.3e-15 over 500 random SE3.Rand() and 200 Twist3 samples, and modestly faster.
|
Thanks for verifying it independently across 500 random poses rather than just re-running my one case — the frame-invariance check over the full SE(3) group is a stronger statement than anything I put in the PR, and it is good to see the residual land at machine precision. I have taken both inline notes. The docstring nit was a real hole and I am glad you caught it: the stale wording was in three places, not one. Both the module-level and class-level multiplicand tables listed the Force/Momentum rows as "adjoint transpose product", and the On the computation path, you were right and my first measurement was not. A crude timing loop said your suggestion was slower; a proper I took Before switching I confirmed the two expressions agree: max absolute difference 1.3e-15 over 500 Re-ran Disclosure: I use AI assistance in my work, and I review and verify everything before it goes out. The measurements above are my own. |
|
Thank you for your interest in SMTB, and for this fix! Confirmed and merged -- the coadjoint transform for |
… direction (#691) Robot.rne()'s backward recursion propagated force through Xup[j], the child<-parent motion transform, but force needs to go the other way (child->parent) and so needs the other direction's transform too. This was silently masked by a bug in spatialmath-python's SE3 * SpatialForce (adjoint transpose instead of the coadjoint, fixed in spatialmath-python 1.1.18 / rai-opensource/spatialmath-python#207) that happened to cancel it out for this specific case. With that upstream bug fixed, the direction mismatch here was exposed: URDF/ETS-based robots' rne() gave wrong torques for any joint with a translational offset from its parent (i.e. virtually all real robots), while DHRobot's compiled-C++ rne() path (which never touches spatialmath's SE3/SpatialForce classes) was unaffected throughout. Verified against the Puma560 DHRobot reference torques, the Spong textbook 2-link closed-form (test_invdyn), and the symbolic RNE path (test_symdyn, whose expected string is regenerated here -- Q1 is unchanged, Q0 differs only by an unsimplified sin^2+cos^2 term introduced by the added .inv()). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Twist3.__mul__ raised a hard ValueError for any right operand it didn't directly recognize, instead of returning NotImplemented -- so Python never got the chance to fall back to right.__rmul__(left). This made Twist3 * SpatialVelocity/Acceleration/Force/Momentum unconditionally fail, even though SpatialVector.__rmul__ explicitly documents supporting Twist3 alongside SE3. Found while independently verifying rai-opensource#207 (SpatialForce/Momentum coadjoint fix): that PR's Twist3 code path is correct but was untestable through normal `*` syntax because of this bug.
…210) ## Summary - `Twist3.__mul__` raised a hard `ValueError` for any right operand it didn't directly recognize (`Twist3`, `SE3`, scalar), instead of returning `NotImplemented`. That meant Python never fell back to `right.__rmul__(left)`, so `Twist3 * SpatialVelocity` / `SpatialAcceleration` / `SpatialForce` / `SpatialMomentum` all failed unconditionally -- even though `SpatialVector.__rmul__` explicitly documents supporting `Twist3` alongside `SE3` (`isinstance(left, (SE3, Twist3))`). - Found while independently verifying #207 (`SpatialForce`/`SpatialMomentum` coadjoint fix): that PR's `Twist3` code path (`SpatialForce.__rmul__`'s own override) is mathematically correct but was untestable through normal `*` syntax because of this bug -- it only worked via `SE3 *`. - Fix: return `NotImplemented` for unrecognized right operands instead of raising. Genuinely unsupported types (e.g. `Twist3(...) * "hello"`) still fail, now with Python's standard `TypeError` rather than a custom `ValueError`. - Updates `Twist3.__mul__`'s own docstring table, which didn't mention `SpatialVector` as a supported right operand at all. - Fills in the `tests/test_spatialvector.py::test_products` stub (previously just `pass`, with a comment literally noting "twist x v, twist x a, twist x F" as untested) -- checks `Twist3 * X` agrees with the equivalent `SE3 * X` for all four `SpatialVector` subclasses. ## Test plan - [x] New test fails on unfixed code: `ValueError: twist *, incorrect right operand` - [x] Passes after the fix, for all of `SpatialVelocity`/`SpatialAcceleration`/`SpatialForce`/`SpatialMomentum` - [x] Confirmed genuinely unsupported types still fail, now with `TypeError` (Python's standard) instead of the old custom `ValueError` - [x] Grepped `tests/` for anything asserting the old `ValueError` -- nothing depends on it - [x] `pytest tests/` -- full suite, 342 passed, no regressions
Fixes #169.
Problem
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 transposeAd^T. Because the SE(3) adjoint is non-orthogonal whenever the translation is nonzero,Ad^T != Ad^-T, soSpatialForceandSpatialMomentumwere transformed incorrectly for any pose with translation (correct only for a pure rotation, whereAdis orthogonal). Velocity/acceleration (motion), which correctly useAd, were fine.Repro / self-oracle
Instantaneous power
f . vis frame-invariant, so it must be preserved by the transform:The transformed wrench's moment component came out with the wrong sign;
f2should be[0, 0, 1, 0, -1, 0]but was[0, 0, 1, 0, 1, 0].Fix
Use
np.linalg.inv(Ad).T(the coadjoint) inSpatialForce.__rmul__and in the force/momentum branch of the generalSpatialVector.__rmul__(whichSpatialMomentumuses). The motion branch is unchanged.Test
The
test_productsstub intests/test_spatialvector.pywas empty, so these transforms were untested. Addstest_spatial_transform_dualityasserting the power invariance (f2 . v2 == f . v, convention-independent) plus the explicit transformed wrench and momentum. Red before the fix (power2 != 0), green after; the existing motion tests are unaffected.Reported by @Yuxiang-Ma, who cited Featherstone's Rigid Body Dynamics Algorithms.
This change was prepared with AI assistance and reviewed by me before submission.