servo.write(90). One line, and the servo goes to 90°. It works — right up until it doesn’t: a wobbly arm that overshoots and settles late, a stepper that skips under load, a gripper that arrives at its target hard enough to rattle the whole frame. The servo isn’t broken. Nobody ever told it how to get to 90° — only that it should.
That gap — between naming a destination and actually planning the motion that gets there — is what a trajectory generator solves. It’s a small enough idea to explain without equations, and it’s one of the layers in Universal Interface Stack, a hardware-agnostic C++ control stack for robots — the layer that sits above motor and encoder drivers and decides what curve they should be tracking in the first place. This piece covers the part of it that plans motion for a single motor, and touches on what happens once a robot has more than one.
A target is not a motion plan
No real motor can teleport. Between “start” and “target,” something happens — some curve gets traced through position, velocity, and acceleration over time. The only question is whether that curve was decided, or whether it just happened, shaped by whatever the motor’s own internal control loop does when it’s asked to close a large error as fast as it can.
Two ways to arrive at the same 90°
0° → 90°, vMax = 180°/s, aMax = 600°/s². The step asks for infinite acceleration at t=0 — physically impossible, so in practice something else (a firmware ramp you don't control, or raw current into a stalled load) decides the real shape for you. The trajectory decides it up front instead.
The dashed line isn’t a real motion — it’s a stand-in for “I didn’t plan this.” Whatever a servo or stepper actually does when you hand it a bare target, it isn’t that vertical jump; it’s some curve the hardware improvises, and you don’t get to choose its shape. The solid line is the alternative: a curve computed in advance, from two numbers you already know about your hardware — how fast it can go, and how fast it can change speed.
Accelerate, cruise, decelerate
Those two numbers are the whole interface. A trapezoidal profile — the shape this library is named after — spends a move in three phases: speed up at the motor’s maximum acceleration, hold the maximum safe speed as long as there’s distance left, then slow down in time to land exactly on target at zero velocity.
Velocity and acceleration during that same move
180°/s max, held for 0.2s.
±600°/s², never more.
Same move as above, split into its two derivatives. The dashed lines mark the phase boundaries at t=0.3s and t=0.5s. Acceleration is a bounded step, not a spike — that's the entire difference between this and the naive step command: nothing here ever asks the hardware for more than the two limits you gave it.
Those two limits are the whole API. In code, planning this exact move is:
TrajectoryLimits limits(180.0f, 600.0f); // vMax (deg/s), aMax (deg/s^2)
TrapezoidalProfile profile;
profile.plan(0.0f, 90.0f, limits); // q0, qf -- minimum time
// every control tick, t = seconds since plan():
float pos, vel, accel;
bool stillMoving = profile.evaluate(t, pos, vel, accel);
evaluate() is a pure function of elapsed time — no clock owned internally, no state that changes except what plan() set up. Call it once a control loop tick, feed pos (or vel, for a velocity-mode driver) to the motor, and the same three numbers replay identically whether t comes from an Arduino’s millis() or an EtherCAT master’s cycle counter.
Short moves skip the cruise entirely
Not every move is long enough to reach the cruise phase. A short hop only has room to accelerate partway before it has to start slowing down again to land exactly on target — the velocity profile never flattens into a plateau, it just ramps up and straight back down. The shape degrades gracefully from trapezoid to triangle; nothing about the underlying rule changes, the move is just too short to make full use of the speed limit.
A 20° move never reaches the same top speed as a 90° move
Same vMax/aMax limits, two different distances. The short move settles by t=0.365s — it isn't slower per degree, it just runs out of room to keep accelerating before it has to start slowing down again. TrapezoidalProfile detects this internally; the caller never has to special-case it.
One smooth joint isn’t the whole story
Everything above is one motor. A robot arm has several, and each joint’s own move is a different distance — a shoulder sweeping 80°, an elbow sweeping 40°, a wrist sweeping 15°. Plan each of those independently for minimum time and they finish at three different moments: the wrist is done in under a third of a second while the shoulder has only just crossed a third of its own swing. Every individual joint traces a perfectly smooth trapezoid — and the arm’s tip still doesn’t move smoothly through space, because the joints aren’t arriving together.
Three joints, planned independently vs. synchronized
Each joint at its own minimum time.
All three re-planned to 0.74s.
Position shown as percent of each joint's own target, so three different distances plot on one axis. Unsynchronized, the wrist (0.32s) and elbow (0.52s) both finish well before the shoulder (0.74s) — each curve is individually smooth, but the set arrives staggered. TrajectoryGroup takes the slowest axis's own minimum-time duration and re-plans every other axis to exactly that duration, so all three cross 100% together.
Concretely:
TrajectoryLimits limits(180.0f, 600.0f); // same limits, every joint
ITrajectoryProfile* joints[] = { &shoulder, &elbow, &wrist };
float q0[] = { 0.0f, 0.0f, 0.0f };
float qf[] = { 80.0f, 40.0f, 15.0f };
TrajectoryLimits perAxis[] = { limits, limits, limits };
TrajectoryGroup group;
group.plan(joints, q0, qf, perAxis, 3); // re-plans every axis to the shared duration
Matching arrival time across joints is what TrajectoryGroup does — it’s still working entirely in joint space, one independent trapezoid per axis, just stretched to a common finish line. It says nothing about the shape of the path the arm’s tip actually traces between those two poses; a straight line through space, or a curve around an obstacle, is a related but different problem, sitting one layer up. That’s for another article.
Why this lives in its own tiny library
None of the math above cares what’s on the other end of evaluate()’s output. plan() and evaluate() never touch a clock, allocate memory, or assume a platform — the caller supplies elapsed time, and gets back a position, velocity, and acceleration, deterministically, every time. That’s what makes the exact same trapezoid in the charts above run identically on an 8-bit Arduino driving a single RC servo and on a Linux box under a hard-real-time EtherCAT master driving a whole arm — and what makes it possible to validate that shape once, on a desktop build with no hardware attached at all, before it ever touches a motor.
That first pairing isn’t hypothetical — it’s exactly what runs today inside Servo Calibrator: an Arduino Nano builds a real RC servo’s calibration table, then plans every point-to-point move against that table with this same TrapezoidalProfile, on the same 8-bit chip.
Universal-Trajectory-Interface is one piece of the Universal Interface Stack — the layer that turns “go to 90°” into a plan for getting there, so the layer below it (the motor driver) only ever has to track a curve, never invent one.
Get the library
Universal-Trajectory-Interface is free, public, and MIT-licensed:
github.com/vishwam-aggarwal/Universal-Trajectory-Interface.
It isn’t in the Arduino IDE’s Library Manager index yet, so add it the
way you’d add any GitHub-only library — download the repo as a ZIP and
Sketch → Include Library → Add .ZIP Library…, or git clone it
straight into your sketchbook’s libraries/ folder. src/ has zero
Arduino-specific code in it, so the same header also builds in a plain
desktop C++ project, if you want to unit-test a trajectory shape before
it ever touches a motor.
Comments
Name and comment are public. Email is never shown or shared — it's only used if I need to follow up. New comments are held for review before they appear.
Loading comments…