- WiFi CSI-based indoor positioning system for self-hosted home environments - docs/plan/plan.md: full 9-phase implementation plan (65 gaps closed by analysis) - docs/research/: CSI fundamentals, physics, algorithms, signal processing, mesh topology, accuracy limits, literature - docs/notes/: recovery mechanisms, simulation testing, UX visualization - .marathon/instruction.md: per-iteration marathon instructions with detailed commit format - .marathon/start.sh: GLM-5 tmux launcher via ZAI proxy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6.1 KiB
Signal Processing Pipeline
Raw int8 I/Q pairs from the ESP32 require several processing steps before they are usable for sensing.
Step 1: Acquire Raw I/Q Pairs
From wifi_csi_info_t.buf (int8 array). Separate LLTF and HT-LTF portions based on len and the enabled field flags in wifi_csi_config_t. Skip null/guard subcarriers per the ESP-IDF subcarrier index tables.
Step 2: Compute Complex CSI
# Raw buffer: alternating Im, Re pairs
im = csi_buf[0::2].astype(float) # imaginary
re = csi_buf[1::2].astype(float) # real
amplitude = np.sqrt(re**2 + im**2)
phase_raw = np.arctan2(im, re) # range [−π, π], contaminated
Step 3: Phase Sanitisation
Raw phase is contaminated by several hardware error sources.
a. Sampling Frequency Offset (SFO) / Packet Detection Delay (PDD)
Creates a linear phase slope across subcarriers: φ(k) = φ_true(k) + a·k + b.
Mitigation — linear regression across subcarrier index k:
k_idx = np.arange(len(phase))
phase_unwrapped = np.unwrap(phase_raw)
slope, intercept = np.polyfit(k_idx, phase_unwrapped, 1)
phase_corrected = phase_unwrapped − (slope * k_idx + intercept)
b. Phase Unwrapping
Raw arctan2 output wraps at ±π. Unwrap before linear fitting:
phase_unwrapped = np.unwrap(phase_raw)
c. IQ Imbalance
Hardware imperfection causing M-shaped amplitude and S-shaped phase distortion as a function of subcarrier index. Stable across time but varies between TX-RX pairs. Calibrate with a reference measurement (cable-connected TX/RX); subtract the template from operational data.
d. AGC (Automatic Gain Control) Uncertainty
Each packet's CSI amplitude is scaled by a random gain factor β. Options:
- Compensate using reported RSSI and noise floor from
rx_ctrl.rssi - Disable AGC in driver (impacts packet decoding reliability — not recommended for production)
e. Central Frequency Offset (CFO)
Random frequency shift between TX and RX clocks → time-varying phase bias. Estimated using the known 4 µs spacing between consecutive HT-LTFs within a single packet (phase difference between LTF1 and LTF2 encodes CFO directly).
f. Cross-Node Phase Calibration
Phases are not coherent across independent nodes — each has its own hardware oscillator. For multi-node fusion:
- Reference-link normalisation: divide each link's CSI by the CSI of a known reference path
- Conjugate multiplication: between antenna pairs on the same device to cancel common hardware noise
- Full coherence: only achievable with shared reference clock hardware (ESPARGOS platform)
Step 4: Subcarrier Selection
Not all subcarriers are equally informative.
Exclude:
- Guard/null subcarriers (indices ±28 to ±32, DC at 0)
- Pilot subcarriers (carry constant-phase pilots, not data — though they can serve as phase references)
Prefer:
- Mid-band subcarriers (edge subcarriers suffer more from the M-shaped hardware response)
- Subcarriers with higher temporal variance (carry more motion information)
NBVI algorithm (Normalised Band Variance Index — from ESPectre): selects 12 non-consecutive subcarriers that maximise information while avoiding correlation between adjacent subcarriers.
Step 5: Feature Extraction
Motion Detection
Variance or standard deviation of CSI amplitude over a sliding window:
motion_score = np.var(amplitude_window, axis=0).mean()
Compare to baseline variance. Threshold crossing = motion event.
Velocity / DFS
Short-Time Fourier Transform (STFT) of complex CSI over time. The Doppler frequency shift:
f_d = 2 · v · cos(θ) / λ
For walking at 1 m/s at 2.4 GHz: f_d ≈ 16 Hz.
from scipy.signal import stft
freqs, times, Zxx = stft(csi_complex, fs=20.0, nperseg=64)
doppler_spectrum = np.abs(Zxx)
Localization (AoA-ToF)
Channel Impulse Response via IFFT across subcarriers:
cir = np.fft.ifft(H_complex, n=256)
tof_est = np.argmax(np.abs(cir)) / (256 * subcarrier_spacing) # seconds
Apply 2D MUSIC across subcarriers (virtual array) for joint AoA-ToF per SpotFi.
Breathing Rate
from scipy.signal import butter, filtfilt
b, a = butter(4, [0.1, 0.5], btype='bandpass', fs=20.0)
breathing_signal = filtfilt(b, a, amplitude_ts)
fft_spectrum = np.abs(np.fft.rfft(breathing_signal))
breathing_rate_hz = np.argmax(fft_spectrum) / len(breathing_signal) * 20.0
Step 6: Multi-Link Fusion
Per-link features are combined in the mothership positioning engine:
- For each link, compute deltaRMS = RMS(current_CSI − baseline)
- Accumulate Fresnel zone influence weights across the voxel grid
- Decay grid over time (factor ~0.95 per cycle) to allow blobs to move or disappear
- Find peaks via non-maximum suppression
- Apply Kalman/UKF to smooth peak positions over time
Timing Summary
| Stage | Where | Rate |
|---|---|---|
| CSI capture | ESP32 (csi_callback) | 10–100 Hz per link |
| I/Q → amplitude | ESP32 (optional) or mothership | Per packet |
| UDP send | ESP32 (transport.c) | Per packet |
| Baseline update | Mothership (baseline/ema.go) | Per received packet |
| Fresnel accumulation | Mothership (positioning/fresnel.go) | Per received packet |
| Blob extraction | Mothership | Per accumulation cycle |
| WebSocket publish | Mothership (hub/hub.go) | Per blob extraction |
| Dashboard render | Browser | ~10 Hz (rAF throttled) |
Key Practical Notes
- Phase is more sensitive than amplitude for detecting fine motion (breathing, small displacements) but requires sanitisation.
- Amplitude is more robust for gross motion and presence detection — use it for the primary pipeline.
- Subcarrier diversity is the main advantage of CSI over RSSI — some subcarriers may fade while others remain informative; averaging across subcarriers reduces noise.
- int8 dynamic range (−128 to +127) limits the detectable signal range per subcarrier. Objects very close to a node will saturate.
- channel_filter_en = false in
wifi_csi_config_tgives independent per-subcarrier data; setting it true smooths adjacent subcarriers and reduces per-subcarrier noise at the cost of spatial frequency resolution.