Skip to content

Repository files navigation

Trajectory Optimizer

Minimum-jerk and time-optimal joint trajectories under velocity, acceleration, and jerk limits.

CI Python License

Position, velocity, acceleration and jerk against time for three profiles driving the same 1.5 radian move. The trapezoidal acceleration jumps vertically at each phase change, so its jerk trace is flat at zero between two invisible impulses, while the S-curve holds its jerk at plus or minus 20 rad/s cubed in four rectangular blocks and the minimum-jerk curve stays smooth throughout and takes the longest.

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.

Choosing a profile

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.

Installation

Requires Python 3.12 or later.

git clone https://github.com/Eelis03/trajectory-optimizer.git
cd trajectory-optimizer
uv sync

Using 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.

Usage

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.py

Each script accepts --samples for the sample count, --output for the figure path, and --dpi for its resolution.

Results

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.

Spline interpolation through via points

Position, velocity, acceleration and jerk of four splines through the same four via points. All four position curves pass through the same points, but the jerk panel separates them: the natural quintic stays close to zero throughout while the clamped quintic swings from plus 6 to minus 3 near the two ends.

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.

Time-optimal time scaling

Position, velocity, acceleration and jerk of a three joint spline path after time scaling. The acceleration of joint 2 reaches minus 2.0 exactly at the final instant and nowhere else, while every other trace stays well inside its own bound for the whole move.

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.

Multi-joint synchronisation

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.

How it works

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 map

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.

Verification

uv run pytest --cov=src/trajectory_optimizer --cov-report=term-missing
uv run ruff check .
uv run mypy

The 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.py

It 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.

References

Algorithms

  • 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.

Dependencies

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

License

Released under the MIT license. See LICENSE.

About

Minimum-jerk and time-optimal joint trajectories under velocity, acceleration, and jerk limits.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages