Robotics Engineering · Lesson 10 of 26

The Real-Time Mental Model
The tail is where robots break

A loop that runs on time on average is not a real-time loop. The number that decides whether the arm trips the safety stop is not the mean — it is the once-a-second straggler in the tail.

Prerequisites: you can sort a list + you have written a loop. Everything else is built here.
6
Chapters
7
Interactive Sims
3
Code Labs

Chapter 0: The Hook

A robot arm's control loop is supposed to run every millisecond. It does — on average. You have the logs to prove it: mean loop time 1.03 ms, right where the spec says it should be. The plots look healthy. The demo ran all week.

Then, once a second, something breaks. The arm reaches for a part, and instead of stopping cleanly it overshoots by a few millimetres, the joint torque spikes, and the safety controller slams an emergency stop. A human walks over, clears the fault, and the line resumes. It happens again roughly sixty seconds later. Nobody can reproduce it on demand, and every average you compute says the system is fine.

The average is fine and the robot still trips. That contradiction is the entire subject of this lesson. The mean loop time is 1.03 ms. The problem is a single cycle, once a second, that takes 4 ms instead of 1 — and that one late cycle is the exact moment the arm overshoots.

Here is the mechanism, in one breath. Every millisecond the loop reads the joint angle, computes how fast it is moving, and commands a torque to bring it to rest at the target. The velocity estimate is (angle_now − angle_last) / dt, and the controller was tuned assuming dt = 1 ms. When a background task — a logger flushing to disk, a network stack draining a buffer — steals the CPU for 3 ms, the next loop iteration runs late. Now dt is really 4 ms, but the code still divides by 1 ms. The velocity is silently overestimated by 4×, the torque command is wrong, the arm lurches, and the safety stop fires.

The mean did not see it coming. The mean cannot see it coming, because averaging is exactly the operation that erases a rare, large event. This lesson is about the number that does see it — the tail — and about the discipline that keeps the tail bounded instead of hoping it stays small.

The average is a liar

A thousand loop cycles. Almost every one lands near 1 ms (teal). But drag the tail rate up and a few cycles jump to 4 ms (red) — late cycles caused by a background task. Watch the mean barely move while the worst case that trips the robot stays pinned at 4 ms. The mean is the wrong statistic.

Tail rate 1.0%

Push the slider from zero. At 1% tail — ten late cycles out of a thousand — the mean creeps up from 1.00 ms to about 1.03 ms, a change so small you would never flag it in a dashboard. Meanwhile the worst case is nailed to 4 ms the whole time. If a 4 ms cycle trips the robot, then the mean moving by 0.03 ms is telling you nothing about the thing that actually matters.

Do the arithmetic by hand once

You do not need a thousand samples to feel this. Take ten loop-time measurements, in microseconds, sorted for you:

980, 990, 995, 1000, 1005, 1010, 1015, 1020, 1030, 4000

Nine of them cluster tightly between 980 and 1030 µs — a healthy 1 ms loop with a little jitter. One is 4000 µs: the preempted cycle. Compute the two summary numbers by hand.

The mean. Add them: 980 + 990 + 995 + 1000 + 1005 + 1010 + 1015 + 1020 + 1030 + 4000 = 13,045. Divide by ten: 1304.5 µs. Notice what happened — a single 4000 µs sample dragged the "typical" number up by 300 µs. The mean of nine healthy cycles alone is 1005 µs; the one straggler added 299.5 µs to it. The mean is a blend of the common case and the disaster, and it reports neither honestly.

The median (p50). With ten sorted values the median is the average of the 5th and 6th: (1005 + 1010) / 2 = 1007.5 µs. That is the honest "typical" cycle — and it sits calmly at 1 ms, completely blind to the 4000 µs spike. Neither the mean nor the median saw the event that trips the robot.

The one number that saw it is the maximum, or in a larger sample the p99.9 — the value that only the worst tenth of a percent of cycles exceed. Here it is 4000 µs, four times the median. That is the number a real-time engineer quotes. "Mean loop time 1.03 ms" is not an answer; "p99.9 loop time 4 ms against a 1 ms deadline" is a diagnosis.

Now compute the tail percentile itself — by hand

The max is easy: it is just the last element of the sorted list. But "p99" and "p99.9" are the numbers you will actually be asked for, and they are not a list index — they are a fractional rank, and the value is interpolated between the two samples that straddle that rank. This is the one piece of arithmetic in the whole lesson that people fumble in an interview, so do it once, slowly, by hand. It is the same computation the from-scratch code at the bottom of this chapter performs; here we substitute real numbers so the code is never a black box.

Ten samples were too few to see a p99 — the 99th percentile of ten points lands almost on the maximum and there is nothing to interpolate against. So take a slightly larger sample: twenty loop-time measurements, in microseconds, already sorted for you. Nineteen are healthy; one is the preempted straggler at 4000 µs:

980, 985, 988, 992, 996, 1000, 1004, 1008, 1010, 1012,
1015, 1018, 1020, 1024, 1028, 1030, 1034, 1038, 1040, 4000

Index them from 0 (that is s[0] = 980) to 19 (s[19] = 4000). There are n = 20 samples, so n − 1 = 19 gaps between them. The recipe for the percentile at level q is exactly three steps.

Step 1 — the fractional rank. Multiply the fraction q/100 by the last index n−1:

idx = (q / 100) · (n − 1)

For p50 (the median, as a sanity check): idx = 0.50 × 19 = 9.5. For p99: idx = 0.99 × 19 = 18.81. For p99.9: idx = 0.999 × 19 = 18.981. Notice a fractional rank is almost never a whole number — 9.5, 18.81, 18.981 all fall between two real samples.

Step 2 — the two samples it lands between. Let lo = floor(idx) be the sample just below, and lo + 1 the one just above. For p50, lo = floor(9.5) = 9, so we interpolate between s[9] = 1012 and s[10] = 1015. For both p99 and p99.9, lo = floor(18.81) = floor(18.981) = 18 — so both land in the same gap, between s[18] = 1040 (the top healthy cycle) and s[19] = 4000 (the straggler). That gap is enormous: 4000 − 1040 = 2960 µs wide. This is the crux of why a tail percentile explodes — once the rank crosses into the last gap, it is interpolating across the cliff to the straggler.

Step 3 — interpolate. The value is the lower sample plus the fractional part of the rank times the gap:

p = s[lo] + (idx − lo) · (s[lo+1] − s[lo])

Substitute the real numbers, one line each:

Read those three numbers side by side and the whole lesson is in them. The median is 1013.5 µs — it never left the healthy cluster. But p99 is 3437.6 µs and p99.9 is 3943.76 µs: both are more than three times the median, dragged up the cliff toward the 4000 µs straggler. The only difference between p99 and p99.9 is how far up that cliff the fractional rank climbs — 0.81 of the way versus 0.981 of the way. Against a 1000 µs deadline, both percentiles scream "you are missing the deadline routinely," while the median swears everything is fine.

Why not just quote the standard deviation? Compute it by hand and see

There is a tempting middle answer that an interviewer will offer you as bait: "the mean hides the tail, sure — so report the standard deviation alongside it. That captures the spread, doesn't it?" It sounds reasonable, and it is wrong for exactly the same reason the mean is wrong. The only way to be sure is to compute it once, by hand, on numbers you already trust. Go back to the ten-sample set whose mean you already found to be 1304.5 µs:

980, 990, 995, 1000, 1005, 1010, 1015, 1020, 1030, 4000

The standard deviation is the square root of the average squared distance from the mean. So the recipe is: subtract the mean from each sample, square it, average the squares (that is the variance), and take the square root. Do the subtractions and squarings one row at a time — every deviation is measured from 1304.5 µs:

Look at that last row before you even finish. Nine of the ten squared deviations sit between about 75,000 and 105,000. The straggler's squared deviation is 7,265,720 — roughly seventy times larger than any of the others, because squaring a distance punishes the outlier far harder than it punishes the cluster. The standard deviation is not going to be robust to the tail; it is going to be dominated by it, in a way you cannot read back out.

Finish the arithmetic. Sum the ten squares: they total 8,074,972.5. Divide by ten for the variance: 8,074,972.5 / 10 = 807,497.25. Take the square root: σ ≈ 898.6 µs. (Dividing by nine instead of ten — the sample-variance convention — gives 897,219.2 and σ ≈ 947.2 µs; the difference does not change the story.)

Now ask what that number actually tells you against a 1000 µs deadline, and watch it fail three ways at once:

To be fair to the standard deviation, see it work where it is the right tool — so you know it is the shape that is wrong here, not the statistic itself. Drop the one straggler and compute σ on the nine healthy cycles alone: their mean is (980+990+995+1000+1005+1010+1015+1020+1030)/9 = 9045/9 = 1005 µs, the squared deviations sum to 1950, so σ = √(1950/9) = √216.67 ≈ 14.7 µs. Now mean ± 2σ = 1005 ± 29.4 = 975.6 to 1034.4 µs — and that band brackets the actual data range of 980 to 1030 µs almost exactly. On a single symmetric hump, mean ± 2σ genuinely tells you "here is where the cycles live," because that is the distribution σ was built for. The straggler is what breaks it, not the arithmetic.

The standard deviation assumes a shape the data does not have. σ is the right summary when the data is a single hump — roughly Gaussian, spread symmetrically around the mean, exactly like those nine healthy cycles where mean ± 2σ nailed the range. Loop times with the straggler are not that. They are a tight spike at 1 ms plus a rare, far-flung outlier: a bimodal, heavy-tailed distribution. On that shape, σ jumps from 14.7 µs to 898.6 µs the instant the one 4000 µs cycle enters — it becomes a weighted blend of the spike's width and the straggler's distance, the exact same crime the mean commits, one moment further out. The percentile makes no shape assumption at all: it just sorts and reads off a position, so it reports the straggler as a duration you can hold against the deadline. That is why the answer to "quote the mean" is never "quote the mean and the standard deviation" — it is "quote a tail percentile."

Line up all four statistics on the same ten cycles and the division of labour is stark — each is answering a different question, and only one of them is answering the question a deadline poses:

StatisticValue on the 10 cyclesQuestion it answersBlind to
mean1304.5 µs"What is the total work, amortised?"whether any single cycle missed
median (p50)1007.5 µs"What does a typical cycle look like?"the rare cycle entirely
std dev (σ)898.6 µs"How wide is the hump?" — if there is one humpreports a ±900 µs spread no cycle exhibits
max / p99.94000 / 3973 µs"What is the worst a cycle can do?"— nothing; this is the deadline's own question

Only the last row speaks the deadline's language: a deadline is a threshold on a single cycle's duration, so the only honest comparison is against a statistic that preserves a single cycle's duration — the max, or a deep percentile that reaches it. The mean amortises, the median discards, the standard deviation reshapes; all three destroy the one number the deadline checks. This is the sentence to say in an interview when handed a vector of latencies: "the mean and standard deviation both assume the latencies are one hump around a center — for a real-time loop they are a spike plus a tail, so I report p99.9 against the deadline and the tail-to-mean ratio, not the mean."

Why the tail moves and the median does not. A percentile is a position in the sorted list. Nineteen of twenty samples are healthy, so any percentile below the 95th sits inside the healthy cluster and reports ~1 ms. The moment q is high enough that its fractional rank crosses into the last gap — here anywhere past the 94.7th percentile, since 18/19 ≈ 0.947 — the interpolation reaches across the 2960 µs cliff and the reported value leaps. That threshold is exactly the tail rate: with one bad cycle in twenty, everything from about p95 upward is a diagnosis of the straggler, and everything below it is blind to it.

Do not take that on faith either — sweep q across the same twenty samples and watch the value walk off the cliff at exactly the predicted rank. Each row is one application of the three-step recipe above; run any of them by hand and you will land on the value shown.

Percentile qFractional rank
0.01q · 19
Lands betweenReported valueVerdict
p509.500s[9]=1012, s[10]=10151013.5 µshealthy 1 ms
p9017.100s[17]=1038, s[18]=10401038.2 µshealthy 1 ms
p94.717.993s[17]=1038, s[18]=10401040.0 µsstill healthy — last cycle before the cliff
p9518.050s[18]=1040, s[19]=40001188 µsrank just crossed the cliff
p9918.810s[18]=1040, s[19]=40003437.6 µsdeep in the tail
p99.918.981s[18]=1040, s[19]=40003943.76 µsalmost the straggler

The value is flat and boring — ~1040 µs — right up to p94.7, then it snaps upward the instant the fractional rank ticks past 18. Between p94.7 and p95 the reported loop time jumps from 1040 µs to 1188 µs; by p99 it is 3437.6 µs. This is the whole reason "quote a high percentile" is the discipline: the number you report is entirely determined by whether your chosen q reaches the tail. Quote p90 and you will swear the loop is healthy; quote p99 and you have caught the fault. The tail rate here is one in twenty (5 %), and the crossover lands at exactly 1 − 1/20 of the way up — which is why for a once-in-a-thousand fault you must reach all the way to p99.9 to see it at all.

Five lenses on this topic

This lesson looks at real-time behaviour through five lenses. They are not five topics — they are five ways of interrogating the same one: what does "on time" actually cost, and where does it break?

LensThe question it asksWhat a shallow answer sounds like
CONCEPT"Define a deadline. Now define hard, firm, and soft, and give the value function for each.""Real-time means fast." (It does not. It means predictable. A slow loop with a bounded worst case is real-time; a fast loop with an unbounded tail is not.)
DESIGN"You have three periodic tasks sharing one core. Will they meet their deadlines? Show the utilisation."Adding up average execution times instead of worst-case, and ignoring that a 40 % utilisation can still miss.
CODE"Given a vector of loop times, compute p50, p99, and p99.9, and tell me the tail-to-mean gap."Reporting np.mean() and stopping.
DEBUG"The demo ran fine all week and now trips once a second. Go.""It's a hardware problem." (Then why does it happen on a period? A once-a-second fault is a once-a-second task.)
FRONTIER"Is a soft real-time OS ever enough for a control loop?""Just use an RTOS." (Sometimes the honest answer is a dedicated core, a PREEMPT_RT kernel, or moving the loop onto a microcontroller entirely.)

What this lesson is, and is not

The mechanics of how an operating system delivers timing — scheduling, priorities, interrupts, context switches, the guts of an RTOS — are taught in detail elsewhere on the site. This lesson does not repeat them. It builds the mental model: the statistics, the deadline taxonomy, and the reason a control loop's rate is a hard constraint rather than a suggestion. When a mechanism below wants its full treatment, follow the link.

If you want…Read
What "real-time" means at the systems level: determinism, worst-case bounds, interruptsEmbedded Real-Time Systems
How an RTOS actually schedules: priorities, preemption, context switches, jitter sourcesEmbedded RTOS
Where the jitter comes from across a multi-sensor robot: clocks, timestamps, synchronisationTime & Synchronisation

What this lesson spends its words on is the craft of reasoning about time: the taxonomy you can classify a deadline with, the statistics that reveal the tail, the utilisation arithmetic that predicts a miss before it happens, and the failure taxonomy with the exact metric that exposes each fault.

The design context, in numbers

Give the scenario real dimensions, because "a control loop" is not a design and neither is "make it fast." A collaborative arm's inner torque loop is a concrete object with rates and budgets:

Notice the number that matters is not the average utilisation (30 % — looks idle) but the coincidence: the moment the logger and the loop want the CPU at the same time. Real-time design is the business of guaranteeing that coincidence never blows the deadline, or of proving what happens when it does.

So walk the worst-case coincidence as a timeline, in microseconds, with a stopwatch. Say the control cycle is released at t = 0 and its deadline is one period later at t = T = 1000 µs. On a good cycle nothing else is running: the loop's compute C = 300 µs runs from 0 to 300, finishes at t = 300, and there are 700 µs of slack before the deadline. Utilisation on that cycle is C/T = 300/1000 = 0.30 — comfortably idle. Now line the logger up against it:

That is the whole failure in five stopwatch reads: a task with 30 % average utilisation misses its deadline because on one cycle in a thousand it had to wait 800 µs for a lower-value job to get out of the way. The slack was 700 µs; the interference was 800 µs; the difference, 100 µs, is the overrun. No average would ever surface this, because the average blends this one 1100 µs cycle with 999 cycles of 300 µs and reports a healthy load.

Now trace the overrun into the physical damage, because "missed the deadline by 100 µs" does not sound like a reason to slam an e-stop. The cost is not the 100 µs of lateness itself — it is that the next cycle now sees a stretched sampling interval and computes a wildly wrong velocity. The velocity estimate is a finite difference:

v̂ = (angle_now − angle_last) / Δt

The controller was written assuming Δt = 1 ms and hard-codes that constant in the denominator. But the late cycle stretched the true interval between these two samples to Δt = 4 ms. Put numbers on the joint: it was at angle_last = 0.100 rad and moved to angle_now = 0.104 rad, a real change of 0.004 rad. Two computations of the same motion:

The ratio is 4.0 / 1.0 = 4× — the velocity is overestimated by exactly the factor the interval stretched, because Δt sits in the denominator. The joint is ambling at 1 rad/s; the controller believes it is racing at 4 rad/s and commands a hard braking torque to arrest a motion that is not happening. That torque spike is the instantaneous value the safety controller sees — and the safety side does no averaging, so the mean that hid the late cycle cannot hide the torque it caused. One 100 µs overrun becomes a 4× velocity error becomes an e-stop.

The DESIGN trap: summing average execution times

The same mean-versus-tail confusion has a second life in the DESIGN lens, and it is worth catching here because it is the exact wrong answer the lens table above warns about: "adding up average execution times instead of worst-case." Watch it happen on the very object you just traced. An engineer is asked whether the torque loop fits its period and reasons like this: the loop's average compute is C̅ = 300 µs, the period is T = 1000 µs, so the average utilisation is

U̅ = C̅ / T = 300 / 1000 = 0.30

— the loop uses 30 % of the CPU, the core is 70 % idle, ship it. That number is arithmetically correct and completely useless, for a reason you can now state precisely: a deadline is not a question about the average period, it is a question about the worst single period. And on the worst single period, the logger's 800 µs flush lands on top of the loop's worst-case compute of Cwcet = 350 µs, so the demand on that one period is

Cwcet + interference = 350 + 800 = 1150 µs > T = 1000 µs

The loop needs 1150 µs of the period and only has 1000 — an overrun of 1150 − 1000 = 150 µs. Expressed as a utilisation, the worst period demands 1150/1000 = 1.15, i.e. 115 % of the time available. So the honest and the naive utilisation disagree by a mile — 1.15 versus 0.30 — and the deadline cares only about the 1.15.

Here is the part that makes the trap so dangerous: averaging over the run hides the miss even after it has happened. Take 1000 cycles, 999 of them at 300 µs and one at the failed 1100 µs, and average:

observed = (999 · 300 + 1 · 1100) / 1000 = 300.85 µs

The measured average utilisation is 300.85/1000 = 0.301 — still 30 %, still "idle," with the deadline miss already baked into the data. This is the mean-lies lesson wearing a scheduling hat: the average execution time is exactly the statistic that erases the one worst period, which is the only period a hard deadline cares about. The correct DESIGN move is to sum worst-case compute plus worst-case interference against the deadline — and when there are several tasks sharing a core, to count how many times each higher-priority task can fire inside one period. That full multi-task interference arithmetic — the rate-monotonic utilisation bound and the activation-count interference sum — is derived step by step in Chapter 3; the point to carry out of Chapter 0 is only this: a design that adds average execution times has answered a question no deadline ever asked.

From scratch: the tail in four lines

The whole "mean lies, tail tells" idea is four lines of code. No library beyond a sort:

python
def percentile(times, q):        # q in [0, 100]
    s = sorted(times)          # tail statistics start with a sort
    idx = (q / 100) * (len(s) - 1)  # fractional rank
    lo = int(idx)
    return s[lo] + (idx - lo) * (s[min(lo + 1, len(s) - 1)] - s[lo])  # linear interp

mean  = sum(times) / len(times)
p50   = percentile(times, 50)
p999  = percentile(times, 99.9)
print(f"mean {mean:.0f} us  p50 {p50:.0f} us  p99.9 {p999:.0f} us")

Do not take the code on faith — run it in your head on the exact ten samples you already averaged by hand, and watch it reproduce your numbers. Feed it times = [980, 990, 995, 1000, 1005, 1010, 1015, 1020, 1030, 4000] (already sorted, n = 10) and trace each line:

So the print rounds those to mean 1304 us  p50 1008 us  p99.9 3973 us. Three numbers, three completely different stories about the same ten cycles: the mean is a blend that reports neither the common case nor the disaster, the p50 is the honest typical cycle at 1 ms, and only the p99.9 — the sorted index that reaches into the tail — sees the 4 ms cycle that trips the robot.

One dataset is not a proof — a function can be right on one input and wrong on the next. So run the same four lines against the twenty-sample vector you interpolated by hand earlier in this chapter, and demand that the code land on the exact numbers you already trust: p50 = 1013.5, p99 = 3437.6, p99.9 = 3943.76 µs. If the code reproduces both the n = 10 answers above and these n = 20 answers, it is not a coincidence — it is the algorithm. Feed it:

times = [980, 985, 988, 992, 996, 1000, 1004, 1008, 1010, 1012,
         1015, 1018, 1020, 1024, 1028, 1030, 1034, 1038, 1040, 4000]

Now len(s) = 20, so len(s) − 1 = 19 — the same 19 gaps the by-hand pass used. Trace each line and check it against the earlier interpolation:

The code reproduced every number from both datasets — the n = 10 median of 1007.5 and the n = 20 median of 1013.5, the n = 10 p99.9 of 3973.27 and the n = 20 p99.9 of 3943.76 — because it is doing exactly what your hand did: idx is the fractional rank, lo is floor(idx), and the return is the linear interpolation across the gap. There is nothing hidden in the four lines. And notice the deeper point the two runs make together: the mean moved from 1304.5 to 1161.1 when the healthy sample count changed, but the p99.9 barely moved (3973 to 3944) — because a high percentile is anchored to the tail, not to how much of the common case you logged. That stability is the last reason to quote it.

The library form is numpy.percentile(times, [50, 99, 99.9]) — same answer, same linear-interpolation convention. You will build exactly this in the Code Lab in Chapter 2, and it is the kernel that drives the histogram in the Chapter 4 showcase. The point of writing it from scratch is that once you see that a percentile is a sorted index, you understand why the mean cannot recover it: sorting keeps the tail; averaging destroys it.

The failure, and the metric that reveals it

Symptom: the system passes every average-based check — mean loop time in spec, CPU utilisation low — yet trips on a fixed period (here, once a second). Cause: a periodic background task preempts the control loop, producing a rare late cycle whose value averages away. The metric that reveals it: the p99.9 loop time plotted against the deadline, or equivalently a histogram of loop times. The mean and the utilisation are blind to it by construction; the tail percentile is not. And the give-away that it is a task and not hardware: the fault has a period — log the timestamps of the trips and take the difference, and it lands on a clean 1 s (or whatever the offending task's period is).

That last sentence is a debugging move you can do with nothing but a text log, and it is worth making concrete, because it is the single test that separates "it's a flaky sensor" from "it's a periodic task." When the arm trips, you have a timestamp for each fault. Do not stare at the raw timestamps — they look like random big numbers. Take the difference between consecutive ones instead. A random hardware glitch gives you a scatter of differences with no pattern; a periodic task gives you the same difference every time, and that difference is the offending task's period. Play with it below.

The debug trace: differencing the trip timestamps reveals the period

Each red tick is a logged trip time along a wall-clock axis. On their own they look like scattered noise. Set the offending task's period and add some timing jitter (and a few random non-periodic glitches), then read the panel: it computes the consecutive Δt between trips. A periodic cause makes every Δt collapse onto one value — the period — even buried in jitter. That constant is the fingerprint of a task, not hardware.

Task period 1000 ms
Timing jitter 15 ms

Push the jitter up and the Δt values spread — but they spread around the period, never away from it, so the median Δt still reads the task's period cleanly. Drag the period and watch the whole fingerprint slide to the new value. This is why "timestamp the faults and difference them" is the first thing a real-time engineer does: it turns an unreproducible intermittent trip into a named number you can go hunt for in the process list.

Frontier: where this stopped being folklore

Tail latency as a first-class engineering concern was crystallised for the whole field by Dean and Barroso's "The Tail at Scale" (Communications of the ACM, 2013). Their setting was web services, not robots, but the lesson is identical and they made it quotable: in a system of many components, the overall response time is governed by the slowest component, so reducing the average does almost nothing while reducing the 99.9th percentile is everything. A robot's control loop is a chain of the same kind — sensor read, estimate, control, actuate — and a stall anywhere in the chain becomes the loop's tail. The modern robotics counterpart is the PREEMPT_RT patch for Linux (merged into the mainline kernel in 2024 after two decades out-of-tree), which bounds exactly the preemption latency that produces these tails. We return to both in the Field Guide.

The arm trips once a second. Mean loop time is 1.03 ms, median is 1.00 ms, and the spec deadline is 1 ms. What is the single most useful next number to look at?

Chapter 1: Hard / Firm / Soft

In Chapter 0 the robot tripped because one cycle came in at 4 ms against a 1 ms deadline. But we never asked the question underneath that: what actually happens when a deadline is missed? The answer is not one answer. It depends entirely on what kind of deadline it is — and getting that classification wrong is how engineers over-build cheap systems and under-build dangerous ones.

Everyone reaches for the word "real-time" to mean "fast." That is the single most expensive misconception in the field. A real-time system is one whose correctness depends on when a result is produced, not just whether it is correct. A loop that produces the right torque 2 ms too late has produced a wrong output, even though the arithmetic was flawless. Real-time means predictable, and a slow-but-bounded system is more real-time than a fast-but-unbounded one.

The definition, precisely. A deadline is a time D by which a task must complete for its result to be useful. A real-time constraint is a deadline whose violation changes the correctness of the system, not just its speed. The three flavours — hard, firm, soft — differ only in what a missed deadline costs.

The value function is the whole taxonomy

The cleanest way to define the three flavours is with a single object: the value function V(t), which says how useful a result is if it arrives at time t. Let D be the deadline. Then:

Read those as one picture: hard falls to negative infinity, firm falls to a flat zero, soft slides down a ramp. That is the entire taxonomy, and it is why "real-time" alone tells you almost nothing — the useful question is always which shape is this deadline's value function?

The three value functions

Move the completion time slider and read the value of a result that arrives at that moment, for all three kinds of deadline. Before D they agree; after D they diverge completely. Change the soft decay to see how gently a soft deadline forgives lateness.

Completion time 0.80 D
Soft decay window 1.0 D

Reading the three curves by hand at three completion times

Before we classify real subsystems, let us prove to ourselves that we can evaluate each value function by hand at specific instants — the slider does it visually, but the arithmetic is what you will do on a whiteboard. Fix the deadline at D = 100 (arbitrary units) and the soft decay window at τ = 200. We will read all three flavours at three moments: one on time, one just barely late, and one far late.

At t = 80 (on time, t ≤ D). Every value function is defined as V(t) = 1 for t ≤ D, so there is nothing to compute: hard = 1, firm = 1, soft = 1. This is the flat shelf on the left of the widget where all three curves lie on top of each other. On-time work is on-time work; the flavour is invisible until you are late.

At t = 130 (30 units late, t > D). Now the branches diverge. Hard takes the catastrophic branch: V(130) = −∞. Firm takes the zero branch: V(130) = 0. Soft takes the ramp: substitute into 1 − (t − D)/τ = 1 − (130 − 100)/200 = 1 − 30/200 = 1 − 0.15 = 0.85. So a result that is 30 units late is a disaster for a hard task, garbage for a firm task, and still 85 % as good for a soft one. Same lateness, three completely different costs.

At t = 200 (100 units late, exactly one τ past D). Hard is still −∞, firm is still 0. Soft: 1 − (200 − 100)/200 = 1 − 100/200 = 1 − 0.5 = 0.50. Being late by a full decay window costs the soft task exactly half its value — that is what τ means: the number of units of lateness that burns half the value. Push to t = 300 and the ramp reaches 1 − 200/200 = 0; beyond that the max(0, ·) clamp holds it at zero, so a soft deadline eventually becomes indistinguishable from a firm one. Drag the completion-time slider to 0.80·D, 1.30·D, and 2.00·D and watch the three dots land on exactly these numbers.

Why τ is the whole story for a soft deadline. Two soft tasks with the same D but τ = 50 versus τ = 500 forgive lateness ten-fold differently: at 30 units late the first is at 1 − 30/50 = 0.40, the second at 1 − 30/500 = 0.94. Quoting "soft" without τ is like quoting a deadline without a number — the classification is incomplete until you name the decay window.

Worked example: classify four subsystems by hand

The taxonomy is only useful if you can apply it. Take four real subsystems on the same robot arm and classify each, then read the value at a specific late arrival — with the arithmetic shown, not asserted.

(1) The safety torque monitor checks every cycle whether the commanded torque exceeds a limit and cuts power if so. Deadline D = 1 ms (one control period). If it is late, a dangerous torque is applied for an extra millisecond and the arm can injure someone. Evaluate the hard value function at a 1 ms overrun, t = 2 ms: since 2 > D = 1, we take the catastrophic branch, V(2 ms) = −∞. There is no arithmetic to soften — the branch itself is the answer. Hard. This subsystem justifies a dedicated core, a watchdog, and formal WCET analysis.

(2) The camera-based object detector feeds grasp targets to the planner at 30 Hz, deadline D = 33 ms. If a frame's detection is late, the planner reuses the previous target for one cycle and the new detection is discarded. The value of a late frame is the firm branch: V(50 ms) = 0. But the sharper question a designer asks is the expected value per frame given a miss rate p — and that is real arithmetic. A firm result is worth 1 when on time and 0 when missed, so E[V] = (1 − p)·1 + p·0 = 1 − p. At a measured miss rate of p = 0.02, E[V] = 1 − 0.02 = 0.98. Read as a rate: over a one-second window the detector runs 30 frames, of which 30·(1 − 0.02) = 29.4 update the planner and 30·0.02 = 0.6 are dropped — roughly one dropped frame every ~1.7 s. Harmless as long as p stays small. Firm. This justifies a "drop the frame" policy, not a dedicated core.

(3) The teach-pendant UI echoes the operator's jog commands to a screen. Deadline D = 100 ms (the threshold where lag feels sluggish). If the echo is 150 ms late, it still shows — just feels slightly laggy. Take a linear decay over a τ = 200 ms window: V(150 ms) = 1 − (150 − 100)/200 = 1 − 50/200 = 1 − 0.25 = 0.75. Still three-quarters as useful. Soft. This justifies nothing special; a best-effort OS thread is fine.

(4) The trajectory-preview overlay on the operator display predicts the arm's path ~500 ms ahead and redraws it at 20 Hz, deadline D = 50 ms. A late preview is still a readable path for a while, decaying over τ = 150 ms as the drawn path drifts from where the arm actually is. Read it at three arrival times, all with the same ramp 1 − (t − D)/τ: at t = 80 ms, V = 1 − (80 − 50)/150 = 1 − 30/150 = 1 − 0.20 = 0.80; at t = 125 ms, V = 1 − (125 − 50)/150 = 1 − 75/150 = 1 − 0.50 = 0.50; at t = 200 ms, V = 1 − (200 − 50)/150 = 1 − 150/150 = 1 − 1 = 0, and the max(0, ·) clamp keeps it there for any later arrival. Same shape as the pendant, faster decay because a stale prediction rots quicker than a stale echo. Soft (with the shorter τ that a preview demands).

The design payoff: the four subsystems share the same robot but demand wildly different engineering. Classifying them correctly is what stops you from spending a dedicated core on a UI echo (waste) or running a safety monitor as a best-effort thread (danger). The value function is not academic — it is the budget allocator.

The number that sizes the budget: expected value under a miss rate

Classification tells you the shape; the budget question asks how much a given miss rate costs you, and for firm and soft that is a genuine expected-value calculation you can run on the back of an envelope. Imagine two subsystems facing the same 2 % miss rate, p = 0.02 — one firm, one soft that lands at 0.75 when late (the pendant). We want the average value each delivers per invocation.

Firm. A firm task is worth 1 on time and exactly 0 when missed, so the expected value is a two-term average weighted by the miss rate:

E[V]firm = (1 − p)·1 + p·0 = 1 − p = 1 − 0.02 = 0.98

Soft (lands at 0.75 when late). A soft task keeps partial value on a miss, so the missed branch contributes 0.75 rather than 0:

E[V]soft = (1 − p)·1 + p·0.75 = 0.98 + 0.02·0.75 = 0.98 + 0.015 = 0.995

Now read the two losses relative to a perfect run (value 1). The firm task loses 1 − 0.98 = 0.020 per invocation; the soft task loses 1 − 0.995 = 0.005. The firm task's loss is 0.020 / 0.005 = larger for the identical 2 % miss rate — entirely because a firm miss throws the whole result away while a soft miss keeps three-quarters of it. This is the quantitative reason a firm deadline deserves a tighter miss-rate budget than a soft one: the same misbehaviour is four times as expensive. Push the rate to p = 0.10 and the gap widens — firm E[V] = 0.90 (loss 0.10), soft E[V] = 0.975 (loss 0.025), still exactly the 4× ratio, because the ratio of losses is fixed at 1 / (1 − 0.75) = 4 regardless of p.

The one-line takeaway for a budget meeting: a firm miss costs you the whole result; a soft miss costs you only the part the decay has eaten. Two subsystems at the same miss rate can differ 4× in delivered value — so the miss-rate budget you allow is not one number for the robot, it is one number per flavour.

Inverting the question: the miss-rate budget each flavour buys you

The expected-value formula runs the other direction too, and that is the direction a real spec is written in. Instead of "given a miss rate, what value?" ask "given a required value floor, how many misses can I afford?" Write the expected value for any late-branch value vlate in one line:

E[V] = (1 − p)·1 + p·vlate = 1 − p·(1 − vlate)

Demand E[V] ≥ a floor of 0.99 (deliver at least 99 % of ideal value on average) and solve for the largest tolerable p:

p ≤ (1 − 0.99) / (1 − vlate) = 0.01 / (1 − vlate)

For a firm task, vlate = 0, so p ≤ 0.01 / (1 − 0) = 0.01 — a 1 % miss-rate budget. For the soft pendant, vlate = 0.75, so p ≤ 0.01 / (1 − 0.75) = 0.01 / 0.25 = 0.04 — a 4 % budget, four times more forgiving for the identical value floor. And the hard task? Its late branch is −∞, so any p > 0 drives E[V] to −∞: the only tolerable budget is p = 0, which is why hard tasks are never specified by an average value at all. They are specified by a failure probability over a mission — a 1 kHz hard loop runs 1000×3600 = 3.6×10⁶ cycles an hour, and a safety standard demands the probability of a dangerous failure per hour sit in a defined band (for IEC 61508 / ISO 13849 SIL 3, that band is 10⁻⁸ to 10⁻⁷ per hour). Different mathematics entirely, because "on average" is meaningless when a single miss is catastrophic.

FlavourV(t > D)Value floor specMiss-rate budget (E[V] ≥ 0.99)Engineering it buys
Hard−∞failure prob / hour, not averagep = 0 (zero tolerance)dedicated core, watchdog, WCET proof
Firm01 − pp ≤ 1 %drop-the-frame policy, no dedicated core
Soft1 − (t−D)/τ1 − 0.25·p (at vlate=0.75)p ≤ 4 %best-effort thread

Read the table as a budget ladder: the same 0.99 value floor buys a hard task zero misses, a firm task 1 %, and a soft task 4 % — and the engineering column is what that budget costs to guarantee. Misclassify a firm subsystem as hard and you spend a dedicated core to buy a zero-miss guarantee nobody needed; misclassify it as soft and you allow 4× too many misses. The value function is not describing the task — it is writing the spec.

From scratch: the value functions in code

python
import numpy as np

def value_hard(t, D):
    return np.where(t <= D, 1.0, -np.inf)      # cliff to catastrophe

def value_firm(t, D):
    return np.where(t <= D, 1.0, 0.0)         # cliff to zero, then harmless

def value_soft(t, D, tau):
    late = np.clip((t - D) / tau, 0, 1)         # fraction of the decay window used
    return np.where(t <= D, 1.0, 1.0 - late)  # ramp down to zero over tau

# classify the pendant echo: D = 100 ms, tau = 200 ms, arrives at 150 ms
print(value_soft(np.array([150.0]), 100, 200))   # -> [0.75]
# the preview overlay: D = 50 ms, tau = 150 ms, arrives at 125 ms
print(value_soft(np.array([125.0]), 50, 150))    # -> [0.50]

def expected_value(v_late, p):
    return (1 - p) * 1.0 + p * v_late          # on-time worth 1, miss worth v_late

print(expected_value(0.0,  0.02))                # firm -> 0.98
print(expected_value(0.75, 0.02))                # soft -> 0.995  (4x smaller loss)

The only difference between the three functions is what fills the else branch after the deadline: negative infinity, zero, or a ramp. That single line is the taxonomy. There is no library for this because there is nothing to abstract — the value function is the specification, and you write it per subsystem. And expected_value is the one-liner that turns a flavour plus a measured miss rate into the number a budget meeting actually argues over.

The failure, and the metric that reveals it

Symptom: a subsystem occasionally misses its deadline and the whole robot faults out, even though the missed subsystem is "just a video feed" or "just a log." Cause: a firm or soft deadline was wired into a hard-deadline dependency — e.g. the planner blocks waiting for the detection instead of reusing the last target, so a firm miss propagates into a hard-loop stall. The metric that reveals it: a dependency latency trace — timestamp each stage's input-available and output-ready, and look for a stage whose downstream consumer's start time tracks it one-for-one. If the control loop's start time jumps whenever the detector is late, the detector's firm deadline has been given hard consequences by a blocking call. The fix is to make the miss harmless: non-blocking read, last-known-good fallback, a freshness timestamp the consumer checks.

Derivation: how a firm miss becomes a hard stall, with timestamps

"A firm miss propagates into a hard-loop stall" is easy to say and easy to disbelieve — a firm task's misses are supposed to be harmless. Let us trace it on a concrete timeline until the mechanism is undeniable. Two tasks:

Step 1 — the firm miss, in isolation. The detector's bad cycle finishes at t = 45 ms against its 33 ms deadline. It missed by 45 − 33 = 12 ms. By the firm definition that late detection is worth 0 — you should discard it and let the planner reuse the previous target. On its own, this costs one stale frame and nothing else. So far, harmless, exactly as "firm" promises.

Step 2 — the wiring mistake. Suppose the control loop was written to read the target synchronously: target = detector.get_target(), a blocking call that does not return until the detector's current cycle finishes. This one line silently converts the detector's firm deadline into a hard dependency of the control loop.

Step 3 — the stall, timestamped. The control tick at t = 40 ms issues that blocking read. The detector is still busy until t = 45 ms, so the call cannot return until t = 45 ms. That single control cycle therefore takes 45 − 40 = 5 ms against its 1 ms deadline — a stall of 5 / 1 = 5× the control period. The control ticks that should have fired at t = 41, 42, 43, 44 ms never ran: 4 skipped control updates, during which the actuator holds its last torque for the full 5 ms. That is precisely the 4 ms-versus-1 ms overrun from Chapter 0 — only now it is 5 ms, and its root cause is not a slow control loop but a firm task the control loop was told to wait for.

The propagation, in one line: a 12 ms firm miss (harmless by itself) became a 5 ms hard-loop stall only because a blocking read gave it hard consequences. Remove the blocking call — make it a non-blocking read of a last-known-good slot — and the detector may overrun by any amount without the control loop ever stalling: its tick reads whatever target is currently in the slot and returns in microseconds. The flavour of a deadline is not just a property of the task; it is a property of how its consumer waits on it.

The inverse mistake: a soft task starving a hard one

The blocking-read bug hides a firm task inside a hard loop. The inverse mistake is just as common and just as timestampable: giving a soft task higher priority than a hard one on a shared core, so the soft task can preempt and starve the loop that actually matters. Trace it:

The stall, timestamped. The soft burst starts at t = 10.0 ms and holds the single core until t = 12.5 ms. Because it outranks the control loop, the hard ticks scheduled at t = 10, 11, and 12 ms cannot run until the burst releases the CPU at 12.5 ms. That is 3 consecutive missed control deadlines: the tick due at t = 10 runs 12.5 − 10 = 2.5 ms late, the one due at t = 11 runs 1.5 ms late, the one due at t = 12 runs 0.5 ms late — every one of them past its 1 ms deadline. A soft task with no business near the control loop just caused three hard misses, and the CPU was only 30 % loaded the whole time. Utilisation looked healthy; the priority order was the bug.

The fix and why it works: invert the priorities so the hard loop preempts the soft flush. Now each 1 ms period the control loop takes its 0.3 ms first and the flush gets the remaining 0.7 ms — so the 2.5 ms of flush work stretches to 2.5 / 0.7 ≈ 3.6 ms of wall-clock, and the control loop never misses a deadline. The soft task simply finishes a little later, which is exactly what "soft" permits. The rule this hands you — hard tasks must out-prioritise soft ones on shared hardware — is the seed of rate-monotonic scheduling, which Chapter 11 makes rigorous. Here it is enough to see that a flavour without a matching priority is a spec you did not implement.

Frontier: mixed-criticality systems

Real robots and vehicles run hard, firm, and soft tasks on the same hardware — you cannot afford a separate computer per criticality level. The research field that studies this is mixed-criticality scheduling, launched by Vestal's 2007 paper "Preemptive Scheduling of Multi-Criticality Systems with Varying Degrees of Execution Time Assurance" (RTSS 2007). Its core idea: a high-criticality task is analysed with a pessimistic (large) worst-case execution time, a low-criticality one with an optimistic estimate, and the scheduler is allowed to shed low-criticality work if a high-criticality task overruns its optimistic budget. This is the formal version of "when the safety monitor needs the CPU, drop the video frame" — and it is why the DO-178C (avionics) and ISO 26262 (automotive) safety standards care so much about how you partition tasks by criticality. We list the standards in the Field Guide.

A 30 Hz object detector occasionally produces a detection 10 ms late. The planner is written to reuse the previous grasp target whenever a fresh one has not arrived, and nothing else depends on that specific frame. What kind of deadline is this, and what does a miss cost?

Chapter 2: Latency, Jitter, WCET

Chapter 1 gave us the deadline and what a miss costs. This chapter gives us the three numbers that tell us whether we will miss it. They are constantly confused — people say "latency" when they mean "jitter," or quote a mean when they need a worst case — and every one of those confusions has tripped a real robot. We will define each precisely, work an example by hand, then build the measurement in code.

Three numbers, one signal

Take a single repeating operation — one iteration of a control loop — and measure how long it takes on each of many repetitions. That stream of durations is your raw signal. Three summaries of it matter:

The relationship, in one line: latency is the signal, jitter is its spread, and WCET is the top of its support. A schedulability proof compares WCET to the deadline; a stability proof cares about jitter; and the mean — the number everyone reports — is used in exactly neither. That is why the mean is a liar for real-time work.

WCET vs observed max: why a short soak cannot see the ceiling

The WCET bullet said the observed max is only a lower bound on the true worst case. That is not a hedge — it is arithmetic, and the arithmetic tells you exactly how long you must run to observe a rare excursion at all. Suppose the event that violates your deadline happens once in a million cycles — a probability p = 10−6 per cycle. How many times do you expect to see it in a soak of length N? The expected count is simply N · p.

Short soak, N = 10,000 cycles. The expected number of occurrences of the once-in-a-million event is:

N·p = 10,000 · 10−6 = 0.01

You expect to see the event about once every hundred such runs. Ninety-nine soaks out of a hundred finish with a clean bill of health and never touched the failure at all. The observed max is the max of the healthy cluster; the true WCET is nowhere in the sample.

Long soak, N = 10,000,000 cycles. Same product, three orders of magnitude more cycles:

N·p = 10,000,000 · 10−6 = 10

Now the event shows up about ten times, so the observed max finally has a chance to reach into the tail. The rule of thumb falls straight out: to observe a 1-in-k event with any reliability you must run at least a few times k cycles — here a few times 106, which is why 107 is the honest floor and 104 is theatre.

The same fact wrecks deep percentiles on short runs. A percentile is a sorted index (we prove this below), so p99.99 on N samples sits at fractional rank r = (99.99/100)·(N−1). On a 10,000-cycle run:

rp99.99 = 0.9999 · 9,999 = 9,998.0001  (a rank of 9,998 out of a 0–9,999 index — the single top-two observations)
rp99.9 = 0.999 · 9,999 = 9,989.001  (essentially the tenth-from-top sample)

There is no such thing as a stable p99.99 in 10,000 samples: the index it would need does not correspond to a repeatable event, so the number it returns is whatever noise happened to land on top this run.

The reciprocal rule: to see a 1-in-k tail, soak for several times k cycles; to estimate a percentile at the (1−1/k) level with confidence, you want on the order of 100×k samples so many observations, not one, sit in the tail. Report any deep percentile with the sample count behind it — "p99.9 = 4000 µs (rank 9,989 of 10,000, one observation)" is honest; "p99.9 = 4000 µs" alone invites a false sense of a ceiling.

Turn that rule into a wall-clock soak time, because that is the number a test plan actually schedules. Our arm's torque loop runs at 1 kHz, so it produces exactly 1000 cycles every second. Suppose the requirement is to certify that a deadline-violating stall happens less than once in a million cycles. To observe such an event with any stability — say around ten occurrences — the reciprocal rule demands N ≈ 10×106 = 107 cycles. Convert to time by dividing by the rate:

t = 107 cycles / 1000 cycles-per-second = 10,000 s = 2.78 hours

If instead you follow the stricter "run for at least 100× the reciprocal of the fault rate" habit — so the estimate rests on ~1000 tail draws, not ten — the soak grows to N = 100×106 = 108 cycles:

t = 108 / 1000 = 100,000 s ≈ 27.8 hours ≈ 1.16 days

Now put the short bench beside it. A 60-second smoke test at 1 kHz collects

N = 60 s × 1000 cycles/s = 60,000 cycles  →  expected events = 60,000 × 10−6 = 0.06
runs per observed failure ≈ 1 / 0.06 ≈ 17

An expected count of 0.06 means you see the failure in roughly 1 run in 17, and the other sixteen ship a green light over a real defect. The gulf between "0.06 expected events in a minute" and "10 expected events in three hours" is the entire reason field units miss deadlines that the CI bench swore were impossible: the bench was three orders of magnitude too short to have met the fault even once.

Why p99.9 and not the mean — derived

Here is the argument that makes the whole lesson click. Suppose your control loop runs at 1 kHz — 1000 cycles per second, 3.6 million cycles per hour. Suppose a rare stall makes one cycle in a thousand come in late. How often does the robot meet a late cycle?

Do the wall-clock arithmetic explicitly. One late cycle in a thousand, at a thousand cycles per second, is one late cycle every second:

(1 late / 1000 cycles) × (1000 cycles / second) = 1 late / second

Scale that up to an hour and a day:

1 / second × 3600 s/hour = 3,600 / hour
3,600 / hour × 24 hours = 86,400 / day

So if a late cycle can trip the robot, "it only happens 0.1 % of the time" means it happens about 86,400 times a day. Rarity in percentage terms is not rarity in wall-clock terms when the loop runs fast — the fast loop turns a tiny per-cycle probability into a large daily count.

Now ask which statistic sees that 0.1 % event. The mean averages it in with 999 healthy cycles, so it barely moves. The median (p50) is the middle cycle — it is defined to ignore the top half entirely, so it never sees the tail. The p99 — the value exceeded by 1 % of cycles — sees events at or above the 1-in-100 rate, so a 1-in-1000 event is still in its tail. Only the p99.9 — the value exceeded by 0.1 % of cycles — lands exactly where the 1-in-1000 stall lives.

The rule of thumb: quote the percentile that matches your fault rate. If a fault happens once per thousand cycles, the p99.9 is the number that describes it. If once per million, you need p99.9999. The mean is the answer to a question nobody in real-time ever asks: "how long does a cycle take if I blend all of them together?"

The rule is a one-line formula, not a memorised table. A fault that happens at rate f per cycle lives in the top f fraction of cycles, so the percentile that lands on it is the one below which the remaining 1−f of cycles fall:

percentile = 100 × (1 − f)

Turn the crank for the fault rates a 1 kHz loop actually meets, and also read how often each fault strikes in wall-clock time (rate×1000 per second):

Fault rate fPercentile 100(1−f)Wall-clock rate at 1 kHz
1-in-10 = 0.1100(1−0.1) = p90100 / second
1-in-100 = 0.01100(1−0.01) = p9910 / second
1-in-1000 = 0.001100(1−0.001) = p99.91 / second → 86,400 / day
1-in-106100(1−10−6) = p99.99990.001 / second → 86.4 / day

Two things jump out. First, the "9"s in the percentile name are literally counting the zeros in the fault rate — a 1-in-103 fault needs a percentile with three nines (p99.9), a 1-in-106 fault needs six (p99.9999). Second, even the rarest row here — a one-in-a-million stall — still strikes about 86 times a day on a loop that runs a thousand times a second. That is the whole reason we chase these deep percentiles at all: on fast loops, "astronomically rare per cycle" is still "several times an hour" on the wall clock.

Where each percentile lands

The loop-time distribution from Chapter 0: a tight healthy cluster and a thin tail of preempted cycles. The vertical markers show where mean, p50, p99, and p99.9 fall. Drag the tail rate to change how often a cycle stalls, and the spike size to change how bad each stall is — watch how the spike size moves only the deep-tail markers (and drags the mean a little) while the median never budges.

Tail rate 1.0%
Spike size 4000 µs

At 0.1 % tail, only the p99.9 marker is out in the tail — every other statistic sits in the healthy cluster, blind. Push the tail rate to 1 % and the p99 marker jumps out too; at 5 % even the p99 is deep in the tail and the mean finally starts to drift. The lesson is mechanical: a percentile only sees a tail event once the event is more common than one minus that percentile.

Worked example: percentiles of ten samples, by hand

Take the ten sorted loop times from Chapter 0 again, in microseconds:

980, 990, 995, 1000, 1005, 1010, 1015, 1020, 1030, 4000

The standard "linear interpolation" percentile (what numpy.percentile uses by default) maps a percentile q to a fractional rank r = (q/100)·(n−1) into the sorted array, and interpolates between the two nearest samples. With n = 10, so n−1 = 9:

p50 (the median), step by step. First the fractional rank, then the bracketing indices, then the interpolation:

r = 0.50 · 9 = 4.5
lo = ⌊4.5⌋ = 4 → s[4] = 1005,   s[5] = 1010
p50 = 1005 + (4.5 − 4)·(1010 − 1005) = 1005 + 0.5·5 = 1007.5 µs

The median lands calmly in the healthy cluster, completely blind to the 4000 µs straggler.

p90, step by step. The rank now climbs to the very edge of the tail:

r = 0.90 · 9 = 8.1
lo = ⌊8.1⌋ = 8 → s[8] = 1030,   s[9] = 4000
p90 = 1030 + (8.1 − 8)·(4000 − 1030) = 1030 + 0.1·2970 = 1030 + 297 = 1327 µs

At 0.1 of the way into the last gap, p90 has already leaked 297 µs of the straggler's pull into its value — the tail has begun to bite.

The mean, for contrast. Sum all ten and divide:

mean = (980 + 990 + 995 + 1000 + 1005 + 1010 + 1015 + 1020 + 1030 + 4000) / 10 = 13,045 / 10 = 1304.5 µs

Notice the mean (1304.5) is lower than the p90 (1327) here — the single 4000 outlier pulls the mean up above the bulk, but the p90 index sits right against that outlier and inherits almost all of it. The two "typical" numbers disagree by roughly 300 µs, which is your first sign the distribution is not the bell curve your intuition assumes.

Why ten samples cannot hold a p99. Try to push the rank deeper and the reason falls out. Both p99 and p99.9 land in the very last gap, between index 8 (1030) and index 9 (4000):

rp99 = 0.99 · 9 = 8.91 → p99 = 1030 + 0.91·(4000 − 1030) = 1030 + 0.91·2970 = 3732.7 µs
rp99.9 = 0.999 · 9 = 8.991 → p99.9 = 1030 + 0.991·2970 = 3973.27 µs

The two ranks differ by only 8.991 − 8.91 = 0.081, so p99 and p99.9 are almost the same number and both are just the lone 4000 µs straggler read at slightly different fractions. There is nothing to interpolate against in the tail because there is only one tail sample. This is exactly why the next section moves to twenty samples with two tail cycles — you need at least a couple of points in the tail before a deep percentile means anything.

That is the entire idea of a percentile: a sorted index, with interpolation between neighbours. Once you see that, it is obvious why the mean cannot recover a percentile — the mean throws away the ordering, and the ordering is the whole point.

Deep-tail percentiles by hand: p99 and p99.9 on twenty samples

Ten samples cannot even hold a p99 — the top 1 % of ten cycles is a tenth of a cycle, so p99 collapses onto the single largest value. To watch the deep-tail interpolation the Code Lab automates, we need a longer vector. Here are twenty sorted loop times in microseconds: eighteen healthy cycles and two preempted ones.

980, 985, 990, 992, 995, 998, 1000, 1002, 1005, 1008, 1010, 1012, 1015, 1018, 1020, 1025, 1030, 1040, 3800, 4200

Now n = 20, so n−1 = 19, and the sorted indices run 0 (value 980) through 19 (value 4200). Index 18 is 3800 and index 19 is 4200 — the two tail cycles. Apply the same fractional-rank rule r = (q/100)·(n−1):

p50. The rank is right in the middle, far from either tail cycle:

r = 0.50 · 19 = 9.5
lo = ⌊9.5⌋ = 9 → s[9] = 1008,   s[10] = 1010
p50 = 1008 + 0.5·(1010 − 1008) = 1008 + 1 = 1009 µs

Squarely in the healthy cluster, blind to the tail — exactly as a median should be.

p90. The rank has just crossed into the gap between the top healthy cycle (index 17) and the first spike (index 18):

r = 0.90 · 19 = 17.1
lo = ⌊17.1⌋ = 17 → s[17] = 1040,   s[18] = 3800
p90 = 1040 + 0.1·(3800 − 1040) = 1040 + 0.1·2760 = 1040 + 276 = 1316 µs

p90 sits in no-man's-land — higher than any healthy cycle, lower than any real preemption — because its rank fell in the wide gap between cluster and tail.

p99. Now the rank pushes past index 18, into the interval spanning the two spike cycles:

r = 0.99 · 19 = 18.81
lo = ⌊18.81⌋ = 18 → s[18] = 3800,   s[19] = 4200
p99 = 3800 + 0.81·(4200 − 3800) = 3800 + 0.81·400 = 3800 + 324 = 4124 µs

With two spikes in twenty cycles (a 10 % tail), the 1-in-100 value has fully entered the preemption band.

p99.9. Deeper still into the same top interval — the rank barely moves, but it moves toward the maximum:

r = 0.999 · 19 = 18.981
p99.9 = 3800 + 0.981·(4200 − 3800) = 3800 + 0.981·400 = 3800 + 392.4 = 4192.4 µs

Almost the raw maximum, because at n = 20 the p99.9 index (18.981) is only 0.019 below the very last sample.

Read the four numbers in order — 1009, 1316, 4124, 4192 — and you can see the rank climb out of the cluster (p50), cross the gap (p90), and then walk up the two tail samples (p99, p99.9). That walk is the interpolation arithmetic; the Code Lab below does exactly this at n = 1000, where the deep-tail rank finally rests on many spike observations instead of two, which is why its p99.9 is a stable estimate and this one is not.

Jitter by hand: peak-to-peak and standard deviation

We defined jitter as the spread of the latency distribution, quoted two ways — peak-to-peak (max − min) or standard deviation. Prose is not a realization; let us put numbers on both for one small set. Take five consecutive loop times, in microseconds:

1000, 1005, 995, 1010, 990

Peak-to-peak is the crudest jitter measure and the easiest by hand: the largest minus the smallest. Here max = 1010, min = 990, so peak-to-peak jitter = 1010 − 990 = 20 µs. It is a pure worst-case-of-this-sample number: one wild outlier sets it entirely, which is both its virtue (it catches the single bad cycle a stddev would average away) and its flaw (it is unstable and grows as you collect more samples).

Standard deviation is the smoother measure. Compute it in five steps — mean, deviations, squared deviations, variance, then the root:

Step 1, the mean (a round number here on purpose, so the deviations stay clean):

mean = (1000 + 1005 + 995 + 1010 + 990) / 5 = 5000 / 5 = 1000 µs

Step 2, subtract the mean from each sample to get the deviations:

1000−1000 = 0,   1005−1000 = +5,   995−1000 = −5,   1010−1000 = +10,   990−1000 = −10

Step 3, square each deviation and sum them:

0² + 5² + (−5)² + 10² + (−10)² = 0 + 25 + 25 + 100 + 100 = 250

Step 4, the variance is the average squared deviation (population form, dividing by n = 5):

variance = 250 / 5 = 50 µs²

Step 5, the standard deviation is the square root of the variance:

σ = √50 = √(25·2) = 5√2 ≈ 7.07 µs

Notice the two jitter numbers disagree — 20 µs peak-to-peak versus 7.07 µs standard deviation — and that disagreement is the whole subtlety. The stddev has divided the energy of the two ±10 extremes across all five samples; peak-to-peak reports the extreme raw. For control stability (Chapter 3) the peak matters more than the average, because a single Δt excursion is what the controller was never designed for — which is exactly why a real-time report should carry both a spread number and a worst-case number, never just one.

Same mean, different jitter: a set like 999, 1000, 1000, 1000, 1001 has the identical 1000 µs mean but peak-to-peak of only 2 µs and stddev √((1+0+0+0+1)/5) = √0.4 ≈ 0.63 µs — an order of magnitude tighter, from the same average. The mean cannot tell these two loops apart; jitter is precisely the axis it is blind to.

Work that tight second set through the same recipe so the contrast is not just asserted. Its mean is (999 + 1000 + 1000 + 1000 + 1001)/5 = 5000/5 = 1000 µs — identical to the first set. But its deviations are only −1, 0, 0, 0, +1, so:

sum of squared deviations = 1 + 0 + 0 + 0 + 1 = 2
variance = 2 / 5 = 0.4 µs²  →  σ = √0.4 ≈ 0.63 µs

Set both results beside each other: same 1000 µs mean, but σ = 7.07 µs for the first loop versus σ = 0.63 µs for the second — an 11× difference in jitter that the mean is completely blind to. A report that quotes only the mean would call these two loops identical; a control engineer would call one a time bomb and the other rock-solid.

One more number falls straight out of the standard deviation, and it explains why even the honest statistics need a sample count. The standard error of the mean — how much the estimate of the mean itself wobbles across repeated runs — is the standard deviation divided by √N. With our σ ≈ 7.07 µs, watch it shrink as the soak lengthens:

SEM(5) = 7.07 / √5 = 7.07 / 2.236 = 3.16 µs
SEM(500) = 7.07 / √500 = 7.07 / 22.36 = 0.316 µs
SEM(50,000) = 7.07 / √50,000 = 7.07 / 223.6 = 0.0316 µs

Read the pattern: to pin the mean down to ±0.1 µs you need SEM ≤ 0.1, which means N ≥ (7.07 / 0.1)² = 70.7² ≈ 5000 samples. That is the reassuring case — the √N law makes the mean converge, and a few thousand samples nail it. The unsettling contrast is the tail: while the mean's error falls like 1/√N, the observed maximum only ever rises as you collect more samples, because each new draw can only push the max up, never down. That asymmetry — the mean converges, the max diverges upward — is the deep reason a benchmark that is long enough to trust its mean can still be far too short to trust its worst case.

Here is the whole jitter-and-confidence calculation as a small function, so you can reproduce every number above from scratch rather than trusting the prose:

python
import numpy as np

def jitter_stats(x):
    x = np.asarray(x, float)
    ptp   = x.max() - x.min()          # peak-to-peak: the raw worst-case spread
    sigma = x.std()                    # population std (ddof=0): the smoothed spread
    sem   = sigma / np.sqrt(len(x))   # standard error of the MEAN estimate
    return ptp, sigma, sem

loop = [1000, 1005, 995, 1010, 990]
ptp, sigma, sem = jitter_stats(loop)
print(ptp, sigma, sem)          # -> 20.0   7.071   3.162  (matches the hand derivation)

# how many samples to pin the mean to +-0.1 us?  SEM = sigma/sqrt(N) <= 0.1
n_needed = int(np.ceil((sigma / 0.1) ** 2))
print(n_needed)                 # -> 5000

# the WCET margin check: torque loop C=350,T=1000 preempted by a monitor C=120,T=500
acts = int(np.ceil(1000 / 500))       # activations of the monitor in one torque period
interference = acts * 120          # -> 2 * 120 = 240 us
print(350 + interference <= 1000)  # -> True, slack 410 us

The three-line jitter_stats is the entire realization of this chapter's spread numbers: ptp is the raw worst case, sigma is the smoothed spread, and sem is how much the mean itself is still guessing. There is no deeper library call — peak-to-peak is a subtraction, the standard deviation is the root of the average squared deviation, and the standard error just divides by √N. Every number the prose derived by hand comes back out of this function unchanged.

Design: the WCET margin, in numbers

A hard-deadline task's design rule is one inequality: WCET ≤ deadline − interference. Put numbers on it for the arm's torque loop. The compute WCET is 350 µs (measured max, plus a safety factor to bound the true worst case). The deadline is the 1000 µs period. The interference — the worst-case time the loop can be preempted by higher-priority work — is what the design must bound. If the logger can preempt for 800 µs, then 350 + 800 = 1150 > 1000: the inequality fails, and you have a design defect that will show up as exactly the once-a-second trip from Chapter 0. Fix it by lowering the interference (move the logger to a lower priority, cap its burst, run it on another core) until 350 + interference ≤ 1000, i.e. interference ≤ 650 µs.

That was the loop against a single interferer. Real cores host several tasks, and "interference" is not a number you guess — it is a number you compute from who can preempt whom. Take three periodic tasks on one core, each with a worst-case compute time C and a period T:

TaskPriorityC (µs)T (µs)
safety monitorhigh120500
torque loopmedium3501000
loggerlow8008000

Step 1 — per-task utilisation, then the total. Each task's utilisation is its compute over its period — the fraction of the CPU it consumes on average:

Umonitor = 120 / 500 = 0.24
Utorque = 350 / 1000 = 0.35
Ulogger = 800 / 8000 = 0.10
U = 0.24 + 0.35 + 0.10 = 0.69

Step 2 — the rate-monotonic bound. For n tasks scheduled by rate-monotonic priorities (shorter period = higher priority), Liu & Layland's sufficient bound is n·(21/n − 1). For n = 3:

bound = 3·(21/3 − 1) = 3·(1.2599 − 1) = 3·0.2599 = 0.7798

Since U = 0.69 ≤ 0.78, the set is guaranteed schedulable by this test — every task will meet its deadline. But that guarantee is average utilisation; it says nothing about the torque loop's worst single period, which is what a hard deadline actually cares about. For that we go back to the margin inequality and compute the torque loop's interference explicitly.

Step 3 — interference from higher-priority work, by activation count. Only tasks of higher priority than the torque loop can preempt it: here, just the safety monitor. In one torque-loop period of 1000 µs, how many times can a 500 µs-period monitor fire? The ceiling of the period ratio:

activations = ⌈1000 / 500⌉ = 2

Each activation costs its own worst-case compute of 120 µs, so the worst-case interference the monitor can inject into one torque period is:

interference = 2 × 120 = 240 µs

Step 4 — the margin check. Substitute into WCET + interference ≤ deadline:

350 + 240 = 590 ≤ 1000  →  slack = 1000 − 590 = 410 µs

The loop survives with 410 µs to spare — as long as only the safety monitor preempts it. Now expose the defect from the single-interferer version: if a priority inversion lets the low-priority logger's 800 µs burst run inside the torque period (a held mutex, a non-preemptible section), the sum becomes

350 + 240 + 800 = 1390 > 1000  (over by 390 µs)

and the loop misses. The utilisation test passed (0.69 ≤ 0.78) yet the loop still misses — because a mean-based test cannot see a worst-case preemption stack-up. The fix is a cap: the logger's burst inside any torque period must satisfy 350 + 240 + X ≤ 1000, i.e.

X ≤ 1000 − 350 − 240 = 410 µs

The same slack number answers a second design question: how much can the torque loop's own WCET grow — from added filtering, a new safety check — before it breaks, given only the legitimate 240 µs of monitor interference? Solve WCET + 240 ≤ 1000:

WCET ≤ 1000 − 240 = 760 µs

So the torque compute has headroom from its current 350 µs up to 760 µs — a 410 µs budget you can spend on features, and the exact same 410 µs the logger was about to steal. That single slack figure is the whole margin: spend it on the logger and the loop misses; spend it on the loop's own work and you are fine. The inequality just tells you it is one budget, not two.

The two-number habit: a schedulability test (utilisation ≤ bound) tells you the set is usually fine; a margin inequality (WCET + interference ≤ deadline) tells you the hard task survives its worst period. Ship both. The gap between them — U = 0.69 says "fine" while the interference stack-up says "missed" — is exactly the mean-vs-tail gap from the top of this chapter, wearing a scheduling hat.

The failure, and the metric that reveals it

Symptom: a benchmark reports a great mean and a great p99, the team ships, and the field units miss deadlines. Cause: the benchmark did not run long enough to observe the tail. To see a 1-in-a-million event with confidence you need tens of millions of samples; a 10,000-cycle benchmark has a p99.9 that is pure noise and a p99.99 that does not exist. The metric that reveals it: the sample count behind each percentile, and the percentile's confidence interval. A p99.9 computed from 1000 samples rests on a single data point — report it with its rank (1 of 1000) so the reader knows it is one observation, not a stable estimate. The fix is to run the measurement for at least 100× the reciprocal of the fault rate you care about, and to prefer the observed worst case over a long soak to any interpolated deep percentile from a short run.

Make the "confidence interval shrinks" claim concrete — you can reproduce this yourself. A p99.9 estimate is really a count of how many samples land in the top 0.1 %, and the noise on that count follows the binomial standard deviation √(N·p·(1−p)) with p = 0.001. Compare a short run to a long one:

Before — N = 1000 samples. First the rank the p99.9 interpolates against, then the count of tail samples and its noise:

rank = 0.999·(1000 − 1) = 0.999·999 = 998.001  (essentially the single top observation)
expected tail count = 0.001·1000 = 1
sd of that count = √(1000·0.001·0.999) ≈ √0.999 ≈ 1.0  →  relative spread ≈ 1.0/1 = 100 %

Whether you saw the fault zero, one, or two times is pure luck; the p99.9 you print swings wildly run to run.

After — N = 1,000,000 samples. The same three quantities, now with a thousand-fold longer soak:

rank = 0.999·(106 − 1) = 998,999  (resting on many tail observations)
expected tail count = 0.001·106 = 1000
sd of that count = √(106·0.001·0.999) ≈ √999 ≈ 31.6  →  relative spread ≈ 31.6/1000 = 3.2 %

The estimate has stopped rattling because a thousand independent tail draws, not one, now define it. The relative spread fell from ~100 % to ~3.2 % purely from running 1000× longer — a factor of √1000 ≈ 31.6, exactly the √N law. That is the metric to reproduce and to report: not just the percentile, but the number of tail observations behind it and how much the estimate wobbles across repeated soaks.

Frontier: static WCET analysis

Measuring the worst case can never prove the worst case — you only ever observe a lower bound. For hard-real-time avionics, the field instead computes a provable upper bound by static analysis of the binary, modelling the pipeline and caches. The canonical tool is aiT from AbsInt, built on the abstract-interpretation framework of Wilhelm et al.'s survey "The Worst-Case Execution-Time Problem — Overview of Methods and Survey of Tools" (ACM TECS, 2008). The hard part is not the arithmetic; it is that modern CPUs have caches, branch predictors, and out-of-order execution that make the worst path depend on history in ways that are exponential to enumerate — which is exactly why safety-critical robotics often chooses simpler, more predictable processors (or a bare-metal microcontroller) over a fast but timing-opaque application core. We name the tools in the Field Guide.

A 1 kHz loop stalls on 1 cycle in 1000. Which statistic, computed over a long run, lands exactly on that stall's magnitude?

Chapter 3: Fixed-Rate Loops

We now have the deadline taxonomy and the statistics. This chapter answers the question a fresh engineer always asks: why not just run the control loop as fast as the CPU allows, best-effort, instead of pinning it to a fixed rate? It sounds efficient. It is, in fact, the direct cause of the Chapter 0 trip. A control loop's rate is a fixed physical constant of the closed-loop system, not a scheduling convenience — and running it best-effort silently changes the math the controller was designed around.

A controller is designed at one specific rate

Here is the piece most software engineers have never been told. When a control engineer designs a discrete controller — a PID, a state-feedback law, a filter — they design it for a specific sample interval Δt. Every gain in the controller is a number that depends on Δt. Change Δt and you have silently changed the controller into a different one, which may no longer be stable.

Take the simplest case: a derivative term. Velocity is estimated as

v̂ = (θnow − θlast) / Δt

where θ is the joint angle. The code divides by the Δt it was told to assume — the nominal period — not the Δt that actually elapsed. If the loop was late and the true interval was 1.3 ms but the code divides by 1.0 ms, the velocity estimate is off by the ratio 1.3/1.0 = 1.3: a 30 % overestimate. Work it: a genuine angle change of 0.5 rad over a true 1.3 ms is a velocity of 0.5/0.0013 = 385 rad/s, but the code computes 0.5/0.001 = 500 rad/s. The derivative gain then commands a braking torque 30 % too large, and the arm jerks. A 3 ms straggler like Chapter 0's produces a 4× error — more than enough to trip the safety envelope.

The trap named: the controller's gains encode Δt. A best-effort loop delivers a different Δt every cycle, so every gain is silently wrong by the ratio of actual-to-assumed interval. The fix is either to hold Δt truly fixed (a real-time loop) or to feed the measured Δt into the control law every cycle (a gain-scheduled loop) — but you must do one of them, and best-effort code does neither.

The deeper reason: stability is not linear in Δt

It gets worse than a scaled gain. For many discrete controllers, stability itself depends on Δt in a convex way — small Δt is stable, large Δt is unstable, and the boundary is a hard edge. When the loop jitters, some cycles run with a small Δt (comfortably stable) and some with a large Δt (unstable). The stable cycles pull the state toward zero; the unstable ones push it away. Whether the loop survives is decided by the average growth rate, and because the growth rate is convex in Δt, jittering Δt around a fixed mean makes the average growth worse than running at that mean — even though the mean Δt is unchanged.

This is the same "the mean lies" theme as Chapter 2, now for stability. A best-effort loop whose mean rate matches the design rate can still diverge, because the tail of large-Δt cycles injects energy the small-Δt cycles cannot remove. Formally: if the per-step growth factor ρ(Δt) is convex, then by Jensen's inequality the average of ρ over a jittered Δt exceeds ρ at the mean Δt. Sit the controller right at its stability edge, where ρ(mean) = 1, and any jitter tips the average above 1 and the loop diverges. We will watch exactly this happen in code.

Fixed rate holds; jitter diverges — same mean rate

A controller tuned to sit at its stability edge for a fixed 20 ms interval. The fixed-rate loop (every step exactly 20 ms) stays bounded. The jittered loop has the same mean interval but each step wobbles ±40 %. Drag the jitter up and watch the red trajectory blow up while the teal one holds — the mean rate never changed.

Jitter (±% of Δt) 40%

Set the jitter to zero and the two curves are identical — both are the fixed-rate loop, both bounded. Raise it and the red curve, whose mean interval is still exactly 20 ms, climbs away from zero and diverges. That is the entire argument against best-effort control in one picture: identical average rate, opposite fate, decided by the tail.

Design: will three tasks fit? The utilisation test

Fixing the rate is necessary but not sufficient — you also have to prove the tasks fit on the CPU. The tool is CPU utilisation. For a task with worst-case execution time Ci and period Ti, its utilisation is Ui = Ci/Ti — the fraction of the CPU it needs. Sum them for total utilisation U = ∑i Ci/Ti.

For rate-monotonic scheduling (fixed priorities, shortest period highest priority), Liu and Layland's 1973 bound says n tasks are guaranteed schedulable if U ≤ n(21/n − 1). Work a concrete case — three tasks on one core:

TaskWCET CPeriod TU = C/T
torque loop200 µs1000 µs0.200
state estimator300 µs2000 µs0.150
telemetry400 µs4000 µs0.100

Total U = 0.200 + 0.150 + 0.100 = 0.450. The RMS bound for n = 3 is 3·(21/3 − 1) = 3·(1.2599 − 1) = 3·0.2599 = 0.780. Since 0.450 ≤ 0.780, the three tasks are guaranteed to meet their deadlines under rate-monotonic scheduling. Notice the CPU is only 45 % busy on average — the utilisation test is not about average business, it is about whether the worst-case arrivals can still be served in time.

Why the bound is below 100 %: at U just under the bound you have proof of no missed deadline; above the bound you might still be fine, but you have no guarantee — you'd need the exact response-time analysis. The gap between 78 % and 100 % is the price of the guarantee. Chapter 0's system had a fine utilisation and still missed, because the logger's 800 µs burst was interference the utilisation sum did not capture — a reminder that utilisation is necessary, not sufficient.

Ten misses per thousand cycles — and the mean compute load is 300 µs, a comfortable 30 % of the period. The average says the CPU is nearly idle. The deadline misses come entirely from the coincidence of the preemption, which the average cannot see. This is Chapter 0's once-a-second trip, reduced to eight lines you can count.

Read the two outputs. Both schedules average to a 20 ms interval — the fixed one exactly, the jittered one to four decimal places. The fixed loop stays bounded; the jittered loop, with identical average rate, diverges by a factor of a hundred. If you ran this controller best-effort and looked only at the mean loop rate, every dashboard would say "on target" while the arm shook itself apart. That is why a control loop's rate is fixed, not best-effort.

The failure, and the metric that reveals it

Symptom: a control loop is unstable or noisy in the field but perfectly stable in a bench test on the same code. Cause: the bench ran on an idle machine with tight timing; the field unit shares the CPU and jitters, so the effective Δt varies and the controller wanders past its stability edge — or the derivative term divides by the nominal Δt instead of the measured one. The metric that reveals it: a histogram of the actual inter-cycle interval Δt (the timestamp difference between consecutive loop starts), overlaid with the Δt the controller assumes. A fixed-rate loop shows a spike at the nominal interval; a jittery one shows a wide spread with a fat right tail. The instant give-away: plot the loop's Δt jitter next to the control error and see the error spike land on the same cycles as the Δt excursions. Fix: pin the loop to a real-time thread with a periodic wakeup, and feed the measured Δt into the control law rather than assuming the nominal one.

Frontier: analysing jitter's effect on control

The formal study of how timing jitter degrades control performance is a whole subfield. The landmark is Cervin, Åström, and colleagues' Jitterbug and TrueTime tools (Lund University, early 2000s; see Cervin et al., "How Does Control Timing Affect Performance? Analysis and Simulation of Timing Using Jitterbug and TrueTime," IEEE Control Systems Magazine, 2003). They let you compute the exact performance loss from a given latency-and-jitter distribution, turning "jitter is bad" into a number you can trade against CPU cost. The modern descendant is the control–scheduling co-design literature, which designs the controller and the schedule together so the controller is robust to the jitter the schedule actually produces — rather than pretending the schedule is perfect. We point to the tools in the Field Guide.

Two runs of the same controller use sample intervals with the identical mean rate: one perfectly fixed, one jittered ±40 %. The fixed run is stable; the jittered run diverges. Why?

Chapter 4: Timeline Showcase

This is the payoff. Everything in the lesson — the deadline, the tail, the jitter, the miss — is one live instrument now. A fixed grid of deadline ticks marches left to right. Each control cycle is a dot that should land on its tick. Inject jitter and the dots start to wobble off their ticks; inject preemption and some dots slip clean past the deadline line and turn red. Beside the timeline, a histogram of loop times updates in real time with the p50, p99, and p99.9 markers you now know how to read.

Play with it until it breaks. The whole point is that you can dial a system from healthy to failing and watch which number moves first. The mean barely stirs. The p99.9 leaps. The red misses appear. This is what "the tail is where robots break" looks like as a moving picture.
Live control-loop timeline — jitter it until it misses

Each dot is one control cycle; it should land on a green deadline tick. Jitter spreads the dots around their ticks. Preemption rate injects rare long cycles — dots that slip past the red deadline line become misses. The histogram on the right tracks the loop-time distribution with p50, p99, p99.9 and the mean. Watch which one moves.

Jitter 15%
Preemption rate 0.8%

Start with jitter at 15 % and preemption at 0.8 %. The dots wobble gently, and every few seconds one slips red. Read the histogram: the mean and p50 sit calmly at the nominal period while the p99.9 marker lives out in the tail with the preempted cycles. Now crank the preemption rate: the red misses multiply, the tail of the histogram fattens, the p99.9 marches right — and the mean still barely moves until the misses are frequent. You are watching, in real time, the exact reason a mean-based dashboard is blind to the fault that trips the robot.

Trace one cycle through the instrument, by hand

The instrument is not magic; it is the arithmetic of Chapters 0–2 running sixty times a second. Every constant it uses is named in the code: the nominal loop is SHOW_NOMINAL = 700 µs, the deadline is SHOW_PERIOD = 1000 µs, jitter is symmetric with amplitude jitAmp = jit × 700 × 0.4, and a preemption adds a one-sided stall of +1800…3300 µs. Let us push one nominal cycle and one preempted cycle through it and predict, on paper, exactly what the readout will say.

The healthy cycle. Take the default jitter of 15 %. The jitter amplitude is

jitAmp = 0.15 × 700 × 0.4 = 42 µs

so this cycle draws a loop time somewhere in 700 ± 42 = 658…742 µs. Say the random draw lands at 720 µs. Compare to the deadline: 720 < 1000, so miss = false.

On the timeline this dot renders teal, sitting comfortably inside the on-time band; in the histogram it drops into a bin left of the green deadline line. The widest this cycle could ever be at 15 % jitter is 742 µs — still 258 µs of clearance.

This is the whole reason jitter alone rarely misses: symmetric jitter at any sane amplitude stays inside the 300 µs headroom the nominal loop leaves. It shifts a dot left or right of its tick; it does not launch it over the deadline.

The preempted cycle. Now suppose the preemption coin comes up (probability 0.8 %) and the stall draws 2500 µs out of its 1800–3300 range. The loop time is

t = 700 + 2500 = 3200 µs

Against the 1000 µs deadline that is

3200 / 1000 = 3.20× the deadline

— a red dot flung far above the on-time band, and a lonely bar way out in the right tail of the histogram. That factor of 3.20× is a computed number, not a guess: 3200 µs of work against a 1000 µs window.

Notice how badly a single such cycle blows the deadline — over-run by 2200 µs, more than double the whole period. And yet, as the next worked example shows, it barely disturbs the mean. That asymmetry — catastrophic for the cycle, invisible to the average — is the entire lesson in one dot.

Worked example 1 — jitter cannot miss on its own. Set preemption to 0 and jitter to 15 %. jitAmp = 0.15 × 700 × 0.4 = 42 µs, so the cycle range is 700 ± 42 = 658…742 µs, every value under 1000. Even at the extreme — jitter 100 % — jitAmp = 1.0 × 700 × 0.4 = 280 µs, so the widest possible cycle is 700 + 280 = 980 µs < 1000. Zero misses, mathematically guaranteed, no matter how far you drag the jitter slider. The misses are always preemption.
Worked example 2 — the mean–tail gap, on the instrument's own window. The instrument keeps SHOW_HIST_N = 2000 samples for its statistics. At the default 0.8 % preemption, a full window holds 0.008 × 2000 = 16 preempted cycles (say each a 3200 µs stall) among 1984 nominal 700 µs cycles. The mean is
mean = (1984×700 + 16×3200) / 2000 = (1 388 800 + 51 200)/2000 = 1 440 000/2000 = 720 µs
— a comfortable 0.72× the period, still green. Now the tail. Sort the 2000 values; the 16 stalls occupy the top 0.8 %. The p99 boundary is the value at fractional rank 0.99 × 1999 ≈ 1979, which is still a 700 µs nominal cycle (the stalls only begin at index 1984). But the p99.9 boundary is at 0.999 × 1999 ≈ 1997 — squarely inside the stalls — so p99.9 = 3200 µs = 3.20× the period. The gap is 3200 − 720 = 2480 µs: the mean says 0.72× deadline, the p99.9 says 3.20× deadline, from the same 2000 cycles. That is why the readout prints both multiples side by side — watch them diverge as you raise the slider.

One honest subtlety the instrument teaches. If you shrink the window so that only a single cycle in 1000 is preempted, the p99.9 index (0.999 × 999 ≈ 998) lands just below that lone spike and interpolates to about 702 µs, not 3200 — you need the miss rate to exceed 0.1 % before the p99.9 marker climbs onto the tail at all.

This is the "quote the percentile that matches your fault rate" rule from Chapter 2 made concrete: a 1-in-1000 fault needs p99.9; a 1-in-10 000 fault hides from p99.9 and needs p99.99. The percentile you watch has to be as rare as the fault you are hunting.

The instrument's default 0.8 % — 8 faults per 1000 — is deliberately chosen to sit just above the p99.9 threshold, so the marker jumps out onto the tail where you can see it. Nudge preemption below 0.1 % and the p99.9 slides back into the healthy cluster even though real misses are still happening; that is not a bug, it is the percentile telling you to look further out.

Worked example 3 — a rarer fault forces p99.99, computed on the instrument's own window. Drop the slider to 0.1 % preemption. Over the 2000-sample window that is 0.001 × 2000 = 2 stalls (say 3200 µs each) sitting at sorted indices 1998 and 1999, with 1998 nominal 700 µs cycles below them. Now read the two markers by their fractional ranks:
p99.9  index = 0.999 × 1999 = 1997.001 → lo = 1997 (still nominal)
p99.99 index = 0.9999 × 1999 = 1998.800 → lo = 1998 (the first stall)
The linear-interpolation rule then reads p99.9 = s[1997] + 0.001 × (s[1998]−s[1997]) = 700 + 0.001 × 2500 = 702.5 µs — still parked in the healthy cluster — while p99.99 = s[1998] + 0.800 × (s[1999]−s[1998]) = 3200 + 0.800 × 0 = 3200 µs = 3.20× the deadline. Same 2000 cycles, and only the percentile as rare as the fault sees it: at a 1-in-1000 fault rate the p99.9 goes blind and p99.99 is the marker that lands on the tail. This is the Chapter 2 rule — match the percentile to the fault rate — made fully numeric on the same window the instrument sorts every frame.

That is the honest teeth behind the “look further out” advice above. As the fault gets rarer, the percentile you must quote marches from p99 to p99.9 to p99.99 to p99.999, one decimal place per order of magnitude in the fault rate. A dashboard frozen on p99.9 is exactly one order of magnitude blind: it will show green through a fault that trips the robot once every ten thousand cycles — roughly once every ten seconds at 1 kHz.

Design with numbers: what the constants encode

The instrument's constants are not arbitrary; each one is a real-time design decision you can read off in numbers. Every pixel you see on cv-show is one of these seven values doing its job — here is the whole mapping from on-screen element to the exact variable in showStep/showDraw and the number it carries:

On screenCode constantValueWhat it encodes
the deadline / green lineSHOW_PERIOD1000 µsthe 1 kHz control period; overrun past it = miss
the healthy cluster's centreSHOW_NOMINAL700 µsnominal work, 0.70 utilisation, 300 µs slack
dot wobble left/right of tickjit × 700 × 0.4±0…280 µssymmetric jitter; ±42 µs at the 15 % default
the red tail cycles+1800 + rnd×1500+1800…3300 µsone-sided preemption stall → 2.5–4.0× cycles
dots visible on the timelineSHOW_MAXDOTS60a 60 ms magnifying glass at 1 kHz
samples behind the markersSHOW_HIST_N2000a 2.0 s statistics memory
the preemption sliderpre0…0.10fault probability per cycle; 0.008 at default

Utilisation. The nominal loop takes 700 µs inside a 1000 µs period, so the healthy utilisation is SHOW_NOMINAL / SHOW_PERIOD = 700/1000 = 0.70. That leaves 300 µs of slack — 30 % of the period.

This slack is exactly why symmetric jitter of ±42 µs (or even the ±280 µs worst case) never crosses the line: jitter eats into slack, and there is a lot of slack to eat. The deadline sits at 1.0× the period because in a periodic control loop the cycle must finish before its own next release; overrun past 1.0 means the next cycle starts late, which is the definition of a miss.

Set the nominal to 950 µs instead of 700 (utilisation 0.95) and even modest jitter would start clipping the deadline. The 30 % headroom is a design choice, not luck — it is the margin the loop rate is deliberately given so that ordinary jitter cannot turn into a miss.

Dots to wall-clock. The timeline shows SHOW_MAXDOTS = 60 cycles at once. If this loop runs at 1 kHz (the rate the whole lesson has assumed — 1000 µs per cycle), then 60 dots span 60 × 1 ms = 0.06 s = 60 ms of real time. The full 2000-sample statistics window is 2000 × 1 ms = 2.0 s of history. So the timeline is a 60 ms magnifying glass and the histogram is a 2-second memory — which is why the dots refresh in a blink while the percentile markers settle more slowly.

Preemption slider to misses per second. A preemption rate p at loop rate R produces, in expectation, p × R preemptions per second. At the default p = 0.8 % = 0.008 and R = 1 kHz that is 0.008 × 1000 = 8 misses per second — roughly one red dot every 125 ms.

On the 60-dot timeline you therefore expect 60 × 0.008 = 0.48 red dots visible at any instant. About one frame in two shows a miss, which matches "every few seconds one slips red" once you remember the timeline is only a 60 ms window into a stream running far faster than your eye.

Double the slider to 1.6 % and you double to 16 misses per second; halve it and you halve them. That linear relationship — misses per second grows in direct proportion to the rate — is the whole point of putting a rate control on a Poisson-like fault: the slider is a dial straight onto the fault frequency.

Now watch the statistics react to that same sweep. Below is the full 2000-sample window computed by hand at four slider settings, each stall taken as a representative 3200 µs = 3.20× cycle. Read it top to bottom and the lesson is a single column: the mean creeps, the p99.9 jumps to the tail the instant even one stall lands past its rank, and the miss count grows in lockstep with the slider. Every number is arithmetic you can redo on paper — the mean is ((2000−k)×700 + k×3200)/2000 for k stalls, and each percentile is the rank test from Chapter 0.

preemptionstalls kmeanp50p99p99.9missesmean×p99.9×
0 %070070070070000.700.70
0.8 %167207007003200160.723.20
1.6 %3274070032003200320.743.20
8 %160900700320032001600.903.20

Two boundaries in that table earn their place. The p99 is nominal at 0.8 % but 3200 at 1.6 %: the p99 rank is index 0.99 × 1999 ≈ 1979, so it only climbs onto the tail once the stalls number more than 2000 − 1979 ≈ 21 — which happens between 0.8 % (16 stalls, below 21) and 1.6 % (32 stalls, above 21). That is why doubling the fault rate flips the p99 from green to red while the p99.9 was already red at half that rate: each percentile has its own fault-rate threshold, exactly its own tail fraction. And the mean× column is the alibi — even at a punishing 8 % preemption, one cycle in twelve blown, the mean is still 0.90× the deadline. A mean-only alarm would call this healthy while the robot is missing 160 deadlines a second.

The instrument's kernel, from scratch

The showcase is doing streaming statistics: it keeps a bounded ring of recent loop times, re-sorts it, and reads off the same percentile function you wrote in Chapter 0. Here is that kernel in the same four-line spirit — a ring buffer, a re-sort, and the percentile read — exactly what showStep and showDraw perform every frame:

python
from collections import deque

WINDOW = 2000                       # SHOW_HIST_N: keep the last 2000 loop times
times  = deque(maxlen=WINDOW)         # a ring buffer: push evicts the oldest for free

def on_cycle(t_us):                 # called once per control cycle, like showStep()
    times.append(t_us)              # O(1) push; maxlen drops the stalest sample

def stats():                        # called to refresh the readout, like showDraw()
    s = sorted(times)              # re-sort the whole window; tail stats need order
    n = len(s)
    def pct(q):                     # same linear-interp percentile as Chapter 0
        idx = q / 100 * (n - 1)
        lo  = int(idx)
        return s[lo] + (idx - lo) * (s[min(lo + 1, n - 1)] - s[lo])
    mean = sum(times) / n
    return mean, pct(50), pct(99), pct(99.9), sum(1 for x in times if x > 1000)  # mean, p50, p99, p99.9, misses

The library one-liner for the whole percentile trio is numpy.percentile(times, [50, 99, 99.9]), and the miss count is numpy.count_nonzero(numpy.asarray(times) > 1000) — same answer, same interpolation convention as the from-scratch version. The re-sort every frame is O(n log n) over 2000 samples, which is trivial at 60 Hz; a production profiler would keep an order statistic tree or a t-digest instead, but the toy re-sort is exactly right for making the mechanism visible. This is the same showPct the instrument calls at cv-show's draw — you have now written its engine.

python
# the percentile markers the showcase draws, as a numpy one-liner
import numpy as np
p50, p99, p999 = np.percentile(times, [50, 99, 99.9])   # the teal / purple / red lines on the histogram
mean            = np.mean(times)                        # the warm line — the one that lies
misses          = np.count_nonzero(np.asarray(times) > 1000)  # dots that crossed the deadline

The bars the histogram draws are the other half of the kernel, and they are just as simple: a fixed value range split into equal-width bins, one increment per sample. This is verbatim the arithmetic showDraw runs at the cv-show canvas — a bin width binw = (xhi−xlo)/nb and a bin index b = floor((t−xlo)/binw) — only written here as plain Python so you can see there is no library magic under the bars:

python
def histogram(times, xlo=600, xhi=3800, nb=48):   # same range/bin count as showDraw()
    binw = (xhi - xlo) / nb                     # bin WIDTH in us: (3800-600)/48 = 66.67 us/bin
    hist = [0] * nb                            # one counter per bin, all starting at zero
    for t in times:
        b = int((t - xlo) / binw)             # which bin: floor of (value - lo) / width
        if 0 <= b < nb:                       # clip anything outside [xlo, xhi]
            hist[b] += 1                       # drop this sample into its bin
    return hist                                # bar heights, left (fast) to right (tail)

Trace one sample to see the bins are not arbitrary. A nominal 700 µs cycle lands in bin floor((700−600)/66.67) = floor(1.5) = 1 — the second bar from the left, right where the healthy cluster piles up. A 3200 µs stall lands in bin floor((3200−600)/66.67) = floor(39.0) = 39 — far out in the right tail, past the green deadline line at bin floor((1000−600)/66.67) = 6. So the deadline sits at bar 6, the healthy mass at bar 1, and every miss at bar 39-and-beyond: the visual gap between the cluster and the tail is 33 empty bins wide, which is exactly why a lonely stall reads as a bar stranded out in space rather than a fatter version of the main hump. The library one-liner for the same counts is numpy.histogram(times, bins=48, range=(600, 3800))[0] — identical convention, same left-closed bins.

One last piece of the kernel is worth doing on paper end to end: the pct() interpolation, run against a concrete sorted window so you can see every arithmetic step the red marker takes. Take the default 0.8 % window — 1984 nominal cycles at 700 µs followed by 16 stalls at 3200 µs, sorted so the stalls occupy indices 1984…1999. Here is pct(99.9) line by line:

1. fractional rank
idx = 99.9/100 × (2000−1) = 0.999 × 1999 = 1997.001
2. floor to lower index
lo = floor(1997.001) = 1997  (≥ 1984, so already inside the stall block)
3. fetch the two bracketing samples
s[1997] = 3200 µs  ·  s[1998] = 3200 µs
4. linear-interpolate
p99.9 = s[1997] + (1997.001−1997) × (s[1998]−s[1997]) = 3200 + 0.001×0 = 3200 µs
↻ same for p50, p99

Now run the same four steps for pct(50) on the same window, so you can lay the two side by side and see the machine is identical:

1. fractional rank
idx = 50/100 × (2000−1) = 0.50 × 1999 = 999.5
2. floor to lower index
lo = floor(999.5) = 999  (< 1984, so inside the healthy block)
3. fetch the two bracketing samples
s[999] = 700 µs  ·  s[1000] = 700 µs
4. linear-interpolate
p50 = 700 + (999.5−999) × (700−700) = 700 + 0.5×0 = 700 µs

Identical machine, four identical steps, and the answer diverges by a factor of 4.6 — p50 = 700 versus p99.9 = 3200 — purely because the rank of p99.9 falls inside the 16-sample stall block while the rank of p50 falls in the 1984-sample healthy block. That divergence — not any special “tail formula” — is the entire mechanism behind the two markers you watch pull apart on the screen. Slide preemption up and you are literally pushing more stalls above the p99.9 rank, then above the p99 rank, then eventually above the p50 rank; each marker jumps the instant the stall count crosses its own tail fraction.

What to notice

Debug this on the screen — symptom → revealing metric. Symptom: the red (miss) dots on the timeline cluster on a fixed rhythm while the mean readout stays green and calm. That pairing — a periodic visible fault under a healthy average — is the exact once-a-second signature from Chapter 0. The metric that reveals it: diff the timestamps of the red dots. If the control loop runs at 1 kHz and a red appears every 1000th cycle, the differences are a clean 1000 ms = 1.0 s with no scatter — a constant period says the cause is a periodic task preempting the loop, not jitter. Random jitter would scatter the miss timestamps; a task nails them to a grid. (Contrast: raising the jitter slider alone never produces reds at all — worked example 1 — so a rhythmic red pattern must be preemption.)

Frontier: what a real profiler measures that this toy abstracts

This instrument draws loop times you can already reason about; a production tail-latency profiler measures the same quantity on real silicon and formalises why it is the number that governs the system.

The reference is Dean and Barroso, "The Tail at Scale" (Communications of the ACM, 2013). Their claim: in a pipeline of stages, the loop's latency is set by the slowest stage on each pass. So if a control cycle chains, say, ten stages that each go slow 1 % of the time independently, the loop hits a tail on 1 − 0.9910 ≈ 9.6 % of cycles — the tail amplifies as you add stages, and shrinking each stage's average does almost nothing to it.

That is precisely why the showcase plots a distribution and a p99.9, not a bar of averages: it is a picture of the exact statistic Dean and Barroso argue governs the system.

The modern robotics counterpart is what tools like cyclictest and ftrace on a PREEMPT_RT kernel (mainlined into Linux in 2024) actually record: the true preemption latency the OS imposes — the real-world source of the 1800–3300 µs stall this sim injects by hand.

The toy abstracts scheduler internals, cache misses, and IRQ storms into one +1800…3300 µs draw. A real profiler measures each of those separately and attributes the tail to its true cause — which is the difference between seeing that the loop has a tail (this sim) and knowing which line of code produced it (the profiler).

You set preemption to 0 and jitter to 100 %. With nominal 700 µs and jitter amplitude 0.4× the nominal, what is the widest a single cycle can get, and will any dot cross the 1000 µs deadline?

Now confirm it on the instrument itself. The screen is the second half of the test: drag preemption to zero and jitter to 100 %, and you will watch the histogram widen symmetrically toward — but never past — the green line, the mean and p99.9 markers staying within 280 µs of the nominal, and the miss count frozen at zero. Then flick preemption back up and watch the p99.9 leap to the tail while the mean barely stirs. If you can predict which marker moves and whether a dot goes red before you touch a slider, you hold the real-time mental model — that is the whole point of the instrument.

Chapter 5: Field Guide

This is the reference chapter — the page you keep open next to the terminal. It condenses the whole lesson into the cheat sheet, the design patterns, the drills, and the debugging table you reach for when a real robot is misbehaving at 2 a.m. Nothing new is introduced; everything here was derived above.

The one-page cheat sheet

TermPrecise meaningThe number to quote
Deadline DTime by which a result must complete to be useful.The period for a periodic loop; the reaction budget for an event.
HardLate = catastrophic. V(t>D) = −∞.WCET vs D. Zero misses tolerated.
FirmLate = worthless but harmless; discard.Miss rate vs a quality budget.
SoftLate = worth less, decaying. V(t>D) ramps to 0.The p95/p99 of latency vs the decay window.
LatencyTime for one operation, stimulus to response.Its full distribution, not the mean.
JitterVariation in latency, cycle to cycle.Peak-to-peak or std of the interval.
WCETProvable upper bound on execution time.The static bound, or observed max over a long soak.
Tail (p99.9)Value exceeded by 0.1 % of cycles.Match the percentile to the fault rate: 1-in-1000 → p99.9.
Utilisation U∑ Ci/Ti — fraction of CPU demanded.vs the RMS bound n(21/n−1) for a guarantee.
The one sentence that carries the lesson: real-time means predictable, not fast — so quote the tail (p99.9 vs the deadline), fix the loop rate, and prove the utilisation fits, because the mean is the answer to a question no real-time engineer ever asks.

Derivation you should be able to rebuild: the utilisation bound

The cheat sheet quotes the rate-monotonic guarantee — a task set is schedulable if the total utilisation U stays under n(21/n−1) — as a fact to reach for. A field guide is not a place to memorise a formula you cannot rebuild, so here it is derived from nothing, then run on a concrete task set. If someone erases the whiteboard and asks you to reproduce the bound, this is the path.

Set the stage. Rate-monotonic (RM) priority assignment means: the task with the shortest period gets the highest priority, always, and never changes. A task i is described by two numbers — its worst-case compute time Ci (how long one job takes if nothing goes right) and its period Ti (how often a new job arrives). Its individual utilisation is the fraction of the CPU it demands: ui = Ci/Ti. The total is U = ∑ Ci/Ti.

The question RM answers is: for what values of U is every deadline met, no matter how the periods line up? Liu & Layland's 1973 answer is the least-upper-bound — the largest U below which schedulability is guaranteed. Above it, some task sets still work and some do not; below it, all of them work. We derive that bound.

Build the worst case by hand for two tasks, then read off the pattern. Order them by period, T1 ≤ T2, so task 1 is higher priority. The stressful arrangement is when task 2's period is less than twice task 1's, so during one job of task 2 the higher-priority task 1 fires twice and steals CPU twice. Push each task's compute up until the schedule is exactly full — any more and a deadline slips. Working through that "just barely fits" algebra and then minimising the resulting utilisation over the ratio T2/T1 gives, for two tasks, the tightest packing at

U2 = 2 (21/2 − 1) = 2 (1.41421 − 1) = 2 × 0.41421 = 0.8284.

Generalise the same "fill until exactly full, then minimise over the period ratios" argument to n tasks and the packing worsens smoothly: the more tasks you interleave, the more ways their firings can gang up, so the guaranteed bound drops. The closed form is

Un = n (21/n − 1).

Sanity-check its shape before trusting it. For one task, U1 = 1·(21−1) = 1 — a single periodic task can use the whole CPU, which is obviously right. For two, 0.8284 as above. For three, 0.7798. As n → ∞ the bound descends monotonically to ln 2 ≈ 0.6931 — the famous "69 %" rule of thumb. So the bound is 100 % for one task and sinks toward 69 % for many; if your total utilisation is under 69 % you are safe for any number of RM tasks without even counting them.

Now run it on a real three-task loop — the exact drill from the coding list, worked here with every intermediate value so nothing is asserted:

TaskWorst-case compute CPeriod Tu = C/T
Torque loop (hard)0.30 ms1.00 ms0.30/1.00 = 0.30
State estimator (firm)0.10 ms0.50 ms0.10/0.50 = 0.20
Telemetry (soft)0.05 ms0.25 ms0.05/0.25 = 0.20

Sum the demands, then compute the bound for n = 3, then compare — substitute the numbers, then simplify, one step at a time:

U = 0.30 + 0.20 + 0.20 = 0.70.
bound = 3 (21/3 − 1) = 3 (1.25992 − 1) = 3 × 0.25992 = 0.7798.
0.70 ≤ 0.7798 ⇒ schedulable — every deadline met under RM.
Read the margin, not just the verdict. U = 0.70 sits 0.08 below the bound of 0.78 — about a 10 % headroom. That thin margin is exactly why the lesson's once-a-second trip still happens: the bound is a guarantee about the periodic worst-case compute you listed, and it says nothing about a logger that grabs the CPU for 0.8 ms outside this table. Pass the utilisation test and you have proven the periodic set fits; you have not proven the tail is bounded. The utilisation bound is necessary, never sufficient.
Where the bound is only sufficient, never necessary. A task set with U above 0.78 — even at U = 1.0 — can still be schedulable if the periods are harmonic (each a multiple of the next). The bound is a one-line pass; failing it means "run the exact response-time analysis," not "give up." Quote the bound to screen fast, then fall back to the exact test when the screen fails.

Derivation you should be able to rebuild: the Δt bug in drill 6

Drill 6 asserts a "30 % velocity overestimate." A field guide that only states the answer is the thing this lesson warns against. Here is the arithmetic in full, so you can reproduce it at the whiteboard and, more importantly, see why a timing slip becomes a control error.

The setup, from Chapter 0's mechanism. A joint controller runs nominally every T = 1 ms. Each cycle it estimates angular velocity by finite difference — the change in angle over the interval — and the code is written v = (angle_now − angle_last) / T, hard-coding the nominal interval T. Now one cycle runs 30 % late: the CPU was stolen, so the real elapsed interval is

dtreal = 1.30 × T = 1.30 × 1 ms = 1.30 ms.

Take a concrete rotation over that late cycle: the joint actually moved Δθ = 0.13 rad during the 1.30 ms. Compute both the truth and what the buggy code reports — substitute, then simplify:

vtrue = Δθ / dtreal = 0.13 rad / 0.00130 s = 100 rad/s.
vest = Δθ / T = 0.13 rad / 0.00100 s = 130 rad/s.
vest / vtrue = 130 / 100 = 1.30 ⇒ a 30 % overestimate.

The ratio is not a coincidence of the numbers — it is structural. Both estimates share the same numerator Δθ, so the ratio is just the interval ratio flipped: dividing by a T that is 1.30× too small inflates the result by exactly 1.30×. The velocity error equals the timing error, one for one. (Cross-check against Chapter 0's 4 ms straggler: dtreal = 4T, so the overestimate there is 4×, matching the "velocity overestimated by 4×" claim in the hook.)

Now carry it into torque, which is where the robot lurches. Near the target the position term is small, so the braking command is dominated by the derivative term τd = kd · v. Take a gain kd = 0.5 N·m·s/rad:

τtrue = kd · vtrue = 0.5 × 100 = 50 N·m.
τbuggy = kd · vest = 0.5 × 130 = 65 N·m.
excess = 65 − 50 = 15 N·m  (the same 30 % too much).
This is the lurch, quantified. Because τd is linear in v, the 30 % velocity error propagates untouched into a 30 % torque error — 15 N·m of extra braking on a joint that was already close to rest. Command 65 N·m where 50 was called for and the arm overshoots the target, the joint torque spikes, and the safety controller trips. One stolen cycle, one wrong divisor, one emergency stop. The two fixes: hold the interval truly fixed with a real-time periodic wakeup so dt is never late, or — more robust — divide by the measured interval each cycle (v = Δθ / dt_measured) so the estimate stays correct even when the schedule slips.

Derivation you should be able to rebuild: which percentile to quote

The cheat sheet's tail row says "match the percentile to the fault rate: 1-in-1000 → p99.9." That is the single most useful sentence in the guide, and it is worth deriving rather than trusting, because getting it wrong is how a healthy-looking dashboard hides the fault that trips the robot.

Start from the definition. The pq percentile of a sample is the value that q % of the sample falls below; equivalently, (100−q) % falls at or above it. So a percentile is a knob that selects a fraction of the worst cycles: the higher q, the thinner the slice of stragglers you are looking at.

Now match the knob to the fault. A fault that happens once every N cycles occupies a fraction 1/N of all cycles — it lives in the top 1/N of the sorted loop times. To point the percentile exactly at that slice, choose q so that the fraction above it, (100−q)/100, equals 1/N:

(100 − q) / 100 = 1 / N ⇒ q = 100 − 100/N.

Substitute the rates and simplify, one line each — this is the whole cheat-sheet row, derived:

N = 100  →  q = 100 − 100/100 = 100 − 1 = 99  (p99).
N = 1000  →  q = 100 − 100/1000 = 100 − 0.1 = 99.9  (p99.9).
N = 10000  →  q = 100 − 100/10000 = 100 − 0.01 = 99.99  (p99.99).

This is why the lesson's once-a-second trip needs p99.9 and nothing softer. At the 1 kHz control loop, one thousand cycles pass every second, so a once-a-second fault is exactly a 1-in-1000 event: N = 1000, and the formula lands on p99.9. Quote p99 instead and you are averaging over the top 1 % — ten times wider than the fault — so the straggler is diluted by nine healthy-ish cycles and the number looks fine. The median and mean are hopeless: they describe the middle and the bulk, and the fault is neither.

Put the same 1-in-1000 rate into human units so the stakes are concrete. One fault per second is 3,600 per hour and 86,400 per day — every one a chance for the arm to overshoot. A statistic that hides 86,400 daily faults is not conservative; it is blind.

The measurement pitfall that voids a deep percentile. A percentile is only meaningful if enough samples sit above it. To see the p99.9 with, say, ten straggler samples behind it you need at least 10 / 0.001 = 10,000 cycles — and at 1 kHz that is a 10-second soak minimum, longer for a stable estimate. A 1-second bench run has one thousand cycles and therefore one cycle above p99.9: the reported p99.9 is that single sample, pure noise. This is exactly the "benchmark too short" bug in the debugging table — prefer the observed max over a long soak to an interpolated deep percentile from a short run.

Design pattern, instantiated with numbers: the detector → planner seam

The patterns table lists "non-blocking read + last-known-good" as an abstraction. Here it is made concrete with a latency budget and a bandwidth budget in actual numbers — the two figures a design reviewer will ask for by name.

The seam: a 30 Hz object detector (firm) feeds a 1 kHz motion planner (hard). Their rates do not divide evenly — the planner fires 1000/30 ≈ 33 times per detection — so most planner cycles have no fresh detection, and any single detection may be late. Wire this with a blocking read and the hard 1 kHz loop stalls whenever the firm 30 Hz producer is late: a firm miss becomes a hard miss, and the whole robot faults on a dropped video frame. Wire it with a non-blocking read of a double-buffered slot and a late detection is simply skipped — the planner reuses the previous target for one more of its 33 cycles, a ≤33 ms staleness the controller already tolerates.

Latency budget (numbers). The planner's own deadline is its period, 1 ms. The non-blocking read must cost effectively zero against that budget: reading one pointer-swap slot is ~tens of nanoseconds, so it consumes <0.01 % of the 1 ms and the hard loop's worst case is unchanged. A blocking read, by contrast, could add up to a full detector period — 33 ms, or 33× the planner's entire deadline — the instant the producer stalls. That single number, 33 ms of possible blocking against a 1 ms deadline, is the whole argument for the pattern.

Bandwidth budget (numbers). Say each detection is a list of up to 20 boxes, each 6 floats (x, y, w, h, class-id, score) at 4 bytes = 24 bytes, so 480 bytes per detection. At 30 Hz that is 480×30 = 14.4 KB/s — utterly negligible, which confirms the seam is latency-bound, not bandwidth-bound: the design problem is when the data arrives, never how much. Double-buffering costs one extra 480-byte slot to decouple producer and consumer — a trade of under a kilobyte of RAM to turn a hard fault into a harmless skip.

The seam in one line: a firm producer feeding a hard consumer is wired non-blocking with last-known-good, because 33 ms of possible blocking against a 1 ms deadline is 33× the budget, while the RAM cost of decoupling them is under a kilobyte. Numbers, not boxes and arrows, decide the wiring.

From-scratch: the schedulability checker

The two derivations above become fifteen lines you can carry in your head. This is the whole utilisation test with no library — sum the demands, compute the least-upper-bound, compare, and report where the margin sits. Run it on the three-task set above and it prints the same 0.70 ≤ 0.7798 verdict you worked by hand.

python
def rms_schedulable(tasks):
    # tasks: list of (compute_C, period_T) in the same time unit
    U = sum(C / T for C, T in tasks)   # total demanded fraction of the CPU
    n = len(tasks)
    bound = n * (2 ** (1 / n) - 1)         # Liu & Layland least-upper-bound
    return {
        "U": round(U, 4),
        "bound": round(bound, 4),
        "margin": round(bound - U, 4),      # positive = headroom to the guarantee
        "guaranteed": U <= bound,             # True = every deadline provably met
    }

# the torque / estimator / telemetry loop, times in milliseconds
loop = [(0.30, 1.00), (0.10, 0.50), (0.05, 0.25)]
print(rms_schedulable(loop))
# -> {'U': 0.7, 'bound': 0.7798, 'margin': 0.0798, 'guaranteed': True}
What the code does that the formula does not: it returns the margin, not just a boolean. A verdict of True at margin 0.0798 means "fits, with ~8 points of headroom" — and a robotics reviewer reads that margin as the answer to "how much aperiodic interference can this loop absorb before the guarantee is void?" A checker that only printed True would hide exactly the number that matters when the logger starts stealing cycles. Note also what it does not model: guaranteed=False is a fail-to-prove, not a proof of failure — drop to exact response-time analysis before you rearchitect.

Field-guide explorer

The cheat sheet packs three deadline classes and one schedulability rule into a table. Here they are made live: drag the two sliders and the panel redraws the value-function shape and the utilisation-vs-bound bar together, so you can feel how a class and a load interact before you ever quote a number.

Value function & schedulability, on one control

Top: the value V(t) of a result vs when it finishes — pick hard (plunge to −∞), firm (flat zero), or soft (ramp) with the class slider, and read the value at your chosen late arrival. Bottom: total utilisation U vs the RMS bound n(21/n−1) — drag the load up and watch the bar cross the guarantee line. One instrument for the whole cheat sheet.

Deadline class hard
Arrival time (× D) 1.40 D
Total utilisation U 0.70
A fault trips your robot roughly once in every 1000 loop cycles. Which single latency percentile do you quote against the deadline to characterise that fault?

System-design patterns

PatternWhat it buysWhen to reach for it
Rate-monotonic prioritiesA schedulability proof (U ≤ the RMS bound) with fixed priorities by period.Multiple periodic tasks on one core with hard deadlines.
Dedicated core / isolationRemoves interference entirely for the critical loop.A hard loop that cannot tolerate any preemption tail.
Non-blocking read + last-known-goodTurns a firm miss into a harmless one — the consumer reuses the previous value instead of stalling.A firm/soft producer feeding a hard consumer (detector → planner).
Measured-Δt controlFeeds the actual interval into the control law so gains stay correct under jitter.Any loop that cannot guarantee a perfectly fixed rate.
Priority ceiling / inheritanceBounds priority inversion so a low task holding a lock cannot stall a high task indefinitely.Shared resources (mutexes) between tasks of different priority.
Watchdog + safe stateDetects a missed hard deadline and forces a safe fallback (brake, hold, cut torque).Every hard loop — the last line of defence when the bound is violated anyway.
Budget shedding (mixed-criticality)Drops low-criticality work when a high-criticality task overruns.Mixed hard/firm/soft tasks sharing scarce compute.

Coding drills

Work these by hand or in the Code Labs above until they are reflex:

  1. Percentiles from a vector. Given loop times, compute mean, p50, p99, p99.9 and the tail-to-mean ratio. Explain why the mean cannot recover the p99.9. (Code Lab, Chapter 2.)
  2. Count the misses. Given per-cycle compute and a periodic preemption, compute finish times and count deadline misses at a given budget. Show that raising the budget past the preemption spike drives misses to zero. (Code Lab, Chapter 3.)
  3. Break the loop with jitter. Run a controller at its stability edge with fixed vs jittered Δt of the same mean; show the jittered one diverges. State the Jensen's-inequality reason. (Code Lab, Chapter 3.)
  4. Utilisation check. Given three tasks (C, T each), compute U and compare to the RMS bound; state whether the schedule is guaranteed. (Worked in Chapter 3: U = 0.45, bound 0.78 → schedulable.)
  5. Classify a deadline. For a given subsystem, write V(t) and label it hard/firm/soft; read the value at a specified late arrival. (Worked in Chapter 1.)
  6. Derivative-gain error. A cycle runs 30 % late but the code divides by the nominal Δt. Compute the resulting velocity overestimate (30 %) and the torque error it produces.

Debugging scenarios: symptom → cause → the metric that reveals it

SymptomLikely causeThe metric that reveals it
Trips on a fixed period (e.g. once a second); mean loop time in spec.A periodic background task preempts the loop, producing a rare late cycle.p99.9 loop time vs deadline; and the period of the trips (timestamp-diff the faults → lands on the offending task's period).
Passes the bench, misses in the field on identical code.Bench ran on an idle machine (tight timing); field unit shares the CPU and jitters.Histogram of actual inter-cycle Δt; overlay the assumed Δt. A fat right tail = jitter the controller never modelled.
Control loop noisy/unstable in the field, stable in sim.Derivative term divides by nominal Δt, not the measured one; or Δt wanders past the stability edge.Plot control error against Δt excursions — error spikes land on the same cycles as Δt spikes.
Whole robot faults when a "minor" subsystem is late.A firm/soft producer wired into a hard consumer via a blocking call.Dependency latency trace: the hard loop's start time jumps one-for-one with the firm producer's lateness.
Benchmark looks great; production misses deadlines.Benchmark too short to observe the tail; deep percentiles are noise.Sample count behind each percentile; prefer the observed max over a long soak to an interpolated p99.99 from a short run.
A high-priority task occasionally stalls with the CPU idle.Priority inversion — a low task holds a lock the high task needs while a medium task runs.Lock hold-time trace vs task priority; the high task's blocked interval overlaps a low task holding its mutex.

Classical vs modern

DimensionClassical viewModern practice
Quoting timingReport the mean latency.Report the tail (p99.9) against the deadline; the mean is a footnote.
Bounding WCETMeasure the max in a test run.Static WCET analysis (aiT) for hard systems; long soak + margin otherwise.
OS for controlBare-metal or a small RTOS only.PREEMPT_RT Linux (mainline since 2024) for rich stacks; RTOS/microcontroller for the innermost hard loop.
SchedulingCyclic executive (a fixed hand-built time-table).Rate-monotonic / EDF with formal schedulability, plus mixed-criticality shedding.
Controller designAssume a perfect fixed rate.Control–scheduling co-design; measured-Δt control robust to the real jitter.
Jitter"Keep it small."Quantify its performance cost with Jitterbug/TrueTime and trade it against CPU.

Where this sits in the track

This lesson is the mental model; the mechanisms that deliver real-time behaviour live in the sibling lessons. Follow these to go deeper:

To learn…Read
Determinism, interrupts, and worst-case bounds at the systems levelEmbedded Real-Time Systems
How an RTOS schedules: priorities, preemption, context switches, priority inversionEmbedded RTOS
Where jitter is born across sensors: clocks, timestamps, synchronisationTime & Synchronisation
What the control loop is actually doing: state estimation and driftDrift & Loop Closure

Recommended reading

You now hold the real-time mental model. Given loop times, you reach for the tail, not the mean. Given a deadline, you classify it hard/firm/soft and know what a miss costs. Given a control loop, you know its rate is a fixed physical constant and can prove a jittered version diverges even at the same mean. And given a once-a-second trip on a healthy-looking dashboard, you know to look for a periodic task, plot the p99.9, and time the faults — because the tail is where robots break.

"The first principle is that you must not fool yourself — and you are the easiest person to fool." — Richard Feynman. A healthy mean is exactly the fool's comfort; the tail is the truth.