Transmit and decode real LoRa frames between two USRPs at 915 MHz, using the EPFL gr-lora_sdr physical layer — built on the node at experiment time, from source, in about thirty-five seconds.
LoRa is a chirp spread-spectrum modulation designed to trade data rate for range and robustness. This tutorial builds a complete LoRa link between two COSMOS nodes: one transmits a frame every second with an incrementing payload, the other decodes and CRC-checks each one.
The physical layer is not ours. It is gr-lora_sdr from the Telecommunication Circuits Laboratory at EPFL (GPL-3.0) — a full LoRa transceiver in GNU Radio that interoperates with commercial silicon. COSMOS contributes the orchestration, the hardware, and a small fork that changes four defaults so the examples run here.
Two things make this worth doing on a testbed rather than a bench:
After completing this tutorial you will be able to:
| Difficulty | Intermediate |
| Estimated time | 2 min 15 s end to end, of which ≈40 s is compiling the PHY (measured 2026-09-06) |
| Domain / sandbox | Any with two USRP-equipped nodes |
| Topic group | SDR & GNU Radio |
| Transmits? | Yes — 915 MHz, US ISM band. Stay inside your reservation |
| Last verified | 2026-09-06 on sb5 (node1-1 → node1-2), 57/60 frames CRC-good. Also 2026-08-14 on grid (node18-19 → node18-2) |
| Role | Qty | Notes |
|---|---|---|
| USRP-equipped node — transmitter | 1 | any B200-family radio — B210 and B205mini both verified; resolved from live inventory |
| USRP-equipped node — receiver | 1 | must be a different node |
This link uses one transmit channel and one receive channel on separate nodes, so a single-channel B205mini (sb4) works exactly as well as a B210 (sb5, grid). An earlier version of the experiment asserted the product string B210 and refused sb4's radios for no physical reason; that check is gone.
| Image | Loaded onto | Provides |
|---|---|---|
baseline-sdr.ndz |
both nodes | UHD 4.9, GNU Radio 3.10, and every build dependency |
GNU Radio 3.10 is a hard requirement and it is checked before anything is compiled. A node on an older image still advertises a USRP and will still be selected by capability matching, so the role asserts the version and tells you to re-image rather than letting cmake fail later with a missing header.
| Component | Source |
|---|---|
| LoRa PHY | tutorials/gr-lora_sdr, branch cosmos — built on the node |
| GNU Radio, UHD, cmake, boost, pybind11 | preinstalled in the image; nothing is installed by the run |
Transmits at 915 MHz, inside the US 902–928 MHz ISM band.
[ node A ]--USRP--> ((( 915 MHz, SF7, BW 125 kHz ))) -->--USRP--[ node B ]
lora_TX.py one frame per strobe, lora_RX.py
incrementing payload id decode + CRC
Log into a console and confirm the framework is present:
ssh <username>@console.grid.cosmos-lab.org
cosmos-run --version
cosmos-run sdr-gnuradio/lora-link.description.yml
That single command resolves two USRP nodes from live inventory, clones the PHY at a pinned ref, compiles it on both, runs the link, counts the frames, stops the transmitter, and fetches the results.
The description names no nodes — it asks for capabilities and lets the framework choose, and it names the image so the run is reproducible:
apiVersion: cosmos/v1
name: lora-link
description: Two-node LoRa link at 915 MHz, frames decoded and CRC-checked
parameters:
sf:
type: int
default: 7
description: >-
Spreading factor. Higher spreads each symbol over more chips - slower,
and more robust. 7 is the fastest and the usual starting point.
seconds:
type: int
default: 60
description: Seconds to transmit; the strobe is derived from the spreading factor
tx_gain:
type: int
default: 70
description: USRP transmit gain in dB (B210 range 0-89.8)
rx_gain:
type: int
default: 40
description: USRP receive gain in dB (B210 range 0-76)
resources:
tx:
image: baseline-sdr.ndz
requires:
sdr.usrp: {}
rx:
image: baseline-sdr.ndz
requires:
sdr.usrp: {}
steps:
- role: cosmos_lab.orchestration.lora_link
vars:
cosmos_lora_sf: "{{ sf }}"
cosmos_lora_seconds: "{{ seconds }}"
cosmos_lora_tx_gain: "{{ tx_gain }}"
cosmos_lora_rx_gain: "{{ rx_gain }}"
collect: [/run/cosmos/lora]
Check where it can run before booking time — this needs no reservation and touches nothing:
cosmos-run sdr-gnuradio/lora-link.description.yml --resolve-only
Vary the spreading factor, or transmit longer:
cosmos-run sdr-gnuradio/lora-link.description.yml -e sf=9 -e seconds=120
Most COSMOS SDR tutorials treat the radio stack as an image contract: UHD, SoapySDR and GNU Radio ship inside the disk image and a playbook probes for them rather than installing them. This tutorial deliberately does the opposite for one small piece, and the reason is worth understanding.
gr-lora_sdr is an out-of-tree GNU Radio module — a plugin compiled against GNU Radio's own headers and ABI. Two facts follow:
baseline-sdr node: 2.4 s to configure, 35 s to compile, and no additional apt packages — every dependency is already in the image.So the heavy, version-coupled stack stays an image contract, and this one module does not. The role proves the point rather than assuming it — after make install it imports the module and fails loudly if that does not work.
Every one of these was hit while porting this tutorial, and they generalise to any SDR code you bring to a testbed.
1. The regulatory band. Upstream defaults to 868.1 MHz, the EU LoRa band. COSMOS is in New Jersey, where 868 MHz is licensed spectrum, not ISM. Running the stock example verbatim would transmit out of band. The fork moves it to 915 MHz.
2. The radio is not the one you think. Upstream hardcodes a networked USRP (addr=192.168.10.5 for TX, .6 for RX) from the author's bench. A COSMOS node sees its own USB B210 and other radios reachable on the sandbox — with empty or wrong device arguments, UHD picks whichever it finds first. The experiment then runs, reports frames, and describes a link that does not exist. The role discovers the local serial and pins it.
3. Gains default to zero. 0 dB is a cabled-bench setting and closes no over-the-air link.
These are not criticisms of the upstream project — they are the ordinary consequence of code written for one bench. They are the reason the COSMOS fork exists, and each is an environment-overridable default rather than a hardcoded COSMOS value.
The experiment above answers does the link work. LoRa is built to answer a
different question — how far does it go — and that needs a second experiment:
cosmos-run sdr-gnuradio/lora-atten-sb4.description.yml
It runs on sb4, whose radios are cabled through a programmable attenuator
matrix, so the path is repeatable to a decibel instead of being whatever the
room happens to be that day.
The figure of merit is packet reception rate, not bit error rate. LoRa's
receiver either resolves a chirp or it does not, so the honest measurement is
the fraction of transmitted frames arriving CRC-good. The curve is a cliff, and
where the cliff sits is the result.
Hold the transmitter at full drive and lengthen the path instead. This is the
measurement LoRa exists for, and the shape is the point.
SF7, transmit gain 70, receive gain 40, 30 frames per point, sb4 node1-4 →
node1-5 through the programmable matrix:
| matrix setting (dB) | ~real path (dB) | CRC-good | PRR |
|---|---|---|---|
| 0 | 22 | 29/30 | 97 % |
| 20 | 42 | 26/30 | 87 % |
| 22 | 44 | 26/30 | 87 % |
| 24 | 46 | 25/30 | 83 % |
| 26 | 48 | 25/30 | 83 % |
| 28 | 50 | 25/30 | 83 % |
| 30 | 52 | 26/30 | 87 % |
| 32 | 54 | 22/30 | 73 % |
| 34 | 56 | 5/30 | 17 % |
| 35 | 57 | 0/30 | 0 % |
| 45 | 67 | 0/30 | 0 % |
| 50 | 72 | 0/30 | 0 % |
| 55 | 77 | 0/30 | 0 % |
| 58 | 80 | 0/30 | 0 % |
| 60 | 82 | 0/30 | 0 % |
Thirty decibels of path costs almost nothing, and then four decibels cost
everything. Reception is flat at 83–97 % from 0 to 30 dB of setting, still
73 % at 32, down to 17 % at 34, and gone by 35. That knee is the whole
character of a chirp-spread link: it does not fade gracefully, it works until
it abruptly does not.
Two details worth reading rather than skipping:
The matrix setting is not the path loss. Add roughly 22 dB of fixed
insertion (splitter in, combiner out) for the real figure — the right-hand
column above.
The attenuator was checked independently, by measuring received power at the
radio rather than trusting the setting:
att 0 dB : -17.33 dBfs
att 30 dB : -47.39 dBfs 30.06 dB down, for a 30 dB change
att 60 dB : -62.10 dBfs floor is -62.91 - signal is IN the noise
TX silent : -62.91 dBfs
Two things fall out of that. The matrix tracks its setting to a fraction of a
decibel, so the axis is trustworthy. And beyond ~35 dB of setting the signal is
at or under the receiver's own noise floor, so every zero past the cliff is a
link genuinely below sensitivity — not an artifact.
Do this check whenever a sweep returns nothing. cosmos_rfmatrix with
state: queried is read-only and takes a second; on 2026-09-07 it was what
revealed the matrix controller had died while still accepting commands and
reporting success, which had turned roughly thirty sweep points into "0 frames
decoded".
The transmitter reads its gain as int(os.environ["COSMOS_TX_GAIN"]), so a
fractional gain such as 57.5 raises ValueError, the flowgraph dies during
construction, and the point records zero frames — which is indistinguishable
from being below the cliff.
A 2.5 dB sweep produced 0 at every half-step while its integer neighbours read
10 %, 73 % and 83 %. A real link cannot do that, which is the only reason it was
noticed. The experiment now rejects fractional gains with that explanation
rather than letting a crashed transmitter masquerade as a measurement.
Measured on sb4 2026-09-07, node1-4 → node1-5, SF7 at 915 MHz, path pinned
at the matrix maximum (60 dB setting ≈ 82 dB of real path), 30 frames per
point:
| tx gain (dB) | CRC-good | reception |
|---|---|---|
| 50 | 0 / 30 | 0 % |
| 53 | 0 / 30 | 0 % |
| 55 | 2 / 30 | 7 % |
| 56 | 10 / 30 | 33 % |
| 57 | 14 / 30 | 47 % |
| 58 | 23 / 30 | 77 % |
| 59 | 25 / 30 | 83 % |
| 60 | 24 / 30 | 80 % |
| 62 | 24 / 30 | 80 % |
| 65 | 25 / 30 | 83 % |
| 70 | 28 / 30 | 93 % |
The whole transition happens in under 5 dB: nothing at 53, a third of the
frames at 56, three quarters at 58.
Two things to take from it. The transition is sharp — a few decibels
separate "almost nothing" from "almost everything" — which is what a
chirp-spread receiver does at its detection threshold. And the top of the curve
plateaus below 100 %: those are frames lost to collisions and timing, not to
path loss, and no extra power fixes them.
Everything above is SF7. Run it again at -e sf=12 and you get the comparison
LoRa exists to make. On this link the textbook answer does not appear, and
that is worth more than a tidy curve.
SF12 spreads each symbol over 32× as many chips as SF7. Five spreading-factor
steps is about 15 dB of processing gain, so SF12 should still decode about
15 dB further down — cliff near 42 dB of drive where SF7 cliffs near 57.
Measured on the same pair, same path (60 dB of matrix setting, ~82 dB real),
same receive gain, each point a full run:
| tx gain (dB) | SF7 | SF12 |
|---|---|---|
| 40 | — | 0/25 (0 %) |
| 43 | — | 0/25 (0 %) |
| 45 | — | 0/25 (0 %) |
| 47 | — | 0/25 (0 %) |
| 49 | — | 0/25 (0 %) |
| 50 | 0/30 (0 %) | — |
| 51 | — | 0/25 (0 %) |
| 53 | 0/30 (0 %) | 0/25 (0 %) |
| 55 | 2/30 (7 %) | 1/25 (4 %) |
| 56 | 10/30 (33 %) | — |
| 57 | 14/30 (47 %) | 14/25 (56 %) |
| 58 | 23/30 (77 %) | — |
| 59 | 25/30 (83 %) | — |
| 60 | 24/30 (80 %) | 19/25 (76 %) |
| 62 | 24/30 (80 %) | — |
| 65 | 25/30 (83 %) | 22/25 (88 %) |
| 70 | 28/30 (93 %) | 22/25 (88 %) |
Both cross half their frames at about 57 dB. The 15 dB is not there.
Two things this is not, both checked:
tasks/cadence.yml):So the result stands: this receiver does not deliver SF12's theoretical
processing gain. The most likely explanation is the frame detector — a
correlation threshold that does not scale with the spreading factor gives up
long before the demodulator would, and the link then fails at the same
received power regardless of SF. Confirming that means instrumenting
detection separately from CRC, which this tutorial does not yet do.
That is a genuinely open question you can pick up on real hardware, and it is
a better exercise than reproducing a number from a datasheet.
LoRa SF7 @ 915.000 MHz, node18-19.grid.cosmos-lab.org -> node18-2.grid.cosmos-lab.org:
43 frames decoded, 43 with a valid CRC, highest payload id 42
Collected to ~/cosmos-artifacts/<run-id>/:
node18-19.grid.cosmos-lab.org/run/cosmos/lora/tx.log
node18-2.grid.cosmos-lab.org/run/cosmos/lora/result.json
node18-2.grid.cosmos-lab.org/run/cosmos/lora/rx.log
description.yml
run.json
{ "crc_ok": "43", "decoded": "43", "last_id": "42" }
Success = decoded equals crc_ok, and last_id is one less than the number of frames (ids start at zero). 43 frames in 45 s of transmission — SF7's strobe is 1000 ms, so one frame per second here — all CRC-valid, no gaps in the ids — every frame the transmitter sent was received and decoded.
A gap between last_id and decoded is lost frames. Some loss is normal at long range or low gain; total loss is not, and the run refuses rather than reporting it:
Only 2 frames decoded in 60 s of transmission, against a floor of 30.
That is a link-budget or antenna problem, not a property of LoRa - raise
cosmos_lora_tx_gain, check both antennas are fitted, or pick nodes closer
together.
That refusal is deliberate. A near-zero reception rate reported as a number invites reading it as a measurement of LoRa, when it is a measurement of the setup.
Nothing to undo. The transmitter runs as a transient systemd unit stopped from an always: block, so it stops even when the run fails — which matters, because it occupies a shared ISM channel. The framework releases the nodes at the end of every run.
| Symptom | Likely cause | Fix |
|---|---|---|
GNU Radio 3.8..., and gr-lora_sdr requires 3.10 |
Node is on an older image | Re-image with baseline-sdr.ndz, or pin nodes that carry it |
Only N frames decoded ... against a floor of M |
Link budget, or a missing antenna | Raise tx_gain toward 89; check both antennas; choose closer nodes |
exists but is not a git checkout |
/root/lora left by a manual run |
Remove it, or pass -e cosmos_software_replace_unmanaged=true |
needs imaging ... but cosmos_allow_destructive is false |
Resolution picked a node without a usable SDR stack | Pin nodes you know are on baseline-sdr.ndz, or opt in to imaging |
| Frames decode, ids have gaps | Genuine channel loss | Expected at range or low SNR; raise gain or lower the spreading factor |
no local B210 found |
Node's USB radio is absent or wedged | Check uhd_find_devices on the node; power-cycle it |
master mirrors upstream; cosmos carries four default changes and no DSP changes; COSMOS.md documents the delta and how to merge upstream releases.Orchestration: cosmos_lab.orchestration (cosmos-run), role lora_link; PHY: gr-lora_sdr (EPFL TCL, GPL-3.0) built from source on the node.
Last verified: 2026-09-06 on sb5, node1-1 → node1-2, 915 MHz SF7 BW 125 kHz, 57/60 frames CRC-valid; the sensitivity extension 2026-09-07 on sb4, node1-4 → node1-5, through the attenuator matrix, SF7 and SF12 on the drive axis and a full SF7 sensitivity cliff on the attenuation axis, with the frame strobe derived from the spreading factor. Previously 2026-08-14 on grid, 43/43 frames.
Tags: sdr, gnuradio, lora, ism, ansible.