Lab 6.9 — CFAR Detection & the ROC

Course 2 syllabus · Module 6 · Prev: « Lab 6.8 · Next: Lab 7.1 »

Goal

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.

Equipment & parts

  • 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.3 arm_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):

source venv/bin/activate   # numpy, matplotlib, pyserial
mkdir -p host/lab-6-9 captures/lab-6-9

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:

\[ \hat P_n = \frac{1}{N}\sum_{i\in\text{ref}} |X_i|^2, \qquad T = \alpha\,\hat P_n, \qquad \text{declare } H_1 \text{ if } |X_\text{CUT}|^2 > \alpha\,\hat P_n . \]

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

\[ P_{\text{fa}} = \Big(1 + \frac{\alpha}{N}\Big)^{-N} \quad\Longrightarrow\quad \alpha = N\big(P_{\text{fa}}^{-1/N} - 1\big). \]

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}\):

\[ \alpha = 16\big((10^{-3})^{-1/16} - 1\big) = 16\big(10^{3/16}-1\big) \approx 16(1.540-1) \approx 8.64 \;(\approx 9.4\text{ dB}), \]

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).

  1. 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\).
  2. 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.

  1. 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 */
    }
  2. 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.

  1. 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.
  2. 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.

  1. 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.