Skip to content

Commit 7668d4f

Browse files
committed
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.
1 parent 9897cf8 commit 7668d4f

15 files changed

Lines changed: 1328 additions & 13 deletions

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ jobs:
165165
cat > /tmp/consumer/main.cpp <<'EOT'
166166
#include <array>
167167
#include <cmath>
168+
#include "motionkit/core/dynamics.hpp"
168169
#include "motionkit/core/frame_graph.hpp"
169170
#include "motionkit/core/kinematics.hpp"
170171
#include "motionkit/core/trajectory.hpp"
@@ -227,6 +228,21 @@ jobs:
227228
if (!stop || std::abs(stop.value.stoppingDistance() - 0.3) > 1e-9) {
228229
return 11;
229230
}
231+
// The dynamics header. Holding the example arm against gravity must
232+
// take a non-zero torque somewhere -- an all-zero result would mean
233+
// the installed inertias were lost rather than exported.
234+
const DynamicChain loaded = DynamicChain::sixAxisExample();
235+
std::array<Scalar, 6> torque{};
236+
if (loaded.gravityTorque(joints, torque) != DynamicsError::None) {
237+
return 12;
238+
}
239+
Scalar effort = 0.0;
240+
for (const Scalar t : torque) {
241+
effort += std::abs(t);
242+
}
243+
if (!(effort > 1e-6)) {
244+
return 13;
245+
}
230246
return 0;
231247
}
232248
EOT

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ bump as "something may have moved".
3131

3232
### Added
3333

34+
- **Rigid-body dynamics** (WP-04). `DynamicChain` computes joint torques by
35+
recursive Newton-Euler and the joint-space mass matrix by the
36+
composite-rigid-body algorithm, both allocation-free and callable from a
37+
cyclic task. Gravity is a settable field vector, so an arm on a wall or a
38+
ceiling is not a special case. Inertia tensors that could not belong to a
39+
real body are refused at construction rather than producing plausible wrong
40+
torques. ([ADR-0010](docs/adr/0010-dynamics-in-the-base-frame.md))
41+
- **`SerialChain::linkTransforms`** — where each link has been carried at a
42+
given configuration. Added because dynamics needs it, and exposed rather than
43+
duplicated because the asymmetry it encodes (a joint is carried by everything
44+
upstream of it but not by itself) is worth stating once.
3445
- **Forward and inverse kinematics for serial chains** (WP-03). `SerialChain`
3546
describes revolute joints by an axis and a point rather than DH parameters,
3647
computes forward kinematics as a product of exponentials, and solves the

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ add_library(motionkit_core
4646
src/core/so3.cpp
4747
src/core/se3.cpp
4848
src/core/frame_graph.cpp
49+
src/core/dynamics.cpp
4950
src/core/kinematics.cpp
5051
src/core/trajectory.cpp)
5152
add_library(motionkit::core ALIAS motionkit_core)

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Two caveats worth knowing before you spend an afternoon on them:
2020

2121
- **TSan excludes the allocation tests.** They replace global `operator new`,
2222
which is exactly what TSan's runtime also does. `MOTIONKIT_BUILD_ALLOCATION_TESTS`
23-
is off under that preset, so the count is 128 rather than 139.
23+
is off under that preset, so the count is 144 rather than 157.
2424
- **`ScurveProfile` is rest-to-rest.** Planning to a position from a non-zero
2525
velocity is WP-12 and not implemented. `StopProfile` does start from an
2626
arbitrary state, which is a different problem.

README.md

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@ Eigen, no KDL, no Pinocchio — the algorithms are the point.
1919
| WP-01 | Build system, CI, static analysis, install/export | **Done** |
2020
| WP-02 | SE(3) transforms, frame graph, tool modeling | **Done** |
2121
| WP-03 | Forward and inverse kinematics (6R) | **Done** |
22-
| WP-04 | Rigid-body dynamics (RNEA, CRBA) | Planned |
22+
| WP-04 | Rigid-body dynamics (RNEA, CRBA) | **Done** |
2323
| WP-05 | Trajectory planning (jerk-limited S-curve, multi-axis synchronisation) | **Done** |
2424
| WP-11 | Stopping from an arbitrary state, and the safety envelope it defines | **Done** |
2525
| WP-06 | Hand-eye, TCP and base-frame calibration | Planned |
2626
| WP-12 | Blending and TOPP (needs a position target from a non-zero state) | Planned |
2727
| WP-12 | CUDA batch IK and collision checking | Planned |
2828
| WP-15 | API reference, architecture docs, contribution process | **Done** |
2929

30-
139 tests, all passing under GCC and Clang in Debug and Release. ASan and UBSan
31-
exercise the full suite. TSan exercises the 128 ordinary tests; the eleven
30+
157 tests, all passing under GCC and Clang in Debug and Release. ASan and UBSan
31+
exercise the full suite. TSan exercises the 144 ordinary tests; the thirteen
3232
allocator-interposition tests run in a dedicated executable and are excluded
3333
from TSan because both the tests and the sanitizer runtime replace the global
3434
allocation functions.
@@ -47,7 +47,7 @@ ctest --preset debug
4747
```
4848

4949
Other presets: `release`, `asan`, `tsan`, `tidy`. The `tsan` preset intentionally
50-
runs 128 tests: the eleven tests that instrument global allocation are a
50+
runs 144 tests: the thirteen tests that instrument global allocation are a
5151
test-harness incompatibility with TSan, not an exemption for production code.
5252

5353
Before pushing, run the formatter -- CI enforces it:
@@ -236,6 +236,37 @@ allocates nothing, which is 0.23 % of a 1 kHz cycle, so it can run in the loop
236236
that needs the answer rather than on a thread with a queue in front of it. See
237237
[ADR-0008](docs/adr/0008-kinematics-by-screws-and-a-damped-inverse.md).
238238

239+
**The mass matrix is computed twice, by two algorithms that share no derivation.**
240+
It could have come out of the recursive Newton-Euler code already written — one
241+
call per joint with a unit acceleration — for about twenty lines. The
242+
composite-rigid-body algorithm was written separately anyway, propagating the
243+
momentum of each frozen distal subtree. The two share the link frames and
244+
nothing else, so each is a test of the other, and the agreement is not a
245+
tolerance anybody chose: **8.882e-16 kg·m²**, about one unit in the last place.
246+
A hand-written expected matrix would only have proved that the author and the
247+
implementation multiplied the same way.
248+
249+
**Gravity enters as an acceleration of the base, not as a weight on each link.**
250+
A base accelerating upward at 9.81 m/s² is indistinguishable from inside from a
251+
base at rest in a gravitational field, so one line at the top of the recursion
252+
replaces a term in every link's force balance — and removes `n` chances to get a
253+
sign wrong in configurations nobody tested. It also makes an arm on a wall or a
254+
ceiling free rather than special: `setGravity` takes any vector, and a test
255+
asserts that reversing the field reverses every torque. The gravity torque is
256+
checked against a third derivation entirely — it is the gradient of the
257+
potential energy, which is a one-line sum over link heights sharing no code with
258+
the recursion, and the two agree to **9.05e-09 N·m**. See
259+
[ADR-0010](docs/adr/0010-dynamics-in-the-base-frame.md).
260+
261+
**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+
239270
**Multi-axis moves are driven by one path parameter, not one profile per axis.**
240271
Planning each axis separately and stretching the quick ones does synchronise the
241272
endpoints, and no axis exceeds a limit — and the path is still bent, because
@@ -305,8 +336,8 @@ test wrong.
305336
|---|---|
306337
| GCC + Clang × Debug + Release | `-Wconversion` and `-Wold-style-cast` fire on different constructs per compiler |
307338
| `-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 |
310341
| clang-tidy, `--warnings-as-errors=*` | Rule set and exclusions justified in ADR-0002 |
311342
| `scripts/format.sh --check` with clang-format 18 | Formatting is not a review topic, and CI runs the same check developers run |
312343
| **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.
319350
| Document | What it answers |
320351
|---|---|
321352
| [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 |
323354
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to build, what the gates are, and the conventions clang-format cannot express |
324355
| [docs/review-checklist.md](docs/review-checklist.md) | The questions that have actually caught something here |
325356
| [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
354385
thousands of uniformly sampled rotations from a fixed seed — a property test you
355386
cannot replay is a flake, not a test.
356387

357-
Eleven allocation tests are instrumentation rather than ordinary unit tests. They
388+
Thirteen allocation tests are instrumentation rather than ordinary unit tests. They
358389
run in their own executable because their global `operator new`/`operator delete`
359390
replacements affect an entire process. That target alone suppresses GNU's
360391
`-Wmismatched-new-delete` diagnostic: the `malloc`/`free` pairing is deliberate
@@ -398,6 +429,9 @@ no real-time scheduling:
398429
| `SerialChain::forward`, 6R | 175.7 | 176.6 | 527.7 | 0.018 % |
399430
| `SerialChain::jacobian`, 6R | 417.4 | 427.2 | 1029.7 | 0.043 % |
400431
| `SerialChain::inverse`, 6R seeded | 2209.1 | 2319.8 | 7499.1 | 0.232 % |
432+
| `DynamicChain::inverseDynamics`, 6R | 415.6 | 445.6 | 4967.7 | 0.045 % |
433+
| `DynamicChain::gravityTorque`, 6R | 400.3 | 428.6 | 1038.0 | 0.043 % |
434+
| `DynamicChain::massMatrix`, 6R CRBA | 399.8 | 427.0 | 1050.9 | 0.043 % |
401435

402436
The maximum column is dominated by whatever else the machine was doing, and is
403437
reported anyway: a control loop is sized by its worst cycle, not its median.

benchmarks/bench_core.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <string>
1818
#include <vector>
1919

20+
#include "motionkit/core/dynamics.hpp"
2021
#include "motionkit/core/frame_graph.hpp"
2122
#include "motionkit/core/kinematics.hpp"
2223
#include "motionkit/core/trajectory.hpp"
@@ -181,6 +182,27 @@ int main() {
181182
g_sink = static_cast<Scalar>(solved.value.iterations);
182183
}));
183184

185+
const DynamicChain dyn6 = DynamicChain::sixAxisExample();
186+
const std::array<Scalar, 6> rate{0.5, 0.2, -0.4, 0.9, -0.1, 0.3};
187+
const std::array<Scalar, 6> accel{-0.2, 1.1, 0.6, -0.7, 0.4, 0.8};
188+
std::array<Scalar, 6> torque{};
189+
std::array<Scalar, 36> mass{};
190+
191+
report("DynamicChain::inverseDynamics (6R)", measure([&](std::size_t) {
192+
(void)dyn6.inverseDynamics(pose, rate, accel, torque);
193+
g_sink = torque[0];
194+
}));
195+
196+
report("DynamicChain::gravityTorque (6R)", measure([&](std::size_t) {
197+
(void)dyn6.gravityTorque(pose, torque);
198+
g_sink = torque[0];
199+
}));
200+
201+
report("DynamicChain::massMatrix (6R, CRBA)", measure([&](std::size_t) {
202+
(void)dyn6.massMatrix(pose, mass);
203+
g_sink = mass[0];
204+
}));
205+
184206
std::printf(
185207
"\n Timings come from an ordinary desktop with no isolated cores and no\n"
186208
" real-time scheduling, so the maximum column is dominated by whatever\n"

0 commit comments

Comments
 (0)