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.
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 v | Residual e | e ÷ v (s) | Map quality |
|---|---|---|---|
| 0.8 m/s | 0.014 m | 0.0175 | crisp |
| 1.4 m/s | 0.022 m | 0.0157 | crisp |
| 2.2 m/s | 0.032 m | 0.0145 | walls slightly thick |
| 3.4 m/s | 0.047 m | 0.0138 | double 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.
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.
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 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.
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:
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.
And that is only the translation channel. The rotation channel is worse, because rotation errors get multiplied by range.
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.
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.
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.
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:
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:
Now expand x(t − Δt) as a Taylor series about t. The robot has velocity v and acceleration a:
So the state the measurement really describes differs from the state the estimator assumed by a small vector. Name it:
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):
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:
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.
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.
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/s | 1.4 m/s | 2.2 m/s | 3.4 m/s | 5.0 m/s |
|---|---|---|---|---|---|
| 1 ms | 0.5 | 1.4 | 2.2 | 3.4 | 5.0 |
| 5 ms | 2.5 | 7.0 | 11.0 | 17.0 | 25.0 |
| 12 ms | 6.0 | 16.8 | 26.4 | 40.8 | 60.0 |
| 33 ms (one frame) | 16.5 | 46.2 | 72.6 | 112.2 | 165.0 |
| 80 ms (a stale FIFO) | 40.0 | 112.0 | 176.0 | 272.0 | 400.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.
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.
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.
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:
| Topic | Message type | header.stamp | Decimal seconds |
|---|---|---|---|
/cam/image_raw | sensor_msgs/msg/Image | {sec: 1712, nanosec: 353000000} | 1712.353000000 |
/imu/data_raw | sensor_msgs/msg/Imu | {sec: 1712, nanosec: 341000000} | 1712.341000000 |
Subtract them in the integer field, which is the only place this arithmetic is exact:
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.
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:
| Field | Shape / type | Contents |
|---|---|---|
samples | float32[32][6] — 768 bytes | row i = [ax, ay, az, gx, gy, gz], row 0 oldest, row 31 newest |
stamp | builtin_interfaces/Time — one value | 1712.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:
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 rows | Effective camera–IMU offset | Error at 3.4 m/s |
|---|---|---|
Back-dates each row using the formula above and publishes 32 Imu messages with individual stamps | 13.0 − 1.0 = +12.0 ms, uniform across rows | 40.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 packet | 90.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 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.
N_SAMPLES = 32 instead of the message's actual length, it computes row 0's time as tstamp − 0.001 − 31/400 = tstamp − 78.5 ms, while row 0 truly occurred at tstamp − 0.001 − 63/400 = tstamp − 158.5 ms. The row is labelledassert 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.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:
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.
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:
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.
"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:
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:
Five lags, every product written out. No shortcuts — this is the whole algorithm.
| lag l | the five products, term by term | score |
|---|---|---|
| −1 | 0·0 + 1·0 + 2·0 + 1·1 + 0·2 | 1 |
| 0 | 0·0 + 1·0 + 2·1 + 1·2 + 0·1 | 4 |
| +1 | 0·0 + 1·1 + 2·2 + 1·1 + 0·0 | 6 |
| +2 | 0·1 + 1·2 + 2·1 + 1·0 + 0·0 | 4 |
| +3 | 0·2 + 1·1 + 2·0 + 1·0 + 0·0 | 1 |
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.
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+:
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.
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.
argmax hides it.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.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.
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 field | The Δt | Scales with | Magnitude on our robot |
|---|---|---|---|
| Camera–IMU offset | 12 ms of pipeline difference | v and ω | 40.8 mm at 3.4 m/s |
| Rolling-shutter skew | readout time across the sensor, 12 ms top to bottom | ω mostly | at 60 °/s: 0.72° of skew between the first and last row |
| LiDAR motion distortion | one rotation period, 100 ms at 10 Hz | v and ω | at 3.4 m/s the first and last point of a scan are taken 340 mm apart |
| Actuator command latency | compute + CAN + driver, 8 ms | ω of the joint | a wrist at 180 °/s is 1.44° past where the planner thinks |
| Stale transform lookup | age of the newest tf sample, up to 33 ms | v 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.
Six words get used interchangeably in casual conversation and mean six different things in a design review. Getting them right is free signal.
| Term | What it is | Units | How it hurts |
|---|---|---|---|
| Offset | Clock A reads a different value than clock B right now | seconds | Constant bias, v·Δt. Calibratable — measure once, subtract forever. |
| Skew (rate error) | Clock A ticks at a different rate than clock B | parts per million | Offset that grows linearly with uptime. Not calibratable as a constant; needs discipline. |
| Drift | The skew itself changing, usually with temperature | ppm per °C, or ppm/s | Yesterday's correction is wrong today. Forces continuous, not one-shot, sync. |
| Jitter | Random variation of a stamp about its mean | seconds (std or p99) | Irreducible noise floor. Sets the accuracy ceiling — you can subtract a mean, never a sample of noise. |
| Latency | Delay between the physical event and the software seeing it | seconds | Becomes an offset if uncorrected; becomes phase lag in any control loop. |
| Wander | Slow (sub-10 Hz) variation of the offset | seconds | Too slow for the servo to reject, too fast to calibrate. The residual you live with. |
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 | |
|---|---|
| Symptom | Localisation 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 not | Not 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 metric | Regress 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 test | Park 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 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.
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.
| # | Check | Cost | What it rules in or out |
|---|---|---|---|
| 1 | Park the robot for 10 minutes and watch the error | 10 min, zero code | If 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. |
| 2 | Regress historical error on traverse speed across the fleet database — slope and intercept, not a ratio | one SQL query plus a two-parameter fit | The 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. |
| 3 | Replay one bag at half playback rate through the same estimator | 20 min | Distinguishes a data problem from a compute problem. If slowing the replay fixes it, you were dropping frames or missing deadlines, not misaligning clocks. |
| 4 | Shake test + cross-correlation of gyro vs camera rotation | 1 hour incl. tooling | Measures Δt directly, to a fraction of a millisecond. This is the definitive test. |
| 5 | Scope the trigger line against the driver stamp | half a day, needs hardware | Localises which stage of which chain contributes the offset, so you can fix the cause rather than subtract the symptom. |
"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.
| Chapter | The question | The one-line answer |
|---|---|---|
| 1 | Do 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. |
| 2 | Does 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. |
| 3 | Given 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. |
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.
| Angle | The question it poses | What 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.
phc2sys died at 03:12. File it, restart the service, add an alert.now().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 work | Year | What it changed |
|---|---|---|
| Furgale, Rehder & Siegwart, "Unified Temporal and Spatial Calibration for Multi-Sensor Systems", IROS | 2013 | Before 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", IROS | 2018 | Furgale'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–2020 | The 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.
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".
| Quantity | Target | Where it comes from |
|---|---|---|
| Camera–IMU relative offset, residual after correction | < 1 ms | 10 mm budget at 5 m/s, with margin |
| Camera–IMU jitter, p99 | < 0.5 ms | Irreducible; must sit well under the offset target |
| Host–LiDAR clock offset (PTP) | < 100 µs | Deskew quality at 10 Hz rotation |
| Clock rate error after discipline | < 1 ppm | Keeps a one-hour mission under 3.6 ms of accumulated skew |
| Sync-health telemetry cadence | 1 Hz per sensor | So a dead sync daemon is caught in seconds, not in a customer report |
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."
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:
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:
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.
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:
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 grade | Typical stability | Drift in 1 hour | Cost | Where you see it |
|---|---|---|---|---|
| MEMS / basic XO | ±50 ppm | 180 ms | < $0.20 | Sensor boards, MCUs, most consumer hardware |
| TCXO (temp-compensated) | ±2 ppm | 7.2 ms | $1–5 | GNSS receivers, better IMUs |
| OCXO (oven-controlled) | ±0.01 ppm | 36 µs | $50–500 | Time servers, telecom, survey-grade GNSS |
| Rubidium | ±0.0005 ppm | 1.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.
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.
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 (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.
Let θ be the true offset (server clock minus client clock). Then the outbound leg and the return leg give:
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:
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:
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:
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.
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:
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:
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.
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.
Generalise those two examples and you get the error law of NTP, which is worth memorising because it explains every architectural decision that follows:
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:
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:
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.
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 (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.
| Mechanism | Where the stamp is taken | Typical accuracy | What limits it |
|---|---|---|---|
| NTP over the internet | userspace | 1–50 ms | Route asymmetry, queueing |
| NTP over a quiet LAN | userspace | 0.1–1 ms | Scheduler jitter, interrupt latency |
| PTP, software timestamping | kernel driver | 10–100 µs | Kernel latency, switch queueing |
| PTP, NIC hardware timestamping | MAC layer, in silicon | 0.1–1 µs | Switch residence-time variation |
| PTP + transparent/boundary clocks | MAC, every hop corrects | < 100 ns | Cable-length asymmetry, PHY delay |
| White Rabbit (CERN, now IEEE 1588-2019) | MAC + phase-locked PHY | < 1 ns | Fibre dispersion |
| Shared hardware trigger | a wire | < 10 ns | Cable 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.
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.
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 aligns | Clock readings | Physical capture instants |
| Residual error | 0.1 µs – 1 ms depending on tier | < 10 ns |
| Works with third-party sensors | Yes, if they speak the protocol | Only if they expose a trigger pin |
| Handles free-running phase | No — a 30 Hz camera and a 400 Hz IMU still land on different instants | Yes — that is the whole point |
| Cost | PTP NIC + switch, ~$40–200 | An FPGA/MCU pin, wiring harness, EMC review |
| Fails how | Gracefully — offset grows, telemetry shows it | Silently, if the harness breaks and the sensor free-runs |
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.
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.
| Sensor | Rate | Payload | Bandwidth | Clock domain | Sync mechanism |
|---|---|---|---|---|---|
| 4 × global-shutter camera, 1280×800 mono | 30 Hz | 1.02 MB/frame | 123 MB/s total | FPGA counter | Hardware trigger, 30.000 Hz |
| IMU (6-axis MEMS) | 400 Hz | 28 B/sample | 11 kB/s | Its own XO, latched by FPGA | Data-ready line latched on the same counter |
| LiDAR, 32-beam spinning | 10 Hz rotation, 600 kpt/s | 16 B/point | 9.6 MB/s | Internal, PTP slave | PTP (gPTP) over Ethernet |
| GNSS receiver | 10 Hz fix, 1 Hz PPS | ~200 B/fix | 2 kB/s | GNSS time | PPS line to FPGA + host; NMEA for the second number |
| Wheel encoders | 200 Hz | 16 B | 3 kB/s | Motor controller MCU | CAN timestamped on arrival + measured constant latency |
| Host computer | — | — | — | CLOCK_REALTIME | ptp4l 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 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":
ptp4lphc2sysclock_gettime(CLOCK_REALTIME) — what rclcpp::Clock::now() returns.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.
| Stage | Mean | Jitter (p99 − mean) | Calibratable? |
|---|---|---|---|
| Trigger edge → shutter open | 2 µs | < 0.1 µs | Yes, and negligible |
| Exposure (auto, 2–20 ms) | 8 ms (centre at +4 ms) | ±9 ms as light changes | Yes — if the driver reports the actual exposure per frame |
| Sensor readout | 2 ms | < 0.05 ms | Yes, 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 ms | Mean yes, jitter no |
| ROS callback dispatch | 2.0 ms | ±3.0 ms | No — 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.
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.
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
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
The production form. On a real robot you do not write any of this; you configure it and then monitor it.
| Job | Tool | The line that matters |
|---|---|---|
| Discipline the NIC clock | ptp4l (linuxptp) | ptp4l -i eth0 -m -H -s — -H is hardware timestamping, -s is slave-only |
| Copy PHC → system clock | phc2sys | phc2sys -s eth0 -w -m — the link everyone forgets |
| NTP fallback / GNSS PPS | chrony | refclock PPS /dev/pps0 lock NMEA |
| Inspect the PTP domain | pmc | pmc -u -b 0 'GET CURRENT_DATA_SET' — shows offsetFromMaster |
| Measure end-to-end topic lag | ROS 2 | ros2 topic delay /camera/image_raw — compares header.stamp to now() |
| Trace where the time actually goes | ros2_tracing / LTTng | Bédard et al., RA-L 2022 — per-callback timelines with ~ microsecond overhead |
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.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 plot | What it means | Which failure |
|---|---|---|
| Straight ramp from zero, slope = a fixed ppm, resets on reboot | An undisciplined oscillator is free-running | F1 — the dead phc2sys |
| Sawtooth: ramp, then a vertical discontinuity back toward zero | Something is stepping the clock instead of slewing it | F2 — the backwards step |
| Flat offset, non-zero, at full value one second after boot, moves with link load | A constant bias baked into the estimate | The asymmetric path |
| Triangle wave, period of minutes, error bounded by one sample interval | Two free-running periodic sources beating against each other | The 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 | |
|---|---|
| Symptom | Localisation 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 hides | Monitoring 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 metric | Log 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 tell | The 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. |
| Fix | Run 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 | |
|---|---|
| Symptom | Occasional, 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. |
| Cause | A 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 metric | Count 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. |
| Fix | Configure 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. |
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 | |
|---|---|
| Symptom | A 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. |
| Cause | The 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 hides | Exactly 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 metric | Log 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 tell | The 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. |
| Fix | Deploy 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 | |
|---|---|
| Symptom | One 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. |
| Cause | The 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 fails | The 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 metric | Compare 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 numbers | Commanded 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 tell | Bounded 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. |
| Fix | Make 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. |
Three things are genuinely moving here, and each deserves one specific paper or standard you can name.
| Direction | The specific reference | Why it changes the design |
|---|---|---|
| Determinism moves into the network | IEEE 802.1AS-2020 (gPTP) and the wider TSN family — 802.1Qbv time-aware shaping | Sync 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 targetless | Furgale, 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 ordinary | White Rabbit, folded into IEEE 1588-2019 as the High Accuracy profile; the Open Compute Time Appliance Project's open time servers | Nanosecond 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. |
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.
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?CLOCK_REALTIME.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.
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."Define two quantities precisely, because the whole chapter is the gap between them.
| Term | Definition | Who knows it |
|---|---|---|
| Capture time tcap | The 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 trx | The 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. |
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.
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:
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:
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
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.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:
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.
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.
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.
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.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 site | Mean error L | Jitter σ | Cost to implement | Error at 3.4 m/s (mean ± noise) |
|---|---|---|---|---|
| A. FPGA latch on the trigger edge | ~0 (known by construction) | < 0.001 ms | High — needs the hardware and a counter→host mapping | 0.0 ± 0.003 mm |
| B. Sensor's own timestamp (GenICam chunk / PTP-stamped) | 0.05 ms | 0.02 ms | Low — if the sensor supports it | 0.17 ± 0.07 mm |
| C. Kernel driver, on first-byte interrupt | 7.5 ms | 0.3 ms | Medium — driver patch | 25.5 ± 1.0 mm |
| D. Userspace driver, after the read returns | 13.0 ms | 0.9 ms | Low | 44.2 ± 3.1 mm |
E. ROS callback, now() | 15.0 ms | 3.1 ms | Zero — it is the default | 51.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 instant | Step | ms after mid-exposure | What is happening in that step |
|---|---|---|---|
| Mid-exposure — the truth | — | 0.0 | Where site A lands, by construction, because the FPGA knows both the trigger edge and T. |
| Sensor's own chunk stamp | +0.05 | 0.05 → row B | The sensor latches its internal counter. The 0.05 ms is the sensor's own pipeline, not yours. |
| Exposure ends, readout begins | +T/2 = 4.0 | 4.0 | The other half of the integration window. A true first-byte latch would live here. |
| Frame-complete interrupt fires | +2.0 readout | 6.0 | The sensor must finish draining all rows before the DMA-complete IRQ asserts. |
| The kernel handler actually runs | +1.5 IRQ→handler | 7.5 → row C | Interrupt-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 transfer | 13.0 → row D | 1.02 MB across USB3 or GigE, plus the copy into the message buffer. |
| The ROS callback body starts | +2.0 executor | 15.0 → row E | Queue 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.
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.
| Field | Who writes it | What it means | Use it for |
|---|---|---|---|
header.stamp | The publisher, by hand | Whatever 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_timestamp | The DDS middleware on publish | When publish() was called — strictly after the driver was done. | Measuring publish–to–receive transport latency. Never for geometry. |
DDS reception_timestamp | The DDS middleware on the subscriber | When the sample landed in the subscriber's queue. | Queue-depth and transport diagnostics. |
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.
| Requirement | Why |
|---|---|
1. header.stamp is capture time, not receive time | It is the only field downstream code reads. |
| 2. The applied latency correction is a declared, logged parameter | So 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/2 | Removes the 9 ms auto-exposure swing. |
| 4. Batched samples are back-dated individually | Removes the 41.85 ms mean FIFO error. |
| 5. Sensor sequence numbers are published and gaps are counted | A dropped frame silently shifts everything downstream by one period. |
6. A diagnostic reports trx − tcap per message | Turns latency into a monitored signal instead of an assumption. |
| 7. Any device→host clock conversion publishes its residual | The 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.
| # | Term | Value | Running total | Why it is in the budget |
|---|---|---|---|---|
| 1 | Camera capture → callback latency (row E) | 15.0 ms | 15.0 ms | The instant a frame reaches your code, the capture time you must query for is already 15 ms old. |
| 2 | Jitter tail, not jitter mean: +3σ at σ = 3.1 ms | 3 × 3.1 = 9.3 ms | 24.3 ms | A buffer sized on the mean is empty exactly on the frames where it matters. Budget p99.9, not p50. |
| 3 | Consumer is one camera period behind under load | 1/30 = 33.3 ms | 57.6 ms | The VIO front end is its own thread. When the CPU is hot it processes frame n while frame n+1 arrives. |
| 4 | Front-end work before it asks for IMU | 1/30 = 33.3 ms | 90.9 ms | Detection and matching on a 1280×800 frame are budgeted at one frame period. The query happens after that. |
| 5 | Safety factor on the worst case | × 2, rounded | 200 ms | 90.9 × 2 = 181.8 → 200 ms. Actual factor 200 / 90.9 = 2.2×. |
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.| Stream | Rate | Per-sample | 200 ms of history |
|---|---|---|---|
| IMU | 400 Hz | 28 B | 80 samples = 2.2 kB |
| Wheel odometry | 200 Hz | 16 B | 40 samples = 0.6 kB |
| Pose / tf | 200 Hz | 64 B | 40 samples = 2.6 kB |
| Camera, 4 × 1280×800 mono | 30 Hz | 1.02 MB | 6 frames × 4 = 24.5 MB |
| LiDAR | 10 Hz | 0.96 MB | 2 scans = 1.9 MB |
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
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.
%.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.
| Mechanism | What it gives you | Where you meet it |
|---|---|---|
| GenICam chunk data | The sensor appends its own capture timestamp to every frame, in the frame | Basler, FLIR, Allied Vision — enable ChunkModeActive + ChunkSelector=Timestamp |
| GigE Vision 2.0 + IEEE 1588 | The chunk timestamp is already in the PTP domain — no conversion needed | Any PTP-capable industrial camera |
V4L2 V4L2_BUF_FLAG_TIMESTAMP_COPY | Kernel stamps at the interrupt rather than at dequeue | UVC and CSI cameras on Linux |
| IMU hardware sample counter | Exact sample index, so you back-date with the true rate not the nominal one | Bosch BMI, TDK ICM, Analog Devices ADIS |
SO_TIMESTAMPING | Per-packet hardware receive timestamps from the NIC | Any Ethernet sensor on Linux |
ros2_tracing / LTTng | Where the milliseconds actually went, per callback | Bédard et al., RA-L 2022 |
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 | |
|---|---|
| Symptom | VIO 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. |
| Cause | The 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 metric | Log 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 confirmation | Lock 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. |
| Fix | Stamp 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 | |
|---|---|
| Symptom | The 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. |
| Cause | All 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 metric | Histogram 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 metric | Cross-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. |
| Fix | Back-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 | |
|---|---|
| Symptom | The 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. |
| Cause | Frames 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 arithmetic | 240 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 metric | Plot 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 tell | Scale 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. |
| Fix | Republish 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. |
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.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.
| Direction | The specific reference | What changes |
|---|---|---|
| The sensor stamps itself, in the PTP domain | GigE Vision 2.0 with IEEE 1588; GenICam chunk timestamps | The 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 all | Gallego et al., "Event-based Vision: A Survey", TPAMI 2022 | An 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 it | Bédard, Lütkebohle & Dagenais, "ros2_tracing", RA-L 2022; TIER IV's CARET | Instead 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. |
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.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.
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.
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.
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:
| Family | What it does | Worst-case error | Cost |
|---|---|---|---|
| Nearest / zero-order hold | Use the closest sample unchanged | v · h / 2 | One comparison |
| Linear / SLERP | Blend the two bracketing samples | |x″| · h2 / 8 | A search plus a few flops |
| Continuous-time | Fit a B-spline or GP through the whole stream and evaluate it | Set by the basis order and knot spacing | An optimisation problem |
The first two bounds are worth deriving, because the ratio between them is the argument you will make in a design review.
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:
Put the interval at [0, h] and let p(t) be the straight line through the two bracketing samples:
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
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
Setting g″(ξ) = 0 and solving for e(s) gives e(s) = ½ x″(ξ) w(s). The instant s was arbitrary, so rename it t:
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:
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.
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
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
| Spline | Stencil & continuity | Interior order | Used 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) / 2h | h3 — the centred-difference tangent is itself only second-order accurate, so it costs you one power | The cheapest honest "continuous-time" you can drop into an existing buffer. This is the third curve in the widget below. |
| Cubic B-spline | 4 control points; does not pass through them; C2 | h4 once the control points are fitted rather than copied from the samples | Kalibr, 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.
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:
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 Ω:
Dot the same expression with q1, using q1·q1 = 1:
Step 2 — eliminate. Multiply the second equation by cos Ω and subtract it from the first. The βcosΩ terms cancel and what is left is
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)Ω:
Substituting, the numerator collapses:
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
Therefore α sin2Ω = sin Ω sin((1−u)Ω), and one factor of sin Ω cancels:
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:
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.
Two implementation details separate a correct SLERP from a nearly correct one:
| Detail | What goes wrong without it | The 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. |
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.
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.
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.
| ApproximateTime | Interpolating buffer | |
|---|---|---|
| Operation | Selects the existing messages with the smallest time span | Evaluates each stream at a common instant |
| Residual time error | Bounded 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 IMU | up to 1.25 ms → 4.25 mm at 3.4 m/s | a·h2/8 with h = 2.5 ms → 0.0016 mm |
| Two free-running 30 Hz cameras | up to 16.7 ms → 56.7 mm at 3.4 m/s | n/a — interpolate both onto one query clock |
| Failure style | Silent. 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 architecture that is actually right. Three components, and it is the same shape whether you write it yourself or use tf2:
| Production tool | What it is | The 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 ApproximateTime | Selects near-simultaneous tuples | Does not interpolate. Tuning slop changes what it accepts, never what it computes. |
GTSAM PreintegratedImuMeasurements | Integrates IMU between two keyframe instants, with the right covariance | Feed it correctly back-dated per-sample stamps or the pre-integrated covariance is wrong too. |
| Kalibr / Coco-LIC B-spline | A single continuous pose function of time; query any instant | Knot spacing is a real hyperparameter — too coarse and you smooth away real motion. |
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
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
F5: the silent Time(0) lookup | |
|---|---|
| Symptom | A 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. |
| Cause | lookupTransform(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 metric | Instrument 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. |
| Fix | Always 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 | |
|---|---|
| Symptom | The 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. |
| Cause | Two 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 metric | Histogram |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. |
| Fix | Replace pairing with an interpolating buffer keyed on the slow stream's capture times. |
| F7: NLERP that passed the unit test | |
|---|---|
| Symptom | Attitude 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. |
| Cause | The 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 metric | Evaluate 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 cost | 0.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. |
| Fix | Use 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 here is the same idea taken to its conclusion: stop storing a trajectory as samples and start storing it as a function.
| Direction | The specific reference | What it buys |
|---|---|---|
| Continuous-time as a basis expansion | Furgale, Barfoot & Sibley, "Continuous-Time Batch Estimation Using Temporal Basis Functions", ICRA 2012 | The 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 process | Barfoot, Tong & Särkkä, "Batch Continuous-Time Trajectory Estimation as Exactly Sparse Gaussian Process Regression", RSS 2014 | The 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 rigs | Lang et al., "Coco-LIC: Continuous-Time Tightly-Coupled LiDAR-Inertial-Camera Odometry using Non-Uniform B-spline", RA-L 2023 | Knot 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 2022 | The 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 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.
bracket() returning None, applied to the error metric instead of the query.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.
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.
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 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.
| Symbol | Meaning | Bench range |
|---|---|---|
| v | Ground speed along the path | 0.4 – 5.0 m/s |
| Δt | Camera-minus-IMU time offset. Positive = the camera is late. | −40 – +40 ms |
| A | Slalom amplitude — how hard the robot turns | 0.4 – 3.0 m |
| θ(t) | True heading | derived from the path |
| ω(t) | True yaw rate, = v · κ where κ is path curvature | derived |
| Θ(t) | Heading swept since the start: θ(t) − θ(0) | derived |
The estimator's reported heading is stale by Δt, so it is wrong by the amount the robot turned during that interval:
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 δθ ≈ δθ):
Both v and Δt are constants here, so they come straight out of the integral, and what remains integrates to the swept heading:
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.
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.
Run these in order. Each one is a claim from an earlier chapter that the bench either confirms or refutes.
| # | Do this | What you should see | Which claim it tests |
|---|---|---|---|
| 1 | Set Δt = 0, then sweep the speed from 0.4 to 5.0 | The 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. |
| 2 | Set Δt = 12 ms, sweep the speed again | Error 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. |
| 3 | Hold v = 3.4, sweep Δt from −40 to +40 | A 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. |
| 4 | Set the amplitude A to 0.4, keep Δt = 12 ms and v = 5.0 | 12.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. |
| 5 | Press Subtract constant with Δt = 12 ms, then sweep the speed again | 0.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. |
| 6 | Press Wrong sign | Error 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 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.
| Readout | What it is | What to watch for |
|---|---|---|
| peak cross-track | Largest perpendicular distance between the true and estimated paths, from the full non-linear integration | It 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 above | Agreement is the point. If they diverged, the derivation would be wrong. |
| heading error at peak ω | ωmax · Δt, in degrees | This is what an attitude monitor would report. Under about 0.1° it is invisible in any dashboard. |
| landmark smear at 5 m | 5 · δθmax, the lateral displacement of a landmark 5 m out | This is the number that determines whether loop closures survive. Compare it to your inlier gate. |
| budget | Whether the peak error is inside 50 mm | Note how much speed you can carry at 5 ms versus 20 ms. That difference is what a hardware trigger buys. |
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:
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.
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.
Sooner or later you have to choose. Here is the ladder from Chapter 0, priced.
| Fix | Residual Δt | vmax (both channels) | Engineering | BOM | Fails how |
|---|---|---|---|---|---|
| 1. Do nothing | 12 ms | 2.3 m/s | — | — | Silently, 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 rig | — | Goes stale — drifts with temperature, exposure and CPU load. Needs a recalibration cadence. |
| 3. Move the stamp to the kernel or the sensor | ~0.3 ms | 93 m/s | 2–4 weeks of driver work per sensor | — | Vendor SDK will not expose it; you fall back to 2 for that sensor. |
| 4. Hardware trigger + PTP for the rest | < 0.01 ms | not the limit | 4–8 weeks incl. EMC | ~$40–120 | Silently, if the harness breaks — needs a liveness check on the observed frame interval. |
| 5. Estimate Δt online as a filter state | tracks drift, ~0.5 ms | 56 m/s | 2–3 weeks, plus observability care | — | Goes unobservable when the robot is still or moving smoothly; needs a hold-last-good policy. |
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 moves | Mechanism | Observed swing on this robot |
|---|---|---|
| Auto-exposure | Mid-exposure slides with T — Chapter 2, worked example 1 | ±4.5 ms between full sun and a dim warehouse |
| CPU load | Scheduler 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 |
| Temperature | Oscillator 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 last step, and the one that keeps the bug fixed: this bench is a unit test. It needs no hardware and no recorded data.
assert abs(td_hat - 0.012) < 0.0005 — this catches the sign inversion of F7, which no amount of code review reliably catches.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.
Forty lines that reproduce worked example 1 exactly. Notice that the entire bug is one line — th_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
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."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
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 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
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.| F7: sign-inverted time correction | |
|---|---|
| Symptom | A 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 arithmetic | The 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 metric | Plot 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. |
| Fix | Define 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. |
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:
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.
Θ 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 ms | What it means |
|---|---|---|---|
| Straight test track, 200 m | ≈ 0.00 | 0 mm | The bug is undetectable. This is the validation gap. |
| Gentle sidewalk slalom (our bench) | 0.79 | 32.4 mm | 65% of a 50 mm budget consumed by timing alone. |
| Right-angle turn at an intersection | 1.57 | 3.4 × 0.012 × 1.57 = 64.1 mm | Over 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 corners | 6.28 total | 256 mm accumulated if the turns do not cancel | Why a lap around a warehouse fails to close while a straight run looks perfect. |
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.
| Cause | Mechanism | How to tell it apart |
|---|---|---|
| The IMU is the late one | FIFO 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 applied | Someone 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 epochs | A 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. |
If you retain nothing else from this chapter, retain these. Each is one multiplication and each answers a question you will be asked.
| Number | The formula | The question it answers |
|---|---|---|
| 1 ms at 1 m/s = 1 mm | e = 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. |
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.
| Signal | Cadence | Alarm on | Catches |
|---|---|---|---|
CLOCK_REALTIME − PHC | 1 Hz | |value| > 100 µs, or any non-zero slope | F1 — the dead phc2sys, within seconds instead of within a shift |
| Monotonicity violations per topic | 1 Hz counter | any non-zero value | F2 — the backwards clock step |
| Online td estimate per sensor pair | 0.1 Hz | drift > 2 ms from the calibrated value | F3 — exposure and thermal drift, before it eats the budget |
diff(stamp) p99 per topic | 1 Hz | bimodality, or p99 > 2× nominal period | F4 — batch stamping, and dropped frames as a bonus |
| Loop-closure residual ÷ traverse speed | per run | ratio > 5 ms, or rising week over week | The aggregate symptom — the one number that would have caught the whole Chapter 0 ticket before a human did |
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.
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.
| Check | Expected | What a failure means |
|---|---|---|
| Δt = 0 at every speed | Error identically 0.0 mm | Your simulator has a bug of its own — probably an integration offset or an index shift — and every other number it prints is contaminated. |
| Halve Δt | Error halves exactly | Something non-linear is leaking in. At small offsets the relationship must be exactly linear. |
| Halve the speed | Error halves exactly | You may be integrating over samples rather than over time, which conflates rate with speed. |
| Flip the sign of Δt | Same magnitude, mirrored bend | An asymmetry means a clamp or an absolute value somewhere in the correction path — exactly the bug F7 describes. |
Someone will push on the model's limits, and knowing them is more valuable than defending the model.
| Simplification | What a full system adds | Direction of the effect |
|---|---|---|
| Attitude comes only from the camera | A real VIO blends camera and IMU attitude, so only the camera's share of the correction carries the bias | Reduces the error by the camera's weight — typically 0.3 to 0.7 — but does not change the v·Δt·Θ shape |
| No loop closure | Loop closure would pull the estimate back — if it were accepted | Both 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 Δt | Real offsets drift with temperature and swing with auto-exposure | Makes it worse and much harder to see, because the "constant" you calibrated moves |
| Translation channel omitted | The along-track v·Δt shift of Chapter 0 is also present | Adds error, but it is along-track and therefore largely absorbed by scale — which is why the rotation channel dominates on a turning route |
| Perfect kinematics | Wheel slip, suspension, uneven ground | Adds noise, not bias — and this bug is a bias, which is exactly why it survives averaging |
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.
| Rung | The name to say |
|---|---|
| 2. Measure once, subtract a constant | Mair, 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 + PTP | IEEE 1588-2008 (PTP v2), and its automotive / AVB profile IEEE 802.1AS-2020 (gPTP). |
| 5. Estimate td online as a filter state | Li & 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.
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.
| Concept | The 30-second explanation | Key equation | Tool | Classic paper | Recent 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. | t̂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 | — | — |
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.
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.
| Stamp | Written by | Read on whose clock | The physical event |
|---|---|---|---|
| t1 | client | client | request leaves |
| t2 | server | server | request arrives |
| t3 | server | server | reply leaves |
| t4 | client | client | reply 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 θ:
The reply leaves at server-time t3, spends dback in flight, and is read on a clock sitting θ behind. So you subtract θ:
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.
Step 3 — add them. The two θ terms reinforce, and the two unknown delays subtract:
Divide by two and solve for the thing you want:
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:
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:
Both delays are physically non-negative, so each is somewhere in [0, RTT], which means |dout − dback| ≤ RTT and therefore:
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.
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:
Step 2 — construct the error function, and use the trick. Fix the query point t. Define a helper function of a free variable s:
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:
Substituting back into g(t) = 0 gives the exact error at the query point, with no approximation anywhere:
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:
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:
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.
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:
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:
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:
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.
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.
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.
| Sensor | Rate & shape | Payload / message | Transport | Where the stamp is taken today | Latency: mean / p99 jitter | Sync 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 |
header.stamp, always. Latency corrections are declared parameters, not magic numbers. Per-frame exposure published so mid-exposure is computed per frame."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 line | Where it comes from | Cost (ms) | Running total |
|---|---|---|---|
| Camera↔IMU trigger skew | 1.2 m harness at 5 ns/m = 6 ns of cable propagation | 0.000006 | 0.00 |
| FPGA counter → host clock mapping | Residual of the continuously fitted offset-plus-rate line, p99, published as a diagnostic | 0.15 | 0.15 |
| Mid-exposure residual | Exposure-register quantisation (1 µs) plus the readout model; what survives after tcap = t0 + T/2 | 0.10 | 0.25 |
| gPTP host↔LiDAR PHC delta | p99 across two transparent-clock hops with correction fields | 0.02 | 0.27 |
| LiDAR scan-start uncertainty | What remains after azimuth deskew — not the 100 ms of spread, only the uncertainty in when the rotation began | 0.50 | 0.77 |
| Radar CAN-FD arbitration jitter | p99 of a bus we do not control, with no PTP and no trigger available | 0.90 | 1.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 ms | 0.0001 | 1.67 |
| Wheel-encoder ISR jitter | MCU interrupt latency under worst-case load | 0.10 | 1.77 |
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."
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.ros2 bag play paces by receive time by default, which papers over the very ordering that caused the bug./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.Prompt C — "A vendor LiDAR only exposes receive timestamps. What do you do?"
Prompt D — "The robot must run eight-hour shifts with no recalibration."
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)
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
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
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].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.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%
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.lags[k] = −30, thirty samples from the truth, and the three values around it are y0 = 0.061041, y1 = 0.063313, y2 = 0.061990.prom = (c.max() - c.min()) / c.max()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
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."capture_times(t_rx=1000.0000, n=32, fs=400.0, transport_s=0.0031)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
| Symptom | Root cause | The 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. |
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.
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."
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."
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."
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.]
| Problem | Classical | Modern | When to use which |
|---|---|---|---|
| Clock agreement | NTP over IP, userspace stamping, 0.1–1 ms on a LAN | PTP with NIC hardware timestamping and transparent switches, < 1 µs; gPTP/TSN for scheduled delivery | NTP 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 alignment | Shared hardware trigger from an FPGA | Still the hardware trigger — nothing has replaced it | Trigger 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 calibration | Offline 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 representation | Discrete poses at keyframes, interpolated on demand with lerp and SLERP | Continuous-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 fusion | message_filters ApproximateTime — select near-simultaneous tuples | Capture-time buffers with interpolation and fixed-lag release | ApproximateTime 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 uncertainty | A point estimate of the offset | An interval (Spanner's TrueTime, OSDI 2012) or a state with a covariance | Always 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. |
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.
| Paper | Why this one |
|---|---|
| Mills, "Internet Time Synchronization: The Network Time Protocol", IEEE Trans. Communications, 1991 | The 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 2013 | The 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 2012 | The 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 2018 | The 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 2012 | Not 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 2022 | The honest controlled comparison. Citing a paper that complicates the frontier narrative is worth more than citing one that flatters it. |
| Repository | What to actually read |
|---|---|
richardcochran/linuxptp | clock.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/kalibr | aslam_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-Mono | Search 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/geometry2 | tf2/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_filters | include/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_tracing | The 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. |
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.
| Lesson | What it adds |
|---|---|
| Calibration & Time Sync | The 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 Clocks | What 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 & Transforms | The 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 Costs | Why inflating R does not fix a bias, in full: Mahalanobis distance, covariance propagation, and what robust costs actually do to a deterministic error. |
| Classical VIO | The system where all of this lands: IMU pre-integration, sliding-window optimisation, and why time offset is one of the states. |