Skip to content

Commit fafbb2a

Browse files
committed
feat: add jerk-limited trajectory planning (WP-05)
ScurveProfile plans a time-optimal rest-to-rest move under velocity, acceleration and jerk limits as seven constant-jerk segments. Short moves lose the cruise, shorter ones lose the acceleration plateaus as well; all three shapes come out of one construction with the unused segments at zero duration, so sampling costs the same wherever in the profile it lands. Sampling is O(1), noexcept and allocation-free, as is planning. SynchronizedTrajectory drives every axis from a single path parameter s: 0 -> 1. Planning each axis independently and stretching the faster ones does synchronise the endpoints and keeps every axis inside its limits, but it still bows the path because each axis retains its own profile shape and the ratios between them drift through the move. Measured on a two-axis move: 13.6 mm off the straight line, against 1.1e-16 for the path-parameter form. Two findings from the tests: A short move produced a cruise segment of about 1e-17 seconds because the cruise duration was computed from leftover distance in every case -- effectively asking for zero as the difference of two values agreeing to fifteen digits. Numerically harmless, but hasCruisePhase() then reported that the move cruised, and predicates are what callers branch on. Cruise presence is now decided by a closed-form comparison. Forward Euler at 1 kHz lags the commanded position by half a step of velocity -- 1.0 mm on a 2 m/s move -- and the error cancels to exactly zero by the end of a symmetric rest-to-rest profile. An acceptance test checking only final position therefore passes even though the machine was in the wrong place throughout the move. Both halves are measured; the header explicitly directs callers to sample the profile rather than integrate it. MotionLimits defaults to zeros and validate() rejects them: an unconfigured axis must be one that may not move, not one with no ceiling. Finiteness is checked before positivity because a NaN limit passes <= 0 and then passes every downstream bound check as well. Expected moves into its own header and names its error type explicitly, so the trajectory module can use it without depending on the frame graph. The allocation probe is renamed to test_no_allocation.cpp now that its coverage extends beyond the frame graph. Also fixes MOTIONKIT_BUILD_BENCHMARKS, which previously failed at configure time because add_subdirectory() pointed at an empty, untracked directory that was absent from fresh checkouts. Benchmarks now exist, CI builds and runs them, and the install-consumer job exercises the new headers so the installed package contract covers the trajectory API. As a supporting repository fix, line endings are pinned to LF so Windows checkouts can execute the project scripts. With core.autocrlf=true and no .gitattributes, scripts/format.sh acquired a /usr/bin/env bash\r shebang, making it unrunnable under both WSL and Git Bash while Linux CI remained unaffected. 31 new tests; 108 total, green under GCC and Clang in Debug and Release, ASan/UBSan, TSan and clang-tidy.
1 parent e283861 commit fafbb2a

16 files changed

Lines changed: 1795 additions & 41 deletions

.gitattributes

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Line endings are normalised to LF in the working tree, not only in the object
2+
# store. Without this, a Windows checkout with core.autocrlf=true gets CRLF
3+
# files, and `scripts/format.sh` -- whose shebang then reads `/usr/bin/env
4+
# bash\r` -- cannot be run at all from WSL or Git Bash. CI never sees it,
5+
# because CI checks out on Linux.
6+
* text=auto eol=lf

.github/workflows/ci.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,31 @@ jobs:
7878
UBSAN_OPTIONS: print_stacktrace=1
7979
run: ctest --preset ${{ matrix.preset }}
8080

81+
benchmarks:
82+
name: benchmarks build and run
83+
runs-on: ubuntu-24.04
84+
steps:
85+
- uses: actions/checkout@v4
86+
- name: Install toolchain
87+
run: sudo apt-get update && sudo apt-get install -y ninja-build
88+
89+
# An option nobody exercises stops working quietly. MOTIONKIT_BUILD_BENCHMARKS
90+
# spent its first months pointing add_subdirectory() at a directory that
91+
# was empty -- and therefore untracked, and therefore absent on a fresh
92+
# checkout -- so turning the option on failed at configure time.
93+
#
94+
# The numbers are not asserted on. A shared runner has no isolated cores
95+
# and no real-time scheduling, so a threshold here would be a flake
96+
# generator. This job proves the benchmark still builds and still runs to
97+
# completion; the figures in the README come from a known machine.
98+
- name: Configure with benchmarks on
99+
run: |
100+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMOTIONKIT_BUILD_BENCHMARKS=ON -DMOTIONKIT_BUILD_TESTS=OFF
101+
- name: Build
102+
run: cmake --build build -j "$(nproc)"
103+
- name: Run
104+
run: ./build/benchmarks/motionkit-bench
105+
81106
static-analysis:
82107
name: clang-tidy + clang-format
83108
runs-on: ubuntu-24.04
@@ -138,7 +163,10 @@ jobs:
138163
target_link_libraries(consumer PRIVATE motionkit::core)
139164
EOT
140165
cat > /tmp/consumer/main.cpp <<'EOT'
166+
#include <array>
167+
#include <cmath>
141168
#include "motionkit/core/frame_graph.hpp"
169+
#include "motionkit/core/trajectory.hpp"
142170
int main() {
143171
using namespace motionkit;
144172
const SE3 pose(SO3::rotZ(1.0), Vec3{1.0, 2.0, 3.0});
@@ -158,6 +186,23 @@ jobs:
158186
if (!resolved || !resolved.value.isApprox(pose, 1e-12, 1e-12)) {
159187
return 4;
160188
}
189+
// Every public header has to be reachable from the installed tree,
190+
// not just the one the first consumer happened to use.
191+
const std::array<Scalar, 2> here{0.0, 0.0};
192+
const std::array<Scalar, 2> there{1.0, 0.25};
193+
const std::array<MotionLimits, 2> limits{MotionLimits{2.0, 8.0, 40.0},
194+
MotionLimits{2.0, 8.0, 40.0}};
195+
const auto move = SynchronizedTrajectory::plan(here, there, limits);
196+
if (!move || move.value.duration() <= 0.0) {
197+
return 5;
198+
}
199+
std::array<MotionSample, 2> setpoints{};
200+
if (!move.value.sample(move.value.duration(), setpoints)) {
201+
return 6;
202+
}
203+
if (std::abs(setpoints[1].position - 0.25) > 1e-12) {
204+
return 7;
205+
}
161206
return 0;
162207
}
163208
EOT

CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ target_compile_options(motionkit_warnings INTERFACE
4444
add_library(motionkit_core
4545
src/core/so3.cpp
4646
src/core/se3.cpp
47-
src/core/frame_graph.cpp)
47+
src/core/frame_graph.cpp
48+
src/core/trajectory.cpp)
4849
add_library(motionkit::core ALIAS motionkit_core)
4950

5051
target_include_directories(motionkit_core

README.md

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ Eigen, no KDL, no Pinocchio — the algorithms are the point.
2020
| WP-02 | SE(3) transforms, frame graph, tool modeling | **Done** |
2121
| WP-03 | Forward and inverse kinematics (6R) | Planned |
2222
| WP-04 | Rigid-body dynamics (RNEA, CRBA) | Planned |
23-
| WP-05 | Trajectory planning (S-curve, TOPP, blending) | Planned |
23+
| WP-05 | Trajectory planning (jerk-limited S-curve, multi-axis synchronisation) | **Done** |
2424
| WP-06 | Hand-eye, TCP and base-frame calibration | Planned |
25+
| WP-11 | Blending, TOPP, and planning from a non-zero initial state | Planned |
2526
| WP-12 | CUDA batch IK and collision checking | Planned |
2627

27-
77 tests, all passing under GCC and Clang in Debug and Release. ASan and UBSan
28-
exercise the full suite. TSan exercises the 73 ordinary tests; the four
28+
108 tests, all passing under GCC and Clang in Debug and Release. ASan and UBSan
29+
exercise the full suite. TSan exercises the 101 ordinary tests; the seven
2930
allocator-interposition tests run in a dedicated executable and are excluded
3031
from TSan because both the tests and the sanitizer runtime replace the global
3132
allocation functions.
@@ -44,8 +45,8 @@ ctest --preset debug
4445
```
4546

4647
Other presets: `release`, `asan`, `tsan`, `tidy`. The `tsan` preset intentionally
47-
runs 73 tests: the four tests that instrument global allocation are a test-harness
48-
incompatibility with TSan, not an exemption for production code.
48+
runs 101 tests: the seven tests that instrument global allocation are a
49+
test-harness incompatibility with TSan, not an exemption for production code.
4950

5051
Before pushing, run the formatter -- CI enforces it:
5152

@@ -97,6 +98,26 @@ if (const auto camera_T_tcp = frames.lookup(camera, tcp)) {
9798
}
9899
```
99100

101+
Point-to-point motion is planned once and sampled every cycle. Limits are
102+
per-axis; the axes stay synchronised and travel a straight line in joint space:
103+
104+
```cpp
105+
#include "motionkit/core/trajectory.hpp"
106+
107+
const std::array<Scalar, 6> here{0.0, 0.0, 0.0, 0.0, 0.0, 0.0};
108+
const std::array<Scalar, 6> there{1.0, 0.2, -0.8, 0.4, 1.6, -0.3};
109+
const std::array<MotionLimits, 6> limits{/* v, a, j per axis */};
110+
111+
const auto move = SynchronizedTrajectory::plan(here, there, limits);
112+
if (!move) {
113+
return log(toString(move.error));
114+
}
115+
116+
// In the 1 kHz task: sample, never integrate.
117+
std::array<MotionSample, 6> setpoints{};
118+
move.value.sample(elapsed_seconds, setpoints);
119+
```
120+
100121
---
101122

102123
## Design decisions worth arguing about
@@ -148,6 +169,33 @@ counts allocations proves nothing if the counter is inert. Failures come back as
148169
yet calibrated — and `Disconnected` is deliberately a different answer from
149170
`UnknownFrame`.
150171

172+
**Multi-axis moves are driven by one path parameter, not one profile per axis.**
173+
Planning each axis separately and stretching the quick ones does synchronise the
174+
endpoints, and no axis exceeds a limit — and the path is still bent, because
175+
each axis keeps its own profile shape and the ratios between them drift through
176+
the move. Measured on a two-axis move: **13.6 mm** off the straight line, from a
177+
plan in which nothing was ever violated. Driving every axis from a single
178+
`s: 0 → 1` makes the ratios constant by construction; the same measurement comes
179+
back 1.1e-16. Whichever axis binds each limit runs exactly at it, which is what
180+
time-optimal means once the path is fixed. See
181+
[ADR-0006](docs/adr/0006-jerk-limited-profiles-and-a-single-path-parameter.md).
182+
183+
**Sample a trajectory; do not integrate it.** Forward Euler at 1 kHz lags the
184+
commanded position by half a step of velocity — 1.0 mm on a 2 m/s move. Then,
185+
because a rest-to-rest profile accelerates and decelerates by equal amounts, the
186+
error cancels to **exactly zero** by the end. An acceptance test that checks the
187+
final position passes, while the machine was in the wrong place for the entire
188+
move. `TrajectorySampling.EulerIntegrationLagsMidMoveThenLandsOnTargetAnyway`
189+
measures both halves.
190+
191+
**Unset limits mean the axis may not move.** `MotionLimits` defaults to zeros
192+
and `validate()` rejects them. Reading an unset limit as "no limit" makes
193+
forgetting to configure an axis indistinguishable from configuring it for full
194+
speed, and the difference is only observable on the machine. Finiteness is
195+
checked before positivity, because a NaN limit passes `<= 0` and then passes
196+
every bound check downstream too — comparisons against NaN are false however
197+
they are written.
198+
151199
**Euler angles are an export format, never a representation.**
152200
`toRPY` recovers pitch via `atan2(-m₂₀, hypot(m₀₀, m₁₀))` rather than
153201
`asin(-m₂₀)`, for the same conditioning reason — and tool-down poses sit exactly
@@ -167,8 +215,8 @@ test wrong.
167215
|---|---|
168216
| GCC + Clang × Debug + Release | `-Wconversion` and `-Wold-style-cast` fire on different constructs per compiler |
169217
| `-Werror` with `-Wconversion -Wsign-conversion -Wold-style-cast -Wshadow` | Silent narrowing in a pose pipeline is a field failure, not a warning |
170-
| ASan + UBSan on all 77 tests, `-fno-sanitize-recover=all` | A UBSan finding fails the build rather than printing a note |
171-
| TSan on the 73 ordinary tests | Ahead of the threaded executor in WP-08; the four allocator-interposition tests are excluded because TSan defines the same global allocation hooks |
218+
| ASan + UBSan on all 108 tests, `-fno-sanitize-recover=all` | A UBSan finding fails the build rather than printing a note |
219+
| TSan on the 101 ordinary tests | Ahead of the threaded executor in WP-08; the seven allocator-interposition tests are excluded because TSan defines the same global allocation hooks |
172220
| clang-tidy, `--warnings-as-errors=*` | Rule set and exclusions justified in ADR-0002 |
173221
| `scripts/format.sh --check` with clang-format 18 | Formatting is not a review topic, and CI runs the same check developers run |
174222
| **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` |
@@ -181,7 +229,7 @@ Unit tests assert known values; the interesting ones assert **properties** over
181229
thousands of uniformly sampled rotations from a fixed seed — a property test you
182230
cannot replay is a flake, not a test.
183231

184-
Four allocation tests are instrumentation rather than ordinary unit tests. They
232+
Seven allocation tests are instrumentation rather than ordinary unit tests. They
185233
run in their own executable because their global `operator new`/`operator delete`
186234
replacements affect an entire process. That target alone suppresses GNU's
187235
`-Wmismatched-new-delete` diagnostic: the `malloc`/`free` pairing is deliberate
@@ -197,6 +245,38 @@ and is the mechanism being tested. The warning remains enabled everywhere else.
197245

198246
---
199247

248+
## Benchmarks
249+
250+
The claim that these operations are callable from a cyclic task is only worth as
251+
much as the number, so there is a number. No google-benchmark: the library takes
252+
no third-party dependencies, and a benchmark you cannot build straight after
253+
cloning is a benchmark nobody runs.
254+
255+
```bash
256+
cmake -S . -B build/bench -DCMAKE_BUILD_TYPE=Release -DMOTIONKIT_BUILD_BENCHMARKS=ON
257+
cmake --build build/bench -j
258+
./build/bench/benchmarks/motionkit-bench
259+
```
260+
261+
Nanoseconds per call, GCC 15 `-O2`, ordinary desktop with no core isolation and
262+
no real-time scheduling:
263+
264+
| Operation | min | median | max | of a 1 kHz cycle |
265+
|---|---|---|---|---|
266+
| `SO3` composition | 46.1 | 46.3 | 95.3 | 0.005 % |
267+
| `FrameGraph::lookup`, tool to camera | 250.2 | 251.8 | 311.5 | 0.025 % |
268+
| `ScurveProfile::sample` | 4.0 | 4.4 | 11.7 | 0.0004 % |
269+
| `SynchronizedTrajectory::sample`, 6 axes | 8.2 | 8.6 | 15.1 | 0.001 % |
270+
| `SynchronizedTrajectory::plan`, 6 axes | 82.8 | 86.2 | 119.0 | 0.009 % |
271+
272+
The maximum column is dominated by whatever else the machine was doing, and is
273+
reported anyway: a control loop is sized by its worst cycle, not its median.
274+
The last row is the interesting one — planning a six-axis move costs less than a
275+
`FrameGraph` lookup, so a mid-move re-plan on a feed-rate override is something
276+
the cyclic task can do itself rather than hand to another thread.
277+
278+
---
279+
200280
## Licence
201281

202282
Apache-2.0.

benchmarks/CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Deliberately no google-benchmark. The library takes no third-party
2+
# dependencies, and a benchmark that pulls one in cannot be built by someone who
3+
# just cloned the repo -- which is when the numbers are wanted.
4+
add_executable(motionkit_bench bench_core.cpp)
5+
target_link_libraries(motionkit_bench PRIVATE motionkit::core motionkit::warnings)
6+
set_target_properties(motionkit_bench PROPERTIES OUTPUT_NAME motionkit-bench)

benchmarks/bench_core.cpp

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
//
3+
// Per-call cost of the operations this library claims are callable from a
4+
// cyclic control task. The claim is only worth as much as the number.
5+
//
6+
// Method: each operation runs in batches, and the batch is timed rather than
7+
// the call, because a steady_clock read costs more than most of the calls
8+
// measured here. Batches are repeated and the distribution reported, since the
9+
// median says what a cycle normally costs and the maximum says whether a cycle
10+
// can be missed.
11+
12+
#include <algorithm>
13+
#include <array>
14+
#include <chrono>
15+
#include <cstddef>
16+
#include <cstdio>
17+
#include <string>
18+
#include <vector>
19+
20+
#include "motionkit/core/frame_graph.hpp"
21+
#include "motionkit/core/trajectory.hpp"
22+
23+
namespace motionkit {
24+
namespace {
25+
26+
constexpr std::size_t kBatch = 1000;
27+
constexpr std::size_t kRepeats = 500;
28+
/// One cycle of a 1 kHz control task, for scale.
29+
constexpr double kCycleNanos = 1'000'000.0;
30+
31+
/// Somewhere for results to go that the optimiser cannot reason about.
32+
volatile Scalar g_sink = 0.0;
33+
34+
void barrier() {
35+
#if defined(__GNUC__) || defined(__clang__)
36+
asm volatile("" ::: "memory");
37+
#endif
38+
}
39+
40+
struct Result {
41+
double min_ns{0.0};
42+
double median_ns{0.0};
43+
double max_ns{0.0};
44+
};
45+
46+
template <typename F>
47+
Result measure(F&& body) {
48+
std::vector<double> per_call;
49+
per_call.reserve(kRepeats);
50+
51+
// Warm the caches and let the branch predictors settle; the first batch is
52+
// not what a control loop experiences.
53+
for (std::size_t i = 0; i < kBatch; ++i) {
54+
body(i);
55+
}
56+
57+
for (std::size_t r = 0; r < kRepeats; ++r) {
58+
barrier();
59+
const auto start = std::chrono::steady_clock::now();
60+
for (std::size_t i = 0; i < kBatch; ++i) {
61+
body(i);
62+
}
63+
const auto finish = std::chrono::steady_clock::now();
64+
barrier();
65+
const auto elapsed =
66+
std::chrono::duration_cast<std::chrono::nanoseconds>(finish - start).count();
67+
per_call.push_back(static_cast<double>(elapsed) / static_cast<double>(kBatch));
68+
}
69+
70+
std::sort(per_call.begin(), per_call.end());
71+
return Result{per_call.front(), per_call[per_call.size() / 2], per_call.back()};
72+
}
73+
74+
void report(const char* name, const Result& r) {
75+
std::printf(" %-38s %8.1f %9.1f %9.1f %7.4f%%\n", name, r.min_ns, r.median_ns,
76+
r.max_ns, 100.0 * r.median_ns / kCycleNanos);
77+
}
78+
79+
FrameGraph buildArm(std::array<FrameId, 3>& of_interest) {
80+
FrameGraph graph;
81+
graph.reserve(24);
82+
FrameId current = graph.declareRoot("base").value;
83+
for (int i = 0; i < 6; ++i) {
84+
const SE3 link(SO3::fromRPY(0.11, -0.07, 0.23), Vec3{0.13, -0.05, 0.21});
85+
current = graph.declareFrame("joint" + std::to_string(i), current, link).value;
86+
}
87+
const SE3 offset(SO3::fromRPY(0.0, 0.0, 0.4), Vec3{0.0, 0.0, 0.125});
88+
of_interest[0] = graph.declareFrame("tcp", current, offset).value;
89+
of_interest[1] = graph.declareFrame("camera", current, offset).value;
90+
of_interest[2] = current;
91+
return graph;
92+
}
93+
94+
} // namespace
95+
} // namespace motionkit
96+
97+
int main() {
98+
using namespace motionkit;
99+
100+
std::array<FrameId, 3> frames{};
101+
FrameGraph graph = buildArm(frames);
102+
103+
constexpr MotionLimits axis{2.0, 8.0, 40.0};
104+
const ScurveProfile profile = ScurveProfile::plan(0.0, 2.0, axis).value;
105+
106+
const std::array<Scalar, 6> start{0.0, 0.0, 0.0, 0.0, 0.0, 0.0};
107+
const std::array<Scalar, 6> goal{1.0, 0.2, -0.8, 0.4, 1.6, -0.3};
108+
const std::array<MotionLimits, 6> limits{axis, axis, axis, axis, axis, axis};
109+
const SynchronizedTrajectory arm =
110+
SynchronizedTrajectory::plan(start, goal, limits).value;
111+
std::array<MotionSample, 6> samples{};
112+
113+
std::printf("motionkit micro-benchmarks (nanoseconds per call)\n");
114+
std::printf(" %-38s %8s %9s %9s %8s\n", "operation", "min", "median", "max",
115+
"of 1 kHz");
116+
std::printf(" %s\n", std::string(78, '-').c_str());
117+
118+
report("SO3 composition", measure([&](std::size_t) {
119+
const SO3 r = SO3::fromRPY(0.1, 0.2, 0.3) * SO3::fromRPY(0.3, 0.2, 0.1);
120+
g_sink = r.w();
121+
}));
122+
123+
report("FrameGraph::lookup (tcp <- camera)", measure([&](std::size_t) {
124+
g_sink = graph.lookup(frames[0], frames[1]).value.translation().x;
125+
}));
126+
127+
report("FrameGraph::lookup (tcp <- base)", measure([&](std::size_t) {
128+
g_sink = graph.lookup(frames[0], frames[2]).value.translation().z;
129+
}));
130+
131+
report("ScurveProfile::sample", measure([&](std::size_t i) {
132+
const Scalar t = profile.duration() * static_cast<Scalar>(i % 1000) / 1000.0;
133+
g_sink = profile.sample(t).position;
134+
}));
135+
136+
report("SynchronizedTrajectory::sample (6 axes)", measure([&](std::size_t i) {
137+
const Scalar t = arm.duration() * static_cast<Scalar>(i % 1000) / 1000.0;
138+
(void)arm.sample(t, samples);
139+
g_sink = samples[0].position;
140+
}));
141+
142+
report("SynchronizedTrajectory::plan (6 axes)", measure([&](std::size_t i) {
143+
const Scalar factor = 0.5 + 0.0005 * static_cast<Scalar>(i % 1000);
144+
const std::array<MotionLimits, 6> scaled{
145+
limits[0].scaled(factor), limits[1].scaled(factor),
146+
limits[2].scaled(factor), limits[3].scaled(factor),
147+
limits[4].scaled(factor), limits[5].scaled(factor)};
148+
g_sink = SynchronizedTrajectory::plan(start, goal, scaled).value.duration();
149+
}));
150+
151+
std::printf(
152+
"\n Timings come from an ordinary desktop with no isolated cores and no\n"
153+
" real-time scheduling, so the maximum column is dominated by whatever\n"
154+
" else the machine was doing. It is reported anyway: a control loop is\n"
155+
" sized by its worst cycle, not its median.\n");
156+
return 0;
157+
}

0 commit comments

Comments
 (0)