Skip to content

LEO NTN Testbed: 5G NR over a moving satellite, with handover

A 5G Standalone network in which the UE reaches the gNB through a low-Earth-orbit satellite at 600 km. Unlike GEO, the satellite moves: the delay changes continuously, the satellite is only visible for a few minutes per pass, and keeping a UE connected means handing it from one satellite to the next. Four scenarios run on one host, from a single pass to a satellite train that keeps a cell on the air indefinitely.

LEO NTN testbed system architecture. The OAI nrUE column contains NAS, RRC with SIB19 and re-sync on handover, MAC with satellite switch target and timing, PHY with sync and Doppler, the rfsimulator device acting as client for single cell and server for handover, the UE identity and endpoints, LEO parameters including NTN initial time drift of -40 and time sync I of 0.1, and the thread layout. The centre OCUDU gNB column contains CU-CP with NGAP, XnAP, neighbour relations, mobility manager and NGAP Path Switch, CU-UP with PDCP, SDAP, F1-U and GTP-U, a DU-high with the DU manager, RRC, RLC and MAC including K_offset 40 and T304, and an NTN configuration manager holding per-satellite context, per-cell context, orbital compute, satellite switch and satellite train configuration, rotate along orbit and find setting time. Below are DU-low upper PHY, the RU SDR type with the NTN channel controller, and the rfsimulator radio session with real-time pacing, the rfsim shim carrying NTN link state, delay and drift, and the vendored simulator. To the right are OCUDU gNB 2 with gNB ID 412 and PCI 2 on satellite 2 connected over Xn using XnAP over SCTP 38422 between 127.0.0.1 and 127.0.0.2, the OAI CN5G and SD-Core network functions, and the leo_tracker.py dashboard on port 8500 which follows the logs and is not in the data path. The footer lists interfaces and a scenario component count table: one pass uses 1 gNB, 1 cell and 1 satellite; handover uses 2 gNBs, 2 cells and 2 satellites with the UE as rfsim server; switch uses 1 gNB, 1 cell and 2 satellites; train uses 1 gNB, 1 cell and N satellites.
Figure 1: LEO NTN testbed system architecture. One propagator drives both the broadcast SIB19 ephemeris and the emulated channel, so they cannot drift apart.

What this is

This testbed covers four scenarios on one host:

#ScenarioWhat movesCells
1Single-cell LEO passOne satellite, horizon to horizon1 gNB
2Inter-cell handover (Xn)Two satellites, one gNB each; the UE hands over between cells2 gNBs
3Intra-cell satellite switch, one-shotTwo satellites behind one cell; the cell switches satellite once1 gNB
4Intra-cell satellite switch, loop (satellite train)N satellites behind one cell; the cell switches to the next one every time the serving one sets1 gNB
LEO NTN testbed use cases, four panels. Panel 01, single-cell pass: one satellite from horizon to horizon, with the OAI nrUE at the north pole and a single-cell OCUDU gNB, rising at 19 ms, zenith at 4 ms, then muted at setting. Mechanisms are K_offset 40, UL sync 30 s, rx_delay plus drift and horizon mute. Results: 12.8 min full pass, 4 to 19 ms round trip, 10 of 10 pings OK. Panel 02, inter-cell handover: two satellites with one gNB each over Xn, gNB 411 with PCI 1 serving and gNB 412 with PCI 2 as target, the OAI nrUE acting as rfsim server and SD-Core performing the path switch. Mechanisms are XnAP handover, NGAP path switch, UE as rfsim server, SD-Core only. Results: 227 ms handover to target, 82 ms path switch, 391 of 391 pings with no gaps. Panel 03, in-cell satellite switch: the cell keeps its identity while satellite 0 setting at 28.8 degrees hands to satellite 1 rising at 28.8 degrees at the UTC t-ServiceStart deadline, with the same NCI, PCI and SSB and no Xn, no RACH and no core involvement; the OAI nrUE re-syncs in-cell. Mechanisms are satSwitchWithReSync-r18, UL sync 5 s, SIB19 advertises it, no target cell. Results: 268 s armed ahead, applied 34 ms after, 0 failures and 0 releases. Panel 04, satellite train: N satellites so the cell never goes off air, showing satellites S0 through S4 with 12 satellites 30 degrees apart and a wrapping list, a switch elevation mask of 4.5 degrees, and the OCUDU gNB keeping the OAI nrUE attached. Mechanisms are num_satellites 12, switch elevation 4.5 degrees, min lead 10 s, armed on every switch. Results: 8.0 min switch gap, at least 10 satellites for coverage, 373 of 373 pings OK.
Figure 2: the four scenarios, with the mechanism and measured result for each.

Components:

  • OCUDU gNB (TOSSI-Foundation/OCUDU-RAN, branch ntn) with the OAI rfsimulator radio driver
  • OAI nrUE (TOSSI-Foundation/OAI-RAN, branch rfsim_ocudu) with SIB19 Rel-18/19 decoding and satSwitchWithReSync-r18
  • SD-Core as the 5G core for every handover scenario
  • LEO tracker: a small web dashboard that follows the gNB and UE logs

This replaces the earlier ZMQ-based LEO testbed, where the channel model ran inside the UE's ZMQ driver.

One ephemeris, two consumers

The gNB propagates the configured satellite state vector (Keplerian, from epoch_timestamp) every SIB19 update. The same propagated state feeds two things:

  1. SIB19, which the UE uses to pre-compensate its timing advance and to know when the satellite changes.
  2. The emulated channel: the rfsimulator applies the service-link round-trip delay to the gNB's receive path, extrapolated between updates with the delay drift. When the satellite is below the horizon at the reference location the link is muted in both directions.

Because the channel and the broadcast ephemeris come from one propagator, they cannot drift apart: the delay the UE's uplink experiences is exactly the timing advance it derived from SIB19.

The whole round trip is carried on one leg (the gNB receive path). Timing is exact; the downlink carries no delay and, by default, no Doppler. Service-link Doppler emulation exists (cell_cfg.ntn.emulate_doppler: true) but must be paired with UE-side continuous frequency-offset compensation and is off in every configuration below.

PropertyValue
Orbit600 km circular, polar, ECEF state vector
Orbit radius / speed6 956 752 m / 7 569 m/s (0.0623 deg/s)
Reference location / UE positionNorth pole (lat 90, lon 0), WGS84 surface
Round-trip delay4.0 ms at zenith, ~19 ms at the horizon
Delay driftup to ~46 us/s at the horizon, 0 at zenith
Pass, horizon to horizon~12.8 min
Cell-specific K_offset40 slots
UL sync validity30 s (5 s for satellite switching)

The air interface is identical to the GEO testbed: band n256, 2185 MHz DL, -190 MHz UL offset, 10 MHz / 52 PRB / 15 kHz, SSB at 62 subcarriers from Point A, HARQ feedback disabled, UL HARQ mode B.

Prerequisites

  • Ubuntu 22.04 or 24.04, x86-64, 8+ cores recommended
  • sudo rights
  • SD-Core reachable from the host
  • Accurate system time (NTP): the gNB propagates from wall-clock UTC and the UE switches satellites at a UTC deadline
  • Python 3 for the pass placement script and the tracker (standard library only)

1. Get the sources

mkdir -p ~/ntn && cd ~/ntn
git clone -b ntn https://github.com/TOSSI-Foundation/OCUDU-RAN.git
git clone -b rfsim_ocudu https://github.com/TOSSI-Foundation/OAI-RAN.git OAI_RAN

The run scripts find the UE at ../OAI_RAN relative to OCUDU-RAN; set OAI=/path/to/OAI_RAN if you place it elsewhere.

2. Build

OCUDU gNB:

sudo apt-get update
sudo apt-get install -y cmake make gcc g++ pkg-config git python3 \
    libfftw3-dev libmbedtls-dev libsctp-dev libyaml-cpp-dev libgtest-dev \
    libzmq3-dev libboost-program-options-dev

cd ~/ntn/OCUDU-RAN
cmake -S . -B build_ntn -DCMAKE_BUILD_TYPE=Release -DENABLE_RFSIM=ON
make -C build_ntn -j"$(nproc)" gnb
ls -l build_ntn/apps/gnb/gnb

OAI nrUE:

cd ~/ntn/OAI_RAN/cmake_targets
./build_oai -I
./build_oai --nrUE -w SIMU
ls -l ran_build/build/nr-uesoftmodem ran_build/build/librfsimulator.so

Core network

This testbed has been tested against both SD-Core and OAI CN5G. The single-cell pass and the satellite switch work with either. OAI CN5G does not support the NGAP Path Switch procedure, so the UE context and PDU session do not survive an inter-cell handover with it. All handover scenarios below use SD-Core.

The provided configs expect:

ItemValueConfig keys
AMF192.168.1.115cu_cp.amf.addr in sdcore.yml, leo_ho_sat1.yml, leo_ho_sat2.yml
gNB (single cell, and satellite 1) N2/N3 bind192.168.1.117cu_cp.amf.bind_addr, cu_up.ngu.socket[0].bind_addr
gNB satellite 2 N2/N3 bind192.168.1.120same keys in leo_ho_sat2.yml
PLMN / TAC00101 / 1
SliceSST 1, SD 010203 (66051)tai_slice_support_list, cell_cfg.slicing
SubscriberIMSI 001010100000001, key 5122250214c33e723a5dd523fc145fc0, OPc 981d464c7c52eb6e5036234984ad0bcf, DNN internetUE command line

Replace the addresses with your own. The two gNBs of the inter-cell handover each need their own host address on the network that reaches the AMF, for example:

ip -4 addr                                    # find the interface facing SD-Core
sudo ip addr add 192.168.1.120/24 dev <iface> # only if the second address is missing

The two gNBs talk Xn over loopback (127.0.0.1 and 127.0.0.2), which needs no setup on Linux.

All scenarios use this UE identity and slice on the command line:

UE_SDCORE="--uicc0.imsi 001010100000001 \
  --uicc0.key 5122250214c33e723a5dd523fc145fc0 --uicc0.opc 981d464c7c52eb6e5036234984ad0bcf \
  --uicc0.nssai_sst 1 --uicc0.nssai_sd 0x010203 \
  --uicc0.pdu_sessions.[0].nssai_sst 1 --uicc0.pdu_sessions.[0].nssai_sd 0x010203 \
  --uicc0.pdu_sessions.[0].dnn internet"

The commands below write these flags out in full.

3. Placing the satellite

The ephemeris is a state vector, so "start at 30 degrees" means recomputing several numbers together. scripts/set_leo_pass.sh does that and stamps the epoch in one step:

cd ~/ntn/OCUDU-RAN
./scripts/set_leo_pass.sh --help
./scripts/set_leo_pass.sh --show                           # current geometry and epoch
./scripts/set_leo_pass.sh --elev horizon --single          # single cell, rising at 0 deg
./scripts/set_leo_pass.sh --elev 30 --ho                   # handover pair, designed pass
./scripts/set_leo_pass.sh --elev 40 --elev2 10 --ho        # the two satellites set independently
./scripts/set_leo_pass.sh --elev zenith --single --lead 30 # 30 s to start the binaries
FlagMeaning
--elev <deg|horizon|zenith>Where satellite 1 starts (rising)
--elev2 <deg|horizon|zenith>Where satellite 2 starts; default is 18.3958 deg of orbit behind satellite 1
--single / --ho / --bothWhich configs to write (default --both)
--lead <s>Stamp the epoch this many seconds in the future (default 12)
--no-epochChange geometry only

The epoch lives only in configs/ntn/leo_rfsim_gnb.yml; the handover overlays inherit it, which keeps both satellites on one clock. The script also writes the epoch to /tmp/leo_epoch, which the commands below use to wait for a moment in the pass:

Tplus(){ while [ "$(date +%s)" -lt $(( $(cat /tmp/leo_epoch) + $1 )) ]; do sleep 1; done; }

Run scripts re-stamp the epoch themselves unless you pass KEEP_EPOCH=1.

4. The LEO tracker

scripts/leo_tracker.py follows the gNB and UE logs and serves a live page:

  • to-scale side view of every satellite, with the switch elevation mask
  • countdown to the next satellite switch
  • satellite cards: elevation, range, round trip, drift, link state
  • elevation-over-time chart with the serving satellite highlighted
  • event timeline with pop-up notifications for satellite switches, handovers, path switch, UE state
cd ~/ntn/OCUDU-RAN
python3 scripts/leo_tracker.py                                          # newest run found in /tmp
python3 scripts/leo_tracker.py /tmp/gnb_leo_sattrain.log --ue /tmp/ue_leo_sattrain.log
python3 scripts/leo_tracker.py /tmp/gnb_leo_sat1.log /tmp/gnb_leo_sat2.log --ue /tmp/ue_leo_ho.log
python3 scripts/leo_tracker.py --host 127.0.0.1 --port 8500              # local access only

Open http://<host>:8500. By default it binds all interfaces without authentication; use --host 127.0.0.1 on shared networks. If the page shows "not written for N min, this is a finished run", the gNB is not writing the log it is following.

Scenario 1: single-cell LEO pass

The satellite rises at the horizon when the epoch is reached and the UE attaches at low elevation, then stays connected until the satellite sets (~12.8 min), when the emulated link is muted.

sudo pkill -9 -f nr-uesoftmodem; sudo pkill -9 -f 'build_ntn/apps/gnb/gnb'
sudo rm -f /tmp/gnb_leo.log
cd ~/ntn/OCUDU-RAN
./scripts/set_leo_pass.sh --elev horizon --single --lead 20

Terminal 1, gNB

cd ~/ntn/OCUDU-RAN
sudo ./build_ntn/apps/gnb/gnb -c configs/ntn/leo_rfsim_gnb.yml -c configs/ntn/sdcore.yml

Terminal 2, UE (start within the 20 s lead)

sudo ~/ntn/OAI_RAN/cmake_targets/ran_build/build/nr-uesoftmodem \
    -O ~/ntn/OAI_RAN/targets/PROJECTS/GENERIC-NR-5GC/CONF/ue.ntn.leo.rfsim.conf \
    --band 256 -C 2185000000 --CO -190000000 -r 52 --numerology 0 --ssb 62 \
    --rfsim --rfsimulator.[0].serveraddr 127.0.0.1 \
    --time-sync-I 0.1 --ntn-initial-time-drift -40 \
    --uicc0.imsi 001010100000001 \
    --uicc0.key 5122250214c33e723a5dd523fc145fc0 --uicc0.opc 981d464c7c52eb6e5036234984ad0bcf \
    --uicc0.nssai_sst 1 --uicc0.nssai_sd 0x010203 \
    --uicc0.pdu_sessions.[0].nssai_sst 1 --uicc0.pdu_sessions.[0].nssai_sd 0x010203 \
    --uicc0.pdu_sessions.[0].dnn internet

--ntn-initial-time-drift -40 seeds the UE's downlink timing tracker with the drift at the rising horizon, before it has read SIB19; --time-sync-I 0.1 keeps the tracking loop gentle.

Terminal 3, traffic and tracker

ping -I oaitun_ue1 8.8.8.8
python3 ~/ntn/OCUDU-RAN/scripts/leo_tracker.py /tmp/gnb_leo.log

Verify

gNB log (/tmp/gnb_leo.log):

[NGAP    ] [I] "NG Setup Procedure" finished successfully
[NTN     ] [I] Emulated NTN channel updated, cell=0x66c000 rx_delay=19331us drift=-46.141us/s doppler=off sat=0
[RRC     ] [I] ue=0 c-rnti=0x4601: DCCH UL rrcSetupComplete

rx_delay falls to ~4000 us at zenith and rises again; drift changes sign at zenith. When the satellite sets, the gNB logs NTN: satellite set below the horizon at the reference location ... emulated link down.

Measured on this testbed, attaching at the rising horizon (19.7 ms round trip): the UE attached at the epoch and 10 of 10 pings to 8.8.8.8 returned in 122 to 174 ms. Echo times are dominated by the K_offset scheduling delay and uplink grant timing, not by the few milliseconds of propagation delay.

Scenario 2: inter-cell handover over Xn

Satellite 1 carries cell PCI 1 (gNB 411), satellite 2 carries cell PCI 2 (gNB 412), trailing satellite 1 by 18.4 degrees of orbit. The UE attaches through satellite 1; while satellite 1 descends and satellite 2 climbs through 30 to 40 degrees, the handover is commanded from the gNB console. The gNBs run an Xn handover, the target completes NGAP Path Switch with SD-Core, and the PDU session survives.

Satellite switching, two sequence diagrams side by side. On the left, inter-cell handover over Xn, between the OAI nrUE moving from PCI 1 to PCI 2, gNB 411 as source on satellite 1, gNB 412 as target on satellite 2, and SD-Core. Step 1, the handover is commanded on the gNB console. Step 2, XnAP HandoverRequest carrying the UE context and PDU session. Step 3, HandoverRequestAcknowledge. Step 4, RRCReconfiguration, the handover command. Step 5, re-sync and random access on PCI 2 with a new C-RNTI. Step 6, rrcReconfigurationComplete, 227 ms after HandoverRequest. Step 7, NGAP Path Switch Request and Ack, 82 ms later, moving the N3 tunnel to the target. Step 8, UEContextRelease. On the right, in-cell satellite switching with satSwitchWithReSync-r18 between the OAI nrUE which stays in the cell, the single-cell OCUDU gNB, and the served satellite moving from SAT 0 to SAT 1. Step 1, the gNB finds when SAT 0 sets through 4.5 degrees by bisection to 20 ms. Step 2, SIB19 broadcasts satSwitchWithReSync-r18 carrying t-ServiceStart. Step 3, the UE arms the switch on its own UTC clock with 268 s of notice. Step 4, t-ServiceStart, where both sides act on the same UTC deadline. Step 5, SAT 1 is promoted into service, applied 34 ms after the deadline. Step 6, the UE re-syncs in-cell and keeps RRC and the PDU session. The panel notes there is no target cell, no Xn, no random access and no core signalling, and the cell keeps its NCI, PCI and SSB while the next switch is armed immediately. A comparison table at the bottom contrasts the two mechanisms across target cell, RAN signalling, random access, core signalling, trigger and core support.
Figure 3: inter-cell Xn handover compared with in-cell satellite switching. The Xn handover moves the UE to a new cell; the in-cell switch changes the satellite behind one cell with no target cell and no core signalling.

How it differs from the single-cell setup

  1. rfsimulator roles are inverted. One rfsimulator server can have many clients, so for the UE to hear both cells, the UE is the server and both gNBs are clients (serveraddr=127.0.0.1). The UE starts first; the gNBs print connect() failed until they reach it.
  2. Satellite 2's gNB starts late, once the UE is attached through satellite 1. Both cells transmit into one sample stream, so a second cell coming up during initial access is interference.

The designed pass

Placed with ./scripts/set_leo_pass.sh --elev 30 --ho:

Time from epochSatellite 1Satellite 2Action
T+0 s30.0 deg, 7.17 ms-2.0 deg, below horizon (muted)UE attaches via satellite 1
T+34 s40.0 deg0.0 deg, 18.85 msSatellite 2 rises
T+120 s87.6 deg6.1 degStart gNB 2
T+123 s89.8 deg (zenith)6.3 deg
T+295 s20.4 deg30.0 degHandover window opens
T+310 s18.0 deg34.0 degCommand the handover
T+329 s15.4 deg40.0 degWindow closes
T+508 ssets40.0 degSatellite 1 link muted
T+803 ssets

One command

cd ~/ntn/OCUDU-RAN
./scripts/set_leo_pass.sh --elev 30 --ho --no-epoch
./scripts/run_leo_ho_rfsim.sh

The script starts the UE as server with the SD-Core subscriber, starts gNB 1, waits for the PDU session, starts a continuous ping to 8.8.8.8, starts gNB 2 at T+120, sends ho to gNB 1's console at T+310 and reports the result. Tunables: SAT2_AT, HO_AT, DUR, EPOCH_LEAD.

Step by step

sudo pkill -9 -f nr-uesoftmodem; sudo pkill -9 -f 'build_ntn/apps/gnb/gnb'
sudo rm -f /tmp/gnb_leo_sat1.log /tmp/gnb_leo_sat2.log
cd ~/ntn/OCUDU-RAN
./scripts/set_leo_pass.sh --elev 30 --ho --lead 30

Terminal 1, UE (rfsimulator server), start first

sudo ~/ntn/OAI_RAN/cmake_targets/ran_build/build/nr-uesoftmodem \
    -O ~/ntn/OAI_RAN/targets/PROJECTS/GENERIC-NR-5GC/CONF/ue.ntn.leo.rfsim.conf \
    --band 256 -C 2185000000 --CO -190000000 -r 52 --numerology 0 --ssb 62 \
    --rfsim --rfsimulator.[0].serveraddr server \
    --time-sync-I 0.1 --ntn-initial-time-drift -40 \
    --uicc0.imsi 001010100000001 \
    --uicc0.key 5122250214c33e723a5dd523fc145fc0 --uicc0.opc 981d464c7c52eb6e5036234984ad0bcf \
    --uicc0.nssai_sst 1 --uicc0.nssai_sd 0x010203 \
    --uicc0.pdu_sessions.[0].nssai_sst 1 --uicc0.pdu_sessions.[0].nssai_sd 0x010203 \
    --uicc0.pdu_sessions.[0].dnn internet

Terminal 2, gNB 1, satellite 1 (keep this terminal: it is the console used for ho)

cd ~/ntn/OCUDU-RAN
sudo ./build_ntn/apps/gnb/gnb -c configs/ntn/leo_rfsim_gnb.yml -c configs/ntn/leo_ho_sat1.yml

Wait until the UE reports TUN Interface oaitun_ue1 successfully configured.

Terminal 3, traffic

ping -I oaitun_ue1 -D -i 1 8.8.8.8

Terminal 4, gNB 2, satellite 2, at T+120

cd ~/ntn/OCUDU-RAN
Tplus(){ while [ "$(date +%s)" -lt $(( $(cat /tmp/leo_epoch) + $1 )) ]; do sleep 1; done; }
Tplus 120
sudo ./build_ntn/apps/gnb/gnb -c configs/ntn/leo_rfsim_gnb.yml -c configs/ntn/leo_ho_sat2.yml

Wait for Xn setup between the gNBs:

sudo grep -a 'XnSetup' /tmp/gnb_leo_sat1.log /tmp/gnb_leo_sat2.log

Command the handover at T+310. Get the UE's C-RNTI on gNB 1:

Tplus 310; sudo grep -ao 'c-rnti=0x[0-9a-f]*' /tmp/gnb_leo_sat1.log | tail -1

Then type into terminal 2 (gNB 1 console), using the RNTI in hex without 0x:

ho 1 4601 2 00101 1

Syntax: ho <serving pci> <rnti> <target pci> <target plmn> <target tac>.

Verify

Source gNB (/tmp/gnb_leo_sat1.log):

[XNAP    ] [I] Tx PDU: HandoverRequest
[XNAP    ] [I] Rx PDU: HandoverRequestAcknowledge

Target gNB (/tmp/gnb_leo_sat2.log):

[XNAP    ] [I] Rx PDU: HandoverRequest
[XNAP    ] [I] Tx PDU: HandoverRequestAcknowledge
[RRC     ] [I] ue=0 c-rnti=0x4601: DCCH UL rrcReconfigurationComplete
[NGAP    ] [I] ue=0 ran_ue=0 amf_ue=...: "Path Switch Procedure" finished successfully
[XNAP    ] [I] Tx PDU: UEContextRelease

UE: Starting re-sync detection for target Nid_cell 2, then random access on the target.

Measured on this testbed with the designed pass:

MetricResult
Handover commandT+310 s, satellite 1 at 18 deg, satellite 2 at 34 deg and rising
HandoverRequest to rrcReconfigurationComplete on the target227 ms
Path Switch with SD-Corefinished successfully 82 ms later
Ping to 8.8.8.8, attach to end of run391 sent, 391 received, no sequence gaps

The target may log Decoding failure from the ASN.1 layer followed by Could not decode Path Switch Request Ack Transfer for psi=1. Keeping the uplink tunnel in use. It is harmless with SD-Core: the handover completes and the existing uplink tunnel stays valid.

Scenario 3: intra-cell satellite switch, one-shot

Release 17/18 NTN lets a cell keep its identity (NCI, PCI, SSB) while the satellite carrying it changes: SIB19 advertises the next satellite (satSwitchWithReSync-r18) and the time it takes over (t-ServiceStart). There is no target cell, no Xn, no RACH and no core signalling. The UE re-syncs in-cell at t-ServiceStart.

configs/ntn/leo_satswitch.yml adds a second satellite to the single cell and switches once, at the crossover where both satellites are at ~28.8 degrees (T+252 s), so the delay is continuous and only its drift reverses.

Time from epochSatellite 1Satellite 2
T+0 s30.0 deg, 7.17 ms0.3 deg, 18.65 ms
T+124 szenith9.8 deg
T+252 s28.7 deg28.9 deg, switch
T+384 szenith
T+764 ssets

Run

Place satellite 1 at 30 degrees, then stamp the epoch of both satellites and the switch time together (epoch 20 s ahead, switch 252 s after it):

sudo pkill -9 -f nr-uesoftmodem; sudo pkill -9 -f 'build_ntn/apps/gnb/gnb'
sudo rm -f /tmp/gnb_leo_satswitch.log
cd ~/ntn/OCUDU-RAN
./scripts/set_leo_pass.sh --elev 30 --single --no-epoch
EPOCH=$(date -u -d '+20 seconds' '+%Y-%m-%dT%H:%M:%S')
SWITCH=$(date -u -d '+272 seconds' '+%Y-%m-%dT%H:%M:%S')
sed -i "s/epoch_timestamp: '[^']*'/epoch_timestamp: '$EPOCH'/" configs/ntn/leo_rfsim_gnb.yml configs/ntn/leo_satswitch.yml
sed -i "s/t_service: '[^']*'/t_service: '$SWITCH'/; s/t_service_start: '[^']*'/t_service_start: '$SWITCH'/" configs/ntn/leo_satswitch.yml

Terminal 1, gNB

cd ~/ntn/OCUDU-RAN
sudo ./build_ntn/apps/gnb/gnb -c configs/ntn/leo_rfsim_gnb.yml -c configs/ntn/leo_satswitch.yml -c configs/ntn/sdcore.yml

Terminal 2, UE (start within the 20 s lead), the same UE command as scenario 1 with 2>&1 | tee /tmp/ue_leo_satswitch.log appended.

Terminal 3, traffic and tracker

ping -I oaitun_ue1 -D -i 1 8.8.8.8
python3 ~/ntn/OCUDU-RAN/scripts/leo_tracker.py /tmp/gnb_leo_satswitch.log --ue /tmp/ue_leo_satswitch.log

Verify

gNB (/tmp/gnb_leo_satswitch.log):

[NTN     ] [I] Sat-switch promotion scheduled, cell=0x66c000 serving satellite 0 -> 1 at t_service=...
[NTN     ] [I] Sat-switch promotion applied, cell=0x66c000 serving satellite 0 -> 1

UE:

[NR_MAC] satSwitchWithReSync-r18 armed: switching satellite in ... ms (at UTC deadline), ssb-TimeOffset 0 s
[NR_MAC] satSwitchWithReSync-r18: t-ServiceStart reached, taking the target satellite into service in-cell

Measured on this testbed:

MetricResult
Promotionscheduled at start-up for t_service, applied 34 ms after it
UEarmed from SIB19 268 s ahead, switched once at the UTC deadline
Sync failures / RRC releases0 / 0
Ping to 8.8.8.8 across the switch299 sent, 298 received (the last one cut off at shutdown), no sequence gaps

Scenario 4: intra-cell satellite switch loop (satellite train)

A one-shot switch only moves the problem to the second satellite's horizon. The satellite train keeps the cell on the air indefinitely: the gNB generates num_satellites satellites evenly spaced around the configured satellite's orbit, and every time the serving satellite sets through switch_elevation_deg it hands the cell to the satellite rising behind it, then immediately arms the next switch. The list wraps, so this repeats for as long as the gNB runs. The UE keeps its RRC connection and PDU session throughout.

The satellite train loop, continuous LEO cell coverage through coordinated in-cell satellite switching, shown as five numbered steps. Step 1, propagate: the gNB propagates every satellite of the train from one epoch and one state vector, with 12 satellites 30 degrees apart. Step 2, find the set time: it computes when the serving satellite sets through the switch elevation, by bisection to 20 ms. Step 3, advertise: that moment goes out in SIB19 as t-ServiceStart, with the next satellite named, using satSwitchWithReSync-r18. Step 4, the UE arms: the UE re-reads SIB19 every 2.5 s and arms the switch against its own UTC clock, with ul_sync_validity of 5 s. Step 5, switch in-cell: at the deadline the next satellite takes the cell over and nothing about the cell changes, keeping the same NCI, PCI and SSB. An arrow returns from step 5 to step 1, labelled the next switch is armed immediately and the list wraps. A measured strip along the bottom, for 12 satellites starting at zenith, reports 337 s of notice before the first switch, 8.0 min between switches, 0 sync failures and 0 RRC releases, and 373 of 373 pings across the switches.
Figure 4: the satellite train loop. Each switch arms the next one, so the cell stays on the air for as long as the gNB runs.
# configs/ntn/leo_sattrain.yml
cell_cfg:
  ntn:
    ntn_ul_sync_validity_dur: 5
    sat_train:
      num_satellites: 12
      switch_elevation_deg: 4.5
      min_lead_s: 10
KeyRangeMeaning
num_satellites2-64Satellites spread evenly round the orbit, the configured one included
switch_elevation_deg0-60The serving satellite hands over as it sets through this elevation
min_lead_s1-600Minimum notice given in SIB19 before a switch

Coverage is continuous only while the spacing is below twice the central angle at the switch elevation: 39.7 degrees for this 600 km orbit at 4.5 degrees, so at least 10 satellites.

SatellitesSpacingTime between switchesNext satellite's elevation at the switch
1036 deg9.6 min~9.6 deg
1230 deg8.0 min~22 deg
1820 deg5.3 min~88 deg
3610 deg2.7 min~23 deg

ntn_ul_sync_validity_dur: 5 makes the UE re-read SIB19 about every 2.5 s, so it learns of each switch well before min_lead_s. The switch moment is computed from the orbit (the serving satellite's 4.5-degree setting time, found by bisection to 20 ms) and advertised as t-ServiceStart; the UE acts on it against its own UTC clock. If the gNB starts while the configured satellite is already below the switch elevation, it starts on the highest satellite of the train instead.

Run

sudo pkill -9 -f nr-uesoftmodem; sudo pkill -9 -f 'build_ntn/apps/gnb/gnb'
sudo rm -f /tmp/gnb_leo_sattrain.log
cd ~/ntn/OCUDU-RAN
./scripts/set_leo_pass.sh --elev zenith --single --lead 20

Starting at zenith puts the first switch about 5.5 minutes into the run; with --elev horizon it comes after the full ~12 min pass.

Terminal 1, gNB

cd ~/ntn/OCUDU-RAN
sudo ./build_ntn/apps/gnb/gnb -c configs/ntn/leo_rfsim_gnb.yml -c configs/ntn/leo_sattrain.yml -c configs/ntn/sdcore.yml

Terminal 2, UE (start within the 20 s lead), the same UE command as scenario 1 with 2>&1 | tee /tmp/ue_leo_sattrain.log appended.

Terminal 3, traffic and tracker

ping -I oaitun_ue1 -D -i 1 8.8.8.8
python3 ~/ntn/OCUDU-RAN/scripts/leo_tracker.py /tmp/gnb_leo_sattrain.log --ue /tmp/ue_leo_sattrain.log

Verify

gNB (/tmp/gnb_leo_sattrain.log). Every switch is followed at once by the next one being armed:

[NTN     ] [I] Sat-train armed, cell=0x66c000 satellite 0 -> 1 at 20:25:34.645635951 (in 337 s): 0 sets through 4.5 deg, 1 will be at 21.9 deg
[NTN     ] [I] Sat-switch promotion applied, cell=0x66c000 serving satellite 0 -> 1 time=20:25:34.662690833
[NTN     ] [I] Sat-train armed, cell=0x66c000 satellite 1 -> 2 at 20:33:35.873628333 (in 481 s): 1 sets through 4.5 deg, 2 will be at 21.9 deg

Quick checks:

sudo grep -aE 'Sat-train armed|promotion applied' /tmp/gnb_leo_sattrain.log
grep -ac 'synch Failed' /tmp/ue_leo_sattrain.log         # expect 0
grep -ac 'RRC moved into IDLE' /tmp/ue_leo_sattrain.log  # expect 0

Measured on this testbed with 12 satellites, starting at zenith:

MetricResult
First switcharmed 337 s ahead, applied on time; satellite 0 released at 4.52 deg
Next switcharmed immediately, 481 s later; satellite 2 at 21.9 deg at the switch
UEswitched once at the UTC deadline and re-armed from the next SIB19
Sync failures / RRC releases0 / 0
Ping to 8.8.8.8 across the switch373 sent, 373 received, no sequence gaps

Files

FilePurpose
configs/ntn/leo_rfsim_gnb.ymlBase LEO cell: ephemeris, epoch, K_offset 40
configs/ntn/sdcore.ymlSD-Core overlay for single-cell runs (AMF, bind addresses, slice)
configs/ntn/leo_ho_sat1.yml / leo_ho_sat2.ymlHandover overlays: gNB IDs, PCIs, Xn, neighbour relations, SD-Core addresses, satellite ephemerides
configs/ntn/leo_satswitch.ymlOne-shot satellite switch overlay
configs/ntn/leo_sattrain.ymlSatellite train overlay
scripts/set_leo_pass.shPlace satellites at a chosen elevation and stamp the epoch
scripts/run_leo_ho_rfsim.shScenario 2 in one command
scripts/leo_tracker.pyLive dashboard
OAI_RAN/targets/PROJECTS/GENERIC-NR-5GC/CONF/ue.ntn.leo.rfsim.confUE GNSS position (north pole)

References

DocumentRelevance
TS 38.300 section 16.14NTN overview, transparent payload, Doppler responsibilities
TS 38.331 section 5.7.19, SIB19NTN-Config, satSwitchWithReSync-r18, t-ServiceStart-r17
TS 38.213 section 4.2UE timing advance pre-compensation, K_offset
TS 38.423XnAP handover preparation
TS 38.413NGAP Path Switch
TR 38.821NR NTN solutions
TR 38.811NTN channel and deployment scenarios

Source repositories

RepositoryBranchContents
OCUDU-RANntngNB: rfsimulator NTN channel emulation, satellite train, configs, run scripts, tracker
OAI-RANrfsim_ocudunrUE: SIB19 Rel-18/19, satSwitchWithReSync-r18, UTC t-ServiceStart, NTN UE configs

See also the GEO NTN rfsimulator testbed.