Online trajectory generation
The otg module turns a stream of stepped targets into a 1 kHz command whose velocity,
acceleration and jerk never exceed a budget. It is what target
control runs on its thread, and it is public (Otg,
MultiOtg<N>, CartesianOtg, OtgLimits) for a loop of your own. This page is the
reasoning: why a generator and not a filter, how it plans, the three rules for putting it in
a control loop, why its budget is smaller than the rate limiter’s, and what the loop does
every cycle.
The problem
Most programs that want to move the arm are not 1 kHz programs: a planner, a vision loop, a
script on a socket, a person at a keyboard. What they produce is a stream of targets,
irregular, sometimes bursty, sometimes silent for seconds, and every one of them a step. Sent
as is, a 5 cm step reaches the robot as a 50 m/s jump and the motion generator refuses it with
cartesian_motion_generator_velocity_discontinuity (that is what nonrealtime_commander --raw
demonstrates). Something has to turn the steps into a motion, causally, with only the latest
target in hand.
The alternatives each fail one requirement. A spline needs future knots, and a commander at 10 Hz, or silent for two seconds, cannot supply them. A quintic re-fitted from the current state to the target every cycle is smooth but has no notion of the limits: its peak velocity and acceleration scale with the step and shrink with the chosen duration, and no duration is right for both a 1 mm and a 10 cm step. A first-order low-pass filter is causal and cheap, but its first-cycle demand is the step times the gain (0.31 m/s for 5 cm at 1 Hz), so the rate limiter behind it does the real work, the peak speed still scales with the step, and the approach is exponential, never quite arriving.
What the generator does
An online trajectory generator is a small state machine that owns the commanded position, velocity and acceleration and, every cycle, re-plans the time-optimal jerk-limited profile from that state to rest at whatever the latest target is, then follows it for one cycle. It is causal (only the latest target), limit-respecting by construction (velocity, acceleration and jerk within the budget on every cycle, including a 2 ms one after a lost packet), C2 (the acceleration is continuous; only the jerk switches), time-optimal within its profile family, and it lands exactly on the target and stays there.
The plan is the seven-segment profile: a jerk-limited transfer from the current (v, a) to a
peak velocity v_p with zero acceleration (three segments with closed-form durations), a
cruise at v_p, and the mirror transfer to rest. The one free parameter is v_p: ±v_max
with a cruise when the target is far enough, otherwise a root of the displacement function,
which is increasing in v_p except for a hump next to v_rd = v + a|a| / (2 j_max), the
velocity reached by ramping the acceleration straight to zero. So the domain is split at
v_rd and v_rd ± a_max² / j_max, every piece whose ends bracket the target is bisected,
and the shortest plan wins. This is the profile structure of Haschke, Weitnauer and Ritter
(On-line planning of time-optimal, jerk-limited trajectories, IROS 2008), evaluated one
cycle at a time the way Ruckig (Berscheid and Kröger, RSS 2021) does. No dependencies, no
allocation.
A target that moves mid-motion is re-planned from the current velocity and acceleration. A target closer than the braking distance gives a root of the opposite sign: the profile passes the target, brakes, and comes back, within the limits and without a jerk spike. A target that stays for seconds is reached and held exactly; the target velocity is always zero, so this is a generator for positional targets, not for velocity tracking.
#![allow(unused)]
fn main() {
extern crate franka;
use franka::otg::{Otg, OtgLimits};
let limits = OtgLimits { max_velocity: 0.3, max_acceleration: 0.5, max_jerk: 20.0 };
let mut otg = Otg::new(0.0, limits).unwrap();
otg.set_target(0.05).unwrap();
let mut t = 0.0f64;
while otg.position() != 0.05 {
otg.step(0.001);
t += 0.001;
}
// The full norm budget on one axis; the target-control loop gives each axis 1/sqrt(3)
// of it, which stretches the same step to about 0.85 s.
assert!((t - 0.658).abs() < 0.01, "a 5 cm S-curve under these limits takes 0.658 s");
}
MultiOtg<N> runs one generator per axis (with_limits gives each axis limits of its own,
which seven joints need) and, when synchronised, stretches the faster axes to the slowest
one’s duration by lowering their peak velocity (a second bisection); an axis that is exactly
braking to its target has no peak to lower and keeps its minimum duration. CartesianOtg is
MultiOtg<3>. Synchronised axes arrive together, so a diagonal target moves along a straight
line.
The property test
crates/franka-rs/src/otg/tests.rs simulates two thousand random target sequences (every
third with random limits): steps at random times, holds up to three seconds, bursts of twenty
targets 5 ms apart, gaps. Every cycle it asserts that the velocity, acceleration and
finite-difference jerk stay within the limits; that a target beyond the braking distance is
never overshot; that every target is reached within 10 % of the time an admissible
brake-then-move profile would take; and, on the synchronised three-axis variant that every
eighth sequence also runs, that an axis at rest whose target did not change stays bit-exact.
The three rules, learnt on the arm
The first version of the bridge in examples/nonrealtime_commander.rs ran this generator on
a real FER (2026-09-08) with limit_rate_cartesian_pose behind it, and the run did not go
cleanly: the generator stayed on its targets, but the limiter clamped it on the first
two-axis move and from then on the command orbited at the velocity cap until the robot
refused it. The per-cycle log of that run is a fixture in the module’s tests. Replayed
through the generator alone, it ends exactly on the last target with every per-axis limit
respected. Replayed through the generator and the limiter, the limiter clamps the command
by nanometres at 1.601 s (y braking at −0.5 m/s² while z starts at +0.5 m/s² is a norm
of 0.71 m/s² and 28 m/s³ of jerk), and 300 ms later the command is millimetres behind the
generator’s own state, which never hears of it. libfranka’s limiter has no braking logic:
tracking a pose it has fallen behind, it saturates at the budget, passes the pose, and
reverses, and the replay reproduces the ±10 cm orbit in the log. Three rules follow.
- The limits are per axis. Two axes at full acceleration have a vector norm √2 above it,
and a Cartesian budget is a norm (that is what
limit_rate_cartesian_posebounds). So the generator getsOtgLimits::per_axis_for_norm(3): each limit divided by √3, which is also what keeps a synchronised diagonal move inside the budget. - Step one nominal cycle per command (
DELTA_T, 1 ms), not the measured period. The robot and the rate limiter check every packet against a 1 ms budget, so a 2 ms step after a lost packet is a doubled velocity to them; the 52 cycles of 2–4 ms in that log each doubled an increment. - Re-anchor on the robot’s echo of the position every cycle with
set_position(O_T_EE_c)(orq_d), so that whatever runs behind the generator can shape one command but never accumulate a lag it plans against. The position only: the echoed twist is a mean over the cycle, half an acceleration step behind the generator’s end-of-cycle state, and re-anchoring on it throttles the plan to a crawl.
With the first two rules the replayed backstop never touches a command (worst alteration below 1e-9 m) and every target is met exactly. With the third rule alone it binds by up to 50 µm and the run stays bounded, millimetres from the targets and no orbit, but does not land exactly, because a vector-norm clamp distorts one axis’s corrections while another saturates. Both replays are regression tests in the module.
Why the budget is smaller than the limiter’s
The crate’s rate limiter with limit_rate = true is a port of libfranka’s, and its constants
(13 m/s² and 6500 m/s³ on an FER, 9 m/s² and 4500 m/s³ on an FR3) are what the robot accepts
in Cartesian space. The robot also runs inverse kinematics on every commanded pose and
checks the continuity of the result in joint space, and that is the check a stepped target
stream trips. On a real FER near the ready pose a ramp at 2.5 m/s² was refused as
cartesian_motion_generator_joint_velocity_discontinuity, 1.5 m/s² passed, and the cause is
the ordinary per-joint acceleration limit: joint 2 moves about 3.2 rad per metre of x travel
there, so 2.5 m/s² is 8 rad/s² against its 7.5 rad/s² limit. On an FR3 the same refusal
came in the cycle a joint crossed its 10 rad/s². The full account, with dates, is on FER /
Panda specifics.
A second limit appeared on the same FER above roughly 1 m/s² of commanded acceleration: the
robot’s external-force estimate O_F_ext_hat_K crossed 20 N at about 0.25 m/s and raised
cartesian_reflex, so for fast target steps the collision thresholds, not the kinematic
limits, were the binding constraint (the examples’ 10 N nominal thresholds were crossed at
0.25 m/s, which is why the commander example sets libfranka’s example thresholds
explicitly). Both figures are an observation on one arm, not a specification.
Hence the defaults: a translational budget of 0.3 m/s, 0.5 m/s², 20 m/s³, under which a
5 cm step along one axis becomes an S-curve that peaks at about 0.12 m/s and lands after
about 0.85 s (each axis gets 1/√3 of the norm budget); a rotational
budget of 0.5 rad/s, 1.0 rad/s², 20 rad/s³ (a fifth of the FR3’s rotational velocity limit
and a seventeenth of its acceleration limit); and for joints 20 % of the negotiated
version’s joint limits (JointTargetControlOptions::scaled_limits(version, fraction)).
The backstop behind the generator, limit_rate_cartesian_pose under the same budget, must
never bind, and to keep it from binding on noise it references the twist and acceleration
it sent, not the echoed O_dP_EE_c / O_ddP_EE_c: on FCI v10 the echo is float32, and
the rounding of a rotation matrix is worth about 200 rad/s³ of jerk against a 20 rad/s³
budget. The rotational axes of the generator also carry libfranka’s pose-interface factor
(0.99) that the backstop applies to its rotational limits; without it the backstop binds on
every synchronised jerk.
What the loop does every cycle
start_cartesian_target_control / start_joint_target_control spawn a named thread that
runs control_cartesian_pose / control_joint_positions with the loop’s own rate limiter
on and the low-pass filter off. Every cycle, on that thread:
- Anchor. The first cycle takes the robot’s echo of its commanded position (
O_T_EE_c,q_d) as the start, the initial target and the first setpoint, so the first command of the motion is the echo itself: on FCI v10 the first command is its own filter reference and would otherwise go out as a jump.start_*returns only once that cycle has run, sotarget()andstate()are valid from the first call. - Read the slot. The latest target comes through a single-writer seqlock
(
robot::target_control::TargetSlot) the loop polls without blocking; a torn read keeps the previous target for that one cycle.set_*serialises its callers with a mutex on the user side only. The stop flag is read before the slot, so a target published just beforestop()is not lost. - Generate under the three rules. Per-axis limits (the Cartesian budgets are norms and get
per_axis_for_norm(3); the joint limits are per joint already), oneDELTA_Tper command whatever the measured period,set_positionon the echo before every re-plan, axes synchronised. The orientation runs on three more axes of the same generator, on the rotation vector of the orientation error in the base frame (log(R_target R_echo^T)), re-anchored at zero every cycle and composed back asexp(step) R_echo: a constant-axis turn underrotation_limits, arriving together with the translation. - Backstop.
limit_rate_cartesian_pose/limit_rate_joint_positionsunder the same budget (the joint one tightened to the robot’s velocity envelope atq), against the echo, with the loop’s libfranka limiter behind it. Neither is meant to bind; the observer is told by how much the backstop moved the command (backstop_alteration). - Guard. If the measured position strays more than
max_deviationfrom the start (0.30 m Cartesian, 1.0 rad joint by default) or the measured orientation turns more thanmax_angular_deviation(0.5 rad), the target freezes where the command is, the generator brings it to rest, and the loop ends withFrankaError::Controlcarryingtarget_control::DEVIATION_MESSAGE. - Land, hold, finish. After
stop()the generator runs on until every axis has landed: withinSettle::toleranceof the target (1e-3: 1 mm or 1 mrad), slower thanREST_VELOCITY(1e-4 m/s or rad/s) and accelerating less thanREST_ACCELERATION(0.05). The hold freezes a velocity step of at mostREST_VELOCITYin one cycle, a jerk of 100 per second cubed, which the joint side of a Cartesian command amplifies about threefold (1 mm/s froze as 3840 rad/s³ on joint 2 in the simulator, over its 3750); not smaller, because thefloat32echo of an FR3 keeps a landed generator in micro-profiles that peak at about 2e-5 per second and 0.01 per second squared. Then the loop stops stepping the generator and sends the robot’s echo of the last command, bit for bit and past the backstop, forSettle::cyclescycles (250), and setsmotion_finishedon one more of it. A motion never finishes on a moving command: a real FER refused exactly that withcartesian_motion_generator_velocity_discontinuity. If the generator has not landed withinSTOP_TIMEOUT_CYCLES(5000, five seconds) the same hold starts from wherever the command is.
Nothing allocates on the realtime thread after the start; the observer, called every cycle
with the state and what was sent, must keep it that way. Dropping a handle without stop()
requests the stop and detaches: the loop settles and finishes on its own, holding its
Arc<Robot> until it has. This loop has run on franka-sim, on a real FER and on a real FR3;
the hardware runs are listed in Benchmarks and hardware validation.