Robotics Engineering · Lesson 2 of 26

Time, Clocks &
Sensor Alignment

The class of bug that only appears when the robot moves fast — where the twelve milliseconds hide, how to measure them, and how to engineer them out.

Prerequisites: Basic calculus + a little numpy. Frames from Lesson 01 help but are not required.
6
Chapters
3
Code Labs
9
Interactive Sims

Chapter 0: The Ticket — Twelve Milliseconds

It is 9:40 on a Tuesday and a ticket lands in the queue of a team that builds sidewalk delivery robots. It does not open with a definition. It opens with a symptom.

The ticket. "Our robot localises beautifully at walking pace. Under 1.5 metres per second, the map is crisp and the loop-closure residual is around two centimetres. Above 2 m/s it falls apart — the trajectory bows outward on every turn and the map smears. We have not touched the estimator in six weeks. The gains are the same, the covariances are the same, the code is the same. Tell me where you would look."

Attached is a printout. Same robot, same route, four speeds.

Speed vResidual ee ÷ v (s)Map quality
0.8 m/s0.014 m0.0175crisp
1.4 m/s0.022 m0.0157crisp
2.2 m/s0.032 m0.0145walls slightly thick
3.4 m/s0.047 m0.0138double walls, half the loop closures rejected

e is the loop-closure residual over a fixed 240 m out-and-back route, same robot, same lighting, four commanded speeds. 0.8 m/s is a slow walk; 3.4 m/s is delivery pace.

Look hard at that table before reading on. The error is not exploding. It is not oscillating. It grows smoothly and roughly linearly in speed: quadrupling the speed roughly triples the error.

The third column is where most people go wrong, so do the wrong thing first and watch it fail. The obvious move is to divide each error by its speed, because metres divided by metres-per-second is seconds — and if the answer is a constant, that constant is a time and the bug has a clock in it.

But the four ratios are not constant. They fall monotonically: 17.5 ms, 15.7 ms, 14.5 ms, 13.8 ms. That is a 27 % spread, far too large to wave away as measurement noise, and it slides in one direction rather than scattering. If you stop here you conclude "not a clean time constant" and go looking somewhere else. That conclusion would be wrong, and the reason it is wrong is the actual lesson.

Why the ratio drifts. A ratio only tells you the slope of a line if that line passes through the origin. This one does not. Sitting underneath the timing term is a small, speed-independent error floor — a few millimetres of residual extrinsic-calibration error plus feature-localisation noise — that is present even when the robot is parked. Dividing by v smears that fixed floor across the ratio column, and because you are dividing a constant by a growing number, it inflates the low-speed ratios most. Hence the monotone slide.

So do not read the ratio. Read the slope. Fit a straight line e = m·v + b through the four points and let the data separate the two error sources for you. Least squares by hand, on four points, takes about a minute.

Worked example 0 — the line fit, every step.
Means first:
v = (0.8 + 1.4 + 2.2 + 3.4) / 4 = 7.8 / 4 = 1.95 m/s
e = (0.014 + 0.022 + 0.032 + 0.047) / 4 = 0.115 / 4 = 0.02875 m
Now the deviations from those means:
Δv = −1.15, −0.55, +0.25, +1.45
Δe = −0.01475, −0.00675, +0.00325, +0.01825
The cross-product sum, term by term:
(−1.15)(−0.01475) = 0.0169625
(−0.55)(−0.00675) = 0.0037125
(+0.25)(+0.00325) = 0.0008125
(+1.45)(+0.01825) = 0.0264625
Sve = 0.0169625 + 0.0037125 + 0.0008125 + 0.0264625 = 0.04795
The sum of squared speed deviations:
Svv = 1.152 + 0.552 + 0.252 + 1.452 = 1.3225 + 0.3025 + 0.0625 + 2.1025 = 3.79
Slope: m = Sve / Svv = 0.04795 / 3.79 = 0.012652 s = 12.65 ms
Intercept: b = e − m·v = 0.02875 − (0.012652 × 1.95) = 0.02875 − 0.024671 = 0.004079 m = 4.08 mm

Two numbers fell out of one table, and they are two different bugs.

The slope is 12.65 milliseconds. It has units of seconds and it multiplies velocity, so it is a time offset: something in this robot is describing the world at one instant and labelling it with another. The intercept is 4.08 millimetres. It has units of metres and it survives at v = 0, so it is a static geometric error — extrinsics, intrinsics, or feature noise. It would still be there on a robot that never moved.

Check the fit before you trust it. Predicted errors at the four speeds are 0.0142, 0.0218, 0.0319, 0.0471 m, so the residuals are −0.20, +0.21, +0.09, −0.10 millimetres. Four points, sub-quarter-millimetre residuals, no structure left in them. Two parameters explain this entire table.

You can also do it without least squares, in your head, which is what you would actually do at a whiteboard. Guess the intercept as about 5 mm from the shape of the data, subtract it from every error, and then take the ratios:

The same answer, by eye.
(0.014 − 0.005) / 0.8 = 0.009 / 0.8 = 0.01125 s
(0.022 − 0.005) / 1.4 = 0.017 / 1.4 = 0.01214 s
(0.032 − 0.005) / 2.2 = 0.027 / 2.2 = 0.01227 s
(0.047 − 0.005) / 3.4 = 0.042 / 3.4 = 0.01235 s
Mean = 0.01200 s. Now the four numbers are the same number, and the monotone slide is gone — because removing the intercept removed the thing that was causing it. The bug has a clock in it: about 12 milliseconds.
The single most useful reflex in this whole lesson. When an error grows with velocity, do not divide — fit. Plot error against speed and read two numbers off the line. The slope has units of seconds and is a timing bug. The intercept has units of metres and is a geometry bug. Dividing conflates them and makes a clean timing signature look dirty. The same reflex works with angular rate on the x-axis: the slope is again a time, and it is the same time. Errors that carry a hidden time constant are almost never algorithm bugs. They are alignment bugs.
Field note. "Before I divide anything I want a plot of error against speed, not a table of ratios, because the ratio only equals the slope if the intercept is zero — and the intercept is exactly the static calibration error I would otherwise blame the timing for. Here the slope is about 12.7 ms and the intercept is about 4 mm, which tells me there are two separate defects and gives me the size of each."

Where the twelve milliseconds live

The robot carries a global-shutter camera at 30 Hz and a MEMS IMU at 400 Hz. The camera hangs off a USB3 controller; its driver stamps each frame the moment the last byte lands in userspace. The IMU sits on an SPI bus behind a small microcontroller that batches 32 samples into one packet and hands them up; the ROS node stamps the whole packet with now().

Two different stamping conventions, two different physical paths, and nobody ever measured the difference between them. When someone finally did — by shaking the robot and cross-correlating the camera-derived rotation rate against the gyro — the answer came back at 12 milliseconds. The camera's reported time is 12 ms later than the IMU's for the same physical instant.

Twelve milliseconds. On a desk, that is nothing. You cannot perceive it, you cannot see it in a plot of either signal on its own, and no unit test in the repository is capable of noticing it.

The arithmetic, by hand, at four speeds

Here is the entire physics of the bug in one line. The estimator believes a camera measurement describes the world at time t. The world it actually describes is the world at time t − Δt. Between those two instants the robot moved. The distance it moved is the error injected into that measurement:

epos = v · Δt

where v is the robot's speed in metres per second and Δt is the unmodelled time offset in seconds. That is it. There is no hidden constant, no gain, nothing to tune. Now put numbers in it, one step at a time.

Worked example 1 — walking pace.
v = 1.4 m/s, Δt = 0.012 s.
e = 1.4 × 0.012
e = 0.0168 m
e = 16.8 mm
The robot's own footprint is 600 mm wide. Its localisation requirement is 50 mm. A 16.8 mm bias sits comfortably inside the requirement and inside the noise floor of the sensor. Nobody will ever file a bug.
Worked example 2 — delivery pace.
v = 3.4 m/s, Δt = 0.012 s (unchanged — it is a property of the wiring, not the motion).
e = 3.4 × 0.012
e = 0.0408 m
e = 40.8 mm
That is 2.43× the walking-pace error, from the exact same 12 ms. It now eats 82% of the 50 mm budget on its own, before any other error source contributes a millimetre.

And that is only the translation channel. The rotation channel is worse, because rotation errors get multiplied by range.

Worked example 3 — the turn. The robot rounds a corner at ω = 60 °/s. First convert to radians, because every trigonometric identity you are about to use wants radians:
ω = 60 × (π / 180) = 60 × 0.017453 = 1.0472 rad/s
The attitude error over the same 12 ms is
Δθ = ω · Δt = 1.0472 × 0.012 = 0.012566 rad = 0.72°
Now push that through to a landmark 5 m away. For small angles the lateral displacement is r · Δθ:
elat = 5 × 0.012566 = 0.0628 m = 62.8 mm
A landmark you are certain about to 2 cm is being placed 6.3 cm away from where it belongs, every frame, in a direction that flips sign when the robot turns the other way.

Now you know why the map develops double walls. On the outbound leg the robot turns left and every landmark is shifted one way. On the return leg it turns right and every landmark is shifted the other way. The same physical wall gets written into the map twice, 12 cm apart, and the loop-closure detector — which is doing its job perfectly — refuses to accept that these are the same place.

The velocity-scaled error — drag both sliders

Left: the error a fixed time offset injects, as a function of speed. Right: the same offset seen as a shift between the two sensor streams. Notice that the offset slider changes the slope of the line, never its shape — and that at low speed every slope looks the same.

Time offset Δt12 ms
Robot speed v1.4 m/s
Landmark range r5.0 m
Try: set Δt to 40 ms and sweep the speed from 0.2 to 4. The error crosses the 50 mm budget line at 1.25 m/s — which is why a rig that is fine on a test track fails on a sidewalk.

Reading that plot as a spec. The dashed horizontal line is the 50 mm localisation budget. Every straight line through the origin is one value of Δt. Where a line crosses the budget is the maximum safe speed for that offset — a single number that turns "our sync is a bit sloppy" into "this robot may not exceed 1.25 m/s". Framing a timing spec as a speed limit is how you get a hardware budget approved.

Deriving the linearity, not just observing it

The table said the error was linear in speed. Let us prove it, because "I noticed the plot was straight" is a technician's answer and "here is why it must be straight" is an engineer's.

Let x(t) be the robot's true position at time t. A camera measurement z is generated by photons that left the world at the true capture instant, which is t − Δt. But the message carries the label t, so the estimator forms its residual against the wrong state:

r = z − h(x(t))     where in truth   z = h(x(t − Δt)) + ν

Here h is the measurement function (project a landmark into the image), and ν is the genuine sensor noise — the zero-mean part, the part the covariance R is actually describing. Substituting:

r = h(x(t − Δt)) − h(x(t)) + ν

Now expand x(t − Δt) as a Taylor series about t. The robot has velocity v and acceleration a:

x(t − Δt) = x(t) − v · Δt + ½ a · Δt2 − …

So the state the measurement really describes differs from the state the estimator assumed by a small vector. Name it:

δ = x(t − Δt) − x(t) = −v · Δt + ½ a · Δt2 − …

Now do the step that is usually skipped, because it is the step that explains why a Jacobian appears here at all. h is a nonlinear function of the state, but δ is tiny — millimetres and milliradians — so expand h itself to first order about x(t):

h(x(t) + δ) = h(x(t)) + H · δ + O(|δ|2),    where   H = ∂h/∂x   evaluated at x(t)

That is the definition of the measurement Jacobian: the matrix that converts a small displacement in state space into the small displacement it causes in measurement space. It is not a fudge factor and it is not optional — it is the only object that can carry metres of position error into pixels, or radians, or whatever units h outputs.

Substitute δ into the expansion and put it back in the residual. The h(x(t)) terms cancel exactly, because one came from the truth and one from the estimator's assumption, and they are the same quantity:

bias(r) = h(x(t) + δ) − h(x(t)) = H · δ ≈ −H · v · Δt   +   ½ H · a · Δt2

Nothing was waved through. The chain is: a time offset becomes a state offset (Taylor in t), and a state offset becomes a measurement offset (Taylor in x). Two expansions, and the Jacobian is the second one.

Worked example 3b — the general formula, reproducing the hand arithmetic. The formula above is abstract until you write H down for a real sensor. Take the range-bearing landmark from Worked example 3. State is x = (px, py, θ); the landmark sits at (mx, my); write dx = mx − px, dy = my − py, r = √(dx2 + dy2). The measurement is
h(x) = [ r , atan2(dy, dx) − θ ]
and differentiating each output with respect to each state gives the 2×3 Jacobian
H = [ −dx/r   −dy/r   0 ;   dy/r2   −dx/r2   −1 ]
Put the robot at the origin facing +x with the landmark 5 m straight ahead, so dx = 5, dy = 0, r = 5. Every entry becomes a number:
Range row: −5/5 = −1,   −0/5 = 0,   0
Bearing row: 0/25 = 0,   −5/25 = −0.2,   −1
Worked example 3c — now multiply, and watch 40.8 mm and 62.8 mm fall out of one matrix.
Pure translation. Driving at v = 3.4 m/s along +x, Δt = 0.012 s, so δ = (−0.0408, 0, 0).
Range row: (−1)(−0.0408) + (0)(0) + (0)(0) = +0.0408 m = 40.8 mm of range bias.
Bearing row: (0)(−0.0408) + (−0.2)(0) + (−1)(0) = 0 rad.
Driving straight at a landmark dead ahead corrupts range and leaves bearing untouched — and 40.8 mm is exactly Worked example 2, now derived rather than asserted.

Pure rotation. Turning at ω = 1.0472 rad/s, so δ = (0, 0, −0.012566).
Range row: (−1)(0) + (0)(0) + (0)(−0.012566) = 0 m. Spinning in place does not change how far away the landmark is.
Bearing row: (0)(0) + (−0.2)(0) + (−1)(−0.012566) = +0.012566 rad.
That is a bearing error in radians. To see it in the map, multiply by the range, because an angular error subtends an arc:
5 × 0.012566 = 0.0628 m = 62.8 mm — exactly Worked example 3.

Both at once (the actual corner). δ = (−0.0408, 0, −0.012566) gives 40.8 mm along the line of sight and 62.8 mm across it. Those are perpendicular, so the landmark lands
√(40.82 + 62.82) = √(1664.64 + 3943.84) = √5608.48 = 74.9 mm
from where it belongs. On a 50 mm budget, twelve milliseconds and a normal corner produce a 75 mm error by themselves.
The structural insight the −1 hands you. Look at where the −1 sits: bearing row, heading column. Bearing error picks up attitude error one-for-one, in radians, with no range in the coefficient. The range only enters when you convert that angle into map-space metres. That is the whole reason rotation is the nastier channel — the translation term is v·Δt and stops there, while the rotation term is r·ω·Δt and grows without bound as you look further away. At 15 m instead of 5 m, the same corner puts the landmark 188 mm out.
Worked example 4 — is the quadratic term worth carrying? Take an aggressive delivery robot: a = 2.0 m/s2, v = 3.4 m/s, Δt = 0.012 s.
Linear term: v · Δt = 3.4 × 0.012 = 0.0408 m = 40.8 mm
Quadratic term: ½ · a · Δt2 = 0.5 × 2.0 × (0.012)2 = 0.5 × 2.0 × 0.000144 = 0.000144 m = 0.144 mm
Ratio: 40.8 / 0.144 = 283×.
The linear term dominates by nearly three orders of magnitude. This is why fitting a straight line through error-versus-speed works as a diagnostic in practice — the curvature is 283 times smaller than the effect you are measuring, so it hides under the residuals — and why every rule of thumb in this lesson is written as v·Δt with no correction term. Carry the quadratic only when Δt is large enough that a·Δt is comparable to v — which for a = 2 m/s2 and v = 1 m/s means Δt approaching half a second.

The bias is proportional to Δt, which is fixed by the wiring, and to v, which the operator controls with the throttle. The robot is, without anyone intending it, running a parameter sweep over its own bug every time it accelerates.

The error budget, as a grid

This table converts a vague worry into a number you can hold a design review over. Every cell is v × Δt, in millimetres. The 50 mm localisation requirement is the line the robot is not allowed to cross.

Δt ↓  /  v →0.5 m/s1.4 m/s2.2 m/s3.4 m/s5.0 m/s
1 ms0.51.42.23.45.0
5 ms2.57.011.017.025.0
12 ms6.016.826.440.860.0
33 ms (one frame)16.546.272.6112.2165.0
80 ms (a stale FIFO)40.0112.0176.0272.0400.0

Read across the 12 ms row and you can see the whole ticket. At 0.5 m/s the bug contributes 6 mm and is invisible. At 3.4 m/s it contributes 40.8 mm and has eaten the budget. Nothing changed except the throttle.

Read down the 3.4 m/s column and you get the design spec in one glance: to keep this term under 10 mm at delivery speed you need Δt < 3 ms. That single inequality is what the entire architecture in Chapter 1 exists to satisfy.

The two paths a timestamp can take

Here are the two pipelines side by side, with the latency each stage actually contributes on this robot. This is Chapter 2's material in preview, but you need to see the shape now, because the 12 ms is not one mistake — it is the difference between two chains of small, individually reasonable ones.

Camera path
photons → exposure 8 ms (centre at +4 ms) → sensor readout 2 ms → USB3 transfer 5.5 ms → driver wake 1.5 ms → stamp here
↓ reported time is 13.0 ms after the true capture instant
IMU path
sample → MCU FIFO (up to 32 deep) → SPI burst 0.4 ms → driver wake 0.6 ms → stamp here, applied to the whole packet
↓ reported time is 1.0 ms after the newest sample, and 78.5 ms after the oldest
The difference the estimator sees
13.0 − 1.0 = 12.0 ms of relative offset, plus a per-sample error inside every IMU packet

Every individual number in that diagram is defensible. An 8 ms exposure is reasonable indoors. A 5.5 ms USB3 transfer for a 3.7 MB frame is reasonable. Batching 32 IMU samples to avoid 400 interrupts per second is reasonable. The bug is that nobody subtracted one chain from the other, and the estimator was handed the difference as if it were zero.

Field note. "The offset is not a mistake anyone made. It is the accumulated, unmeasured difference between two perfectly sensible data paths. That is why it survives code review — there is no line of code to point at. It only shows up when you measure the two paths against each other."

The same twelve milliseconds, as bytes on the wire

Latency diagrams are easy to nod at and impossible to implement from. So here is the identical situation written as the actual message fields a subscriber receives, because that is where you have to go looking, and because the shape of the IMU message hides a second bug that is three times larger than the headline.

The two stamps, for one physical instant. The robot is bumped at a true wall-clock instant of 1712.340000000 s in the host time base. The camera's exposure is centred on that instant; an IMU sample is taken at that instant. Both sensors publish. Here is what lands in the two message headers:

TopicMessage typeheader.stampDecimal seconds
/cam/image_rawsensor_msgs/msg/Image{sec: 1712, nanosec: 353000000}1712.353000000
/imu/data_rawsensor_msgs/msg/Imu{sec: 1712, nanosec: 341000000}1712.341000000

Subtract them in the integer field, which is the only place this arithmetic is exact:

353 000 000 − 341 000 000 = 12 000 000 ns = 0.012 s = 12 ms

That is the whole bug, in one integer subtraction. The camera header says 353 ms; the IMU header says 341 ms; the physics happened at 340 ms. Both stamps are late, by 13.0 ms and 1.0 ms respectively, and the estimator only ever sees their difference. Nothing in either message is flagged, malformed, or out of range. A schema validator passes both.

Why you cannot find this by reading one message. Open either header on its own and it looks perfect: a monotonically increasing stamp, the right order of magnitude, matching the wall clock to the second. The defect only exists in the relationship between two messages published by two different drivers, and no single-topic tool — ros2 topic echo, ros2 topic hz, a rosbag inspector — can express that relationship. This is precisely why the diagnostic has to be a cross-correlation and not an inspection.

The IMU packet, as an array. Now open the thing the flow diagram called "one packet". The MCU hands the driver a burst of 32 samples, six floats each:

FieldShape / typeContents
samplesfloat32[32][6] — 768 bytesrow i = [ax, ay, az, gx, gy, gz], row 0 oldest, row 31 newest
stampbuiltin_interfaces/Time — one value1712.341000000, taken by the driver after the SPI burst completed

Thirty-two instants, one label. The samples are 1/400 s = 2.5 ms apart, and the driver's stamp trails the newest of them by the 0.6 ms wake plus the 0.4 ms burst = 1.0 ms. So the true instant of row i is:

ttrue(i) = tstamp − 0.001 − (31 − i) / 400
Worked example 5b — how stale is each row?
Row 31 (newest): (31 − 31)/400 = 0, so age = 0.001 + 0 = 1.0 ms.
Row 0 (oldest): (31 − 0)/400 = 31/400 = 0.0775 s = 77.5 ms, so age = 1.0 + 77.5 = 78.5 ms. That is the number the flow diagram quoted without showing its work: thirty-one sample intervals plus one driver wake.
The mean over the packet: the ages are an arithmetic sequence from 0 to 31 intervals, whose mean index gap is 31/2 = 15.5, so
mean age = 0.001 + (15.5 / 400) = 0.001 + 0.03875 = 0.03975 s = 39.75 ms
(of which 38.75 ms is the FIFO span and 1.0 ms is the driver).

Stop and look at 39.75 ms. It is 3.3× the headline 12 ms, and it is sitting inside every IMU packet on the robot. Whether it hurts you depends entirely on one line in the driver:

What the driver does with the 32 rowsEffective camera–IMU offsetError at 3.4 m/s
Back-dates each row using the formula above and publishes 32 Imu messages with individual stamps13.0 − 1.0 = +12.0 ms, uniform across rows40.8 mm
Assigns tstamp to all 32 rows (the "stamp the whole packet with now()" path)13.0 − 39.75 = −26.75 ms on average, and it varies by 77.5 ms within one packet90.9 mm, with the sign reversed

Read that second row twice. Not only is the error 2.2× larger, the sign of the correction flips. A team that measures +12 ms on a rig whose driver back-dates, then ships the same constant to a fleet whose driver does not, has just doubled the error it was trying to remove. The measured offset is a property of the whole pipeline, driver code included — not of the wiring.

The one-line reason this robot's number is a clean 12 ms. Its driver back-dates. That is why the shake test returns a sharp, repeatable peak rather than a smeared one: every gyro sample carries its own correct instant, so the only thing left between the two streams is a single constant. When you meet a rig whose correlation peak is a 78 ms-wide plateau instead of a spike, you are looking at an unexpanded FIFO, and no scalar offset will fix it.

The degraded case, which is where this actually bites. The FIFO is 32 deep nominally. Under CPU load — a perception node spiking, a log flush, a thermal throttle — the reader misses its wake-up and the MCU keeps filling. Suppose it backs up to 64 samples before the next read.

Worked example 5c — the backed-up FIFO, three numbers.
1. The span doubles. 63 intervals at 2.5 ms = 63/400 = 0.1575 s = 157.5 ms of history in one message, against 77.5 ms nominal.
2. The mean staleness doubles. mean age = 0.001 + (63/2)/400 = 0.001 + 0.07875 = 79.75 ms. Every naive consumer's error just went from 39.75 ms to 79.75 ms without a single line of code changing.
3. A hardcoded 32 becomes an 80 ms lie. This is the real killer. If the back-dating loop uses a compile-time N_SAMPLES = 32 instead of the message's actual length, it computes row 0's time as tstamp − 0.001 − 31/400 = tstamp78.5 ms, while row 0 truly occurred at tstamp − 0.001 − 63/400 = tstamp158.5 ms. The row is labelled
158.5 − 78.5 = 80.0 ms too late.
At 3.4 m/s that is 3.4 × 0.080 = 0.272 m = 272 mm — the bottom-right corner of the error-budget grid above, reached from a completely different direction. And it appears only under CPU load, which is why it reproduces on the loaded robot and never on the bench.
The assertion that would have caught it. Rows 32 through 63 make (31 − i) negative, so the buggy formula places them after the driver's own stamp — samples claiming to be from the future. One line, assert t_true[i] ≤ t_stamp for all i, turns a silent 272 mm mapping error into a loud crash on the first overrun. Every timestamp-expansion routine you write should carry it.

Three ways this shows up downstream, and they look nothing alike

Part of what makes this bug expensive is that it presents differently to three different teams, and none of their symptoms sounds like "clock".

1. Mapping — smear and rejected loops. Already derived: landmarks are displaced by v·Δt along the direction of travel, so the same wall observed on an outbound and a return pass lands in two places. At 3.4 m/s the two copies sit 2 × 40.8 = 81.6 mm apart. A loop-closure verifier with a 50 mm inlier threshold rejects the match, and the map never closes.

2. Control — eroded phase margin. A state estimate that is systematically stale acts, from the controller's point of view, as pure transport delay in the feedback path. Pure delay costs phase linearly with frequency:

phase lag (degrees) = 360 · f · Δt
Worked example 5 — what 12 ms costs the controller.
A path-following loop with a gain crossover at f = 5 Hz.
lag = 360 × 5 × 0.012 = 360 × 0.06 = 21.6°
If that loop was designed with a 45° phase margin, it now has 45 − 21.6 = 23.4°. It is still stable, but it will ring visibly on a step, and the ringing amplitude will grow the harder you drive it. The controls engineer files a bug titled "steering oscillates at speed" and starts detuning gains — treating the symptom, in a different repository, on a different team.

3. Perception — "works in sim, not on hardware". A simulator delivers camera and IMU data with identical, usually zero, latency. Every learned model trained or validated in sim is therefore trained on perfectly aligned data. Ported to hardware it inherits 12 ms of misalignment it has never seen, and its errors are blamed on the sim-to-real gap in appearance rather than the sim-to-real gap in time.

The decisive move: inject the measured hardware latency into the simulator and re-run. If the sim-to-real discrepancy collapses, you have proved the cause without touching the model. It is a cheap, decisive experiment.

How the twelve milliseconds was actually measured

Nobody found this number by reading code. They found it by producing a signal that both sensors could see and asking how far apart the two copies were.

The procedure takes four minutes. Pick the robot up and shake it in yaw — brisk, irregular, roughly 1 to 10 Hz, for about 30 seconds. The gyro reports angular rate directly at 400 Hz. The camera reports images; run a cheap frame-to-frame rotation estimate and divide by the frame interval to get a camera-derived angular rate at 30 Hz. Resample both onto a common 200 Hz grid. Then slide one against the other and find the shift that makes them agree best:

Δt̂ = arg maxτk ωgyro(tk) · ωcam(tk + τ)

The peak of that curve is the offset. On this robot it returned 12.4 ms with a repeatability of ±0.3 ms across five runs. Build it three times below — by hand, from scratch, and as the library call — then again as a runnable Code Lab in Chapter 1.

Why shaking works and driving does not. The estimator can only see the offset through motion it can measure in both streams. Gentle, smooth motion produces signals with little high-frequency content, and the correlation peak becomes a broad, flat plateau whose maximum is dominated by noise. Vigorous, irregular motion produces a sharp peak. This is the same observability argument that governs every calibration problem: you cannot estimate what you do not excite. Calibration & Time Sync works through the degenerate motions in full.

The code

"Cross-correlate them" is a sentence. Implementing it is where you discover what the sentence contains: a resampling decision, a sign convention, a windowing subtlety, and a refinement step. Do it three times, each one strictly more capable than the last.

Form 1 — five samples, on paper. Before any array library, convince yourself the peak lands where it should. Take a gyro burst that is one triangular bump, and a camera trace that is the same bump arriving one grid step late:

g = [ 0, 1, 2, 1, 0 ]      c = [ 0, 0, 1, 2, 1 ]

The score for a candidate lag l slides the camera window and dots it against the fixed gyro window. Anything that falls off either end counts as zero:

score(l) = ∑k g[k] · c[k + l]

Five lags, every product written out. No shortcuts — this is the whole algorithm.

lag lthe five products, term by termscore
−10·0 + 1·0 + 2·0 + 1·1 + 0·21
00·0 + 1·0 + 2·1 + 1·2 + 0·14
+10·0 + 1·1 + 2·2 + 1·1 + 0·06
+20·1 + 1·2 + 2·1 + 1·0 + 0·04
+30·2 + 1·1 + 2·0 + 1·0 + 0·01

The curve is 1, 4, 6, 4, 1 and it peaks at l = +1 — which is exactly the delay we injected. The peak is where the two copies of the same physical event line up, and only there do the two "2"s multiply each other to give the 4 that dominates the sum. Read the alignment off the l = +1 row: g[2] = 2 met c[3] = 2.

The sign convention, decided once and never again. A positive lag means you had to slide the camera forward in the index to make it agree, which means the camera's samples were labelled later than the instants they describe. Positive peak = camera is late. Get this backwards and you will apply the correction with the wrong sign, doubling the error instead of removing it — the exact failure this lesson names as Failure mode 7 in Chapter 4. Write the convention in the docstring, not in your memory.

Sub-sample, also on paper. The grid step is 5 ms at 200 Hz, so an integer peak can only ever report multiples of 5 ms. The true offset is not a multiple of 5 ms. Fit a parabola through the peak and its two neighbours and take its vertex. For three equally spaced points at −1, 0, +1 with heights y, y0, y+:

d = ½ · (y − y+) / (y − 2y0 + y+)
Worked example 6 — the vertex, by hand.
The symmetric case first. Our toy curve has y = 4, y0 = 6, y+ = 4:
d = 0.5 × (4 − 4) / (4 − 12 + 4) = 0.5 × 0 / (−4) = 0
Refined lag = 1 + 0 = 1.000. A symmetric peak sits exactly on its sample, which is the sanity check that the formula is not lying to you.

Now a real, slightly lopsided peak: y = 4.0, y0 = 6.0, y+ = 5.2.
Numerator: 4.0 − 5.2 = −1.2
Denominator: 4.0 − (2 × 6.0) + 5.2 = 4.0 − 12.0 + 5.2 = −2.8
d = 0.5 × (−1.2) / (−2.8) = 0.5 × 0.42857 = +0.2143 samples
At 200 Hz one sample is 5 ms, so that is 0.2143 × 5 = 1.07 ms of correction that the integer grid could not have produced. Note the denominator is negative: at a maximum the curvature is negative, and if you ever compute a positive denominator you have picked a minimum, not a peak.

Form 2 — from scratch, with the shapes written down. Now the real thing. The two drivers hand you arrays of different lengths at different rates; getting them onto a common grid without extrapolating is half the job.

python
import numpy as np

# What the two drivers actually hand you, over a 30 s shake:
#   t_imu     (12000,)     seconds, 400 Hz, host time base
#   gyro      (12000, 3)   rad/s, columns [gx, gy, gz]; yaw is column 2
#   t_cam       (900,)     seconds, 30 Hz -- these labels are LATE by dt
#   cam_omega   (900,)     rad/s, from frame-to-frame rotation / frame interval

def estimate_offset(t_a, a, t_b, b, fs=200.0, max_lag_s=0.100):
    """Seconds by which stream b's LABELS lag the instants they describe,
    relative to stream a. POSITIVE result => b is stamped late."""

    # 1. Common grid. Clip to the OVERLAP -- np.interp silently clamps
    #    outside the data range, which would fabricate flat tails and
    #    bias the correlation. Never extrapolate to make shapes match.
    t0, t1 = max(t_a[0], t_b[0]), min(t_a[-1], t_b[-1])
    grid = np.arange(t0, t1, 1.0 / fs)     # (5994,) -- 29.97 s, not 30.00
    A = np.interp(grid, t_a, a)             # (5994,) 400 Hz decimated to 200
    B = np.interp(grid, t_b, b)             # (5994,)  30 Hz upsampled to 200

    # 2. Zero-mean BOTH. A gyro bias of 0.01 rad/s times 6000 samples is a
    #    constant added to every lag -- it cannot move the peak, but it can
    #    swamp the parabola's curvature and wreck the sub-sample step.
    A = A - A.mean()
    B = B - B.mean()

    # 3. Brute-force lag sweep, deliberately wider than any plausible offset
    #    so you can SEE that the peak is a peak and not the edge of the window.
    L    = int(round(max_lag_s * fs))          # 20 samples = 100 ms
    lags = np.arange(-L, L + 1)              # (41,)
    m    = L + 1                            # guard band, so every lag
    c    = np.zeros(lags.size)               # (41,) scores the SAME window length
    for i, l in enumerate(lags):
        c[i] = np.dot(A[m : A.size - m], B[m + l : B.size - m + l])

    # 4. Sub-sample refinement: parabola through peak and both neighbours
    k = int(np.argmax(c))
    assert 0 < k < lags.size - 1, "peak on the edge: widen max_lag_s"
    y0, y1, y2 = c[k - 1], c[k], c[k + 1]
    d = 0.5 * (y0 - y2) / (y0 - 2 * y1 + y2)  # in samples, |d| <= 0.5
    return (lags[k] + d) / fs, lags, c, k, d

td, lags, c, k, d = estimate_offset(t_imu, gyro[:, 2], t_cam, cam_omega)
print("integer peak : %+d samples = %+.1f ms" % (lags[k], lags[k] / 200.0 * 1e3))
print("sub-sample d : %+.4f samples" % d)
print("offset       : %.2f ms" % (td * 1e3))

# integer peak : +2 samples = +10.0 ms
# sub-sample d : +0.4924 samples
# offset       : 12.46 ms        <- bench says 12.4 +/- 0.3 ms

Look at what the two printed lines mean together. The lag sweep on a 5 ms grid could only ever say 10.0 ms — it is off by 19 % and would have under-corrected the error by 8 mm at delivery speed. The parabola adds +0.4924 of a sample, which is 2.46 ms, and lands on 12.46 ms against an injected truth of 12.4 ms. Four lines of refinement bought an order of magnitude in resolution without changing the grid, the data, or the search.

Why the guard band m exists. Without it, a lag of +20 would compare 5974 samples while a lag of −20 compares 5974 different ones, and the two sums would not be commensurable — longer windows accumulate more product and the argmax drifts toward whichever end has more energy. Slicing A[m : size−m] against B[m+l : size−m+l] holds the window length fixed at exactly 5952 samples for every candidate. It is one of those details that never shows up in the maths and always shows up in the bug tracker.

Form 3 — the one-liner. NumPy will do the whole sweep in C. It is faster, shorter, and you should still not ship it alone.

python
# A, B already on the common grid and zero-meaned
lag = np.correlate(B, A, 'full').argmax() - (A.size - 1)
print("%+d samples = %+.1f ms" % (lag, lag / 200.0 * 1e3))   # +2 samples = +10.0 ms

# Argument order IS the sign convention. Verify it on the toy arrays,
# every time, because 'full' mode's index offset is easy to get backwards:
g = np.array([0., 1., 2., 1., 0.])
cc = np.array([0., 0., 1., 2., 1.])           # same bump, one step late
print(np.correlate(cc, g, 'full'))         # [0 0 0 1 4 6 4 1 0]  <- the hand table!
print(np.correlate(cc, g, 'full').argmax() - (len(g) - 1))   # +1  correct
print(np.correlate(g, cc, 'full').argmax() - (len(cc) - 1))  # -1  swapped!

Notice that the toy call reproduces the hand table exactly — [0 0 0 1 4 6 4 1 0], with the 1, 4, 6, 4, 1 you computed on paper sitting in the middle. That is how you check a library against your own understanding instead of trusting it: make it reproduce five numbers you already know.

Why you still write the loop. The one-liner returns an index. The loop returns a curve, and the curve is the diagnostic. Three things only the curve can tell you:
1. Is it a peak or a plateau? On our vigorous shake the neighbour sits at 98.4 % of the peak. Replace the shake with a single lazy 0.5 Hz sway and the neighbour rises to 99.999 % — the curve is flat and the argmax is chasing noise. Same code, same data volume, meaningless answer.
2. How badly does that hurt? Add 0.15 rad/s of noise to both. The vigorous shake still returns 12.43 ms with a ±0.42 ms spread over eight runs. The 0.5 Hz sway returns anything from −1.05 to 20.94 ms — a spread of ±11 ms, twenty-six times worse, and on two of those runs the sign is wrong. That is the observability argument as a measured number rather than an assertion.
3. Is it one peak or several? A periodic excitation correlates at every multiple of its period. A 5 Hz sine gives peaks every 200 ms and the estimator cannot tell them apart. The curve shows you the ambiguity; argmax hides it.
Ship both, and gate on the curve. Use np.correlate for the sweep because it is fast, then reconstruct the three points around the peak and compute a sharpness ratio y/y0. Reject the calibration if that ratio exceeds about 0.995 — the operator shook the robot too gently and the answer is not trustworthy. A calibration routine that can refuse is worth far more than one that always returns a number.

Why "nothing in the estimator changed" is the whole point

Engineers who have not shipped a robot reach immediately for the estimator. They propose retuning the process noise, or adding a robust cost, or switching from an EKF to a sliding-window optimiser. Every one of those proposals is a way of telling the filter to trust the camera less.

It does not work, and understanding why it does not work is the crux of this whole chapter. A time offset is not noise. Noise is zero-mean and averages out; that is precisely the property estimators exploit. A time offset produces an error that is deterministic, repeatable, and perfectly correlated with the robot's own motion. Averaging a thousand frames does not shrink it, because all thousand are wrong in the same direction. Inflating the measurement covariance only tells the filter to ignore its best sensor.

The one-sentence diagnosis: "This error is not zero-mean, so no amount of covariance tuning removes it. It is proportional to the robot's velocity, so it hides completely at low speed. That signature — bias that scales with motion and vanishes at rest — points at time, not at the estimator."

The same bug wearing five different costumes

Once you can recognise "error × time" you start seeing it everywhere in a robot. Every row below is the same multiplication with a different name on the badge.

Name in the fieldThe ΔtScales withMagnitude on our robot
Camera–IMU offset12 ms of pipeline differencev and ω40.8 mm at 3.4 m/s
Rolling-shutter skewreadout time across the sensor, 12 ms top to bottomω mostlyat 60 °/s: 0.72° of skew between the first and last row
LiDAR motion distortionone rotation period, 100 ms at 10 Hzv and ωat 3.4 m/s the first and last point of a scan are taken 340 mm apart
Actuator command latencycompute + CAN + driver, 8 msω of the jointa wrist at 180 °/s is 1.44° past where the planner thinks
Stale transform lookupage of the newest tf sample, up to 33 msv and ω112 mm at 3.4 m/s if you take the latest instead of interpolating

Notice the LiDAR row: 340 millimetres. It is the largest number on the page, and it is the reason every production LiDAR stack runs a deskew step that back-projects each point using the pose at the instant that point was measured. Deskew is nothing more than the interpolation machinery of Chapter 3, applied per-point instead of per-frame.

Generalisation worth saying out loud: "Any time a robot treats a measurement as instantaneous when it was actually taken over a window, or taken at a different instant than its label claims, the error is the motion during that window. So the question I ask of every sensor is not 'how accurate is it' but 'what is its capture window, and what is its label referring to'."

The vocabulary, precisely

Six words get used interchangeably in casual conversation and mean six different things in a design review. Getting them right is free signal.

TermWhat it isUnitsHow it hurts
OffsetClock A reads a different value than clock B right nowsecondsConstant bias, v·Δt. Calibratable — measure once, subtract forever.
Skew (rate error)Clock A ticks at a different rate than clock Bparts per millionOffset that grows linearly with uptime. Not calibratable as a constant; needs discipline.
DriftThe skew itself changing, usually with temperatureppm per °C, or ppm/sYesterday's correction is wrong today. Forces continuous, not one-shot, sync.
JitterRandom variation of a stamp about its meanseconds (std or p99)Irreducible noise floor. Sets the accuracy ceiling — you can subtract a mean, never a sample of noise.
LatencyDelay between the physical event and the software seeing itsecondsBecomes an offset if uncorrected; becomes phase lag in any control loop.
WanderSlow (sub-10 Hz) variation of the offsetsecondsToo slow for the servo to reject, too fast to calibrate. The residual you live with.
The distinction that decides architectures: "What is the difference between offset and skew, and why does it change your architecture?" The answer: an offset can be measured once at the factory and stored in a calibration file. A skew cannot — it turns into a growing offset, so it needs a servo that keeps correcting it. Any rig with two free-running crystals has skew — and skew is the reason NTP has a feedback loop in it.

Failure mode 0: velocity-scaled bias

Every chapter in this lesson names at least one failure mode with the symptom you would actually see and the metric that reveals it. Here is the one this whole chapter is about.

Velocity-scaled bias
SymptomLocalisation and mapping quality degrade smoothly with commanded speed. No crashes, no divergence, no tracking loss — just steadily worse numbers. Loop closures start being rejected above some speed threshold. The map develops doubled surfaces on out-and-back routes.
What it is notNot noise (it does not average out over a long route). Not divergence (the covariance stays healthy). And not purely a spatial calibration error — there is one of those here too, but it is the 4.1 mm intercept, and it is present at every speed including zero. The two coexist and the line fit is what tells them apart: kill the 12.65 ms slope and a 4.1 mm floor remains, unchanged at every speed.
The metricRegress loop-closure residual on mean traverse speed across every run in the fleet database — a line fit, not a ratio. The slope has units of seconds and is your unmodelled time offset; the intercept has units of metres and is your static calibration error. On this robot the raw ratios slide (0.0175, 0.0157, 0.0145, 0.0138 s) which looks like a dirty signal, but the line fit is clean: slope 0.01265 s → 12.65 ms, intercept 0.00408 m → 4.1 mm, with residuals of 0.2 mm. Two defects, separated, sized, and ranked by one query. Reporting only the ratio hides the intercept and makes the timing signature look inconsistent.
The confirming testPark the robot and run for ten minutes. The error should fall to the intercept — about 4 mm, not zero — which confirms motion-coupling and measures the static floor for free. Then drive the identical route at half speed and predict the answer before you look. With e = m·v + b, halving v gives e(v/2) = e(v)/2 + b/2, so the residual should land at 47/2 + 4.1/2 = 23.5 + 2.05 = 25.6 mm, not 23.5 mm. Getting 25.6 mm confirms both parameters at once; getting a clean 23.5 mm would mean there is no intercept and you mis-fit the line; getting 47 mm again means the error is not velocity-coupled at all and you are in the wrong chapter.
The fix ladder(1) Measure Δt by cross-correlation and subtract it as a constant. (2) Move the stamping site closer to the sensor. (3) Hardware-trigger the sensors so the offset is structurally zero. (4) Make Δt an estimated state so it tracks drift.

The double wall, with coordinates

"The map smears" is vague. Here is the same statement with numbers, which is what you should reach for whenever you need to be concrete.

A corridor wall runs along the line y = 2.000 m. The robot drives the corridor eastbound at 3.4 m/s, observing the wall with the camera. Because every camera measurement is 12 ms stale but labelled as current, the estimator places each observation at the pose the robot will hold 12 ms later — that is, 40.8 mm further east than the pose that actually produced it.

Work the outbound pass. Suppose a wall point is genuinely at (10.000, 2.000) in the map frame and the robot is at x = 6.000 when it sees it.

Now the return pass, westbound at the same speed. The velocity is now −3.4 m/s, so the sign flips:

Two copies of one wall, separated by 10.041 − 9.959 = 0.082 m. That is the double wall, and 82 mm is comfortably larger than the 50 mm inlier gate on the loop-closure verifier, which is why the loop is rejected rather than merged.

The tell inside the tell. The separation is 2 v Δt, not vΔt, because the sign of v flips between passes. So if you measure the doubling distance in a map you can read the offset straight off it: Δt = separation / (2v) = 0.082 / (2 × 3.4) = 0.01206 s. A map artefact just gave you a millisecond-accurate clock measurement, with no instrumentation at all.

Triage order, cheapest first

Given the ticket at the top of this chapter, this is the order to work in. The order matters as much as the content, because each step is chosen to buy the most information per minute.

#CheckCostWhat it rules in or out
1Park the robot for 10 minutes and watch the error10 min, zero codeIf error → 0, the fault is motion-coupled: time, or a motion-dependent model error. If not, it is a static bias: extrinsics, intrinsics, or sensor bias.
2Regress historical error on traverse speed across the fleet database — slope and intercept, not a ratioone SQL query plus a two-parameter fitThe slope names the time offset in seconds and the intercept names the static error in metres, both from data you already have and both sized. A ratio would conflate them and make the timing signature look inconsistent.
3Replay one bag at half playback rate through the same estimator20 minDistinguishes a data problem from a compute problem. If slowing the replay fixes it, you were dropping frames or missing deadlines, not misaligning clocks.
4Shake test + cross-correlation of gyro vs camera rotation1 hour incl. toolingMeasures Δt directly, to a fraction of a millisecond. This is the definitive test.
5Scope the trigger line against the driver stamphalf a day, needs hardwareLocalises which stage of which chain contributes the offset, so you can fix the cause rather than subtract the symptom.
Note step 3. Half-rate replay is the single most underrated diagnostic in robotics, and very few teams use it. It separates the two big families of speed-dependent failure — misaligned data versus missed deadlines — in twenty minutes, without touching the robot. If the bug survives half-rate replay of the same bag, compute is exonerated and the problem is in the data itself.

The whole diagnosis in ninety seconds

"The error is linear in speed, so I would fit a line rather than take a ratio — the ratio only equals the slope if the intercept is zero, and here it is not. The slope comes out at about 12.7 milliseconds, which has units of seconds and therefore names an unmodelled time offset between two sensors, not an estimator problem. The intercept comes out at about 4 millimetres, which has units of metres and is a separate, static calibration error worth roughly a tenth as much.

I would confirm it two ways. First, park the robot: if the error goes to zero, it is motion-coupled. Second, shake it and cross-correlate the gyro against a camera-derived rotation rate; the peak of that correlation is the offset directly.

Then I would fix it in the order that costs least. Short term, subtract the measured constant. Medium term, move the stamping site from the ROS callback down to the driver, or better, to a hardware trigger — that removes the offset structurally instead of compensating for it. Long term, put the offset in the state vector so it tracks thermal drift, and add a fleet telemetry signal so a robot whose sync service dies is flagged before a customer notices.

The one thing I would not do is retune the filter. A time offset is a deterministic bias correlated with the robot's own motion, so no covariance change removes it — it would just make me trust my best sensor less."

Ninety seconds. It names the observation, the diagnosis, two independent confirmations, a cost-ordered fix ladder, and one explicit anti-pattern. Everything after this chapter is the material that backs each of those sentences and the follow-up questions they invite.

The three questions this lesson answers

ChapterThe questionThe one-line answer
1Do the two sensors even share a clock?Usually not. Clocks differ in offset and in rate, and only one architecture — a shared hardware trigger — makes the question go away.
2Does the timestamp on the message describe when the data was captured?Almost never, unless someone deliberately made it so. The default is the time the software noticed.
3Given two streams that never sample at the same instant, how do you compare them?Interpolate to a common time, with a buffer that refuses to extrapolate. Never pair-and-hope.

Five angles on the same material

This topic is on no syllabus. There is no chapter in Thrun or Barfoot called "timestamps". Every engineer who understands it learned it in the field, by losing a week to it. That makes it an unusually honest proxy for whether you have shipped — and this lesson works it from five angles.

AngleThe question it posesWhat a shallow answer sounds like
Concept"Derive the error a 5 ms offset causes.""It would make things less accurate."
Design"Architect the timing for a 6-sensor rig.""Run NTP." (and stop there)
Code"Implement the time-aligned lookup."Nearest-neighbour with no bracketing and no edge cases.
Debug"The map smears only when turning. Go.""Recalibrate the extrinsics."
Frontier"What is changing here?"Silence, or "PTP I guess".

A day in the life of the engineer who owns this.

The frontier

Everything in this chapter has been standing on three specific pieces of work, and none of them has been named yet. Naming them is not bibliography — it is the difference between "I have a diagnostic" and "I know which decade of the field my diagnostic came from, and what replaced it".

The workYearWhat it changed
Furgale, Rehder & Siegwart, "Unified Temporal and Spatial Calibration for Multi-Sensor Systems", IROS2013Before this, Δt was something you measured separately and pasted into a config file. Furgale represents the trajectory as a continuous-time B-spline instead of a set of discrete poses, which means the state can be evaluated at any real-valued instant — and therefore Δt can be differentiated and dropped into the same batch optimisation as the extrinsics. Time stops being a pre-processing step and becomes a parameter. This is the machinery inside kalibr_calibrate_imu_camera, which is what your team is almost certainly already running.
Qin & Shen, "Online Temporal Calibration for Monocular Visual-Inertial Systems", IROS2018Furgale's estimate is a batch answer: correct for the rig on the day you waved a checkerboard at it. Qin & Shen make td a live state in the sliding-window optimiser of VINS-Mono, updated every keyframe from ordinary motion, by shifting feature observations along their image-plane velocity. That is the whole "per-boot constant versus filter state" argument you overhear at 16:30, and this paper is the answer to it: the offset moves, so estimate it while you move.
IEEE 1588-2019 and IEEE 802.1AS-2020 (gPTP)2019–2020The standards revisions that pushed hardware timestamping down into commodity silicon — and, via 802.1AS, into automotive Ethernet as a mandatory profile rather than an option. 1588-2019 folds in the High Accuracy profile (the White Rabbit lineage) and takes the achievable residual from microseconds to sub-nanosecond on supporting hardware. The practical consequence for a robot: the host-to-host part of the problem is becoming an infrastructure guarantee you configure, not an error term you measure.

Read those three as one arc. 2013: the offset becomes estimable. 2018: the offset becomes estimable online. 2019–2020: the part of the offset that lives in the network stops being your problem at all. What is left over — the sensor-to-sensor residual inside one box — is the part that is still open.

The open question, which is what makes this frontier rather than history. All three works above treat Δt as constant over the interval they estimate it on. It is not. A camera's readout timing, an MCU's crystal, and a USB controller's scheduling all shift with die temperature, and a delivery robot goes from a cold garage to direct August sun inside one shift. Nobody has a settled answer to how fast Δt may be allowed to be re-estimated before the estimator starts absorbing genuine motion into the time state and quietly corrupting scale. Estimate it too slowly and you track a stale number; too quickly and Δt becomes a nuisance parameter that soaks up your IMU bias. If you want one sentence to carry out of this section: "the frontier is not measuring the offset — that is solved twice over — it is deciding the bandwidth at which you are allowed to keep re-measuring it."
Where to go deeper. Chapter 1's frontier section extends this arc forward to targetless and continuous-time LiDAR-inertial calibration, and Chapter 3 works the estimator-side version of the bandwidth question. The mathematics of the B-spline formulation itself — knot spacing, the observability conditions, why a constant-velocity segment makes Δt unobservable — lives in Calibration & Time Sync rather than here.

The spec you end up writing

Everything above converges on one artefact: a timing specification with numbers in it. This is what the rest of the lesson builds toward, and being able to sketch it is what separates "I have heard of PTP" from "I have shipped a synchronised rig".

QuantityTargetWhere it comes from
Camera–IMU relative offset, residual after correction< 1 ms10 mm budget at 5 m/s, with margin
Camera–IMU jitter, p99< 0.5 msIrreducible; must sit well under the offset target
Host–LiDAR clock offset (PTP)< 100 µsDeskew quality at 10 Hz rotation
Clock rate error after discipline< 1 ppmKeeps a one-hour mission under 3.6 ms of accumulated skew
Sync-health telemetry cadence1 Hz per sensorSo a dead sync daemon is caught in seconds, not in a customer report
Where this lesson deliberately stops. The underlying theory already lives on this site and repeating it would be a waste of your time. For the mathematics of temporal calibration — the B-spline trajectory, the observability conditions, the full Kalibr pipeline — read Calibration & Time Sync. For what "simultaneous" even means when there is no shared clock at all, read Lamport Clocks. This lesson spends its words on the performance: the derivation under pressure, the architecture with its costs, the failure taxonomy, and the tradeoff you have to defend.
Quiz: A colleague argues that a 12 ms camera-IMU offset can be absorbed by inflating the camera's measurement covariance, since the filter will then weight it appropriately. What is wrong with that argument?

Chapter 1: Clock Domains, PTP, NTP & Hardware Triggering

Draw a box on the whiteboard, label it "camera". Draw another, label it "IMU". Now ask the only question that matters: "Do these two share a clock?"

The answer that ends the conversation early is "yes, they are both on the robot." The answer that opens it up is: "Physically, no — there are at least three independent oscillators in this rig and probably five. The real question is which ones are disciplined to a common reference, how tightly, and what the residual is."

The concept

A clock domain is a set of timestamps produced by counting the same physical oscillator. Two timestamps from the same domain are directly comparable. Two timestamps from different domains are not comparable at all until you know the mapping between them — and that mapping has two parameters, not one.

Model a clock as a function that turns true time t into a reading C(t). To first order every real clock obeys:

C(t) = (1 + ε) · t + b

b is the offset, in seconds: how far the reading is from truth right now. ε is the rate error or skew, dimensionless, and always quoted in parts per million because the numbers are otherwise all zeros: a 50 ppm crystal has ε = 50 × 10−6 = 0.00005.

Now compare two clocks, A and B, in different domains:

CA(t) − CB(t) = (εA − εB) · t + (bA − bB)

That is the entire theory of clock synchronisation in one line. The gap between two clocks is a straight line in time: a constant term you can measure once, plus a slope you have to keep correcting. Every protocol in this chapter exists to estimate one or both of those two numbers.

Worked example 1 — how bad is a cheap crystal?
A standard 25 MHz MEMS oscillator is specified at ±50 ppm over its temperature range. Suppose the camera board sits at +30 ppm and the IMU board at −16 ppm.
Relative rate error: εrel = 30 − (−16) = 46 ppm
Accumulated offset after one minute: 46 × 10−6 × 60 = 0.00276 s = 2.76 ms
After ten minutes: 46 × 10−6 × 600 = 0.0276 s = 27.6 ms
After forty minutes: 46 × 10−6 × 2400 = 0.1104 s = 110.4 ms
After one hour: 46 × 10−6 × 3600 = 0.1656 s = 165.6 ms.
Now convert that to distance, which is the only unit a robot cares about. At 3.4 m/s: 3.4 × 0.1656 = 0.563 m = 563 mm of position error — more than a robot body length, from two crystals that both passed incoming inspection.

Read that example again, because it contains the single most important structural fact in this chapter: a rate error is not a bug you can calibrate away once. Measure the offset at boot, store 2.76 ms in a file, and forty minutes later the file is wrong by 108 ms. Any architecture that treats sync as a one-shot factory step is broken by physics.

Then run the example backwards, because that is the form the question usually arrives in. You are not asked "how much drift in an hour"; you are asked "we have a 3 ms alignment spec — how long does this rig hold it?" Divide instead of multiply:

tbudget = spec / εrel
Worked example 1b — how long does the spec survive? Take the same 3 ms alignment budget the Practice widget below plots as a red line.
Two MEMS crystals, εrel = 46 ppm: t = 0.003 / (46 × 10−6) = 0.003 / 0.000046 = 65 s.
Two TCXOs, worst-case εrel = 2 − (−2) = 4 ppm: t = 0.003 / (4 × 10−6) = 750 s = 12.5 min.
Two OCXOs, εrel = 0.02 ppm: t = 0.003 / (2 × 10−8) = 150,000 s ≈ 42 hours.
The MEMS answer is the one to say out loud: sixty-five seconds. An undisciplined cheap-crystal rig blows a millisecond-class spec before the robot has finished driving out of the depot. That is why "we sync at boot" is not an architecture.

One more reading of the same number, because it is the one that unlocks the Debugging section. The slope is the diagnostic. If you log the difference between two clocks and it is a straight line through the origin, you are looking at εrel directly: read the rise over the run and you have the ppm. If it is a straight line with a jump in it, a daemon stepped. If it is flat but noisy, you are looking at measurement noise on a disciplined clock. Three shapes, three completely different bugs, one plot.

Oscillator gradeTypical stabilityDrift in 1 hourCostWhere you see it
MEMS / basic XO±50 ppm180 ms< $0.20Sensor boards, MCUs, most consumer hardware
TCXO (temp-compensated)±2 ppm7.2 ms$1–5GNSS receivers, better IMUs
OCXO (oven-controlled)±0.01 ppm36 µs$50–500Time servers, telecom, survey-grade GNSS
Rubidium±0.0005 ppm1.8 µs$1k+Grandmasters that must hold over a GNSS outage

The right column is the design conversation. Nobody puts a rubidium standard on a sidewalk robot. Instead you put a cheap oscillator everywhere and discipline it — run a servo that continuously nudges its offset and its rate toward a reference. The protocols below are how the servo gets its error signal.

The one place an expensive oscillator still earns its price is holdover: the interval during which a clock must keep time after its reference disappears. A grandmaster steered by GNSS is only as good as its sky view, and a robot drives into tunnels, under bridges, and between buildings. When the satellites go away the grandmaster stops being disciplined and starts being just an oscillator again — the same straight line from the top of this section, restarted from zero.

Worked example 1c — sizing a grandmaster for a tunnel. The robot passes under a 400 m overpass at 3.4 m/s, so GNSS is unavailable for 400 / 3.4 = 118 s. Round to a 120 s outage. Alignment spec is still 3 ms; give holdover a tenth of it, 300 µs, so the rest of the budget survives for the sensor path.
Required stability: ε = 300 × 10−6 s / 120 s = 2.5 × 10−6 = 2.5 ppm.
A TCXO at ±2 ppm clears it: 2 × 10−6 × 120 = 240 µs. A MEMS part does not: 50 × 10−6 × 120 = 6.0 ms, twice the entire spec, from one overpass.
Now change one number — the robot parks in a warehouse for an 8-hour shift with no sky at all. 28,800 s × 2 × 10−6 = 57.6 ms from the TCXO; the OCXO at 0.01 ppm gives 28,800 × 10−8 = 288 µs and is the only part that survives. Holdover requirement is set by outage duration, not by accuracy, and that is the sentence that gets you the OCXO line item approved.

Two consequences worth knowing. First, holdover is why the PTP wire protocol carries a clockClass field: a GNSS-locked grandmaster advertises class 6, and the instant it loses lock it re-advertises as class 7, "holdover". The number is not cosmetic — the Best Master Clock Algorithm uses it to decide whether some other node should take over. Second, holdover degradation is graceful and announced, which makes it the easiest of all the failure modes in this chapter to detect, and therefore the least likely to be the answer when something is quietly wrong.

The two-parameter habit. Every time you talk about a clock, say both numbers. "The offset is 80 ns" is half a sentence. "The offset is 80 ns and the residual rate error is under 0.05 ppm, so it will still be under a microsecond in twenty seconds if the link dies" is the whole one. The second half is the engineering; the first half is what the dashboard already printed.

This is also the reason the disciplining servo in the code section estimates a rate and not just an offset: correcting position without correcting rate means you re-acquire the same error immediately, forever.

NTP: two round trips, one assumption

NTP (Network Time Protocol) estimates the offset between a client and a server using four timestamps from one exchange. The derivation is short enough to do at a whiteboard, and worth doing because it is the smallest complete example of a measurement model with an unobservable nuisance parameter.

t1
Client sends the request, by the client's clock
↓ travels the network for dout
t2
Server receives it, by the server's clock
↓ server thinks for a while
t3
Server sends the reply, by the server's clock
↓ travels the network for dback
t4
Client receives it, by the client's clock

Let θ be the true offset (server clock minus client clock). Then the outbound leg and the return leg give:

t2 = t1 + θ + dout      t4 = t3 − θ + dback

Two equations, three unknowns: θ, dout, dback. The system is underdetermined, and no amount of extra exchanges fixes that — each new exchange adds two more unknowns with two more equations. NTP closes the gap with one assumption, and it is the only assumption in the protocol:

The NTP assumption: the path is symmetric, dout = dback = δ/2. Everything NTP claims about accuracy is downstream of this being true, and on a real network it is routinely false.

With symmetry assumed, subtract the two equations to eliminate the delay, and add them to eliminate the offset. Do it slowly, because the two directions are easy to swap under pressure and the whole point of this derivation is that you can produce it at a whiteboard. First rewrite each equation as a difference, so both legs have the same shape:

t2 − t1 = θ + dout      t4 − t3 = −θ + dback

Look at the signs before you touch the algebra, because they tell you which operation does what. θ appears with opposite signs, so subtracting the two lines reinforces it and adding them kills it. The delays appear with the same sign, so it is the other way round for them. Subtract first:

(t2 − t1) − (t4 − t3) = (θ + dout) − (−θ + dback) = 2θ + (dout − dback)

The delay term has not vanished — it has been reduced to a difference. That is where the assumption enters, and it is the only place it enters: if dout = dback the parenthesis is zero and what is left is exactly twice the offset.

(t2 − t1) − (t4 − t3) = 2θ     (symmetric path)   ⇒   θ̂ = ½[ (t2 − t1) − (t4 − t3) ]

That is already the answer. The only remaining step is cosmetic: −(t4 − t3) is the same thing as (t3 − t4), which is how RFC 5905 and every implementation write it, so match the two forms in your head once and you will never mis-transcribe it:

θ̂ = ½[ (t2 − t1) − (t4 − t3) ] = ½[ (t2 − t1) + (t3 − t4) ]

Now go back to the same two difference equations and add them instead. This time it is θ that cancels, and notice that this cancellation needs no assumption at all — the ±θ simply annihilates:

(t2 − t1) + (t4 − t3) = (θ + dout) + (−θ + dback) = dout + dback = δ

Regroup those same four stamps and you get the form that is printed everywhere. Expand both sides to check it rather than trusting the shape: the left is t2 − t1 + t4 − t3, and the right is t4 − t1 − t3 + t2. Same four terms, same four signs.

δ = (t2 − t1) + (t4 − t3) = (t4 − t1) − (t3 − t2)

Read the right-hand form out loud when you write it: total elapsed on the client's clock, minus the interval the server admits it spent holding the packet. Both brackets are differences taken within a single clock domain, which is why δ is immune to the offset — a constant added to both t2 and t3 cancels inside its own bracket.

The pair, boxed — and the asymmetry between them.
θ̂ = ½[ (t2 − t1) + (t3 − t4) ]     δ = (t4 − t1) − (t3 − t2)
Say this while you draw the box: δ is exact and θ̂ is not. The round trip needed no assumption; the offset needed symmetry. Everything that goes wrong in the next two examples is a consequence of that one difference, and it is why a healthy-looking δ is not evidence that θ̂ is healthy.
Worked example 2 — a healthy exchange. All values in milliseconds, client clock zeroed at t1.
t1 = 0.0, t2 = 12.2, t3 = 15.5, t4 = 28.1
Offset: θ̂ = ½[(12.2 − 0.0) + (15.5 − 28.1)] = ½[12.2 + (−12.6)] = ½(−0.4) = −0.2 ms
Round trip: δ = (28.1 − 0.0) − (15.5 − 12.2) = 28.1 − 3.3 = 24.8 ms
So the client is 0.2 ms ahead of the server — a beautifully small number, on a 24.8 ms round trip.
Worked example 3 — the same round trip, asymmetric path. Now suppose the true offset is exactly zero but the outbound leg is congested: dout = 18.0 ms, dback = 6.8 ms. Same total delay, same server processing time.
t1 = 0.0, t2 = 0.0 + 0 + 18.0 = 18.0, t3 = 21.3, t4 = 21.3 − 0 + 6.8 = 28.1
θ̂ = ½[(18.0 − 0.0) + (21.3 − 28.1)] = ½[18.0 − 6.8] = ½(11.2) = +5.6 ms
δ = 28.1 − 3.3 = 24.8 ms — identical to the healthy case.
NTP confidently reports 5.6 ms of offset that does not exist, and the round-trip statistic gives no hint that anything is wrong.

Generalise those two examples and you get the error law of NTP, which is worth memorising because it explains every architectural decision that follows:

θ̂ − θ = ½ (dout − dback)

Derive that in one line rather than quoting it, since you already have the pieces. Substitute the true relations t2 − t1 = θ + dout and t3 − t4 = θ − dback straight into the estimator:

θ̂ = ½[ (θ + dout) + (θ − dback) ] = θ + ½(dout − dback)

Now bound it, which takes two more lines and is the part most people skip. Both legs are physical delays, so both are non-negative, and by the exact result above they sum to the measured δ. That pins dback = δ − dout with dout ∈ [0, δ]. Substitute and the two-variable expression collapses to one variable:

θ̂ − θ = ½( dout − (δ − dout) ) = dout − ½δ

As dout sweeps its entire feasible range from 0 to δ, that expression sweeps linearly from −δ/2 to +δ/2, and nothing in the protocol narrows it, because dout is never observed. Check it against Worked example 3: there dout = 18.0 and δ = 24.8, so the predicted error is 18.0 − 12.4 = +5.6 ms — exactly the number the estimator produced, from a formula that never saw the congestion.

The bound, stated the way a staff engineer states it. Since delays cannot be negative, the asymmetry is bounded by the round trip, so the worst-case NTP error is δ/2. On a 24.8 ms round trip that is ±12.4 ms — the entire budget of the bug in Chapter 0, from a protocol that reported a 0.2 ms offset. The reported number is a point estimate; δ/2 is the honest interval. Quote the interval.

This is why NTP is a fine way to keep log files roughly in order and a poor way to align a camera to an IMU. Its accuracy is not limited by its arithmetic; it is limited by an assumption about a network you do not control. Google's Spanner (Corbett et al., OSDI 2012) took the opposite and more honest approach for exactly this reason: its TrueTime API returns an interval [earliest, latest] instead of a point, and the database is built to wait out the uncertainty rather than pretend it is zero.

PTP: move the timestamp closer to the wire

PTP (Precision Time Protocol, IEEE 1588) uses the same four-timestamp exchange and the same symmetry assumption. It is three to four orders of magnitude better than NTP, and the reason has nothing to do with the mathematics.

The dominant error in software NTP is not path asymmetry on a switched LAN — it is the stack. When a userspace daemon calls clock_gettime() after recvmsg() returns, the timestamp includes the kernel's interrupt latency, the scheduler's wake-up delay, and whatever else the CPU was doing. On a loaded non-realtime Linux box that is tens to hundreds of microseconds, and it is variable, which means it does not average out into a calibratable constant.

PTP's insight: put the timestamping hardware in the network interface itself. A PTP-capable NIC has its own free-running counter (the PHC, PTP Hardware Clock) and latches it the instant the packet's start-of-frame delimiter crosses the MAC layer. The software stack is now entirely outside the measurement.

MechanismWhere the stamp is takenTypical accuracyWhat limits it
NTP over the internetuserspace1–50 msRoute asymmetry, queueing
NTP over a quiet LANuserspace0.1–1 msScheduler jitter, interrupt latency
PTP, software timestampingkernel driver10–100 µsKernel latency, switch queueing
PTP, NIC hardware timestampingMAC layer, in silicon0.1–1 µsSwitch residence-time variation
PTP + transparent/boundary clocksMAC, every hop corrects< 100 nsCable-length asymmetry, PHY delay
White Rabbit (CERN, now IEEE 1588-2019)MAC + phase-locked PHY< 1 nsFibre dispersion
Shared hardware triggera wire< 10 nsCable propagation, ~5 ns per metre

The transparent clock is the mechanism worth being able to explain, because it is where PTP stops being "NTP but faster" and becomes a different idea. A PTP-aware switch measures how long each sync packet actually sat inside it — the residence time — and writes that number into a correction field in the packet itself. The receiver subtracts it. Queueing delay, the thing that destroys NTP, is now measured rather than assumed away.

PTP in three sentences. "PTP and NTP solve the same equations. PTP wins because it timestamps in the NIC hardware rather than in userspace, which removes the operating system from the measurement path. And PTP-aware switches report their own queueing delay in a correction field, so the symmetric-path assumption stops being load-dependent."

Hardware triggering: deleting the question

Every protocol above estimates the relationship between clock domains. Hardware triggering does something categorically different: it makes the sensors sample at the same instant, so there is no relationship left to estimate.

A single line — a 3.3 V pulse from an FPGA or a microcontroller — fans out to the trigger input of every camera and to a latch input on the IMU. When the edge arrives, every camera opens its shutter and the IMU's current sample index is captured into a register alongside the FPGA's own counter value. There is now exactly one clock in the system that matters, and it is the FPGA's.

Worked example 4 — how good is a wire? Signals propagate in copper at roughly 2 × 108 m/s, which is 5 ns per metre.
On a 1.2 m robot, the worst-case skew from cable length alone is 1.2 × 5 = 6 ns.
Convert that to position error at 3.4 m/s: 3.4 × 6 × 10−9 = 2.04 × 10−8 m = 20 nanometres.
Compare to the 12 ms software offset from Chapter 0, which cost 40.8 mm. The wire is two million times better, and it costs one FPGA pin.

That factor of two million is the argument. The natural objection is "why not just use PTP everywhere?" — and the answer is that PTP aligns clocks while a trigger aligns captures, and only the second one removes the exposure-timing and free-running-phase problems as well.

Protocol sync (NTP/PTP)Hardware trigger
What it alignsClock readingsPhysical capture instants
Residual error0.1 µs – 1 ms depending on tier< 10 ns
Works with third-party sensorsYes, if they speak the protocolOnly if they expose a trigger pin
Handles free-running phaseNo — a 30 Hz camera and a 400 Hz IMU still land on different instantsYes — that is the whole point
CostPTP NIC + switch, ~$40–200An FPGA/MCU pin, wiring harness, EMC review
Fails howGracefully — offset grows, telemetry shows itSilently, if the harness breaks and the sensor free-runs
The nuance that scores points. A hardware trigger removes the relative offset between triggered sensors. It does not tell you what those captures mean in the host's own time base — you still need one mapping from FPGA counter to host clock, and that mapping still has an offset and a rate. The usual answer is a GNSS PPS (pulse per second) line into both the FPGA and the host, or a single PTP link between them. Anyone who says "hardware trigger, done" has not built one.

The other silent failure: if the trigger cable is disconnected, most cameras quietly fall back to free-running mode and keep producing images. Nothing errors. You need a liveness check that compares the observed frame interval against the commanded one and screams when they diverge.

The design

Here is the rig to architect, with real rates and real byte counts. This is a mid-size delivery robot, and the numbers are the ones you should be prepared to defend in a design review.

SensorRatePayloadBandwidthClock domainSync mechanism
4 × global-shutter camera, 1280×800 mono30 Hz1.02 MB/frame123 MB/s totalFPGA counterHardware trigger, 30.000 Hz
IMU (6-axis MEMS)400 Hz28 B/sample11 kB/sIts own XO, latched by FPGAData-ready line latched on the same counter
LiDAR, 32-beam spinning10 Hz rotation, 600 kpt/s16 B/point9.6 MB/sInternal, PTP slavePTP (gPTP) over Ethernet
GNSS receiver10 Hz fix, 1 Hz PPS~200 B/fix2 kB/sGNSS timePPS line to FPGA + host; NMEA for the second number
Wheel encoders200 Hz16 B3 kB/sMotor controller MCUCAN timestamped on arrival + measured constant latency
Host computerCLOCK_REALTIMEptp4l on the NIC, phc2sys to the system clock

Total: ~133 MB/s. Check the arithmetic rather than quoting the sum, because the camera term dominates by two orders of magnitude and that is itself the finding. One frame is 1280 × 800 × 1 byte = 1,024,000 B = 1.02 MB. Four cameras at 30 Hz: 4 × 30 × 1.024 = 122.9 MB/s. LiDAR: 600,000 pt/s × 16 B = 9.6 MB/s. Everything else — IMU 400 × 28 = 11.2 kB/s, encoders 200 × 16 = 3.2 kB/s, GNSS 10 × 200 = 2 kB/s — sums to 16 kB/s, which is 0.01% of the total. The whole bandwidth problem is the cameras.

Now turn bandwidth into latency, because that is the conversion the sync argument needs. Put the four cameras on two USB3 host controllers, two each. A 5 Gb/s USB3 link does not deliver 5 Gb/s of payload: 8b/10b line coding takes 20% (→ 4.0 Gb/s = 500 MB/s), and USB3 bulk framing plus UVC headers take roughly another quarter, leaving about 375 MB/s of usable payload. One frame alone on an idle controller therefore takes 1.024 / 375 = 2.73 ms.

But the cameras are hardware-triggered, so they are never staggered — both frames on a controller are generated in the same instant and the second one queues behind the first. Its bytes finish at 2 × 2.73 = 5.46 ms, and a pipeline that waits for all four views must budget the last arrival, not the average. That is the 5.5 ms in the link-transfer row below; it is a derived number, not a measured one.

The utilisation trap, in numbers. Average utilisation on each controller is 61.4 MB/s of 375 MB/s = 16.4%, which any capacity review would call "plenty of headroom". But the traffic is not average — it is 100% of the link for 5.5 ms out of every 33.3 ms frame period, and 5.5 / 33.3 = 16.5% duty cycle. Same number, two completely different stories. Bandwidth budgets quoted as averages hide exactly the burst that creates the latency you are trying to sync away. Quote the burst window and the duty cycle, not the mean.
It also sizes the jitter: ±1.2 ms of contention at 375 MB/s is 0.0012 × 375 = 450 kB of foreign traffic landing between your packets — about half a frame. That is a scheduling decision, not a noise process, which is why the row is marked jitter not calibratable.

The same conversion is the argument against moving cameras onto the shared 10 GbE spine "because there is room". There is: 133 MB/s of 1180 MB/s is 11%. But now all four frames contend with the LiDAR's 9.6 MB/s and with each other in one queue, and the last frame lands at 4 × 1.024 / 1180 = 3.5 ms plus whatever the LiDAR inserted. Faster on paper, and coupled to a stream you do not control. Every megabyte you move is milliseconds of the offset you are trying to eliminate, and every shared link is a stage whose jitter you cannot subtract.

The daemon stack, and the piece everyone forgets. On Linux the chain has three independent links, and an engineer who names all three is immediately distinguishable from one who says "we run PTP":

1. Grandmaster
GNSS receiver disciplined by satellites, or an OCXO holdover box. Announces itself via BMCA (Best Master Clock Algorithm).
↓ PTP messages over Ethernet
2. ptp4l
Disciplines the NIC's PHC to the grandmaster. Reports offset-from-master every second. This is the link people monitor.
↓ PHC is now correct — but nothing else is
3. phc2sys
Copies the PHC into CLOCK_REALTIME, the clock every ROS node actually reads. Omit this and ptp4l reports a perfect lock while your application timestamps free-run.
↓ the system clock is finally disciplined
4. Application
clock_gettime(CLOCK_REALTIME) — what rclcpp::Clock::now() returns.
This is the highest-yield operational fact in the chapter. ptp4l without phc2sys is the classic production failure: every dashboard is green, the PTP offset is 80 ns, and the application clock is drifting at 46 ppm because nobody connected the last link. It is Chapter 0's bug, delivered by a monitoring system that says everything is fine.

Latency budget, stage by stage, for the camera path on this rig. This is the table a design review runs on. The point is not the exact numbers — it is that you know which stages have jitter and which do not.

StageMeanJitter (p99 − mean)Calibratable?
Trigger edge → shutter open2 µs< 0.1 µsYes, and negligible
Exposure (auto, 2–20 ms)8 ms (centre at +4 ms)±9 ms as light changesYes — if the driver reports the actual exposure per frame
Sensor readout2 ms< 0.05 msYes, fixed by the sensor
Link transfer (1.02 MB)5.5 ms±1.2 ms (bus contention)Mean yes, jitter no
Driver wake (non-RT kernel)1.5 ms±0.8 msMean yes, jitter no
ROS callback dispatch2.0 ms±3.0 msNo — depends on system load

Reading the table is the skill. The "calibratable?" column is doing all the work, and it splits every row into two independent questions: can I subtract the mean? and can I subtract this frame's deviation from that mean? A stage whose mean is fixed and known contributes a constant lag you remove with one subtraction and forget. A stage whose per-frame value you cannot observe contributes noise that survives every correction you will ever apply. Sum the first kind to get the bias; combine the second kind to get the floor.

Worked example 5 — combining the jitter. Independent noise sources add in quadrature, not linearly, because independent variances add and the standard deviation is the square root of the variance. Stamping at the ROS callback, take every row whose per-frame deviation you cannot observe — that is the three rows marked "jitter no" or "no": link transfer (±1.2 ms), driver wake (±0.8 ms), and callback dispatch (±3.0 ms). Square them, sum, take the root, showing every term:
1.22 = 1.44    0.82 = 0.64    3.02 = 9.00
σtotal = √(1.44 + 0.64 + 9.00) = √11.08 = 3.33 ms
And the mean over the whole path is 4 + 2 + 5.5 + 1.5 + 2.0 = 15.0 ms (the exposure row contributes its centre, +4 ms; the 2 µs trigger-to-shutter term is below the rounding).
So the reported timestamp is 15.0 ± 3.33 ms late. You can subtract the 15.0. You can never subtract the 3.33 — it is a fresh random draw every frame, and it is your accuracy floor. At 3.4 m/s: 3.4 × 0.00333 = 0.0113 m = 11.3 mm of irreducible position noise.
Why the ±9 ms exposure row is not in that sum — and what happens when it is. Exposure is the largest jitter term in the whole table, and it is excluded for exactly one reason: this driver reports the actual exposure duration per frame, so the mid-exposure instant is computable per frame and the term becomes subtractable rather than random. That is a property of the driver, not of the physics.
On a camera whose driver does not expose it — and there are many, including several popular USB3 machine-vision stacks in auto-exposure mode — the row moves back into the noise sum:
9.02 = 81.00, so σtotal = √(81.00 + 1.44 + 0.64 + 9.00) = √92.08 = 9.60 ms, which is 3.4 × 0.00960 = 32.6 mm.
The exposure alone contributes 81 of that 92.08 — 88% of the variance — so every other engineering effort on the pipeline is rounding error until it is fixed. This is the first question to ask about any new camera, ahead of resolution, frame rate and lens: does the driver report per-frame exposure? If it does not, either lock the exposure to a fixed value (giving up dynamic range to buy timing) or replace the driver. Nothing downstream can recover it.

That contrast is the reason the stamping site matters more than the protocol. Perfect PTP with a 100 ns lock buys you nothing if the number you then write into header.stamp was measured after 3.33 ms of scheduler noise — and on the driver that hides its exposure, that same 100 ns lock is 96,000 times smaller than the 9.6 ms of noise sitting on top of it (9.6 × 10−3 / 100 × 10−9 = 96,000). Sync the clocks, then move the stamp, then interrogate the driver. In that order, because each step is cheaper than the one after it and the last one recovers the most.

The code

Two implementations, both short, both worth being able to produce on demand. First the NTP offset estimator, exactly as derived above, with the honest uncertainty interval attached.

python
import numpy as np

def ntp_offset(t1, t2, t3, t4):
    """One NTP exchange. t1,t4 on the client clock; t2,t3 on the server clock.
    Returns (offset_estimate, round_trip, worst_case_error).
    offset is (server - client): ADD it to a client stamp to get server time."""
    theta = 0.5 * ((t2 - t1) + (t3 - t4))   # symmetric-path estimate
    delta = (t4 - t1) - (t3 - t2)               # round trip, minus server think time
    return theta, delta, delta / 2.0          # the bound is HALF the round trip

# Healthy exchange (ms)
print(ntp_offset(0.0, 12.2, 15.5, 28.1))   # (-0.2, 24.8, 12.4)
# True offset zero, but the outbound leg is congested
print(ntp_offset(0.0, 18.0, 21.3, 28.1))   # (+5.6, 24.8, 12.4)  <- same delta!

The second is the one you would actually ship: a least-squares fit of both clock parameters at once, so you recover the rate as well as the offset. This is what a disciplining servo is doing underneath, and it is four lines.

python
def fit_clock(t_ref, t_dev):
    """Paired observations of the same instants on two clocks.
    Model:  t_dev = (1 + eps) * t_ref + b
    Returns (offset_seconds_at_t_ref_zero, skew_ppm)."""
    A = np.column_stack([t_ref, np.ones_like(t_ref)])   # (N, 2)
    slope, b = np.linalg.lstsq(A, t_dev, rcond=None)[0]
    return b, (slope - 1.0) * 1e6                       # ppm

# 20 minutes of PPS-vs-host-clock pairs, 1 Hz
t_ref = np.arange(1200, dtype=float)
t_dev = t_ref * (1 + 46e-6) + 0.0031 + np.random.default_rng(0).normal(0, 2e-5, 1200)
b, ppm = fit_clock(t_ref, t_dev)
print("offset %.4f ms, skew %.2f ppm" % (b * 1e3, ppm))  # ~3.10 ms, ~46.0 ppm
Why the two-parameter fit and not a running average of offsets? An average assumes the offset is constant and will lag a drifting clock forever, always reporting a stale correction. Fitting the slope lets you predict the offset at a future instant, which is what a servo needs in order to correct the rate rather than chase the error. Saying "I would estimate both offset and rate, because a rate error looks like a growing offset and an offset-only correction can never catch up" is a complete answer to a common follow-up.

The third one is the one that actually runs inside ptp4l and phc2sys, and almost nobody can produce it from scratch. A batch least-squares fit tells you what the clock did; a servo decides what to do about it, once per second, forever, with no memory of the whole history. linuxptp's default is a plain PI controller: proportional term for the offset in front of you, integral term to absorb the constant rate error.

python
def pi_servo(offsets_ns, kp=0.7, ki=0.3):
    """linuxptp's clock servo. offsets_ns[k] = measured (device - reference)
    at the end of interval k, one sample per second. Yields the frequency
    correction in ppb to program via clock_adjtime() for the next interval.
    Units work out for free at a 1 s interval: nanoseconds per second IS ppb."""
    drift = 0.0                     # the integrator
    for o in offsets_ns:
        drift += ki * o             # accumulates toward the crystal's TRUE rate error
        yield kp * o + drift        # ppb to apply right now

# Closed loop against a crystal that runs 46 ppm fast, no phase steps (slew only)
TRUE_PPB = 46.0 * 1000.0              # 46 ppm = 46000 ppb = 46000 ns gained per second
o, drift, corr = 0.0, 0.0, 0.0
for k in range(1, 8):
    o    += TRUE_PPB - corr         # ns gained this second = true rate minus what we applied
    drift += 0.3 * o
    corr  = 0.7 * o + drift
    print("t=%ds  offset %7.0f ns   integrator %7.0f ppb   correction %7.0f ppb" % (k, o, drift, corr))
# t=1s  offset   46000 ns   integrator   13800 ppb   correction   46000 ppb
# t=3s  offset   32200 ns   integrator   37260 ppb   correction   59800 ppb
# t=7s  offset     598 ns   integrator   46547 ppb   correction   46966 ppb
Worked example 6 — hand-cranking the servo, seven seconds. Do this on paper once and the loop stops being magic. The crystal gains 46,000 ns every second. Start with everything at zero. Each row: measure the offset, add ki × offset to the integrator, then apply kp × offset + integrator.
t = 1 s. One uncorrected second, so o = 46,000 ns. Integrator: 0 + 0.3 × 46,000 = 13,800. Correction: 0.7 × 46,000 + 13,800 = 32,200 + 13,800 = 46,000 ppb. Net rate next second: 46,000 − 46,000 = 0.
t = 2 s. The rate is now exactly cancelled, so nothing is gained and nothing is recovered: o stays at 46,000. Integrator: 13,800 + 13,800 = 27,600. Correction: 32,200 + 27,600 = 59,800 ppb. Net rate: 46,000 − 59,800 = −13,800 ppb, so the clock now runs slow and starts paying back the accumulated 46 µs.
t = 3 s. o = 46,000 − 13,800 = 32,200. Integrator: 27,600 + 0.3 × 32,200 = 27,600 + 9,660 = 37,260. Correction: 0.7 × 32,200 + 37,260 = 22,540 + 37,260 = 59,800 ppb. Net: −13,800 again.
t = 4 s. o = 32,200 − 13,800 = 18,400. Integrator: 37,260 + 5,520 = 42,780. Correction: 12,880 + 42,780 = 55,660. Net: −9,660.
t = 5 s. o = 18,400 − 9,660 = 8,740. Integrator: 42,780 + 2,622 = 45,402. Correction: 6,118 + 45,402 = 51,520. Net: −5,520.
t = 6 s. o = 8,740 − 5,520 = 3,220. Integrator: 45,402 + 966 = 46,368. Correction: 2,254 + 46,368 = 48,622. Net: −2,622.
t = 7 s. o = 3,220 − 2,622 = 598 ns. Integrator: 46,368 + 179 = 46,547 ppb.
Offset sequence: 46,000 → 46,000 → 32,200 → 18,400 → 8,740 → 3,220 → 598 ns. Seven seconds from 46 µs to sub-microsecond.
Three things to read off that table.
1. The integrator converges to the crystal, not to zero. After seven steps it reads 46,547 ppb against a true error of 46,000 ppb — within 1.2%. The steady-state integrator value is a measurement of the local oscillator's rate error, which means you can log it and watch the crystal warm up. A servo integrator that wanders by tens of ppb over an hour is thermal drift; one that jumps is a hardware fault or a grandmaster change.
2. kp + ki = 1.0 is not a coincidence. With that choice, the very first correction exactly cancels a rate error that accumulated over exactly one sync interval, which is the fastest a causal loop can respond without knowing the answer in advance. It also sets up the mild ringing you can already see coming: at t = 7 the correction still exceeds the true rate by 966 ppb, so o goes slightly negative next second before settling. A PI servo overshoots; that is what the I term costs.
3. Nothing here ever steps the clock. Every correction is a rate change, so time stays monotonic throughout — which is precisely the property failure mode F2 below destroys. Slewing is slower than stepping and it is the only option that keeps Δt positive.

The production form. On a real robot you do not write any of this; you configure it and then monitor it.

JobToolThe line that matters
Discipline the NIC clockptp4l (linuxptp)ptp4l -i eth0 -m -H -s-H is hardware timestamping, -s is slave-only
Copy PHC → system clockphc2sysphc2sys -s eth0 -w -m — the link everyone forgets
NTP fallback / GNSS PPSchronyrefclock PPS /dev/pps0 lock NMEA
Inspect the PTP domainpmcpmc -u -b 0 'GET CURRENT_DATA_SET' — shows offsetFromMaster
Measure end-to-end topic lagROS 2ros2 topic delay /camera/image_raw — compares header.stamp to now()
Trace where the time actually goesros2_tracing / LTTngBédard et al., RA-L 2022 — per-callback timelines with ~ microsecond overhead
A trap inside ros2 topic delay. It reports now() − header.stamp, which is the age of the message as claimed. If the driver stamps at the callback, that number is small and reassuring — and completely blind to the 15 ms that already elapsed before the stamp was taken. A healthy topic delay is evidence of nothing.

Debugging

Four failure modes, all of which have taken down real fleets, each with a metric that names it unambiguously. Two of them — F1 and F2 — carry numbers because the rest of the lesson refers back to them by number; the other two are named. Three of the four present identically at the first report: "the sensors are misaligned and it got worse over the shift." The whole diagnostic skill is telling them apart, and you do it before touching a config file, by looking at the shape of the error-versus-time plot:

Shape on an error-vs-time plotWhat it meansWhich failure
Straight ramp from zero, slope = a fixed ppm, resets on rebootAn undisciplined oscillator is free-runningF1 — the dead phc2sys
Sawtooth: ramp, then a vertical discontinuity back toward zeroSomething is stepping the clock instead of slewing itF2 — the backwards step
Flat offset, non-zero, at full value one second after boot, moves with link loadA constant bias baked into the estimateThe asymmetric path
Triangle wave, period of minutes, error bounded by one sample intervalTwo free-running periodic sources beating against each otherThe silent free-run

Commit that table to memory in preference to the four tables below it. When a symptom lands in your queue, what you ask for first matters most; "can I see offset plotted against uptime?" is the question that collapses four candidate causes to one, and it costs nothing to ask.

F1: ptp4l without phc2sys
SymptomLocalisation quality degrades slowly over a shift. The first hour is fine; by hour three the map is unusable and loop closures are all rejected. A reboot fixes it — for another hour. Every sync dashboard is green.
Why it hidesMonitoring watches ptp4l's offsetFromMaster, which is genuinely 80 ns. The PHC really is locked. Nothing monitors whether that lock was ever copied into the clock the application reads.
The metricLog CLOCK_REALTIME − PHC directly, once per second. A healthy system holds it under a microsecond and flat. This failure shows a perfectly straight ramp, and its slope is the crystal's rate error in ppm. On our rig: 46 ppm, so 46 µs of new error every second, 165 ms after an hour.
Distinguishing tellThe error is proportional to uptime, not to speed, not to temperature, not to route. Reboot resets it to zero. Nothing else in a robot has that signature.
FixRun phc2sys -s eth0 -w -m, supervise it, and alarm on the PHC-to-system delta rather than on the PTP offset. Then add the delta to fleet telemetry so this is caught in seconds.
F2: the clock stepped backwards
SymptomOccasional, unreproducible estimator explosions. The covariance blows up in a single update, the robot e-stops, and the bag replays cleanly afterwards. Roughly once per shift, no pattern anyone can find.
CauseA time daemon decided the offset was large enough to step rather than slew. ntpd's default step threshold is 128 ms; when it fires, CLOCK_REALTIME jumps. If it jumps backwards, two consecutive messages can carry Δt ≤ 0, and every propagate step that divides by Δt, or integrates over it, produces a garbage covariance.
The metricCount monotonicity violations per hour on every timestamped stream: how many times stamp[k] ≤ stamp[k−1]. On a healthy robot this is exactly zero. Anything above zero is a step. Plot the offset against wall time and you will see the sawtooth: a slow ramp, then a vertical drop.
FixConfigure slew-only (chrony's maxslewrate, or ntpd -x), and separately make the estimator defensive: reject non-monotonic samples, clamp Δt to a sane range, and never divide by an unvalidated Δt. Use CLOCK_MONOTONIC_RAW for anything measuring an interval; use CLOCK_REALTIME only for stamps that must be comparable across machines.
The clock-choice rule, in one line. Intervals want CLOCK_MONOTONIC — it never jumps and never goes backwards. Cross-machine correlation wants CLOCK_REALTIME — it is the only one that means the same thing on two computers. Using REALTIME for a timeout is how you get a robot that waits forty minutes because a daemon adjusted the clock.
The asymmetric path — PTP converging beautifully to the wrong number
SymptomA stubborn, repeatable misalignment of tens of microseconds between the LiDAR and the host, present from the first minute after boot and unchanged three hours later. Reboots do nothing. It appeared the week a network engineer re-cabled the rack, and one bench rig does not reproduce it.
CauseThe delay-request path and the sync path do not traverse the same links, or traverse them at different rates. A store-and-forward switch must clock an entire frame in before it can start clocking it out, so a 1500-byte frame costs 1500 × 8 / 109 = 12 µs per hop at 1 Gb/s and 1500 × 8 / 108 = 120 µs per hop at 100 Mb/s. Route the reply through one 100 Mb/s leg that the request does not use and the asymmetry is 120 − 12 = 108 µs. PTP splits it in half: a silent 54 µs bias, permanently.
Why it hidesExactly the reason derived in the concept section: δ is exact and θ̂ is not. The round-trip statistic is correct in the presence of asymmetry, so every delay-based dashboard stays green. And ptp4l's own reported offset is small and stable, because a constant bias is invisible to a servo — the servo converges beautifully to the wrong number.
The metricLog meanPathDelay from ptp4l -m (or pmc -u -b 0 'GET CURRENT_DATA_SET') and watch it while you deliberately load the link — push the LiDAR stream, or run iperf3 one direction only. A symmetric path holds meanPathDelay nearly constant under one-directional load; an asymmetric one moves, and the movement is your asymmetry. Cross-check against an independent reference: a PPS line into a spare GPIO gives you ground truth the network cannot bias.
Distinguishing tellThe error is constant. It does not grow with uptime (that is F1), it has no discontinuities (that is F2), and it does not oscillate on a minutes-long cycle (that is the silent free-run below). Plot error against uptime and get a horizontal line at 54 µs. A bias that is already at its full value one second after boot cannot be a rate error, and that single observation eliminates half the candidate causes before you have logged into a switch.
FixDeploy transparent clocks so every hop reports its own residence time, or make the topology symmetric (identical link speeds, identical hop counts both ways). Where neither is possible, PTP lets you configure a static correction: ptp4l's asymmetry option (or delayAsymmetry in the profile) applies a fixed offset in nanoseconds. Measure it once against PPS, write it down, and re-measure it every time anyone touches the cabling — because a static correction is a calibration, with all the staleness risk that implies.
The silent free-run — the trigger harness is broken and nothing said so
SymptomOne camera's contribution to the VIO solution degrades and recovers on a cycle of a few minutes, indefinitely. Feature tracks break in bursts. The bag looks fine on casual inspection; the frame rate is exactly 30 Hz; no node logs an error, ever.
CauseThe trigger wire came loose (or the FPGA's output stage failed, or someone reflashed it and dropped the pin config). Almost every machine-vision camera silently falls back to free-running mode when the external trigger stops arriving — it keeps producing images, on its own crystal, at its own nominal rate. Nothing errors because from the camera's point of view nothing is wrong.
Why the obvious metric failsThe instinct is to alarm on frame-interval jitter. It does not work: a free-running camera is driven by its own oscillator, so its inter-frame interval is extremely stable — a standard deviation well under a microsecond, often better than the triggered case, because it has no cable and no FPGA in the path. Jitter is the wrong statistic. The broken system looks cleaner than the healthy one.
The metricCompare the mean interval against the commanded one, measured on the FPGA's counter, and monitor the trigger-to-frame phase: (frame arrival stamp) modulo (trigger period). Triggered, that phase is a constant. Free-running, it walks monotonically through the full period and wraps, forever.
The numbersCommanded 30.000 Hz = 33.3333 ms. The camera's own crystal is nominally 30 Hz but 100 ppm off, so its interval is 33.3333 × (1 ± 10−4), differing by 3.33 µs per frame. To accumulate one whole frame period takes 1 / (100 × 10−6) = 10,000 frames = 10,000 / 30 = 333 s ≈ 5.6 minutes. That is the beat period, and it is exactly the "few minutes" cycle in the symptom: the camera slides through every possible phase relative to the IMU, is briefly well-aligned, then briefly a half-frame (16.7 ms) out, and repeats. At 3.4 m/s a half-frame is 3.4 × 0.0167 = 57 mm of apparent landmark displacement, appearing and vanishing on a 5.6-minute cycle.
Distinguishing tellBounded and periodic. F1's error grows without limit; this one is capped at half a frame period and comes back. If someone reports "it gets bad, then it gets better on its own", stop looking for a drift and start looking for two free-running periodic sources beating. Divide one frame period by the observed beat period to recover the relative ppm and confirm it against the camera's datasheet.
FixMake the liveness check structural: the FPGA counts trigger edges emitted, the driver counts frames received, and a supervisor alarms the instant the two counters diverge by more than one. Add a phase monitor with a hard threshold. And ship a power-on self-test that fires a burst of triggers at a deliberately odd rate — 29 Hz, say — and refuses to bring up the stack unless every camera reports 29 Hz back. A free-running camera cannot fake that.
The general rule these four share. Every one of them is a failure that produces plausible output. The PHC stays locked, the round trip stays healthy, the frame rate stays exactly 30 Hz, the images keep arriving. This is the defining property of timing bugs and the reason they cost weeks: there is no exception to catch, so the only detection mechanism is a metric you decided to log before the failure existed. If you want a timing stack to be observable, the answer is always the same three logs — system-clock minus hardware-clock, monotonicity violations per stream, and edges-emitted minus frames-received. All three are cheap, all three are boring, and each one turns a multi-week hunt into a glance at a dashboard.

The frontier

Three things are genuinely moving here, and each deserves one specific paper or standard you can name.

DirectionThe specific referenceWhy it changes the design
Determinism moves into the networkIEEE 802.1AS-2020 (gPTP) and the wider TSN family — 802.1Qbv time-aware shapingSync and scheduled delivery become one mechanism. A TSN switch reserves a slot for the LiDAR stream, so its queueing delay is bounded by configuration rather than measured after the fact. This is arriving in automotive Ethernet now and will make "is the path symmetric" a settable property.
Calibration goes continuous and targetlessFurgale, Rehder & Siegwart, IROS 2013 (Kalibr) → Qin & Shen, IROS 2018 (online td in VINS-Mono) → Lang et al., RA-L 2023 (Coco-LIC) → Chen et al., 2024 (iKalibr, targetless spatiotemporal)The offset stops being a constant in a YAML file and becomes a continuously estimated quantity, recovered from ordinary driving with no checkerboard. Removes the annual recalibration ritual and tracks thermal drift for free.
Sub-nanosecond becomes ordinaryWhite Rabbit, folded into IEEE 1588-2019 as the High Accuracy profile; the Open Compute Time Appliance Project's open time serversNanosecond sync used to mean a particle accelerator. Datacentre-scale deployments have commoditised the hardware, and the same NICs land in robots. At 1 ns, timing stops being an error source at any robot speed and the whole class of bug in Chapter 0 disappears structurally.
The one-sentence frontier answer. "The direction of travel is that time synchronisation is becoming a property of the infrastructure rather than of the application — gPTP and TSN in the network, hardware timestamping in commodity NICs — while the residual that infrastructure cannot fix, the sensor-to-sensor offset, is moving into the estimator as a continuously calibrated state. Kalibr in 2013 made it a batch parameter; VINS-Mono in 2018 made it a filter state; iKalibr in 2024 made it targetless."

Practice

Four sync architectures, racing against uptime

Each trace is the camera–IMU offset for one architecture, plotted against minutes since boot. Pick an architecture, then drag the uptime slider and watch which ones are still inside the 3 ms spec line at the end of a shift.

Uptime45 min
Crystal grade46 ppm
The numbers drill. Cover the right-hand column and answer each to one significant figure in under ten seconds. Order-of-magnitude fluency is what a design conversation actually runs on, and these six are the ones this chapter earns.
Two 50 ppm crystals, worst case → how much drift in a minute?  100 ppm × 60 s = 6 ms.
3 ms spec, 46 ppm relative → how long until you break it?  0.003 / 46e−6 ≈ 65 s.
24.8 ms round trip on NTP → worst-case offset error, in millimetres at 3.4 m/s?  ±12.4 ms → 3.4 × 0.0124 = 42 mm.
1.2 m of copper → trigger skew?  5 ns/m × 1.2 = 6 ns. Ignore it.
1.02 MB frame on a link with 375 MB/s of usable payload → transfer time?  1.024 / 375 ≈ 2.7 ms.
A 120 s GNSS outage with a ±2 ppm TCXO → holdover error?  2e−6 × 120 = 240 µs.
The sixty-second answer, out loud. Rehearse this until it is one breath: "There are five oscillators in this rig, so every cross-sensor timestamp is a two-parameter mapping — offset and rate. Rate is the one that kills you: 46 ppm relative breaks a 3 ms spec in about a minute, so sync has to be continuous, not a factory step. NTP and PTP solve the same four-timestamp equations; PTP wins by moving the stamp into the NIC and by having switches report their own queueing, but both inherit a ±δ/2 error from the symmetry assumption. Where I can, I delete the question instead of estimating it — one trigger wire gives sub-10 ns alignment for the price of an FPGA pin. Then I still need one mapping from the trigger domain to the host, and I monitor it with three logs: system clock minus hardware clock, monotonicity violations, and edges emitted minus frames received."
Quiz: Your ptp4l logs show rms 78ns max 210ns, steady. Camera-to-IMU alignment nonetheless grows linearly with uptime, reaching about 165 ms by the end of the first hour, and a reboot resets it to zero. What is the most likely explanation, and what would you log to confirm it in one minute?
Why the other two are wrong, which is the half of the answer that matters. Naming the right cause is table stakes; eliminating the plausible ones is what closes the investigation.
Holdover fails on two independent numbers. A grandmaster in holdover is coasting on an OCXO at roughly 0.01 ppm — that is 36 µs in an hour, not 165 ms, four orders of magnitude short of the observed drift. And holdover is announced: the master re-advertises clockClass 7, which is exactly why the confirming command in that option is a reasonable thing to run and exactly why it will come back clean. A failure the protocol tells you about is rarely the failure that survived three weeks of investigation.
Asymmetry fails on the shape. Route asymmetry produces a fixed bias of half the path difference — the 54 µs worked out in the asymmetric-path table above — which is at full value one second after boot and identical three hours later. The symptom here is a ramp proportional to uptime that a reboot zeroes. A constant cannot ramp, so this is eliminated by inspecting the plot before you touch the network.
And the slope names the culprit. 165 ms over 3600 s is 46 µs per second, which is 46 ppm — the relative rate error of two undisciplined MEMS crystals from Worked example 1. When a drift rate matches a datasheet number that precisely, you are looking at a clock nobody is steering, and the only clock in this stack that fits is CLOCK_REALTIME.

Chapter 2: Capture Time vs Receive Time — Where the Stamp Actually Lands

Chapter 1 got every clock in the robot agreeing to within a hundred nanoseconds. This chapter is about the fact that it did not help, because the number written into the message was never a measurement of when the data was captured.

The question that separates people who have shipped. "Your camera driver puts a timestamp in header.stamp. What physical event does that number refer to?" The weak answer is "when the image was taken". The correct answer is a question: "which driver? Because the default in most of them is now() called from the callback, which is the moment software noticed, and on this rig that is fifteen milliseconds and three point one milliseconds of jitter after the photons."

The concept

Define two quantities precisely, because the whole chapter is the gap between them.

TermDefinitionWho knows it
Capture time tcapThe instant the physical measurement was made — the centre of the exposure window, the instant the accelerometer's sample-and-hold closed, the instant a LiDAR beam left the aperture.The sensor, and sometimes not even it.
Receive time trxThe instant a piece of software first held the data. Depends on the bus, the driver, the kernel, the scheduler and the current CPU load.Everyone, trivially. Which is why it is what gets used.
trx = tcap + L + η     where E[η] = 0

L is the latency: the mean, repeatable part of the pipeline. η is the jitter: the zero-mean random part. This split is the most useful decomposition in the chapter, because the two halves have completely different fates.

The asymmetry that drives every design decision here. L is calibratable — measure it once, subtract it forever, and it costs you nothing but the measurement. η is not — it is a fresh draw on every message, so the best you can ever do is model its variance and inflate R accordingly. Therefore: every engineering effort should go into moving the stamp earlier, because that shrinks η. Effort spent estimating L more precisely has a floor set by η and stops paying almost immediately.

Why the middle of the exposure, and not the start

A camera does not sample the world at an instant. It integrates over a window. If the shutter opens at t0 and closes at t0 + T, the pixel value is:

I(u, v) = ∫t0t0+T E(u, v, τ) dτ

where E is the irradiance arriving at that pixel. So what single instant should we attribute the image to?

Take a point feature moving across the sensor at a constant image velocity. During the exposure it smears into a streak. The intensity along that streak is uniform, because the feature spent equal time at every position. Any reasonable corner or centroid detector will localise the streak at its centroid, and the centroid of a uniform segment is its midpoint. So:

tcap = t0 + T / 2

That is the mid-exposure convention, and it is a derived result, not a convention someone picked. It is exact for constant velocity, and its error for accelerating motion is the second-order term we already showed is 283 times smaller.

It is worth doing that error bound properly, because it is the one place in this chapter where you can be too careful. Under acceleration the streak is no longer uniform — the feature dwells longer where it moves slower — but the intensity deposited at a position is still proportional to the time spent there, so the streak's centroid is exactly the time-average of position over the window. Expand x(t) = xmid + v·s + ½a·s2 about the midpoint, with s running over [−T/2, +T/2]. The linear term averages to zero by symmetry. The quadratic does not: the mean of s2 over that window is T2/12, so

⟨x⟩ − x(tmid) = ½ a · (T2/12) = a T2 / 24
Worked example 1a — how good is the mid-exposure convention, in millimetres? Take the worst exposure on this rig and a brisk acceleration: T = 20 ms, a = 2.0 m/s2, v = 3.4 m/s.
T2 = 0.0202 = 4.0 × 10−4 s2
a T2 = 2.0 × 4.0 × 10−4 = 8.0 × 10−4
a T2 / 24 = 3.33 × 10−5 m = 0.033 mm
As an equivalent time error, divide by speed: 3.33 × 10−5 / 3.4 = 9.8 × 10−6 s = 9.8 µs.
Now the comparison that matters. Stamping at the start of exposure instead of the middle costs v·T/2 = 3.4 × 0.010 = 34 mm. The ratio is 34 / 0.033 = 1020×.
The lesson: choosing the right instant is worth three orders of magnitude more than modelling the acceleration inside it. Anyone who proposes a jerk-aware exposure model before their driver stamps at the midpoint is optimising the fourth decimal place of the wrong number.
Worked example 1 — the auto-exposure swing. The camera is hardware-triggered, so t0 is known to nanoseconds. Auto-exposure is enabled, as it must be for a robot that goes outdoors.
Bright sunlight: T = 2 ms → tcap = t0 + 1.0 ms
Dim warehouse: T = 20 ms → tcap = t0 + 10.0 ms
Swing: 10.0 − 1.0 = 9.0 ms
This is an offset that changes with the lighting. At 2.2 m/s it is 2.2 × 0.009 = 0.0198 m = 19.8 mm of position error that appears when the robot drives into a shadow and disappears when it comes out.
And crucially: a perfect hardware trigger does not fix it. The trigger nails t0. The exposure length moves tcap.
The consequence people get wrong. A correct driver must report the actual exposure used for each frame, and the timestamp must be ttrigger + Tframe/2 with that frame's own T. A driver that reports the exposure it requested, or reports one exposure for the whole session, has silently built a lighting-dependent time offset into the robot. If you fix the exposure to remove the problem, you have traded a timing bug for a dynamic-range bug — and outdoors, that is a worse trade.

Rolling shutter: one image, one thousand capture times

A rolling shutter sensor does not expose all rows at once. It sweeps the exposure window down the sensor, so row r starts its exposure later than row 0 by a fixed line time. Over a sensor of H rows with total readout time Tro:

tcap(r) = t0 + r · (Tro / H) + T / 2
Worked example 2 — per-row time on a 1080-row sensor. Tro = 12 ms, H = 1080, T = 5 ms.
Line time: 12 / 1080 = 0.011111 ms = 11.111 µs per row
Row 0: tcap = t0 + 0 + 2.5 = 2.5 ms
Row 540: tcap = t0 + 540 × 0.011111 + 2.5 = t0 + 6.0 + 2.5 = 8.5 ms
Row 1079: tcap = t0 + 11.989 + 2.5 = 14.489 ms
Top row to bottom row: 14.489 − 2.5 = 11.989 ms of spread inside a single image.
At ω = 60 °/s that is 60 × 0.011989 = 0.719° of rotation between the top and bottom of one frame. A vertical doorframe photographs as a slanted one.

This is why a single header.stamp on a rolling-shutter image is a lie by construction, and why the honest fix is to carry the line time in CameraInfo and let the estimator evaluate the pose per row. The other fix, which is the one shipped by every serious robotics rig, is to buy global-shutter sensors.

The engineering call on shutters. "Global shutter for anything geometric that moves faster than a walk. Rolling shutter is not a small correction — a 12 ms readout at 60 °/s puts 0.72° of unmodelled rotation inside one image, which is comparable to the total attitude error budget. You can model it properly with a continuous-time formulation, but that is a research-grade cost to avoid a two-times-price-difference part."

The IMU FIFO: one packet, thirty-two different instants

A 400 Hz IMU raises 400 interrupts per second, and 400 interrupts per second on a small CPU is real load. So the sensor buffers. It fills an internal FIFO and hands up a batch when the batch is full or a timer fires.

Now a driver receives one packet at time trx containing 32 samples, and every naive implementation ever written does the obvious thing: it stamps them all with now(). Consider what that claims.

Worked example 3 — how stale is the oldest sample? fs = 400 Hz, batch of 32, transport delay 3.1 ms.
Sample period: 1 / 400 = 0.0025 s = 2.5 ms
Newest sample was captured at: trx − 3.1 ms
Oldest sample was captured 31 periods earlier: 31 × 2.5 = 77.5 ms before that
Oldest capture time: trx − 3.1 − 77.5 = trx − 80.6 ms
Mean error if you stamp them all at trx: 3.1 + (31/2) × 2.5 = 3.1 + 38.75 = 41.85 ms
At 3.4 m/s, that mean error is 3.4 × 0.04185 = 0.1423 m = 142 mm.
This single mistake is three and a half times worse than the entire 12 ms bug in Chapter 0.

Worse than the magnitude is the shape. Because every packet has the same internal structure, the error is not random — it is a sawtooth, ramping from 80.6 ms down to 3.1 ms and jumping back, 12.5 times per second. Integrate a gyro over that and you accumulate a rotation bias in a fixed direction. To an IMU pre-integration front-end this looks exactly like a gyro bias, and the estimator will happily "correct" a bias that is actually a timestamping error — which means the bias estimate goes wrong too, and now two states are lying.

The correct back-dating rule, in one line. A burst arrives together; it was not captured together. Assign the newest sample trx − Ltransport and walk backwards in steps of 1/fs. If the sensor exposes a hardware sample counter, use the counter differences instead of assuming a nominal rate — the IMU's own crystal is one of the ones from Chapter 1, and its "400 Hz" is really 400 Hz ± 50 ppm.

Five places you could put the stamp

This is the table to draw on the whiteboard. Every row is a real option someone has shipped; the last column is the one that decides.

Stamping siteMean error LJitter σCost to implementError at 3.4 m/s (mean ± noise)
A. FPGA latch on the trigger edge~0 (known by construction)< 0.001 msHigh — needs the hardware and a counter→host mapping0.0 ± 0.003 mm
B. Sensor's own timestamp (GenICam chunk / PTP-stamped)0.05 ms0.02 msLow — if the sensor supports it0.17 ± 0.07 mm
C. Kernel driver, on first-byte interrupt7.5 ms0.3 msMedium — driver patch25.5 ± 1.0 mm
D. Userspace driver, after the read returns13.0 ms0.9 msLow44.2 ± 3.1 mm
E. ROS callback, now()15.0 ms3.1 msZero — it is the default51.0 ± 10.5 mm

Read the mean column and options C, D and E look like a 2× spread — annoying but survivable, and all three are calibratable. Read the jitter column and the picture changes completely: 0.3, 0.9, 3.1. That is a 10× range in the quantity you can never remove.

Those five numbers are not five measurements — they are one ladder, read at five heights. Every row is a prefix sum of the same pipeline, so if any single value is challenged you can rebuild all of them. Take the default rig from the Practice widget below: exposure T = 8 ms, readout 2.0 ms, transfer 5.5 ms, driver work 1.5 ms, executor callback 2.0 ms. Start the clock at the true capture instant, which is mid-exposure.

The instantStepms after mid-exposureWhat is happening in that step
Mid-exposure — the truth0.0Where site A lands, by construction, because the FPGA knows both the trigger edge and T.
Sensor's own chunk stamp+0.050.05 → row BThe sensor latches its internal counter. The 0.05 ms is the sensor's own pipeline, not yours.
Exposure ends, readout begins+T/2 = 4.04.0The other half of the integration window. A true first-byte latch would live here.
Frame-complete interrupt fires+2.0 readout6.0The sensor must finish draining all rows before the DMA-complete IRQ asserts.
The kernel handler actually runs+1.5 IRQ→handler7.5 → row CInterrupt-to-handler wake-up on a stock kernel. This is the term PREEMPT_RT (Lesson 14) exists to shrink.
The userspace read returns+5.5 transfer13.0 → row D1.02 MB across USB3 or GigE, plus the copy into the message buffer.
The ROS callback body starts+2.0 executor15.0 → row EQueue wait plus executor wake-up, sharing a thread with every other callback in the node.

Check it against the widget: at its default T = 8 ms the last two prefix sums are exactly 13.0 and 15.0, the same numbers as rows D and E. (The widget draws the 1.5 ms of driver work after the transfer rather than before it; addition commutes, so the totals that define rows D and E are identical either way.) Being able to say "fifteen milliseconds is four plus two plus one and a half plus five and a half plus two, and here is what each term is" is the difference between quoting a table and owning one.

Now do the same for the jitter column — and notice it does not add the same way. Independent random contributions combine in quadrature, not linearly, so you recover each hop's own σ by subtracting squares:
Sensor → kernel handler: √(0.32 − 0.022) = √(0.09 − 0.0004) = 0.299 ms — essentially all of row C's jitter is the interrupt path.
Kernel → userspace: √(0.92 − 0.32) = √(0.81 − 0.09) = 0.849 ms
Userspace → callback: √(3.12 − 0.92) = √(9.61 − 0.81) = 2.966 ms
That last hop contributes 8.80 of the 9.61 ms2 of total variance — 92% — from a step that does no work on the image at all. It is queueing. Which is why the single highest-leverage change available to you, before any hardware purchase, is simply not stamping in the callback.
The decision rule to state out loud. "I would rank stamping sites by jitter, not by mean latency, because the mean is a constant I subtract once and the jitter is my permanent accuracy floor. Option B costs nothing and gets me to 0.02 ms; option E is free but locks in 3.1 ms forever. On this rig I would take B for every sensor that supports it, C for the ones that do not, and treat E as a bug."

The design

What ROS 2 actually gives you. There are three distinct times attached to a message and they mean three different things. Conflate them and the bug reports write themselves.

FieldWho writes itWhat it meansUse it for
header.stampThe publisher, by handWhatever the driver author decided. Nothing enforces that it is capture time.Everything geometric. It is the only field tf2 and message_filters read.
DDS source_timestampThe DDS middleware on publishWhen publish() was called — strictly after the driver was done.Measuring publish–to–receive transport latency. Never for geometry.
DDS reception_timestampThe DDS middleware on the subscriberWhen the sample landed in the subscriber's queue.Queue-depth and transport diagnostics.
The trap in that table. header.stamp is the only one anything geometric consumes, and it is the only one with no defined semantics — a struct field a human filled in. Every other field is precisely defined by the middleware and useless for geometry. That inversion is why this whole class of bug exists.

The driver contract. When you review a new sensor driver, this is the checklist. Any "no" is a change request, not a nitpick.

RequirementWhy
1. header.stamp is capture time, not receive timeIt is the only field downstream code reads.
2. The applied latency correction is a declared, logged parameterSo the 15 ms is auditable and changes to it show up in a diff.
3. Per-frame actual exposure is published, and the stamp is trigger + T/2Removes the 9 ms auto-exposure swing.
4. Batched samples are back-dated individuallyRemoves the 41.85 ms mean FIFO error.
5. Sensor sequence numbers are published and gaps are countedA dropped frame silently shifts everything downstream by one period.
6. A diagnostic reports trx − tcap per messageTurns latency into a monitored signal instead of an assumption.
7. Any device→host clock conversion publishes its residualThe conversion is a fit; an unmonitored fit is a future outage.

Memory cost of doing it right. Correct capture-time stamping forces you to buffer, because you cannot align a stream to an instant you have not yet received data for. Sizing that buffer is a design-round question with an arithmetic answer, and "200 ms" is not a round number somebody liked. It is the worst-case query lag — how far into the past your newest consumer will ever reach — times a safety factor. Build it one term at a time, using only numbers already on this page.

#TermValueRunning totalWhy it is in the budget
1Camera capture → callback latency (row E)15.0 ms15.0 msThe instant a frame reaches your code, the capture time you must query for is already 15 ms old.
2Jitter tail, not jitter mean: +3σ at σ = 3.1 ms3 × 3.1 = 9.3 ms24.3 msA buffer sized on the mean is empty exactly on the frames where it matters. Budget p99.9, not p50.
3Consumer is one camera period behind under load1/30 = 33.3 ms57.6 msThe VIO front end is its own thread. When the CPU is hot it processes frame n while frame n+1 arrives.
4Front-end work before it asks for IMU1/30 = 33.3 ms90.9 msDetection and matching on a 1280×800 frame are budgeted at one frame period. The query happens after that.
5Safety factor on the worst case× 2, rounded200 ms90.9 × 2 = 181.8 → 200 ms. Actual factor 200 / 90.9 = 2.2×.
Worked example 4 — check the 200 ms against the storage table, then break it in both directions.
200 ms × 400 Hz = 80 IMU samples; 80 × 28 B = 2240 B = 2.2 kB — matches the row below.
200 ms × 30 Hz = 6 frames per camera; × 4 cameras × 1.02 MB = 24.5 MB — matches.
200 ms × 10 Hz = 2 LiDAR scans — matches, and note this is the minimum that can bracket anything at 10 Hz: with one scan in the ring there is no interval, only a point.
Too shallow (say 50 ms). 50 < 90.9, so every query succeeds on the bench and a few percent fail once the CPU is hot. In tf2 that is "Lookup would require extrapolation into the past"; in the Code Lab below it is the first None. Load-dependent failures reproduce in the field and not in CI, which is the worst property a bug can have.
Too deep (say 1 s). The small streams do not care — 5× of 5.4 kB is still nothing. The images do: 24.5 MB → 122 MB, which on a 4–8 GB compute module is the difference between fitting and paging. And paging is a latency spike, which increases the query lag, which makes you want a deeper buffer. Size from the lag budget, never from "more is safer".
The follow-up they will ask, and the number that answers it. "What would make you change 200 ms?" — "The rate of the slowest consumer, not the fastest producer. If I align to the LiDAR at 10 Hz instead of the camera, terms 3 and 4 become 100 ms each: 15.0 + 9.3 + 100 + 100 = 224.3 ms worst case, times two is 449, so I would size that ring at 500 ms. Same rig, same sensors, 2.5× the buffer — because buffer depth is set by who asks the question, not by who produces the data."
StreamRatePer-sample200 ms of history
IMU400 Hz28 B80 samples = 2.2 kB
Wheel odometry200 Hz16 B40 samples = 0.6 kB
Pose / tf200 Hz64 B40 samples = 2.6 kB
Camera, 4 × 1280×800 mono30 Hz1.02 MB6 frames × 4 = 24.5 MB
LiDAR10 Hz0.96 MB2 scans = 1.9 MB
The asymmetry to exploit. Buffering the small, fast streams is essentially free — five kilobytes buys you 200 ms of perfectly interpolatable IMU, odometry and pose history. Buffering images is 24.5 MB and is what actually constrains you. So the architecture that falls out is: buffer the cheap high-rate streams deeply, and align everything else to the images rather than the other way round. Images become the query timestamps; everything else gets interpolated to them. This one observation shapes the whole of Chapter 3.

The code

Reconstructing a capture time is arithmetic, but arithmetic with a lot of places to put a sign backwards. Here it is written the way it should be reviewed — every term named, every term logged.

python
import numpy as np

def camera_capture_time(t_rx, exposure_s, readout_s, transfer_s, driver_s,
                         row=None, rows=None):
    """Reconstruct the capture instant from the receive instant.
    Every term is a MEAN; the jitter around them is your accuracy floor."""
    # Walk the pipeline BACKWARDS from arrival. Every step is a subtraction.
    t_exposure_end = t_rx - driver_s - transfer_s - readout_s
    #   ^ readout BEGAN here, so integration stopped here. This is the END of
    #     the exposure window [t_exposure_end - T, t_exposure_end], not its start.
    t_cap = t_exposure_end - 0.5 * exposure_s      # mid-exposure  <- the derived bit
    if row is not None:                              # rolling shutter, per row
        # t_exposure_end belongs to the LAST row; row 0 stopped one sweep earlier.
        t_row0_exposure_end = t_exposure_end - readout_s
        t_cap = t_row0_exposure_end - 0.5 * exposure_s + row * (readout_s / rows)
    return t_cap

# The Chapter 0 camera, in a dim warehouse. Print with %.5f, not repr - see below.
t_rx = 100.0000
print("bright: %.5f" % camera_capture_time(t_rx, 0.002, 0.002, 0.0055, 0.0015))
print("dim   : %.5f" % camera_capture_time(t_rx, 0.020, 0.002, 0.0055, 0.0015))
# bright: 99.99000
# dim   : 99.98100   -> the dim frame was captured 9.0 ms EARLIER than the bright one
#   bright longhand: 100.0 - 0.0015 - 0.0055 - 0.002 - 0.001 = 99.99000
#   dim    longhand: 100.0 - 0.0015 - 0.0055 - 0.002 - 0.010 = 99.98100

# --- this function IS worked example 2, not a paraphrase of it ---------------
T_RO, H, T = 0.012, 1080, 0.005
kw = dict(exposure_s=T, readout_s=T_RO, transfer_s=0.0055, driver_s=0.0015, rows=H)
t0 = camera_capture_time(100.0, row=0, **kw) - 0.5 * T   # row 0 opened here
for r in (0, 540, 1079):
    print("row %4d -> t0 + %.3f ms" % (r, (camera_capture_time(100.0, row=r, **kw) - t0) * 1e3))
print("spread  : %.3f ms" % ((camera_capture_time(100.0, row=H - 1, **kw)
                          - camera_capture_time(100.0, row=0, **kw)) * 1e3))
# row    0 -> t0 + 2.500 ms
# row  540 -> t0 + 8.500 ms
# row 1079 -> t0 + 14.489 ms     <- the three numbers from worked example 2
# spread  : 11.989 ms            <- and its 11.989 ms of intra-frame spread

def imu_batch_capture_times(t_rx, n, fs, transport_s):
    """A burst ARRIVES together; it was not CAPTURED together."""
    newest = t_rx - transport_s
    return newest - np.arange(n - 1, -1, -1) / fs      # oldest first

ts = imu_batch_capture_times(100.0, 32, 400.0, 0.0031)
print("oldest is %.1f ms stale, newest %.1f ms" % ((100.0-ts[0])*1e3, (100.0-ts[-1])*1e3))
# oldest is 80.6 ms stale, newest 3.1 ms
Note the sign flips depending on what you hold fixed. Worked example 1 pinned the hardware trigger t0, so a longer exposure pushed tcap later: the window opens at a known instant and its midpoint slides forward as the window grows. The code above pins trx instead, because that is the only instant a driver actually observes — so a longer exposure means the window must have opened earlier to still be finished by the time readout began, and tcap moves backward. Same physics, opposite sign. This is the single most common review catch in a driver diff, and it is worth saying out loud before you write a line: "I am reconstructing backwards from arrival, so every term in this function subtracts."

The rolling-shutter branch has the same trap one level down. The anchor t_exposure_end is the instant the last row stopped integrating — that is what the arrival time tells you about. But the per-row formula from earlier in this chapter, tcap(r) = t0 + r·(Tro/H) + T/2, counts rows upward from row 0. Adding row * line_time to a last-row anchor would place row 1079 after the whole frame had finished reading out, which is why the code steps back one full sweep to t_row0_exposure_end before it adds anything. If your two expressions disagree about which row is the origin, the bug is invisible at row 0 and maximal at the bottom of the image — and "the error grows down the frame" is a symptom people reliably misattribute to lens distortion.

Why the prints use %.5f and not print(x). A raw repr of these values is 99.99000000000001. That is not a bug in the arithmetic; it is float64 doing what float64 does. And it points at a real design constraint: a double has a 52-bit mantissa, so at a Unix epoch of about 1.75×109 s the spacing between representable values is 1.75×109 × 2−52 = 3.9×10−7 s = 389 ns. Chapter 1 spent real money getting PTP to 78 ns rms — and a single float64 seconds-since-epoch timestamp would quantise that away by a factor of five. This is exactly why builtin_interfaces/Time is int32 sec + uint32 nanosec and not a double, and why you do the pipeline arithmetic in relative seconds (as above) before adding it to an absolute epoch.

What makes this function reviewable. Three things matter here, and none of them is Python fluency. First, the anchor is named ("t_rx is the only instant I observe"). Second, every term is a mean and the docstring says so — it is doing real work, because the function silently promises an accuracy floor equal to the jitter of the terms it subtracts. Third, the test ships with the function: a function whose output you cannot check against a hand-worked number is a function nobody should merge.

The production form is mostly about getting the sensor to tell you, so you never have to model the pipeline at all.

MechanismWhat it gives youWhere you meet it
GenICam chunk dataThe sensor appends its own capture timestamp to every frame, in the frameBasler, FLIR, Allied Vision — enable ChunkModeActive + ChunkSelector=Timestamp
GigE Vision 2.0 + IEEE 1588The chunk timestamp is already in the PTP domain — no conversion neededAny PTP-capable industrial camera
V4L2 V4L2_BUF_FLAG_TIMESTAMP_COPYKernel stamps at the interrupt rather than at dequeueUVC and CSI cameras on Linux
IMU hardware sample counterExact sample index, so you back-date with the true rate not the nominal oneBosch BMI, TDK ICM, Analog Devices ADIS
SO_TIMESTAMPINGPer-packet hardware receive timestamps from the NICAny Ethernet sensor on Linux
ros2_tracing / LTTngWhere the milliseconds actually went, per callbackBédard et al., RA-L 2022

Debugging

Three failure modes, and all three share a structure worth naming before you read them: each one has a plausible, wrong first hypothesis that a competent team will chase for a week. Texture. Gyro bias. Wheel radius. The value you bring when you debug this is not the correct hypothesis — it is the one measurement that separates the correct hypothesis from the plausible one in an afternoon, and the discipline to take that measurement before touching any code.

Work them in that order: symptom, the decoy everyone reaches for, the discriminating metric, the cheap confirmation, and only then the fix. Reaching for the fix first proves nothing about the diagnosis; reaching for the discriminating metric first proves everything.

F3 is the one you meet first, because auto-exposure is on by default and nobody thinks of the exposure control loop as part of the timing stack. It is, and it is the only part that a hardware trigger cannot fix.

F3: exposure-coupled time offset
SymptomVIO drift is worse indoors than outdoors, worse at dusk than at noon, and much worse in tunnels and under awnings. Every engineer's first hypothesis is "less texture indoors", which is plausible and wrong.
CauseThe driver stamps at the start of exposure, or uses a fixed nominal exposure, so the true capture instant slides with the auto-exposure setting: 1 ms of shift in bright light, 10 ms in dim light.
The metricLog the per-frame exposure alongside the online time-offset estimate and compute their correlation coefficient over a mixed-lighting run. Texture-driven drift gives ρ near 0. A stamping bug gives ρ above 0.5, usually above 0.8. One scatter plot settles a week-long argument.
The cheap confirmationLock the exposure to a fixed value and re-run the same route. If the lighting dependence vanishes, it was timing, not texture. Costs one drive.
FixStamp at trigger + Tframe/2 using the frame's actual reported exposure. If the sensor will not report per-frame exposure, cap the auto-exposure range so the swing is bounded, and put the residual swing in the error budget explicitly.

F4 is the expensive one, because its decoy is not merely wrong — it is a state the estimator will happily absorb. A timestamping error that looks like gyro bias gets fitted as gyro bias, so the filter reports healthy residuals while two of its states quietly hold each other's errors. That is the worst possible failure signature: an estimator that is confidently wrong and internally consistent. Any time an estimated calibration parameter disagrees with the same parameter measured on a bench, suspect that the estimator is absorbing a timing error, not that the bench is lying.

Notice how the two metrics below play different roles. The stamp-difference histogram proves the bug exists in thirty seconds and needs no motion, no ground truth and no second sensor. The gyro-versus-camera cross-correlation quantifies it, and its peak offset lands on a suspiciously round 40 ms — exactly half the 80.6 ms batch span. Round numbers in a measured offset are a fingerprint of structure, not of noise; noise does not land on halves.

F4: the batch stamped at arrival
SymptomThe estimator's gyro bias estimate is non-zero, direction-dependent, and disagrees with the bias measured on a bench while the unit sits still. Pre-integrated rotation between keyframes is consistently short in the direction of turning.
CauseAll 32 samples of a FIFO burst carry the arrival stamp, so their effective Δt is zero inside the batch and one full batch period between batches. The integrator's time base is a staircase rather than a ramp.
The metricHistogram diff(stamp) for the IMU topic. A correctly back-dated stream gives a tight spike at 2.5 ms. A batch-stamped stream gives a bimodal histogram: a tall spike at 0.0 ms (31 of every 32 gaps) and a small one at 80 ms. That histogram is unmistakable and takes thirty seconds to produce.
Second metricCross-correlate the gyro against the camera-derived rotation rate (Lab 1). A batch-stamping bug shifts the correlation peak by roughly half the batch duration — here 40 ms — a suspiciously round number that is exactly half of 80.6.
FixBack-date per sample. Prefer the sensor's hardware sample counter over a nominal rate so the IMU's own ±50 ppm does not leak back in.

F5 is the one the driver contract promises and most reviews forget. Item 5 of that checklist — publish the sensor's sequence number and count the gaps — reads like bookkeeping until you work out what a single dropped frame does to a pipeline that assumes frames are uniformly spaced. It does not produce noise. It produces a scale error, and scale errors are attributed to wheel radius, to monocular initialisation, to tyre pressure, to anything except the USB controller.

Do the arithmetic before reading the table, because the result is an identity worth memorising. Over a route of duration τ the robot travels v·τ. If the pipeline advances its estimate by one nominal frame period T = 1/30 s per received frame, and a fraction p of frames never arrive, then it advances only N(1−p) times, so it integrates v·τ·(1−p) metres. The trajectory comes out short by exactly the drop rate — independent of speed, independent of route, independent of the estimator.

F5: the silently dropped frame
SymptomThe trajectory has the right shape — every turn is in the right place, every corridor is straight, loop closures are accepted — but the whole thing is uniformly too small. A 240 m surveyed route reports 238.8 m. Wheel odometry and VIO disagree by a constant percentage that does not change with speed.
CauseFrames are being dropped in transport (USB bandwidth, a full DDS queue, a missed deadline) and nothing counts them. Two mechanisms follow. (a) Anything that advances per received message loses one frame period of motion per drop. (b) Worse: a driver that reconstructs tcap from a locally incremented frame index (t_start + n_local·T) desynchronises from the sensor's own index, so every subsequent stamp is one period too early — a permanent step of 33.3 ms that accumulates with every further drop.
The arithmetic240 m at 3.4 m/s = 70.59 s; × 30 Hz = 2118 frames. At a 0.5% drop rate that is 0.005 × 2118 = 10.6 drops. Each costs v·T = 3.4 / 30 = 0.1133 m. Total 10.6 × 0.1133 = 1.20 m short over 240 m = 0.50% — the drop rate, exactly. Under mechanism (b) instead, 10.6 drops leave a standing offset of 10.6 × 33.3 = 353 ms, worth 3.4 × 0.353 = 1.20 m of position error that a bag replay reproduces perfectly and a unit test cannot see.
The metricPlot diff(sequence_number) for every hardware-sourced topic. On a healthy stream every value is exactly 1; any other value is a drop, and the value minus one is how many. Histogram it and the answer is a single glance. Then compare the count to the sensor's own frame counter over 60 s: 30 Hz should give 1800, and 1791 means nine.
Distinguishing tellScale error from a mis-calibrated wheel radius is a fixed percentage that is independent of CPU load and bandwidth. Drop-induced shortfall tracks the drop rate: halve the image resolution or move the camera to its own USB controller and the scale error moves with it. If measured scale error ≈ measured drop rate, you are done arguing.
FixRepublish the sensor's own sequence number or hardware frame counter in the message, and derive capture time from that counter (t_ref + (n_sensor − n_ref)·T), never from a local increment. Publish a diagnostic counting gaps per minute and alarm above a threshold you chose deliberately — because "we drop 0.5% of frames" is an acceptable engineering position, and "we did not know we dropped frames" never is.
Ten-minute triage, from one bag, before you touch any code. Three plots, one pass over the data, no robot time and no ground truth. 1. Histogram diff(stamp) per topic → a spike at 0.0 ms exposes F4. 2. Scatter per-frame exposure against the online time-offset estimate → ρ above 0.5 exposes F3. 3. Histogram diff(seq) per topic → anything other than 1 exposes F5. If all three come back clean, the remaining offset is a constant, and a constant is not a debugging problem — it is a bench measurement, which is exactly what Chapter 4 builds.

The frontier

A frontier answer fails in two opposite ways. It is vague — "I think event cameras are interesting" — or it is a list of paper titles with no claim attached. The structure that works here has three rungs, and the three rows below are deliberately one of each: what is available today and you are not using, what deletes the problem instead of solving it, and what replaces your model of the pipeline with a measurement of it.

Rung one is the strongest of the three, because it is not speculation. A GigE Vision 2.0 camera with IEEE 1588 will hand you a capture timestamp already expressed in the robot's PTP domain, and the entire CODE section above — every subtraction, every sign you could get backwards — becomes dead code. The interesting question is therefore not "does this exist" but "why is your stack not using it", and the honest answers (the sensor predates the standard; the driver drops the chunk data; nobody enabled ChunkModeActive) are all cheap to fix relative to their payoff.

Rung two is worth understanding precisely because it dissolves the chapter's vocabulary rather than improving it. An event camera has no exposure window, so mid-exposure is undefined; no rows read in sequence, so rolling shutter is undefined; no frame, so frame latency is undefined. What survives is the buffer, the bracketing query and the interpolation — Chapter 3's machinery, running at microsecond granularity on a stream that never stops. Say that out loud and you have shown you know which of your concepts were about physics and which were about frames.

Rung three is the one that changes your Monday. The latency table earlier in this chapter is a set of estimates; ros2_tracing turns each row into a measured distribution with under 2% overhead, which means the numbers can go into CI and a regression in driver latency becomes a failing build rather than a field report six months later.

DirectionThe specific referenceWhat changes
The sensor stamps itself, in the PTP domainGigE Vision 2.0 with IEEE 1588; GenICam chunk timestampsThe whole reconstruct-the-pipeline exercise disappears: the frame carries a capture time that is already in the robot's time base. This is available today and still not the default in most stacks — naming it as the cheapest available win is a strong answer.
Sensors with no frames at allGallego et al., "Event-based Vision: A Survey", TPAMI 2022An event camera emits per-pixel brightness changes with microsecond timestamps and no exposure window. Mid-exposure, rolling shutter and frame latency all stop being concepts. The cost is that everything downstream must be rewritten for an asynchronous stream, which is exactly the interpolation machinery of Chapter 3 taken to its limit.
Measuring the stack instead of modelling itBédard, Lütkebohle & Dagenais, "ros2_tracing", RA-L 2022; TIER IV's CARETInstead of estimating L from a datasheet, you trace it: per-callback timelines at microsecond resolution with under 2% overhead. The latency table earlier in this chapter stops being an estimate and becomes a measurement that CI can regress against.
The one-sentence frontier answer. "The direction of travel is that the stamp is moving upstream, toward the silicon. The sensor stamping itself in the PTP domain is available now and removes the reconstruction entirely; event cameras remove the exposure window and therefore the concept of a capture instant, at the cost of rewriting everything downstream as an asynchronous stream; and tracing removes the need to model the pipeline at all by measuring it per callback. The first is a procurement decision I would make today, the second is a research bet, and the third I would turn on this week."
Dated claims, so a future reader can check them. As of 2026: GigE Vision 2.0 + IEEE 1588 chunk timestamps are shipping in mainstream industrial cameras; ros2_tracing and CARET are maintained and in use on production stacks; event-camera pipelines remain research-grade for metric SLAM despite strong results in high-dynamic-range and high-speed niches. If you are reading this later, the row most likely to have moved is the third — check whether an event-based front end has become a shipping product rather than a benchmark result before you repeat that claim in a room.

Practice

The pipeline, and the five places you could stamp it

The bar is one frame's journey from photons to callback. The white marker is the true capture instant (mid-exposure). Click a stamping site to move the stamp; drag the exposure slider and watch the true instant slide out from under a fixed correction.

Exposure T8.0 ms
Speed v3.4 m/s
Quiz: A driver stamps images at the ROS callback with a mean latency of 15 ms and 3.1 ms of jitter. A colleague proposes subtracting a measured 15 ms constant and calling it fixed. What is the strongest objection, and what would you do instead?
Answer first — then open: the autopsy on all three

Option 1 is the answer, and the number is the argument. σ = 3.1 ms × 3.4 m/s = 10.5 mm of irreducible per-frame position noise. Move the stamp to the kernel interrupt (σ = 0.3 ms) and that floor becomes 1.02 mm; take the sensor's own chunk timestamp (σ = 0.02 ms) and it is 0.07 mm. A driver patch buys a 10× improvement in the quantity you can never subtract, which is why the ranking is by jitter and not by mean.

Option 0 is the misconception this question exists to catch. "Zero-mean noise averages out" is true when you average repeated measurements of one fixed quantity. Here every frame observes a different pose, so η is not repeated measurement noise to be averaged away — it is per-measurement noise that must be carried in R, inflated by v2σ2. And the zero-mean premise itself fails under load: CPU pressure rises exactly when the vision pipeline is busy, which is exactly when the robot is moving, so η and v become correlated and E[v·η] ≠ 0. A bias appears precisely in the regime you cared about.

Option 2 attacks the wrong half, and pays in pixels. Halving resolution does shrink the transfer term — it is a real lever on L. But L is the half that costs nothing to remove: measure it once, subtract it forever. Jitter is dominated by scheduler wake-up latency and callback contention, not by bytes on the wire, so σ barely moves. You would have spent half your image resolution buying down the free half of the error and left the 10.5 mm floor exactly where it was.

Chapter 3: Interpolation & Time-Alignment of Asynchronous Streams

Every clock now agrees to a hundred nanoseconds and every message carries an honest capture time. The robot still cannot fuse anything, because the camera samples at 30 Hz, the IMU at 400 Hz, the LiDAR at 10 Hz and the wheel encoders at 200 Hz, and no two of them ever sample at the same instant.

The question this chapter answers. "Your camera fires at t = 1.03333 s and your nearest IMU samples are at 1.03250 s and 1.03500 s. What is the angular rate at the camera instant?" There are three families of answer, they differ by two orders of magnitude in accuracy, and the cheapest one is the one most codebases ship.

The concept

Formally: you hold samples of a signal x at instants {t0, t1, …} and you need x at a query instant tq that is not one of them. Three families:

FamilyWhat it doesWorst-case errorCost
Nearest / zero-order holdUse the closest sample unchangedv · h / 2One comparison
Linear / SLERPBlend the two bracketing samples|x″| · h2 / 8A search plus a few flops
Continuous-timeFit a B-spline or GP through the whole stream and evaluate itSet by the basis order and knot spacingAn optimisation problem

The first two bounds are worth deriving, because the ratio between them is the argument you will make in a design review.

Bound 1: zero-order hold

If samples are spaced h apart, the farthest a query can sit from its nearest sample is h/2. Over that gap the signal moves at rate x′, so:

|eZOH| ≤ |x′| · h / 2
Worked example 1. A 30 Hz camera stream, robot at v = 2.0 m/s.
h = 1/30 = 0.03333 s
worst gap = h/2 = 0.016667 s
|e| = 2.0 × 0.016667 = 0.03333 m = 33.3 mm
Note this scales with velocity and with h, both linearly. It is the same v·Δt of Chapter 0, wearing yet another hat.

Bound 2: linear interpolation

Put the interval at [0, h] and let p(t) be the straight line through the two bracketing samples:

p(t) = x(0) + (t / h) · [ x(h) − x(0) ]

Define the error e(t) = x(t) − p(t). By construction e(0) = e(h) = 0. Everything else follows from Rolle's theorem applied twice — this is where the ½x″(ξ) comes from, and it is worth being able to produce it, because "the standard interpolation remainder" is a phrase, not an argument.

Step 1 — build a function with three roots. Pick any query instant s strictly inside (0, h). Let w(t) = t(t − h), which vanishes at 0 and h and is non-zero at s. Now define

g(t) = e(t) − [ e(s) / w(s) ] · w(t)

The scaling was chosen exactly so that g(s) = e(s) − e(s) = 0. And g(0) = g(h) = 0 because both e and w vanish there. So g has three roots in [0, h]: at 0, at s, and at h.

Step 2 — Rolle, twice. Between two roots of a differentiable function there is a root of its derivative. Three roots of g give two roots of g′ (one in (0, s), one in (s, h)); two roots of g′ give one root of g″, at some ξ strictly inside (0, h).

Step 3 — differentiate twice and read off the answer. p is a straight line so p″ = 0, and w(t) = t2 − ht so w″ = 2, a constant. Therefore

g″(t) = x″(t) − 0 − [ e(s) / w(s) ] · 2

Setting g″(ξ) = 0 and solving for e(s) gives e(s) = ½ x″(ξ) w(s). The instant s was arbitrary, so rename it t:

e(t) = ½ x″(ξ) · t · (t − h)
The one-line version to say out loud. "The error vanishes at both samples, so subtract off a multiple of t(t−h) to force a third root at the query point, then Rolle twice. The second derivative of a straight line is zero and the second derivative of t(t−h) is 2, so what survives is one half of x″ times t(t−h)." Thirty seconds, and it turns a memorised formula into a derivation.

The polynomial t(t − h) is a downward parabola with roots at 0 and h, so its largest magnitude is at the midpoint t = h/2, where it equals (h/2)(−h/2) = −h2/4. Substituting:

|elin| ≤ ½ · |x″|max · h2 / 4 = |x″|max · h2 / 8

For position, x″ is acceleration. The error no longer depends on how fast you are going at all — only on how fast that is changing.

Worked example 2 — the 120× that decides the design. Same 30 Hz stream, robot at v = 2.0 m/s and a = 2.0 m/s2.
Zero-order hold: 2.0 × (1/30) / 2 = 33.33 mm
Linear: 2.0 × (1/30)2 / 8 = 2.0 × 0.0011111 / 8 = 0.00027778 m = 0.278 mm
Ratio: 33.33 / 0.278 = 120×
Algebraically the ratio is (v h/2) / (a h2/8) = 4v / (a h). It grows as h shrinks, so the faster your stream, the more embarrassing zero-order hold becomes. Six lines of interpolation code buy two orders of magnitude, for free, forever.
Say the general form, not the number. "Nearest-neighbour error is first order in the sample spacing and scales with velocity. Linear is second order and scales with acceleration. On a 30 Hz stream at 2 m/s that is 33 mm versus 0.28 mm — and the gap widens as the stream gets faster, because the ratio is 4v over a·h."

Bound 3: the third family, and why it is not free

The first two bounds are special cases of one identity. Fit the unique polynomial of degree n through n+1 samples t0 … tn and the same Rolle argument, run n+1 times instead of twice, gives

e(t) = x(n+1)(ξ) / (n+1)! · (t − t0)(t − t1) … (t − tn)

Zero-order hold is n = 0 (one sample, error ∝ h). Linear is n = 1 (two samples, error ∝ h2). A cubic through four samples is n = 3 and its error is ∝ h4. Each extra sample in the stencil buys another power of h — provided the extra samples exist, which is the whole catch.

Production continuous-time stacks do not use the raw Lagrange cubic (it oscillates and it is only C0 across knots). They use a spline: a piecewise cubic stitched together with continuity conditions. The two you meet are

SplineStencil & continuityInterior orderUsed by
Catmull–Rom (a cardinal spline)4 samples; passes through every sample; C1, with each tangent set to the centred difference (xi+1 − xi−1) / 2hh3 — the centred-difference tangent is itself only second-order accurate, so it costs you one powerThe cheapest honest "continuous-time" you can drop into an existing buffer. This is the third curve in the widget below.
Cubic B-spline4 control points; does not pass through them; C2h4 once the control points are fitted rather than copied from the samplesKalibr, Coco-LIC, and every continuous-time estimator in the frontier table — because C2 means the second derivative exists, and that is what an accelerometer measures.

The cost that is easy to miss. A four-sample stencil needs a sample on each side of the bracketing pair, not just of the query. So the minimum lag doubles: linear waits for one sample past tq, a cubic waits for two. On a 400 Hz IMU that is 2.5 ms becoming 5.0 ms. And on the first and last interval of any finite buffer the four-point stencil does not exist at all, so the scheme silently drops to second order exactly where a naive implementation clamps and pretends. This is why the widget below measures its error over the fully-bracketed span only, and why B-spline fitters pad knots beyond the data.

The honest ranking, and when the third family stops being worth it. Order h, h2, h3 is the accuracy ladder; one sample, two samples, four samples is the latency ladder; and the flops go from a comparison, to two multiplies, to a cubic evaluation plus a wider search. On a 400 Hz IMU with h = 2.5 ms, linear is already at a·h2/8 = 0.0016 mm — four orders of magnitude below sensor noise. Going cubic there buys nothing you can measure and doubles your lag. The third family earns its keep when h is large and the derivative matters: a 10 Hz LiDAR (h = 100 ms), rolling-shutter deskew, or anywhere the estimator wants d/dt of the trajectory in closed form rather than a finite difference.

Rotations do not average

Position interpolates by blending numbers. Rotation does not, because rotations do not live in a vector space — the average of two unit quaternions is not a unit quaternion, and rescaling it to unit length does not put it where you wanted.

Set up the problem on the sphere. Two unit quaternions q0 and q1 subtend an angle Ω, with cos Ω = q0 · q1. We want a path that stays on the unit sphere and sweeps at a constant angular rate, so at parameter u it must sit at angle uΩ from q0. Write the answer as a blend and impose exactly that:

q(u) = α q0 + β q1  with  q0·q(u) = cos(uΩ)  and  q1·q(u) = cos((1−u)Ω)

Those are two linear equations in α and β, and solving them is the derivation of SLERP. Do it once by hand and you never have to memorise the formula again.

Step 1 — substitute and get the system. Dot q(u) = αq0 + βq1 with q0, using q0·q0 = 1 (unit quaternion) and q0·q1 = cos Ω:

α + β cos Ω = cos(uΩ)

Dot the same expression with q1, using q1·q1 = 1:

α cos Ω + β = cos((1−u)Ω)

Step 2 — eliminate. Multiply the second equation by cos Ω and subtract it from the first. The βcosΩ terms cancel and what is left is

α (1 − cos2Ω) = cos(uΩ) − cos((1−u)Ω) cos Ω

That leading factor is the determinant of the 2×2 system — and 1 − cos2Ω = sin2Ω. (Notice immediately where the numerical trouble will be: the determinant vanishes as Ω → 0. That is the entire reason for the small-angle fallback two tables below; it is not a hack, it is a singular linear system.)

Step 3 — one product-to-sum identity. Use cos A cos B = ½[cos(A−B) + cos(A+B)] with A = Ω and B = (1−u)Ω, so A−B = uΩ and A+B = (2−u)Ω:

cos((1−u)Ω) cos Ω = ½[ cos(uΩ) + cos((2−u)Ω) ]

Substituting, the numerator collapses:

α sin2Ω = cos(uΩ) − ½cos(uΩ) − ½cos((2−u)Ω) = ½[ cos(uΩ) − cos((2−u)Ω) ]

Step 4 — one difference-to-product identity. cos X − cos Y = −2 sin((X+Y)/2) sin((X−Y)/2). With X = uΩ and Y = (2−u)Ω the half-sum is Ω and the half-difference is (u−1)Ω, so

cos(uΩ) − cos((2−u)Ω) = −2 sin Ω sin((u−1)Ω) = 2 sin Ω sin((1−u)Ω)

Therefore α sin2Ω = sin Ω sin((1−u)Ω), and one factor of sin Ω cancels:

α = sin((1−u)Ω) / sin Ω

Step 5 — β for free. Swapping u ↔ 1−u swaps the two constraint equations, which swaps the roles of q0 and q1, so β is α with u in place of 1−u. Putting them together gives SLERP, spherical linear interpolation:

q(u) = [ sin((1−u)Ω) · q0 + sin(uΩ) · q1 ] / sin Ω
Two sanity checks before you trust it. At u = 0 the coefficients are sinΩ/sinΩ = 1 and sin 0 / sinΩ = 0, so q(0) = q0. ✓ At u = 1 they swap, so q(1) = q1. ✓ And the result is automatically a unit quaternion: ‖q(u)‖2 = α2 + β2 + 2αβcosΩ, and substituting the two sines and expanding sin2((1−u)Ω) + sin2(uΩ) + 2 sin((1−u)Ω) sin(uΩ) cosΩ reduces to sin2Ω. No renormalisation step is required — unlike NLERP, where the renormalisation is the error.
Worked example 3 — SLERP by hand, and what NLERP costs. Interpolate a quarter of the way from identity to a 90° yaw. In (w, x, y, z):
q0 = (1, 0, 0, 0), q1 = (cos 45°, 0, 0, sin 45°) = (0.70711, 0, 0, 0.70711)
cos Ω = q0·q1 = 0.70711 → Ω = 45° = 0.78540 rad, sin Ω = 0.70711
(note Ω is HALF the rotation angle — that is the quaternion double cover, not an error)

At u = 0.25:
sin((1−0.25) × 0.78540) = sin(0.58905) = 0.55557 → α = 0.55557 / 0.70711 = 0.78570
sin(0.25 × 0.78540) = sin(0.19635) = 0.19509 → β = 0.19509 / 0.70711 = 0.27590
q = 0.78570·(1,0,0,0) + 0.27590·(0.70711,0,0,0.70711)
  = (0.78570 + 0.19509, 0, 0, 0.19509) = (0.98079, 0, 0, 0.19509)
Recovered angle: 2·arccos(0.98079) = 2 × 0.19635 = 0.39270 rad = 22.500° — exactly a quarter of 90°. ✓

Now NLERP (blend then normalise), which is what a careless implementation does:
raw = 0.75·(1,0,0,0) + 0.25·(0.70711,0,0,0.70711) = (0.92678, 0, 0, 0.17678)
‖raw‖ = √(0.85892 + 0.03125) = √0.89017 = 0.94349
normalised = (0.98229, 0, 0, 0.18737)
Recovered angle: 2·arccos(0.98229) = 21.598°
Error: 22.500 − 21.598 = 0.902°

Sweep u across the whole interval and the worst NLERP error for a 90° separation is 0.919°, near u = 0.22. At a landmark 10 m away that is 10 × 0.919 × (π/180) = 160 mm of lateral error, from choosing the wrong four lines of code.

Why NLERP is wrong in a specific, describable way. A chord across a sphere, projected back onto the surface, is traversed slowly near the endpoints and quickly in the middle. So NLERP is exact at u = 0, u = 0.5 and u = 1 — which is exactly why it survives a midpoint unit test — and wrong everywhere in between. Test at u = 0.25 or it will pass and ship.

Two implementation details separate a correct SLERP from a nearly correct one:

DetailWhat goes wrong without itThe numbers
The dot-sign flip. If q0·q1 < 0, negate q1 first.q and −q are the same rotation (the double cover). If the dot is negative, Ω is obtuse and SLERP takes the long way around the sphere.A true 45° step with a sign-flipped input becomes a 315° sweep — the robot's attitude estimate spins the wrong way for one interval and the estimator sheds every landmark.
The small-angle fallback. If q0·q1 > 0.9995, use NLERP instead.SLERP divides by sin Ω, which is the determinant sin2Ω of Step 2 after one cancellation. It goes to zero for nearly identical quaternions — catastrophic cancellation, then NaN.0.9995 corresponds to Ω = arccos(0.9995) = 1.8119°. Sweep u across the whole interval at that separation and the worst NLERP error is 5.8 × 10−5 degrees — about 1 microradian, which is three orders of magnitude below any gyro you will ever mount. Below the threshold the division is unambiguously the bigger risk. This is a numerical-stability decision made from a computed bound, not folk wisdom.

The lag you cannot avoid

Interpolation needs a sample on both sides of the query. So to answer "what was the angular rate at tq" you must wait until a sample arrives after tq. That wait is real latency, and it is the price of correctness.

lagmin = 1 / fslowest stream being interpolated
Worked example 4 — which stream should be the query clock?
The rig is the one from Chapter 2: four 1280×800 mono cameras at 30 Hz (1.02 MB per frame) and one IMU at 400 Hz (28 B per sample). Both options are priced against the same 200 ms ring-buffer horizon, so the comparison is apples to apples.

Option A: align the camera to the IMU. Query at IMU instants; interpolate the images.
  Lag: wait for the next image → up to 1 / 30 = 33.3 ms.
  Buffer: 200 ms of imagery = 0.200 × 30 = 6 frames per camera × 4 cameras × 1.02 MB = 24.5 MB.
  (that is the camera row of the Chapter 2 buffer table, unchanged)

Option B: align the IMU to the camera. Query at image instants; interpolate the IMU.
  Lag: wait for the next IMU sample → up to 1 / 400 = 2.5 ms.
  Buffer: 200 ms of IMU = 0.200 × 400 = 80 samples × 28 B = 2,240 B = 2.24 kB.

Now divide, using only the six numbers above.
  Latency ratio = 33.3 ms / 2.5 ms = 13.3×
  Memory ratio = 24.5 × 106 B / 2.24 × 103 B = 10,900×
Option B is 13.3× lower latency and 10,900× cheaper in memory. Both ratios are dimensionless, so state them as ratios — "an order of magnitude in lag, four orders of magnitude in memory" — and the numbers survive any change of rig.
Where each ratio comes from, algebraically. Let the slow/fat stream run at fs with ss bytes per sample and the fast/cheap one at ff with sf bytes, over a common horizon T.
  lag ratio = (1/fs) / (1/ff) = ff / fs → 400/30 = 13.3 ✓ — it depends only on the rates
  memory ratio = (T fs ss) / (T ff sf) = (fs ss) / (ff sf) → the horizon T cancels, leaving a ratio of bandwidths: 30×4.08 MB/s versus 400×28 B/s = 122 MB/s / 11.2 kB/s = 10,900 ✓
So the second ratio is really "which stream costs more bytes per second", and it is 10,900× regardless of how deep you make the buffer. That is a much stronger sentence in a design review than a memorised 11,000.

That is the architecture. The slowest, fattest stream provides the query timestamps; every fast, cheap stream is interpolated onto them. It falls straight out of the buffer-cost table in Chapter 2 and it is what every production VIO stack does, whether or not its authors phrase it this way.

And when you genuinely cannot wait? A 200 Hz control loop cannot accept 2.5 ms of extra lag for the current estimate. Then you extrapolate — propagate the last state forward with the IMU — and accept an error that grows as ½aΔt2 plus integrated bias. The correct architecture runs both: an extrapolated fast path for control, and an interpolated, slightly-delayed path for the map. Never one path pretending to be both.

The design

The ROS 2 recipe everyone reaches for, and why it is a trap. message_filters::sync_policies::ApproximateTime takes N topics and emits tuples whose timestamps are close together. It is well engineered, widely used, and solves a different problem than the one you have.

ApproximateTimeInterpolating buffer
OperationSelects the existing messages with the smallest time spanEvaluates each stream at a common instant
Residual time errorBounded below by h/2 of the coarser stream. Tuning cannot reduce it.Zero by construction — both streams are evaluated at exactly tq
30 Hz camera + 400 Hz IMUup to 1.25 ms → 4.25 mm at 3.4 m/sa·h2/8 with h = 2.5 ms → 0.0016 mm
Two free-running 30 Hz camerasup to 16.7 ms → 56.7 mm at 3.4 m/sn/a — interpolate both onto one query clock
Failure styleSilent. It emits a tuple and nothing reports how far apart the members were.Explicit. A query outside the buffer returns None.

That third row is the number to remember: 4.25 mm versus 0.0016 mm, a factor of 2,700, between selecting and interpolating. And the fourth row is worse, because two free-running cameras with random relative phase pair with an average error of 8.3 ms and a worst case of 16.7 ms — nearly 57 mm of geometric error that the pipeline never mentions.

The honest use for ApproximateTime. It is correct when the streams really are hardware-triggered together and you just need to regroup them — four cameras on one strobe, for example, where the true time span is nanoseconds. Used that way it is a demultiplexer, not a synchroniser. Using it on genuinely asynchronous streams is where the 57 mm comes from.

The architecture that is actually right. Three components, and it is the same shape whether you write it yourself or use tf2:

1. Capture-time ring buffers
One per fast stream. Depth = max expected query lag × rate, plus margin. IMU at 400 Hz × 300 ms = 120 samples = 3.4 kB. Refuses queries outside its range.
↓ a query timestamp arrives, from the slow/fat stream
2. Bracket + interpolate
Binary search for the bracketing pair — O(log n) — then lerp for vectors, slerp for rotations, and integrate for pre-integrated quantities.
↓ every stream now evaluated at exactly the same instant
3. Fixed-lag release
Hold a query until every stream has a sample past it, then release. Lag = 1/ffastest interpolated stream, so 2.5 ms here. Emit a diagnostic when a query is dropped for being unbracketable.
Production toolWhat it isThe gotcha
tf2_ros::Buffer::lookupTransform(target, source, t)Exactly the architecture above, for transforms. Interpolates translation linearly and rotation with SLERP.Passing Time(0) means "the latest available", not "now". It silently returns a transform of unknown age. Every tutorial uses it.
message_filters ApproximateTimeSelects near-simultaneous tuplesDoes not interpolate. Tuning slop changes what it accepts, never what it computes.
GTSAM PreintegratedImuMeasurementsIntegrates IMU between two keyframe instants, with the right covarianceFeed it correctly back-dated per-sample stamps or the pre-integrated covariance is wrong too.
Kalibr / Coco-LIC B-splineA single continuous pose function of time; query any instantKnot spacing is a real hyperparameter — too coarse and you smooth away real motion.

The code

The whole aligner, from scratch, in under forty lines — short enough to rebuild from memory when you need it.

python
import numpy as np

def bracket(ts, tq):
    """(i, u) with ts[i] <= tq <= ts[i+1]; None if unanswerable."""
    if len(ts) < 2 or tq < ts[0] or tq > ts[-1]:
        return None                       # refuse, never clamp
    i = min(np.searchsorted(ts, tq, side='right') - 1, len(ts) - 2)
    return int(i), (tq - ts[i]) / (ts[i+1] - ts[i])

def slerp(q0, q1, u):
    """Unit quaternions (w,x,y,z). Constant angular rate from q0 to q1."""
    q0 = np.asarray(q0, float); q1 = np.asarray(q1, float)
    d = float(q0 @ q1)
    if d < 0.0:                        # q and -q are the SAME rotation:
        q1, d = -q1, -d               # flip so we take the short arc
    d = min(1.0, max(-1.0, d))
    if d > 0.9995:                    # under 1.81 deg apart: sin(Om) -> 0
        q = q0 + u * (q1 - q0)        # NLERP is safe and accurate here
        return q / np.linalg.norm(q)
    Om = np.arccos(d); s = np.sin(Om)
    return (np.sin((1-u)*Om)/s) * q0 + (np.sin(u*Om)/s) * q1

def pose_at(ts, pos, quat, tq):
    """Evaluate a pose stream at an arbitrary instant. None if out of range."""
    br = bracket(ts, tq)
    if br is None:
        return None
    i, u = br
    p = pos[i] + u * (pos[i+1] - pos[i])     # vectors: linear
    q = slerp(quat[i], quat[i+1], u)         # rotations: spherical
    return p, q
The three decisions in that code. (1) "I return None rather than clamping, because a clamped pose is a silent lie and a None is a loud one." (2) "Translation is linear, rotation is SLERP — rotations are not a vector space." (3) "The sign flip is because q and −q are the same rotation, so without it I would take the long arc." Three sentences, three decisions worth narrating in any code review.

Two natural follow-ups. "Make the bracket search O(1)" and "what is the third family?". Both are short, and both show up in production code.

python
class MonotoneBracket:
    """Queries from a fixed-lag aligner arrive in time order, so the binary
    search is wasted work: remember the last index and walk forward.
    O(1) amortised, and it is the version that runs in the hot path."""
    def __init__(self, ts):
        self.ts = ts
        self.i  = 0
    def __call__(self, tq):
        ts = self.ts
        if tq < ts[0] or tq > ts[-1]:
            return None                          # out of buffer: refuse
        if tq < ts[self.i]:
            self.i = 0                            # a rewind (bag replay) -> restart
        while self.i + 2 < len(ts) and ts[self.i + 1] < tq:
            self.i += 1
        i = self.i
        return i, (tq - ts[i]) / (ts[i+1] - ts[i])

def catmull_rom(ts, xs, tq):
    """The third family, cheap version: a C1 cubic THROUGH the samples.
    Needs TWO samples on each side, so it refuses one interval further in
    than lerp does -- that extra interval is the extra lag it costs."""
    if len(ts) < 4 or tq < ts[1] or tq > ts[-2]:
        return None                              # unbracketed for a 4-pt stencil
    i = int(np.searchsorted(ts, tq, side='right')) - 1
    i = min(max(i, 1), len(ts) - 3)     # keep the 4-point stencil in range
    h = ts[i+1] - ts[i]
    u = (tq - ts[i]) / h
    p0, p1, p2, p3 = xs[i-1], xs[i], xs[i+1], xs[i+2]
    return 0.5 * ((2*p1)
                  + (-p0 + p2) * u
                  + (2*p0 - 5*p1 + 4*p2 - p3) * u**2
                  + (-p0 + 3*p1 - 3*p2 + p3) * u**3)

def catmull_rom_deriv(ts, xs, tq):
    """...and its derivative in closed form -- the actual reason to want a
    continuous-time representation at all, since an IMU measures d/dt."""
    i = int(np.searchsorted(ts, tq, side='right')) - 1
    i = min(max(i, 1), len(ts) - 3)
    h = ts[i+1] - ts[i]; u = (tq - ts[i]) / h
    p0, p1, p2, p3 = xs[i-1], xs[i], xs[i+1], xs[i+2]
    return 0.5 * ((-p0 + p2)
                  + 2*(2*p0 - 5*p1 + 4*p2 - p3) * u
                  + 3*(-p0 + 3*p1 - 3*p2 + p3) * u**2) / h
Check it by hand before you trust it. Take four samples of x(t) = t2 at t = 0, 1, 2, 3 — so xs = [0, 1, 4, 9] — and evaluate at tq = 1.5, which sits in the middle interval with i = 1, h = 1, u = 0.5.
p0, p1, p2, p3 = 0, 1, 4, 9
constant term: 2p1 = 2
linear term: (−p0 + p2)u = (0 + 4)(0.5) = 2
quadratic: (2p0 − 5p1 + 4p2 − p3)u2 = (0 − 5 + 16 − 9)(0.25) = 2 × 0.25 = 0.5
cubic: (−p0 + 3p1 − 3p2 + p3)u3 = (0 + 3 − 12 + 9)(0.125) = 0
sum = 2 + 2 + 0.5 + 0 = 4.5, times ½ = 2.25 — and 1.52 = 2.25 exactly. ✓
Catmull–Rom reproduces quadratics exactly on a uniform grid, which is why its error rides on the third derivative. Compare with what linear would have given: ½(1 + 4) = 2.5, off by 0.25. That single line — "it is exact on quadratics, so its error is third order" — is the whole justification, and you just proved it with four integers.

Debugging

F5: the silent Time(0) lookup
SymptomA manipulator's grasps are accurate when it moves slowly and miss by centimetres when it moves quickly. No errors, no warnings, no exceptions. Every transform lookup succeeds.
CauselookupTransform(target, source, rclcpp::Time(0)) means "give me the most recent transform you have", not "give me the transform now". It returns a pose of unknown age — anywhere from 0 to one full publication period. It never interpolates, because there is nothing to interpolate to.
The metricInstrument the returned transform.header.stamp and histogram tquery − treturned. Correct usage gives a spike at zero. Time(0) usage gives a uniform distribution from 0 to 1/ftf — a flat-topped histogram is diagnostic, and its width tells you which publisher is responsible.
FixAlways pass the real query time, and pass a timeout so the call waits for the bracketing sample instead of returning stale data. Then handle ExtrapolationException explicitly — dropping a measurement is correct; using a stale one is not. Ban bare Time(0) in review.
F6: ApproximateTime with generous slop
SymptomThe estimator reports a healthy median residual with a fat tail. The robust cost is doing a lot of work — 4–8% of measurements are being downweighted — and the affected measurements are, on inspection, from perfectly good frames.
CauseTwo asynchronous streams are being paired, not interpolated. When their relative phase drifts, the policy pairs messages up to slop apart, and every such pair injects v·Δt of error into a measurement the estimator believes is simultaneous.
The metricHistogram |ta − tb| over every emitted tuple. An interpolating pipeline gives identically zero. A pairing pipeline gives a spread up to slop. Overlay that histogram on the residual magnitudes: if the tail of the residuals lines up with the tail of the pairing error, you have your answer in one plot.
The trap in the "fix"Reducing slop does not reduce the error — it makes the policy drop the badly-paired tuples instead. Your residual tail improves and your effective measurement rate quietly falls. Watch the sync callback rate against the input rates; if it is below the minimum of the two, you are silently discarding data.
FixReplace pairing with an interpolating buffer keyed on the slow stream's capture times.
F7: NLERP that passed the unit test
SymptomAttitude is right at every keyframe and slightly wrong between them. Reprojection residuals are healthy on average with a periodic ripple at the keyframe rate. The tracker loses a few percent more features on turns than on straights, and nobody can find a bug, because the rotation interpolation has a passing test: assert slerp(q0, q1, 0.5) == halfway, green since the first commit.
CauseThe implementation is NLERP — blend the two quaternions, renormalise. The renormalisation projects a chord back onto the sphere, and that projection is exactly the identity at u = 0, u = 0.5 and u = 1 by symmetry, and wrong everywhere else. The one point a lazy test picks is the one point the bug cannot be seen at.
The metricEvaluate the interpolator at u = 0.25 for a known 90° step and compare against 22.5°. NLERP returns 21.598°. That 0.902° is the signature; there is no other common bug that produces a clean 22.5 at u = 0.5 and a clean 21.598 at u = 0.25. In a running system, plot interpolated yaw error against the fractional position within each keyframe interval: NLERP gives a double-humped curve, zero at 0, 0.5 and 1, peaking near u = 0.22 and 0.78 with opposite signs. A genuine timing offset gives a monotone ramp instead, and a bad extrinsic gives a constant — three different shapes, three different root causes, one plot.
The cost0.919° worst case for a 90° step. At a landmark 10 m away that is 10 × 0.919 × π/180 = 160 mm of lateral error, arriving only during fast rotation — which is precisely when you were already blaming motion blur.
FixUse SLERP with the dot-sign flip and the 0.9995 fallback, and make the test suite evaluate at an asymmetric u. Add u = 0.25 and u = 0.7 to every interpolation test you own; a test that only checks endpoints and midpoints certifies a chord as an arc.

The frontier

The frontier here is the same idea taken to its conclusion: stop storing a trajectory as samples and start storing it as a function.

DirectionThe specific referenceWhat it buys
Continuous-time as a basis expansionFurgale, Barfoot & Sibley, "Continuous-Time Batch Estimation Using Temporal Basis Functions", ICRA 2012The trajectory becomes a B-spline with a handful of control points. You can query any instant analytically, and derivatives — velocity, acceleration — come out in closed form, which is exactly what an IMU measures. Rolling shutter and LiDAR deskew stop being special cases.
Continuous-time as a Gaussian processBarfoot, Tong & Särkkä, "Batch Continuous-Time Trajectory Estimation as Exactly Sparse Gaussian Process Regression", RSS 2014The same thing with principled uncertainty. A white-noise-on-acceleration prior gives an exactly sparse inverse kernel, so the query is cheap and you get a covariance at the query instant, not just a mean.
Non-uniform knots for multi-rate rigsLang et al., "Coco-LIC: Continuous-Time Tightly-Coupled LiDAR-Inertial-Camera Odometry using Non-Uniform B-spline", RA-L 2023Knot density adapts to the motion, so an aggressive turn gets fine temporal resolution and a straight corridor does not pay for it. This is what makes continuous-time practical at 10 Hz LiDAR plus 400 Hz IMU on real compute.
Is it worth it?Cioffi, Cieslewski & Scaramuzza, "Continuous-Time vs. Discrete-Time Vision-based SLAM: A Comparative Study", RA-L 2022The honest answer, from a controlled comparison: continuous-time wins clearly for high-rate and rolling-shutter sensors, and is roughly a wash for a well-synchronised global-shutter rig. Citing a paper that complicates the frontier story is worth more than citing one that simplifies it.
The tradeoff to state. "Continuous-time turns interpolation from a post-hoc patch into part of the estimator, so the trajectory is differentiable and every sensor can be evaluated at its own true instant. The costs are real: knot spacing is a hyperparameter that can smooth away genuine motion, the state is no longer a list of poses so tooling and debugging get harder, and the comparative study says the win is small for a well-triggered global-shutter rig. I would reach for it when I have rolling shutter, a spinning LiDAR, or sensors I cannot trigger — and not otherwise."

Practice

Three ways to answer a query between samples

The grey curve is the truth — a signal you never actually get to see. The teal dots are the samples you hold. Drag the query time and watch the three reconstructions disagree on the purple line: nearest hands you a stale value, linear cuts every corner, the cubic bends with the signal. Then drag the sample rate: the readout measures the worst error of each family over the whole bracketed span and reports how it changed against what the h, h2, h3 laws predict.

Query time tq0.315
Sample rate8 Hz
Why the measurement window is 0.25–0.75 s and not the whole second. A cubic needs two samples on each side of the query. In the first and last interval of a finite buffer those samples do not exist, so any implementation has to invent them — and whatever it invents is second-order at best, which would swamp the h3 law you are trying to see. The widget therefore scores every family only where all three are fully bracketed. That is not a convenience: it is the same rule as bracket() returning None, applied to the error metric instead of the query.
The order of accuracy, measured rather than asserted

The same three reconstructions, re-scored at every sample rate from 4 to 30 Hz and plotted on log axes, where a power law becomes a straight line and its exponent becomes a slope. Same colours as above — nearest on top, linear in the middle, cubic at the bottom. The faint dashed guides have slopes of exactly −1, −2 and −3. If the derivations above are right, each measured curve should run parallel to its own guide. The purple line is wherever you left the sample-rate slider.

Read the two metrics against each other. On RMS the three slopes come out at roughly −1.0, −1.9 and −3.2, which is the theory to within the fit. On worst-case the nearest and cubic slopes hold but the linear one sags toward −1.8, and the curve is visibly ragged. That is not the bound failing. The max is a supremum over a moving grid: as the rate changes, the worst point jumps from one local extremum of the signal to another, so the sampled max is a step function pretending to be smooth. The bound is a worst case; the measured max is a worst case that keeps changing where it lives. Being able to say that about your own plot is worth more than a clean line.
SLERP vs NLERP, on the arc

The example rotation is pure yaw, so its quaternions live in the two-dimensional (w, z) plane and the unit sphere is honestly a circle. q0 is at the right. SLERP walks the arc at constant speed. NLERP walks the chord at constant speed and then pushes the result back out to the circle along the dotted radius — and a chord, projected back, crawls near the ends and rushes through the middle. SLERP is the filled teal dot, NLERP the open orange ring, and the small ring inside the circle is the chord point before renormalisation. Drag u and read the gap. Drag Ω past 90° and watch the dot product go negative and the sign flip fire. The right panel is the same disagreement plotted against u, in degrees of rotation.

Blend fraction u0.25
Separation Ω45°
Three things to do with this widget before you move on.
1. Set u = 0.5 and read the error: 0.000°. Now set u = 0.25 and read 0.902°. That is the midpoint unit test passing on a broken implementation, live, at the exact numbers of Worked example 3. Then look at the right-hand panel: the error curve touches zero at u = 0, 0.5 and 1 and nowhere else. Any test suite that samples only those three points certifies NLERP as correct.
2. Pull Ω down to 2°, then to 1°. The whole error curve flattens onto the axis: the worst error falls to 7.8 × 10−5 degrees at 2° and 9.8 × 10−6 at 1°, an eightfold drop for a halving because the error goes as Ω3. At 1° the readout turns green — the dot product has crossed 0.9995 and the fallback fires. That is the whole argument for the threshold in one drag: NLERP stops being an approximation you tolerate and becomes numerically indistinguishable, exactly as sin Ω in the denominator becomes something you should not divide by.
3. Push Ω past 90°. The dot product goes negative, the flip fires, and the teal arc jumps to the other side — the short way round — while the red dashed arc shows the long sweep you would have taken without it. At Ω = 157° a 46° step becomes a 314° one, which is the sign-flip row of the table above, drawn.
Quiz: You have a 30 Hz camera and a 400 Hz IMU and you must fuse them. Which stream provides the query timestamps, and what does that choice cost you?
The full answer, and why each distractor is tempting.
Why the camera. Bracketing is the constraint: to interpolate a stream onto an instant you need one of its samples on each side. So the query clock has to be the stream you are not interpolating, and the lag you pay is one period of the stream you are interpolating. Query at image instants and you wait 1/400 = 2.5 ms for the bracketing IMU sample, holding 200 ms of IMU = 80 × 28 B = 2.24 kB.
Why "the IMU" is the trap. It inverts the cost. Querying at IMU instants means interpolating images, so you wait for the next image — 1/30 = 33.3 ms, thirteen times worse — and you hold 200 ms of imagery, 6 frames × 4 cameras × 1.02 MB = 24.5 MB. The "more fusion points" instinct is also wrong on its own terms: the information rate is set by the camera, and querying at 400 Hz just resamples 30 Hz of visual information thirteen times over while the estimator double-counts correlated measurements.
Why "ApproximateTime" is the trap that ships. It is free of buffering because it never evaluates anything — it selects the nearest existing pair. With slop = 16.7 ms it will happily emit a tuple whose members are 16 ms apart, which at 3.4 m/s is 56.7 mm of geometric error injected into a measurement the estimator believes is simultaneous, and it will not tell you.
The sentence that closes it. "The slowest, fattest stream provides the query timestamps and every fast, cheap stream is interpolated onto them. The cost is one period of the fastest interpolated stream in fixed lag — 2.5 ms here — and it is worth it, because the alternative is 13× the lag and four orders of magnitude the memory."

Chapter 4: Showcase — The Two-Sensor Alignment Bench

A delivery robot weaves down 96 m of sidewalk, swinging 1.6 m to each side every 24 m as it threads between parked scooters. At walking pace its localisation is clean. Wind it up to 3.4 m/s — same route, same code, same map, same everything — and it finishes 32 millimetres off. Two thirds of a 50 mm budget, spent on nothing that appears in any log.

That robot is on the bench below. Set the offset slider to +12 ms and the 32 mm appears; drag it back to zero and it vanishes, at every speed you can select. The rest of this chapter is the explanation of why those two sentences are both true.

The setup

The route is fixed — four lobes, 96 m of ground track, a lateral amplitude of 1.6 m over a 24 m wavelength. It is a realistic sidewalk weave around parked scooters, and it is chosen because it exercises the rotation channel hard while returning to the same heading, so nothing is hidden by a lucky cancellation.

Two sensors. The IMU is correct. The camera provides attitude, and its measurements describe the world Δt seconds before the label they carry. The estimator, believing the labels, dead-reckons along a heading that is stale by exactly that amount.

SymbolMeaningBench range
vGround speed along the path0.4 – 5.0 m/s
ΔtCamera-minus-IMU time offset. Positive = the camera is late.−40 – +40 ms
ASlalom amplitude — how hard the robot turns0.4 – 3.0 m
θ(t)True headingderived from the path
ω(t)True yaw rate, = v · κ where κ is path curvaturederived
Θ(t)Heading swept since the start: θ(t) − θ(0)derived

The governing equation, derived

The estimator's reported heading is stale by Δt, so it is wrong by the amount the robot turned during that interval:

δθ(t) = ω(t) · Δt

Dead-reckoning at speed v along a heading that is off by δθ accumulates cross-track error at a rate of v · δθ (for small angles, sin δθ ≈ δθ):

de / dt = v · δθ(t) = v · ω(t) · Δt

Both v and Δt are constants here, so they come straight out of the integral, and what remains integrates to the swept heading:

e(t) = v · Δt · ∫0t ω(τ) dτ = v · Δt · Θ(t)
Read what that equation says. The cross-track error is the product of three independent things: how fast you are going, how badly the clocks disagree, and how much you have turned. It does not depend on how long you have been driving, or how far. A robot that drives 10 km in a straight line accumulates zero error from this mechanism. A robot that turns 360° in a car park accumulates the lot.
Worked example 1 — predict the bench before you run it.
The slalom's heading swings between arctan(A · 2π/L) and its negative. With A = 1.6 m and L = 24 m:
slope amplitude = 1.6 × (2π / 24) = 1.6 × 0.26180 = 0.41888
θmax = arctan(0.41888) = 0.39662 rad (22.72°)
Peak swept heading (one reversal, max to min): Θmax = 2 × 0.39662 = 0.79324 rad
Now predict the peak cross-track error at v = 3.4 m/s, Δt = 12 ms:
e = 3.4 × 0.012 × 0.79324 = 0.032364 m = 32.4 mm
Set the sliders to those values and read the panel. The simulator integrates the full non-linear kinematics with no small-angle assumption and reports 32.4 mm. Two independent routes to the same number.
Why that agreement matters. Anyone can wiggle a slider. Being able to say "I predict 32.4 millimetres, and here is the three-line derivation that gets me there" is the difference between having played with a demo and understanding a system. The equation e = v · Δt · Θ is small enough to derive on a whiteboard in under a minute.

The bench

Alignment bench — drag the latency and watch the trajectory bend

Grey is ground truth. Orange is what the estimator believes. The two coincide exactly when Δt = 0, regardless of speed. Push Δt away from zero and the orange path stops tracking the corners — it under-turns, because its heading is stale. The readout underneath prints the simulated peak error (from the full non-linear integration, no small-angle assumption anywhere) next to the predicted v·Δt·Θ, so you can check the derivation against the thing it claims to describe.

Offset Δt+12 ms
Speed v3.4 m/s
Slalom amplitude A1.6 m
Peak error against speed — the velocity-scaled signature

Each line is one value of Δt. The dot is the bench's current operating point, and the dashed line is the 50 mm localisation budget. Where a line crosses the budget is that robot's maximum safe speed.

Six experiments, with what you should see

Run these in order. Each one is a claim from an earlier chapter that the bench either confirms or refutes.

#Do thisWhat you should seeWhich claim it tests
1Set Δt = 0, then sweep the speed from 0.4 to 5.0The orange path stays exactly on grey. Peak error stays at 0.0 mm at every speed.Speed alone causes nothing. The bug needs a Δt to multiply.
2Set Δt = 12 ms, sweep the speed againError grows perfectly linearly: about 4.8, 13.3, 20.9, 32.4, 47.6 mm at 0.5, 1.4, 2.2, 3.4, 5.0 m/s.e = v · Δt · Θ. Divide any of those by its speed and you get 9.5 mm per m/s — a constant.
3Hold v = 3.4, sweep Δt from −40 to +40A clean V shape through zero. Sign only flips which way the path bends; magnitude depends on |Δt|.The offset is signed, and getting the sign right matters as much as the magnitude.
4Set the amplitude A to 0.4, keep Δt = 12 ms and v = 5.012.5 mm — down from 47.6 mm at A = 1.6, and the ratio is exactly the ratio of swept headings: 0.79331 / 0.20867 = 3.80, and 47.6 / 12.5 = 3.80.Θ is the third factor, and it is the only one you changed. Note that A = 0.4 is not "nearly straight" — it still sweeps 0.209 rad, and 12.5 mm is a quarter of the budget.
5Press Subtract constant with Δt = 12 ms, then sweep the speed again0.4, 1.0, 2.4, 3.6 mm at 0.5, 1.4, 3.4, 5.0 m/s. Still perfectly linear in speed — but the slope has collapsed from 9.5 to 0.71 mm per m/s.The mean is calibratable; the residual is the 0.9 ms of stamping jitter from Chapter 2. Calibration changes the coefficient of v·Δt·Θ, never its shape.
6Press Wrong signError doubles rather than vanishing: about 64.7 mm instead of 32.4 mm.A sign error in the correction is worse than no correction at all. See the failure mode below.
Experiment 4 is the one that costs teams months. Validation on a straight test track sweeps speed — the obvious variable — and finds nothing, because Θ is near zero. The bug ships. It then appears in the field, where the route has corners, and gets attributed to "the environment" or "the customer's floor". Any speed sweep that does not also sweep turning is testing one factor of a three-factor product.
Worked example 4 — what experiment 4 actually pays for.
The route's swept heading is Θ = 2 · arctan(A · 2π/24). Turning the amplitude down from 1.6 m to 0.4 m:
A = 1.6: Θ = 2 · arctan(1.6 × 0.26180) = 2 × 0.39666 = 0.79331 rad
A = 0.4: Θ = 2 · arctan(0.4 × 0.26180) = 2 × 0.10434 = 0.20867 rad
At v = 5.0 m/s and Δt = 12 ms the two predictions are
5.0 × 0.012 × 0.79331 = 0.04760 m = 47.6 mm
5.0 × 0.012 × 0.20867 = 0.01252 m = 12.5 mm
A 3.80× reduction, and 0.79331 / 0.20867 = 3.80 — the error ratio is the swept-heading ratio, because nothing else in the product changed.
Do not read experiment 4 as "the bug went away". 12.5 mm is a quarter of a 50 mm budget from timing alone, and no review would wave that through. A = 0.4 m over a 24 m wavelength is a lane-keeping wobble, not a straight line, and it still sweeps 0.209 rad — over a quarter of the headline value. The genuinely invisible case is the Straight test track row of the three-routes table further down this chapter, where Θ really is ≈ 0 and the predicted error really is 0 mm at any speed. That row, not this experiment, is the validation gap.

Where the residual floor comes from

Experiment 5 is the one that most needs its number derived rather than quoted, because "press the button and the error mostly goes away" is exactly the kind of hand-wave that collapses under one follow-up question. So: why 2.4 mm, and not zero?

Subtracting a calibrated constant removes the mean of the offset. It cannot remove the jitter, because jitter is a fresh random draw on every frame and there is no single number to subtract. Chapter 2's stamping-site table gives the figure directly: option D, a userspace driver stamping after the read returns, carries a mean of 13.0 ms and a jitter of 0.9 ms. That 0.9 ms is what the bench uses as its post-correction residual, and it is the honest floor for a team that has calibrated well but has not moved the stamp.

Worked example 5 — deriving the 2.4 mm floor.
The governing equation does not care whether Δt is a bias or a residual — it is the same product either way:
efloor = v · σjitter · Θ
At v = 3.4 m/s: 3.4 × 0.0009 × 0.79331 = 0.002428 m = 2.4 mm
At v = 0.5 m/s: 0.5 × 0.0009 × 0.79331 = 0.000357 m = 0.4 mm
At v = 1.4 m/s: 1.4 × 0.0009 × 0.79331 = 0.001000 m = 1.0 mm
At v = 5.0 m/s: 5.0 × 0.0009 × 0.79331 = 0.003570 m = 3.6 mm
Press Subtract constant and sweep the speed slider; those are the four numbers the panel prints.
The floor is still linear in speed — and that is the lesson. Divide any of those by its speed and you get the same 0.71 mm per m/s (0.0009 s × 0.79331 rad = 0.00071 m per m/s). The uncorrected slope was 0.012 × 0.79331 = 0.00952 m per m/s, or 9.5 mm per m/s. Calibration changed the slope by exactly 12 / 0.9 = 13.3×. It did not flatten the line.

That distinction is worth saying out loud in a room, because it is the difference between a fix and a mitigation. If correcting the mean had removed the speed dependence, the residual would be noise — and noise averages down over a run, so a longer route would help. It is not noise. It is a smaller bias with the same v·Δt·Θ shape, perfectly correlated with the robot's own motion, and averaging does nothing to it. The only way to change the shape is to make Θ or v smaller, which is a product decision, or to make σjitter smaller, which means moving the stamping site — option C at 0.3 ms, or option B at 0.02 ms. That is why Chapter 2 insisted you rank stamping sites by jitter and not by mean.
The follow-up this prepares you for. "You said calibration takes it from 32 mm to 2 mm. Can we go faster now?" — "Yes, but not by as much as it looks. The floor is 0.71 millimetres per metre per second, so a 50 mm budget bought entirely by this one channel is 70 m/s and stops being the binding constraint. What binds instead is drift in the calibrated constant itself, which moves about 1.9 milliseconds across a shift — and 1.9 ms is 6.5 mm at 3.4 m/s, nearly three times the jitter floor. So the recalibration cadence, not the jitter, is what actually sets the speed limit after fix 2."

Reading the panel

ReadoutWhat it isWhat to watch for
peak cross-trackLargest perpendicular distance between the true and estimated paths, from the full non-linear integrationIt agrees with the prediction to inside 0.5% everywhere in the slider space — there is no setting at which they part company. Worst case is the far corner: v = 5.0, Δt = 40 ms, A = 3.0 gives 267.7 mm simulated against 266.3 mm predicted. Watch that gap stay closed; if it ever opens, the bench is broken, not the theory.
predicted v·Δt·ΘThe closed form derived aboveAgreement is the point. If they diverged, the derivation would be wrong.
heading error at peak ωωmax · Δt, in degreesThis is what an attitude monitor would report. Under about 0.1° it is invisible in any dashboard.
landmark smear at 5 m5 · δθmax, the lateral displacement of a landmark 5 m outThis is the number that determines whether loop closures survive. Compare it to your inlier gate.
budgetWhether the peak error is inside 50 mmNote how much speed you can carry at 5 ms versus 20 ms. That difference is what a hardware trigger buys.
Why the small-angle step never costs you anything here. The derivation replaced sin(δθ) with δθ. The relative cost of that swap is 1 − sin(x)/x ≈ x²/6, so it only matters once the heading error δθ is large — and δθ is tiny even at the extremes of this bench.

Peak heading error is ωmax · Δt, and ωmax = A · kw² · v with kw = 2π/24 = 0.26180 rad/m:
Defaults (A = 1.6, v = 3.4, Δt = 12 ms): ωmax = 1.6 × 0.068539 × 3.4 = 0.3729 rad/s, so δθ = 0.3729 × 0.012 = 0.00447 rad (0.26°). Small-angle error: 0.00447²/6 = 0.0003%.
Far corner (A = 3.0, v = 5.0, Δt = 40 ms): ωmax = 3.0 × 0.068539 × 5.0 = 1.0281 rad/s, so δθ = 0.0411 rad (2.36°). Small-angle error: 0.0411²/6 = 0.028%.

Both are far below the 0.5% residual you actually observe, which means the observed gap is not the small-angle approximation at all — it is the finite integration step (N = 900 rectangles over the route) plus the fact that the peak of a discretely sampled curve lands between samples. Knowing which of your two approximations dominates is a genuinely useful reflex: if you had blamed the small-angle term and gone looking for it, you would have found nothing, because it is two orders of magnitude too small to be the thing you are seeing.

Turning the bench into a speed limit

The single most useful thing you can do with e = v · Δt · Θ is invert it. Fix the error budget, and the equation hands you the maximum speed the robot may travel:

vmax = ebudget / (Δt · Θ)
Worked example 2 — the speed limit table. With ebudget = 0.050 m and this route's Θ = 0.79324 rad:
Δt = 1 ms: vmax = 0.050 / (0.001 × 0.79324) = 0.050 / 0.00079324 = 63.0 m/s
Δt = 5 ms: vmax = 0.050 / 0.0039662 = 12.6 m/s
Δt = 12 ms: vmax = 0.050 / 0.0095189 = 5.25 m/s
Δt = 33 ms: vmax = 0.050 / 0.026177 = 1.91 m/s
Δt = 80 ms: vmax = 0.050 / 0.063459 = 0.79 m/s

Those are the rotation channel alone. The along-track channel from Chapter 0 contributes another v · Δt on top, with a factor of 1 rather than 0.79, so a conservative combined budget roughly halves every number above: at 12 ms the honest limit for this route is closer to 2.9 m/s, which is exactly where the ticket in Chapter 0 said things fell apart.

Why to phrase it this way. "We need better time sync" is a request that competes with every other request and loses. "This robot is currently limited to 2.9 metres per second by a twelve-millisecond timing error, and a forty-dollar trigger harness raises that to sixty" is a business case. Same physics, different sentence, completely different outcome in a planning meeting.

It also inverts cleanly the other way: if the product team says the robot must do 4 m/s, the equation tells you the sync spec that requirement implies — Δt below 0.050 / (4 × 1.793) = 7.0 ms total across both channels. Now the timing budget is derived from a product requirement rather than argued about.

What the four fixes actually cost

Sooner or later you have to choose. Here is the ladder from Chapter 0, priced.

FixResidual Δtvmax (both channels)EngineeringBOMFails how
1. Do nothing12 ms2.3 m/sSilently, in the field, blamed on the environment
2. Measure once, subtract a constant~3 ms (jitter + drift)9.3 m/s~1 week incl. the shake rigGoes stale — drifts with temperature, exposure and CPU load. Needs a recalibration cadence.
3. Move the stamp to the kernel or the sensor~0.3 ms93 m/s2–4 weeks of driver work per sensorVendor SDK will not expose it; you fall back to 2 for that sensor.
4. Hardware trigger + PTP for the rest< 0.01 msnot the limit4–8 weeks incl. EMC~$40–120Silently, if the harness breaks — needs a liveness check on the observed frame interval.
5. Estimate Δt online as a filter statetracks drift, ~0.5 ms56 m/s2–3 weeks, plus observability careGoes unobservable when the robot is still or moving smoothly; needs a hold-last-good policy.
The answer that lands. "I would do 2 immediately because it is a week and takes the robot from 2.3 to 9.3 metres per second. In parallel I would scope 4 for the next hardware revision, since it removes the problem structurally rather than compensating for it. I would add 5 last, as a monitor rather than as the primary mechanism — it is the only one that tracks drift, but it goes unobservable exactly when the robot is parked, so it cannot be the thing the system depends on."

Why the calibrated constant goes stale

Fix 2 is the cheapest and it is the one that quietly decays. Three things move the offset after you have measured it, and an engineer who names all three is describing a system they have operated.

What movesMechanismObserved swing on this robot
Auto-exposureMid-exposure slides with T — Chapter 2, worked example 1±4.5 ms between full sun and a dim warehouse
CPU loadScheduler latency at the stamping site grows with contention+0.4 ms mean, and the p99 jitter roughly doubles when the perception stack is under load
TemperatureOscillator frequency shifts, and any undisciplined domain accumulates it~0.5 ppm per °C on an AT-cut crystal — harmless if disciplined, a growing ramp if not

Put those together and a constant measured at 12.0 ms on a cool morning in a bright loading bay can be 13.9 ms on a warm afternoon indoors. That 1.9 ms of residual is 6.5 mm at 3.4 m/s — acceptable, but it is the reason fix 2 needs either a recalibration cadence or fix 5 running alongside as a monitor.

The cadence question, answered with a number. "How often should we recalibrate?" is answerable: recalibrate when the expected drift consumes a stated fraction of the budget. If the offset moves 1.9 ms across a shift and your residual allowance is 3 ms, then once per shift is the right cadence, and the shake test takes four minutes. If you cannot afford four minutes per shift, you have just built the business case for fix 4.

Turning the bench into a regression test

The last step, and the one that keeps the bug fixed: this bench is a unit test. It needs no hardware and no recorded data.

1. Generate
Synthesise the slalom, sample the camera at 30 Hz and the IMU at 400 Hz, and inject a known offset of 12.0 ms into the camera stamps.
↓ feed the synthetic streams to the real estimator
2. Estimate
Run the production alignment and calibration code. It should recover 12.0 ms.
↓ assert on the recovered value AND its sign
3. Assert
assert abs(td_hat - 0.012) < 0.0005 — this catches the sign inversion of F7, which no amount of code review reliably catches.
↓ then the end-to-end gate
4. Gate
Run the full route at 3.4 m/s with the correction applied and assert peak cross-track under 5 mm. A future refactor that moves the stamping site fails here, loudly, in CI.
Why synthetic beats recorded here. A recorded bag has an unknown true offset, so the best you can assert is "the answer did not change" — which passes happily if the answer was wrong all along. Synthetic data has a known ground truth, so you can assert correctness rather than stability. Use recorded bags for realism and synthetic streams for correctness; a mature stack has both.

The code

Both boxes of that flow are small enough to write at a whiteboard, and both are worth being able to produce on demand. Box 1 is the generator — the bench itself. Box 2 is the estimator — the shake test that produces the td_hat the assertion checks. Here they are, from scratch, in that order.

1. The bench integrator

Forty lines that reproduce worked example 1 exactly. Notice that the entire bug is one lineth_est = th - om*td — and that everything around it is honest kinematics with no small-angle assumption anywhere. That asymmetry is the point: a one-line defect in a hundred lines of correct code, producing an error a third of a pixel wide.

python
import numpy as np

L_SLALOM, X_END = 24.0, 96.0          # wavelength and ground track, in metres

def bench(v, td, A, N=900):
    """Peak cross-track error (m) when the reported heading is stale by td seconds.
    Returns (peak_error_m, swept_heading_rad). No small-angle assumption anywhere."""
    kw = 2*np.pi / L_SLALOM              # spatial frequency, rad per metre
    h  = (X_END / v) / N                   # integration step, seconds
    tx = ty = ex = ey = 0.0                # truth and estimate, integrated separately
    peak = 0.0
    th_hi, th_lo = -9.0, 9.0              # running max/min heading -> Theta

    for i in range(N+1):
        x     = v * (i*h)                                  # ground-frame x = v*t
        slope = A*kw*np.cos(kw*x)                       # d/dx of A*sin(kw*x)
        th    = np.arctan(slope)                       # true heading
        curv  = -A*kw*kw*np.sin(kw*x) / (1+slope**2)**1.5  # signed curvature
        sp    = v*np.sqrt(1+slope**2)                    # speed ALONG the path, not v
        om    = curv * sp                                  # yaw rate = kappa * path speed

        th_est = th - om*td            # THE BUG, in one line: a heading td seconds old

        th_hi, th_lo = max(th_hi, th), min(th_lo, th)
        tx += np.cos(th)*sp*h;      ty += np.sin(th)*sp*h       # ground truth
        ex += np.cos(th_est)*sp*h;  ey += np.sin(th_est)*sp*h   # what the estimator believes
        peak = max(peak, np.hypot(ex-tx, ey-ty))

    return peak, th_hi - th_lo

peak, Theta = bench(v=3.4, td=0.012, A=1.6)
print(f"Theta     = {Theta:.5f} rad")                  # Theta     = 0.79331 rad
print(f"simulated = {peak*1000:.1f} mm")               # simulated = 32.4 mm
print(f"predicted = {3.4*0.012*Theta*1000:.1f} mm")    # predicted = 32.4 mm

for v in (0.5, 1.4, 2.2, 3.4, 5.0):                # the whole of experiment 2
    p, _ = bench(v, 0.012, 1.6)
    print(f"v={v:>4}  {p*1000:6.1f} mm   e/v = {p/v*1000:.2f} mm per m/s")
# v= 0.5     4.8 mm   e/v = 9.52 mm per m/s
# v= 1.4    13.3 mm   e/v = 9.52 mm per m/s
# v= 2.2    20.9 mm   e/v = 9.52 mm per m/s
# v= 3.4    32.4 mm   e/v = 9.52 mm per m/s
# v= 5.0    47.6 mm   e/v = 9.52 mm per m/s
Three decisions to narrate while you write it. (1) "sp is the speed along the path, not the ground-frame v — a slalom is longer than its projection, and if I integrate with v I will under-run the route and every number will be slightly small." (2) "I integrate truth and estimate as two separate dead-reckonings and difference them at the end, rather than integrating the error directly — that way the small-angle step lives only in the closed form I am checking against, never in the thing doing the checking." (3) "I track Θ as max-minus-min heading rather than assuming 2·arctan(A·kw), so that if I later change the path shape the prediction still has the right Θ and the two routes stay independent."

2. The shake-test offset estimator

Fix 2 of the ladder — "measure once, subtract a constant" — is the rung everyone reaches for first, and it is the one whose implementation nobody shows. Here is the whole thing. You shake the robot by hand for twenty seconds, take the gyro's yaw rate on one side and a camera-derived rotation rate on the other, and find the shift that lines them up.

Four steps, and every one of them is a place implementations go wrong: resample onto a common grid (two rates cannot be correlated sample-against-sample), zero-mean both (a gyro bias is DC, and DC correlated against DC swamps the peak), correlate across the lag range, then interpolate the peak to sub-sample resolution.

python
import numpy as np

def estimate_td(t_gyro, w_gyro, t_cam, w_cam, fs=400.0, max_lag=0.100):
    """td, in seconds, to SUBTRACT from the camera stamps to reach the IMU base.
    w_* are scalar rotation-rate magnitudes; the sign convention is fixed here,
    once, and asserted in CI -- see failure mode F7."""

    # --- 1. resample both streams onto ONE clock ------------------------------
    t0 = max(t_gyro[0],  t_cam[0])  + max_lag      # trim so every lag stays
    t1 = min(t_gyro[-1], t_cam[-1]) - max_lag      # inside both records
    grid = np.arange(t0, t1, 1/fs)
    a = np.interp(grid, t_gyro, w_gyro)              # 400 Hz -> grid (near no-op)
    b = np.interp(grid, t_cam,  w_cam)               # 30 Hz -> grid (upsampled)

    # --- 2. zero-mean, or DC buries the peak ----------------------------------
    a = a - a.mean()
    b = b - b.mean()
    na, nb = np.linalg.norm(a), np.linalg.norm(b)

    # --- 3. normalised cross-correlation, one lag at a time --------------------
    K    = int(round(max_lag*fs))                     # 100 ms at 400 Hz = 40 samples
    lags = np.arange(-K, K+1)
    r    = np.empty(len(lags))
    for j, k in enumerate(lags):                   # r[j] = dot( a(t), b(t+k) )
        if k >= 0:
            x, y = a[:len(a)-k], b[k:]
        else:
            x, y = a[-k:],       b[:len(b)+k]
        r[j] = float(x @ y) / (na*nb)               # normalise -> r is a rho, not a scale

    # --- 4. parabolic sub-sample peak -----------------------------------------
    j = int(np.argmax(r))
    if j == 0 or j == len(r)-1:                       # peak pinned at the edge means
        raise ValueError("peak at lag limit -- widen max_lag")   # max_lag is too small
    y0, y1, y2 = r[j-1], r[j], r[j+1]
    delta = 0.5*(y0 - y2) / (y0 - 2*y1 + y2)         # in samples; |delta| <= 0.5
    return (lags[j] + delta) / fs, float(r[j])

Drive it with a synthetic shake carrying a known 12.0 ms offset, exactly as the regression-test flow above demands:

python
TD_TRUE = 0.012

def shake(t):
    """A hand shake with three tones, so the correlation peak is sharp."""
    return (1.9*np.sin(2*np.pi*1.7*t)
          + 1.1*np.sin(2*np.pi*4.3*t + 0.7)
          + 0.6*np.sin(2*np.pi*9.1*t + 2.1))

t_gyro = np.arange(0.0, 20.0, 1/400.0);  w_gyro = shake(t_gyro)
t_cam  = np.arange(0.0, 20.0, 1/30.0);   w_cam  = shake(t_cam - TD_TRUE)
# the camera stamps are LATE: the frame labelled t actually saw the world at t - td

td_hat, rho = estimate_td(t_gyro, w_gyro, t_cam, w_cam)
print(f"td_hat = {td_hat*1000:.3f} ms   rho = {rho:.4f}")   # td_hat = 11.848 ms  rho = 0.9957
assert abs(td_hat - TD_TRUE) < 0.0005                 # the assertion from box 3, passing
Why step 4 is not optional, in one number. The coarse argmax can only land on a grid point, and the grid step is 1/fs = 1/400 = 2.5 ms. Run this without the parabolic refinement and it returns exactly 12.500 ms — wrong by 0.5 ms, which at 3.4 m/s on this route is 1.3 mm before you have made a single other mistake, and at the 5 ms grid of a 200 Hz IMU it would be 2.5 ms and 6.8 mm. The three-sample parabola costs one line and takes it to 11.848 ms, an error of 0.152 ms. Sub-sample interpolation is the difference between a calibration that beats the jitter floor and one that is the jitter floor.
And the residual 0.152 ms is a bias, not noise — here is the diagnosis. The obvious suspicion is the correlation grid, so test it: re-run at fs = 800 and fs = 1600 Hz and the answer moves to 11.849 and 11.850 ms. It barely budges, so the grid is not the cause. Re-run with the camera at 60, 120, 240 Hz and it stays near 11.85 too, so the camera rate is not the cause either. What is the cause shows up when you excite one frequency at a time:

pure 1.7 Hz shake → 11.223 ms (−0.78 ms)
pure 4.3 Hz shake → 11.950 ms (−0.05 ms)
pure 9.1 Hz shake → 11.981 ms (−0.02 ms)

A slow shake produces a broad, flat correlation peak, and a three-point parabola fitted to a broad non-parabolic peak is biased — 40× worse at 1.7 Hz than at 9.1 Hz. The engineering instruction that falls out of this is concrete and slightly counter-intuitive: shake fast, not far. Amplitude buys signal-to-noise; bandwidth buys resolution in td. This is the same observability argument the online estimators in the frontier section below run into, arriving from the other direction.

And now the library one-liner, which you should reach for in production and be able to replace at a whiteboard:

python
from scipy.signal import correlate, correlation_lags

fs   = 400.0
grid = np.arange(0.1, 19.9, 1/fs)
a = np.interp(grid, t_gyro, w_gyro); a -= a.mean()
b = np.interp(grid, t_cam,  w_cam ); b -= b.mean()

r    = correlate(b, a, mode='full')                       # FFT-based, O(n log n)
lags = correlation_lags(len(b), len(a), mode='full') / fs
print(f"td_hat = {lags[np.argmax(r)]*1000:.3f} ms")          # td_hat = 12.500 ms
Read those two results together. The library version is shorter, faster (FFT rather than a Python loop over 81 lags) and gives 12.500 ms — the grid-quantised answer, because correlate returns samples and nothing in SciPy interpolates the peak for you. The from-scratch version gives 11.848 ms. Neither is buggy; the library simply does not do the step that matters most, and anyone who says "I would use scipy.signal.correlate" and stops there has just accepted a half-millisecond quantisation error without noticing. Use the library for the correlation and keep your own three lines for the peak.

Failure mode 7: the correction with the wrong sign

F7: sign-inverted time correction
SymptomA team measures the offset correctly, applies the correction, and the error gets worse by exactly a factor of two. Because the fix "obviously" cannot be the cause, the investigation moves on and the correction stays in.
The arithmeticThe true residual offset after correction is Δt − Δt̂. Correct sign: 12 − 12 = 0 ms. Inverted sign: 12 − (−12) = 24 ms. The error goes from 32.4 mm to 64.7 mm — precisely 2×, which is the fingerprint.
The metricPlot peak error against the applied correction and sweep it through zero. The correct sign gives a V with its minimum at the true offset; the inverted sign gives a V whose minimum sits at −Δt. The location of the minimum is the answer, and the sweep takes ten minutes on recorded data.
Why it happens"The camera is 12 ms late" can mean subtract 12 ms from the camera stamp, or add 12 ms to the IMU stamp, or add 12 ms to the camera stamp depending on which way you read the arrow. There is no convention. The only defence is a unit test built from a synthetic stream with a known injected offset.
FixDefine the sign once, in a comment, at the definition site: "td is added to the camera timestamp to bring it into the IMU time base." Then assert it against synthetic data in CI. Never document it only in a design doc.
Use the bench to internalise the numbers. Set Δt = 12 ms, v = 3.4, then read the story off it: "peak cross-track thirty-two millimetres, driven by v times delta-t times swept heading, seventy-nine hundredths of a radian for this route. Drop the speed to one point four and it falls to thirteen, which is why nobody filed a bug at walking pace." Then hit Subtract constant and finish: "and correcting the mean leaves about two and a half millimetres, which is nine tenths of a millisecond of stamping jitter times the same speed and the same swept heading — a floor set by where the driver puts the timestamp, not by how well I calibrated."

Why the bench draws the error twelve times larger

The orange trace is the true error multiplied by twelve. That is a deliberate, labelled exaggeration, and it is worth understanding why it is necessary — because the reason is the reason this bug survives in production.

At 3.4 m/s with a 12 ms offset the peak cross-track error is 32.4 mm on a 96 m route with a 1.6 m slalom amplitude. Express that as a fraction of the picture:

Worked example 3 — the invisibility ratio.
Route width drawn: 96 m. Peak error: 0.0324 m.
Ratio = 0.0324 / 96 = 0.000338 = 0.034% of the figure width.
On a 900-pixel-wide plot that is 900 × 0.000338 = 0.30 pixels.
Against the lateral extent instead: 0.0324 / (2 × 1.6) = 1.0% — 3 pixels on a 300-pixel-tall plot.

A third of a pixel. Nobody has ever spotted this bug by looking at a trajectory plot, and nobody ever will. It is a five-centimetre error hiding inside a hundred-metre picture, and the only way to see it is to compute a residual and plot that — against speed, against turning, against uptime.

The generalisable habit. Never debug a small systematic error by looking at the quantity itself. Plot the difference from a reference, scaled so that the expected magnitude fills the frame, and plot it against the variable you suspect. Every diagnostic in this lesson is that habit applied: residual against speed, offset against uptime, td against exposure, stamp gaps as a histogram.

The same law, three real routes

Θ is a property of the route, not of the robot, so the same 12 ms produces very different damage depending on where the robot works. This table is worth carrying into a design review, because it turns "our sync is a bit off" into a deployment-specific number.

RouteΘ per leg (radians)Peak cross-track at 3.4 m/s, 12 msWhat it means
Straight test track, 200 m≈ 0.000 mmThe bug is undetectable. This is the validation gap.
Gentle sidewalk slalom (our bench)0.7932.4 mm65% of a 50 mm budget consumed by timing alone.
Right-angle turn at an intersection1.573.4 × 0.012 × 1.57 = 64.1 mmOver budget on a single corner. Every city block has four.
Turn-in-place at a dock (180°)3.14≈ 0 mm (v ≈ 0)Lots of turning, no speed — the product is still zero. And this is exactly why docking is the free excitation opportunity for estimating td.
Warehouse aisle loop, 4 corners6.28 total256 mm accumulated if the turns do not cancelWhy a lap around a warehouse fails to close while a straight run looks perfect.
Note the fourth row carefully. Turn-in-place has the largest Θ in the table and the smallest error, because v is nearly zero. This is the same product that makes the straight track useless, viewed from the other side — and it is why a docking manoeuvre is a safe place to estimate the offset but a useless place to detect its consequences.

When the offset comes out negative

Drag the slider left of zero. The path still bends, mirrored. That is not an academic case — a negative measured offset (the camera appearing earlier than the IMU) is common, and it has three distinct causes that you diagnose differently.

CauseMechanismHow to tell it apart
The IMU is the late oneFIFO batching back-dates nothing, so IMU samples carry stamps up to 80.6 ms after their capture (Chapter 2, F4). Relative to that, the camera looks early.Histogram diff(imu_stamp). A bimodal spike at 0 ms and 80 ms says the IMU is the problem, and no amount of camera correction will help.
A correction is already appliedSomeone previously subtracted a constant and you are now measuring the residual, which can overshoot past zero.Grep for the constant. Then re-measure with it disabled — the raw offset should be positive and larger.
Different epochsA sensor reports a hardware counter in microseconds since power-on, and the driver's conversion to host time has a wrong or stale base.The apparent offset is enormous (seconds to hours), not milliseconds, and it changes on every reboot. A magnitude that is not in the tens of milliseconds is almost always an epoch bug, not a latency one.
The magnitude heuristic. Real pipeline latency lives between 0.1 ms and 100 ms. If your measured offset is 400 ms you are looking at a buffer, not a latency. If it is 37 years you are looking at a Unix-epoch versus power-on-epoch confusion. If it is exactly 18 seconds, you have found GPS time versus UTC and the accumulated leap seconds — which is a genuinely common bug on GNSS-equipped robots and worth naming out loud.

The three numbers to memorise

If you retain nothing else from this chapter, retain these. Each is one multiplication and each answers a question you will be asked.

NumberThe formulaThe question it answers
1 ms at 1 m/s = 1 mme = v · Δt"How much does a millisecond cost?" It scales exactly — 12 ms at 3.4 m/s is 12 × 3.4 = 41 mm, in your head, on demand.
1 ms at 60 °/s = 0.06°δθ = ω · Δt"What does it do to attitude?" And at range r, multiply by r in radians: 0.06° is 1.05 mrad, so 5.2 mm at 5 m.
Cross-track = v · Δt · ΘΘ = radians of heading swept"Why does it only show up on our delivery route?" Because that route turns and the test track does not.

Scaling the bench to a fleet

One robot on a bench is a debugging tool. Sixty robots in a city needs the same physics turned into a monitored signal, and this is the part that separates a demo from a deployment.

SignalCadenceAlarm onCatches
CLOCK_REALTIME − PHC1 Hz|value| > 100 µs, or any non-zero slopeF1 — the dead phc2sys, within seconds instead of within a shift
Monotonicity violations per topic1 Hz counterany non-zero valueF2 — the backwards clock step
Online td estimate per sensor pair0.1 Hzdrift > 2 ms from the calibrated valueF3 — exposure and thermal drift, before it eats the budget
diff(stamp) p99 per topic1 Hzbimodality, or p99 > 2× nominal periodF4 — batch stamping, and dropped frames as a bonus
Loop-closure residual ÷ traverse speedper runratio > 5 ms, or rising week over weekThe aggregate symptom — the one number that would have caught the whole Chapter 0 ticket before a human did
The closing line for a system-design review. "Every failure mode in this lesson has a one-dimensional scalar that reveals it, and all five are cheap enough to ship as telemetry. So the design goal is not 'get the sync right' — sync degrades, that is what clocks do. The goal is that when it degrades, a number moves before a customer notices."

The whole argument, rehearsed

Here is how this material actually gets used, as a compressed transcript of a design-review conversation. What matters is not the first answer — it is how many follow-ups the answer survives.

Them: "Localisation is fine at walking pace and bad at speed. Where do you look?"

You: "First I would check whether the error is linear in speed, because that is the signature of an unmodelled time offset — error over speed has units of seconds. I would pull the fleet database and divide historical loop-closure residual by mean traverse speed. If that ratio is constant, the constant is the offset."

Them: "Suppose it is constant, about twelve milliseconds. Convince me that is enough to matter."

You: "Two channels. Translation gives v times delta-t — at three point four metres per second, forty-one millimetres, against a fifty-millimetre budget. Rotation is worse on a turning route: cross-track error is v times delta-t times the heading swept, so on a slalom that sweeps about eight tenths of a radian that is another thirty-two millimetres, and unlike the translation term it does not cancel out into scale."

Them: "Why does the estimator not just absorb it?"

You: "Because it is not noise. It is a deterministic bias perfectly correlated with the robot's own motion, so averaging does nothing and inflating R only makes the filter distrust its best sensor. The error would still be linear in speed after tuning — the tuning just makes everything worse in exchange."

Them: "Our validation sweeps speed on a straight track and finds nothing. Explain that."

You: "Because the rotation term is a product of three things and one of them is the swept heading, which is nearly zero on a straight track. You can sweep speed to five metres per second and multiply by zero the whole time. The test has to sweep turning as well — a figure-of-eight, not a straight line."

Them: "Fine. Fix it."

You: "Immediately: measure it by shaking the robot and cross-correlating gyro against camera-derived rotation rate, then subtract the constant. That is one week and it takes the safe speed from about two point three to nine metres per second. In parallel I would scope a hardware trigger for the next board revision, because that removes the offset structurally instead of compensating for it — a wire is nanoseconds, and the software path is milliseconds. And I would add an online estimate of the offset as a monitor, not as the primary mechanism, because it goes unobservable when the robot is parked."

Them: "How do you keep it fixed?"

You: "A synthetic regression test with a known injected offset, asserting on both the magnitude and the sign. The sign is the one that bites — if you apply the correction backwards the error doubles instead of vanishing, and nobody suspects the fix. A recorded bag cannot catch that, because it has no ground truth; synthetic data does."

Seven exchanges, and every one of them is a number or a mechanism from this lesson. Notice what never appears: no filter tuning, no vague appeals to "more data", no claim that a learned model would help.

Sanity checks that catch a broken bench

If you build one of these yourself — and you should, it is an afternoon — these four checks catch almost every implementation bug before it teaches you something false.

CheckExpectedWhat a failure means
Δt = 0 at every speedError identically 0.0 mmYour simulator has a bug of its own — probably an integration offset or an index shift — and every other number it prints is contaminated.
Halve ΔtError halves exactlySomething non-linear is leaking in. At small offsets the relationship must be exactly linear.
Halve the speedError halves exactlyYou may be integrating over samples rather than over time, which conflates rate with speed.
Flip the sign of ΔtSame magnitude, mirrored bendAn asymmetry means a clamp or an absolute value somewhere in the correction path — exactly the bug F7 describes.

What the bench deliberately leaves out

Someone will push on the model's limits, and knowing them is more valuable than defending the model.

SimplificationWhat a full system addsDirection of the effect
Attitude comes only from the cameraA real VIO blends camera and IMU attitude, so only the camera's share of the correction carries the biasReduces the error by the camera's weight — typically 0.3 to 0.7 — but does not change the v·Δt·Θ shape
No loop closureLoop closure would pull the estimate back — if it were acceptedBoth directions: it helps when the smear is under the inlier gate, and does nothing once past it, which is the cliff at ~50 mm
Constant ΔtReal offsets drift with temperature and swing with auto-exposureMakes it worse and much harder to see, because the "constant" you calibrated moves
Translation channel omittedThe along-track v·Δt shift of Chapter 0 is also presentAdds error, but it is along-track and therefore largely absorbed by scale — which is why the rotation channel dominates on a turning route
Perfect kinematicsWheel slip, suspension, uneven groundAdds noise, not bias — and this bug is a bias, which is exactly why it survives averaging

The frontier

The five-fix ladder is not folk knowledge. Every rung of it is a published, citable method with a name attached. Being able to say "that is the Kalibr formulation" instead of "you measure it somehow" is the difference between using the field and reinventing it.

RungThe name to say
2. Measure once, subtract a constantMair, Fleps, Suppa & Burschka, Spatio-temporal initialization for IMU to camera registration, ROBIO 2011. Then Furgale, Rehder & Siegwart, Unified Temporal and Spatial Calibration for Multi-Sensor Systems, IROS 2013 — the formulation behind Kalibr.
4. Hardware trigger + PTPIEEE 1588-2008 (PTP v2), and its automotive / AVB profile IEEE 802.1AS-2020 (gPTP).
5. Estimate td online as a filter stateLi & Mourikis, Online Temporal Calibration for Camera-IMU Systems, IJRR 2014. Then Qin & Shen, Online Temporal Calibration for Monocular Visual-Inertial Systems, IROS 2018 — the mechanism shipped in VINS-Mono.

Rung 2 — and why Kalibr is more than the cross-correlation you just wrote. Mair et al. is essentially the estimator from the code section: excite the rig, correlate two rotation-rate signals, take the peak. It returns one scalar and nothing else. Furgale, Rehder & Siegwart do something structurally different — they solve for td jointly with the 6-DoF spatial extrinsics inside a single continuous-time batch estimator, representing the trajectory as a B-spline so that pose is differentiable at any instant rather than only at sample times, and they return a joint covariance over all of it.

That joint solve is not academic tidiness. On a turning trajectory, a mis-estimated td and a mis-estimated lever arm between camera and IMU produce nearly the same residual — both look like "the camera saw the feature from slightly the wrong place". Solve for one while holding the other fixed and the error simply relocates into whichever parameter you left free, and both come out confidently wrong with small-looking uncertainties. The one-line version to say out loud: "temporal and spatial calibration are correlated on rotating trajectories, so I would solve them together rather than in sequence."

Rung 4 — where the "< 0.01 ms" number comes from. With hardware timestamping in the PHY at both ends and transparent or boundary clocks in between, PTP holds PHC-to-PHC offset in the tens-to-hundreds-of-nanoseconds range: three to four orders of magnitude better than the 0.1–1 ms that NTP over a quiet LAN delivers, because NTP's stamps are taken in userspace and inherit every bit of scheduler jitter on the way.

The 10 µs in that ladder cell is therefore deliberately conservative, and knowing why is the detail that shows you have operated this rather than read about it: the nanoseconds live between the two hardware clocks, but almost all robot software reads CLOCK_REALTIME, which reaches the PHC only through phc2sys — a userspace daemon. That last hop is the microsecond-scale part of the chain, and it is also the part that dies silently, which is exactly failure mode F1 from Chapter 1.

Rung 5 — two different ways to make td a state. Li & Mourikis append td to the MSCKF state vector, so the filter estimates it alongside pose, velocity and IMU biases, and it tracks thermal and exposure drift for free — the two things that make the calibrated constant of rung 2 go stale. Qin & Shen make the same idea cheap enough to drop into an existing optimiser: rather than re-timing the whole measurement, they shift each feature observation in the image plane by td times that feature's measured pixel velocity, which turns the reprojection residual into an explicit, differentiable function of td. One extra scalar in the state, one extra term in the Jacobian.

The observability caveat is the same equation you have been staring at. The ladder's row 5 says the online estimate "goes unobservable when the robot is still or moving smoothly", and both papers say so too — the Jacobian ∂r/∂td is proportional to feature velocity in the image. Stationary robot, zero feature velocity, zero Jacobian column, singular information matrix in that direction, and the estimate wanders off under process noise while reporting a shrinking covariance if you are careless about it.

That is not a separate fact to memorise. It is v·Δt·Θ read backwards. The error this bug causes is a product of speed and turning; so is the information the data carries about it. No motion, no error — and no motion, no evidence. Which is exactly the fourth row of the routes table: turn-in-place at a dock has Θ = 3.14 and v ≈ 0, so it produces almost no error and almost no information about td from the translation channel, though the rotation excitation still helps a gyro-camera correlation. The place with the most information is the place with the most damage: fast, hard turning.
The frontier sentence that lands. "The 2011–2013 line of work treats td as a calibration parameter you solve for offline — Kalibr does it jointly with the extrinsics, which matters because the two are correlated on a turning trajectory. The 2014–2018 line treats it as a state you estimate online, which is the only thing that tracks exposure and thermal drift, but it is observable only under motion, so the honest architecture is both: Kalibr offline for the prior, the online estimate as a monitor against that prior, and an alarm when they disagree by more than the budget allows. And if I get to influence the next board revision, a hardware trigger makes all of it a check rather than a mechanism."
Quiz: Your validation suite sweeps speed from 0.5 to 5 m/s on a straight 200 m test track and finds no speed-dependent error. The same build shows 40 mm of speed-dependent error in the field. What is the most likely explanation, and what would you change about the test?

Chapter 5: Field Guide

Sooner or later the ticket lands on you: "Our fleet localises fine in the depot and drifts at highway speed — and it ships in six weeks." Everything below is what has to be loaded before that sentence lands. A live incident is not the time to derive this material. It is only the time to use it.

Seven sections: the cheat sheet (with the three derivations worth being able to do at a whiteboard directly underneath it), system-design patterns, coding drills, debugging scenarios, three debugging dialogues written out in full, the classical-versus-modern call, and what to read next.

1 · The cheat sheet

ConceptThe 30-second explanationKey equationToolClassic paperRecent work
Clock model A clock has two parameters, not one: where it is now and how fast it ticks. Offset is calibratable once; rate error turns into a growing offset and needs a servo. C(t) = (1+ε)t + b chrony, ptp4l Mills, "Internet Time Synchronization: the Network Time Protocol", IEEE T-Comm 1991 Geng et al., "Huygens", NSDI 2018; Li et al., "Sundial", OSDI 2020
NTP offset Four timestamps, one round trip, one assumption: that the path is symmetric. The estimate is a point; the honest answer is an interval of half the round trip. θ = ½[(t2−t1) + (t3−t4)]
error = ½(dout−dback)
chronyc tracking Mills 1991 Corbett et al., "Spanner", OSDI 2012 — TrueTime returns an interval, not a point
PTP Same equations as NTP; three to four orders better because the timestamp is taken in NIC silicon and PTP switches report their own queueing delay. same as NTP, plus a per-hop correction field linuxptp, pmc IEEE 1588-2008 IEEE 1588-2019 (High Accuracy / White Rabbit); IEEE 802.1AS-2020 gPTP for TSN
Hardware trigger Aligns captures, not clocks. A wire is ~5 ns per metre, so the residual is nanoseconds instead of milliseconds. You still need one mapping into host time. tskew = Lcable · 5 ns/m FPGA / MCU strobe, GNSS PPS — (engineering practice) Nikolic et al., "A Synchronized Visual-Inertial Sensor System with FPGA Pre-Processing", ICRA 2014
Mid-exposure An image is an integral over a window; a moving feature smears uniformly, so its centroid is the midpoint. Auto-exposure therefore moves the capture instant. tcap = t0 + T/2 GenICam chunk timestamps Lang et al., "Ctrl-VIO: Continuous-Time VIO for Rolling Shutter Cameras", RA-L 2022
Latency vs jitter The mean is a constant you subtract once; the jitter is a fresh draw per message and is your permanent accuracy floor. So move the stamp, do not just model it. trx = tcap + L + η
σtot = √∑σi2
ros2_tracing, LTTng Bédard, Lütkebohle & Dagenais, "ros2_tracing", RA-L 2022
Interpolation bounds Nearest-neighbour is first order and scales with velocity; linear is second order and scales with acceleration. On a 30 Hz stream that is 120×. ZOH: v·h/2
linear: |x″|·h2/8
tf2::Buffer — (numerical analysis) Cioffi, Cieslewski & Scaramuzza, "Continuous-Time vs Discrete-Time Vision-based SLAM", RA-L 2022
SLERP Rotations are not a vector space. Blend-and-normalise is exact at u = 0, 0.5 and 1 and wrong in between — up to 0.92° for a 90° step. q(u) = [sin((1−u)Ω)q0 + sin(uΩ)q1] / sinΩ Eigen, GTSAM, tf2 Shoemake, "Animating Rotation with Quaternion Curves", SIGGRAPH 1985 Solà et al., "A micro Lie theory for state estimation in robotics", 2018 (arXiv 1812.01537)
Temporal calibration Find the shift that makes two motion signals agree. Needs excitation — you cannot estimate what you do not excite. d = arg maxτ ∑ ωa(t)·ωb(t+τ) Kalibr Furgale, Rehder & Siegwart, IROS 2013 Chen et al., "iKalibr: targetless spatiotemporal calibration", 2024
Online td Put the offset in the state vector so it tracks drift. Free when the robot moves; unobservable when it does not. ∂r/∂td = −J · v (the velocity is the Jacobian) VINS-Mono ESTIMATE_TD Qin & Shen, IROS 2018 Lv et al., "OA-LICalib: observability-aware", T-RO 2022
Continuous time Store the trajectory as a function, not as samples. Query any instant analytically and get derivatives for free. T(t) = ∑i Bi(t) · ξi Kalibr, Coco-LIC, STEAM Furgale, Barfoot & Sibley, ICRA 2012; Barfoot, Tong & Särkkä, RSS 2014 Lang et al., "Coco-LIC", RA-L 2023
The error law Three factors: speed, offset, swept heading. Straight-line tests multiply the third by zero. ealong = v·Δt
ecross = v·Δt·Θ
a synthetic bench

1b · The three derivations worth being able to do at a whiteboard

A cheat sheet is a lookup table, and there is a real difference between reading one off the inside of your skull and rebuilding it. Twelve rows are up there; three of them come up constantly — in design reviews, in onboarding sessions, in postmortems — in the form "derive that for me, on the board, now." Those three are below with every algebraic step written out, in the order you should write them.

How to use this subsection. Do not read it. Cover it, write each derivation from the first line alone, then compare. The failure mode at the whiteboard is never that you had not heard of the four-timestamp equation — it is that you knew the answer and could not produce the two lines in front of it.

(i) The NTP offset, and the exact line where it dies

Set the notation up before writing a single equation, because this derivation is pure bookkeeping and the bookkeeping is where people fall over. There are two clocks. The single unknown is θ, the amount the server clock is ahead of the client clock. There are two transit delays, and the entire point of the exercise is that they are different numbers: dout for client→server and dback for server→client. And there are four observations, each read on the clock belonging to whoever wrote it down.

StampWritten byRead on whose clockThe physical event
t1clientclientrequest leaves
t2serverserverrequest arrives
t3serverserverreply leaves
t4clientclientreply arrives

Step 1 — write each arrival as departure + delay + clock conversion. The request leaves at client-time t1, spends dout in flight, and is then read on a clock sitting θ ahead. So you add θ:

t2 = t1 + dout + θ

The reply leaves at server-time t3, spends dback in flight, and is read on a clock sitting θ behind. So you subtract θ:

t4 = t3 + dback − θ

Step 2 — rearrange each into a difference you can actually form. You may only subtract two stamps taken on the same clock — that constraint is what forces the pairing to be (t2−t1) and (t3−t4) rather than the more natural-looking (t2−t1) and (t4−t3). Say that sentence out loud when you write this line; it is the step everyone fumbles.

t2 − t1 = dout + θ
t3 − t4 = −dback + θ

Step 3 — add them. The two θ terms reinforce, and the two unknown delays subtract:

(t2−t1) + (t3−t4) = 2θ + (dout − dback)

Divide by two and solve for the thing you want:

θ = ½[(t2−t1) + (t3−t4)] − ½(dout − dback)

Step 4 — the line that dies. Look at what is now on the right. The first bracket is built entirely from the four stamps, so it is measurable. The second term contains dout and dback separately, and no combination of the four stamps can ever separate them — every observable involves only their sum. So you delete it, and the deletion is not algebra, it is an assumption:

θ̂ = ½[(t2−t1) + (t3−t4)]
error = θ̂ − θ = ½(dout − dback)
The question to answer before it is asked. "Which step assumes path symmetry?" — Step 4, and only Step 4. Steps 1 to 3 are exact for any pair of delays whatsoever. The symmetry assumption is the single act of dropping ½(dout − dback), and the size of what you dropped is your error. Saying "the whole thing assumes symmetry" is memorisation; pointing at the one term is derivation.

Step 5 — subtract instead of adding, and the bound falls out. Form the round trip from the same four stamps, this time pairing them so that θ cancels:

RTT = (t4 − t1) − (t3 − t2) = dout + dback

Both delays are physically non-negative, so each is somewhere in [0, RTT], which means |dout − dback| ≤ RTT and therefore:

|error| ≤ RTT / 2

That is the whole reason a round trip is worth measuring. It does not improve the estimate by one nanosecond — it brackets it. This is exactly Spanner's TrueTime move, made eleven years before Spanner.

Worked example — run the derivation on real stamps. A client polls a server whose clock is roughly ten seconds ahead. All four numbers are in seconds, each read on its own clock:
t1 = 0.000000  ·  t2 = 10.000420  ·  t3 = 10.000560  ·  t4 = 0.000900
The two differences:
t2 − t1 = 10.000420 − 0.000000 = 10.000420 s
t3 − t4 = 10.000560 − 0.000900 =  9.999660 s
Sum and halve:
10.000420 + 9.999660 = 20.000080 s
θ̂ = 20.000080 / 2 = 10.000040 s
The round trip, for the interval:
t4 − t1 = 0.000900 s  (total elapsed on the client)
t3 − t2 = 0.000140 s  (time the server spent holding the request)
RTT = 0.000900 − 0.000140 = 0.000760 s = 760 µs
The honest answer: θ = 10.000040 ± 0.000380 s, i.e. ±380 µs. Not "the offset is 10.000040".
Now break the symmetry and check the algebra predicts the damage. Suppose the true delays were dout = 600 µs outbound (a congested uplink) and dback = 160 µs back. The derivation says the error is exactly ½(dout − dback):
½(0.000600 − 0.000160) = ½ × 0.000440 = +220 µs
So the true offset is θ = 10.000040 − 0.000220 = 9.999820 s, and the estimator was 220 µs wrong.
Verify it forwards, which is what you do on the board if pushed: the request left at 0.000000, arrived in true client-time at 0.000600, which on the server clock reads 0.000600 + 9.999820 = 10.000420 — that is t2. The reply left at server-time 10.000560, i.e. client-time 10.000560 − 9.999820 = 0.000740, and arrived 160 µs later at 0.000900 — that is t4. The stamps are consistent, and no algorithm reading them could have told.
Sanity check against the bound: 220 µs ≤ RTT/2 = 380 µs. The interval was honest even though the point estimate was not.
Convert to the only unit that matters: at 3.4 m/s, 220 µs is 3.4 × 0.000220 = 0.00075 m = 0.75 mm. Harmless here. Now scale it: on a loaded switch with RTT = 8 ms and 3 ms of asymmetry the error is 1.5 ms = 5.1 mm, and on a Wi-Fi link where the uplink retries and the downlink does not, asymmetry of tens of milliseconds is routine — which is the real reason no geometric pipeline runs on NTP over Wi-Fi.

(ii) The linear-interpolation error bound, and where the 8 comes from

The cheat sheet asserts |x″|·h2/8. Nearly everyone can quote it; almost nobody can say why the denominator is 8 rather than 2 or 4, and that is precisely the follow-up. It comes from two places multiplied together, and you should derive them separately.

Step 1 — set up the interpolant. Two samples, x(ti) and x(ti+1), a spacing h = ti+1 − ti, and a query somewhere between them written as t = ti + u·h with u in [0, 1]. The linear interpolant is:

L(t) = x(ti) + u · [x(ti+1) − x(ti)]

Step 2 — construct the error function, and use the trick. Fix the query point t. Define a helper function of a free variable s:

g(s) = x(s) − L(s) − K · (s − ti)(s − ti+1)

and choose the constant K so that g(t) = 0. That is always possible, because the quadratic factor is non-zero strictly between the samples. Now count the roots of g: it vanishes at s = ti (both x and L pass through the sample, and the quadratic factor is zero), at s = ti+1 for the same reason, and at s = t by construction. Three roots.

Step 3 — apply Rolle twice. Three roots of g means two roots of g′ (one between each adjacent pair), and two roots of g′ means one root of g″. Call it ξ, and it lies somewhere inside the interval. Differentiate the definition of g twice: L is linear so L″ = 0, and the quadratic (s−ti)(s−ti+1) = s2 − (…)s + (…) has second derivative exactly 2. So:

0 = g″(ξ) = x″(ξ) − 2K  ⇒  K = x″(ξ) / 2

Substituting back into g(t) = 0 gives the exact error at the query point, with no approximation anywhere:

x(t) − L(t) = ½ x″(ξ) · (t − ti)(t − ti+1)

Step 4 — convert the product to u, and maximise it. With t = ti + uh we have (t − ti) = uh and (t − ti+1) = (u − 1)h, so their product is u(u−1)h2 = −u(1−u)h2. Take absolute values:

|x(t) − L(t)| = ½ |x″(ξ)| · u(1−u) · h2

Maximise u(1−u) over [0, 1]. Differentiate: 1 − 2u = 0, so u = ½, and the value there is ½ × ½ = ¼. That is the second factor. Multiply the two:

|error| ≤ |x″|max · (½) · (¼) · h2 = |x″| · h2 / 8
Where the 8 comes from, in one sentence you can say out loud. A factor of 2 from the Taylor/Rolle constant, and a factor of 4 from the worst case of u(1−u), which sits at the midpoint of the interval. That last part is a genuinely useful operational fact: linear interpolation is exact at both samples and worst exactly halfway between them, so a synchroniser that always queries near a sample boundary is quietly better than its bound suggests, and one that queries mid-interval — which is the average case — is exactly at it.

The zero-order-hold bound is the same argument truncated one term earlier. Hold the last sample and the error is |x(t) − x(thold)| ≤ |x′| · |t − thold|; nearest-neighbour selection guarantees the hold distance never exceeds h/2, giving v · h/2. First order in h, and it scales with velocity. The linear bound is second order in h, and it scales with acceleration. Those are different physical quantities, which is why the ratio is not a constant.

Worked example — the ratio, computed twice. A 30 Hz transform stream, so h = 1/30 = 0.033333 s. Robot at v = 2.0 m/s with a = 2.0 m/s2.
ZOH: v · h/2 = 2.0 × 0.033333 / 2 = 2.0 × 0.016667 = 0.033333 m = 33.33 mm
Linear: a · h2/8. First h2 = 0.0333332 = 0.00111111 s2. Then 2.0 × 0.00111111 = 0.00222222. Then divide by 8: 0.000277778 m = 0.278 mm
Ratio, numerically: 33.33 / 0.278 = 120×
Ratio, algebraically — and this is the version to put on the board, because it tells you when the answer changes:
(v·h/2) ÷ (a·h2/8) = (v·h/2) × (8 / (a·h2)) = 4v / (a·h)
Check: 4 × 2.0 / (2.0 × 0.033333) = 8.0 / 0.066667 = 120. Same number.
What the algebraic form buys you. The advantage of interpolating grows as the stream gets faster (h shrinks) and shrinks as the motion gets more aggressive (a grows). At h = 2.5 ms (a 400 Hz IMU) the same robot gets 4 × 2.0 / (2.0 × 0.0025) = 1,600×. If somebody asks "when is nearest-neighbour good enough?", set the ratio to 1 and solve: h = 4v/a = 4 m. Four seconds of spacing. Never, in other words.

(iii) ∂r/∂td = −J · v, and why the robot has to move

This one gets asked as "you said you'd put the time offset in the state — what's its Jacobian?", and the correct answer is short, so the risk is answering too fast and missing the observability half.

Step 1 — write the residual with td visible in it. A feature is measured in the image at pixel umeas. The image is labelled with time t, but its true capture instant is t + td. So the prediction must evaluate the geometry at t + td, not at t:

r(td) = umeas − π( pc(t + td) )

where pc is the landmark expressed in the camera frame — a 3-vector in metres — and π is the projection, metres to pixels. Everything with a time argument is a trajectory, which is why this is a natural fit for the continuous-time representation of Chapter 3.

Step 2 — differentiate, one chain-rule link at a time. Only the second term depends on td, and it depends on it only through pc:

∂r/∂td = − (∂π/∂pc) · (∂pc/∂td)

Step 3 — name both factors. The first is J, the 2×3 projection Jacobian, in pixels per metre. It is not a new object — it is the identical matrix the bundle adjustment already builds for every observation, which is why adding td to the state is nearly free. The second is the landmark's velocity in the camera frame; for a static landmark it is the negative of the camera's own velocity, expressed in the camera frame. Call it v, in metres per second. So:

∂r/∂td = − J · v

Step 4 — check the units, out loud. J is px/m, v is m/s, so the product is px/s. It is a 2-vector of pixels per second. That is not a coincidence and it is not an analogy — the Jacobian of the residual with respect to the time offset is the feature's image velocity. Multiply it by a td in seconds and you get pixels, which is what a residual is measured in. Saying this sentence is worth more than writing the equation.

The implementation shortcut this unlocks. You never have to compute J · v. The optical-flow tracker already gives you the same feature's pixel position in the previous frame, so the image velocity is one finite difference: vimg = (uk − uk−1) / (tk − tk−1), a 2-vector in px/s that costs a subtraction. That is exactly what VINS-Mono does — go read ESTIMATE_TD in the projection factor and you will find the image velocity sitting there where the Jacobian column should be.

Step 5 — the observability half, which is the actual point. Look at what happens when the camera stops. v = 0, so ∂r/∂td = 0 identically: the entire column of the Jacobian is zeros. In the normal equations HTH that produces a zero eigenvalue in the td direction, the system is singular, and the covariance of td grows without bound. There is nothing subtle about it — the residual does not change when td changes, so no amount of data can tell you what td is.

Say it as physics, not as linear algebra. "If nothing in the image is moving, then shifting the shutter by ten milliseconds produces the same picture, so the data cannot possibly contain the answer. You cannot estimate what you do not excite." That sentence covers temporal calibration, gyro-bias observability, and half of Kalibr's motion instruction sheet at once.
Worked example — how observable is it, in pixels? Camera with focal length f = 600 px, a landmark at depth Z = 3.0 m, robot translating laterally (perpendicular to the optical axis) at v = 3.4 m/s.
Image velocity of that feature: |vimg| = f · v / Z = 600 × 3.4 / 3.0 = 2040 / 3.0 = 680 px/s
Residual induced by a 12 ms offset: 680 × 0.012 = 8.16 px
Against a feature-detection noise floor of about 0.3 px, that is an SNR of 8.16 / 0.3 = 27. Wildly observable.
Smallest offset this configuration can resolve: set the induced residual equal to the noise floor and solve: td,min = 0.3 / 680 = 0.00044 s = 0.44 ms. That is the resolution the online estimator gives you for free, while the robot is driving.
Now park the robot. v = 0, so |vimg| = 0 px/s, so a 12 ms offset induces 0.000 px of residual and a 1200 ms offset also induces 0.000 px. td,min = 0.3 / 0 = infinite. The estimator does not fail loudly; it simply stops learning, and if you have no staleness flag it will happily report the value it had two hours ago with the covariance it had two hours ago.

2 · System-design patterns

Prompt A — "Design the timing architecture for a six-sensor autonomous vehicle rig."

Before the framework, put the rig on the board. A design answer that names sensor classes is boxes and arrows; a design answer that names rates, payload sizes, transports and measured latencies is an engineering position. Draw this table first — it takes ninety seconds and it makes every later sentence checkable.

SensorRate & shapePayload / messageTransportWhere the stamp is taken todayLatency: mean / p99 jitterSync mechanism assigned
4× camera 30 Hz, global shutter, 1920×1200 mono8 2.30 MB/frame → 69.1 MB/s each, 276 MB/s aggregate MIPI CSI-2, 4-lane, direct to SoC ROS driver callback (userspace) 15.0 ms / ±3.1 ms Hardware trigger — one FPGA strobe to all four, plus per-frame exposure published so mid-exposure is computed per frame
IMU 400 Hz, 6-DOF, 32-deep FIFO → batches at 12.5 Hz 32 × 6 × 4 B = 768 B + header → 9.6 kB/s SPI @ 10 MHz to the same FPGA Arrival of the batch (the bug) 3.1 ms transport (±0.4 ms), plus up to 77.5 ms of intra-batch staleness Same FPGA strobe on the data-ready line + per-sample back-dating from the hardware sample counter
LiDAR 10 Hz, 128 beams × 1024 columns = 131,072 points/rev 24 B/point (xyz + intensity + ring + per-point time) → 3.15 MB/rev, 31.5 MB/s 1000BASE-T1 single-pair Ethernet UDP packet arrival at the host 4.6 ms / ±4.0 ms gPTP (802.1AS, two transparent-clock hops) + per-point azimuth deskew
Radar 20 Hz, up to 128 detections/frame 128 × 32 B = 4.1 kB/frame → 82 kB/s = 0.66 Mb/s CAN-FD, 5 Mb/s data phase Sensor-internal counter, mapped by a CAN sync frame 12.0 ms / ±2.5 ms None available — the vendor exposes no PTP and no trigger, so this becomes an explicit budget line item and the sensor stays out of the pose loop
GNSS / RTK 10 Hz fix + 1 PPS edge 96 B/fix → 0.96 kB/s UART 115200 for the message, GPIO for the PPS edge NMEA sentence arrival (wrong — use the edge) PPS edge < 50 ns (±10 ns); message trails by 120 ms (±40 ms) PPS into both the FPGA and the host; the receiver is also the gPTP grandmaster
Wheel encoders 100 Hz, 4 wheels 16 B/message → 1.6 kB/s Quadrature into the same MCU that holds the FPGA counter MCU interrupt service routine 0.4 ms / ±0.1 ms Native — already in the FPGA counter domain, no mapping needed
The bandwidth line the table forces you to say. 276 + 31.5 + 0.08 + 0.01 + 0.001 + 0.002 ≈ 308 MB/s ≈ 2.46 Gb/s aggregate. That single number decides the topology before any timing argument starts: the cameras cannot ride the vehicle Ethernet backbone, so they go on CSI-2 straight into the SoC, and the backbone carries only the 252 Mb/s of LiDAR plus change. Deriving the transport split from a bandwidth sum is the mark of someone who has physically built one of these.
Framework: requirement → budget → tiers → monitoring → failure.
  1. Start from the product number, not the protocol. "The vehicle must localise to 50 mm at 15 m/s. The error law has two channels — along-track e = v·Δt with a factor of 1, and cross-track e = v·Δt·Θ — where Θ is the heading swept during the offset window, which on our qualification route peaks at Θ = 0.793 rad. Adding the two channels worst-case gives a combined factor of (1 + Θ) = 1.793, so the total time error must stay under 50 mm / (15 m/s × 1.793) = 50 / 26.895 = 1.86 ms. That is my budget, it is a dimensionless constant I can defend, and everything else is derived from it."
  2. Tier the sensors by what they support. Cameras and IMU: one hardware trigger, because they are tightly coupled and the residual is nanoseconds. LiDAR and radar: gPTP, because they are Ethernet devices with their own clocks. GNSS: PPS into both the FPGA and the host, and it is also the grandmaster.
  3. Name the mapping. "The trigger gives me relative alignment. I still need one FPGA-counter-to-host-time mapping, fitted continuously as offset plus rate, with its residual published as a diagnostic."
  4. Say where the stamp goes. "Capture time in header.stamp, always. Latency corrections are declared parameters, not magic numbers. Per-frame exposure published so mid-exposure is computed per frame."
  5. Monitor it. The five telemetry signals from Chapter 4 — PHC delta, monotonicity violations, online td, stamp-difference p99, residual-over-speed.
  6. Degrade explicitly. "If sync health degrades past the budget, the vehicle reduces its speed limit rather than continuing at full speed with a silently worse estimate. The speed limit is derived from the measured offset, so degradation is graceful and quantified."

Now roll the budget up from the rig table, line by line. This is the part almost nobody does, and it converts step 1 from a slogan into an engineering position. Every row below traces to a row of the table above.

Budget lineWhere it comes fromCost (ms)Running total
Camera↔IMU trigger skew1.2 m harness at 5 ns/m = 6 ns of cable propagation0.0000060.00
FPGA counter → host clock mappingResidual of the continuously fitted offset-plus-rate line, p99, published as a diagnostic0.150.15
Mid-exposure residualExposure-register quantisation (1 µs) plus the readout model; what survives after tcap = t0 + T/20.100.25
gPTP host↔LiDAR PHC deltap99 across two transparent-clock hops with correction fields0.020.27
LiDAR scan-start uncertaintyWhat remains after azimuth deskew — not the 100 ms of spread, only the uncertainty in when the rotation began0.500.77
Radar CAN-FD arbitration jitterp99 of a bus we do not control, with no PTP and no trigger available0.901.67
Interpolation residual (IMU into camera query)a·h2/8 = 2.0 × (0.0025)2/8 = 1.6 µm; divided by 15 m/s that is 0.0001 ms0.00011.67
Wheel-encoder ISR jitterMCU interrupt latency under worst-case load0.101.77
Read the three sentences off the rollup.
1. We are inside, but barely. 1.77 ms consumed against a 1.86 ms budget — 0.09 ms of margin, which is 4.8%. Inverting the same equation, the rig certifies to vmax = 50 / (1.77 × 1.793) = 50 / 3.174 = 15.8 m/s against a product requirement of 15 m/s.
2. One line is half the budget. Radar contributes 0.90 of the 1.77 ms — 51% — and it is the only line we do not control. So the radar row of the rig table already says "not in the pose loop", and the vendor conversation in Prompt C is where the 0.90 gets attacked.
3. The trigger line is not a rounding error, it is a rounding zero. Six nanoseconds against a 1,860,000 ns budget. That is what "a wire beats every protocol" means quantitatively, and it is why the tiering step puts the two pose-critical sensors on the same strobe and argues about everything else afterwards.
Continuing the framework.

The point that lands: the last item in the list. Almost nobody proposes making the timing budget an input to the speed governor, and it follows directly from vmax = ebudget/(Δt·Θ) — the same inversion that produced 15.8 m/s two paragraphs ago, now evaluated continuously on the measured Δt instead of once at design time.

Prompt B — "Design bag recording and replay so that timing bugs reproduce."

Framework: what to record → what determinism means → what breaks it.
  1. Record both times, always. Every message needs its capture time (header.stamp) and its receive time. A bag with only one of them cannot reproduce a latency bug, because the latency is precisely the difference you threw away.
  2. Record the sync telemetry as a topic. PHC delta, PTP offset, per-frame exposure. Otherwise post-mortem analysis of a timing failure has no timing data.
  3. Replay on capture time, not on receive time. ros2 bag play paces by receive time by default, which papers over the very ordering that caused the bug.
  4. Define determinism honestly. Deterministic replay means the same inputs in the same order produce the same output. That requires a single-threaded or explicitly-ordered executor; a multi-threaded executor reorders callbacks and is not deterministic no matter how the bag is played.
  5. Use /clock and use_sim_time properly. Under sim time, now() returns the bag's clock. Any node that reads the wall clock — a timeout, a watchdog, a log rotation — is now inconsistent with the rest of the system. Audit for it; this is the single most common replay bug.
  6. Add a synthetic generator alongside. Bags give realism with unknown ground truth; synthetic streams give known ground truth. You need both, for the reason in Chapter 4.

Prompt C — "A vendor LiDAR only exposes receive timestamps. What do you do?"

Framework: measure → model → bound → escalate.
  1. Measure the latency distribution first. Spin the sensor next to a triggered camera, correlate a shared motion signal, and get the mean and the p99 jitter. You cannot budget for a number you have not measured.
  2. Model the internal structure. A spinning LiDAR emits points over a full rotation, so a single stamp is wrong for every point but one. Reconstruct per-point times from the azimuth: t(point) = tscan start + (azimuth / 2π) × Trot. That is 100 ms of spread at 10 Hz — 340 mm at 3.4 m/s — and it is why deskew exists. Work it end to end on the board
Worked example — per-point deskew, from rotation period to residual. A 10 Hz spinning LiDAR, 1024 azimuth columns per revolution, robot at v = 3.4 m/s.
Step 1 — the rotation period. Trot = 1 / 10 Hz = 0.100 s = 100 ms.
Step 2 — pick one point and find its time. A point at azimuth 270° is 270/360 = 0.750 of the way through the sweep, so it was measured
0.750 × 0.100 = 0.0750 s = 75.0 ms after the scan started.
Step 3 — convert that to a distance, which is the error you make by ignoring it. Stamp the whole cloud at the scan start and that one point is misplaced by
3.4 m/s × 0.0750 s = 0.255 m = 255 mm.
Step 4 — the full-rotation spread. First point to last point, the whole revolution:
3.4 × 0.100 = 0.340 m = 340 mm. The scan is a 340 mm smear, and every point in it is smeared by a different amount, which is why it is not removable by a single offset.
Step 4b — the least-bad single stamp, for comparison. Stamp at mid-rotation instead of scan start and the worst point is only ±Trot/2 = ±50 ms off: 3.4 × 0.050 = ±0.170 m = ±170 mm. Better by half, still thirty-four times over a 5 mm line item. Choosing a better single stamp does not solve a per-point problem.
Step 5 — the residual after deskew, which has two parts.
(a) Azimuth quantisation. 1024 columns over 0.100 s is 0.100/1024 = 97.7 µs per column. Evaluating at the column centre halves it: ±48.8 µs. In distance: 3.4 × 0.0000488 = 0.000166 m = ±0.17 mm. Negligible.
(b) Scan-start uncertainty. Whatever error you have in when the rotation began applies to every point equally. With the 0.50 ms line item from the Prompt A rollup: 3.4 × 0.00050 = 0.0017 m = 1.7 mm.
The conclusion to state out loud: deskew turns 340 mm of spread into 1.7 mm of residual, and 99% of that residual is scan-start uncertainty, not the azimuth model. So the engineering effort goes into knowing when the scan started — a PTP-disciplined sensor clock or an exposed rotation-index pulse — and not into a fancier interpolation of the azimuth. People get this backwards and spend a month on the wrong half.
  1. Bound what remains. If the jitter is 4 ms, say so and put it in the error budget as an explicit line item rather than pretending it is zero.
  2. Escalate with a number, not a complaint. "Your sensor's receive-only timestamping costs us 14 mm of localisation error at our operating speed, which is 28% of our budget. PTP support or an exposed scan-start trigger would remove it." Vendors respond to numbers — and here is where the 14 mm came from, because the first thing a competent vendor engineer does is ask.
Worked example — deriving the number in the vendor letter. Three inputs, all measured, none assumed.
Input 1 — the jitter. 4 ms p99, from step 1: spin the sensor next to a triggered camera and correlate a shared motion signal. Write it as a time in seconds: Δt = 0.004 s. Note carefully that this is the jitter, not the mean — the mean latency is calibratable and we already subtracted it; the jitter is a fresh draw per scan and is the part the vendor actually owes us.
Input 2 — the operating speed. v = 3.4 m/s, the fleet's commanded delivery speed.
Input 3 — the swept-heading factor. For the letter we quote the straight-line case, where the cross-track channel is multiplied by Θ = 0 and only the along-track channel survives. That gives a factor of 1.0. This is the conservative, least-arguable version of the claim — you want a number the vendor cannot talk you down from.
Multiply: e = v · Δt · factor = 3.4 × 0.004 × 1.0
3.4 × 0.004 = 0.0136
0.0136 × 1.0 = 0.0136 m = 13.6 mm ≈ 14 mm
As a fraction of the 50 mm budget: 13.6 / 50 = 0.272 = 27.2% ≈ 28%
And the honest upper bound, which belongs in the same letter one line down. On a turn, the cross-track channel comes back and the combined factor is (1 + Θ) = 1.793:
3.4 × 0.004 × 1.793 = 0.0136 × 1.793 = 0.02439 m = 24.4 mm, which is 24.4 / 50 = 48.8% of the budget.
The sentence that actually moves a vendor: "Your receive-only timestamping costs us 27% of our localisation budget on a straight and 49% in a turn. We are asking for a rotation-index pulse on a spare pin, which costs you nothing in silicon, or 1588 support on the existing PHY." Two numbers, one bounded ask, no adjectives.
  1. Architect around it. Never let an unsyncable sensor be in the tight pose loop. Use it for mapping and obstacle detection where a few milliseconds are tolerable, and keep the triggered camera-IMU pair as the pose source.

Prompt D — "The robot must run eight-hour shifts with no recalibration."

Framework: what drifts → what does not → what to estimate online.
  1. Separate the constants from the drifters. Cable propagation, sensor readout time and mechanical extrinsics are effectively constant over a shift. Clock rate, exposure-dependent capture instant and scheduler latency are not.
  2. Kill the rate drift with discipline, not with calibration. A servoed clock has no accumulating rate error, so an eight-hour shift and a one-minute shift look the same. This is the whole reason PTP has a servo.
  3. Estimate the residual online. Make td a filter state so exposure and thermal drift are tracked continuously. State the cost honestly: it is unobservable when the robot is stationary or moving smoothly, so hold the last good value and flag staleness.
  4. Add an excitation opportunity. Many robots turn in place at a dock. That is free excitation. Trigger a td re-estimate on every docking manoeuvre and you get a fresh calibration several times a shift with no operator involvement.
  5. Bound the worst case. "If td has not been re-estimated in two hours, the speed governor drops the limit by the amount the drift model says we might have accumulated." Graceful degradation again, and again derived from the same equation.

3 · Coding drills

Drill 1 — "Implement a time-aligned pose lookup." The most commonly asked of the four.

Declare the shapes before you write the body — write the comment block first. Pinning down units and component order before touching logic is what keeps the body to five honest lines.

python
# ts   : (N,)  float64 — seconds, CAPTURE times, strictly increasing
# pos  : (N,3) float64 — metres, in the map frame
# quat : (N,4) float64 — UNIT quaternions, component order (w, x, y, z)
#                          map_R_body, i.e. body-to-map. Eigen and scipy
#                          both hand you (x, y, z, w) — convert at the
#                          boundary or the rotation is silently wrong.
# tq   : ()    float64 — seconds, the instant to evaluate
# returns ((3,) float64 metres, (4,) float64 unit quat (w,x,y,z)) or None
# slerp() is the Chapter 3 implementation: same (w,x,y,z) order, short-arc
# flip on negative dot, NLERP fallback under 1.81 deg of separation.
def pose_at(ts, pos, quat, tq):
    if len(ts) < 2 or tq < ts[0] or tq > ts[-1]:
        return None                                  # refuse; never clamp
    i = min(np.searchsorted(ts, tq, 'right') - 1, len(ts) - 2)
    u = (tq - ts[i]) / (ts[i+1] - ts[i])
    return pos[i] + u*(pos[i+1] - pos[i]), slerp(quat[i], quat[i+1], u)
What to say while writing: "I return None on an out-of-range query rather than clamping, because a clamped pose is a silent lie." · "Translation is linear, rotation is SLERP — rotations are not a vector space." · "searchsorted makes this O(log n); a linear scan would be fine for a hundred samples and wrong for a hundred thousand." · "In production this is tf2::Buffer::lookupTransform, and I would use that rather than re-implement it."

Drill 2 — "Estimate the time offset between two rate signals."

python
# a, b : (N,) float64 — rad/s, the SCALAR gyro magnitude |omega| of each
#        stream, already resampled onto ONE common uniform grid at fs.
#        Magnitude, not the 3-vector, so no frame alignment is needed —
#        |omega| is rotation-invariant, which is the whole trick.
#        b is the stream suspected to be LATE.
# fs      : () float  — Hz, the common grid rate (200.0 here)
# max_lag : int, SAMPLES of search either side of zero
# guard   : int, SAMPLES trimmed from both ends so every lag scores the
#           same window length. Requires guard >= max_lag.
# returns () float — SECONDS. Positive means b lags a.
def estimate_td(a, b, fs, max_lag=40, guard=50):
    a = a - a.mean(); b = b - b.mean()          # DC would dominate the score
    N = len(a); lags = np.arange(-max_lag, max_lag+1)
    c = np.array([a[guard:N-guard] @ b[guard+l:N-guard+l] for l in lags])
    k = int(c.argmax())
    y0, y1, y2 = c[k-1], c[k], c[k+1]
    d = 0.5*(y0 - y2) / (y0 - 2*y1 + y2)          # parabolic vertex
    return (lags[k] + d) / fs                        # seconds
What to say while writing: "Zero-mean first, or a DC offset dominates the dot product." · "The guard band keeps the window length identical at every lag, so the scores are comparable." · "Sub-sample refinement matters — on a 200 Hz grid one sample is 5 ms, which is most of the budget. A parabola through the peak and its neighbours gets me to a fraction of a millisecond." · "This only works under excitation; smooth motion gives a flat peak and a meaningless argmax. I would report the peak prominence alongside the estimate and refuse to return a number when it is low."

Run it, by hand, on real numbers. Saying "I'd cross-correlate" is a claim. Walking through one call — inputs, intermediate values, output — is a demonstration, and it takes ninety seconds.

python
# Build two 200 Hz traces offset by EXACTLY 12 ms. 12 ms is 2.4 samples
# at 200 Hz — deliberately non-integer, so the sub-sample step has work.
>>> fs = 200.0; t = np.arange(2000) / fs          # (2000,) float64, 10.0 s
>>> sig = lambda u: np.sin(2*np.pi*1.7*u) + 0.6*np.sin(2*np.pi*4.1*u)
>>> a = sig(t)                                  # (2000,) rad/s
>>> b = sig(t - 0.012)                          # (2000,) rad/s, 12 ms LATE
>>> estimate_td(a, b, fs)
0.012009
Every intermediate value, so you can reproduce it on a whiteboard.
1. The shapes. a and b are (2000,) float64. With guard = 50 the scored window is 2000 − 100 = 1900 samples at every lag — that is what the guard band is for. The correlation array c is (81,) float64, one entry per lag in [−40, +40].
2. The integer peak. The maximum sits at k with lags[k] = 2. The true peak is at 2.4 samples, and 2 is the nearest integer below it, so the integer answer is already 0.4 samples short before any refinement.
3. The three values around the peak (units rad2/s2, summed over the 1900-sample window):
y0 = c[k−1] = 1287.93  (lag 1)
y1 = c[k]   = 1295.56  (lag 2)
y2 = c[k+1] = 1294.73  (lag 3)
Read the asymmetry: y2 is much closer to the peak than y0 is, which says the true maximum lies to the right of lag 2. That asymmetry is the sub-sample information, and it is the entire reason the parabolic step works.
4. The parabolic vertex, computed by hand. d = 0.5(y0 − y2) / (y0 − 2y1 + y2)
numerator: 0.5 × (1287.93 − 1294.73) = 0.5 × (−6.7991) = −3.3996
denominator: 1287.93 − 2(1295.56) + 1294.73 = 1287.93 − 2591.12 + 1294.73 = −8.4628
d = −3.3996 / −8.4628 = +0.4017 samples
The denominator is negative, and that sign is a free assertion: it is the numerical statement that this is a maximum and not a minimum. If it ever comes out positive or near zero the fit is meaningless and you must not return a number.
5. Convert to seconds. (lags[k] + d) / fs = (2 + 0.4017) / 200 = 2.4017 / 200 = 0.012009 s = 12.01 ms
6. Grade your own answer. Truth is 12.00 ms, so the residual bias is 0.0085 ms — small but not zero, because a real autocorrelation peak is not exactly a parabola. At 3.4 m/s that is 0.029 mm, four hundred times inside any budget line here. If you ever needed better you would raise fs or fit a Gaussian instead of a parabola; you would not add more data.
7. What the parabolic step bought. Without it, the integer argmax alone gives 2/200 = 10.00 ms — a 2.0 ms error, which at 3.4 m/s is 6.80 mm, four times the entire LiDAR scan-start line item from Prompt A. Sub-sample refinement is not a polish step. It is 235× of the accuracy.

Now degrade the input and look at what actually comes out. This is the half of the drill that separates people who have run this on a robot from people who have read about it. The failure is not an exception, and it is not a None. It is a confident, precise, catastrophically wrong float.

python
# Same rig, same 12 ms offset — but the robot is creeping down a
# corridor. |omega| is a slow drift plus noise, not an excitation.
>>> rng = np.random.default_rng(7)
>>> drift = lambda u: 0.30 + 0.02*np.sin(2*np.pi*0.05*u)
>>> a2 = drift(t) + rng.normal(0, 0.004, t.shape)
>>> b2 = drift(t - 0.012) + rng.normal(0, 0.004, t.shape)
>>> estimate_td(a2, b2, fs)
-0.149340                       # −149.34 ms. Truth is +12.00 ms.
>>> c.min(), c.max()          # excited run: (-368.55, 1295.56)
(0.060268, 0.063313)         # 81 lags, total swing 4.8%
Read the degraded output carefully — this is the whole point of the drill.
The array is flat. All 81 correlation values lie between 0.060268 and 0.063313. The entire swing across the whole ±40-sample search is 0.003045, which is 4.8% of the peak. Compare the excited run, where c ran from −368.55 to +1295.56 — it went strongly negative at some lags, because a real signal shifted far enough anti-correlates. There is no peak here; there is a plateau with noise on it.
So argmax returns noise. It lands at lags[k] = −30, thirty samples from the truth, and the three values around it are y0 = 0.061041, y1 = 0.063313, y2 = 0.061990.
And the parabola fits the noise perfectly happily:
numerator = 0.5 × (0.061041 − 0.061990) = 0.5 × (−0.000949) = −0.000475
denominator = 0.061041 − 2(0.063313) + 0.061990 = −0.003595
d = −0.000475 / −0.003595 = +0.1320 samples — and note the denominator is still negative, so the "is it a maximum" assertion passes. It is a maximum. It is a maximum of noise.
result = (−30 + 0.1320) / 200 = −29.8680 / 200 = −0.149340 s
The function returned −149.34 ms for a rig whose true offset is +12.00 ms: wrong sign, magnitude twelve times too large, an error of 161 ms — 549 mm of position error at 3.4 m/s — reported to six significant figures with no warning of any kind.
Now the trap, because the obvious quality metric fails here. The instinct is to report the correlation coefficient at the winning lag, ρ = c[k] / √((a·a)(b·b)), both evaluated over the same guarded window. Compute it:
excited ρ = 0.9995  ·  degraded ρ = 0.6852
A ratio of only 1.5×, and 0.69 looks like a perfectly respectable correlation. It would pass any threshold you were willing to defend. ρ is the wrong metric, and the reason is worth saying out loud: it measures how well the two signals agree at the best lag, but both signals are mostly the same slow drift, so they agree well at every lag. Agreement is not the question.
The scalar that actually works is peak prominence — how much worse the correlation gets when you shift away from the peak. Dimensionless, no units, no tuning:
prom = (c.max() - c.min()) / c.max()
Excited: (1295.56 − (−368.55)) / 1295.56 = 1664.11 / 1295.56 = 1.2845
Degraded: (0.063313 − 0.060268) / 0.063313 = 0.003045 / 0.063313 = 0.0481
A factor of 26.7×, with nothing in between, so the gate needs no careful tuning: reject below about 0.3, return None, hold the last good value, and raise the staleness flag. Returning a number without a prominence gate is the actual bug in this drill — the arithmetic above is entirely correct, and it will still put half a metre of error into a robot.

Drill 3 — "Back-date a FIFO burst." Four lines, and the sign is the whole exercise.

python
# t_rx        : () float64 — SECONDS, host monotonic time the BATCH arrived
# n           : int        — samples in this batch (32 for our IMU)
# fs          : () float   — Hz, nominal sample rate (400.0)
# transport_s : () float   — SECONDS, MEASURED mean transport + driver
#               latency. Mean only — the jitter around it is a separate
#               budget line and cannot be back-dated away.
# returns (n,) float64 — SECONDS, capture times, OLDEST FIRST, matching
#         the sample order inside the packet.
def capture_times(t_rx, n, fs, transport_s):
    """A burst ARRIVES together; it was not CAPTURED together."""
    newest = t_rx - transport_s
    return newest - np.arange(n-1, -1, -1) / fs   # oldest first
What to say while writing: "The newest sample is transport_s old; every earlier one is another sample period older." · "At 400 Hz with a 32-deep FIFO the oldest sample is 80.6 ms stale, so stamping the batch at arrival puts a 42 ms mean error into every packet." · "If the sensor exposes a hardware sample counter I would use counter differences instead of the nominal rate, because 400 Hz is really 400 Hz plus or minus fifty parts per million."
One worked call, and the degraded case that follows from it.
capture_times(t_rx=1000.0000, n=32, fs=400.0, transport_s=0.0031)
Newest sample: 1000.0000 − 0.0031 = 999.99690 s
Sample period: 1/400 = 0.0025 s = 2.5 ms
The offsets subtracted, oldest first: [31, 30, …, 1, 0] / 400 = [0.07750, 0.07500, …, 0.00250, 0.00000] s
Returned array (32,) float64: first element 999.91940, last element 999.99690
Span: 999.99690 − 999.91940 = 0.07750 s = 77.5 ms, exactly 31 × 2.5 ms as the model predicts.
Degraded case — what the ±50 ppm crystal actually costs. If the true rate is 400 Hz × (1 + 50×10−6) = 400.02 Hz, the true period is 1/400.02 = 0.00249988 s, so each step is 0.00000013 s wrong. Over the 31 steps inside one batch: 31 × 1.25×10−7 = 3.9×10−6 s = 3.9 µs — 13 µm at 3.4 m/s. Ignorable.
But now chain it. If you ever use the nominal rate to advance a sample index across batches rather than re-anchoring on each trx, the same 50 ppm runs for the whole shift: 50×10−6 × 3600 s = 0.18 s = 180 ms after one hour, which is 612 mm at 3.4 m/s. Same constant, four orders of magnitude apart, and the only difference is whether the error re-anchors every 80 ms or accumulates. Back-date within a batch with the nominal rate; never chain across batches without the hardware counter.

Drill 4 — "Write a fixed-lag synchroniser." The one that separates people who have shipped this.

python
# names : list[str]  — one per stream, e.g. ['imu', 'wheel', 'lidar']
# depth : int        — samples retained per stream. Set it from
#         max_query_lag * rate: 300 ms * 400 Hz = 120, round up to 256.
# push(name: str, t: float64 SECONDS capture time, monotone per stream,
#      v: Any — a (3,) float64 ndarray, a (4,) quaternion, an image
#      handle. The class never looks inside v; interpolation is the
#      caller's job, which is why it works for rotations too.)
# query(tq: float64 SECONDS) -> None   registers a wanted instant
# drain() -> list[float64] SECONDS, ASCENDING, of released query times
# Memory: 3 streams * 256 * (8 B time + 24 B vec) = 24.6 kB, forever.
class FixedLagSync:
    """Emit (tq, values...) only when EVERY stream brackets tq."""
    def __init__(self, names, depth=256):
        self.buf = {n: [] for n in names}; self.depth = depth
        self.pending = []                       # query times awaiting release

    def push(self, name, t, v):
        b = self.buf[name]; b.append((t, v))
        if len(b) > self.depth: b.pop(0)       # drop oldest, bounded memory

    def query(self, tq): self.pending.append(tq)

    def drain(self):
        out = []
        for tq in sorted(self.pending):
            if any(not b or b[-1][0] < tq for b in self.buf.values()):
                break                           # still waiting: DO NOT extrapolate
            if any(b[0][0] > tq for b in self.buf.values()):
                self.pending.remove(tq); continue   # too old: drop and COUNT it
            out.append(tq); self.pending.remove(tq)
        return out
What to say while writing: "There are exactly three outcomes per query — release, wait, or drop — and the drop has to be counted, because silent drops are how a pipeline loses half its measurements without anyone noticing." · "The lag is one period of the fastest interpolated stream, which is why the camera provides the query times and the IMU gets interpolated." · "Bounded buffers mean the memory is O(depth × streams) regardless of how long the robot runs."

4 · Debugging scenarios

SymptomRoot causeThe metric that reveals it
Localisation degrades smoothly with speed. No crashes, no divergence. Loop closures rejected above a threshold speed. Doubled surfaces on out-and-back routes.Unmodelled constant time offset between two sensors.Loop-closure residual ÷ traverse speed. A constant ratio, in seconds, is the offset. Confirm by parking (error → 0) and by halving the speed (error halves).
Drift is worse indoors, at dusk and in tunnels. "It must be the texture."The stamp is at the start of exposure, or uses a nominal exposure, so mid-exposure slides with the auto-exposure setting — up to 9 ms.Correlation between per-frame exposure and the online td estimate. Texture-driven drift gives ρ ≈ 0; a stamping bug gives ρ > 0.5. Confirm by locking the exposure and re-driving the same route.
Occasional, unreproducible covariance explosions. Once per shift, no pattern. The recorded bag replays cleanly.A time daemon stepped the clock instead of slewing it, producing Δt ≤ 0 between two messages.Monotonicity violations per hour on every timestamped topic. Healthy is exactly zero. Plot the offset against wall time and look for a sawtooth — ramp, then vertical drop.
Healthy median residual with a fat tail. The robust cost is downweighting 4–8% of measurements, and the affected frames look fine on inspection.Asynchronous streams are being paired by ApproximateTime rather than interpolated, so each tuple carries up to slop of hidden time error.Histogram of |ta − tb| over the emitted tuples. An interpolating pipeline gives identically zero. Overlay it on the residual magnitudes; matching tails is the confirmation. Also watch the sync callback rate — tightening slop silently drops data.
The gyro bias estimate is non-zero and direction-dependent, and disagrees with the bench value measured at rest.A FIFO burst is stamped at arrival, so the integrator's time base is a staircase and the pre-integrated rotation is short in the direction of turning.Histogram of diff(stamp) on the IMU topic. Correct back-dating gives one tight spike at 1/fs. Batch stamping gives a bimodal histogram: a tall spike at 0 ms and a small one at the batch period.
Grasps are accurate slowly and miss by centimetres quickly. No errors anywhere; every transform lookup succeeds.lookupTransform(..., Time(0)) — "the latest available", not "now". Returns a pose of unknown age and never interpolates.Histogram of tquery − treturned. Correct usage spikes at zero; Time(0) gives a flat-topped uniform distribution whose width is one publication period of the responsible publisher.
The meta-pattern behind all six. Every one of them is diagnosed by a one-dimensional scalar plotted against the right variable — speed, exposure, wall time, or stamp difference. None needs a debugger, a new sensor, or a research project. When a symptom lands on your desk, your first move should be to name the scalar and the axis, not to propose a fix.

5 · Three debugging dialogues

Everything above is material. This is practice, and they are not the same skill. Below are three debugging conversations on this topic, written out as dialogue: a mentor's prompt, your first answer — which is deliberately the shallow or wrong one, because that is what actually comes out of your mouth under time pressure — the probe that follows, and the corrected answer.

Three dialogues, drawn from three of the six symptoms in the table above. Read the mentor's lines out loud. Cover your own lines and answer before you look.

Why the first answers are wrong on purpose. A walkthrough that only shows the polished answer teaches you a script, and scripts fall apart on the first follow-up. What you actually need rehearsed is the recovery: the sixty seconds between realising your first answer was thin and producing a better one without visible panic. Every dialogue below has that beat in it, and it is the beat worth practising.

Dialogue 1 — "It gets worse with speed."

MENTOR — "Our delivery robot's loop-closure error grows with speed. Nine centimetres at 0.8 metres per second, forty-one centimetres at 3.4. Nobody has touched the estimator in six weeks. You have forty minutes. Go."

YOU (first pass) — "That sounds like the motion model degrading. At higher speed the constant-velocity assumption breaks down, so I would increase the process noise Q and retune the covariances."

MENTOR — "Suppose I have already done that. I doubled Q. Then I quadrupled it. The error is unchanged at every speed. What does that tell you?"

[What the probe is really testing: whether you can tell a bias from a variance. Everything downstream depends on it.]

YOU (corrected) — "Then it is not a noise problem at all, and I should not have started there. Inflating Q widens the belief. It does not move the mean. If the error survives a fourfold change in Q, the error is deterministic — it is a bias — and a bias survives any covariance you hand it."

MENTOR — "Good. Keep going."

YOU — "I want the shape of the bias, and I have four data points, so I will divide each error by its speed. 0.09 over 0.8 is 0.113. 0.16 over 1.4 is 0.114. 0.27 over 2.2 is 0.123. 0.41 over 3.4 is 0.121."

YOU — "Four numbers that are one number, to within measurement noise. And metres divided by metres-per-second is seconds. There is a clock inside this bug."

MENTOR — "So the offset is a hundred and thirteen milliseconds?"

YOU — "No, and I want to be careful here. What I have shown is that a constant with units of seconds is in the loop — that is the diagnosis. The magnitude of that ratio is inflated, because a 240-metre loop integrates many stale observations before it closes, so the ratio is roughly the per-observation offset times however many of them the loop accumulates. The constancy is the evidence. The size needs a direct measurement."

MENTOR — "Fine. Where in a stack does a constant with units of seconds hide?"

YOU — "Anywhere the capture instant and the label disagree. Five candidates, and I would write them on the board in this order: camera driver stamping at the ROS callback instead of at exposure; IMU FIFO stamped at batch arrival instead of back-dated; LiDAR stamped at packet arrival instead of deskewed per point; a transform lookup asking for Time zero; and a pairing policy that selects rather than interpolates. All five are measurable, none of them needs a new sensor."

MENTOR — "Pick the cheapest measurement."

YOU — "Cross-correlate gyro magnitude against the camera-derived angular rate over a segment with real turning. Both signals are already in the bags, so there is nothing to instrument. Zero-mean both, guard band so every lag scores the same window, correlate over plus or minus forty samples on a 200 hertz grid, then a parabolic fit through the peak and its two neighbours. That is an afternoon, no hardware, and it gets me to a tenth of a millisecond."

MENTOR — "It comes back twelve milliseconds. Now what?"

YOU (first pass) — "Then I subtract twelve milliseconds in the driver and we are done by Thursday."

MENTOR — "Are you? What did you just assume?"

YOU (corrected) — "That the twelve milliseconds is a constant. It may not be. In most camera pipelines the dominant term is exposure-dependent, and auto-exposure moves it every frame. So before I subtract anything I want the correlation between per-frame exposure and the offset estimate."

MENTOR — "Say that correlation is 0.7."

YOU — "Then the offset is a function, not a number, and subtracting its mean leaves a residual that tracks the lighting — which is exactly the 'it drifts worse in tunnels' complaint that gets blamed on texture. The fix is structural, not arithmetic: publish per-frame exposure and stamp at mid-exposure, t capture equals t start plus T over two. That collapses the exposure term down to the quantisation of the exposure register. Then subtract whatever constant remains, and put the leftover jitter in the budget as an explicit line rather than pretending it is zero."

MENTOR — "One last thing. How do you prove to me it is fixed?"

YOU — "Two tests, and neither of them is 'the map looks better'. First, park the robot and re-run the route slowly: the error has to go to zero, because v times delta-t with v equal to zero is zero. If it does not go to zero, the offset was not the whole story and I want to know that before I ship. Second, halve the speed: the residual has to halve. Linear in v, or my model is wrong."

YOU — "And then a third thing, which is the one I would actually argue for in review: a CI gate that replays the qualification route at 3.4 metres per second and asserts peak cross-track under five millimetres. Without it, the next refactor that moves the stamping site re-introduces this silently, and we rediscover it in the field in six months."

Dialogue 2 — "It explodes twice a shift and replays clean."

MENTOR — "New symptom, same robot. Once or twice a shift the covariance blows up. No pattern anyone can find, not reproducible on demand. And when we replay the recorded bag of the exact minute it happened, it runs clean every time."

YOU (first pass) — "Unreproducible plus clean-on-replay usually means a race condition. I would start by forcing a single-threaded executor and seeing whether it goes away."

MENTOR — "It does not go away. And notice what you skipped past. I told you the bag replays clean. What does that one fact rule out?"

[The probe is the same probe as Dialogue 1 wearing different clothes: what did you assume? Here the assumption was that replay and live differ only in threading.]

YOU (corrected) — "It rules out the data. If the same bytes in the same order produce a clean run, the recorded message contents are innocent. So whatever is wrong lives outside the message payloads — in the environment the callbacks execute in. And the largest single thing in that category is the clock: on replay we are on sim time driven by the /clock topic, and live we are on the system clock with a daemon actively disciplining it."

MENTOR — "Keep pulling on that."

YOU — "A time daemon has exactly two ways to correct an error. It can slew, which changes the tick rate so that time stays monotone and continuous, or it can step, which teleports the clock. If something steps it backwards, then for one pair of consecutive messages delta-t is zero or negative."

YOU — "And a filter is full of places that divide by delta-t: numerical derivatives, process noise integrated as Q times delta-t, any normalisation by elapsed time. A zero gives you an infinity, a negative gives you a negative variance, and either one destroys the covariance in a single update. Once. Which is exactly the shape of the symptom — not a slow degradation, a discontinuity."

MENTOR — "It happens twice a shift and I cannot sit and watch for it. How do I see it?"

YOU — "One scalar, always on, counted per timestamped topic: monotonicity violations per hour. Healthy is exactly zero, which means the alarm threshold is not a tuning argument — anything above zero is a defect. That is worth a lot; most robot metrics die in a meeting about what the threshold should be."

YOU — "Alongside it, plot the measured offset against wall time. A slewing daemon draws a smooth curve. A stepping one draws a sawtooth: a slow ramp, then a vertical drop. You can identify the failure from the shape of that plot in about two seconds, which is the sort of thing you want when it is 2 a.m."

MENTOR — "And the fix?"

YOU (first pass) — "Configure chrony so it never steps."

MENTOR — "That is it?"

YOU (corrected) — "That is the first of three. Yes — slew-only, and just as importantly make sure there is exactly one time daemon. Half the cases like this are chrony and systemd-timesyncd both running and fighting each other, which nobody notices because each one individually looks configured correctly."

YOU — "Second: 'never step' means a large error at boot takes hours to slew out, so I need a boot policy. Allow exactly one step while the robot is still in the safe state, verify the offset is inside spec, and only then permit motion. After that, stepping is forbidden for the rest of the shift."

YOU — "Third, and this is the one I would not skip: the estimator itself gets a guard. Reject any measurement whose delta-t is not strictly positive, count the rejection, and never let a non-positive delta-t reach a division. Defence in depth, because the daemon config is one apt upgrade away from changing underneath us."

MENTOR — "Why does the guard need a counter? It already fixed the crash."

YOU — "Because a silent guard converts a loud bug into a slow one, and that is a worse trade than it looks. If I clamp delta-t and say nothing, the covariance stops exploding, everyone declares victory, and the pose quietly gets a little worse every time the clock misbehaves. Six weeks later somebody opens a ticket about the map smearing and there is no evidence trail at all. The counter is what keeps the guard honest — and if it ever goes non-zero I want it on the same dashboard as the monotonicity metric, because they are the same failure seen from two ends."

Dialogue 3 — "The arm misses when the base is moving."

MENTOR — "Different team, mobile manipulator. The arm grasps accurately when the base is stationary or creeping. When the base is moving at speed it misses by two to three centimetres. No exceptions thrown, no lookup failures, every transform request returns successfully."

YOU (first pass) — "If it is speed-dependent with no errors, I would look at arm calibration under dynamic load. Flex in the links, or the controller lagging the commanded trajectory when the base is accelerating."

MENTOR — "Both plausible. Both cost a week to test properly. Is there anything you can check in ten minutes?"

[This probe is about ordering, not correctness. Your first answer was not wrong; it was expensive, and you reached for it before the cheap one.]

YOU (corrected) — "Yes, and I should have led with it. Speed-dependent with no failures is the v-times-delta-t signature again, and the cheapest hypothesis is that the transform is stale rather than wrong. Ten minutes: grep the perception and grasp nodes for a lookupTransform with a zero time argument."

MENTOR — "Say you find one. What is it actually doing?"

YOU — "Time zero does not mean 'now'. It means 'the latest sample available in the buffer', and critically it never interpolates. So it returns a pose of unknown age, uniformly distributed somewhere between zero and one publication period of whichever publisher is slowest in that transform chain."

MENTOR — "Turn that into millimetres for me."

YOU — "If the responsible publisher is a 30 hertz camera-driven transform, the age is uniform on nought to 33.3 milliseconds. Mean 16.7, worst case 33.3."

YOU — "At 0.3 metres per second: 16.7 milliseconds gives 5 millimetres mean, 33.3 gives 10 millimetres worst. Inside the grasp tolerance, which is exactly why it works when the base creeps and nobody has ever seen a failure."

YOU — "At 1.5 metres per second: 25 millimetres mean, 50 millimetres worst. That is your two to three centimetres, and it appears with no error message because from the transform library's point of view nothing went wrong. It was asked for the latest transform and it returned the latest transform."

MENTOR — "Prove it to me before you change anything."

YOU — "Histogram of t-query minus t-returned on the grasp path. Two lines of logging. Correct usage — asking for the image's own capture stamp — gives a spike at zero. Time zero gives a flat-topped uniform distribution, and the width of that flat top is one publication period of the responsible publisher, which conveniently also tells me which publisher to go and look at. One afternoon of data and the argument is over."

MENTOR — "The fix."

YOU — "Ask for the instant the measurement actually describes: lookupTransform of target, source, and the image header stamp, with a timeout, and handle the exception properly rather than catching it and falling back to Time zero — that fallback is precisely how this bug gets re-introduced by a well-meaning patch. Asking for a real instant forces the buffer down its interpolation path, and the residual stops being the staleness error v times h and becomes the interpolation error a times h squared over eight."

MENTOR — "Numbers on that swap."

YOU — "With h of 33.3 milliseconds and a of 2 metres per second squared: h squared is 0.00111, times 2 is 0.00222, divided by 8 is 0.000278 metres. 0.28 millimetres, against 25 millimetres of mean staleness at 1.5 metres per second. Same buffer, same data, same hardware — two orders of magnitude, and the only thing that changed is which timestamp you asked for."

MENTOR — "Anything you would add?"

YOU — "A lint rule, but a narrow one. Time zero is legitimate — for static transforms, and for a 'what is the robot doing right now' operator display — so banning it outright would be wrong and would get worked around. I would require every use to carry a comment justifying it, and fail CI on an unjustified one in any node on the pose or grasp path. That way the rule is about intent rather than syntax, and it survives contact with the team."

Coda — two questions worth asking any robotics team

MENTOR — "Anything you want to ask me?"

YOU — "Two things. First: what is your measured camera stamping jitter at p99, and who measured it? Second: can anyone on the team change the stamping site today without a CI gate failing?"

[Why those two. The first asks whether the team has numbers or opinions, and the answer — including "I do not know" — tells you more about the engineering culture than any org-chart question will. The second asks whether their timing correctness is enforced or merely believed. Both are questions only somebody who has been burned would think to ask.]

What the three dialogues have in common — read this before you rehearse.
Every first answer was a variance fix for a bias problem, or an expensive test run before a cheap one. Dialogue 1 reached for Q. Dialogue 2 reached for threading. Dialogue 3 reached for a week of mechanical characterisation. All three are respectable instincts and all three were the wrong first move.
Every corrected answer named a scalar and an axis before proposing a change. Error divided by speed. Monotonicity violations against wall time. t-query minus t-returned as a histogram. Not one of them needed a debugger, new hardware, or a research project.
Every probe was the same probe. "What did you just assume?" It arrived three times in three costumes. If you internalise one thing from this section, make it the habit of asking yourself that question before anyone else has to.
The drill. Three of the six symptoms are written out above. Now do the other three yourself — the exposure-correlated drift, the fat-tailed residual from ApproximateTime pairing, and the direction-dependent gyro bias — out loud, with a timer set to fourteen minutes each, and with the debugging table covered. Record yourself. The gap between what you thought you said and what you actually said is the entire reason this section exists. And when you want the pressure to be real: the Studio button on this page runs a timed practice session on exactly this material.

6 · Classical vs modern

ProblemClassicalModernWhen to use which
Clock agreementNTP over IP, userspace stamping, 0.1–1 ms on a LANPTP with NIC hardware timestamping and transparent switches, < 1 µs; gPTP/TSN for scheduled deliveryNTP is fine for logs and for anything whose budget is above ~10 ms. Anything geometric needs PTP. Reach for TSN when you also need bounded delivery latency, not just aligned clocks.
Sensor alignmentShared hardware trigger from an FPGAStill the hardware trigger — nothing has replaced itTrigger whatever you can. This row is a reminder that "modern" is not automatically better: a wire at 5 ns/m still beats every protocol by three orders of magnitude, and the frontier here is making triggers cheaper, not obsolete.
Temporal calibrationOffline batch on a checkerboard sequence (Kalibr, 2013). One number, one YAML file, once a year.Online state in the estimator (VINS-Mono, 2018); targetless continuous estimation (iKalibr, 2024)Offline for the initial value and for certification. Online for drift tracking, but never as the sole mechanism — it is unobservable at rest, so it needs a hold-last-good policy and a staleness flag.
Trajectory representationDiscrete poses at keyframes, interpolated on demand with lerp and SLERPContinuous-time B-spline (Furgale 2012, Coco-LIC 2023) or GP (Barfoot 2014)Discrete is simpler, faster, and easier to debug — and the RA-L 2022 comparative study finds it competitive for a well-triggered global-shutter rig. Go continuous when you have rolling shutter, a spinning LiDAR, or sensors you cannot trigger.
Stream fusionmessage_filters ApproximateTime — select near-simultaneous tuplesCapture-time buffers with interpolation and fixed-lag releaseApproximateTime is correct only when the streams are genuinely triggered together and you are regrouping them. On asynchronous streams it is a 2,700× accuracy loss for no benefit.
Expressing uncertaintyA point estimate of the offsetAn interval (Spanner's TrueTime, OSDI 2012) or a state with a covarianceAlways prefer the interval when a downstream decision depends on ordering. "The offset is 12 ms" invites false confidence; "12 ± 3 ms" is what lets a consumer decide whether it can act.

7 · Recommended reading

The one book. Timothy Barfoot, State Estimation for Robotics (Cambridge, 2nd ed. 2024). It is the only mainstream robotics text that treats continuous-time estimation as a first-class citizen rather than a footnote, which is exactly the deep version of Chapter 3. Chapters 3 and 4 give you the Gaussian-process trajectory formulation; the Lie-group chapters give you why SLERP is the right thing and what its generalisation looks like. Free PDF from the author's page. If you only read fifty pages, read the continuous-time section.

PaperWhy this one
Mills, "Internet Time Synchronization: The Network Time Protocol", IEEE Trans. Communications, 1991The four-timestamp derivation from the person who designed it, including the symmetry assumption stated plainly as an assumption. Twenty minutes, and you will never again quote an NTP offset without its interval.
Furgale, Rehder & Siegwart, "Unified Temporal and Spatial Calibration for Multi-Sensor Systems", IROS 2013The paper that made the time offset a first-class estimated parameter rather than a thing you hoped was zero. It is also the paper behind Kalibr, which you will actually run.
Furgale, Barfoot & Sibley, "Continuous-Time Batch Estimation Using Temporal Basis Functions", ICRA 2012The clearest statement of why a trajectory should be a function of time. Read it and rolling shutter, LiDAR deskew and multi-rate fusion stop being three problems.
Qin & Shen, "Online Temporal Calibration for Monocular Visual-Inertial Systems", IROS 2018The practical online counterpart: how td enters the residual, what its Jacobian is (the velocity), and what happens to observability when the robot stops moving.
Corbett et al., "Spanner: Google's Globally-Distributed Database", OSDI 2012Not robotics, and that is the point. TrueTime returns an interval and the system waits out the uncertainty rather than pretending it away. The best available argument for representing time uncertainty explicitly, and a great thing to cite in a robotics design review.
Cioffi, Cieslewski & Scaramuzza, "Continuous-Time vs. Discrete-Time Vision-based SLAM: A Comparative Study", RA-L 2022The honest controlled comparison. Citing a paper that complicates the frontier narrative is worth more than citing one that flatters it.
RepositoryWhat to actually read
richardcochran/linuxptpclock.c and servo.c. The PI servo that disciplines the PHC is a couple of hundred lines and is Chapter 1's offset-and-rate model in production. Then read phc2sys.c and notice how easy it is to run the first and forget the second.
ethz-asl/kalibraslam_offline_calibration/kalibr — specifically how the time offset enters the B-spline residual, and the error-term construction. Also read the wiki page on calibration motion; it is the observability argument written as an instruction sheet.
HKUST-Aerial-Robotics/VINS-MonoSearch for ESTIMATE_TD. Follow it into the projection factor and look at the Jacobian with respect to td — it is the feature's image velocity, which is the same v·Δt of Chapter 0 in derivative form.
ros2/geometry2tf2/src/buffer_core.cpp: findClosest and the interpolation path. This is Code Lab 2 in production, including the extrapolation policy and the exact error messages you will one day be grepping for.
ros2/message_filtersinclude/message_filters/sync_policies/approximate_time.h. Read it closely enough to confirm for yourself that it selects and never interpolates. It is a genuinely good implementation of the wrong operation for asynchronous streams.
ros2/ros2_tracingThe analysis notebooks. Trace a camera callback end to end and compare the measured latency to the table in Chapter 2 — on your hardware, the numbers will be different, and knowing yours is the point.

The budget calculator

Build a timing budget and read off the speed limit

Stack the four contributions to your total time error, then read the maximum speed at which the robot stays inside the 50 mm budget. The bar turns red when a single contribution dominates — that is the one to fix first.

Two conventions this calculator uses, and you should be able to defend both. First, the speed conversion is 50 mm / (v × 1.793), where 1.793 is not a fudge factor — it is (1 + Θ) with Θ = 0.793 rad, the heading swept during the offset window on the qualification route of Chapter 4. The along-track channel contributes v·Δt with a factor of 1 and the cross-track channel contributes v·Δt·Θ; adding them worst-case gives 1 + Θ. Second, the four sliders are summed linearly, not in quadrature. Clock sync, stamping latency and interpolation are deterministic biases — they do not cancel, they accumulate, so linear addition is the correct model for them. Jitter is the one genuinely random term, and random terms combine as √∑σi2, which is the rule printed in the cheat sheet above. This tool leads with the linear sum because a timing budget is a safety argument: the number you defend in a design review is the one you will not exceed, not the one you will exceed half the time. The readout prints the RSS total underneath so you can see both, and see the gap.

Clock sync0.1 ms
Stamping latency (residual)3.0 ms
Stamping jitter (p99)3.1 ms
Interpolation / pairing1.3 ms
How to use it. Set every slider to the values your last project actually had. If you cannot, that is the gap to close — not by reading more, but by measuring your own stack once. An engineer who can say "our camera stamping jitter was 1.4 ms p99, measured with ros2_tracing" has an unfalsifiable advantage over one who can only recite that jitter matters.

Where this lesson connects

LessonWhat it adds
Calibration & Time SyncThe full mathematics this lesson bridged past: hand-eye AX = XB, the B-spline continuous-time formulation, the observability conditions, and the complete Kalibr workflow.
Lamport ClocksWhat ordering means when there is no shared clock at all — happens-before, logical and vector clocks. The distributed-systems half of the same question, and directly relevant to multi-computer robots.
RIP 01 · Frames & TransformsThe spatial half of the pair. Every transform in this lesson has both a frame and a time; get either wrong and the geometry is wrong in the same way.
RIP 03 · Uncertainty & Robust CostsWhy inflating R does not fix a bias, in full: Mahalanobis distance, covariance propagation, and what robust costs actually do to a deterministic error.
Classical VIOThe system where all of this lands: IMU pre-integration, sliding-window optimisation, and why time offset is one of the states.
Final quiz: You have one week and a robot whose camera-IMU offset is 12 ms with 3.1 ms of jitter. Which single change buys the most, and what is the argument?
The full argument.
Rank the terms before choosing anything. 12 ms mean, 3.1 ms jitter, and a clock-agreement term that NTP on a LAN puts at 0.1–1 ms. At 3.4 m/s those are 40.8 mm, 10.5 mm and 0.34 mm. The ranking is not close, and it is the first thing to put on the board.
Why not PTP (option 0). Every number in that option is true. Sub-microsecond agreement is real, three orders of magnitude is real, and a week is a realistic schedule. It is still the wrong week, because it takes the third-largest term from 0.34 mm to 0.0003 mm and leaves the 40.8 mm completely untouched. PTP is right eventually — it is on the roadmap in every design above — and it is wrong as the one change you get. "Correct but not the bottleneck" is the most expensive kind of wrong in a one-week decision.
Why not the online state alone (option 1). Also all true, and the Jacobian argument is exactly right — that is derivation (iii). The defect is the word alone. An online td is unobservable at rest and near-unobservable under smooth motion, and it does not fail loudly when that happens: Drill 2's degraded run returned −135 ms for a rig whose true offset is +12 ms, to five significant figures, with no complaint. Ship it as the sole mechanism and the robot's timing is defined exactly when it is driving and undefined whenever it stops. It belongs in the architecture as a monitor, with hold-last-good and a staleness flag — not as this week's one change.
Why the third option. It is the only one that attacks both halves of the actual error. The removable half is the 12 ms mean: one cross-correlation run and one constant, an afternoon, 40.8 mm → roughly zero. The irreducible half is the 3.1 ms jitter, which no constant can touch — the only lever on jitter is moving the stamping site, and going from the ROS callback to the kernel IRQ or a sensor chunk timestamp takes it from 3.1 ms to a few hundred microseconds, i.e. 10.5 mm → about 1 mm. Together that is the 51 mm of error budget the other two options leave on the table.
The sentence that closes it. "Subtract what is calibratable, move what is not, and only then spend money on the clock." That ordering is the whole lesson compressed to eleven words.
"The most damaging phrase in the language is: we've always done it this way." — Grace Hopper, who also handed out nanoseconds: 11.8-inch lengths of wire, one for each nanosecond light takes to travel that far. She used them to make engineers feel that time and distance are the same quantity wearing different units. That is this entire lesson: a millisecond, at a metre per second, is a millimetre.