Skip to content

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.

The GEO NTN testbed: a complete 5G standalone network over a modelled 240 ms geostationary link with no radios and no satellite, on a Linux host. An OAI UE-SIM (nr-uesoftmodem) with an NTN UE-side stack, positioning, ECEF fix, timing-advance pre-compensation and Doppler, streams IQ over ZMQ to a geo_buffered_relay.py that models the service link with DL and UL FIFOs, then over ZMQ to the OCUDU RAN gNB (CU-CP, CU-UP, DU) configured for NTN Release 17 with K_offset 240, ephemeris, epoch, HARQ feedback off, HARQ mode B and PRACH 44, connected to an SD-Core 5G core.
The GEO NTN testbed. A full 5G standalone stack runs over a modelled 240 ms geostationary link on a single Linux host: OAI UE-SIM, a GEO delay relay that emulates the service link, the OCUDU RAN gNB configured for Release 17 NTN, and a 5G core. No radios and no satellite required.

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
Message-flow sequence diagram of the full attach over the GEO NTN testbed, between the UE (OAI nrUE), the GEO relay, the gNB (OCUDU RAN) and the 5G core (Aether SD-Core). Broadcast of MIB, SIB1, SIB2 and SIB19 with ephemeris and K_offset 240; the UE pre-compensates timing advance and Doppler; random access Msg1 to Msg4 with each hop about 120 ms; RRC and NAS with registration, authentication, security mode and InitialContextSetup; PDU session setup creating oaitun_ue1; and user-plane IP traffic with a measured round-trip time of approximately 510 ms.
The full attach over the simulated GEO link, from system information through random access, RRC and NAS, PDU session setup, to user-plane IP traffic, with a measured round-trip time of about 510 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:

  1. Never pad without consuming. A pad that does not drop an input sample would permanently lengthen the delay.
  2. Serve credit. served <= pulled is enforced, so a consumer cannot drain the prefill and silently collapse the delay to zero.
  3. shift telemetry. 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 emulatorgeo_buffered_relay.py
Msg3 CRC70 failures, 0 successes77 successes, 1 failure
Msg3 SINR-13 to -29 dBgood
gNB Real-time failure in RF: overflow3350
Outcomecontention resolution fails foreverfull 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 resourcePRBResult
HARQ-ACK, no hopping027.3 dB, decodes
HARQ-ACK, hopping 0 to 240 and 2410.3 dB, 17 dB worse
Scheduling Request24-28.5 dB, never decodes
CSI23 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:

Metric5 MHz / 25 PRB10 MHz / 52 PRB
SR detected by gNB016 to 31
UE SR exhaustion10
InitialContextSetupFailure10
InitialContextSetupResponse01
SMF-allocated UE IPnoyes

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: true
  • pusch.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:

  • sudo is required. The UE needs CAP_NET_ADMIN to create the oaitun_ue1 TUN interface. Without it the attach completes but the data plane never comes up, and the log shows Interface oaitun_ue1 couldn't be configured.
  • --ssb 62, not 60. At 52 PRB the SSB sits at offsetToPointA(5 PRB) x 12 + k_SSB(2) = 62 subcarriers 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

SymptomCauseFix
zmq_bind ... failed on tcp://127.0.0.1:2101A previous UE is still runningKill stale processes (before every run)
UE searches forever, Error decoding PBCH, no SSB foundWrong SSB offsetUse --ssb 62 at 52 PRB
PRACH succeeds, RAR-Msg2 decoded, then Contention resolution failed repeatedlyRelay sample rate or delay does not match the cellRun the relay with no arguments
Same as above, using the stock GNU Radio emulatorThe throttle cannot hold sample timingUse geo_buffered_relay.py
RAR reception failed, zero preambles detected at the gNBStale epoch_timestampRefresh it (before every run)
SR not served! SR counter 64 reached sr_MaxTransmissions 64 then InitialContextSetupFailureCell is 25 PRB, SR PUCCH on the top PRBUse the 10 MHz / 52 PRB configuration
Attach completes but Interface oaitun_ue1 couldn't be configuredUE not running as rootRun the UE with sudo
gNB starts but no UE can attach, no NGSetupResponseCore unreachable or wrong bind addressCheck cu_cp.amf settings and that the core is healthy
ping 8.8.8.8 fails while ping 10.0.0.1 worksUPF NAT and forwardingCore-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

PathPurpose
configs/ntn/gnb_zmq_10mhz.ymlComplete, self-contained gNB configuration
configs/ntn/geo_buffered_relay.pyThe GEO delay relay
configs/ntn/NTN.mdIntroduction to NTN concepts
configs/ntn/ARCHITECTURE.mdComponent and interface breakdown
configs/ntn/CHANGES.mdFull change history and rationale

References

  1. 3GPP, "Non-Terrestrial Networks (NTN)", Technologies Deep Dive. 3gpp.org/technologies/ntn-overview
  2. 3GPP TS 38.300, "NR; Overall description; Stage 2", clause 16.14 (NTN).
  3. 3GPP TS 38.321, "NR; Medium Access Control (MAC) protocol specification", clause 5.4.4 (Scheduling Request).
  4. 3GPP TS 38.213, "NR; Physical layer procedures for control" (K_offset timing relationships).
  5. 3GPP TS 38.331, "NR; Radio Resource Control (RRC) protocol specification" (SIB19).
  6. OpenAirInterface, "How to run a NTN configuration", doc/ntn-configuration.md.
  7. OCUDU NTN tutorial. docs.ocudu.org/tutorials/ntn