Minimum-jerk and time-optimal joint trajectories under velocity, acceleration, and jerk limits.
Three profiles, the same move, the same axes. The bottom row is the whole argument: the trapezoid appears to have no jerk because its jerk is an impulse the plot cannot draw, the S-curve pays for a bounded jerk with four saturated blocks and 0.1500 s, and the minimum-jerk curve buys smoothness with 0.6938 s. This library generates all three from the same inputs, measures them with the same instrument, and proves the measurement rather than sampling it.
A motion controller is handed a start configuration, a goal configuration, and per-joint bounds on
velocity, acceleration, and jerk, and has to pick a profile. uv run python examples/profile_comparison.py answers that for joint 0 moving from 0.0 to 1.5 rad under a velocity
limit of 1.2 rad/s, an acceleration limit of 3.0 rad/s squared, and a jerk limit of 20.0 rad/s
cubed, sampled at 4001 points:
| Profile | Duration (s) | Peak velocity | Peak acceleration | Peak jerk | Integrated squared jerk |
|---|---|---|---|---|---|
| trapezoidal | 1.6500 | 1.2000 | 3.0000 | unbounded | 0.0000 |
| s-curve | 1.8000 | 1.2000 | 3.0000 | 20.0000 | 240.3600 |
| minimum-jerk | 2.3438 | 1.2000 | 1.5766 | 6.9905 | 22.9065 |
| Profile | Duration (s) | Time cost against the fastest | Jerk cost against the lowest |
|---|---|---|---|
| trapezoidal | 1.6500 | fastest | unbounded |
| s-curve | 1.8000 | +0.1500 s, +9.1 percent | 10.49 times |
| minimum-jerk | 2.3438 | +0.6938 s, +42.0 percent | 1.00 times |
Read those two tables together and the decision is made. Take the trapezoid when the axis has no real jerk limit and 0.15 s matters, take the S-curve when it does, and take the minimum-jerk quintic when the drive train, not the clock, is the thing being protected.
The trapezoid's reported peak jerk of 0.0000 is not a bound. Its acceleration steps discontinuously at each of its two phase changes, so its jerk is an impulse there and zero everywhere else, and 0.0000 is the value between the impulses. That is why the table names it unbounded rather than printing a number, and why it should not be selected for an axis with a jerk limit.
The S-curve figure of 240.3600 deserves the same honesty. Its analytic value is the squared jerk
limit times the total time spent at that limit, 400 * 4 * 0.15 = 240.0 exactly. The reported
240.3600 is a Simpson quadrature of an integrand that is piecewise constant and discontinuous, so
the quadrature error falls only as the reciprocal of the sample count rather than as its fourth
power: passing --samples 40001, 400001 and 4000001 gives 240.0360, 240.0036 and 240.0004. It
is a comparison quantity, not an exact figure. The minimum-jerk cost has no such caveat, because its
jerk is continuous: 720 d^2 / T^5 = 720 * 2.25 / 2.34375^5 = 22.9065, which the test suite checks
against the closed form directly.
How much of each budget the three profiles actually consume:
| Profile | Velocity used | Acceleration used | Jerk used |
|---|---|---|---|
| trapezoidal | 1.0000 | 1.0000 | 0.0000 |
| s-curve | 1.0000 | 1.0000 | 1.0000 |
| minimum-jerk | 1.0000 | 0.5255 | 0.3495 |
The S-curve is the only profile that saturates all three bounds at once. The minimum-jerk profile is velocity limited and leaves most of the acceleration and jerk budget unspent, which is the price of minimising the jerk cost rather than the time.
These ratios are not read off the sampling grid. They are the exact peaks of each piecewise
polynomial, solved segment by segment, so a peak that falls between two samples cannot hide from
them. The example prints No limit is exceeded at any instant, solved in closed form rather than a
statement about sample points, and it means it.
Requires Python 3.12 or later.
git clone https://github.com/Eelis03/trajectory-optimizer.git
cd trajectory-optimizer
uv syncUsing pip instead of uv:
python -m venv .venv
.venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"The package ships a py.typed marker, so it delivers its annotations to anything that installs it.
from trajectory_optimizer import (
LimitSet,
PointToPointMove,
certify_limits,
minimum_jerk_time_optimal,
sample_trajectory,
)
move = PointToPointMove.rest_to_rest(start=(0.0,), end=(1.5,))
limits = LimitSet.uniform(dof=1, max_velocity=1.2, max_acceleration=3.0, max_jerk=20.0)
trajectory = minimum_jerk_time_optimal(move, limits)
report = certify_limits(trajectory, limits)
print(f"duration {trajectory.duration:.5f} s")
print(f"peak velocity {trajectory.peak_derivatives().row('velocity')[0]:.5f} rad/s")
print(f"within limits everywhere: {report.ok}")duration 2.34375 s
peak velocity 1.20000 rad/s
within limits everywhere: True
Swapping minimum_jerk_time_optimal for trapezoidal_move or s_curve_move changes nothing else,
because every generator satisfies the same Trajectory protocol. sample_trajectory turns any of
them into a dense trace when a figure or a quadrature is what is wanted.
Runnable examples live in examples/:
uv run python examples/profile_comparison.py
uv run python examples/spline_via_points.py
uv run python examples/time_optimal_scaling.py
uv run python examples/multi_joint_sync.pyEach script accepts --samples for the sample count, --output for the figure path, and --dpi
for its resolution.
Every number here is the output of the command shown above it, at its default settings, on Python 3.12.10 with numpy 2.5.1, scipy 1.18.0, and matplotlib 3.11.1. The profile comparison that opens this page is produced by the same run.
uv run python examples/spline_via_points.py, with a two joint sequence through (0.0, 0.0),
(0.4, -0.2), (1.0, 0.5), (0.6, 0.9) at times 0.0, 1.0, 2.5, and 4.0 s, sampled at 4001 points:
| Spline | Duration (s) | Peak velocity | Peak acceleration | Peak jerk | Integrated squared jerk |
|---|---|---|---|---|---|
| cubic natural | 4.0000 | 0.5858 | 0.9297 | 0.9297 | 3.2041 |
| cubic clamped | 4.0000 | 0.6320 | 1.1789 | 2.3368 | 11.4782 |
| quintic natural | 4.0000 | 0.7435 | 0.7910 | 0.6734 | 0.8916 |
| quintic clamped | 4.0000 | 0.7963 | 1.1450 | 5.8625 | 20.9853 |
| Spline | Max via point error | Start velocity | End velocity |
|---|---|---|---|
| cubic natural | 1.110e-16 | +0.3640, -0.3550 | -0.4468, +0.1586 |
| cubic clamped | 1.110e-16 | -0.0000, +0.0000 | +0.0000, -0.0000 |
| quintic natural | 5.551e-16 | +0.3182, -0.5894 | -0.7435, -0.0486 |
| quintic clamped | 3.331e-16 | +0.0000, -0.0000 | +0.0000, -0.0000 |
Every spline reproduces every via point to within floating point rounding, so the position panel of the figure separates them barely at all and the tables of position error say nothing interesting. The jerk panel is where the choice shows. The natural quintic has the lowest jerk cost of the four, 0.8916, which is expected rather than lucky: setting the third and fourth derivatives to zero at both ends is the variational boundary condition of the interpolant that minimises integrated squared jerk, so the natural quintic spline is the minimum-jerk interpolant of these via points. Clamping the ends to rest raises the cost to 11.4782 and 20.9853, because forcing zero end velocity onto a sequence whose natural motion is nonzero at the ends concentrates jerk near the boundaries, which is the excursion visible at both edges of the bottom panel. The peak acceleration and peak jerk of the natural cubic spline coincide at 0.9297 for this particular data; the value is confirmed against an independent implementation in the test suite.
Once the path is fixed rather than the endpoints, the remaining freedom is the timing along it.
uv run python examples/time_optimal_scaling.py. Case 1 scales a clamped cubic spline through four
three joint via points, nominal duration 4.0 s, to limits of 1.0 rad/s, 2.0 rad/s squared, and
10.0 rad/s cubed:
time scale factor: 1.021694
scaled duration: 4.086778 s
bisection steps: 35
peak source: closed form
active limits: acceleration
| Joint | Limit | Interval (s) | Segments | Peak | Allowed |
| --- | --- | --- | --- | --- | --- |
| 2 | acceleration | 4.0868 to 4.0868 | 2 | 2.0000 | 2.0000 |
| Joint | Velocity used | Acceleration used | Jerk used |
|---|---|---|---|
| 0 | 0.5989 | 0.5647 | 0.1202 |
| 1 | 0.6186 | 0.5647 | 0.2191 |
| 2 | 0.8282 | 1.0000 | 0.3020 |
The figure is the honest picture of what uniform time scaling buys and what it does not. Exactly one constraint binds, the acceleration of joint 2, and it binds at exactly one instant at the very end of the move, which is the single point where the green acceleration trace touches minus 2.0. Every other budget is left partly unused. A globally time-optimal trajectory would ride some constraint almost everywhere instead. Uniform scaling cannot, because it applies one factor at every point of the path; the distinction and what closing it would require are set out in docs/design-notes.md.
Case 2 takes an S-curve generated for limits of 2.0, 6.0, and 60.0, nominal duration 1.183333 s, and retimes it for limits of 1.2, 3.0, and 20.0:
time scale factor: 1.666667
scaled duration: 1.972222 s
bisection steps: 34
peak source: closed form
active limits: velocity
| Joint | Limit | Interval (s) | Segments | Peak | Allowed |
| --- | --- | --- | --- | --- | --- |
| 0 | velocity | 0.7090 to 1.2632 | 2, 3, 4 | 1.2000 | 1.2000 |
Limit usage after scaling: velocity 1.0000, acceleration 0.7200, jerk 0.6480. Here the binding constraint is held across segments 2, 3, and 4 rather than touched once, because the cruise phase of an S-curve is an extended arc at constant velocity. The script also cross-checks the bisection against the closed-form minimum-jerk duration for the same move: both give 2.343750000 s, with a difference of 0.000e+00 s.
uv run python examples/multi_joint_sync.py, with six joints given individual displacements and
individual limits, each first profiled with its own time-optimal S-curve:
| Joint | Displacement | Unsynchronised duration (s) | Stretch factor |
|---|---|---|---|
| 0 | +1.50 | 1.800000 | 1.0000 |
| 1 | -0.80 | 1.338889 | 1.3444 |
| 2 | +0.35 | 0.739780 | 2.4332 |
| 3 | +2.10 | 1.575000 | 1.1429 |
| 4 | +0.00 | 0.000000 | held |
| 5 | -0.05 | 0.510951 | 3.5228 |
The synchronised duration is 1.800000 s, set by joint 0. The largest start velocity magnitude across all joints is 0.000e+00 rad/s, the largest end velocity magnitude is 5.551e-17 rad/s, and the largest final position error is 2.220e-16 rad. Joint 4 has zero displacement, so it is held at its position for the common duration rather than scaled, which is the degenerate case that would otherwise divide by a zero duration.
| Joint | Velocity used | Acceleration used | Jerk used |
|---|---|---|---|
| 0 | 1.0000 | 1.0000 | 1.0000 |
| 1 | 0.7438 | 0.5533 | 0.4115 |
| 2 | 0.2431 | 0.1689 | 0.0694 |
| 3 | 0.8750 | 0.7656 | 0.6699 |
| 4 | 0.0000 | 0.0000 | 0.0000 |
| 5 | 0.0694 | 0.0806 | 0.0229 |
The slowest joint still uses its full budget, and no other joint exceeds any bound, which is the invariant that makes synchronisation by stretching safe: dividing every derivative by a factor greater than one cannot create a violation.
Every generator returns a piecewise polynomial, one polynomial per time segment. Trapezoidal profiles are quadratic per segment, S-curve profiles are cubic, and minimum-jerk moves and quintic spline segments are quintic, so a single container covers all of them and position, velocity, acceleration, and jerk all come from differentiating one coefficient table rather than from finite differences.
Point to point moves use the closed-form quintic that minimises integrated squared jerk, following Flash and Hogan (1985): the Euler-Lagrange condition for that cost is a vanishing sixth derivative, so the extremal is a fifth degree polynomial and the six boundary values determine its six coefficients without any numerical optimisation. The trapezoidal and seven segment S-curve profiles follow the case analysis of Biagiotti and Melchiorri (2008), including the degenerate branches in which the cruise velocity or the acceleration limit cannot be reached. Spline interpolation uses the moment formulation of de Boor (2001) for the cubic case, solved as a banded system, and a knot velocity and acceleration formulation for the quintic case, imposing continuity of the third and fourth derivatives at every interior knot.
Peaks are solved for, not sampled. On one segment each derivative is a polynomial, so its largest
magnitude over that closed segment is attained either at a segment boundary or at a root of its own
derivative, and both sets are available exactly. peak_derivatives collects that finite candidate
set over every segment and joint, certify_limits turns it into a limit report that holds at every
instant, and the time scaling search uses it for its feasibility predicate. That matters because the
peaks that matter often sit at irrational fractions of the duration: the acceleration peak of a
rest-to-rest minimum-jerk move is at t / T = (1 - 1 / sqrt(3)) / 2, which no uniform grid contains.
Time-optimal time scaling holds the geometric path fixed and searches for the smallest stretch
factor whose scaled derivatives all fit inside their bounds. Because a stretch by s divides
velocity by s, acceleration by s squared, and jerk by s cubed, feasibility is monotone in s,
so bisection converges to the boundary of the feasible set. Multi-joint synchronisation applies the
same mechanism, stretching every joint onto the duration of the slowest one. This is time optimal
among reparameterisations of one path, which is a weaker statement than global time optimality; the
distinction, the alternatives that were rejected, and the limitations that remain are set out in
docs/design-notes.md.
| Module | Responsibility |
|---|---|
src/trajectory_optimizer/model.py |
Pure dataclasses for joint limits, via points, boundary conditions, and moves, with validation and no input or output |
src/trajectory_optimizer/algorithm/protocol.py |
The Trajectory protocol every generator satisfies, the CertifiedTrajectory protocol for those that solve for their own peaks, and the single-instant state record |
src/trajectory_optimizer/algorithm/piecewise.py |
Piecewise polynomial container giving exact derivatives and exact peaks, plus the mapping of a scalar path profile onto a joint-space line |
src/trajectory_optimizer/algorithm/minimum_jerk.py |
Closed-form quintic minimum-jerk solution and its analytic time-optimal duration |
src/trajectory_optimizer/algorithm/trapezoidal.py |
Trapezoidal velocity profile including the triangular degenerate case |
src/trajectory_optimizer/algorithm/scurve.py |
Seven segment jerk limited profile including both degenerate cases |
src/trajectory_optimizer/algorithm/spline.py |
Cubic and quintic spline interpolation with natural and clamped ends |
src/trajectory_optimizer/algorithm/scaling.py |
Time scaling wrapper and the bisection search for the shortest admissible timing |
src/trajectory_optimizer/algorithm/synchronisation.py |
Stretching independent joint trajectories onto one common duration |
src/trajectory_optimizer/pipeline.py |
Sampling any trajectory into a structured trace of times and derivatives |
src/trajectory_optimizer/analysis/limits.py |
The sampled limit check, the certified one, and peak usage per joint |
src/trajectory_optimizer/analysis/metrics.py |
Duration, peak derivatives, jerk cost, and joint-space path length |
src/trajectory_optimizer/analysis/report.py |
Markdown rendering of comparison tables, trade-offs, limit reports, and scaling results |
src/trajectory_optimizer/analysis/figures.py |
Stacked derivative figures and profile overlays, drawn without a pyplot backend |
examples/ |
Wiring scripts only, with no logic of their own |
scripts/publish_figures.py |
Regenerates the tracked figures by running the examples |
The layers depend downward only: model imports nothing from the package, algorithm imports
model, pipeline imports algorithm, analysis imports model, pipeline, and the algorithm
result types it renders, and examples imports all four without adding logic of its own.
uv run pytest --cov=src/trajectory_optimizer --cov-report=term-missing
uv run ruff check .
uv run mypyThe suite is 203 tests over three tiers and completes in about 10 seconds. Coverage of
src/trajectory_optimizer is 97 percent of 1204 statements; CI runs the same command with
--cov-fail-under=95 on Ubuntu and on Windows, along with the linter and mypy in strict mode over
the package, the examples, and the figure script.
The first tier is property and invariant tests over the mathematics. Boundary values for position,
velocity, and acceleration are met exactly at both endpoints, no densely sampled point exceeds any
configured limit, the minimum-jerk solution matches p0 + d (10 t^3 - 15 t^4 + 6 t^5) at sampled
times, numerically differentiating position reproduces the reported velocity and acceleration, total
duration decreases monotonically as limits are relaxed, a zero-length move returns a valid
degenerate trajectory rather than dividing by zero, both splines pass exactly through every via
point, and synchronisation gives every joint identical start and end times. The cubic spline is
checked against scipy.interpolate.CubicSpline and the bisection scale factor against the analytic
one. The closed-form peaks are checked against their analytic expressions, against a two million
point grid, and against a constructed move whose violation a three point check calls compliant. The
second tier pins recorded profiles at eleven sampled fractions of each duration to an absolute
tolerance of 1e-9. The third tier runs every script in examples/ as a subprocess with
--samples 51.
The figures in docs/figures are snapshots of a particular run, not build artefacts. Regenerate
them with one command:
uv run python scripts/publish_figures.pyIt runs the three example scripts that own those cases, writes 660 by 620 pixel PNGs at 100 dots per inch, and refuses if the tracked total exceeds 250 KB. CI does not compare the figures byte for byte, because matplotlib output is not byte reproducible across platforms or across library versions, and a check that failed on a font hinting difference would only teach everyone to ignore it.
- Flash, T. and Hogan, N. "The Coordination of Arm Movements: An Experimentally Confirmed Mathematical Model." The Journal of Neuroscience, volume 5, number 7, 1985, pages 1688 to 1703. DOI: 10.1523/JNEUROSCI.05-07-01688.1985. Source of the minimum-jerk quintic and its rest-to-rest closed form.
- Biagiotti, L. and Melchiorri, C. Trajectory Planning for Automatic Machines and Robots. Springer, 2008. ISBN 978-3-540-85628-3. DOI: 10.1007/978-3-540-85629-0. Source of the trapezoidal and seven segment double-S case analysis, including both degenerate branches.
- de Boor, C. A Practical Guide to Splines, revised edition. Applied Mathematical Sciences volume 27, Springer, 2001. ISBN 978-0-387-95366-3. Source of the moment formulation of the interpolating cubic spline and of the natural and clamped end conditions.
- Hoschek, J. and Lasser, D. Fundamentals of Computer Aided Geometric Design. A K Peters, 1993. ISBN 978-1-568-81007-2. Source of the quintic spline continuity conditions used to build the knot velocity and acceleration system.
- Bobrow, J. E., Dubowsky, S. and Gibson, J. S. "Time-Optimal Control of Robotic Manipulators Along Specified Paths." The International Journal of Robotics Research, volume 4, number 3, 1985, pages 3 to 17. DOI: 10.1177/027836498500400301. Statement of the time-optimal path parameterisation problem that uniform time scaling approximates; see the design notes for the difference.
- Shin, K. G. and McKay, N. D. "Minimum-Time Control of Robotic Manipulators with Geometric Path Constraints." IEEE Transactions on Automatic Control, volume 30, number 6, 1985, pages 531 to 541. DOI: 10.1109/TAC.1985.1104009. The phase plane formulation of the same problem, cited as the rejected alternative in the design notes.
- Kunz, T. and Stilman, M. "Time-Optimal Trajectory Generation for Path Following with Bounded Acceleration and Velocity." Robotics: Science and Systems VIII, 2012. DOI: 10.15607/RSS.2012.VIII.027. Modern treatment of path following under velocity and acceleration bounds, cited as a rejected alternative.
- Pham, Q.-C. "A General, Fast, and Robust Implementation of the Time-Optimal Path Parameterization Algorithm." IEEE Transactions on Robotics, volume 30, number 6, 2014, pages 1533 to 1540. DOI: 10.1109/TRO.2014.2351113. Reference implementation strategy for the alternative that was not taken.
- Macfarlane, S. and Croft, E. A. "Jerk-Bounded Manipulator Trajectory Planning: Design for Real-Time Applications." IEEE Transactions on Robotics and Automation, volume 19, number 1, 2003, pages 42 to 52. DOI: 10.1109/TRA.2002.807548. Online jerk bounded planning, cited as a rejected alternative to the offline seven segment solution.
| Package | Version | Purpose | Licence |
|---|---|---|---|
| numpy | 2.5.1 | Array storage, vectorised evaluation of the coefficient tables, and the companion matrix roots behind the exact peaks | BSD 3-Clause |
| scipy | 1.18.0 | scipy.linalg.solve_banded for the tridiagonal cubic spline system and scipy.integrate.simpson for the jerk cost integral |
BSD 3-Clause |
| matplotlib | 3.11.1 | Figures of position, velocity, acceleration, and jerk against time | Matplotlib licence, a BSD-style permissive licence |
| pytest | 9.1.1 | Test runner for all three tiers, development only | MIT |
| pytest-cov | 7.1.0 | Coverage measurement and the CI threshold, development only | MIT |
| ruff | 0.16.1 | Linting and import ordering, development only | MIT |
| mypy | 2.3.0 | Static type checking under strict mode, development only | MIT |
Released under the MIT license. See LICENSE.


