Measure how quiet your acquisition chain actually is. You’ll characterize the STM32 ADC’s noise floor, estimate the power spectral density (PSD) of the noise with Welch’s method (averaged, windowed periodograms) to beat down the variance of a single FFT, and from the measured noise convert to real figures of merit: SNR and ENOB (effective number of bits). This is the measurement that separates a spec sheet from reality — a 12-bit ADC almost never gives 12 real bits — and it’s the skill that lets you say, with numbers, “this is the smallest signal my system can see.” Every converter bring-up, sensor front-end, and RF/audio design ends here.
Recommended reading
Hayes Ch. 3 — random processes, autocorrelation, and the power spectral density: the definition of the thing you’re estimating.
Hayes Ch. 8 — spectrum estimation: the periodogram, its variance problem, and Welch/Bartlett averaging as the fix. Primary reading for the method.
Lyons Ch. 10 — averaging in the DFT/time domain: processing gain and why averaging trades resolution for a lower, smoother noise floor.
Hayes Ch. 8 again, for the error side — periodogram bias and variance are what turn your noise-floor number into a number with error bars. The two results this lab actually spends are restated under Error bars on the estimate below.
Course 1 Lesson 24 — probability, expectation, variance, and the limit theorems. Welch’s method is the law of large numbers applied to periodograms: averaging \(K\) independent estimates cuts the variance by \(K\). This is the theory you’re spending.
Equipment & parts
STM32 Nucleo-64 (NUCLEO-L476RG) with the Module 5 timer-triggered ADC + DMA project.
A grounded ADC input (a jumper from the analog pin to a mid-rail bias, or to ground through a known source impedance) for the noise-floor run; and the MCP4725 DAC to inject a known small tone for the SNR run.
Siglent SDS1104X-E scope to sanity-check the input and to compare its own FFT noise floor.
Host with numpy/scipy (scipy.signal.welch) and pyserial to collect long records and estimate the PSD off-board, plus an optional on-chip Welch implementation.
Wiring & bench setup
Two alternative sources into the same ADC pin, one at a time: Part A grounds the measurement in a stiff mid-rail bias (no signal), Part C swaps in the MCP4725 near-full-scale tone; long records stream to the host over the ST-LINK VCP either way.
The 1 kΩ‖1 kΩ tap (500 Ω) + 100 nF is the low-impedance, stiff bias the Safety section demands — build it right at the pin with the shortest leads on the board.
Unplug the scope probe for the actual noise record. The probe lead is an antenna; its pickup would be billed to the ADC. Probe only to sanity-check the level, then remove it.
Safety & don’t-break-it
The input still must sit inside 0–3.3 V. For a true “grounded input” noise measurement, don’t literally tie the ADC pin to 0 V if your front-end op-amp is single-supply — bias it to mid-rail (~1.65 V) through a low impedance so the noise you measure is the converter’s, not a rail-clip artifact.
Source impedance matters. A high-impedance input pin picks up mains hum and thermal noise — for the converter-noise measurement, drive the pin from a low impedance (a buffer output or a small resistor to a stiff bias). Otherwise you’ll measure your breadboard’s antenna behavior, not the ADC.
Grounding and shielding. Keep leads short, share a single ground, and expect a 50/60 Hz line and its harmonics to show up — identify them so you don’t mistake pickup for converter noise.
No component-damage hazard here; the risk is measuring the wrong noise source and drawing a wrong conclusion.
Project & environment setup
Firmware — reuse the Module 6 projectfirmware/m6-dsp/ (created in Lab 6.1). Nothing new to enable; confirm the .ioc still has:
CubeMX page
Setting
ADC1 + DMA + TIM2
IN5 (PA0), circular half-word DMA, TRGO at \(f_s = 16\) kHz — as Lab 6.1
Connectivity → I2C1
400 kHz — the Part C MCP4725 tone
Connectivity → USART2
115200 8-N-1 VCP — a \(2^{18}\)-sample record (512 kB raw) takes ~45 s at this baud; raise it in the .ioc if that’s tedious
Software Packs
ARM CMSIS-DSP — only needed for the optional Part D on-chip Welch (setup essentials)
Host — the PSD estimation runs on the Mac in the course venv (Toolchain):
Two scripts under labs/lab-6-4/host/ (you write them): capture_record.py — pyserial pulls the long raw record into a binary file; psd_enob.py — the code histogram, the Part B scipy.signal.welch call with the \(K\)-sweep, and the Part C SNR → ENOB computation with matplotlib plots.
Keep this lab’s reconciliation in labs/lab-6-4/host/analysis.ipynb — the notebook convention — and export final figures next to it.
The power spectral density\(S_{xx}(f)\) describes how a random signal’s power is distributed over frequency; for a wide-sense-stationary process it is the Fourier transform of the autocorrelation (Wiener–Khinchin),
\[
S_{xx}(f) = \sum_{m=-\infty}^{\infty} r_{xx}[m]\, e^{-j 2\pi f m / f_s}, \qquad r_{xx}[m] = \mathbb{E}\{x[n]\,x[n+m]\}.
\]
Total power is the integral of the PSD, \(\mathbb{E}\{x^2\} = \int S_{xx}(f)\,df\).
The periodogram and its variance problem. The natural estimate from one length-\(N\) record is the periodogram\(\hat{S}(f_k) = \frac{1}{N}|X[k]|^2\). It is asymptotically unbiased but its variance does not decrease with \(N\) — a single periodogram is a noisy estimate no matter how long the record; it just gets more (equally noisy) frequency points. That’s why a raw FFT of noise looks like grass.
Welch’s method. Split the record into \(K\) segments (optionally overlapping ~50%), apply a window \(w[n]\) to each, take the periodogram of each, and average them:
where \(U\) is the window’s power normalization. Averaging \(K\) approximately independent periodograms reduces the estimator variance by roughly a factor \(K\) (Course 1 Lesson 24 — the variance of a mean of \(K\) i.i.d. estimates is \(1/K\) of the single-sample variance):
So the noise floor estimate gets smoother (its standard deviation drops as \(1/\sqrt{K}\)), at the cost of coarser frequency resolution (shorter segments) — the fundamental resolution/variance trade of spectrum estimation. Overlap recovers some of the lost effective averaging.
From noise to SNR and ENOB. With a full-scale sinusoid of amplitude \(A\) (peak) into a converter, its power is \(A^2/2\). Integrate the measured PSD over all bins except the signal bin (and its harmonics, if measuring SINAD) to get the noise+distortion power \(P_n\). Then
and the effective number of bits follows from the ideal-quantizer SNR formula \(\text{SNR}_\text{ideal} = 6.02\,B + 1.76\) dB, inverted for the measured SNR:
A 12-bit ADC has an ideal SNR of \(6.02\cdot12 + 1.76 = 74\) dB, i.e. 12.0 ENOB — but thermal noise, reference noise, clock jitter, and layout typically knock the real number down to ~10–11 ENOB. That gap is exactly what this lab quantifies.
Quantization-noise density. For a full-scale range \(V_\text{FS}\) and \(B\) bits, one LSB is \(q = V_\text{FS}/2^B\) and the ideal quantization noise power is \(q^2/12\), spread over DC–\(f_s/2\). So the theoretical noise floor per bindrops as you increase \(N\) (finer bins = less noise per bin) — “FFT processing gain,” \(10\log_{10}(N/2)\) dB — which is why a bin’s noise can sit far below the total-noise LSB level. Don’t confuse per-bin floor with total noise; both are meaningful, and Welch reports the density.
Procedure
Part A — Grab a grounded-input noise record.
Bias the ADC input to mid-rail from a low impedance and take no deliberate signal. Sample at \(f_s = 16\) kHz and stream a long record (e.g. \(2^{18}\) samples) to the host over the VCP.
On the host, first look at the raw histogram of ADC codes: its spread (in LSBs) is the time-domain noise. A stuck single-code output means too little noise to even dither (rare); a spread of a few codes is normal.
Part B — Estimate the PSD with Welch.
Run Welch on the record:
import numpy as np, scipy.signal as sigf, Pxx = sig.welch(x_lsb, fs=16000, window="hann", nperseg=1024, noverlap=512, scaling="density")# 10*log10(Pxx) vs f -> the smoothed noise-floor PSD
Sweep the number of segments \(K\) (by changing record length or nperseg) and watch the noise floor estimate smooth out — plot the estimator’s ripple vs. \(K\) and confirm the \(1/\sqrt{K}\) shrinkage of its standard deviation.
Identify and label the mains 50/60 Hz line and its harmonics, plus any switching-supply spurs — these are pickup, not broadband converter noise.
Part C — SNR and ENOB with a known tone.
Play a near-full-scale, bin-centered tone from the DAC into the ADC (e.g. 1 kHz, amplitude ~90% of range to avoid clipping). Take a windowed FFT.
Compute signal power from the signal bin (+ its window main-lobe bins), and noise power by summing all other bins (exclude DC and, for SINAD, the harmonic bins). Form SNR, then ENOB via the boxed formula.
Compare grounded-input (noise-only) vs. tone-present spectra on the same axes — the floor should be unchanged; only the signal bin rises.
Part D — On-chip Welch (optional real-time version).
Implement the averaging on the STM32: accumulate \(|X[k]|^2\) across \(K\) successive windowed FFT blocks (reuse the Lab 6.3 FFT path), divide by \(K\), and stream the averaged PSD. This is the real-time embedded spectrum-monitor form.
Deliverable & expected results
Capture: the grounded-input Welch PSD (dB vs. Hz) with mains lines labeled, the same estimate at \(K=1\) vs. \(K=64\) showing the variance reduction, and the tone-present spectrum used for SNR/ENOB. Log the code histogram width, the measured SNR, and the derived ENOB.
Quantity
Predicted
Measured
Ideal 12-bit SNR
\(6.02\cdot12+1.76 = 74.0\) dB
…
Ideal ENOB
12.0 bits
…
Realistic measured ENOB (STM32 12-bit ADC)
~10–11 bits
…
Welch variance reduction, \(K=64\) vs \(K=1\)
×64 (std ↓ ×8)
…
FFT processing gain, \(N=1024\)
\(10\log_{10}(512)=27\) dB
…
Grounded-input noise, time domain
a few LSB RMS
…
Mains line at 50/60 Hz
present (pickup)
…
Analysis & reconciliation
Error bars on the estimate
A PSD estimate is a random variable, so quote it with an interval, not as a point. Two results are enough for this lab.
Normalized random error. For \(n_d\)independent averages,
\[\varepsilon_r = \frac{1}{\sqrt{n_d}}\]
Welch at 50% overlap does not give one independent average per segment — the overlap correlates neighbours, and the effective count is roughly
\[n_d \approx 1.9 \times (\text{number of segments})\]
so use that, not the raw segment count, whenever you quote an error.
Confidence interval. The estimate is \(\chi^2\)-distributed with \(\nu \approx 2 n_d\) degrees of freedom, so a 95% band on a true PSD \(S\) given the estimate \(\hat{S}\) is
Compute both for your own \(K\) and record them next to the noise-floor number in notes.md; a floor quoted without them is not yet a measurement.
Reconciliation
First reconcile the time-domain and frequency-domain noise: the RMS of the code histogram (in volts) should equal the square root of the integrated PSD over DC–Nyquist — if they disagree, the window power normalization \(U\) or the density scaling is wrong. Then confront the ENOB gap: your measured SNR will fall short of 74 dB, and the deficit is real converter non-idealities (thermal + reference noise, sampling jitter, INL/DNL, and any pickup you failed to shield). Attribute as much as you can — mains lines and switching spurs are not broadband noise and shouldn’t be counted against ENOB if you’re reporting SNR (but they do count in SINAD). Confirm the Welch estimate’s ripple shrinks as \(1/\sqrt{K}\) exactly as Course 1 Lesson 24 predicts for the variance of an average — this is the law of large numbers you can see on a plot. Finally, note that the per-bin noise floor sits well below the total-noise LSB level by the processing gain \(10\log_{10}(N/2)\); this is why the Lab 6.1 FIR’s deep stopband was measurable in principle but limited in practice — the converter noise floor is the real bottom of every measurement in this module.
Cross-platform ports & language variants
See the syllabus Implementation tracks for the framing; this is the Welch-PSD-specific version. Welch is block/batched and embarrassingly parallel over segments — a throughput-bound estimator whose \(K\) periodograms are independent, which points straight at the Jetson.
STM32 bare-metal (C, and Rust). In C the real-time form accumulates \(|X[k]|^2\) incrementally across \(K\) successive windowed-FFT blocks (reusing the Lab 6.3 path), dividing by \(K\) at the end — the streaming-monitor form, tiny memory, one segment in flight, fixed per-block work. Confirm that fixed budget with the DWT cycle counter (setup essentials). In Rust (#![no_std]) it is the same incremental accumulator over the crate FFT.
Raspberry Pi 5 (Linux userspace, C or NumPy).scipy.signal.welch does the whole estimate in one call over a captured record — no reason to hand-roll. float64 gives a cleaner floor than the STM32, which is instructive precisely because it isolates the cause: the STM32’s ENOB gap is converter physics, not a compute-precision limitation.
Jetson Orin Nano. The natural home for the batched form: CuPy / cuFFT computes all \(K\) segment-periodograms in parallel and averages them on-GPU. Welch is embarrassingly parallel over segments, so throughput scales with the array — thousands of segments at once instead of one at a time.
Jetson Orin Nano — detailed procedure (embedded Linux)
The best-fed Jetson port in the module, because its input is real bench data: the noise records the STM32 run captured. Reuse the Lab 6.1 Jetson harness conventions; board config in the Jetson setup essentials.
mkdir -p labs/lab-6-4/edge; copy this lab’s captured grounded-input/mid-scale ADC records there (the serial logs converted to .npy — the same records the notebook analyzed).
CPU reference: scipy.signal.welch with this lab’s exact segmentation (N, window, overlap) over the record. This is the arbiter curve — and being float64, it is also the cleanest-arithmetic version of your own data.
GPU: swap the import to cupyx.scipy.signal.welch (data via cupy.asarray, result back with .get(), synchronize before timestamps). Verify the PSD matches the SciPy curve to within float tolerance — same segmentation, same window, same answer.
Throughput sweep: time both over growing records (more segments \(K\)) and over batches of independent records (channels). CPU time grows linearly in \(K\); the GPU stays nearly flat until the copies dominate — record segments/second at the plateau, and the \(K\) crossover.
The floor argument, closed: overlay the Jetson float64 PSD of the STM32’s own record on the STM32-computed PSD. The floors agree — which proves, with your own data, that the measured ENOB gap is converter physics, not the M4F’s float32 arithmetic. One sentence + one figure in notes.md.
Save timing CSVs and the overlay figure to labs/lab-6-4/edge/.
Raspberry Pi 5 differences: steps 1–2 and 5 run identically (performance governor); no GPU sweep — its float64 welch is the same arbiter with less throughput.
Measure and compare (fill Measured on each platform):
Platform / build
Per-block work
Segments processed
Predicted
Measured
STM32 bare-metal, C (streaming, DWT)
fixed, deterministic
one at a time
determinism
…
STM32 bare-metal, Rust
≈ C
one at a time
≈ C
…
Pi 5, scipy.signal.welch
one-shot over record
all (float64 floor)
cleanest floor
…
Jetson, CuPy/cuFFT batched
GPU-parallel
all \(K\) at once
throughput
…
The lesson: determinism (STM32, fixed per-block work) versus throughput (Jetson, all segments at once) — and a cleaner float64 floor on the host proves the STM32’s ENOB deficit is the converter, not the arithmetic.
Same STM32: bare-metal vs RTOS
The runtime axis has a middle rung worth measuring on the MCU itself: run the streaming Welch estimator under FreeRTOS and compare against the bare-metal build. Unlike the single-filter labs, this one genuinely benefits from multitasking, which makes it the interesting case, and it sets up Lab 7.2.
Bare-metal (above): the DMA callback windows each segment, runs the FFT, and accumulates \(|X[k]|^2\)inline, in ISR context, dividing by \(K\) at the end — acquisition and averaging interleaved in one path.
FreeRTOS (C): split the pipeline into two tasks that overlap. The HAL_ADC_Conv*CpltCallback only osSemaphoreRelease(sem)s (or osMessageQueuePuts the ready-buffer index) so an acquisition task keeps filling segment buffers, while a separate averaging task blocks on osSemaphoreAcquire / osMessageQueueGet, runs the segment FFT, and accumulates the PSD. Setup: enable FREERTOS → CMSIS_V2 and move the HAL timebase to a spare timer (TIM17) per the setup essentials, then osSemaphoreNew / osMessageQueueNew / osThreadNew. Now stage \(k+1\)’s acquisition runs concurrently with stage \(k\)’s FFT+accumulate.
Rust (RTIC / Embassy): in RTIC, the DMA-complete IRQ is a hardware task that spawns a lower-priority averaging software task carrying the segment index; the software task does the FFT+accumulate while the next segment fills. In Embassy, an acquisition async task and an averaging async task communicate over a channel. Same overlapped two-stage structure, statically scheduled.
What you’ll see: beyond the usual short-ISR benefit, the RTOS lets acquisition and averaging overlap — the segment FFTs run while the next segment is still being sampled, a throughput/pipelining win that the inline bare-metal form can’t express as cleanly. The per-block context switch is small against a segment’s FFT+accumulate cost, so you pay little to gain the overlap. Measure the added latency with the DWT counter and confirm the two stages truly run concurrently.
Build (same STM32)
Per-block latency/jitter added
Deadline margin
Structural benefit
Measured
Bare-metal, inline in DMA callback
none (accumulates in ISR)
full
acquisition + averaging serialized in one path
…
FreeRTOS, acquire task ‖ averaging task
+ one context switch (small vs FFT work)
slightly reduced
two stages overlap — next segment fills while last is averaged
Compare the scope’s FFT noise floor to the STM32’s on the same grounded input — the 8-bit scope ADC is noisier; you’ll see it directly.
Add a low-noise buffer (the Lab 4.1 MCP6002 follower) in front of the ADC and re-measure ENOB — a good source impedance often buys a fraction of a bit.
Try Bartlett (non-overlapping, no window) vs. Welch (windowed, overlapped) and confirm Welch’s lower variance for the same record length.
Deliberately add dither (a small pseudo-random signal) and observe the linearization of the quantizer’s DNL in the averaged spectrum — a classic technique that trades a touch of noise for lower spurious tones.
Vary the sample rate and oversample, then decimate: confirm the in-band noise drops by \(10\log_{10}(\text{OSR})\) dB (oversampling gain), the principle behind delta-sigma converters like the ADS1115.
Carry the measured noise variance\(\sigma_v^2\) forward: it is exactly the measurement-noise parameter \(R\) for the real-time Kalman filter in Lab 6.6 — a measured, not guessed, filter parameter.