Bring up a complete 5G Standalone network with the Duranta gNB (an OpenAirInterface fork, nr-softmodem) and the native OAI CN5G core on COSMOS, driving a USRP over the air on the sb1 or grid domains, then attach a commercial Quectel 5G modem — register it, open a PDU session, reach the internet, and measure real DL/UL throughput.
This tutorial builds a self-contained 5G SA cell using Duranta — an OAI (OpenAirInterface) fork whose gNB binary is nr-softmodem and whose config is OAI libconfig (.conf) — paired with the native OAI CN5G 5G core (the oai-amf / oai-smf / oai-upf / … Docker stack). The gNB runs on an sb1 (or grid) compute node and drives a USRP (NI N310) over a real n41 (2.6 GHz) air interface, serving a commercial Quectel RM520N-GL modem.
The stack is standardized with the other COSMOS 5G platforms: PLMN 00101 and UE pool 192.168.100.0/22 with internet access. A single one-shot script (start-all.sh) brings up the whole thing. For the radio-less software version, see the companion Duranta soft-only tutorial.
After completing this tutorial you will be able to:
nr-softmodem gNB + the OAI CN5G core driving a USRP on sb1/grid.LD_LIBRARY_PATH requirement and the dynamic AMF-IP handling.| Difficulty | Advanced |
| Estimated time | 60–90 min |
| Domain / sandbox | sb1 or grid (USRP radios) |
| Topic group | Cellular (4G/5G/O-RAN) |
| Last verified | 2026-09-08 — three-mode parity green on sb1 (srv1-lg1 + N310 sdr1-s1-lg1 + modem sdr1-in3), image duranta-20260907c (Duranta 2026.w36) |
| Upstream source | Duranta (LF Networking, an OpenAirInterface-based RAN + UE stack) — repo github.com/duranta-project/openairinterface5g, tag 2026.w36 (commit b3930e39f5, 2026-09-04) + two COSMOS patches (PUSCH TA-alias guard; Benetel M-Plane get-xml/get-yang) |
Background knowledge
ssh/ip/iperf3 on Linux.Account & access
sb1 (or grid) for the gNB node, the USRP, and the UE node — see Make a reservation.Devices / nodes
| Resource | Role | Qty | Notes |
|---|---|---|---|
| a compute node with a USRP | Duranta gNB (nr-softmodem) + OAI CN5G |
1 | drives the gNB N310 (e.g. 10.39.3.1). The radio is shared — reserve it and coordinate with other users of the sandbox; any N310 on the 10.39.x net works: USRP=<addr> /root/duranta/mk-gnb-oai-40.sh regenerates the config |
| a UE node | UE host — Quectel modem | 1 | USB; AT /dev/ttyUSB2, QMI wwan0 |
| NI N310 | gNB radio | 1 | band n41, 40 MHz, 1T1R |
| Quectel RM520N-GL | commercial 5G UE | 1 | SIM provisioned to a 00101 identity |
Disk images
| Image | Load onto | Provides |
|---|---|---|
duranta.ndz |
gNB node | Duranta nr-softmodem (/opt/duranta, USRP build), OAI CN5G (/opt/oai-cn5g), one-shot start-all.sh + provision.sql, UHD 4.9 |
lte5gue.ndz |
UE node | Quectel tooling (qmicli), UHD 4.9 |
(duranta.ndz / lte5gue.ndz are symlinks to the latest dated builds.)
Software components
| Component | Version | Source |
|---|---|---|
| Duranta gNB (OAI fork) | preinstalled | /opt/duranta/cmake_targets/ran_build/build/nr-softmodem — no license needed |
| OAI CN5G (5GC) | develop (Docker) |
/opt/oai-cn5g/oai-cn5g-fed/docker-compose, docker-compose-basic-nrf.yaml |
| UHD | 4.9.0.0+ds1-1~noble2 | preinstalled |
qmicli (libqmi) |
distro | preinstalled on the UE host |
Spectrum / RF / special
absoluteFrequencySSB 518670), 40 MHz (106 PRB), 30 kHz SCS, TDD (DDDSU). Conducted / shielded RF path on the rig.00101, DNN oai, IMSI 001010000000032 (modem). UE pool 192.168.100.0/22, gateway 192.168.100.1, internet-NAT'd (via oai-ext-dn).The gNB + core run on an sb1/grid node with a local USRP; the Quectel modem is on USB on the UE node:
gNB node (sb1 / grid) UE node
┌────────────────────────┐ ┌────────────────────────┐
│ Duranta nr-softmodem │ n41 2.6 GHz │ Quectel RM520N-GL │
│ OAI CN5G (Docker) │◄─────────────►│ │
│ UE pool 192.168.100/22 │ USRP N310 │ wwan0 → 192.168.100.x │
│ ext-dn NAT → internet │ (OTA) │ │
└────────────────────────┘ └────────────────────────┘
Duranta/OAI needs no license server — you just launch the binary (with LD_LIBRARY_PATH set, because the build was relocated to /opt/duranta; start-all.sh handles it).
ssh <username>@console.<domain>.cosmos-lab.org # e.g. console.sb1... or console.grid...
-r 0 — also expands the Duranta root partition):omf load -i duranta.ndz -t <gnb-node> -r 0 -o 1200
omf load -i lte5gue.ndz -t <ue-node> -r 0 -o 1200
omf tell -a on -t <gnb-node>,<ue-node>
# on the console:
omf tell -a offh -t <sdr-node>
# on the gNB node (the console cannot reach the radio's addresses) -- every wait is bounded and says why it gave up:
for i in $(seq 1 45); do ping -c1 -W1 <usrp-mgmt-ip> >/dev/null 2>&1 || break; sleep 2; done # PoE acts in 20-60 s
ping -c1 -W1 <usrp-mgmt-ip> >/dev/null 2>&1 && echo "radio still answers after 90 s: the port did not power off (check omf stat / the PDU)"
sleep 20 # hold it off
# on the console:
omf tell -a on -t <sdr-node>
# on the gNB node:
for i in $(seq 1 40); do ping -c1 -W1 <usrp-mgmt-ip> >/dev/null 2>&1 && break; sleep 3; done # radio Linux back (~30-60 s)
ping -c1 -W1 <usrp-mgmt-ip> >/dev/null 2>&1 || echo "radio not back after 120 s: power-on was dropped (re-send omf tell -a on) or the radio needs a PDU cold cycle"
for i in $(seq 1 40); do uhd_find_devices --args "addr=<usrp-sfp-ip>" 2>/dev/null | grep -q serial && break; sleep 5; done # MPM up (~70 s)
uhd_find_devices --args "addr=<usrp-sfp-ip>" 2>/dev/null | grep -q serial || echo "MPM did not come up in 200 s -- see Troubleshooting (claimed / wedged radio)"
Why PLMN 00101? A soft UE can only form a 2-digit MNC, so
00101is the canonical testbed PLMN; the COTS SIM is provisioned to match. See the platforms overview.
The image ships /root/duranta/start-all.sh, a validated one-shot that brings the whole stack up in the right order:
ssh root@<gnb-node>
/root/duranta/start-all.sh
It (0) checks the USRP is visible and not claimed by another session (it refuses to start and tells you to power-cycle the radio from the console if it is); (1) applies the RF-underflow IRQ fix and pins the gNB to the isolated cores if cosmos-ofh-isolation --apply was run (else to cores 8–15) — on a two-socket host the gNB and the DATA NICs must share a NUMA node, otherwise the log fills with L (TX-late) letters and the UE cannot decode the DL; (2) brings up OAI CN5G (docker compose up -d); (3) provisions the subscribers (provision.sql: PLMN 00101, DNN oai, IPv4); (4) reads the dynamic AMF container IP and writes it into the gNB .conf (the OAI CN5G AMF IP changes across restarts — this step is essential); (5) derives the DATA interface and pins the USRP route on it — never falling back to CTRL; and (6) launches the gNB (nr-softmodem with LD_LIBRARY_PATH set). Confirm:
grep -aE 'got sync|is in service|NGAP_REGISTER_GNB_CNF' /tmp/oai-gnb.log | tail -3
docker compose -f /opt/oai-cn5g/oai-cn5g-fed/docker-compose/docker-compose-basic-nrf.yaml ps
[NR_RRC] cell PLMN 001.01 Cell ID ... is in service
[PHY] got sync (ru_thread)
got sync = the radio is locked and the cell is on air. To stop the gNB later, use pkill -x nr-softmodem.
ssh root@<ue-node>
systemctl stop ModemManager
python3 - <<'PY'
import serial, time
s = serial.Serial("/dev/ttyUSB2", 115200, timeout=3)
at = lambda c, w=1.0: (s.write((c+"\r\n").encode()), time.sleep(w), s.read_all().decode())[2]
at('AT+QNWPREFCFG="mode_pref",NR5G')
at('AT+QNWPREFCFG="nr5g_disable_mode",0') # 0 = SA enabled
at('AT+QNWPREFCFG="nr5g_band",41:77:78') # the band mask must include n41 (a modem set to 77:78 never sees the cell)
at('AT+CGDCONT=1,"IP","oai"') # IPv4 APN, matches the CN5G DNN "oai"
at('AT+COPS=2',2); at('AT+CFUN=0',3); at('AT+CFUN=1',5); at('AT+COPS=1,2,"00101",12',6) # lock PLMN
time.sleep(10)
print("C5GREG:", at('AT+C5GREG?')); print("QENG:", at('AT+QENG="servingcell"'))
s.close()
PY
echo Y > /sys/class/net/wwan0/qmi/raw_ip; ip link set wwan0 up
qmicli -d /dev/cdc-wdm0 --wds-start-network="apn=oai,ip-type=4" --client-no-release-cid
IP=$(qmicli -d /dev/cdc-wdm0 --wds-get-current-settings | awk '/IPv4 address/{print $NF}')
ip addr add $IP/22 dev wwan0; ip link set wwan0 mtu 1400 up
ip route replace 192.168.100.0/22 dev wwan0
DN=<oai-ext-dn address> # on the gNB node: docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' oai-ext-dn
ping -I wwan0 -c 5 $DN # OAI ext-DN
ping -I wwan0 -c 5 8.8.8.8 # the internet
+C5GREG: 0,1 # registered, home, on NR5G-SA 001/01
IPv4 address: 192.168.100.2
5 packets transmitted, 5 received, 0% packet loss (8.8.8.8)
Use only a scoped route (
192.168.100.0/22 dev wwan0) — never adefault via wwan0. If--wds-start-networkreturnsCallFailed, clear stale UPF state (see Troubleshooting). If the first ping batch shows 100% loss, that's the modem leaving RRC-idle — the next batch succeeds.
The step-by-step above is Mode 1 (manual CLI). The same experiment ships as an Ansible playbook and a Jupyter notebook that read one parameter file, share one log parser and emit one JSON result contract, so the three can be checked against each other.
On the console of your domain (console.sb1.cosmos-lab.org):
cd ~ && wget -q https://www.cosmos-lab.org/files/cosmos-tutorials-duranta-ota.zip
unzip -o cosmos-tutorials-duranta-ota.zip # extracts into ~/tutorials/
cd ~/tutorials && ls duranta-ota
| File | Purpose |
|---|---|
duranta-ota_params.yml |
the single parameter source: radio node + USRP address, band mask, PLMN/IMSI, timeouts |
duranta-ota.yml |
Mode 2 — Ansible: cosmos_preflight, radio power-cycle from the console (omf), gNB + core, modem registration, QMI data call, dual-target measurement, contract |
duranta-ota_manual.sh |
Mode 1 scripted: the exact commands of this page, node by node |
duranta-ota_tutorial.ipynb |
Mode 3 — the notebook (kernel on the console, papermill-runnable) |
duranta-ota_modem.py |
the Quectel AT helper (SA-only, band mask, APN, PLMN lock, registration wait) |
duranta-ota_lib.py |
shared parser + result contract + the parity comparison spec |
duranta-ota_parity.sh |
runs all three modes on the same two nodes and asserts they agree |
duranta-ota_teardown.yml |
clean teardown (data call down, gNB + core down; the radio is left powered) |
OMF_NODESis required and ordered: first node = gNB host (theduranta.ndznode with the USRP), second = UE host (the modem). The radio itself is not inOMF_NODES— it is named in the params (duranta_sdr_node,duranta_usrp_addr) because it is a shared sandbox resource that the playbook power-cycles withomffrom the console: reserve it and coordinate with its other users first.
cd ~/tutorials
export OMF_NODES="srv1-lg1,sdr1-in3" # gNB host, UE host
ansible-playbook duranta-ota/duranta-ota.yml
The playbook power-cycles the radio and waits until it is genuinely claimable (uhd_usrp_probe, not just discovery — MPM answers discovery ~20 s before it accepts a session), regenerates the gNB config for that radio, runs the image's start-all.sh, waits for in service plus an advancing frame counter, then on the UE host configures the modem (SA-only, band mask including the cell's band, APN, PLMN lock), waits for +C5GREG: 0,1, brings up the QMI data call with scoped routes only, and measures ping to the local ext-DN and to 8.8.8.8 plus iperf3 DL/UL to the ext-DN. Outputs land in $DURANTA_OTA_RUN_DIR (default /tmp/duranta-ota-$USER).
export OMF_NODES="srv1-lg1,sdr1-in3"
/opt/cosmos-jupyter/venv/bin/papermill duranta-ota/duranta-ota_tutorial.ipynb duranta-ota/duranta-ota_tutorial-out.ipynb
bash duranta-ota/duranta-ota_parity.sh srv1-lg1 sdr1-in3
=== duranta-ota parity ===
mode ue_ipv4 rsrp_dbm sinr_db tx_late ping_rtt_ms ping_remote_loss_pct dl_mbps ul_mbps
ansible 192.168.100.11 -77 31 0 11.14 0.0 113.0 15.1
manual 192.168.100.11 -77 29 0 12.25 0.0 112.0 14.0
jupyter 192.168.100.11 -76 30 0 12.0 0.0 113.0 13.6
dl_mbps spread max/min = 1.01 (tol 1.60)
ul_mbps spread max/min = 1.11 (tol 1.80)
PARITY: PASS (same experiment in all modes; all checks green)
Pass criteria: every mode reaches in service, registers (+C5GREG: 0,1) and gets a PDU-session address on the same PCI/band; local ping loss ≤ 20 %; DL in 20–400 and UL in 2–120 Mbit/s and within 60 %/80 % of each other across modes (over-the-air numbers depend on the modem's RRC state and the shared RF space, hence the wide bands). (run on sb1, 2026-09-08: gNB srv1-lg1.sb1 + N310 sdr1-s1-lg1, modem on sdr1-in3.sb1, image duranta-20260907c). Green parity is what sets this page's Last verified; re-running it after an image refresh is the regression test.
cell PLMN 001.01 … is in service and got sync.+C5GREG: 0,1 on 001/01; the AMF logs 5GMM-REGISTERED for the SUPI.192.168.100.0/22; ping <oai-ext-dn> and ping 8.8.8.8 = 0% loss.[HW] [xmit] tx samples 0 underflow in /tmp/oai-gnb.log.Measure air capacity with UDP. Run an iperf3 server inside the oai-ext-dn container (the data network) and keep a keepalive ping running:
# on the gNB/core node:
docker exec -d oai-ext-dn iperf3 -s
# on the UE node:
ping -I wwan0 -i 0.3 $DN >/dev/null 2>&1 &
iperf3 -c $DN -B $IP -u -b 150M -R -t 12 # DL
iperf3 -c $DN -B $IP -u -b 20M -t 12 # UL (TCP UL can drop iperf3's control channel under retransmissions — measure UL with UDP)
Measured with a Quectel RM520N-GL (band n41, 40 MHz, 30 kHz SCS, TDD DDDSU), by the gNB server's CPU platform:
| Date | gNB CPU platform | Image | Method | DL, Mbit/s | UL, Mbit/s | RTT | Notes |
|---|---|---|---|---|---|---|---|
| 2026-09-08 | 2× Intel Xeon Gold 6226 @ 2.70 GHz, 24 cores / 24 threads | duranta-20260907c |
the parity bundle, three modes (see above) | 112–113 | 13.6–15.1 | 11–12 ms | RSRP −77, SINR ~30 |
| 2026-09-04 | 2× Intel Xeon Gold 6126 @ 2.60 GHz, 24 cores / 48 threads | duranta-20260905b |
manual, UDP DL / TCP UL | 102 (0.6 % loss at 150 M offered) | 8.5 | ~13 ms | RSRP −87; DL MCS 25, UL MCS 14 after the config notes below |
| 2026-07-15 | not recorded | duranta.ndz of that date |
manual, UDP | ≈115 | ≈4 | ~20 ms | RSRP −79; air-limited |
Config notes (2026-09-04). OAI's stock band-41 file (gnb.sa.band41.fr1.106PRB.usrpb210.conf) carries lab values that cap this cell: pusch_TargetSNRx10 = 150, ul_min_mcs = 16 (UL never adapts and runs one step too hot) and dl_max_mcs = 10. /root/duranta/mk-gnb-oai-40.sh sets 200 / 0 / 28 and att_tx = 12 (0 dB overdrives a nearby modem). After a gNB restart the first --wds-start-network may return CallFailed: AT+COPS=2, then AT+COPS=1,2,"00101",12, then retry.
ssh root@<gnb-node> 'pkill -x nr-softmodem;
docker compose -f /opt/oai-cn5g/oai-cn5g-fed/docker-compose/docker-compose-basic-nrf.yaml down'
ssh root@<ue-node> 'qmicli -d /dev/cdc-wdm0 --wds-stop-network=disable-autoconnect 2>/dev/null'
omf tell -a offh -t <gnb-node>,<ue-node>
Bundle changes 2026-09-14 (republished
cosmos-tutorials-duranta-ota.zip):
- The modem helper (
duranta-ota_modem.py) now self-heals the Quectel's USB data path: before configuring, a non-zerotx_errorsonwwan0triggers a USB device reset of the modem (a healthy host→modem data path shows 0). It also switches the carrier MBN profile off after the last radio cycle — on the RM520N-GL even a lightCFUN=0/1re-activatesROW_Commercial.- The parity harness now runs papermill with
--cwd(the notebook no longer falls back to an older~/tutorialscopy) and runs the tutorial's teardown on every exit ("releasing the rig" / "rig released").These changes were validated with the OCUDU bundles on 2026-09-14 (the modem helper is byte-identical); the Duranta bundle itself was not re-run, so Last verified is unchanged.
| Symptom | Likely cause | Fix |
|---|---|---|
| Modem registers and gets an IP, but 100 % loss; the gNB sees no SR/BSR from the UE | the host→modem USB data endpoint is stuck: every uplink packet fails with -EPROTO before it reaches the modem |
ip -s link show wwan0 — TX errors +1 per packet, TX packets 0 confirms it. A USB device reset of the modem fixes it (the bundle's modem helper does this automatically); a qmi_wwan unbind/bind does not |
ansible-playbook preflight: "OMF_NODES selected 1 node(s) … needs exactly 2" while both nodes answer ssh |
omf stat shows a node as Unknown (UNREACHABLE) (its BMC is wedged), so cosmos-inventory.py drops it |
point Ansible at a static inventory for the run: ANSIBLE_INVENTORY=<file> with a YAML group nodes listing the gNB host then the UE host (ansible_user: root); report the BMC to the testbed operators |
gNB: dlopen(libparams_libconfig.so) … No such file → exits |
relocated build, stale rpath | export LD_LIBRARY_PATH=/opt/duranta/cmake_targets/ran_build/build:$LD_LIBRARY_PATH (start-all.sh does this) |
gNB aborts on UHD stream / start-all.sh says route via CTRL |
fronthaul route fell back to the control net | start-all.sh derives the DATA iface and refuses to launch over CTRL — re-run it (it pins the route on the DATA iface) |
gNB: NGSetup fails / AMF not associated |
OAI CN5G AMF IP is dynamic and the .conf has a stale one |
re-run start-all.sh (it reads docker inspect oai-amf and updates amf_ip_address) |
Modem registers but --wds-start-network → CallFailed |
stale UPF F-TEID after failed PDU cycles | docker compose restart oai-upf oai-smf |
[HW] [xmit] tx samples 0 != …, UE DL glitchy |
fronthaul NIC IRQs on the PHY cores | start-all.sh stops irqbalance + remaps DATA IRQs to housekeeping cores |
Modem won't camp (SEARCH) |
nr5g_disable_mode=1 disables SA on RM520N-GL |
set to 0; lock AT+COPS=1,2,"00101",12 |
AT+COPS=1,2,"00101",12 → +CME ERROR: 30, modem stays in SEARCH while the cell is up |
the modem's NR band mask excludes n41 (AT+QNWPREFCFG="nr5g_band" shows e.g. 77:78) |
AT+QNWPREFCFG="nr5g_band",41:77:78 |
Power-cycle completes and the radio pings, but uhd_find_devices never sees it ("MPM did not come up") |
the radio's MPM daemon did not start or is wedged (RPC timeout); an omf power cycle of the node is not always enough | ssh root@<usrp-mgmt-ip> reboot (N3xx run Linux); if it still does not answer, the radio itself needs a cold cycle at its PDU — ask the testbed operators |
gNB: Someone tried to claim this device again / start-all.sh reports claimed: True |
another session holds the USRP (or a stale claim after a crash) | coordinate with the other user; never reboot a radio someone may be using. If it is yours: omf tell -a offh / 10 s / on on the console |
Log floods with L letters, pucch0_DTX climbs, DL BLER ≈ 1 |
TX samples reach the USRP late: gNB threads on the other NUMA node or sharing cores with NIC IRQs / hyper-thread siblings; or the USRP NIC left with a single TX queue | run cosmos-ofh-isolation --apply + reboot before start-all.sh (it then pins the gNB); keep the USRP NIC at ≥ 8 queues |
ping <ext-dn> works but iperf3 → connection refused / Bad file descriptor |
Docker assigned that address to another container (.131 was mysql on one boot) |
use docker inspect … oai-ext-dn for the target |
Modem: Unknown DNN reject |
APN ≠ the CN5G DNN | AT+CGDCONT=1,"IP","oai" (not internet); clear back-off with AT+CFUN=1,1 |
UE ping 8.8.8.8 fails, ext-DN pings |
ext-dn NAT / route stale | docker exec oai-ext-dn ping 8.8.8.8 to confirm; restart the stack so ext-dn re-reads UE_NETWORK=192.168.100.0/22 |
nr-softmodem as an RFsim server + OAI's nr-uesoftmodem as the client — no USRP. See the Duranta soft-only tutorial (validated DL 164 / UL 84 Mbit/s)..conf TDD pattern for more UL at the cost of DL — see the oai-gnb skill.2026.w36 (commit b3930e39f5); upstream OAI docs at gitlab.eurecom.fr/oai/openairinterface5g.omf-duranta; OAI libconfig + build — skill oai-gnb.basic-nrf config (/opt/oai-cn5g/oai-cn5g-fed/docker-compose); Quectel modem — skill quectel-5g-modem.Author(s): COSMOS team · Last verified: 2026-09-08 (three-mode parity on sb1, duranta-20260907c; first verified 2026-07-15) · Tested image/release: duranta.ndz = duranta-20260907c, built from Duranta tag 2026.w36 (Duranta/OAI nr-softmodem USRP build + OAI CN5G) + lte5gue.ndz, PLMN 00101, pool 192.168.100.0/22 · Bundle: cosmos-tutorials-duranta-ota.zip · Tags: duranta, oai, openairinterface, oai-cn5g, 5g, sa, sdr, usrp, quectel