Measure bit-error-rate versus Eb/N0 for real digital modulations on COSMOS, using two USRP B210 radios and stock GNU Radio 3.10 — from one experiment description that runs unchanged on grid, sb4 and sb5.
You reserve two B210-equipped nodes in one domain and drive them from that domain's console. The framework stages identical DSP on both, transmits from one, captures and measures on the other, collects the results and tears down.
The DSP is stock GNU Radio 3.10 with no custom OOT blocks: an RRC-shaped PSK transmitter, and on receive an AGC → FLL → RRC matched filter → Gardner symbol sync → optional equaliser → Costas loop, then a data-aided BER against a known PN sequence.
A bit-error-rate curve answers one question: how much signal-to-noise does this link need to deliver bits at a given error rate? Everything else — modulation choice, coding, transmit power, antenna gain, how far apart two nodes can be — is a consequence of that answer.
The x-axis is Eb/N0, energy per bit divided by noise power spectral density. It is not SNR, and the difference matters as soon as you compare modulations. SNR measures power in a bandwidth; Eb/N0 normalises out both the bandwidth and the number of bits each symbol carries:
SNR = (Eb/N0) x (bits per symbol) / (samples per symbol)
Normalising this way is what makes the comparison fair. QPSK carries two bits per symbol where BPSK carries one, so at equal SNR QPSK spends half the energy on each bit — and plotted against Eb/N0, ideal BPSK and QPSK land on the same curve. That coincidence is the whole point: any separation you then see between them is real implementation loss, not bookkeeping.
For coherent PSK in additive white Gaussian noise the theory is closed-form, and the run prints it beside every measured point:
BER_BPSK = BER_QPSK = Q(sqrt(2 Eb/N0))
So you are never comparing a measurement against nothing. A good run tracks theory within a fraction of a dB, and the gap that remains is your link's implementation loss — the cost of real synchronisation, phase noise, quantisation and filter mismatch, typically 0.5–2 dB on a B210 pair.
Two failure modes are worth recognising before you run anything, because both produce a plot rather than an error:
1/nbits. At the default 2,000,000 symbols, a BER of 1e-6 is roughly two errors — a number governed by luck, not by the channel. The rule of thumb is ~100 errors per point for a stable estimate, so 1e-6 needs on the order of 1e8 bits. Points past that limit bend away from theory and look like a hardware fault. They are a sample-size artefact. Raise nsyms.cosmos_ber_min_link_ebn0. See When the link is not locking.Measuring a curve means varying Eb/N0 across a range. There are three physically different ways to do that, they are not equivalent, and which one is right is a property of the domain's RF path, not of your experiment. So the description does not name one; it resolves from whichever subdomain you land on:
| Domain | RF path | Method resolved |
|---|---|---|
grid |
open-air B210 pair | inject — noise injection at 2 GHz, rx_gain 60 |
sb4 |
B210 pair through the JFW attenuator matrix | matrix — attenuation sweep |
sb5 |
cabled B210 pair with a fixed pad | inject — noise injection |
A domain not listed falls back to cosmos_ber_method_default. Nothing here is an allowlist — a subdomain absent from the table still runs.
inject)Capture one clean, high-SNR reference over the real link, then add calibrated Gaussian noise to it in software, once per Eb/N0 point.
matrix)Step a programmable attenuator between the two radios — on COSMOS, the JFW matrix — and measure at each setting.
cosmos_ber_matrix_port_tx and cosmos_ber_matrix_port_rx — no default is correct for every sandbox, so a matrix run fails clearly rather than guessing.always: block, since the matrix is shared.ramp)Step the transmitter's own gain and measure at each setting.
Choose deliberately with -e cosmos_ber_method=ramp if you want the physical sweep on grid rather than the resolved injection.
After completing this tutorial you will be able to:
| Difficulty | Intermediate |
| Estimated time | 5 min for one modulation at a small symbol count; hours for a publication-quality tail |
| Domain / sandbox | grid, sb4, or sb5 |
| Topic group | SDR & GNU Radio |
| Transmits? | Yes — stay inside your reservation |
| Last verified | 2026-07-29 on grid (node18-19 → node18-2, 2 GHz, BPSK) |
| Role | Qty | Notes |
|---|---|---|
| B210-equipped node — transmitter | 1 | resolved from live inventory |
| B210-equipped node — receiver | 1 | must be a different node |
| JFW attenuator matrix | 0–1 | sb4 only; required for the matrix method |
| Image | Loaded onto | Provides |
|---|---|---|
baseline-sdr.ndz (or ubuntu2404-uhd4.9-gr3.10.ndz) |
both nodes | UHD, SoapySDR, GNU Radio 3.10 |
| Component | Source |
|---|---|
| GNU Radio 3.10 | preinstalled in the image — probed, never installed |
BER DSP (tx.py, rx.py, waterfall.py) |
staged from tutorials/ber at a pinned ref |
| cosmos-orchestration | preinstalled on the console |
Transmits at 2 GHz on grid, and into the matrix or a cable on sb4/sb5. Stay inside your reservation.
[ TX node ] --USRP B210--> ((( air / matrix / cable ))) --> [ RX node ] --USRP B210
staged DSP: tx.py waterfall.py / rx.py
Log into the console for your domain:
ssh <username>@console.grid.cosmos-lab.org
Confirm the framework is present:
cosmos-run --version
There is no command-by-command equivalent for this one. The experiment sweeps a parameter, resolves its method from the subdomain, and restarts the transmitter per operating point — a manual walkthrough would either be a page of near-identical commands or would quietly drop the sweep, and both teach the wrong thing. Use the ADS-B tutorial if you want to see the manual path on a simpler experiment.
Save this as ber.yml:
apiVersion: cosmos/v1
name: ber-psk
description: Two-node BER against Eb/N0
parameters:
mods: {type: list, default: [bpsk]}
nsyms: {type: int, default: 200000}
resources:
tx:
requires:
sdr.usrp: {model: b210}
rx:
requires:
sdr.usrp: {model: b210}
steps:
- role: cosmos_lab.orchestration.gnuradio_ber
vars:
cosmos_ber_mods: "{{ mods }}"
cosmos_ber_nsyms: "{{ nsyms }}"
cosmos_ber_ebn0: "0:10:1"
collect: [/run/cosmos/ber]
It names no sweep method and no nodes. Check where it can run:
cosmos-run sdr-gnuradio/ber.yml --resolve-only
ber-psk on grid: tx=node18-19.grid.cosmos-lab.org; rx=node18-2.grid.cosmos-lab.org
Then run it:
cosmos-run sdr-gnuradio/ber.yml
Raise the symbol count for a smooth low-BER tail — the default is deliberately small so a first run finishes quickly:
cosmos-run sdr-gnuradio/ber.yml -e nsyms=50000000 -e '{"mods":["bpsk","qpsk"]}'
To pin specific nodes instead of letting the framework resolve them, replace requires: with pin: — note it takes a list:
resources:
tx: {pin: [node18-19.grid.cosmos-lab.org]}
rx: {pin: [node18-2.grid.cosmos-lab.org]}
Everything below is a role variable. Set it in the description's vars: block, or override it for one run with -e. Defaults are the role's; a domain profile may override some of them for the domain you land on.
| Option | Default | What it does |
|---|---|---|
cosmos_ber_mods |
[bpsk, qpsk] |
Modulations measured in one run: bpsk, qpsk, 8psk. |
cosmos_ber_nsyms |
2000000 |
Symbols per point. The single biggest lever on how deep a tail you can resolve — and on runtime and capture size. |
cosmos_ber_ebn0 |
"0:13:0.25" |
Eb/N0 sweep for the inject method, start:stop:step in dB. |
cosmos_ber_freq_hz |
2400000000 |
Carrier frequency. grid resolves this to 2 GHz, which couples far better over the air. |
| Option | Default | What it does |
|---|---|---|
cosmos_ber_method |
auto |
auto, inject, ramp or matrix. Leave it on auto unless you specifically want a method other than the domain's. |
cosmos_ber_subdomain_profiles |
see table above | Per-domain resolution consulted when method: auto. Not an allowlist. |
cosmos_ber_method_default |
inject |
Used for a domain with no profile. |
cosmos_ber_tx_gain_sweep |
[89, 84, 80, 76, 72, 68, 64, 60, 56] |
The ramp method's operating points, in dB. |
cosmos_ber_matrix_atten_db |
[0, 6, 12, 18, 24, 30, 36] |
The matrix method's attenuation points, in dB. |
cosmos_ber_matrix_port_tx |
(none) | Matrix port the transmitter is wired to. Required for matrix. |
cosmos_ber_matrix_port_rx |
(none) | Matrix port the receiver is wired to. Required for matrix. |
| Option | Default | What it does |
|---|---|---|
cosmos_ber_tx_gain |
65 |
Transmit gain in dB. grid resolves to 89 — 65 does not close the measured node18-19 → node18-2 path. |
cosmos_ber_rx_gain |
40 |
Receive gain in dB. grid resolves to 60. |
cosmos_ber_amplitude |
0.6 |
Amplitude into the modulator, ahead of the radio's gain stage. |
cosmos_ber_tx_antenna |
TX/RX |
A B210 transmits only on TX/RX. |
cosmos_ber_rx_antenna |
RX2 |
Receive port. |
cosmos_ber_samp_rate |
1000000 |
Sample rate in Hz. |
cosmos_ber_sps |
4 |
Samples per symbol. Also sets capture size — see below. |
cosmos_ber_rolloff |
0.35 |
RRC roll-off factor. |
cosmos_ber_clock_source |
internal |
internal, external or gpsdo, if the nodes share a reference. |
cosmos_ber_time_source |
internal |
As above. |
Exposed because a marginal link is more often recovered by loosening the loops than by adding power. Left unset, each keeps the DSP's own default.
| Option | Default | What it does |
|---|---|---|
cosmos_ber_fll_bw |
(DSP default) | Frequency-locked-loop bandwidth. Widen for a large carrier offset. |
cosmos_ber_costas_bw |
(DSP default) | Costas carrier-recovery loop bandwidth. |
cosmos_ber_sync_bw |
(DSP default) | Gardner symbol-sync loop bandwidth. |
cosmos_ber_equalizer |
none |
none, cma, lms or trained. A multipath over-the-air path may need one where a cabled path needs nothing. |
cosmos_ber_eq_taps |
(DSP default) | Equaliser tap count. |
cosmos_ber_eq_mu |
(DSP default) | Equaliser step size. |
cosmos_ber_eq_sps |
(DSP default) | Equaliser samples per symbol. |
cosmos_ber_skip |
40000 |
Samples discarded before measuring, letting the AGC and loops settle. |
cosmos_ber_period |
1023 |
Length of the known reference sequence, in symbols. |
cosmos_ber_seed |
0 |
Reference generator seed. Recorded in the metadata, so the transmitted bits stay reproducible. |
| Option | Default | What it does |
|---|---|---|
cosmos_ber_min_link_ebn0 |
5 |
Refuse to report below this measured link Eb/N0. See When the link is not locking. |
cosmos_ber_sigmf |
true |
Write SigMF metadata for each measurement. |
cosmos_ber_capture_iq |
false |
Also keep the raw IQ and the transmitted bits. Off by default — see What each run writes. |
cosmos_ber_results_dir |
/run/cosmos/ber |
Where results land on the node. Match your collect: list if you change it. |
| Option | Default | What it does |
|---|---|---|
cosmos_ber_repo |
https://gitlab.orbit-lab.org/tutorials/ber.git |
Where the DSP comes from. |
cosmos_ber_version |
main |
Pin a tag or commit for a reproducible run; the resolved commit is recorded in run.json either way. |
cosmos_ber_dir |
/root/ber |
Where the DSP is staged on each node. |
cosmos_ber_require_gnuradio |
true |
Probe for GNU Radio and refuse if absent, rather than installing it — an apt build would link against a different UHD than the image's. |
cosmos_ber_tx_unit |
cosmos-ber-tx |
Name of the transient systemd unit the transmitter runs as. |
The collected sweep output:
# mod=bpsk captured=200000 aligned=200000 real-link Eb/N0=20.4 dB (raw BER=0.000e+00 over 200000 bits)
EbN0_dB BER_meas BER_theory nbits
0.0 8.063e-02 7.865e-02 200000
2.0 3.868e-02 3.751e-02 200000
4.0 1.392e-02 1.250e-02 200000
6.0 2.900e-03 2.388e-03 200000
8.0 3.350e-04 1.909e-04 200000
10.0 1.000e-05 3.872e-06 200000
Success = measured tracks the closed-form AWGN theory within a fraction of a dB across most of the range. The gap widens at the tail because 200,000 bits cannot resolve a BER much below 1e-5 — those last points are counting-limited, not channel-limited. Raise nsyms.
Results are SigMF (Signal Metadata Format) v1.0.0. Each measurement produces one file by default, or three if you ask for the raw signal, and they are connected by sharing a single base name:
ber_<modulation>_<method>[_<point>]
so a matrix sweep point at 12 dB attenuation is ber_qpsk_matrix_atten12, and an injection run is ber_bpsk_inject.
| File | Written | Contents |
|---|---|---|
<base>.sigmf-meta |
always | the result and every link parameter |
<base>.sigmf-data |
with capture_iq |
the received IQ, complex float32 |
<base>.tx-bits.u8 |
with capture_iq |
the bits that were transmitted, one byte per bit |
One file is a complete result. The measurement is the ebn0/ber/nbits arrays, and those are metadata — so with capture_iq: false you get a single .sigmf-meta carrying core:metadata_only: true, which is the spec's own way of saying "self-describing metadata, no signal attached". Nothing is missing.
Ask for the raw IQ when you want to re-analyse the signal itself — constellation plots, spectrograms, a different receiver algorithm applied to the same capture:
cosmos-run sdr-gnuradio/ber.yml -e cosmos_ber_capture_iq=true
It is large. Samples are complex float32, so a capture is 8 x sps x nsyms bytes — about 8 MB at 200,000 symbols, but 1.6 GB at the 50,000,000 a smooth tail wants, multiplied by every modulation and every sweep point. That is why it is off by default.
How the three files are connected is worth knowing, because two different rules are at work:
core:dataset exists only to point at a dataset that does not follow the convention, so it is deliberately never written here.).sigmf-bits: those suffixes belong to the spec.A real .sigmf-meta, from the verification run:
{
"global": {
"core:datatype": "cf32_le",
"core:version": "1.0.0",
"core:sample_rate": 1000000.0,
"core:hw": "USRP B210",
"core:description": "COSMOS BER-vs-SNR AWGN-injection bpsk @ 2.000 GHz",
"core:sha512": "ad243d7e6308c0d5cc8872fd3bd9d991...",
"cosmos:domain": "grid",
"cosmos:method": "inject",
"cosmos:modulation": "bpsk",
"cosmos:ebn0": [0.0, 2.0, 4.0, 6.0, 8.0, 10.0],
"cosmos:ber": [0.079935, 0.03894, 0.013975, 0.00273, 0.00031, 0.0],
"cosmos:nbits": [200000, 200000, 200000, 200000, 200000, 200000],
"cosmos:tx_node": "node18-19.grid.cosmos-lab.org",
"cosmos:rx_node": "node18-2.grid.cosmos-lab.org",
"cosmos:tx_serial": "3073463",
"cosmos:tx_gain_db": 89.0,
"cosmos:rx_gain_db": 60.0,
"cosmos:real_link_ebn0_db": 20.49,
"cosmos:tx_bits_file": "ber_bpsk_inject.tx-bits.u8",
"cosmos:tx_bits_format": "u8_unpacked_msb_first",
"cosmos:tx_bits_count": 1023,
"cosmos:tx_bits_sha512": "98d3106d33994cdb343a96850e673cd7...",
"cosmos:bits_per_symbol": 1,
"cosmos:tx_symbol_seed": 0,
"cosmos:tx_symbol_period": 1023
},
"captures": [
{"core:sample_start": 0, "core:datetime": "2026-07-29T11:41:34Z",
"core:frequency": 2000000000.0}
],
"annotations": []
}
Three things there are worth pointing out:
cosmos:ebn0, cosmos:ber and cosmos:nbits are equal-length arrays — the result itself, not a pointer to a CSV that may go missing.core:sha512 and cosmos:tx_bits_sha512 cover the two linked files, so a truncated or mismatched one is detectable rather than quietly producing a wrong curve.The transmitted bits are also derivable — they are one period of a seeded reference — so cosmos:tx_symbol_seed, cosmos:tx_symbol_period and the modulation are recorded whether or not the bits file was written. A lost bits file is recoverable; a lost seed is not.
Upload a .sigmf-data to IQEngine to inspect the constellation and spectrogram in a browser.
If the transmit gain is too low for the path, the run stops rather than reporting numbers:
The link is not locking: measured Eb/N0 = -3.0 dB against a floor of 5 dB.
Ran on grid at 2000.0 MHz with tx_gain=65, rx_gain=60.
This is a link-budget result, not a software fault, and stopping here is
deliberate: below the floor the receiver is not locking, so any BER it reports
is noise dressed up as a curve - typically a flat 0.5 that looks like a
measurement.
This is the intended behaviour, and it is the second failure mode from the overview made concrete. If you set the gain low on purpose, that message is your answer; otherwise raise cosmos_ber_tx_gain (89 closes the grid node18-19 → node18-2 path; 65 does not), or lower cosmos_ber_min_link_ebn0 if you have reason to trust a weaker link.
The transmitter runs as a transient systemd unit and is stopped from an always: block, so it stops even if the run fails. A matrix sweep likewise restores the attenuator it changed — only that attenuator, since the matrix is shared and someone else may be mid-experiment on other ports.
Release the nodes when finished:
omf tell -a offh -t <your nodes>
| Symptom | Likely cause | Fix |
|---|---|---|
The link is not locking |
Transmit gain too low for the path | Raise cosmos_ber_tx_gain toward 89; check antennas or cabling |
| Flat BER near 0.5 in older results | Receiver never locked | Exactly what the link floor now prevents; re-run with the current framework |
| Tail points bend away from theory | Not enough bits to resolve that BER | Raise nsyms; budget ~100 errors per point |
| BPSK and QPSK curves separate widely | Real implementation loss, or a mistuned loop | Expect 0.5–2 dB; beyond that try cosmos_ber_costas_bw / cosmos_ber_fll_bw, or an equaliser |
is not of type 'array' on pin |
pin: takes a list |
Write pin: [node18-2.grid.cosmos-lab.org] |
matrix method with no ports |
matrix_port_tx/_rx have no safe default |
Set both to the ports your nodes are wired to |
exists but is not a git checkout |
/root/ber holds a manual copy from an earlier run |
Remove it, or pass -e cosmos_software_replace_unmanaged=true to move it aside |
| GNU Radio not available | Node is not on an SDR image | The framework probes and refuses rather than installing it; use an SDR image |
| Results directory fills up | capture_iq on with a large nsyms |
Captures are 8 x sps x nsyms bytes each; turn it off or reduce nsyms |
Per-modulation experiments built on this page
| Page | Covers |
|---|---|
| PSK BER — BPSK / QPSK / 8PSK | Single-carrier PSK |
| OFDM BER — a three-mechanism laboratory | OFDM with BPSK/QPSK/8-PSK subcarriers, interactively in GNU Radio Companion and as an automated sweep; compares noise injection, path attenuation and transmit power on the same link |
| QAM BER Experiments | QAM (coming soon) |
For OFDM at 16-QAM and 64-QAM, and for the same experiment over the FR3 up/down-conversion chain, see OFDM BER Across Five Subcarrier Modulations.
.sigmf-dataOrchestration: cosmos_lab.orchestration (cosmos-run); imaging: omf; DSP: stock GNU Radio 3.10.12 staged at a pinned ref. Last verified: 2026-07-29 on grid (node18-19 → node18-2, 2 GHz, BPSK, with capture_iq on). Tags: sdr, gnuradio, ber, sigmf, ansible.