Measure bit-error-rate versus Eb/N0 for real digital modulations on COSMOS, using two
USRP B210 radios and stock GNU Radio 3.10 — run headless and orchestrated from a domain
console with cosmos-ansible. Every run emits SigMF, and a single
generic plotter renders the BER curves. This page is the shared model; each modulation family
has its own page.
Source (clone & run): https://gitlab.orbit-lab.org/tutorials/ber
You reserve two B210-equipped nodes in one domain and drive them from that domain's
console (console.<domain>.cosmos-lab.org). cosmos-ansible stages identical DSP code on
both, starts the transmitter on nodes[0], captures + measures on nodes[1], collects the
results back to the console, and tears down — the same way every time. nodes[0] is always TX,
nodes[1] is always RX (selected by OMF_NODES).
The DSP is stock GNU Radio 3.10 (no custom OOT blocks): RRC-shaped PSK TX; on RX an
AGC → FLL → RRC matched filter → Gardner symbol sync → optional equalizer → Costas loop, then a
data-aided BER against a known PN sequence. The flowgraph is written once and run as a plain
Python script — no GUI at run time.
The same experiment runs on three domains with different RF paths — pick the one that matches
what you want to see:
| Domain | RF path | Best method | Notes |
|---|---|---|---|
| grid | open-air B210 pair (node18-19 → node18-2) | noise-injection @ 2 GHz | real over-the-air channel; use 2 GHz (much stronger coupling than 2.4 GHz), rx_gain 60 |
| sb4 | B210 pair through the JFW attenuator matrix (node1-3 → node1-4) | attenuation sweep | dial SNR by dB — the cleanest calibrated control (needs the JFW matrix up) |
| sb5 | cabled B210 pair + fixed pad (node1-1 → node1-2) | noise-injection | strong clean link → textbook injection curve |
(sb6/sb7 — additional cabled-USRP domains — are coming.)
Every family page shows both:
Each run writes a .sigmf-meta (SigMF v1.0.0) per (modulation, method) into results/ on the
console, with the swept ebn0/ber/nbits arrays and link parameters (domain, TX/RX node,
gains, modulation, method) under a cosmos: extension namespace. Render all of them with the
generic plotter:
ansible-playbook ber/plot.yml # or: python3 sdr_plot.py results/
sdr_plot.py discovers every .sigmf-meta, groups by experiment, and emits one
ber_<experiment>.svg — measured BER-vs-Eb/N0 overlaid with the closed-form AWGN theory for each
modulation. For interactive IQ / constellation / spectrogram exploration of a .sigmf-data
capture (written when ber_capture_iq: true), upload it to IQEngine —
this tutorial does not ship its own IQ viewer.
Reserve a domain, image the two nodes with baseline-sdr.ndz, then from the console:
git clone https://gitlab.orbit-lab.org/tutorials/ber.git
cd ber/ansible
# grid open-air, 2 GHz, BPSK noise-injection waterfall (very large bit count for a smooth tail):
OMF_NODES="node18-19,node18-2" ansible-playbook ber/ber.yml \
-e '{"ber_mods":["bpsk"],"ber_freq_hz":2000000000,"ber_rx_gain":60,"ber_nsyms":50000000}'
ansible-playbook ber/plot.yml # renders results/ber_psk.svg
Every knob lives in ansible/group_vars/all.yml and can be overridden per run with -e. The
full table (modulation set, Eb/N0 range, bit count, sample rate, sps, equalizer, loop bandwidths,
TX/RX gains + ramp, clock/time source, SigMF/IQ capture, provenance) is in the repo README:
https://gitlab.orbit-lab.org/tutorials/ber#settings-catalog. Highlights:
| Knob | Meaning | Default |
|---|---|---|
ber_mods |
modulation(s) in one run | [bpsk, qpsk] |
| method | chosen by which playbook: ber.yml=inject, ramp_sweep.yml=TX-ramp, matrix_sweep.yml=attenuator |
— |
ber_ebn0 |
Eb/N0 sweep start:stop:step dB |
0:13:0.25 |
ber_nsyms |
symbols per point (raise this for a smooth low-BER tail) | 2000000 |
ber_freq_hz |
carrier (Hz) — use 2e9 on grid |
2.4e9 |
ber_tx_gain / ber_rx_gain |
radio gains (dB) | 89 / 40 |
ber_sigmf / ber_capture_iq |
write .sigmf-meta / also raw-IQ .sigmf-data |
true / false |
Last verified: 2026-07-18 (grid, node18-19/node18-2, 2 GHz, GNU Radio 3.10.12, cosmos-ansible).