Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Signal Processing in Rust

What is CSI? introduced amplitude and phase as the two physical quantities recoverable from a CSI sample. This section is about the distance between that formula and a usable signal — which is larger than it looks, and where most of the practical difficulty in Wi-Fi sensing lives.

Reading the Raw Buffer

What esp-csi-rs hands you is csi_data, a slice of i8 values, and csi_data_len telling you how many. The samples are interleaved pairs, one pair per subcarrier.

Establish which element of the pair is which before trusting any phase result. Code in the wild disagrees, including two firmwares in this ecosystem: esp-csi-litetui-rs reads the first element of each pair as the imaginary component, while esp-csi-litegui-rs reads it as the real one.

// One ordering. Verify against your own hardware before relying on it.
let imag = raw[2 * i] as f32;
let real = raw[2 * i + 1] as f32;

let amplitude = (real * real + imag * imag).sqrt();
let phase = imag.atan2(real);

The disagreement is easy to miss because amplitude cannot reveal it: I² + Q² is symmetric in its arguments, so a swapped convention looks identical in a spectrum plot. What it produces is a conjugated phase — a sign flip that silently inverts anything phase-derived. If your phase slope runs the wrong way, this is the first thing to check.

Two cheap optimisations are worth knowing because you will meet them in existing code. Comparing magnitudes does not need the square root, so real² + imag² is often kept as-is; and a pair of exact zeros marks a subcarrier the radio did not report, which is usually carried forward from its neighbour rather than plotted as a null.

Subcarrier Layout

An 802.11n 20 MHz channel uses a 64-point FFT, and not every bin carries data. Guard bands occupy the outermost bins and the DC bin sits in the middle, leaving 52 usable subcarriers:

// Indices 0-5 and 59-63 are guard; index 32 is DC.
valid[0..26].copy_from_slice(&amplitude[6..32]);
valid[26..52].copy_from_slice(&amplitude[33..59]);

The buffer also arrives in FFT order, with positive and negative frequencies in the halves the FFT produced rather than in monotonic frequency order. Swapping the halves puts them in the order a plot should show:

pub fn swap_upper_lower<T>(arr: &mut [T]) {
    let mid = arr.len() / 2;
    for i in 0..mid {
        arr.swap(i, i + mid);
    }
}

Skip that step and the spectrum appears discontinuous in the middle, which is easy to misread as interference.

HT40 roughly doubles everything: expect ~117–128 samples where HT20 gives ~56 and legacy 20 MHz L-LTF gives ~53. Do not hard-code the count — read csi_data_len, and use data_format (the RxCSIFmt classification) to know which layout you are looking at. A capture that mixes formats is common and legitimate; a pipeline that assumes one is not.

Preparing the Signal

Raw CSI fails a consumer in four distinct ways, and each has its own remedy. The order below is the order the problems have to be solved in — you cannot filter across a gap you have not filled, and you cannot compare scales you have not corrected — but nothing here is a fixed pipeline. Take what your data needs.

Missing Samples

Fill what is missing. Subcarriers the radio reported as zero, and gaps in the packet stream where frames were lost, both need a decision. For subcarriers, carrying the neighbouring value forward is the cheap option and linear interpolation across the gap is the better one. For time, resampling onto a uniform grid matters more than it seems: CSI does not arrive at a uniform rate, because Wi-Fi frames are not uniformly spaced, and every frequency-domain technique downstream assumes uniform sampling. An emitter at a fixed period gets you closer to uniform than ambient traffic ever will, which is a large part of why the controlled pairing exists.

Noise and Outliers

Remove what should not be there. The canonical tools, roughly in order of how often they earn their place:

  • Hampel filtering — a running median with a MAD-based outlier threshold. The right first reach for CSI, because the dominant artefact is impulsive spikes rather than additive noise, and a mean-based filter smears them instead of removing them.
  • Moving median or moving average across time per subcarrier.
  • Low-pass filtering, once you know the bandwidth of the motion you care about. Breathing is well under 1 Hz; walking is a few Hz; a gesture is tens. A filter matched to the phenomenon removes a great deal and costs nothing.
  • Smoothing across subcarriers — a 3-tap smoother is often enough, and both on-device front-ends in this ecosystem use exactly that.

Third-party crates worth knowing: biquad for IIR filter sections, ndarray for the array handling, and medians for the order statistics.

Hardware-Induced Phase Terms

Remove what is real but not about the environment. This is what makes phase usable at all, and skipping it is the most common reason phase data looks like noise.

The transmitter and receiver oscillators are free-running and not locked to one another, so each packet is measured against a slightly different reference. Three consequences dominate the raw phase measurement:

  • Phase-locked loop jitter — the receiver’s PLL does not reacquire to the same phase on every packet, leaving a different constant offset each time.
  • Carrier frequency offset (CFO) — the two oscillators differ in frequency, so the error accumulates as phase between packets.
  • Sampling frequency offset (SFO) and packet detection delay (PDD) — timing errors that tilt phase across subcarriers, by a different amount in every packet.

These are larger than the channel effect you are trying to measure. The standard remedies:

  • Linear detrending. Fit a line to phase against subcarrier index within each packet and subtract it. This removes the slope and the constant together, costs one least-squares fit per packet, and is the correct default.
  • Phase unwrapping first. atan2 returns values in (-π, π], so a genuine slope shows up as a sawtooth. Unwrap along the subcarrier axis — add or subtract 2π wherever consecutive values jump by more than π — before fitting anything. Both on-device front-ends do this inline.
  • The CSI-ratio trick. If you have two receive antennas, divide one antenna’s CSI by the other’s. The offsets are common to both chains and cancel in the ratio, leaving a quantity that is genuinely about the channel. This is the cleanest available answer, and it is why multi-antenna hardware is worth the cost when phase matters.

Amplitude needs detrending too, though less dramatically: automatic gain control moves the whole vector when the receiver changes gain, and per-packet normalisation — or tracking the AGC state where it is exposed — keeps that from appearing as motion.

Scale Drift

Put measurements on a comparable footing. Per-subcarrier standardisation, min-max scaling to a fixed range, or normalisation against a static baseline recorded in an empty room. Which one depends entirely on what consumes the output, but doing none is what makes a model trained on Tuesday fail on Wednesday.

Beyond Per-Packet Processing

Three transformations recur once the per-packet pipeline is in place.

Dimensionality reduction. 52 subcarriers are highly correlated; the signal you want usually lives in the first two or three principal components. PCA is the standard reduction, and it doubles as a denoiser — discarded components are mostly noise. nalgebra provides the decomposition.

Channel impulse response. An inverse FFT over the subcarrier axis converts the frequency response into a time-domain impulse response, separating multipath components by delay. This is how you begin to distinguish a reflection off a moving person from the direct path. rustfft is the crate; the resolution is bounded by bandwidth, which is the concrete reason HT40 is worth configuring and why the C5’s 5 GHz band is interesting.

Time-frequency analysis. A short-time Fourier transform over a subcarrier’s amplitude across packets produces a spectrogram whose bright bands are Doppler shifts from motion. Most activity-recognition work operates on these rather than on raw CSI, because the representation makes the motion explicit.

Where the Processing Should Run

esp-csi-rs offers three placements, and the choice is a real one.

Inline, in the CSI callback. Lowest possible latency, zero copies, and hard constraints: no heap, no locks, no I/O, no .await. Enough for amplitude, phase, a threshold, or a running statistic — which is exactly what a battery-powered presence detector needs, and it lets the device transmit an event rather than a stream.

On-device, off the hot path. The async delivery mode queues packets for a task of your own, at the cost of a ~640-byte copy each. This is where filtering and small transforms belong. Be realistic about the budget: these are microcontrollers, and micromath is the practical maths library. Check your part’s datasheet for what it offers beyond scalar arithmetic before budgeting for anything heavier.

On the host. Everything else. Once the stream is Parquet, the normal scientific stack applies, and the question stops being what fits and starts being what is correct.

The useful rule is to push decisions to the device and leave analysis on the host. A device that transmits “someone entered” costs a fraction of the airtime and power of one that transmits every measurement — but you cannot write that detector until you have analysed a great many measurements on a host first.

Summary

In this section we looked at:

  • How to read the raw i8 buffer, and why the interleaving order is worth verifying rather than assuming.
  • The 64-point FFT layout, the 52 usable subcarriers, and the half-swap that puts them in frequency order.
  • The four ways raw CSI fails a consumer — missing samples, outliers, hardware-induced phase terms, scale drift — and why the phase terms are the ones that decide whether phase is usable at all.
  • PCA, the channel impulse response, and time-frequency analysis as the next layer up.
  • Where each kind of processing belongs, given what a microcontroller can afford.