Measure bit-error-rate curves for an OFDM link at BPSK, QPSK, 8-PSK, 16-QAM and 64-QAM, moving the operating point with transmit power. The primary target is the COSMOS FR3 up/down-conversion chain in sandbox 1 — two USRP-2974 nodes and the two Pi-Radio front-ends they feed — and the identical experiment runs unchanged on FR1 (sub-6 GHz) hardware — a plain USRP pair in any sandbox — by turning the front-ends off.
This page replaces the earlier OFDM Transmission Over FR3 demo. That page showed a signal arriving; this one measures how good it is across the whole modulation family, and corrects a frequency-plan setting that quietly cost about 20 dB (see Set the frequency plan below).
Four devices, wired to each other, and sandbox 1 has exactly one set:
sdr1-piradio ──IF──▶ rfdev-sdr1-piradio ──))) 10.3 GHz OTA (((── rfdev-sdr2-piradio ──IF──▶ sdr2-piradio
USRP-2974 Pi-Radio FR3 up Pi-Radio FR3 down USRP-2974
The USRPs generate and capture OFDM at an intermediate frequency; the Pi-Radio front-ends translate between that IF and FR3. BER is measured against a known repeating payload.
Why transmit power, and not injected noise. There are three ways to walk an operating point across a waterfall, and only one of them reaches far enough here. Injected noise adds to the thermal noise already present, so the effective SNR is signal / (injected + thermal) and it asymptotes to the link's own margin over thermal — ask for more than the link has and the sweep quietly measures the link instead of the modulation, returning a plausible number on a curve that flattens. Path attenuation has no such ceiling but needs an attenuator matrix, which this chain does not have. Transmit power has neither limit, and this chain has an unusual amount of it: the Pi-Radio carries 0–60 dB of baseband gain on each of its transmit and receive chains, on top of the USRP's own 0–31.5 dB. That is more dynamic range than a bare B210, not less.
Injected noise is not useless here, though — it is the only mechanism that can apply a chosen noise shape, and Step 4 uses it to measure what impulsive interference costs. Ceilinged is not the same as unusable; it just cannot carry the modulation comparison.
This page is automated only. Every experiment here is a sweep of twenty-odd points with per-point gain changes, settling, accumulation to an error target and guaranteed restoration of shared front-ends. A hand-typed walkthrough would have to drop most of that, and a reader who followed it would be measuring something other than what the numbers claim. The graphical, step-by-step path exists — see OFDM BER experiments, which covers BPSK/QPSK/8-PSK in GNU Radio Companion. 16-QAM and 64-QAM have no graphical path at all, for a reason given under Why the dense modulations are automated-only.
New to this, or want to watch it happen? Start on the companion page, OFDM BER at FR1 — a three-mechanism laboratory. It covers BPSK, QPSK and 8-PSK at 2.4 GHz with an interactive flowgraph you drive by hand, and compares three different ways of moving the operating point. Come back here for the two QAMs, for FR3, or for a single command that produces a whole curve.
Source: flowgraphs and roles ship in the cosmos_lab.orchestration Ansible collection.
After completing this tutorial you will be able to:
| Difficulty | Advanced |
| Band | FR3 at 10.3 GHz (chain covers 6–24 GHz); also runs at FR1 / sub-6 GHz on a plain USRP pair |
| How you run it | Automated only. Every experiment here is a 21-point sweep — see Why there is no interactive path |
| Estimated time | 20 min per modulation; about 2 h for all five |
| Domain / sandbox | sb1 for the FR3 chain (it pins four specific devices); any sandbox with two USRPs for the FR1 variant |
| Topic group | FR3 |
| Last verified | 2026-08-02 — all five modulations swept end to end on sb1 |
Background knowledge
Account & access
Devices / nodes
| Resource | Role | Qty | Notes |
|---|---|---|---|
sdr1-piradio |
transmitter | 1 | USRP-2974 (Krypton), embedded X310 over PCIe |
sdr2-piradio |
receiver | 1 | USRP-2974 |
rfdev-sdr1-piradio |
TX front-end | 1 | Pi-Radio FR3, web UI on 5006, REST on 5111 |
rfdev-sdr2-piradio |
RX front-end | 1 | Pi-Radio FR3 |
Disk images
| Image | Load onto | Provides |
|---|---|---|
baseline-sdr.ndz |
both SDR nodes | UHD 4.9 + GNU Radio 3.10 |
Software components
| Component | Version | Source |
|---|---|---|
| GNU Radio | 3.10.12 | preinstalled in the image |
| UHD | 4.9.0 | preinstalled; NI-RIO server reaches the embedded X310 |
cosmos_lab.orchestration |
— | installed on the sb1 console |
Spectrum / RF / special
Radiates at 10.3 GHz through Vivaldi antennas. Stay inside your reservation. Raising a Pi-Radio transmit gain is gated behind an explicit opt-in.
Both USRP-2974 nodes are reached over SSH from console.sb1.cosmos-lab.org; the Pi-Radio front-ends are reached over HTTP from the console. Nothing runs on the front-ends themselves — they are RF devices, not hosts.
ssh <username>@console.sb1.cosmos-lab.org
omf load -i baseline-sdr.ndz -t sdr1-piradio,sdr2-piradio
omf tell -a on -t sdr1-piradio,sdr2-piradio
omf stat -t sdr1-piradio,sdr2-piradio
A successful
omf loaddoes not prove the node booted your image. Both nodes once reported imaged and POWERON for over an hour while still running the PXE stage OS. Confirm withssh root@sdr1-piradio cat /.omf-image-name; if SSH refuses your key but accepts passwordroot, the node is still in the stage image and needs a power cycle.
The Pi-Radio front-end mixes the incoming IF down by its low-side LO and back up by its high-side LO:
RF = high_lo + (usrp_freq − low_lo)
Keep the USRP deliberately offset from low_lo. Setting them equal is legal, and it is the worst available choice: the signal then sits at baseband inside the front-end, directly under its own LO leakage. Measured on this chain:
| IF = low_lo (zero offset) | 300 MHz offset | |
|---|---|---|
| LO spike vs mean | +16.8 dB | −4 dB |
| Decoding | intermittent | decoded at every gain tried |
| At 5 MSps | no decode | works |
The plan used throughout this page is Pi-Radio's own recommendation — USRP 1.0 GHz, low_lo 0.7 GHz, high_lo 10 GHz, giving RF 10.3 GHz.
If you measure the leakage yourself, switch UHD's automatic DC-offset correction off first (
usrp.set_rx_dc_offset(False, 0)). It is a tracking loop that progressively removes the DC term you are trying to observe — readings fall by 7 dB over a session and vary by 9 dB between identical runs with it on, against 0.1 dB with it off. The +16.8 dB / −4 dB figures above were taken before this was understood and are relative to the wanted signal rather than to the noise floor; treat them as indicative of the direction, not as calibrated values. The sine tutorial carries the corrected absolute measurement.
Do this first, for each modulation, and do not skip it. Every apparent BER floor measured on this testbed has turned out to be the measurement or the link budget rather than the modulation, and the most common single cause is transmit overdrive.
OFDM has a high peak-to-average ratio, so an amplitude that looks modest clips on peaks and the transmit chain compresses. The damage happens before the signal leaves the node, which is why nothing downstream fixes it — receive gain, sample rate, frame length, equalizer choice, pilot density and real-time scheduling were all swept against an overdriven waveform on this very chain and none of them moved it.
cosmos-run fr3/ofdm-drive-probe.description.yml \
-e '{"cosmos_parameters": {"sub": "64qam", "allow_transmit": true}}'
Read off the highest drive that is still error-free, or where the curve turns if it never reaches zero. Two warnings:
cosmos-run fr3/ofdm-ber-txpower.description.yml \
-e '{"cosmos_parameters": {"sub": "bpsk", "allow_transmit": true}}'
Change sub to qpsk, 8psk, 16qam or 64qam for the others, and set amplitude to whatever Step 1 gave you for that modulation — 0.02 for the PSK family on this chain, 0.012 for 16-QAM, 0.008 for 64-QAM.
allow_transmit is required because this run raises a front-end transmit gain. Raising it drives the upconverter harder into shared, regulated spectrum at 10.3 GHz, so the gate defaults to closed and you open it deliberately. Lowering a transmit gain, and any receive change, are not gated.
The play configures both front-ends, verifies they took the settings, stages and compiles the flowgraphs, starts the transmitter as a systemd unit, checks it is actually running, sweeps, and then — in an always: block, so it happens on failure too — stops the transmitter and restores both front-ends to exactly what it found.
Which gain is swept, and why it is the front end. The USRP-2974's embedded X310 carries a UBX daughterboard that stops at 31.5 dB transmit and 37.5 dB receive, and UHD clamps silently above either — ask for 60 and it applies the top without a word, so the description and the metadata would both read 60 while the radio did something else. A sweep walking past the top draws a flat line that reads exactly like a modulation limit. The front end has the range and reports what it applied.
Gain has a two-sided optimum at both ends of the link. This is the least intuitive result on the page and the one most likely to save you a wrong conclusion.
Raising the front-end receive gain from 35 to 50 dB improved every dense modulation by two to three times and moved the transmit optimum down with it:
| at rx front-end 35 | at rx front-end 50 | |
|---|---|---|
| 8-PSK | 1.2e-3 at tx 32 dB | 4.2e-4 at tx 28 dB |
| 16-QAM | 1.1e-2 at tx 36 dB | 6.1e-3 at tx 34 dB |
| 64-QAM | 9.1e-2 at tx 36 dB, 0 perfect packets | 5.7e-2 at tx 36 dB, 300 perfect |
But pushing receive further — front end to 60 dB and the USRP from 30 to 37 dB — made things worse again, which is receive-side compression, the mirror image of transmit overdrive:
| best at rx front-end 50 + USRP 30 | best at rx front-end 60 + USRP 37 | |
|---|---|---|
| 16-QAM | 6.07e-3 at tx 34 dB | 3.48e-2 at tx 20 dB |
| 64-QAM | 5.74e-2 at tx 36 dB | 1.61e-1 at tx 22 dB |
50 dB is a measured optimum, not a maximum. Note what else moved: the transmit optimum slid down by 14 dB, because a receiver closer to compression wants less signal, not more.
Two honest caveats. That comparison changes two gain stages at once, so it shows the receive chain as a whole has an optimum but does not say which stage compressed. And for 8-PSK the same test is inconclusive — at the higher receive setting its best point fell below the bottom of the swept transmit range, so the sweep never covered its optimum.
The general rule this illustrates: a ceiling that moves when you change a gain was never a ceiling.
Bringing your own noise? The format, the requirements, and a dozen lines of Python that generate AWGN or impulsive noise from scratch are on Making a noise file.
Everything above uses transmit power because injected noise ceilings out. That is true, and it is still worth reaching for noise on this chain, because it can do something neither transmit power nor an attenuator can: inject a specific, repeatable, non-Gaussian impairment, and replay the identical recording against every modulation.
cosmos-run fr3/ofdm-ber.description.yml \
-e '{"cosmos_parameters": {"noise_kind": "impulsive", "snr_db": "4:16:0.5",
"allow_transmit": true}}'
noise_kind takes awgn, coloured or impulsive. The impulsive recording is a Gaussian background with 0.1 % of its samples multiplied by 12 — the same total power, concentrated into rare spikes. That is the shape a Gaussian-assuming receiver handles worst, and it is representative of real interference from switching equipment, radar and ignition noise.
Or supply your own recording. noise_file takes a path on the console you are running from; the role validates it and ships it to the receiver:
cosmos-run fr3/ofdm-ber.description.yml \
-e '{"cosmos_parameters": {"noise_file": "~/wifi-burst.sigmf-data",
"allow_transmit": true}}'
Capture an interferer once and replay the identical samples against every modulation — the comparison between modulations then differs only in the modulation. The format is raw interleaved complex64, the .sigmf-data half of a SigMF cf32_le pair; its absolute level is irrelevant because the sweep measures the recording's RMS and scales it to each requested SNR. noise_file_on_node names a file already on the receiver instead, for recordings too large to push per run. Set at most one.
The path is checked on the console before any hardware is touched, so a wrong filename costs about eight seconds instead of a node bring-up.
The axis is the SNR the receiver actually saw, not the one requested. Injected noise adds to the noise the link already has, so the effective ratio is signal / (injected + link noise) — always worse than asked for, and it cannot exceed the link's margin over its own floor. Each run measures that floor with the transmitter off and derives the effective value; both are printed and the figures use the effective one.
Note what the floor measurement cannot see. Taken with the transmitter off, it captures thermal noise, receiver noise figure and ambient interference — but nothing the transmitter contributes. On this chain that gap is the whole story: the signal sits 29.4 dB above the noise floor while the hardware imposes an error-vector floor near 15 dB, so the effective-SNR axis is honest about noise and silent about the distortion that actually limits the dense modulations.
Measured here on BPSK, both kinds scaled to the same RMS against a link sitting 29.4 dB above its own noise floor:
| Effective SNR | AWGN | impulsive |
|---|---|---|
| 8.96 dB | 1.23e-3 | 3.14e-3 |
| 9.95 dB | 1.77e-4 | 1.20e-3 |
| 10.94 dB | 3.09e-5 | 5.99e-4 |
| 11.92 dB | 5.68e-6 | 2.01e-4 |
| 12.90 dB | 9.73e-7 | 5.16e-5 |
| error-free from | 12.66 dB | 15.33 dB |

Impulsive noise costs 2.7 dB at the error-free threshold, and the penalty widens as SNR rises — 53× worse at 12.9 dB against 3× worse at 9 dB. That is the signature of a heavy-tailed impairment: as the Gaussian part becomes negligible the rare spikes dominate what is left, and they are exactly what the receiver's soft decisions are not built for.
One detail worth noticing, because it looks backwards. Impulsive noise starts decoding earlier — the first packets appear at 8.0 dB against 8.5 dB for AWGN. At equal RMS, noise concentrated into 0.1 % of samples is below the Gaussian level for the other 99.9 %, so frame detection has an easier time on average. The link gets going sooner and then cannot clean up. Averages and tails are different questions, and a waterfall answers only the second.
| Parameter | Default | What it does |
|---|---|---|
sub |
bpsk |
bpsk, qpsk, 8psk, 16qam, 64qam |
coding |
none |
none, or cc12 for K=7 rate-1/2 convolutional coding — see below |
usrp_freq_hz |
1000000000 |
What both USRPs tune to |
low_lo_hz |
700000000 |
Front-end low-side LO |
high_lo_hz |
10000000000 |
Front-end high-side LO |
amplitude |
0.02 |
Transmit drive. Take it from Step 1. |
tx_gain |
15 |
USRP transmit gain, dB — held fixed while the front end is swept |
rx_gain_db |
50 |
Front-end receive gain — a measured optimum, not a maximum. See Step 3. |
usrp_rx_gain |
30 |
USRP receive gain, dB. Stays under the UBX's 37.5 dB top. |
tx_gains |
10…50 by 2 |
The sweep axis: front-end transmit gain, dB |
stop_below_ber |
0.0001 |
Stops the sweep once it is good enough to be uninteresting |
allow_transmit |
false |
Opt-in for raising a front-end transmit gain |
noise_kind |
awgn |
Step 4 only: awgn, coloured or impulsive |
snr_db |
24:-2:-0.5 |
Step 4 only: sweep range, start:stop:step |
Sweeping the USRP gain and the front-end gain at once tells you nothing about either, so tx_gain is deliberately held while tx_gains walks.
coding: cc12cosmos-run fr3/ofdm-ber.description.yml -e '{"cosmos_parameters": {"coding": "cc12", "noise_kind": "awgn", "snr_db": "3:12:0.5", "allow_transmit": true}}'
The packet stays 96 bytes either way. Coding does not make the packet longer — it reduces how much of the packet is information: 47 of the 96 bytes carry data and the rest is the codeword. Nothing else in the chain changes.
That is why the comparison is at equal Es/N0 — same transmit power, same occupancy, half the throughput. Say so whenever you publish a coded curve against an uncoded one, because the two are otherwise easy to read as a free improvement.
The sweep then reports two bit error rates per point:
| Column | Meaning |
|---|---|
ber |
The channel, before the decoder. What the link delivered to it. |
coded_ber |
What was delivered to the application, after decoding. |
coded_bits |
How many bits back that number — decoding is capped per point, so this says what the estimate rests on. |
The gap between them is the coding gain. Measured at 10 GHz against a matched uncoded control:
| Effective SNR | Channel BER | Delivered BER |
|---|---|---|
| 11.9 dB | 3.5e-4 | 0 (1.5 Mbit) |
| 11.4 dB | 4.3e-4 | 0 (2.3 Mbit) |
| 10.7 dB | 9.6e-4 | 0 (0.75 Mbit) |
| 10.2 dB | 1.9e-3 | 0 (0.26 Mbit) |
| 9.9 dB | 2.9e-3 | 5.7e-6 |
So a channel running at one error in a thousand is delivered clean.
Two honest limits. This is hard-decision decoding — the receiver slices before the decoder sees it, so soft decisions would be worth roughly another 2 dB and would mean changing the receiver. And no single "gain in dB" is quoted here: the uncoded curve never reaches 1e-5 in this SNR range, so the crossing needed to state one has not been measured.
The same role, the same flowgraphs and the same five modulations run at FR1 on a plain USRP pair — set cosmos_fr3_use_upconverters: false and the front-end steps drop out, leaving the USRP's own transmit gain as the sweep axis. This is how you get 16-QAM and 64-QAM at sub-6 GHz: they have no graphical path anywhere, so this page is the only route to them regardless of band. Ready-made descriptions:
# over the air on the grid, 2.4 GHz, B210 pair
cosmos-run sdr/ofdm-ber-qam-grid-txpower.description.yml \
-e '{"cosmos_parameters": {"sub": "16qam"}}'
Two differences worth knowing. A B210 wants 1 MSps here — it core-dumps its transmitter at 5 MSps inside gr::tagged_stream_block::general_work — while the X310 in a USRP-2974 is happy at 5. And the B210's transmit gain steps in 0.25 dB increments over 0–89.75 dB; asking for anything finer silently rounds and produces duplicate points under different labels.
gnuradio.digital.ofdm_tx maps only 1, 2 and 3 bits per symbol and calls exit(1) on anything else — verified against the installed 3.10.12 and against upstream master. So 16-QAM and 64-QAM are transmitted by a port of that hierarchical block which takes the payload constellation as a parameter. The receiver needed no change: it already derives its equalizer, serializer width and header formatter from a plain payload_mod variable.
The mapping trap. GNU Radio ships more than one 16-QAM generator and they return different constellation points —
qam.qam_constellation(16)is notdigital.constellation_16qam(). A mismatch is invisible at the receiver and looks exactly like a dead link. Both ends here call the same generator. If you build your own and BPSK/QPSK decode while 16-QAM does not, this is why.
Measured 2026-08-02 on sb1. USRP transmit gain 15 dB, USRP receive gain 30 dB, front-end receive gain 50 dB, sweeping the front-end transmit gain. Each modulation at its own drive level, taken from the Step 1 probe.

| Modulation | bits/symbol | drive | Best BER | at front-end TX gain | Perfect packets |
|---|---|---|---|---|---|
| BPSK | 1 | 0.02 | 0 | error-free from 10 dB | every packet |
| QPSK | 2 | 0.02 | 5.37e-6 | 14 dB | 133 794 of 133 835 |
| 8-PSK | 3 | 0.02 | 4.15e-4 | 28 dB | 8 565 of 8 723 |
| 16-QAM | 4 | 0.012 | 4.38e-3 | 34 dB | 14 345 of 17 593 |
| 64-QAM | 6 | 0.008 | 5.73e-2 | 34 dB | 358 of 5 344 |
The five curves land in the order theory predicts, each roughly a constellation step to the right of the last. Nothing decodes below 6 dB on any of them — the OFDM frame detector stops finding headers, so no payload is emitted and there is no BER to compute. That is an absence, not a floor.
The three dense curves are two-sided: BER falls as transmit gain rises, reaches a minimum, then climbs again as the chain compresses. 8-PSK's minimum is a broad basin from 24 to 32 dB; 64-QAM's is narrow and shallow. BPSK is error-free from 10 dB all the way to 40 and shows its first errors only at 42 dB (1.28e-6) — that isolated point on the far right of the figure is the compression side, not a noise limit, which is why no line joins it to the waterfall.
The Step 1 probe put the drive optimum well below the 0.02 the PSK family uses — 0.008 for 64-QAM, 0.012 for 16-QAM — with 0.02 apparently past the window for both. Repeating each full transmit-gain sweep at the probed drive tests that directly:
| at drive 0.02 | at the probed drive | change | |
|---|---|---|---|
| 16-QAM | 6.07e-3 | 4.38e-3 (0.012) | 28 % better |
| 64-QAM | 5.739e-2 | 5.730e-2 (0.008) | 0.2 % — nothing |
So it matters for 16-QAM and not at all for 64-QAM, across drives more than a factor of two apart. Take the drive from a probe rather than assuming, but do not assume the probe's magnitude either: it reported 1.7e-1 for 64-QAM at drive 0.02 where two independent transmit-gain sweeps report 5.7e-2 at the same nominal settings.
Two agreeing measurements beat one disagreeing one, so the sweeps are right and the amplitude and transmit-gain sweep modes are not directly comparable in absolute terms. Use the probe for the shape of the drive window — where the walls are — not to predict a BER. Why the two modes disagree is unresolved.
There is a general lesson in that pair of rows worth more than either number. "The drive was wrong, so these curves are invalid" was a reasonable reading of the probe, and it was half right and half wrong, in a way no amount of reasoning about the probe could have told you. It cost two re-runs to find out, which is cheaper than a tutorial built on either version.
5.7e-2 is poor, and it would be easy — and wrong — to write it up as an EVM limit of the chain. That exact conclusion was reached earlier in this project for 16-QAM and 64-QAM on a different link, on the strength of a shallow curve slope plus a receive-gain test that made things worse, and it was wrong: moving the transmitter from 17 m to 7 m, same receiver, took 16-QAM to 9.4e-5 and 64-QAM from zero perfect packets to 4 584. Both links were simply below the knee of the waterfall.
So the honest statement is: on this chain, at these settings, 64-QAM reaches 5.7e-2 and no further — because that is exactly what 64-QAM does at this chain's 15.36 dB of effective SNR. The arithmetic is below; it agrees to within 2 %. Tried: front-end receive gain at 35, 50 and 60 dB (50 is best), front-end transmit gain across its full range, and two drive levels a factor of 2.5 apart — which changed the answer by 0.2 %. Not tried: a narrower sample rate, which buys SNR per subcarrier; re-balancing gain between the USRP and the front end rather than between the two front ends; and anything that shortens the path. Treat the figure as a measurement of this link, not a property of 64-QAM.
The three dense modulations all settle at the BER a ≈15 dB effective SNR implies. That is a ceiling which does not care what is being sent, and it is worth knowing what it is not, because six plausible explanations have been tested and eliminated:
| Explanation | How it was ruled out |
|---|---|
| Noise power | BER flat against receive gain over 17.5 dB |
| Phase drift within a symbol | BER flat against symbol duration over 4× (5/10/20 MSps) |
| Transmit overdrive | Two full sweeps at drives 2.5× apart agree to 0.2 % |
| Frequency-selective fading | Channel response flat to 3–4 dB, zero carriers >6 dB down |
| Receive clipping / PAPR | Removing 4 % clipping entirely changed 16-QAM by nothing |
| LO phase noise | Common phase error does not scale with carrier frequency (below) |
Measuring the error vector directly and splitting it by mechanism gives 15.36 dB, confirming by a completely different route the number the BER curves imply:
| Mechanism | share of error | alone |
|---|---|---|
| random residual (thermal + ICI) | 70 % | 16.9 dB |
| common phase error | 25 % | 21.4 dB (2.9° rms) |
| deterministic per-carrier (spurs) | 4 % | 29.3 dB |
| mirror / IQ imbalance | 0.7 % | 36.7 dB |
So IQ imbalance is not worth calibrating here — the image is 37 dB down and contributes under one percent of the error. Perfect correction would buy about 0.03 dB. What remains is thermal noise and inter-carrier interference, which no gain setting and no calibration removes.
Ruling six things out says what the ceiling is not. The confirmation that it is only SNR is arithmetic: put 15.36 dB into the textbook uncoded-AWGN expressions and compare with what was measured, each modulation at its own probed drive.
| Modulation | Uncoded AWGN theory at 15.36 dB | Measured on this chain | ratio |
|---|---|---|---|
| 8-PSK | 5.04 × 10⁻⁴ | 4.15 × 10⁻⁴ | 0.82 |
| 16-QAM | 3.29 × 10⁻³ | 4.38 × 10⁻³ | 1.33 |
| 64-QAM | 5.86 × 10⁻² | 5.73 × 10⁻² | 0.98 |
Three constellations, spanning two and a half decades of BER, all within a third of theory and 64-QAM within two percent. This chain is not distortion-limited, EVM-limited, or defective in any way — it is an ordinary AWGN channel with 15.36 dB of SNR, and it delivers precisely the error rate that implies.
So the 5.7 × 10⁻² is not a property of the hardware to be engineered away. It is what 64-QAM does at 15 dB:
| For 64-QAM to reach | it needs |
|---|---|
| 10⁻² | 19.7 dB |
| 10⁻³ | 22.5 dB |
| 10⁻⁴ | 24.3 dB |
| 10⁻⁵ | 25.6 dB |
The chain provides 15.36 dB. Nothing short of more link margin — shorter path, more front-end gain, narrower sample rate — moves that number, and no amount of re-tuning drive or receive gain will, which is exactly what the two drive sweeps 2.5× apart showed when they agreed to 0.2 %.
This is the same trap as everywhere else in these two pages, in its final form. A BER that will not improve looks like a defect to be found. Twice here it was simply the operating point: once the drive, once the end of the sweep axis, and now the link budget itself. Before hunting a mechanism, check what the SNR you actually have predicts — see the FR1 laboratory, where the same 64-QAM reaches 8.5 × 10⁻⁶ on a grid X310 pair for no reason other than 52 dB of margin instead of 15.
Pi-Radio's front-ends cover 6–24 GHz, and both extremes were driven. Change high_lo_hz and the rest follows — RF = high_lo + (usrp_freq − low_lo), so 6.2 GHz gives 6.5 GHz on air and 21.7 GHz gives 22 GHz.
Measured at matched received power, all three with no clipping:
| RF | effective SNR | common phase error | image rejection | channel flatness |
|---|---|---|---|---|
| 6.5 GHz | 10.8 dB | 5.05° | 33.9 dB | 6.9 dB p-p |
| 10.3 GHz | 14.8 dB | 3.92° | 38.5 dB | 3.2 dB p-p |
| 22 GHz | 9.5 dB | 5.61° | 35.4 dB | 4.8 dB p-p |
The link closes at both ends of the band and the channel stays flat throughout — no subcarrier is ever more than 6 dB down.
And this is what retires the phase-noise explanation. Oscillator phase noise scales with carrier frequency, so going from 6.5 to 22 GHz — a factor of 3.4 — should take the common phase error from 5.05° to about 17°. It measured 5.61°, and the middle frequency has the lowest value, which no phase-noise mechanism produces. The common phase error instead sits at a roughly constant 3× the phase estimator's own noise floor at all three frequencies: it is tracking the noise, not the carrier.
Matched received power is not matched SNR. At the same gains the three frequencies differ by 24 dB in received power — 10.3 GHz is much the strongest, being the design point — so 22 GHz needed the receive chain near maximum and more of its power is amplified front-end noise. The effective-SNR column therefore mixes antenna and path efficiency with noise figure and does not separate them. The phase-error column does not depend on that, being a phase measurement rather than a power one.
Read the packet count next to the BER. Frame detection fails well before the payload becomes unreadable, so a BER computed over a handful of surviving frames describes a link that has already stopped being usable. On this chain 64-QAM at 50 dB still reports 2.4e-1 — from 158 packets, against 5 248 at the optimum.
Each run also writes a SigMF metadata record carrying the frequency plan, gains, drive level and every sweep point — enough to reproduce the curve.
The playbook restores the front-ends and stops the transmitter itself, including on failure. When you are finished with the nodes:
omf tell -a offh -t sdr1-piradio,sdr2-piradio
| Symptom | Likely cause | Fix |
|---|---|---|
| BER stuck at a few percent and no receive-side change moves it | transmitter overdrive | Lower amplitude. Run the Step 1 drive probe. |
| A "floor" that shifts when you change any gain | it was never a floor | Keep going — see Step 3 |
| Curve flattens at the good end of a noise sweep | injected noise has hit the link's margin over thermal | Use transmit power, or raise transmit gain |
| No decode at all, plenty of signal | IF sitting on the front-end's LO leakage | Keep ≥100 MHz between usrp_freq_hz and low_lo_hz |
| Numbers look plausible but the link is dead | UHD picked a networked N310 | --dev-args resource=RIO0, always |
| Flat curve at the top of a gain sweep | UHD silently clamped at the UBX's 31.5/37.5 dB | Sweep the front end, not the radio |
| Gain set, nothing changed | firmware returns HTTP 200 on rejection | Read the value back |
SSH refuses your key, password root works |
node is still in the PXE stage image | omf tell offh then on |
uhd_find_devices reports nothing |
queried without resource=RIO0 |
Pass it; the X310 then reports as product X310 |
No SDR matching resource=RIO0 is visible |
another run is holding the radio | Wait for it; this is contention, not a dead device |
¶ The constellation display can lie to you
ofdm_equalizer_simpledfesnaps its output onto the constellation unless its ninth argument,enable_soft_output, is true. A constellation fed from the default equalizer shows two flawless points at ±1 on a link measuring 2.4e-2 BER. The graphical flowgraphs in the companion tutorial enable soft output; if you build your own and the constellation looks impossibly clean, that is why. It changes no decision and BER is unaffected.
ofdm_tx bits-per-symbol limit: https://github.com/gnuradio/gnuradio/issues/7487Author(s): COSMOS team · Last verified: 2026-08-02 (all five subcarrier modulations swept against front-end transmit gain on sb1, each at its own probed drive; receive-gain optimum located at 50 dB; AWGN and impulsive noise injection measured on the same link) · Tested image/release: baseline-sdr.ndz / GNU Radio 3.10.12 / UHD 4.9.0 · Tags: fr3, ofdm, ber, qam, piradio, usrp-2974, sigmf