Build the single most important detection structure in radar, sonar, and digital communications: the matched filter, and its practical companion pulse compression. A matched filter is the linear filter that maximizes output SNR for a known signal shape in white noise — it is the optimum receiver front end. Pulse compression then lets you transmit a long coded pulse (lots of energy, easy on the transmitter) yet resolve time (range) as finely as a short pulse, by correlating the echo against a replica of the transmitted waveform. You’ll emit a linear-FM (LFM) chirp from the STM32 DAC, capture it back through a loopback, matched-filter it (as a correlation, computed with the FFT you already built in Lab 6.3), and measure the two things that make it worth the trouble: the time-bandwidth processing gain and the compressed-pulse resolution\(\approx 1/B\). This is the detection theory of Course 1 Section 12 turned into firmware.
Recommended reading
Course 1 Section 12 — signal detection: the matched filter and correlation receiver. The Richards companion there (radar waveforms — matched filter, the LFM pulse-compression waveform, the ambiguity function, and range–Doppler coupling) is the exact theory this lab implements.
Lyons Ch. 5, 13 — correlation as FIR filtering, fast correlation via the FFT, and the practical FFT-overlap machinery. Primary implementation reading.
Kuo — real-time correlation / block processing and fixed-point considerations on an MCU.
STM32 Nucleo-64 (NUCLEO-L476RG) with the Module 5 timer-triggered ADC + DMA project as the sampler and the on-chip DAC (or MCP4725) as the transmitter.
A loopback path DAC → ADC: the simplest is a direct wire with a series resistor and a mid-rail bias; a cleaner version buffers the DAC through an MCP6002 follower (Lab 4.1) and, optionally, sums in analog noise (Lab 4.2 as an adder).
Siglent SDS1104X-E to view the transmitted chirp and its spectrogram/FFT.
Host with pyserial + NumPy/SciPy to log the compressed-pulse output and cross-check against scipy.signal.correlate / chirp.
Wiring & bench setup
The signal chain: the on-chip DAC transmits the chirp, a series resistor closes the loopback into the ADC, and the scope watches the line (its FFT confirms the swept bandwidth).
No bias network is needed on the direct loopback: the mid-rail bias rides in the chirp table itself (see Safety) — the DAC codes center on 1.65 V.
Summing analog noise (Part D option): build the MCP6002 adder of Lab 4.2 between R and A0, and re-check clipping headroom.
Using the MCP4725 as transmitter instead: wire I²C exactly as Lab 3.3 (SCL → D15/PB8, SDA → D14/PB9, VDD → 3V3, GND → − rail), OUT → the same series R. Its I²C write rate limits the chirp bandwidth — the on-chip DAC is the better transmitter here.
Safety & don’t-break-it
0–3.3 V on both the DAC output and the ADC input. The chirp is a full-scale swing; bias it to mid-rail (1.65 V) with amplitude headroom so neither the DAC nor the ADC input clips. A clipped chirp is no longer LFM — its matched filter is mismatched and the sidelobes blow up.
The ADC pin is not 5 V tolerant. If you route the loopback through the 5 V op-amp rail, divide/clamp back into 0–3.3 V before the ADC (the global 3.3 V rule).
Match the replica to what you actually transmit, not what you intended. The DAC’s reconstruction, any loopback filtering, and quantization all shape the real transmitted pulse. Capturing the actual transmitted chirp once and using it as the replica (a “clean-channel calibration”) is the honest matched filter; using the ideal formula is the mismatched approximation — the lab measures the difference.
No component hazard; the failure modes are clipping (IMD in the sidelobes) and a replica/transmit mismatch (a smeared, lifted mainlobe).
Project & environment setup
Firmware — reuse the Module 6 project (firmware/m6-dsp/, created in Lab 6.1; CMSIS-DSP linked and the 80 MHz clock set per the setup essentials). Confirm/add in the .ioc:
CubeMX page
Setting
Analog → DAC1
OUT1 (PA4), trigger TIM2 TRGO, DMA from the chirp table — timer-paced playout as in Lab 3.3
Analog → ADC1
IN5 (PA0), external trigger TIM2 TRGO, DMA circular (Lab 5.3) — the same timer as the DAC, so transmit and capture share a time base (Procedure step 3)
Timers → TIM2
TRGO = Update event; prescaler/ARR from your chosen \(f_s\). Part A’s \(f_s = 48\) kHz doesn’t divide 80 MHz exactly (80 MHz / 1667 ≈ 47.99 kHz) — whichever you pick, use the actual\(f_s\) in the host math
GPIO
PA8 (D7) output — optional block-timing toggle (the DWT counter is the primary timer)
Connectivity → USART2
115200 8-N-1 — stream the compressed-pulse trace
Host — chirp design (Part A) and cross-checks run in the course venv (Toolchain):
Two scripts in host/lab-6-7/, both yours to write: a chirp/matched-filter design script (Part A — scipy.signal.chirp, autocorrelation and sidelobe check, Hamming-weighted replica, and export of the DAC table as a C array) and a verification script (Analysis — scipy.signal.correlate on the same captured block vs. the on-chip output).
Keep this lab’s reconciliation in host/lab-6-7/analysis.ipynb — the notebook convention — and export final figures next to it.
Where results go:
Artifact
Path
Bench note
docs/lab-6-7.md
Captured actual transmitted chirp = the replica (Part B step 4)
captures/lab-6-7/replica.csv
Compressed-pulse serial logs (per \(B\), per window)
captures/lab-6-7/compressed-B4k.csv, …
Compressed pulse + sidelobe plot
host/lab-6-7/compressed-pulse.png
Width-vs-\(B\) / processing-gain plot
host/lab-6-7/width-vs-B.png
Scope FFT of the transmitted chirp
captures/lab-6-7/chirp-fft.csv (Siglent CSV)
Background
The matched filter. For a known signal \(s[n]\) of length \(L\) in additive white noise of power \(\sigma^2\), the FIR filter that maximizes the output SNR at the decision instant is the time-reversed conjugate of the signal,
\[
h[n] = s^*[L-1-n],
\]
and its output is the cross-correlation of the received data with the signal replica,
An unwindowed LFM matched filter has a sinc-like output with a first range sidelobe of \(\approx -13.2\) dB. Amplitude-weighting the replica (e.g. a Hamming window) pushes the sidelobes down to \(\approx -42\) dB at the cost of a slightly wider mainlobe and a small (\(\approx 1\)–\(1.5\) dB) SNR (mismatch) loss — the classic sidelobe/mainlobe/SNR trade of Lab 6.3, here on the matched filter instead of the spectrum.
Fast correlation via the FFT. Direct correlation of an \(L\)-tap replica against an \(N\)-sample record is \(O(NL)\); for a long chirp that is expensive. The equivalent frequency-domain computation is
\[
y = \text{IFFT}\big\{\,R(f)\cdot S^*(f)\,\big\},
\]
\(O(N\log N)\), reusing exactly the arm_rfft_fast_f32 path from Lab 6.3 (with zero-padding to avoid circular-correlation wrap-around). This is why matched filtering lives comfortably in real time on the Cortex-M4F.
Procedure
Part A — Design the chirp and the matched filter (host).
Pick audio-band parameters the DAC/ADC can produce cleanly: e.g. \(f_s = 48\) kHz, sweep \(f_0 = 2\) kHz to \(2+B\) with \(B = 4\) kHz over \(T = 10\) ms (\(L = f_s T = 480\) samples, \(TB = 40\)). Generate the chirp with scipy.signal.chirp and its matched filter \(h[n]=s[L-1-n]\).
On the host, correlate the clean chirp with itself and confirm the compressed mainlobe width is \(\approx 1/B = 0.25\) ms (\(\approx 12\) samples at 48 kHz) and the first sidelobe is \(\approx -13.2\) dB. Repeat with a Hamming-weighted replica and confirm \(\approx -42\) dB sidelobes.
Part B — Transmit and capture on the STM32.
Store one period of the chirp in a DAC table; play it out (timer-paced, as in Lab 3.3) into the loopback (per Wiring & bench setup) while the timer-triggered ADC+DMA (Lab 5.3) captures the returned block, mid-rail biased. Trigger capture from the same timer so transmit and receive share a time base.
Capture the actual transmitted chirp once (high SNR, no added noise) and use it as the replica — this calibrates out the DAC reconstruction and loopback shaping.
Part C — Matched filter in real time.
In the block callback, zero-pad the received frame and the replica to a common FFT length \(N\), compute \(Y = \text{IFFT}(R\cdot \bar S)\) with arm_rfft_fast_f32 (forward on \(r\), precomputed \(\bar S = \text{conj}(\text{FFT}(s))\), complex multiply, inverse), and find the peak magnitude and its index (the delay). Illustrative inner step:
/* precomputed: Sconj[k] = conj(FFT(replica)), length Nf */arm_rfft_fast_f32(&fft, rx_padded, R,0);/* R = FFT(received) */for(int k =0; k < Nf; k++)/* Y = R .* conj(S) */ cmul(&Y[2*k],&R[2*k],&Sconj[2*k]);arm_rfft_fast_f32(&fft, Y, y,1);/* y = matched-filter output */arm_max_f32(y_mag, N,&peak,&peak_idx);/* compressed pulse */
Stream the compressed-pulse trace to the host; measure the mainlobe width, the peak-to-sidelobe ratio, and the peak index (delay).
Part D — Processing gain and resolution.
Inject controlled noise to set SNR: either add AWGN in firmware to the captured block (precise SNR control) or sum analog noise in the loopback. Compare the SNR of the raw received chirp to the SNR at the matched-filter peak; confirm the gain is \(\approx 10\log_{10}(TB) = 16\) dB.
Vary \(B\) (2 kHz vs. 4 kHz vs. 8 kHz) and confirm the compressed width tracks \(1/B\) while the uncompressed pulse length \(T\) stays fixed — the core claim of pulse compression. Repeat the whole measurement with a Hamming-weighted replica and quantify the sidelobe drop and the SNR-loss it costs.
Deliverable & expected results
Capture: the compressed-pulse trace (linear and dB), the peak-to-sidelobe ratio unwindowed vs. Hamming, the measured processing gain, and the compressed width vs. \(B\).
Quantity
Predicted
Measured
Chirp: \(f_s=48\)k, \(B=4\)k, \(T=10\) ms
\(L=480\), \(TB=40\)
…
Compressed mainlobe width \(\approx 1/B\)
\(0.25\) ms (\(\approx 12\) samples)
…
Processing gain \(10\log_{10}(TB)\)
\(16.0\) dB
…
First sidelobe, unwindowed
\(-13.2\) dB
…
First sidelobe, Hamming replica
\(\approx -42\) dB
…
Hamming SNR (mismatch) loss
\(\approx 1\)–\(1.5\) dB
…
Compressed width at \(B=8\) kHz
\(0.125\) ms (halves)
…
Analysis & reconciliation
Confirm the on-chip matched-filter output matches the host scipy.signal.correlate(rx, replica) for the same captured block — a mismatch is almost always circular-correlation wrap-around (insufficient zero-padding) or a replica taken from the ideal chirp instead of the actually transmitted one. Reconcile the measured processing gain against \(10\log_{10}(TB)\): falling short by a couple dB usually means the transmitted chirp is band-limited by the DAC reconstruction or loopback (effective \(B\) smaller than designed) — measure the true swept bandwidth on the scope FFT and recompute. Check the compressed width against \(1/B\) at each bandwidth; if it stops shrinking as you raise \(B\), you’ve hit the sampling limit (\(B\) approaching \(f_s/2\)) or the loopback’s own bandwidth. The Hamming trade should show cleanly: sidelobes fall from \(\sim-13\) dB to \(\sim-42\) dB, the mainlobe broadens, and the peak SNR drops by the ~1 dB mismatch loss — the same window trade as the spectrum analyzer, now on the detector.
Cross-platform ports & language variants
See the syllabus Implementation tracks. Matched filtering by FFT is a block / batched / throughput-bound operation — the same class as the FFT (6.3) and long-FIR (6.1) labs — so the platform trade is the opposite of Goertzel’s.
STM32 (C, and Rust). The FFT-domain correlation runs in real time on the M4F for modest \(N\); the interesting embedded content is the fixed-point version, where the long coherent integration of \(TB\) samples means the correlation accumulator’s dynamic range grows by \(\approx 10\log_{10}(TB)\) dB and will overflow a naive Q15 sum — you must scale or use a wider accumulator. In Rust (#![no_std], microfft or a CMSIS-DSP binding) that overflow is an explicit saturating/widening choice rather than C’s silent wrap.
Raspberry Pi 5 / Jetson. For a long chirp and a long record — or many range gates at once — the batched \(O(N\log N)\) correlation is exactly what the SBC/GPU amortizes well; float64 removes the accumulator-overflow worry entirely. The Jetson can matched-filter many pulses/beams in parallel. The MCU still owns the case where the decision must be made now, at the pulse repetition interval, with bounded latency.
Same STM32: bare-metal vs RTOS. Bare-metal runs the whole transmit→capture→correlate→peak-find sequence in the DMA-complete path. Under FreeRTOS, the DMA callback hands the captured block to a correlator task (osMessageQueuePut from the ISR → task), which owns the FFT/IFFT and peak detection; measure the added ISR-to-task latency against the pulse repetition interval and confirm the deadline margin. Same fast-path/decision split as Lab 6.5 and the setup that Lab 7.2 generalizes.
Going further
Turn it into a sonar. Route the chirp through a small speaker and capture the echo with a USB/I²S mic (the Module 8 audio front end); the matched-filter peak index is now a real round-trip delay — measure the distance to a wall and confirm \(d = c_\text{air}\,\tau/2\). Pulse compression on your own bench.
Ambiguity function & Doppler. Move the reflector (or add a synthetic frequency offset) and watch the range–Doppler coupling of the LFM waveform — the mainlobe shifts in delay with Doppler, exactly the Richards Ch. 4 ambiguity-function result.
Barker / phase codes. Replace the LFM with a 13-bit Barker code and compare: a phase-coded pulse compresses to one chip with a \(-22.3\) dB (\(1/13\)) sidelobe floor — a different point on the same time-bandwidth trade.
Detection back-end. Feed the compressed-pulse output into the CFAR detector of Lab 6.9 to declare targets at a constant false-alarm rate — matched filter then detector, the full radar receiver chain.