Turn a spectrum into a decision. The FFT (Lab 6.3) and noise-floor (Lab 6.4) labs measure signals; this one detects them — declares “tone present / absent” in each bin at a constant false-alarm rate (CFAR), no matter how the noise floor drifts. A fixed threshold fails the moment the noise level changes (temperature, gain, interference); cell-averaging CFAR estimates the local noise from neighboring bins and floats the threshold with it, holding \(P_{\text{fa}}\) constant. You’ll implement CA-CFAR on the STM32 over the live spectrum, inject tones at controlled SNR, sweep the threshold, and measure the ROC curve — detection probability vs. false-alarm probability — the language every detector is specified in. This is the Neyman–Pearson hypothesis testing of Course 1 Section 13 (and the radar-detection/CFAR and communications-receiver companions there) implemented and characterized on your bench.
Recommended reading
Course 1 Section 13 — statistical detection and hypothesis testing. The Richards companion there (detection fundamentals — radar detection as Neyman–Pearson hypothesis testing and constant-false-alarm-rate detection) is the exact theory; the P&S optimum-receiver companion in Section 12 is its communications twin.
B&P — sampling distributions, confidence intervals, and the statistics of spectral estimates (the exponential/chi-square distribution of FFT-bin power under noise), which set the CFAR math.
Lyons Ch. 11 — signal averaging and the statistics of the noise floor you threshold against.
STM32 Nucleo-64 (NUCLEO-L476RG) running the FFT + CFAR in real time on captured ADC blocks.
MCP4725/on-chip DAC to inject one or more test tones at programmable amplitude (to set SNR); optionally sum in analog noise via an MCP6002 adder, or add AWGN in firmware for precise SNR control.
Siglent SDS1104X-E to confirm the injected tones and their levels.
Host with pyserial + NumPy to log per-bin detections and build the ROC curve (sweep threshold, count hits/false alarms against ground truth).
Wiring & bench setup
The signal chain is the Lab 6.3/6.4 front end with a controllable tone injected: DAC → series resistor → ADC, with the scope confirming the levels that set SNR.
flowchart LR TONE["STM32 DAC1_OUT1<br/>PA4 = A2<br/>test tone table, timer-paced"] R["Series R ≈ 1 kΩ"] RX["STM32 ADC1_IN5<br/>PA0 = A0<br/>FFT + CFAR"] SCOPE["Siglent SDS1104X-E<br/>CH1: tone level check"] SUM["MCP6002 adder<br/>(optional analog noise)"] TONE --> R --> RX TONE -.-> SUM -.-> RX R -.-> SCOPE
flowchart LR
TONE["STM32 DAC1_OUT1<br/>PA4 = A2<br/>test tone table, timer-paced"]
R["Series R ≈ 1 kΩ"]
RX["STM32 ADC1_IN5<br/>PA0 = A0<br/>FFT + CFAR"]
SCOPE["Siglent SDS1104X-E<br/>CH1: tone level check"]
SUM["MCP6002 adder<br/>(optional analog noise)"]
TONE --> R --> RX
TONE -.-> SUM -.-> RX
R -.-> SCOPE
Pin map (both ends are the same Nucleo — the loopback runs across the breadboard):
From
To
Pin/jack
A2 (PA4, DAC1_OUT1)
series R (~1 kΩ) on the breadboard
—
Series R (far side = injection node)
ADC input
A0 (PA0)
Nucleo GND
breadboard − rail
GND
Scope CH1 (10×)
injection node; ground clip → − rail
CH1
(Optional) Saleae CH0 + GND lead
D7 (PA8) block-timing toggle · − rail
CH0
(Optional second source) MCP4725: SCL → D15/PB8, SDA → D14/PB9, VDD → 3V3, GND → − rail, OUT → the MCP6002 adder (Lab 4.2)
second tone / analog noise summed toward A0
—
Part D’s second strong tone needs no extra wiring by default: sum both tones in the same DAC table. The MCP4725 + adder path above is the all-analog alternative.
Adding AWGN in firmware (the precise-SNR option in Equipment) needs no wiring at all — the loopback stays as drawn.
The mid-rail bias rides in the DAC tone table, with headroom for the sum of tones plus noise (see Safety); no bias network on the breadboard.
Safety & don’t-break-it
0–3.3 V, mid-rail biased input, with headroom for the sum of multiple injected tones plus noise — a clipped input creates harmonics/IMD that land in other bins and register as false targets, corrupting your \(P_{\text{fa}}\) measurement.
Guard the cell under test. If target energy leaks into the neighboring “noise” cells used to estimate the floor, the threshold rises with the target and masks it (self-masking). Guard cells around the cell under test (CUT) prevent this — sizing them is part of the lab, not an afterthought.
CFAR assumes homogeneous reference cells. At a band edge, next to a strong interferer, or where two targets are close, the cell-averaging assumption breaks and \(P_{\text{fa}}\) is no longer constant — know the failure modes (Part D) before trusting a detection.
No component hazard; the failure modes are statistical (a mis-estimated floor → wrong \(P_{\text{fa}}\)), which the ROC measurement exposes directly.
Project & environment setup
Firmware — reuse the Module 6 project (firmware/m6-dsp/, created in Lab 6.1; CMSIS-DSP linked — the CFAR runs on the Lab 6.3arm_rfft_fast_f32 output — and the 80 MHz clock set per the setup essentials). Confirm the .ioc has:
CubeMX page
Setting
Analog → ADC1
IN5 (PA0), external trigger TIM2 TRGO, DMA circular (Lab 5.3) — the same front end Labs 6.3/6.4 used
Timers → TIM2
TRGO = Update event; prescaler/ARR from your chosen \(f_s\)
Analog → DAC1
OUT1 (PA4), trigger TIM2 TRGO, DMA from the tone table — programmable amplitude sets the SNR
GPIO
PA8 (D7) output — optional block-timing toggle (the DWT counter is the primary timer)
Connectivity → USART2
115200 8-N-1 — stream per-bin powers and detection flags to the host
Connectivity → I2C1
Only if using the MCP4725 second source: 100 kHz, PB8/PB9
Host — the histogram check (Part A) and the ROC counting (Part C) run in the course venv (Toolchain):
Two scripts in host/lab-6-9/, both yours to write: the bin-statistics check (Part A — histogram one bin’s power over many frames, exponential fit, numpy + matplotlib) and the ROC builder (Part C — for each SNR and \(\alpha\), count hits on the target bin and false alarms on known-empty bins from the logged detections; pyserial collects the frames).
Keep this lab’s reconciliation in host/lab-6-9/analysis.ipynb — the notebook convention — and export final figures next to it.
Where results go:
Artifact
Path
Bench note
docs/lab-6-9.md
Bin-power frames for the histogram (Part A)
captures/lab-6-9/bin-power.csv
Detection logs per SNR/\(\alpha\) (Part C)
captures/lab-6-9/detections-*.csv
Bin-power histogram + exponential fit
host/lab-6-9/bin-hist.png
ROC curves (CA-CFAR per SNR + fixed-threshold overlay)
host/lab-6-9/roc.png
CA-vs-OS masking comparison (Part D)
host/lab-6-9/masking.png
Background
Detection as hypothesis testing. In each bin the receiver decides between \(H_0\) (noise only) and \(H_1\) (signal + noise). A threshold \(T\) gives
\[
P_{\text{fa}} = \Pr(\,|X|^2 > T \mid H_0), \qquad P_{d} = \Pr(\,|X|^2 > T \mid H_1).
\]
The Neyman–Pearson rule fixes \(P_{\text{fa}}\) and maximizes \(P_d\) — you choose your tolerable false-alarm rate and get the best detection it allows. Plotting \(P_d\) against \(P_{\text{fa}}\) as \(T\) varies is the receiver operating characteristic (ROC); a better detector (or higher SNR) bows the curve toward the top-left.
Why a fixed threshold fails. Under noise, the power in an FFT bin (magnitude-squared of a complex-Gaussian bin) is exponentially distributed with mean equal to the local noise power \(P_n\). So a fixed \(T\) gives \(P_{\text{fa}} = e^{-T/P_n}\) — which changes the instant \(P_n\) drifts. Double the noise power and a threshold set for \(P_{\text{fa}}=10^{-3}\) jumps to \(P_{\text{fa}}\approx 3\times10^{-2}\). Constant false-alarm rate requires a threshold that tracks\(P_n\).
Cell-averaging CFAR. Estimate the local noise power from \(N\) nearby reference cells (skipping guard cells adjacent to the CUT), and scale it:
Because the estimate\(\hat P_n\) is itself a random variable (a sum of \(N\) exponentials, i.e. gamma-distributed), the exact CA-CFAR false-alarm rate for a square-law detector is
As \(N\to\infty\) this collapses to the known-noise ideal \(\alpha=-\ln P_{\text{fa}}\); for finite \(N\), \(\alpha\) must be larger, and the gap is the CFAR loss — the SNR you pay for not knowing the noise power a priori (a couple dB for small \(N\), shrinking as \(N\) grows).
A worked threshold. For \(N=16\) reference cells and target \(P_{\text{fa}}=10^{-3}\):
versus the known-noise \(\alpha=-\ln 10^{-3}=6.91\) (\(8.4\) dB) — about a 1 dB CFAR loss at \(N=16\).
Procedure
Part A — Verify the noise statistics (host + STM32).
With no tone, capture many FFT frames and histogram a single bin’s power; confirm it is exponential (mean \(=P_n\)). This validates the distribution the CFAR math assumes and reuses the Lab 6.4 noise floor as \(P_n\).
Compute \(\alpha\) for a chosen \(N\) and \(P_{\text{fa}}\) from \(\alpha=N(P_{\text{fa}}^{-1/N}-1)\); tabulate \(\alpha\) for \(N\in\{8,16,32\}\) and note the CFAR loss shrinking with \(N\).
Part B — CA-CFAR on the STM32.
After the real-time FFT (Lab 6.3), slide a CFAR window across the power spectrum: for each CUT, average the \(N\) reference cells (split into leading/lagging halves, skipping \(G\) guard cells each side), form \(T=\alpha\hat P_n\), and flag detections. Illustrative core:
/* power[]: |X[k]|^2 spectrum; G guard cells, N/2 ref cells each side */for(int k = G + N/2; k < K - G - N/2; k++){float noise =0.0f;for(int j =1; j <= N/2; j++) noise += power[k-G-j]+ power[k+G+j];/* leading + lagging refs */ noise /= N;/* CA noise estimate */ detect[k]= power[k]> alpha * noise;/* CFAR test */}
Inject a single tone (per Wiring & bench setup); confirm it is flagged and that muting it (or moving the whole noise floor by changing input gain) leaves the false-alarm rate on the empty bins unchanged — the CFAR property a fixed threshold lacks.
Part C — Measure the ROC.
Sweep the injected tone amplitude to set several SNRs. For each SNR, sweep \(\alpha\) over a range; for each \(\alpha\), run thousands of frames and count \(P_d\) (fraction of frames the target bin is flagged) and \(P_{\text{fa}}\) (flag rate on known-empty bins). Plot \(P_d\) vs. \(P_{\text{fa}}\) — one ROC curve per SNR.
Overlay a fixed-threshold detector’s ROC and show it degrades (or its operating point drifts off the specified \(P_{\text{fa}}\)) when you shift the noise floor mid-run, while CA-CFAR holds.
Part D — Where CA-CFAR breaks.
Place a second strong tone inside the reference window of a weak one and watch CA-CFAR raise the threshold and mask the weak target. Swap in OS-CFAR (order-statistic: use the \(k\)-th sorted reference cell instead of the mean) and show it survives the interferer at a modest extra CFAR loss — the classic homogeneous-vs-heterogeneous trade.
Deliverable & expected results
Capture: the bin-power histogram (exponential fit), the ROC curves (CA-CFAR at several SNRs, fixed-threshold overlay), and the CA-CFAR-vs-OS-CFAR masking comparison.
Quantity
Predicted
Measured
Bin power under noise
exponential, mean \(P_n\)
…
\(\alpha\) for \(N=16\), \(P_{\text{fa}}=10^{-3}\)
\(\approx 8.64\) (\(9.4\) dB)
…
Known-noise \(\alpha=-\ln P_{\text{fa}}\)
\(6.91\) (\(8.4\) dB)
…
CFAR loss at \(N=16\)
\(\approx 1\) dB (shrinks with \(N\))
…
Fixed threshold when floor drifts
\(P_{\text{fa}}\) changes; CFAR constant
…
ROC vs. SNR
bows toward top-left as SNR ↑
…
Weak target near strong one
CA masks; OS-CFAR survives
…
Analysis & reconciliation
First validate the assumption: if the single-bin power histogram isn’t exponential, the CFAR \(\alpha\) formula doesn’t apply — a non-exponential floor usually means residual tones/spurs (not pure noise) or too few averages. Reconcile the measured\(P_{\text{fa}}\) against the design value: with \(\alpha\) computed from \(N(P_{\text{fa}}^{-1/N}-1)\) the empirical false-alarm rate should land near target; a systematic offset points to correlated reference cells (windowed FFT bins aren’t perfectly independent — the effective \(N\) is smaller than the cell count) or target/spur energy leaking past the guard cells. Confirm the CFAR property directly: shift the noise floor and watch CA-CFAR’s \(P_{\text{fa}}\) stay put while the fixed threshold’s walks off — this is the entire reason CFAR exists. Read the ROC curves as SNR gauges: the knee moving toward the top-left as you raise tone amplitude is the detectability improving, and the CA-vs-OS comparison shows the price of robustness to a nearby interferer (OS-CFAR’s ~extra dB of loss buys immunity to the masking that fooled CA-CFAR).
Cross-platform ports & language variants
See the syllabus Implementation tracks. CFAR is a block operation over the spectrum — a sliding window of adds and one compare per bin — that follows the FFT it runs on, so its platform trade tracks Lab 6.3’s.
STM32 (C, and Rust). The sliding sum is cheap (a running-sum trick makes it \(O(1)\) per bin); the whole detector adds little to the FFT budget — measure it with the DWT counter (setup essentials). It runs comfortably per block in real time. In Rust, the reference-window indexing (leading/lagging halves around guard cells) is exactly the kind of off-by-one/out-of-bounds slip the bounds-checker catches at the edges of the spectrum.
Raspberry Pi 5 / Jetson. CFAR parallelizes trivially — every CUT is independent — so the ROC characterization (thousands of frames × many SNRs × many \(\alpha\)) is a natural batch/GPU job in float64. And 2-D CFAR over a range–Doppler map (the real radar case, Lab 6.7 + Doppler) is a genuine GPU win. The MCU still owns the live, per-block decision at the data rate.
Same STM32: bare-metal vs RTOS. Bare-metal runs FFT→CFAR→detection-list in the block path. Under FreeRTOS, the FFT/CFAR stays on the sample-rate task and the detection list is handed to a lower-priority reporting/decision task (osMessageQueuePut) that applies track logic, logging, and policy — isolating the “what do we do about a detection” code from the hard-deadline detection code. Measure the handoff latency against the block period; this is the Lab 7.2 pattern applied to a detector.
Going further
Chain the receiver. Feed the compressed-pulse output of Lab 6.7 into this CFAR to build the full matched-filter → detector chain, and detect a weak echo in noise at a specified \(P_{\text{fa}}\).
Other CFAR variants. Add GO-CFAR / SO-CFAR (greatest-of / smallest-of the two reference halves) and compare their behavior at clutter edges vs. CA and OS.
2-D CFAR. Run CFAR over a range–Doppler or time–frequency map instead of a 1-D spectrum — the real radar/sonar detector.
Sequential detection. Require \(M\)-of-\(N\) consecutive frames to declare a detection and measure how the integration trades latency for a lower effective \(P_{\text{fa}}\) — Neyman–Pearson across time.