Lab 6.9 — CFAR Detection & the ROC

Course 3 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 the theory section below (and the Richards radar-detection/CFAR and P&S optimum-receiver readings) 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 labs/lab-6-9/host labs/lab-6-9/captures

Two scripts in labs/lab-6-9/host/, 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 labs/lab-6-9/host/analysis.ipynb — the notebook convention — and export final figures next to it.

Where results go:

Artifact Path
Bench note labs/lab-6-9/notes.md
Bin-power frames for the histogram (Part A) labs/lab-6-9/captures/bin-power.csv
Detection logs per SNR/\(\alpha\) (Part C) labs/lab-6-9/captures/detections-*.csv
Bin-power histogram + exponential fit labs/lab-6-9/host/bin-hist.png
ROC curves (CA-CFAR per SNR + fixed-threshold overlay) labs/lab-6-9/host/roc.png
CA-vs-OS masking comparison (Part D) labs/lab-6-9/host/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\).

Theory — Detection as Hypothesis Testing, and CFAR

Detection is binary hypothesis testing

Decide \(H_0\) (interference only) vs \(H_1\) (target \(+\) interference); the error currencies are the false-alarm probability \(P_{FA}\) and the detection probability \(P_D\). Radar cannot price the two errors, so it uses the Neyman–Pearson rule: maximize \(P_D\) subject to \(P_{FA} \le \alpha\) — whose solution is always the likelihood ratio test: compare \(\Lambda(\mathbf{x}) = p(\mathbf{x} \mid H_1)/p(\mathbf{x} \mid H_0)\) (or its log) to a threshold set by the \(P_{FA}\) budget. Sweeping the threshold traces the ROC curve, \(P_D\) against \(P_{FA}\): each threshold is one operating point, and the whole curve is the detector’s quality. For a known signal in Gaussian noise (Course 1 Lesson 20’s log-likelihoods) the LRT is the matched filter of Lab 6.7 followed by a threshold; with unknown phase it becomes the envelope detector. For square-law-detected noise (exponential PDF) the threshold for a desired false-alarm rate is \(T = -\sigma^2 \ln P_{FA}\), and a Swerling-1 fluctuating target obeys the tidy \(P_D = P_{FA}^{1/(1+\bar\chi)}\) at average SNR \(\bar\chi\).

Cell-averaging CFAR

The fixed threshold needs \(\sigma^2\), which in the field varies by tens of dB (clutter, jamming, weather). CFAR processing estimates it from the data. The cell-averaging recipe: (1) slide a window over the range(–Doppler) map; (2) for each cell under test, average \(N\) reference cells, excluding guard cells around the CUT so a straddling target does not pollute the estimate — the sample mean is exactly the ML estimate of the exponential parameter; (3) threshold at \(\hat T = \alpha\, \hat\sigma^2\) with

\[ \alpha = N\left( P_{FA}^{-1/N} - 1 \right) \qquad \Longleftrightarrow \qquad P_{FA} = \left[ 1 + \frac{\alpha}{N} \right]^{-N}, \]

so the achieved false-alarm rate depends only on \(N\) and \(\alpha\)not on the interference power. The finite-\(N\) estimate costs a CFAR loss (a threshold above the ideal, shrinking as \(N\) grows), and the two assumptions — homogeneous interference, target-free reference cells — fail instructively: clutter edges inflate the estimate and neighboring targets mask each other; greatest-of/smallest-of and order-statistic variants are the standard fixes.

Worked by hand

P2 (thresholds and CFAR loss). Square-law detector, known \(\sigma^2\): \(P_{FA} = 10^{-4}\) needs \(T/\sigma^2 = -\ln 10^{-4} = 9.21\) (9.6 dB); \(10^{-6}\) needs \(13.8\) (11.4 dB). Cell-averaging CFAR with \(N = 16\) at \(P_{FA} = 10^{-4}\): \(\alpha = 16(10^{1/4} - 1) = 12.45\), against the ideal \(9.21\) — a CFAR loss of \(10\log_{10}(12.45/9.21) \approx 1.3\) dB.

Theory exercises

Theory exercise 2 [Hand] — (Richards Ch. 6, prob. 1.) Under \(H_0\) the observation has PDF \(p_x(x \mid H_0) = \alpha e^{-x/\alpha}\), \(0 \le x < \infty\); under \(H_1\), \(p_x(x \mid H_1) = \beta e^{-x/\beta}\), with \(\beta > \alpha\). Find the likelihood ratio and the log likelihood ratio, and show the LRT reduces to comparing \(x\) itself to a threshold.

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.

Jetson Orin Nano — detailed procedure (embedded Linux)

Two Jetson-shaped jobs: the ROC at statistical depth (the Monte-Carlo run the MCU could never finish), and CFAR chained onto Lab 6.7’s batched matched-filter output. Reuse the Lab 6.1 Jetson harness conventions; board config in the Jetson setup essentials.

  1. mkdir -p labs/lab-6-9/edge; copy the lab’s captured spectra (noise-only and tone-present frames) and your host CA-CFAR reference implementation’s output on them as the arbiter.
  2. CPU port: the same portable-C sliding-window CFAR (shared/ kernel) over the captured frames; verify detection lists match the STM32 output frame-for-frame (same data, same \(\alpha\), same guards — any diff is an indexing bug at the spectrum edges, exactly what this port is good at flushing out).
  3. The million-frame ROC: in CuPy, synthesize noise frames in bulk (F × N arrays), run the vectorized CFAR across all frames at once (the sliding sum is a cumsum difference — stays vectorized), and count false alarms at each \(\alpha\) over, say, 10⁶ frames. Measured \(P_{fa}\) vs the \(\alpha = N(P_{fa}^{-1/N}-1)\) design equation, with tight error bars down to \(P_{fa} = 10^{-5}\) and beyond — depth the bench alone can’t reach. Repeat with injected tones across an SNR grid for the full ROC surface.
  4. Chain it: take the batched pulse-compression output from Lab 6.7’s Jetson procedure and CFAR it — matched filter → CFAR on one platform, the receiver chain end-to-end, throughput measured in pulses/second.
  5. Save the ROC figures and timing CSVs to labs/lab-6-9/edge/; note in notes.md where the measured \(P_{fa}\) deviates from design at small \(N\) (finite-window effects the formula’s derivation assumes away).

Raspberry Pi 5 differences: steps 1–2 identical, step 3 at reduced F in NumPy (performance governor); no GPU chain.

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.