Measure bit-error rate for an OFDM link carried over two IBM 28 GHz phased-array antenna modules (PAAMs)
in sandbox 1 — against signal level and against beam angle — and take apart, stage by stage in numpy,
the receiver that makes the link work at all.
Two USRP N310s generate and capture OFDM at a 3 GHz intermediate frequency. Two PAAMs, mounted facing
each other, translate that to 28 GHz and back and form beams with 64 elements each. This is the same
OFDM experiment as the FR3 page, moved up a frequency band — and the
move breaks it.
Out of the box this link does not work properly: with GNU Radio's stock OFDM receiver, QPSK floors at
3e-3 to 1e-2 — depending on the sample rate — however much signal you give it. The cause is not noise and not the radios. Each PAAM makes its
28 GHz local oscillator with its own free-running PLL, so the two ends disagree about frequency by
about 250 kHz and their phases wander against each other continuously. A receiver that estimates
the channel once per frame, from the preamble, and then trusts that estimate watches its constellation
rotate through the frame — bit errors grow 15× from the first payload symbol to the last.
Four receiver changes fix it, each one measured on this link: track the phase on the pilots every
symbol, smooth the channel estimate, keep the signal out of the band edges, and use shorter symbols.
Together they take QPSK from 3.5e-3 to 8e-5 in this experiment — about 40× — and in hand-run
sweeps they widened the usable beam sector from about ±10° to ±50°. That is exactly what 5G NR does at FR2 — wide subcarrier spacing and phase-tracking reference
signals (PT-RS) — and here you can see why it has to.
The measurements are automated only. Each is a sweep — 23 SNR points or 13 beam angles, every
one accumulated to an error target, with the arrays configured before and reset after. A hand-typed
version would drop the error targeting and the teardown, and measure something other than what the
numbers claim. What you can do by hand is the part worth understanding: Part 4 runs the receiver
itself, stage by stage, on a recording of this link.
Source: roles, flowgraphs and the recording ship in the cosmos_lab.orchestration collection; the
experiment descriptions in cosmos-experiments.
After completing this tutorial you will be able to:
| Difficulty | Advanced |
| Band | FR2: 28 GHz over the air, 3 GHz IF |
| How you run it | Automated (cosmos-run) for the measurements; numpy, by hand for how the receiver works |
| Estimated time | 60 min (two ~15 min sweeps, plus Part 4) |
| Domain / sandbox | sb1.cosmos-lab.org |
| Topic group | mmWave PAAM |
| Last verified | 2026-10-02 (all three commands run on sb1; engine 0.1.0-340, experiments 0.1.0-63) |
Background knowledge
elements, txrx, pol and theta mean, and how a beam is steered.Account & access
Devices / nodes
| Resource | Role | Qty | Notes |
|---|---|---|---|
srv1-lg1.sb1 |
transmit host | 1 | drives the transmitting N310 over the data network |
srv2-lg1.sb1 |
receive host | 1 | drives the receiving N310 |
| USRP N310, serial 3176DFA | transmitter | 1 | type=n3xx,mgmt_addr=10.37.6.2,addr=10.38.6.2, subdev B:1 (RF3) |
| USRP N310, serial 3176DEB | receiver | 1 | type=n3xx,mgmt_addr=10.37.6.1,addr=10.38.6.1, subdev B:1 |
rfdev4-in2.sb1 |
transmitting PAAM | 1 | 64 elements, configured txrx=tx |
rfdev4-in1.sb1 |
receiving PAAM | 1 | 64 elements, configured txrx=rx |
Disk images
| Image | Load onto | Provides |
|---|---|---|
baseline-sdr.ndz |
both hosts | UHD 4.9, GNU Radio 3.10 |
cosmos-run images a host only if it lacks what the experiment needs; you do not image by hand.
Software components
| Component | Version | Source |
|---|---|---|
cosmos_lab.orchestration |
0.1.0-340 or later | cosmos-orchestration package, preinstalled on every console |
experiment descriptions fr2/ofdm-ber, fr2/ofdm-beam |
0.1.0-63 or later | cosmos-experiments package |
| array control | array_mgmt |
through the AM proxy, am-rt1.cosmos-lab.org:5054 |
Spectrum / RF
28 GHz over the air between two fixed arrays a little under a metre apart. The radios run at a 3 GHz
IF with a 15 MHz LO offset (Part 2 explains it). Sample rate 31.25 MS/s: a 64-point FFT gives
488 kHz subcarriers, and ±20 occupied subcarriers span ±9.8 MHz.
3 GHz IF 28 GHz over the air 3 GHz IF
srv1-lg1 ──▶ N310 3176DFA ──▶ PAAM rfdev4-in2 ))) ~~~~~~~~ ((( PAAM rfdev4-in1 ──▶ N310 3176DEB ──▶ srv2-lg1
(10.38.6.2) txrx=tx, theta txrx=rx, theta (10.38.6.1)
│ │
└──── array_mgmt (AM proxy) ◀───────┘◀── console.sb1
Each PAAM up/down-converts with its own PLL — there is no shared reference between the two ends.
That single fact drives most of this page.
Reserve sb1 and log into its console:
ssh <username>@console.sb1.cosmos-lab.org
Check that cosmos-run can run the experiment here, now — it reads inventory only, and needs no
reservation:
cosmos-run fr2/ofdm-ber.description.yml --resolve-only
Nothing else. cosmos-run powers the hosts, images them if needed, configures both arrays, and resets
and releases them when it finishes — including when it fails.
Each PAAM's 28 GHz LO comes from its own crystal, and crystals disagree by parts per million. At 28 GHz
a few ppm is hundreds of kilohertz. Sent a tone at +1 MHz, this link delivers it about 250 kHz low:
python3 /usr/share/ansible/collections/ansible_collections/cosmos_lab/orchestration/roles/fr3_ofdm_ber/files/fr2_ofdm_dsp.py tone
tone sent at +1000.0 kHz, received at +752.9 kHz -> offset -247.1 kHz
That is half a subcarrier at 31.25 MS/s. An OFDM receiver's fine frequency estimator (Schmidl &
Cox, Part 4) can only resolve offsets within ±½ subcarrier unambiguously, so the experiment retunes
the receiver by −251.5 kHz (rx_freq_offset_hz) before it starts. If your run decodes nothing at
all, this is the first thing to re-measure: the offset drifts by about 1 kHz between minutes and by
more across days.
cosmos-run fr2/ofdm-ber.description.yml --detach
cosmos-run --status
The sweep injects calibrated noise at the receiver and walks the effective SNR from 8 to 30 dB. It
first measures the link's own margin with the transmitter off — injected noise cannot push the SNR past
it, so the top of the curve is a ceiling, not a floor:
received RMS 0.035733, floor 0.00020871 -> signal RMS 0.035732 (44.7 dB above floor)
and writes the curve to ofdm_qpsk.csv in the run's artifacts. Measured 2026-10-02 with the command
above (QPSK, 31.25 MS/s, every improvement on), selected rows:
snr_eff_db packets errors ber perfect
29.854 20992 1089 6.754805e-05 20750
25.941 22922 1384 7.861807e-05 22621
21.977 23156 1656 9.311841e-05 22803
19.985 23054 1984 1.120558e-04 22648
18.988 23353 2756 1.536651e-04 22522
17.991 20413 6360 4.056851e-04 17765
16.993 22753 13866 7.935080e-04 17290
15.994 3527 2925 1.079839e-03 2447
14.995 204 329 2.099929e-03 133
13.996 29 25 1.122486e-03 21
12.997 0 0 0
Read it in two halves. Between 16 and 20 dB it is a waterfall — BER falls about 10× in 3 dB, as
noise should make it. Above 21 dB it stops falling and sits near 8e-5 all the way to 30 dB. With
44.7 dB of margin that is not the injected-noise ceiling: it is the oscillators' phase noise, which
scales with the signal, so more signal does not help. Part 4 shows where that comes from.
Below 16 dB the packet count collapses — 3527, then 204, then 29, then none. That is the frame
detector: OFDM finds each frame from its preamble, a frame whose preamble is lost delivers no bits to
count, and BER is measured only over the frames that were found. The 14 dB point rests on 25 errors in
29 packets: real, but not precise. Quote the packet count beside any BER you take from this end of the
curve.
To see the stock receiver, run the same sweep with every improvement off:
cosmos-run fr2/ofdm-ber.description.yml -e '{"cosmos_parameters": {"improve": false}}' --detach
Measured with that command, 2026-10-02 (margin 45.5 dB):
snr_eff_db packets errors ber perfect
29.879 32547 86981 3.479783e-03 30584
24.961 34328 93528 3.547578e-03 32088
19.988 35666 140871 5.142875e-03 31763
16.994 32142 241999 9.803462e-03 23844
14.996 506 6563 1.688848e-02 263
13.497 0 0 0
The stock receiver's floor, 3.5e-3, is 40× the improved receiver's 8e-5 at the same SNR, with the
same radios, arrays and transmit level. The detection wall barely moves (about 14 dB either way): the
improvements act after a frame is found, on what happens to the phase within it.
cosmos-run fr2/ofdm-beam.description.yml --detach
The receive array stays at broadside; the transmit beam steps from −60° to +60° and BER is measured at
each angle:
BER against TX beam angle, QPSK:
theta packets errors BER perfect
----------------------------------------------------------
-60 0 - - no decode
-50 23510 1825 1.010762e-04 23061
-40 22566 4463 2.575201e-04 21357
-30 22989 2531 1.433543e-04 22073
-20 22277 928 5.424130e-05 21926
-10 21096 869 5.363625e-05 20873
0 20609 580 3.664459e-05 20447
10 24676 1159 6.115718e-05 24457
20 24339 957 5.119741e-05 24104
30 0 - - no decode
40 19924 20520 1.341033e-03 13060
50 24358 2758 1.474319e-04 23083
60 19833 27132 1.781280e-03 11557
----------------------------------------------------------
The arrays reported 5.2 A (transmit) and 3.7 A (receive) when configured, and were reset and released
at the end. Every point decodes 20 000–25 000 packets, so even the best BER rests on hundreds of
errors.
Why the pattern looks the way it does — the received power of a CW tone at the same angles, measured
the same day:
| TX theta | −60 | −50 | −40 | −30 | −20 | −10 | 0 | +10 | +20 | +30 | +40 | +50 | +60 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| tone (dB) | 22.6 | 31.9 | 30.4 | 32.4 | 35.5 | 42.6 | 47.0 | 47.7 | 38.2 | 29.4 | 28.2 | 31.4 | 27.5 |
The BER pattern is the beam pattern, seen through a threshold. Wherever the tone is above roughly
30 dB the link decodes; at −60° and +30° — the two deep nulls — nothing arrives. With the stock
receiver the same sweep stayed below 2e-2 only between −10° and +20°, and sat at 7–8e-2 elsewhere:
the improvements did not move the beam, they lowered the signal level the receiver needs, so more of
the pattern clears it.
Sweep the receive array instead with side: rx:
cosmos-run fr2/ofdm-beam.description.yml -e '{"cosmos_parameters": {"side": "rx"}}' --detach
This part needs no reservation. It runs a complete OFDM receiver, in numpy, on a 20 ms recording of
this link that ships with the collection — QPSK at 31.25 MS/s, captured with the receiver tuned to
exactly 3 GHz and not corrected for the offset in Part 1.
python3 /usr/share/ansible/collections/ansible_collections/cosmos_lab/orchestration/roles/fr3_ofdm_ber/files/fr2_ofdm_dsp.py ofdm
IQ ─▶ [1 spectrum] ─▶ [2 Schmidl & Cox] ─▶ [3 frame sync] ─▶ FFT ─▶ [4 channel est.] ─▶ [5 equalise] ─▶ [6 pilot phase] ─▶ [7 decide]
coarse CFO timing + fine CFO sync word 2 from sync word 2 Y / H per symbol QPSK → bits
recording: 625000 samples at 31.25 MS/s - subcarrier spacing 488.3 kHz
STAGE 1 - where is the signal?
the occupied band is centred at -240.3 kHz, not 0: the two PAAMs'
free-running PLLs disagree by 8.6 ppm of 28 GHz
STAGE 2 - Schmidl & Cox on sync word 1
timing metric peaks at 1.05; its phase gives the offset to within one subcarrier:
-252.0 kHz = -0.516 subcarriers
(more than half a subcarrier - which is why the hardware runs retune the receiver)
STAGE 3 - frames, by correlating against sync word 2
520 frames, spaced 1200 samples (= 15 OFDM symbols of 80)
STAGES 4-7 - estimate, equalise, track, decide
preamble estimate only, no tracking BER 1.44e-01
errors by payload symbol: 999 1710 2149 2619 3215 3739 4073 5247 8084 11700 14568 1742
+ pilot phase tracking BER 4.81e-06
errors by payload symbol: 0 0 0 0 0 0 0 2 0 0 0 0
+ channel-estimate smoothing BER 9.62e-06
errors by payload symbol: 0 0 0 2 0 0 0 2 0 0 0 0
Stage by stage:
H = Y / S.H. On a static channel that is the whole job. Here itH was right at the preamble; the two oscillators then wander apart, and every later symbol isH predicts is the common phase error for that symbol — remove it, andH over neighbouring carriers lowers its noise. On this recording theWhy does GNU Radio's stock receiver not do stage 6? Its equalizer overwrites the pilot carriers with
their ideal values and updates each carrier from its own decisions — it never uses the pilots to
correct the common phase, so drift after the preamble goes uncorrected.
How big is the leftover floor? Phase noise that changes within a symbol cannot be removed by
any per-symbol correction; it leaks energy between subcarriers (inter-carrier interference):
python3 /usr/share/ansible/collections/ansible_collections/cosmos_lab/orchestration/roles/fr3_ofdm_ber/files/fr2_ofdm_dsp.py tone
phase noise L(f), single sideband (receiver noise removed):
3 kHz -34.4 dBc/Hz
10 kHz -43.9 dBc/Hz
30 kHz -52.1 dBc/Hz
100 kHz -64.8 dBc/Hz
300 kHz -77.8 dBc/Hz
what that costs an OFDM symbol of length T (inter-carrier interference):
T = 25.60 us ICI -1.9 dBc -> an SNR ceiling of 1.9 dB
T = 5.12 us ICI -10.5 dBc -> an SNR ceiling of 10.5 dB
T = 2.05 us ICI -16.7 dBc -> an SNR ceiling of 16.7 dB
Phase noise at an offset f is common to a symbol of length T when f·T ≪ 1 and leaks when it
is not; the script weights the measured spectrum by 1 − sinc²(f·T). The answer is the story of FR2
numerology: a 25.6 µs symbol (2.5 MS/s here) cannot work at all, and a 2 µs symbol caps the SNR
near 17 dB — comfortably enough for QPSK (about 10 dB for 1e-4), barely enough for 16-QAM (about
17 dB), which is why 16-QAM still floors near 1e-2 on this link however the receiver is tuned.
Shorter symbols — wider subcarrier spacing — are the only lever, which is why NR at 28 GHz uses
120 kHz spacing where FR1 uses 15–30 kHz.
This ceiling is one contributor to the QPSK floor at the top of Part 2, not all of it: that floor
(~4e-4) corresponds to about 10.5 dB of effective SNR, and channel-estimate noise and the passband
edges take the rest.
Measured 2026-10-02 by hand while the receiver was being developed — QPSK, GNU Radio receiver, each row
adding to the one above. The transmitter here was a hand-run script whose amplitude scale sits about
17 dB below the experiment's, so compare the rows with each other, not with Parts 2 and 3:
| receiver | BER | packets error-free |
|---|---|---|
| stock (25 MS/s, ±26 carriers) | 1.0e-2 | 84 % |
| + pilot phase tracking, static equalizer | 5.2e-3 | errors no longer grow through the frame |
| + channel-estimate smoothing | 8.4e-4 | 93.5 % |
| + ±20 carriers instead of ±26 (out of the passband roll-off) | 4.6e-4 | 96.7 % |
| + 31.25 MS/s (shorter symbols) | 8.9e-5 | 98.5 % |
Each is a switch on the experiment (improve: true turns all of them on):
| description parameter → role variable | what it does |
|---|---|
cosmos_fr3_rx_pilot_tracking |
per-symbol phase from the pilots, static payload equalizer |
cosmos_fr3_rx_channel_smoothing: 2 |
5-carrier moving average of H |
cosmos_fr3_carrier_edge: 20 |
occupy ±20 of the 64 subcarriers |
cosmos_fr3_lo_offset_hz: 15000000 |
tune both radios' LOs 15 MHz away and shift back digitally, so LO leakage and the DC term fall outside the band (worth less than 2× here, kept as good practice) |
samp_rate: 31250000 |
2.05 µs symbols |
16-QAM is the modulation the ceiling in Part 4 bites: 2.6e-2 stock, 8.7e-3 with the improvements
(25 MS/s, ±26 carriers). Getting it lower needs better oscillators — one shared LO into both arrays —
not a better receiver.
Ruled out by measurement, so you do not have to: transmitter noise (a transmitter streaming zeros
raises the floor by 0.0 dB), receive gain (flat from 20 to 50 dB), residual frequency offset (±5 kHz
detuning changes nothing), and inter-symbol interference (a longer cyclic prefix makes BER worse —
longer symbols, more phase noise).
A working run shows, in the run's output:
connected → cleaned → disconnected.Results land in ~/cosmos-artifacts/<run-id>/, with run.json recording every effective parameter.
Nothing to do by hand. Both arrays are reset (PLL powered down) and released in the experiment's
always: block, even when a run fails. If a run was killed outright, release them through the AM proxy:
for a in rfdev4-in1 rfdev4-in2; do
curl -s "http://am-rt1.cosmos-lab.org:5054/array_mgmt/disconnect?dev_name=$a.sb1.cosmos-lab.org"
done
| Symptom | Cause and fix |
|---|---|
| Nothing decodes at any SNR or angle | The frequency offset moved. Re-measure with fr2_ofdm_dsp.py tone's method on a live tone (or the PAAM basics tone flowgraph) and pass the new value as rx_freq_offset_hz. |
No devices found ... type: x300 |
These radios are N310s: type=n3xx, with mgmt_addr and addr. |
Someone tried to claim this device again |
A transmitter was restarted within ~10 s of stopping; the N310 releases its claim a few seconds after the process exits. The experiment waits for this; a hand-run script must too. |
| Points marked INVALID in the beam table | The array's controller did not accept the steer (it has gone silent for minutes at a time, then recovered). Those angles were deliberately not measured. Re-run them. |
Array is already connected |
A previous run was killed before its teardown. Release both arrays (Cleanup). |
| BER flat at a few × 1e-3 however high the SNR | The improvements are off (improve: false) — or you are on the stock receiver deliberately. |
evidence/fr2-ofdm-20261002/ in thecosmos-orchestration repository.Topic group: mmWave PAAM · Last verified: 2026-10-02 · Maintainer: COSMOS team