Lab 6.8 — LMS Adaptive Equalizer

Course 2 syllabus · Module 6 · Prev: « Lab 6.7 · Next: Lab 6.9 »

Goal

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.

Equipment & parts

  • 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]\).

flowchart LR
  TX["STM32 DAC1_OUT1<br/>PA4 = A2<br/>training / symbol source"]
  CH["Channel:<br/>direct R ≈ 1 kΩ — firmware FIR<br/>or RC low-pass — bench channel"]
  RX["STM32 ADC1_IN5<br/>PA0 = A0<br/>timer-triggered + DMA"]
  SCOPE["Siglent SDS1104X-E<br/>CH1 TX · CH2 channel out"]
  TX --> CH --> RX
  TX -.-> SCOPE
  CH -.-> SCOPE

flowchart LR
  TX["STM32 DAC1_OUT1<br/>PA4 = A2<br/>training / symbol source"]
  CH["Channel:<br/>direct R ≈ 1 kΩ — firmware FIR<br/>or RC low-pass — bench channel"]
  RX["STM32 ADC1_IN5<br/>PA0 = A0<br/>timer-triggered + DMA"]
  SCOPE["Siglent SDS1104X-E<br/>CH1 TX · CH2 channel out"]
  TX --> CH --> RX
  TX -.-> SCOPE
  CH -.-> SCOPE

Pin map (both ends are the same Nucleo — the channel sits on the breadboard):

From To Pin/jack
A2 (PA4, DAC1_OUT1) channel input on the breadboard
Clean version: series R (~1 kΩ), direct ADC input — the channel is a known FIR in firmware A0 (PA0)
Bench version: series R, then C → − rail; tap the R–C junction (the RC values from Lab 1.3) ADC input — the RC is the channel A0 (PA0)
Nucleo GND breadboard − rail GND
Scope CH1 (10×) DAC out (PA4); ground clip → − rail CH1
Scope CH2 (10×, optional) channel output at A0 CH2
(Optional) Saleae CH0 + GND lead D7 (PA8) timing toggle · − rail CH0
 bench-channel version:
 NUCLEO-L476RG               breadboard
 ┌──────────────┐   ┌──────────────────────────────────────┐
 │ A2/PA4  ●────┼───┤──R──●──────────────► to A0/PA0 d[n]  │
 │ A0/PA0  ●────┼───┤     │                                │
 │ GND     ●────┼───┤     C  (Lab 1.3 values)              │
 └──────────────┘   │     │                                │
                    │ − rail ──── scope gnd clip           │
                    └──────────────────────────────────────┘
  • 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):

source venv/bin/activate   # numpy, matplotlib, pyserial
mkdir -p host/lab-6-8 captures/lab-6-8

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.

Where results go:

Artifact Path
Bench note docs/lab-6-8.md
Learned taps vs. true channel (serial log) captures/lab-6-8/taps.csv
Learning-curve logs (per \(\mu\)) captures/lab-6-8/learning-mu-*.csv
Equalizer output for the eye diagrams captures/lab-6-8/eq-output.csv
Learning-curve + \(\mu\)-sweep plots host/lab-6-8/learning-curve.png, host/lab-6-8/mu-sweep.png
Before/after eye diagrams host/lab-6-8/eye.png

Background

The adaptive FIR. An \(L\)-tap FIR with time-varying weights \(\mathbf{w}[n]=[w_0,\dots,w_{L-1}]^\top\) produces

\[ y[n] = \mathbf{w}[n]^\top \mathbf{x}[n], \qquad \mathbf{x}[n]=[x[n],x[n-1],\dots,x[n-L+1]]^\top, \]

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:

\[ \boxed{\;\mathbf{w}[n+1] = \mathbf{w}[n] + \mu\, e[n]\, \mathbf{x}[n]\;} \]

— 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

\[ 0 < \mu < \frac{2}{L\,P_x} \quad\Big(\text{more precisely } 0<\mu<\tfrac{2}{\lambda_\max}\Big), \]

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.

  1. 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.

  1. 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] */
  2. 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.

  1. 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.
  2. 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.
  3. 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.

  1. 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)\).
  2. 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).