P2 client w-latency-budget

Latency Budget

Where the delay in a real-time loop comes from, decomposed into filter, buffer and processing — and what a zero-phase filter costs when you insist on running one live.

2 claims on this page are unverified. TODO(confirm) marks a specific statement the author has not yet checked against a primary source. Everything else on this page has been reviewed. Treat a marked claim as provisional and go to the cited source rather than quoting the sentence.

Modes: default — the widget below runs in default. Use Share state to put the exact view in the URL.

Latency Budget

mode: default
Loading Latency Budget…

Data: ds-eegbci , subject S001, run R02, 50–60 s · license ODC-By-1.0 · DOI 10.13026/C28G6P · labels: algorithmic · a cropped, re-referenced or filtered derivative of the source recording.

What it does

A real-time pipeline is replayed over a ten-second trace, block by block, and the delay between an event in the signal and the output that reflects it is decomposed into three rows rather than printed as one number:

worst case = filter group delay + buffer + processing
mean       = filter group delay + buffer/2 + processing

Each row has a different cause and a different remedy, which is the reason the read-out refuses to collapse them. Change the filter order and one row moves; change the block length and a different one does; the processing row never moves on its own, because it is a number you measured and told the widget, and no tool can know what your compute, transport and display cost.

At the widget’s defaults — 160 Hz, an 8–12 Hz linear-phase FIR of 129 taps, blocks of 32 samples, 10 ms of stated processing, run causally — the budget is 400.00 + 200.00 + 10.00 = 610.00 ms worst case and 510.00 ms averaged over where in the block a sample lands. That is L7.3’s pipeline, and it is the default state, so an empty share-link reproduces it.

Only the filter row is measured. A loopback probe — one Hann-windowed packet at the centre of the pass band — goes through the same pipeline and the shift of its energy centroid is read off, which is how latency is measured on a real rig: send a known event and time its arrival. A cross-correlation was tried first and was quietly wrong, because two narrow-band signals correlate just as well at every multiple of the carrier period.

The buffer term has two defensible answers, one sample apart

This is a named control, not a hidden default, because quoting one while meaning the other is the commonest error in a latency budget:

conventionat B = 32, fs = 160 Hzwhat it counts
block period, B/fs (the default)200.00 msa sample is not available until its acquisition period completes, so the block is handed over at the end of the last sample’s period and the oldest sample in it has waited a full block
first-to-last, (B − 1)/fs193.75 msthe difference between the arrival instants of the first and last samples of the block

Whichever is selected, the table prints it and says what the other would read on the same pipeline. A third convention — adding a further B/fs for the hold until the next update — answers “how stale is the number on screen right now” rather than “how late does an event appear”, and is named but not budgeted.

Why a zero-phase filter cannot run causally

Forward–backward filtering has no group delay because, in the interior of a record, it is one convolution with a kernel symmetric about the sample it is correcting. Symmetric means it reaches to both sides, so the output at time t uses signal from after t. Offline that is free; live it does not exist yet. The widget offers the three things a person can actually do about it:

optionfilter rowwhat it really is
Causalthe group delayone forward pass with state carried across blocks — what a live loop can run
Zero-phase, livethe look-aheadforward–backward over a window that reaches past the block: N − 1 samples for an N-tap FIR, exactly twice the causal group delay of the same filter. The delay did not go away; it changed its name from lag to wait
Zero-phase per blockzero, and misleadingfiltfilt on each buffer as if the buffer were the whole recording. The probe returns a negative delay — an output ahead of its input — and every block edge grows its own transient (pf-filter-across-boundaries)

For an IIR the two-sided kernel never ends, so the look-ahead is a truncation criterion, not a bound: how far before an impulse the forward–backward response still exceeds 1 % of its peak. On the shipped 8–12 Hz Butterworths that is 83 / 145 / 186 / 226 samples at orders 2 / 4 / 6 / 8, and every one of them exceeds the causal group delay of the same order.

Controls

ControlWhat it sets
TraceWhich replayed trace: eyes-closed alpha, eyes-open, or the synthetic eyes-open → eyes-closed splice
Filter typeFIR (windowed sinc, Hamming) or IIR (Butterworth, as second-order sections)
OrderFIR: the tap count, forced odd so (N − 1)/2 is a whole sample. IIR: the Butterworth order
BandThe band-pass edges, clamped inside (0, Nyquist), opening on the band the asset declares (8–12 Hz)
Quote the group delay atIIR only: the frequency the table’s number is read at, because an IIR has no single group delay
BufferBlock length in samples, shown in milliseconds and as a loop update rate
Processing delayA stated constant in milliseconds: your compute, transport and display. Not derived, and the row says so
Buffer conventionBlock period or first-to-last, as above
How the filter is runcausal · zero-phase, live · zero-phase per block
Draw raw / reference, window, startView only; they change no number

What to look for

  • Read the three rows before the total, and decide which one you would attack first. The total alone does not tell you.
  • Double the tap count. The filter row doubles with it — a linear-phase FIR delays every frequency by exactly (N − 1)/2 samples, so length and latency are the same dial — and the other two rows do not move. What the length buys is the transition band: 3.3·fs/N, which is 4.09 Hz at 129 taps and 2.05 Hz at 257, for another 400 ms.
  • Change the block length instead, and watch only the buffer row and the loop update rate move.
  • Switch to Butterworth. The group delay stops being one number: the read-out quotes it at a frequency you choose and prints its range across the pass band beside it. Order 4 costs 204.54 ms at 10 Hz where a 129-tap FIR costs 400 — and order 2 does not meet a −20 dB stop-band target on this band at all.
  • Compare the two ways of asking for zero phase, then look at what “zero-phase per block” does to the waveform at each block edge. The filter row says 0 ms and the trace says otherwise.

Used in

w-filter-sandbox’s causal mode shows the same delay as a shift on the waveform. The two agree by construction for a linear-phase FIR — both take (N − 1)/2 — but they are controlled from opposite ends: the sandbox’s control is the transition band, from which it derives the tap count, while this widget’s control is the tap count, from which it derives the transition band as 3.3·fs/N. Quote one or the other; a mixture will not reconcile.

Data provenance

ds-eegbci — EEG Motor Movement/Imagery Database (EEGMMIDB), PhysioNet v1.0.0, ODC-By 1.0, open access, dataset DOI 10.13026/C28G6P. Cite Schalk et al. (2004) and Goldberger et al. (2000) as the dataset asks. The shipped traces are derivatives: cropped to ten seconds, reduced to one channel, and demeaned.

Three traces, each 10 s of subject S001, channel O1, at the recording’s native 160 Hz:

idsourceselection
alpha-ecR02, eyes closed, t0 = 50.0 sthe 10-s window with the highest median 8–12 Hz Hilbert envelope on O1 (97.8 µV), on a 0.5-s grid with 1 s dropped at each end
alpha-eoR01, eyes open, t0 = 1.0 sthe same criterion, minimised (10.6 µV)
splice-eo-ecR01 + R02synthetic: five seconds of alpha-eo followed by five of alpha-ec, joined at t = 5 s with no cross-fade

160 Hz and unfiltered is the point. ds-eegbci is recorded with no hardware filters, so the delay a filter adds is the only delay in the trace, and nothing was resampled — resampling would add a filter, and therefore a delay, to an asset whose entire subject is delay.

The splice is synthetic and labelled so, with derived_from and modifications in its sidecar and in data/manifest.json: no subject produced that ten seconds of EEG. It does two jobs — the alpha envelope steps up at t = 5 s, so you can see how late a causal pipeline notices a change, and the join is a real discontinuity, which is what pf-filter-across-boundaries is about.

traces.json also ships a reference block: SciPy’s own group delays, zero-phase look-aheads and stop-band attenuation for a fixed menu of twelve designs (scipy 1.15.3), so the widget’s TypeScript can be checked against SciPy rather than trusted. The widget’s test suite does exactly that, and the two agree — every FIR group delay exactly, every Butterworth group delay to six decimals, every zero-phase look-ahead to the sample. Every causal design additionally carries an empirically measured lag on the shipped trace; a predicted delay that does not show up as a real lag stops the asset script.

Open the code

site/src/components/widgets/w-latency-budget/Widget.svelte (presentation only), budget.ts (all of the mathematics: design, group delay, look-ahead, the budget, the block-wise replay and the loopback probe), state.ts, data.ts, synthetic.ts (a seeded fallback so everything works before the asset ships, labelled as a simulation when it is showing), README.md, and 66 tests of which nine run against the shipped asset and its SciPy reference. Repository link: TODO(confirm) (GitHub org/repo).

TODO(confirm): screenshot.png in that folder is a labelled placeholder drawn with matplotlib, not a capture of the widget, and at the time this page was written the widget’s charts, theme switching and keyboard paths had been unit-tested and server-rendered but not yet looked at in a browser (site/notes/widget-latency.md §7). This page is where someone should open it and check — in particular whether the three budget rows and the two conventions read clearly side by side at narrow widths.