You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: rigid-body dynamics for a serial chain (WP-04)
DynamicChain computes joint torques by recursive Newton-Euler and the
joint-space mass matrix by the composite-rigid-body algorithm. Both are O(n)
and O(n^2) respectively, allocation-free, non-throwing, and callable from the
cyclic task.
This closes a claim the repository has been making since its first commit. The
README headline and the CMake DESCRIPTION have both said "kinematics, dynamics,
trajectory generation and calibration" while src/core/ contained no dynamics.
That was defensible while the work sat behind a deadline; without one it is a
project describing itself inaccurately.
Everything stays in the base frame -- angular velocity, angular acceleration,
centre-of-mass acceleration, forces, moments and inertia tensors. The classical
formulation rotates each link's quantities into that link's own frame and saves
arithmetic doing it. The cost of that saving is that no intermediate value can
be read without first reconstructing which frame it lives in, and debugging
dynamics is mostly reading intermediate values. Here omega[3] is the angular
velocity of link 3 in the frame the machine is bolted to, which is the frame the
CAD model and the operator already use. It costs two 3x3 products per link to
rotate each inertia tensor, and the benchmark says that is not worth optimising.
Inertias are described the way joints already are, following ADR-0008: mass,
centre of mass and inertia tensor in the base frame at the zero configuration.
That is what a CAD package reports for an assembly. The alternative, a per-link
body frame, needs the link frames agreed first -- exactly what ADR-0008 declined
to require.
Gravity enters by initialising the base acceleration to -gravity, not by adding
a weight term to each link. This is the equivalence principle rather than a
trick: a base accelerating upward at 9.81 m/s^2 is indistinguishable from inside
from a base at rest in a field. One line at the top of the recursion replaces n
lines spread through the loop, and removes n chances to get a sign or a frame
wrong in a way that produces plausible torques in configurations nobody tested.
setGravity takes any vector, so an arm on a wall or hanging from a ceiling is
not a special case, and a test asserts that reversing the field reverses every
torque -- mounting orientation is normally discovered on site.
The mass matrix is computed by an algorithm that shares no derivation with the
recursion, and that is deliberate. It could have come out of inverseDynamics --
one call per joint with a unit acceleration, same complexity, about twenty lines
-- and it would have been correct. It would also have left the mass matrix with
no independent check: the property tests would confirm symmetry, which is then
guaranteed by construction rather than by correctness. Two derivations that
could not have made the same mistake give a figure instead of a tolerance:
CRBA vs RNEA, worst element 8.882e-16 kg m^2
about one unit in the last place. The gravity torque is checked from a third
direction again -- it is the gradient of the potential energy, and the potential
energy is a one-line sum over link heights sharing no code with the recursion.
Numerical differentiation reproduces it to 9.05e-09 N m, the truncation floor of
a central difference on a 1e-6 step. A one-link pendulum pins one case to
arithmetic somebody can check on paper.
No Coriolis matrix. inverseDynamics evaluates M qdd + C qd + g in one pass
without forming C, which is not unique anyway -- many matrices satisfy the
equation -- and costs more to build than the answer it helps compute. It appears
in textbooks because the matrix form is how the equation is analysed, not how it
is evaluated.
build() refuses an inertia tensor that is asymmetric, has a non-positive
principal moment, or violates the triangle inequality on its principal moments.
The third needs eigenvalues, so there is a closed-form symmetric 3x3 eigensolver
that exists for this alone. It earns that because an implausible inertia
produces plausible torques: nothing downstream fails, the arm merely needs
numbers no real machine would need, in poses nobody tested, and the error is
attributed to the controller. The trade is that a genuine point mass is also
refused -- the validator cannot tell a deliberate idealisation from a forgotten
field, and only one of those is common.
Not provided, and named rather than left to be discovered: forward dynamics,
which needs the mass matrix factorised and an integrator and belongs to a
simulator; joint friction; and motor rotor inertia, which reflected through a
high gear ratio is often comparable to the link inertia itself. Anyone using
this to predict actual motor torque needs to add it.
SerialChain gains linkTransforms(), exposed rather than duplicated inside
dynamics because the asymmetry it encodes -- a joint is carried by everything
upstream of it but not by itself -- is worth stating once rather than
rediscovering per caller.
Measured on the six-axis example: inverse dynamics 446 ns, gravity torque
429 ns, mass matrix 427 ns, each under 0.05% of a 1 kHz cycle and allocating
nothing. The O(n^2) mass matrix costs the same as the O(n) recursion at six
joints, because at that size the constant factors decide.
Two findings during verification. GCC at -O3 raised -Wnull-dereference on a
std::vector subscript in a test, unable to prove the buffer non-null from a
runtime joint count; fixed with fixed-size storage rather than a suppression,
since the array carries its size in the type and the question does not arise.
clang-tidy caught an unused <ranges> include -- std::ranges::all_of lives in
<algorithm>.
18 new tests; 157 total, green under GCC and Clang in Debug and Release,
ASan/UBSan and clang-tidy, 144 under TSan, with the documentation gate and the
installed-package consumer both extended to cover the new header.
**An inertia tensor that could not belong to a real body is refused.** Symmetric
262
+
and positive definite is not enough: the principal moments must also satisfy the
263
+
triangle inequality, because no distribution of mass makes one axis harder to
264
+
spin than the other two together. Checking that needs the eigenvalues, so there
265
+
is a closed-form symmetric 3×3 eigensolver that exists for this alone. It earns
266
+
its place because an implausible inertia produces *plausible* torques — nothing
267
+
downstream fails, the arm just needs numbers no real machine would, and the
268
+
error gets blamed on the controller.
269
+
239
270
**Multi-axis moves are driven by one path parameter, not one profile per axis.**
240
271
Planning each axis separately and stretching the quick ones does synchronise the
241
272
endpoints, and no axis exceeds a limit — and the path is still bent, because
@@ -305,8 +336,8 @@ test wrong.
305
336
|---|---|
306
337
| GCC + Clang × Debug + Release |`-Wconversion` and `-Wold-style-cast` fire on different constructs per compiler |
307
338
|`-Werror` with `-Wconversion -Wsign-conversion -Wold-style-cast -Wshadow`| Silent narrowing in a pose pipeline is a field failure, not a warning |
308
-
| ASan + UBSan on all 139 tests, `-fno-sanitize-recover=all`| A UBSan finding fails the build rather than printing a note |
309
-
| TSan on the 128 ordinary tests | Ahead of the threaded executor in WP-08; the eleven allocator-interposition tests are excluded because TSan defines the same global allocation hooks |
339
+
| ASan + UBSan on all 157 tests, `-fno-sanitize-recover=all`| A UBSan finding fails the build rather than printing a note |
340
+
| TSan on the 144 ordinary tests | Ahead of the threaded executor in WP-08; the thirteen allocator-interposition tests are excluded because TSan defines the same global allocation hooks |
310
341
| clang-tidy, `--warnings-as-errors=*`| Rule set and exclusions justified in ADR-0002 |
311
342
|`scripts/format.sh --check` with clang-format 18 | Formatting is not a review topic, and CI runs the same check developers run |
312
343
|**install with repository tests off + downstream consumer compile and run**| Exercises only the installed package contract; it caught a real bug on first run when the exported target was `motionkit::motionkit_core` but consumers used `motionkit::core`|
@@ -319,7 +350,7 @@ test wrong.
319
350
| Document | What it answers |
320
351
|---|---|
321
352
|[docs/architecture.md](docs/architecture.md)| C4 context, container and component views, and the rules that decide where new code goes |
322
-
|[docs/adr/](docs/adr/)|Nine decisions, each with the alternatives that lost and why |
353
+
|[docs/adr/](docs/adr/)|Ten decisions, each with the alternatives that lost and why |
323
354
|[CONTRIBUTING.md](CONTRIBUTING.md)| How to build, what the gates are, and the conventions clang-format cannot express |
324
355
|[docs/review-checklist.md](docs/review-checklist.md)| The questions that have actually caught something here |
325
356
|[CHANGELOG.md](CHANGELOG.md)| What changed, and what `0.x` promises |
@@ -354,7 +385,7 @@ Unit tests assert known values; the interesting ones assert **properties** over
354
385
thousands of uniformly sampled rotations from a fixed seed — a property test you
355
386
cannot replay is a flake, not a test.
356
387
357
-
Eleven allocation tests are instrumentation rather than ordinary unit tests. They
388
+
Thirteen allocation tests are instrumentation rather than ordinary unit tests. They
358
389
run in their own executable because their global `operator new`/`operator delete`
359
390
replacements affect an entire process. That target alone suppresses GNU's
360
391
`-Wmismatched-new-delete` diagnostic: the `malloc`/`free` pairing is deliberate
0 commit comments