Lab 11.30 — Coarse-CFO estimator on a real two-board link¶
Objective¶
The Block 05 coarse-CFO estimator (qpsk_coarse_cfo.v, modelled by coarse_cfo_ref.py) was
proven only against synthetic offsets: a float model, a fixed-point model that matches it
bit-exactly, and an RTL testbench fed a generated capture. Every one of those shares the same
assumption — that a real pair of AD9361s looks like the model.
One board cannot test it. TX_LO and RX_LO come from the same reference, so the CFO is ~0 and
there is nothing to estimate. This lab uses two boards with independent references, which is
the only configuration where a real carrier offset exists, and re-runs the very same float and
fixed-point estimators over the recovered symbols. The fixed-point path is the RTL's arithmetic,
so agreement here is evidence about the shipped HDL rather than about a simulation.
Setup¶
board A ── cyclic QPSK out of the DAC via DMA (course RRC, SPS=8, 480 kSym/s, 3.84 MHz)
│
│ TX1 ──▶ 30 dB attenuator ──▶ RX1 (contained SMA cable, 915 MHz)
▼
board B ── raw IQ capture ──▶ SSH ──▶ host: estimator, EVM, SER
Board B is a second AD9361 board on its own subnet, so both can share one switch without an
address clash (ZYNQ_SSH_HOST_B, default 192.168.20.1). The transmitted waveform is built
from the same Q15 RRC taps the PL transmitter uses, and the RRC is applied circularly —
the DMA replays the buffer end-to-start forever, and a linear convolution would leave a seam
that splatters the spectrum.
Run¶
# ALWAYS first: the analysis chain against the generated waveform, no radio in the path.
python blocks/block_11_integrated_sdr_project/python/lab_11_30_two_board_cfo_validation.py --self-test
# then the real link
python blocks/block_11_integrated_sdr_project/python/lab_11_30_two_board_cfo_validation.py
Useful flags: --carrier (default 915e6), --tx-gain (default −30 dB, conducted), --rx-gain,
--symbols, --capture, --window, --host-a/--host-b.
Result (2026-07-17, 915 MHz, −30 dB TX into 30 dB attenuator, RX gain 50 dB)¶
Verbatim from the script:
board A transmitting: TX=-30.000000 dB at 915.000 MHz, DAC source=0x00000002 (DMA), LO_powerdown=0
receiver alive: capture rms = 253.5 counts
contiguity check: |autocorr| at the 32768-sample cyclic period = 0.9989
timing mu = 1.625 samples, |corr| = 35.6x
EVM = 6.62 %
CFO = -173.2 Hz (std 13.4 Hz) -> -0.189 ppm at 915 MHz
estimator float vs fixed(RTL): mean |diff| = 0.14 Hz, max 0.34 Hz
SER = 0 / 15360 = 0.000000
| Metric | Value |
|---|---|
Contiguity \|autocorr\| at the cyclic period |
0.9989 |
| Correlation peak | 35.6× above mean |
| EVM | 6.62 % |
| Inter-board CFO | −173.2 Hz, std 13.4 Hz (−0.189 ppm at 915 MHz) |
| Estimator float vs fixed (RTL) | mean |diff| = 0.14 Hz, max 0.34 Hz |
| SER | 0 / 15 360 symbols (30 720 bits) |
Two independent TCXOs land ~0.2 ppm apart — a couple of hundred Hz at 915 MHz, stable to ~13 Hz across the record. The estimator is unambiguous to ±60 kHz (±1/8 cycle/symbol at 480 kSym/s), so the hardware sits inside its design range with more than two orders of margin, and the fixed-point arithmetic that the RTL performs tracks the float reference to fractions of a hertz on a live signal. An earlier interactive run of the same sequence gave EVM 6.59 %, CFO −208.0 Hz and float-vs-fixed 0.09 Hz — the same numbers to within run-to-run drift.
The estimator comparison feeds both models the same symbols of the same (non-derotated) segment. Handing the fixed model a derotated slice, or a different symbol count, measures the two estimators' own variance instead of the arithmetic — it inflated this figure from 0.14 Hz to 66 Hz before it was fixed.
Four traps this lab encodes¶
Each of these produced a convincing but entirely false "hardware defect". Three of the four were the measurement, not the radio.
1. iio_readdev defaults to a 256-sample buffer¶
Without -b <N> the capture is stitched from N/256 chunks with dropped samples between
them. Every chunk is valid, so the spectrum and EVM look perfectly reasonable — but the record
is not a contiguous time series. Symptoms: correlation never locks (5–8× instead of 20–70×), the
CFO appears to wander by tens of kHz, BER sticks near random.
Always size the buffer to the whole capture, and verify:
|autocorr| at the cyclic buffer's period: chunked ≈ 0.01 contiguous ≈ 0.99
CFO does not break this check — it only adds a constant phase — so it is a clean yes/no on contiguity. The lab runs it automatically and warns.
2. A DDS tone does not power the TX synthesizer¶
With adi,tx-lo-powerdown-managed-enable=1 the driver keeps out_altvoltage1_TX_LO_powerdown=1
until a real TX stream exists. A DDS-only tone is not a stream, so the synthesizer stays off
and nothing leaves the SMA regardless of hardwaregain. The signature is unmistakable once
you know it: changing TX gain by 40 dB moves the received level by exactly nothing — that is a
broken path, not a weak one. Don't chase cables.
A DMA buffer (what this lab uses) is such a stream and brings the LO up by itself. To bisect
digital-vs-analog when a link is silent, use the AD9361's own loopback — the debugfs attribute is
loopback, not bist_loopback:
echo 1 > /sys/kernel/debug/iio/iio:device0/loopback # digital TX→RX inside the chip
A strong tone there with silence at loopback=0 puts the fault in the analog/LO domain and
exonerates the firmware, the DDS core and every cable.
3. The EVM reference must be rotated by +45°¶
For QPSK sym**4 = −1 always, so angle(mean(sym**4))/4 = π/4 and the correction lands the
constellation on the axes, not at 45°. Compare it against (±1±j)/√2 and you measure a
constant ≈54 % that no gain, timing or LO change will ever move. That immovability is the
clue: physics moves, a constant arithmetic error does not.
4. Correlation sign¶
ifft(fft(a) * conj(fft(b))) peaks at off meaning a[n] ≈ b[n−off], so the aligned reference
is np.roll(tx, +off). The wrong sign gives exactly the 0.75 SER of a random guess — while the
correlation still reports a perfect lock.
The rule all four teach: run the whole analysis chain on a known signal with no radio in the path, and require SER = 0, before concluding anything about hardware.
--self-testdoes exactly this in a few seconds.
Repeatability¶
The lab runs back-to-back with no reboots between runs. Verified with a 25-run unattended series (232 s), no failures:
| Over 25 runs | |
|---|---|
| Decoded | 768 000 bits, 0 errors → BER = 0 (0 / 384 000 symbols) |
| Inter-board CFO | mean −288.5 Hz, σ 11.0 Hz (min −311.5, max −262.0) |
| EVM | mean 7.35 % (6.65…8.27 %) |
| Correlation lock | ≥ 35.5× above mean; contiguity \|autocorr\| ≥ 0.994 every run |
Two things make that possible, and both were hard-won:
- The receiver must be a board of the same class as the transmitter (stock ADI/Pluto,
Linux 5.15). The first receiver — a FishBall/ZynqSDR appliance on a frozen Linux 4.0 — had a
driver that wedged its RX DMA after roughly one capture/reconfiguration per boot, ignored plain
reboot, sometimes ignored sysrq, and occasionally needed a physical power cycle. Reflashing it with the same Pluto image the transmitter runs removed all of that: its RX now returns clean data on every run, andrebootworks. - The transmitter's cyclic DMA is freed at the start of every run by
reset_tx_dma(). Left alone,iio_writedev -cgets stuck in an unkillable (uninterruptible-DMA) state holding the DAC buffer, so the next run fails withUnable to allocate buffer: Device or resource busy— andpkill -9,SIGTERMand a DDS-core RSTN toggle all fail to clear it. An unbind+rebind of thecf_axi_ddsdriver does. It re-registers the DDS core under a newiio:deviceN, which is why the lab addresses the transmitter and receiver by NAME and reads the DAC source by physical address — nothing depends on the DDS device number.
Recipe: flash both boards with the bench image (see the per-board
README-bench.txton each SD card), then just run the lab — repeatedly, unattended. Run--self-testfirst if you have changed the host-side analysis.
Still keep -b fixed within a session: a capture with a different -b makes the driver
stitch the next one from stale chunks (|autocorr| collapses to ~0.02, i.e. trap 1). The lab uses
one buffer size (--capture) for its single read, so this only bites if you probe the receiver by
hand between runs — don't.
A launch trap worth knowing¶
ParamikoCommandRunner wraps every command in sh -lc '...; rc=$?; printf ...' and reads the
channel to EOF. A background job started through it — even nohup ... & — is torn down with
the wrapper, silently and with an empty log. Measured side by side:
start via ParamikoCommandRunner: DAC source = 0x0 (DDS), writedev gone
start via a raw exec_command: DAC source = 0x2 (DMA), writer alive
So the transmitter is launched on a raw session (start_detached). And because a running
iio_writedev still does not prove transmission, the lab reads the DAC source select back and
demands 0x2; a channel left on the DDS with zero scale otherwise looks exactly like a dead
link — which is precisely how this was found.
RF safety¶
This is a conducted lab: board A's TX1 reaches board B's RX1 through a cable and a 30 dB attenuator, and nothing radiates. The default −30 dB TX is safe behind that attenuator. Over the air the limit is −50 dB — do not raise power to "see the signal".
Board B's stock rc.user re-arms a 71 MHz transmitter at −10 dB on every boot. This lab
kills it and forces −89.75 dB before touching anything else, and quiets both boards on exit —
including after a failure.