Implement the workhorse of adaptive signal processing — the least-mean-squares (LMS) algorithm — and use it to do the two things it is famous for: identify an unknown channel and then equalize it. Course 1 Section 12 develops the adaptive filter and the LMS update; no lab so far has actually run one. Here you will. An adaptive FIR filter adjusts its own taps sample-by-sample to minimize the mean-square error against a reference, with nothing but a step size and the instantaneous gradient. You’ll first let it learn an unknown FIR channel (system identification — the taps converge to the channel’s impulse response, directly checkable), then invert a distorting channel to open a closed eye (linear equalization), measuring the learning curve, the stability limit on the step size, and the residual misadjustment. This is the LMS/adaptive-filter theory of Course 1 Section 12 — and the Proakis adaptive-equalization companion there — made real.
Recommended reading
Course 1 Section 12 — adaptive filtering: random processes, the LMS algorithm, and its performance/convergence analysis (Kuo Ch. 7). The Proakis & Salehi companion there (adaptive equalization — the LMS and recursive-least-squares algorithms) is the communications form of exactly this lab.
Hayes — the Wiener filter, steepest descent, and the LMS algorithm as a stochastic-gradient approximation to it; the eigenvalue-spread convergence analysis. The rigorous backing.
Lyons Ch. 5, 6 — FIR structures and the delay-line mechanics the adaptive filter is built on.
STM32 Nucleo-64 (NUCLEO-L476RG) running the adaptive filter in real time on captured samples.
A channel to fight: the clean teaching version is a known digital FIR applied in firmware (perfectly repeatable, taps known for the ID check); the bench version routes the signal through a real analog channel — an RC low-pass (Lab 1.3) or an MCP6002 stage (Module 4) — DAC → channel → ADC, and equalizes that.
MCP4725/on-chip DAC to source the training signal; timer-triggered ADC + DMA to capture.
Host with pyserial + NumPy to log the learning curve and cross-check against a reference LMS.
Wiring & bench setup
The signal chain: the on-chip DAC transmits the training/symbol signal, the channel (a known FIR in firmware for the clean version, a real RC low-pass for the bench version) distorts it, and the ADC captures the result as \(d[n]\).
Start with the clean version (direct loopback, firmware FIR channel): the taps are known exactly, so the Part B system-ID check is unambiguous. Rewire to the RC only once that works.
A sharper analog channel (the Lab 4.4 active filter in place of the RC) raises the eigenvalue spread — expect visibly slower convergence (see Analysis).
The mid-rail bias rides in the DAC signal itself (see Safety); no bias network on the breadboard.
Safety & don’t-break-it
0–3.3 V, mid-rail biased on the DAC output and ADC input, as always. The training signal is often a wideband/PN sequence with a high crest factor — leave headroom so it doesn’t clip, or the “channel” you identify includes a clipping nonlinearity the linear LMS filter can never match.
The step size is a stability fuse, not a tuning knob. Too large a \(\mu\) makes the taps diverge (grow without bound) rather than just converge slowly. Start well below the bound, watch the error, and increase carefully — a diverged adaptive filter can slam the DAC output rail-to-rail if it drives anything.
Normalize against input power. The stable range of \(\mu\) scales inversely with input signal power; a level change at the input can push a previously stable filter into divergence. This is exactly why NLMS (normalized LMS) exists — Part D.
No component hazard; the failure mode is divergence (unbounded taps) or a mis-set step size (crawls, or never settles).
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 the .ioc has:
CubeMX page
Setting
Analog → DAC1
OUT1 (PA4), trigger TIM2 TRGO, DMA from the training/symbol table
Analog → ADC1
IN5 (PA0), external trigger TIM2 TRGO, DMA circular (Lab 5.3) — same timer as the DAC so \(x[n]\) and \(d[n]\) share a time base (a one-sample misalignment wrecks equalization — see Analysis)
Timers → TIM2
TRGO = Update event; prescaler/ARR from your chosen \(f_s\)
GPIO
PA8 (D7) output — optional per-sample timing toggle (the DWT counter is the primary timer)
Connectivity → USART2
115200 8-N-1 — stream taps, error, and equalizer output
Host — the Part A reference LMS and the eye diagrams run in the course venv (Toolchain):
Two scripts in host/lab-6-8/, both yours to write: the reference LMS (Part A — numpy channel + PN input + LMS update, learning-curve and \(\mu\)-sweep plots via matplotlib) and the eye-diagram builder (Part C — overlay symbol-period traces from the logged equalizer output; pyserial does the logging).
Keep this lab’s reconciliation in host/lab-6-8/analysis.ipynb — the notebook convention — and export final figures next to it.
and the error against a desired/reference signal \(d[n]\) is \(e[n]=d[n]-y[n]\). The Wiener-optimal weights minimize \(E[e^2]\); LMS reaches them by stepping each sample down the instantaneous gradient:
— one length-\(L\) inner product and one length-\(L\) scaled-vector add per sample. That is the entire algorithm.
Two wirings, one filter.
System identification. Drive both the unknown channel and the adaptive filter with the same input \(x[n]\); let \(d[n]\) be the channel output. The filter converges so that \(\mathbf{w}\to\) the channel’s impulse response — you can read off the learned taps and compare them to the known channel. This is the well-posed bring-up.
Linear equalization. Put the adaptive filter after the channel; let \(d[n]\) be a delayed copy of the transmitted training symbols. The filter converges to (an approximation of) the channel inverse, removing intersymbol interference (ISI) and re-opening the eye. After training, a real receiver switches to decision-directed mode (using its own hard decisions as \(d[n]\)).
Convergence and stability. With input power \(P_x = E[x^2]\) and filter length \(L\), the mean weights converge for
and the convergence speed is governed by the input-autocorrelation eigenvalue spread \(\lambda_\max/\lambda_\min\) — a strongly colored channel converges slowly, the practical Achilles’ heel LMS. The steady state is not the exact Wiener solution: the gradient noise leaves an excess MSE, quantified by the misadjustment
\[
M = \frac{\text{excess MSE}}{\text{minimum MSE}} \approx \frac{\mu L P_x}{2}.
\]
So \(\mu\) trades convergence speed against steady-state error — fast and noisy, or slow and accurate. NLMS removes the \(P_x\) dependence by normalizing the step, \(\mu[n] = \tilde\mu/(\varepsilon+\|\mathbf{x}[n]\|^2)\), giving a level-robust update that is the practical default.
Procedure
Part A — Reference LMS on the host.
In NumPy, build a known channel (e.g. \(h=[1,\,0.5,\,-0.2]\)), drive it with a white/PN sequence, and run the LMS update. Plot the learning curve\(e^2[n]\) (log scale) and confirm the taps converge to \(h\). Sweep \(\mu\) and watch the speed/misadjustment/divergence trade — this is your ground truth.
Part B — System identification on the STM32.
Generate the training sequence from the DAC; apply the known FIR channel (in firmware for the clean version, or the analog RC/op-amp channel for the bench version, per Wiring & bench setup) and capture \(d[n]\). Run the LMS update per sample in the DMA callback. Illustrative core:
/* per sample: x[] is the tap delay line, w[] the adaptive weights */float y =0.0f;for(int i =0; i < L; i++) y += w[i]* x[i];/* filter output */float e = d - y;/* error */for(int i =0; i < L; i++) w[i]+= mu * e * x[i];/* LMS update *//* shift x[] and insert the new input at x[0] */
Stream the learned taps and confirm they match the known channel impulse response (for the analog channel, compare against its measured impulse/step response from Module 1/4).
Part C — Linear equalization and the eye.
Rewire for equalization: transmit a 2-level (or 4-level) symbol stream through the distorting channel, feed the channel output to the adaptive filter, and use a delayed copy of the transmitted symbols as \(d[n]\) during a training preamble. Run LMS to convergence.
Build an eye diagram on the host from the equalizer output (overlay symbol-period traces). Compare eye opening before vs. after equalization; measure the residual ISI (peak distortion) and the symbol-error count.
Switch to decision-directed mode after the preamble (feed back hard decisions as \(d[n]\)) and confirm it holds the eye open without the reference.
Part D — Step size, misadjustment, and NLMS.
Sweep \(\mu\) across the stable range; for each, measure the convergence time constant (from the learning curve) and the steady-state excess MSE, and plot both against \(\mu\) — reconcile with \(M\approx \mu L P_x/2\). Find the empirical divergence threshold and compare to \(2/(LP_x)\).
Replace LMS with NLMS, change the input level by 6–10 dB, and show NLMS holds convergence where plain LMS (tuned for the old level) either crawls or diverges.
Deliverable & expected results
Capture: the learning curve (log-MSE), learned taps vs. true channel, before/after eye diagrams, and the \(\mu\) vs. speed/misadjustment plot.
Quantity
Predicted
Measured
Stable step-size bound
\(0<\mu<2/(L P_x)\)
…
Learned taps (system ID)
\(\to\) channel \(h\)
…
Misadjustment
\(M\approx \mu L P_x/2\)
…
Convergence: larger \(\mu\) ⇒
faster + higher floor
…
Eye opening after EQ
closed → open (ISI removed)
…
Divergence at
\(\mu \gtrsim 2/(L P_x)\)
…
NLMS vs. LMS on a level change
NLMS robust, LMS breaks
…
Analysis & reconciliation
Confirm the on-chip learned taps match the known channel (system-ID) or the host reference LMS trajectory (same input, same \(\mu\)) — a divergence between them usually means the tap delay line is being shifted in the wrong order or \(d[n]\) isn’t time-aligned with \(y[n]\) (a one-sample misalignment is enough to wreck equalization). Reconcile the measured misadjustment against \(\mu L P_x/2\): if the steady-state floor is far higher than predicted, check for input clipping (a nonlinearity the linear filter can’t model) or a colored input inflating the effective \(P_x\). The eigenvalue-spread effect should be visible — a mild channel converges in a few hundred samples, a sharp/resonant channel takes far longer for the same\(\mu\); that slowdown, not instability, is LMS’s real limitation and the reason RLS (which whitens implicitly) exists. Verify the divergence threshold empirically brackets \(2/(LP_x)\), and that NLMS restores level-robustness — the concrete payoff of normalizing by \(\|\mathbf{x}\|^2\).
Cross-platform ports & language variants
See the syllabus Implementation tracks. LMS is a sequential / feedback / latency-bound algorithm — each update depends on the previous weights, so it cannot be parallelized across samples. It belongs in the same class as the IIR biquad (6.2) and Goertzel (6.5): the STM32’s home turf.
STM32 (C, and Rust). The per-sample “filter + error + update” is a tight recurrence; measure it with the DWT cycle counter (setup essentials) — roughly \(2L\) MACs per sample. The fixed-point version is where the real embedded content lives: the weight update \(\mu\,e\,x\) is a small increment that underflows in Q15 if \(\mu\) is tiny, so a leaky/scaled update or a wider weight word is needed — the same precision-vs-range tension as Lab 6.1. In Rust the accumulator width and the update-scaling are explicit types, and the “shift the delay line” aliasing bug is a borrow-checker error rather than a silent corruption.
Raspberry Pi 5 / Jetson. Because the recurrence is sequential, the GPU offers nothing per-filter; the SBC’s value is batch experimentation — sweeping \(\mu\), filter length, and channel over thousands of trials in float64 to characterize convergence statistically, then porting the chosen configuration back to the MCU. RLS (matrix-based) benefits more from a CPU’s BLAS than LMS does.
Same STM32: bare-metal vs RTOS. The adaptation must run every sample, so it stays in the sample path in both builds; the RTOS variant instead moves the policy — switching between training and decision-directed mode, logging the learning curve, freezing/re-arming the taps — into a supervisory task fed from the sample-rate ISR, keeping the hot update loop minimal. Measure that the per-sample deadline margin is unchanged.
Going further
RLS. Implement recursive least squares and compare convergence on a high-eigenvalue-spread channel — RLS converges in \(\approx 2L\) samples regardless of spread, at \(O(L^2)\) cost per sample. The speed/complexity trade against LMS, measured on the same channel.
Adaptive noise cancellation. Rewire the same filter as a noise canceller (reference = correlated noise) and null an interfering tone out of a signal — the other classic LMS application (Lab 8.3’s classical counterpart).
Adaptive line enhancer / prediction. Use a delayed input as the reference to pull a sinusoid out of broadband noise, an LMS narrowband enhancer.
Track a time-varying channel. Slowly vary the analog channel (e.g. heat/change a component) and watch the taps track — the reason adaptive, not fixed, filters are used in the field. Relate the tracking lag to \(\mu\) (the same speed/noise trade, now vs. a moving target).