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.
Recommended reading
The theory section below — Detection as hypothesis testing, and CFAR: the Neyman–Pearson rule, the likelihood ratio test, the ROC curve, square-law thresholds, and the cell-averaging CFAR recipe with its loss — the exact theory this lab implements; Lab 6.8’s signal-space section is its communications twin.
Richards Ch. 6 — detection fundamentals: radar detection as Neyman–Pearson hypothesis testing and constant-false-alarm-rate detection; Proakis & Salehi Ch. 4 for the optimum AWGN receiver.
Hayes Ch. 8 — the statistics of spectral estimates: the exponential/chi-square distribution of FFT-bin power under noise, which is what sets the CFAR math. Lab 6.4’s Error bars on the estimate restates the degrees-of-freedom result you need here.
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 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:
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\).
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
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).
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.
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.
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.
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).
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.
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.
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.