GEO NTN Testbed: OCUDU gNB with OAI nrUE over ZMQ
This is the technical companion to the NTN introduction: what was built, how it works, and how to compile and run it. A complete 5G Standalone network running end to end over a simulated geostationary satellite link, in software, on a single machine, with no radio hardware and no satellite.
What this project is
The network is real in every sense that matters. A real gNB (OCUDU), a real handset stack (OpenAirInterface nr-uesoftmodem), and a real 5G core (Aether SD-Core) carry out the genuine 5G procedures: cell search, system information, random access, RRC connection setup, NAS authentication, security activation, PDU session establishment, and user-plane IP traffic. The only thing replaced is the radio itself. Instead of transmitting over the air, the gNB and the UE exchange raw IQ samples through TCP sockets, and a relay process in the middle holds a fixed backlog of those samples. That backlog is the propagation delay of a geostationary satellite.
The result is that every layer above the samples genuinely experiences around 240 ms of round-trip delay. This matters because 5G was designed on the assumption that propagation is effectively instantaneous. Non-Terrestrial Network (NTN) support, standardised in 3GPP Release 17, exists precisely to handle the case where it is not, and those mechanisms only get exercised when the delay is actually present. If you are new to NTN as a concept, read the NTN introduction first.
What was achieved
Full attach and a working data plane over the simulated GEO link:
SSB -> SIB19 -> PRACH -> RAR -> Msg3 -> Msg4 -> RRC_CONNECTED
-> NAS authentication -> NAS security mode
-> InitialContextSetupResponse
-> PDUSessionResourceSetupResponse
-> Registration Accept
-> PDU Session Establishment Accept with an SMF-allocated IP address
$ ping -I oaitun_ue1 10.0.0.1
4 packets transmitted, 4 received, 0% packet loss
rtt min/avg/max/mdev = 328/510/638 ms
The measured round-trip time of around 510 ms is exactly what a geostationary link should produce: roughly 480 ms of propagation plus scheduling.
Architecture in brief
The three processes
OAI nrUE relay OCUDU gNB
(handset) (satellite channel) (base station)
| | |
| ZMQ tcp://:2100 <----+---- pull tcp://:2000 |
| (downlink IQ) | |
| | |
| ZMQ tcp://:2101 ---->+----> serve tcp://:2001 |
| (uplink IQ) | |
| |
oaitun_ue1 N2 (SCTP) / N3 (GTP-U)
TUN interface |
user-plane IP Aether SD-Core
The gNB is a single process that internally contains the CU-CP (RRC, NAS relay, N2 termination), the CU-UP (PDCP, GTP-U, N3 termination) and the DU (MAC scheduler, RLC, PHY). The F1 and E1 interfaces between them run in-process.
What is being modelled
UE ))))) service link ))))) SATELLITE ==== feeder link ==== GATEWAY + gNB
~120 ms (transparent) (modelled as (on ground)
bent-pipe) zero length)
This is a transparent payload GEO scenario, which is the Release 17 baseline. The satellite is a dumb RF repeater and the base station is on the ground. In our model the gNB sits at the satellite, so the feeder link has zero length and the entire delay lives on the service link. That is why ta_common is zero in the configuration and the whole propagation budget is carried by cell_specific_koffset.
The relay, and why it is not the stock emulator
The relay (geo_buffered_relay.py) is a pure Python and pyzmq program with four threads, two per direction. Each direction has a FIFO pre-filled with delay_bytes of zeros, so that output[k] = input[k - delay_samples]. Nothing is timestamped. The delay simply is the number of samples resident in the buffer, which at 15.36 Msps and 120350 microseconds is 1,848,576 samples per direction. Three invariants keep it sample-exact:
- Never pad without consuming. A pad that does not drop an input sample would permanently lengthen the delay.
- Serve credit.
served <= pulledis enforced, so a consumer cannot drain the prefill and silently collapse the delay to zero. shifttelemetry. Printed every two seconds, it must read+0. If it moves, the stream has gained or lost a sample.
Crucially, the serve loop sends exactly one block per consumer request. The pacing is therefore the gNB's and the UE's own slot clock, and there is no independent rate limiter to drift against.
OCUDU ships a stock GNU Radio channel emulator, and it does not work for this scenario. It is a flowgraph of the form req_source -> throttle -> delay -> rep_sink. The blocks.throttle element paces from the host wall clock and is documented as not being for precise timing, and nothing ties its output rate to what the radios actually demand. Measured with everything else held constant and only the middle box swapped:
| Stock GNU Radio emulator | geo_buffered_relay.py | |
|---|---|---|
| Msg3 CRC | 70 failures, 0 successes | 77 successes, 1 failure |
| Msg3 SINR | -13 to -29 dB | good |
gNB Real-time failure in RF: overflow | 335 | 0 |
| Outcome | contention resolution fails forever | full attach and IP |
PRACH survives either emulator because it is a long correlation with a 684 microsecond cyclic prefix. Coherent PUSCH demodulation, with a 4.7 microsecond guard interval, does not.
What had to change
The single most important configuration fact
The cell must be 10 MHz / 52 PRB. At 5 MHz / 25 PRB the Scheduling Request PUCCH resource lands on PRB 24, the top of the bandwidth part, and the gNB never decodes it. Measured across a full run:
| PUCCH resource | PRB | Result |
|---|---|---|
| HARQ-ACK, no hopping | 0 | 27.3 dB, decodes |
| HARQ-ACK, hopping 0 to 24 | 0 and 24 | 10.3 dB, 17 dB worse |
| Scheduling Request | 24 | -28.5 dB, never decodes |
| CSI | 23 to 24 | -6.6 dB, never decodes |
The consequence is a cascade. The UE sends 64 Scheduling Requests, none are detected, and per TS 38.321 clause 5.4.4 it then releases PUCCH entirely. From that point it has no way to request an uplink grant, securityModeComplete never leaves its RLC queue, and the AMF times out with InitialContextSetupFailure. Moving to 52 PRB resolves it:
| Metric | 5 MHz / 25 PRB | 10 MHz / 52 PRB |
|---|---|---|
| SR detected by gNB | 0 | 16 to 31 |
| UE SR exhaustion | 1 | 0 |
InitialContextSetupFailure | 1 | 0 |
InitialContextSetupResponse | 0 | 1 |
| SMF-allocated UE IP | no | yes |
Scheduler defects fixed in OCUDU
Three genuine bugs had to be fixed. All are NTN-triggered, because cell_specific_koffset stretches every uplink timeline by roughly 250 slots.
Uplink deadlock from stale in-flight HARQ bytes (lib/scheduler/slicing/slice_ue_repository.cpp). pending_ul_newtx_bytes() subtracted every byte outstanding in the UE's uplink HARQ processes from the slice's pending byte count. Measured at the point of failure, the slice had 259 bytes pending while 544 bytes sat in HARQ, so the subtraction clamped to zero. The gNB concluded the UE had nothing to send and issued no grant. Without a grant the UE could not transmit, so that HARQ never received its CRC and never freed. Self sustaining. It is NTN-specific in practice because a HARQ stays in flight for around 250 slots instead of around 4.
Wrong ring index when clearing PUSCH RB counters (lib/scheduler/slicing/ran_slice_instance.cpp). Allocations are recorded at slot_tx + k2 + ntn_cs_koffset, but the clear index omitted the offset, so reads returned stale counts from the previous cycle.
Dropped maxMIMO-Layers in the RRC encoder (lib/du/du_high/du_manager/converters/asn1_rrc_config_helpers.cpp). The out.ext flag was computed before all extension fields were set, so maxMIMO-Layers, which lives in extension group 0, was omitted from the encoded PDSCH-ServingCellConfig. This asserted in the UE.
Plus a constant change in include/ocudu/scheduler/sched_consts.h, raising SRB1_TRIG_GRANT_SIZE from 16 to 256 bytes.
Release 17 NTN features enabled
Following the 3GPP guidance that geostationary systems should disable HARQ feedback and rely on RLC ARQ:
pdsch.harq_feedback_disabled: truepusch.harq_mode_b: true- RLC AM tuned for the round-trip time:
t-poll-retransmit: 1000,max-retx-threshold: 32
Two limitations are worth recording. OCUDU implements only the Release 15 sr-ProhibitTimer, whose maximum is 128 ms, whereas the OAI GEO reference configuration uses the Release 17 sr-ProhibitTimer-v1700 value of 512 ms. And cell_cfg.ntn.ta_info.ta_common is accepted by the configuration parser but never reaches SIB19; the value that does reach it is ta_common_offset.
Deployment: prerequisites
Tested on Ubuntu 22.04. You need three things built or running: the Aether SD-Core 5G core, the OCUDU gNB, and the OAI UE.
System packages
sudo apt update
sudo apt install -y libzmq3-dev python3-zmq cmake build-essential ninja-build git
Verify:
dpkg -l | grep libzmq3-dev # expect 4.3.4 or later
python3 -c "import zmq; print(zmq.__version__)"
Getting the source
The NTN work lives on the ntn branch of the OCUDU-RAN repository:
git clone https://github.com/TOSSI-Foundation/OCUDU-RAN.git ~/ocudu
cd ~/ocudu
git checkout ntn
Or in a single step:
git clone -b ntn https://github.com/TOSSI-Foundation/OCUDU-RAN.git ~/ocudu
cd ~/ocudu
Confirm you are on the right branch and that the NTN assets are present:
git branch --show-current # expect: ntn
ls configs/ntn/ # expect: gnb_zmq_10mhz.yml, geo_buffered_relay.py
If you already have the repository cloned from a different remote, add this one and fetch the branch instead:
cd ~/ocudu
git remote add tossi https://github.com/TOSSI-Foundation/OCUDU-RAN.git
git fetch tossi ntn
git checkout -b ntn tossi/ntn
The rest of this guide assumes the repository is at ~/ocudu. Adjust the paths if you cloned somewhere else.
The 5G core
Aether SD-Core was used as the 5G core for this deployment. Bring it up per its own documentation and make sure it is running and reachable before you start the gNB.
Provision the subscriber. The IMSI, Ki and OPc in the UE configuration must match a subscriber provisioned in the core, and the DNN and S-NSSAI must match the network slice too. A mismatch here surfaces much later as an authentication or PDU session failure, so check it before you start debugging the radio.
Compilation
OCUDU gNB
cd ~/ocudu
mkdir -p build && cd build
cmake ../ -DENABLE_EXPORT=ON -DENABLE_ZEROMQ=ON
make -j$(nproc)
-DENABLE_ZEROMQ=ON is mandatory. Without it the zmq RF driver is not compiled and the gNB cannot talk to the relay. Verify:
ls -la ~/ocudu/build/apps/gnb/gnb
OAI nrUE
cd ~/oai_zmq/cmake_targets
./build_oai --nrUE --ninja --build-lib all
This produces nr-uesoftmodem and, importantly, liboai_zmqdevif.so, the ZMQ device interface the UE loads at runtime via --device.name oai_zmqdevif. Verify both exist:
ls ~/oai_zmq/cmake_targets/nr-uesoftmodem
ls ~/oai_zmq/cmake_targets/liboai_zmqdevif.so
UE configuration
Edit targets/PROJECTS/GENERIC-NR-5GC/CONF/ue.ntn.geo.conf:
uicc0 = {
imsi = "001010000000002";
key = "<must match the core subscriber DB>";
opc = "<must match the core subscriber DB>";
pdu_sessions = ({ dnn = "oai"; nssai_sst = 1; });
}
position0 = {
x = -4242261.2;
y = 4755938.6;
z = -253924.5;
}
position0 is not optional. NTN UEs are assumed to be GNSS-capable and must pre-compensate their timing advance and Doppler from their own position combined with the satellite ephemeris. Without a position the timing advance calculation is wrong and random access will not complete.
Running
Before every run: two mandatory steps
Kill stale processes. A leftover UE or relay holds the ZMQ ports and the next run fails with zmq_bind ... failed on tcp://127.0.0.1:2101.
sudo pkill -9 -f nr-uesoftmodem
pkill -9 -f 'apps/gnb/gnb'
pkill -9 -f geo_buffered
Refresh the epoch timestamp. The gNB propagates the satellite position from epoch_timestamp and broadcasts the propagated result in SIB19. The UE derives its entire timing advance from that. A stale epoch shifts the timing advance and random access fails.
cd ~/ocudu
sed -i "s/epoch_timestamp: '[^']*'/epoch_timestamp: '$(date -u '+%Y-%m-%dT%H:%M:%S')'/" \
configs/ntn/gnb_zmq_10mhz.yml
Terminal 1: the relay
cd ~/ocudu
python3 -u configs/ntn/geo_buffered_relay.py
Run it with no arguments. The defaults are 120350 microseconds one way at 15.36 Msps, which are the values that match the shipped gNB configuration. Passing the older 7.68 Msps sample rate against a 15.36 Msps cell will produce a contention resolution failure that looks like a radio problem. Expected startup output:
pull-on-serve relay: 120.350 ms delay = 1848576 samples, block 15360 samples, 15.360 Msps
relay running: DL 2000->2100, UL 2101->2001. Ctrl-C to stop.
Terminal 2: the gNB
cd ~/ocudu
./build/apps/gnb/gnb -c configs/ntn/gnb_zmq_10mhz.yml
Before starting the UE, confirm the core connection succeeded:
N2: Connection to AMF on <addr>:38412 was established
Tx PDU: NGSetupRequest
Rx PDU: NGSetupResponse
"NG Setup Procedure" finished successfully
Note that N2 uses SCTP, not TCP. A TCP connectivity probe against port 38412 proves nothing either way. Set cu_cp.amf.addr, cu_cp.amf.bind_addr and the cu_up.ngu socket addresses to match your own core before running. The shipped file contains loopback placeholders following the repository convention.
Terminal 3: the UE
mkdir -p ~/uerun && cd ~/uerun
sudo ~/oai_zmq/cmake_targets/nr-uesoftmodem \
-O ~/oai_zmq/targets/PROJECTS/GENERIC-NR-5GC/CONF/ue.ntn.geo.conf \
--band 256 -C 2185000000 --CO -190000000 -r 52 --numerology 0 --ssb 62 \
--device.name oai_zmqdevif \
--zmq.[0].tx_channels tcp://127.0.0.1:2101 \
--zmq.[0].rx_channels tcp://127.0.0.1:2100
Three details matter:
sudois required. The UE needsCAP_NET_ADMINto create theoaitun_ue1TUN interface. Without it the attach completes but the data plane never comes up, and the log showsInterface oaitun_ue1 couldn't be configured.--ssb 62, not 60. At 52 PRB the SSB sits atoffsetToPointA(5 PRB) x 12 + k_SSB(2) = 62subcarriers from Point A. With the wrong value the UE never decodes PBCH and simply searches forever.- Run from a writable directory. The UE writes files into its working directory.
Verifying success
Watch for this in the UE log:
Received Registration Accept with result 3GPP
Received PDU Session Establishment Accept, UE IPv4: 10.0.0.x
TUN Interface oaitun_ue1 successfully configured, IPv4 10.0.0.x
Then check the interface and the data plane:
ip addr show oaitun_ue1
ping -I oaitun_ue1 -c 4 10.0.0.1
A healthy result is 0% packet loss with a round-trip time around 500 ms. Anything much below that means the delay is not actually being applied and the relay should be checked. Also confirm the relay is still honest:
DL srv 5.27/pull 5.27 Msps (delay 1848776 smp = 120363.0 us, shift +0, under 0, drop 0) |
UL srv 5.28/pull 5.28 Msps (delay 1858558 smp = 120999.9 us, shift +0, under 0, drop 0)
shift must be +0 in both directions. drop should be zero.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
zmq_bind ... failed on tcp://127.0.0.1:2101 | A previous UE is still running | Kill stale processes (before every run) |
UE searches forever, Error decoding PBCH, no SSB found | Wrong SSB offset | Use --ssb 62 at 52 PRB |
PRACH succeeds, RAR-Msg2 decoded, then Contention resolution failed repeatedly | Relay sample rate or delay does not match the cell | Run the relay with no arguments |
| Same as above, using the stock GNU Radio emulator | The throttle cannot hold sample timing | Use geo_buffered_relay.py |
RAR reception failed, zero preambles detected at the gNB | Stale epoch_timestamp | Refresh it (before every run) |
SR not served! SR counter 64 reached sr_MaxTransmissions 64 then InitialContextSetupFailure | Cell is 25 PRB, SR PUCCH on the top PRB | Use the 10 MHz / 52 PRB configuration |
Attach completes but Interface oaitun_ue1 couldn't be configured | UE not running as root | Run the UE with sudo |
gNB starts but no UE can attach, no NGSetupResponse | Core unreachable or wrong bind address | Check cu_cp.amf settings and that the core is healthy |
ping 8.8.8.8 fails while ping 10.0.0.1 works | UPF NAT and forwarding | Core-side configuration, not a radio problem |
Enabling deeper diagnostics
The gNB configuration ships with PCAP capture wired up but disabled. Set any of the *_enable flags to true in the pcap section to capture F1AP, NGAP, MAC, RLC or E1AP, then open the files in Wireshark. Raising log.all_level to debug produces very large logs; prefer targeted levels such as mac_level or f1ap_level.
File reference
| Path | Purpose |
|---|---|
configs/ntn/gnb_zmq_10mhz.yml | Complete, self-contained gNB configuration |
configs/ntn/geo_buffered_relay.py | The GEO delay relay |
configs/ntn/NTN.md | Introduction to NTN concepts |
configs/ntn/ARCHITECTURE.md | Component and interface breakdown |
configs/ntn/CHANGES.md | Full change history and rationale |
References
- 3GPP, "Non-Terrestrial Networks (NTN)", Technologies Deep Dive. 3gpp.org/technologies/ntn-overview
- 3GPP TS 38.300, "NR; Overall description; Stage 2", clause 16.14 (NTN).
- 3GPP TS 38.321, "NR; Medium Access Control (MAC) protocol specification", clause 5.4.4 (Scheduling Request).
- 3GPP TS 38.213, "NR; Physical layer procedures for control" (K_offset timing relationships).
- 3GPP TS 38.331, "NR; Radio Resource Control (RRC) protocol specification" (SIB19).
- OpenAirInterface, "How to run a NTN configuration",
doc/ntn-configuration.md. - OCUDU NTN tutorial. docs.ocudu.org/tutorials/ntn