Skip to content

Open NTN Testbed: network-verified UE location over a LEO satellite

This is a 5G Standalone testbed in which the UE reaches the gNB through a low-Earth-orbit satellite. The LMF works out the UE's position from round trips to that satellite, and a set of scripts compares each fix against the emulated ground truth. The network locates the UE from the satellite link alone, and checks the position the UE reports, without GNSS and without a second ground station.

Open NTN Testbed architecture for network-verified UE location over a LEO satellite, at 600 km in a polar orbit. The top band presents three positioning scenarios. Scenario one, single-satellite pass positioning, is the case Release 18 specifies, with one gNB, one cell and one satellite, running 339 rounds at a fix every nine seconds; its components are the satellite sampled at instants t1, t2 and t3 against a stationary UE at the north pole where the claim equals the truth, plus a mirror point that fits until the satellite passes; its mechanisms are one TRP at many instants, DL-PRS plus SRS, the leo_nrppa.yml overlay, and an ambiguous state that yields no verdict. Scenario two, cross-orbit satellite switching, is still one TRP at different instants but the arc bends through two orbits; its components are satellite 1 handing to satellite 2 at t-ServiceStart with one OCUDU gNB and one OAI nrUE holding the same timing advance continuously, and two crossing ground tracks where the mirror of track 1 is not on track 2; its mechanisms are satSwitchWithReSync-r18, satellite 2 as satellite 1 rotated 90 degrees, a switch at T plus 120 seconds, and a note that the same plane stays ambiguous. Scenario three, concurrent dual-satellite positioning, uses two TRPs on two satellites giving two ranges per instant, with up to 256 TRPs in LPP; its components are satellite 1 with its gNB serving as rfsim client, an OAI nrUE acting as rfsim server for both cells, and satellite 2 on gNB 412 as the neighbour with its own IP address ending .141, where the downlink equals the round trip times the share and the receive path equals the round trip times one minus the share; its mechanisms are satellite 2 rotated 90 degrees with a 0.1 second lead, nr-DL-PRS-SFN0-Offset, ntn_dl_share set to 0.5, and starting the gNB first with the UE as rfsim server. The lower band shows the foundational stack in four columns. OAI nrUE holds MAC, NAS, PHY and RRC, a positioning block with DL-PRS receive, UE Rx-Tx per TRP and LPP over NAS, and an NTN UE block with SIB19 decode, timing-advance pre-compensation, satellite switch Release 18 and a drift of minus 40. The rfsimulator column holds transport over TCP port 4043 carrying IQ samples, with the gNB as server and the UE as server for two satellites, and an emulated link block with round trip from ephemeris, horizon mute, and the ntn_dl_share downlink and receive split. The OCUDU gNB column holds CU-UP, CU-CP, MAC, scheduler, PDCP, DU-Low, RRC and SDAP, a positioning block with DL-PRS transmit, SRS receive, gNB Rx-Tx and NRPPa TRP information, and an NTN cell block with orbit propagation, SIB19, k_offset 40, CU-CP, DU and a 30-bit gNB identifier. The OAI CN5G column lists the network functions oai-amf at .132, oai-smf at .133, oai-upf at .134, oai-nrf at .130, oai-ausf at .138, oai-udm at .137, oai-udr at .136 and mysql at .131, plus an LMF block with LPP over N1, Release 18 codecs, OAM satellites, gnb_id_bits of 30, the NTN solver, and restart per run.
Figure 1: The positioning stack over the LEO link. One propagator drives both the SIB19 ephemeris and the emulated channel; the LMF receives the same ephemeris through OAM, so the measurement and the model cannot disagree.

Network verification of UE location

Release 18 added a way for the network to check a UE's reported location without GNSS, introduced for regulatory reasons: network verification of UE location, TS 38.305 clause 8.10. The procedure is NR Multi-RTT, adapted to a different geometry. Terrestrial Multi-RTT needs several TRPs at separate locations at the same moment, and an NTN cell has only one. The standard therefore allows one TRP at different points in time. The satellite travels at about 7 km/s, so a single satellite sampled across its pass sees the UE from many directions, and the intersection of those ranges gives the UE's position.

The LMF receives each satellite's ephemeris, and the TRP it belongs to, from OAM rather than from the RAN. TS 38.305 clause 5.4.4 states: "For NTN, the LMF is configured by the OAM with satellite related information ... as well as the association between TRP(s) and satellite(s), the association between gNB and TRP(s)."

NTN positioning call flow over LPP and NRPPa, across five columns: OAI nrUE on the OAI-RAN ntn-rfsim branch, OCUDU gNB as the TRP behind the satellite, the AMF in OAI CN5G relaying N1 and N2, the LMF providing Multi-RTT and the NTN solver, and OAM supplying ntn_satellites.json. The set-up phase runs once per run, with a note to restart the LMF before every run because it keeps its measurement history. Step 1, OAM sends the ephemeris and the TRP-to-satellite map to the LMF, per TS 38.305 clause 5.4.4 via lmf_ntn_oam.py generated from the gNB config. Step 2, the gNB broadcasts SIB19 to the UE carrying the same ephemeris, epoch and k_offset, from which the UE pre-compensates its timing advance. Step 3, the LMF sends an NRPPa TRP Information Request, non-UE-associated, via the AMF. Step 4, the gNB returns a TRP Information Response with TRP geographic coordinates, DL-PRS configuration and SFN initialisation time. The next phase configures SRS for this UE, with LMF_AUTO_SUPI selecting the UE to position. Step 5, an NRPPa Positioning Information Request, UE-associated, asks for the UE's uplink SRS. Step 6, the gNB sends an RRC SRS configuration giving SRS resources to the UE. Step 7, a Positioning Information Response returns the uplink SRS configuration. The next phase repeats every round through the pass, 339 rounds in one pass, one TRP at a new instant each round. Step 8, an NRPPa Measurement Request asks for gNB Rx-Tx and UL-RTOA. Step 9, LPP ProvideAssistanceData carries NR-DL-PRS-AssistanceData-r16, nr-DL-PRS-SFN0-Offset and ExpectedRSTD to the UE. Step 10, LPP RequestLocationInformation requests NR Multi-RTT. Step 11, the gNB transmits DL-PRS over the satellite, its sequence carrying the slot per TS 38.211 clause 7.4.1.7. Step 12, the UE transmits SRS pre-compensated for the serving satellite. Step 13, LPP ProvideLocationInformation returns NR-Multi-RTT-MeasList-r16 plus NR-NTN-UE-RxTxMeasurements-r18, giving UE Rx-Tx per TRP. Step 14, an NRPPa Measurement Response returns gNB Rx-Tx and UL-RTOA per TRP. Step 15, the LMF pairs the two halves into a round trip and then a range, on one uplink subframe, per TS 38.305 clause 8.10 step 13. The final phase produces a fix about every nine seconds, with the solver reading every round so far. Step 16, the NTN solver computes a fix and a one-sigma uncertainty, taking the satellite position at time t from the OAM ephemeris by least squares over the pass. Step 17, the LMF issues a verdict on the UE's claim of consistent, inconsistent, or ambiguous meaning no verdict. A legend records that LPP runs between UE and LMF over NAS, NRPPa between gNB and LMF over NGAP, RRC over the air through the satellite, OAM is configuration, and dotted markers are relayed by the AMF. Order follows the TS 38.305 clause 8.10 Multi-RTT procedure, with NRPPa names per TS 38.455 and LPP per TS 37.355.
Figure 2: The measurement exchange. LPP carries the UE half over NAS and NRPPa the gNB half over NGAP; the LMF pairs them into one round trip per instant, then solves over the accumulated pass.

Components

RepositoryBranchContents
OCUDU-RANntngNB: DL-PRS, gNB Rx-Tx, NRPPa TRP information, NTN channel emulation, configs, run and scoring scripts
OAI-RANrfsim_ocudunrUE: DL-PRS reception, UE Rx-Tx per TRP, LPP over NAS
oai-lmfdevelopLMF: LPP over N1, Release 18 codecs, the NTN solver
oai-amflpp-ntnAMF: LPP routing between the UE and the LMF over N1 and N2
oai-cn5gnot pinnedthe rest of the 5G core: SMF, UPF, NRF, UDM/UDR/AUSF, MySQL

Positioning needs two of the core network functions replaced. oai-lmf is a fork of EURECOM's oai-cn5g-lmf, and adds the Release 18 LPP and NRPPa codecs together with the NTN solver.

oai-amf is a fork of EURECOM's oai-cn5g-amf that carries LPP between the UE and the LMF. It tracks each LCS correlation identifier against its SUPI and LMF instance, so an uplink LPP message reaches the LMF that asked for it. It also checks the UE's 5GMM LPP capability and carries the LPP payload container on UL NAS transport. The stock AMF drops that container, so positioning does not work against an unmodified oaisoftwarealliance/oai-amf image, whatever the LMF is doing. The src/common-src submodule is patched on the same branch.

Both images replace their stock counterparts directly. The rest of the core is unchanged.

Prerequisites

  • Ubuntu 22.04 or 24.04, x86-64, 8+ cores recommended
  • sudo rights, Docker and Docker Compose
  • About 40 GB of disk: the regenerated Release 18 ASN.1 codecs are large

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-rfsim
git clone https://github.com/TOSSI-Foundation/oai-lmf.git
git clone -b lpp-ntn --recurse-submodules https://github.com/TOSSI-Foundation/oai-amf.git

2. Build

gNB

cd ~/ntn/OCUDU-RAN
# dependencies: see the OCUDU user manual, https://docs.ocudu.org/user_manual/installation/
cmake -S . -B build_ntn -DCMAKE_BUILD_TYPE=Release -DENABLE_RFSIM=ON
make -C build_ntn -j"$(nproc)" gnb

The binary is produced at ~/ntn/OCUDU-RAN/build_ntn/apps/gnb/gnb. Build in build_ntn rather than build. The NTN tree links the rfsimulator statically, and a default build produces it as a plugin that the NTN configs cannot load.

UE

cd ~/ntn/OAI-RAN-rfsim/cmake_targets
./build_oai -I
./build_oai --nrUE -w SIMU

After any later change to the UE, build both targets:

cmake --build ran_build/build --target nr-uesoftmodem -j"$(nproc)"
cmake --build ran_build/build --target rfsimulator  -j"$(nproc)"

rfsimulator is a separate plugin, and nr-uesoftmodem does not build it. Building only the modem leaves the previous radio binary in place, with nothing to indicate it.

LMF

cd ~/ntn/oai-lmf
docker build -f docker/Dockerfile.lmf.ubuntu -t oai-lmf:lpp-dev .

This takes about six minutes, as the Dockerfile builds with --clean inside. The solver also has a self-test that runs on the host without a container, in about two seconds:

cd ~/ntn/oai-lmf/src/lmf_app
g++ -std=c++17 -O2 -DLMF_NTN_NO_OAM lmf_ntn.cpp lmf_ntn_selftest.cpp -o /tmp/lmf_ntn_selftest && /tmp/lmf_ntn_selftest

It checks the propagator against the delay the gNB emulates for the same ephemeris, and the solver against a synthetic pass with and without noise.

AMF

cd ~/ntn/oai-amf
git submodule update --init --recursive
docker build -f docker/Dockerfile.amf.ubuntu -t oai-amf:lpp-dev .

The src/common-src submodule carries the NAS half of the LPP transport, so the clone needs --recurse-submodules, or the git submodule update above. Without it, LPP messages are dropped before the AMF sees them.

Both images should now be listed:

docker images | grep -E "oai-(amf|lmf):lpp-dev"

3. Core network

The core is stock OAI CN5G, with the AMF and the LMF replaced by the images built above. Nothing else changes.

Clone the core

cd ~
git clone https://gitlab.eurecom.fr/oai/cn5g/oai-cn5g-fed.git oai-cn5g
cd ~/oai-cn5g
mkdir -p conf/ntn logs/lmf

conf/ntn holds the OAM satellite file the LMF reads; logs/lmf collects one JSON line per fix.

Point the compose file at the TOSSI images

Two edits to ~/oai-cn5g/docker-compose.yaml. The AMF only needs its image swapped:

    oai-amf:
        container_name: "oai-amf"
        image: oai-amf:lpp-dev          # was oaisoftwarealliance/oai-amf:develop

The LMF needs the image, two extra mounts and the NTN environment:

    oai-lmf:
        container_name: "oai-lmf"
        image: oai-lmf:lpp-dev          # was oaisoftwarealliance/oai-lmf:develop
        expose:
            - 80/tcp
            - 8080/tcp
        volumes:
            - ./conf/config.yaml:/openair-lmf/etc/config.yaml
            - ./conf/ntn:/openair-lmf/etc/ntn        # a DIRECTORY, not the file
            - ./logs/lmf:/openair-lmf/positions
        environment:
            - TZ=Europe/Paris
            - LMF_AUTO_SUPI=imsi-001010000000002     # position this UE with no AMF-triggered request
            - LMF_AUTO_INTERVAL_S=5                  # seconds between location sessions
            - LMF_MEAS_ROUNDS=5                      # measurement rounds per fix
            - LMF_NTN_SATELLITES=/openair-lmf/etc/ntn/ntn_satellites.json
        depends_on:
            - oai-nrf
            - oai-amf
        networks:
            public_net:
                ipv4_address: 192.168.70.140

Configure the LMF

# ~/oai-cn5g/conf/config.yaml
lmf:
  gnb_id_bits_count: 30   # must equal gnb_id_bit_length in configs/ntn/leo_nrppa.yml
  num_gnb: 1              # 2 for the two-satellite scenario
  trp_info_wait_ms: 10000
  positioning_wait_ms: 10000
  measurement_wait_ms: 10000

gnb_id_bits_count has to match the gNB. The OAI AMF's NGAP decoder handles only a 4-byte gNB ID and mis-keys shorter ones, so if the gNB advertises 22 bits, NRPPa addressed to the LMF does not arrive.

Bring it up

cd ~/oai-cn5g
docker compose up -d
until [ "$(docker compose ps --format '{{.Status}}' | grep -c healthy)" -ge 11 ]; do sleep 5; done
docker compose ps --format '{{.Name}} {{.Status}}'

After rebuilding either image, recreate that container so it picks up the new one:

docker compose up -d --force-recreate oai-lmf      # or oai-amf

Check the swap took

docker compose images oai-amf oai-lmf
docker exec oai-lmf sh -c 'echo $LMF_NTN_SATELLITES; ls /openair-lmf/etc/ntn'
docker logs oai-amf 2>&1 | grep -i lpp | head

If docker logs oai-lmf shows TRP information and LPP capability exchanges but no NTN Multi-RTT round lines, the LMF is running but the AMF is not carrying LPP. Check that oai-amf is running the oai-amf:lpp-dev image.

4. Pass placement and ephemeris distribution

Both the emulated channel and SIB19 come from one ephemeris in the gNB config, so they cannot drift apart. The epoch is stamped immediately before launch:

sudo sed -i "s/epoch_timestamp: '[^']*'/epoch_timestamp: '$(date -u -d '+12 seconds' '+%Y-%m-%dT%H:%M:%S')'/" \
  ~/ntn/OCUDU-RAN/configs/ntn/leo_rfsim_gnb.yml

The LMF needs the same ephemeris through its OAM input. One script writes it from the gNB configuration, so the two cannot disagree:

~/ntn/OCUDU-RAN/scripts/lmf_ntn_oam.py \
  ~/ntn/OCUDU-RAN/configs/ntn/leo_rfsim_gnb.yml \
  ~/oai-cn5g/conf/ntn/ntn_satellites.json

Scenario 1: single satellite over one pass

This is the baseline configuration, and the case Release 18 specifies. It uses one gNB, one cell and one satellite. The LMF collects round trips as the satellite crosses, and solves for the UE's position.

Terminal 1, gNB. The gNB is the rfsimulator server here, so start it first:

sudo ~/ntn/OCUDU-RAN/build_ntn/apps/gnb/gnb \
  -c ~/ntn/OCUDU-RAN/configs/ntn/leo_rfsim_gnb.yml \
  -c ~/ntn/OCUDU-RAN/configs/ntn/leo_nrppa.yml

Terminal 2, UE:

sudo ~/ntn/OAI-RAN-rfsim/cmake_targets/ran_build/build/nr-uesoftmodem \
  -O ~/ntn/OAI-RAN-rfsim/targets/PROJECTS/GENERIC-NR-5GC/CONF/ue.ntn.leo.rfsim.conf \
  --band 256 -C 2185000000 --CO -190000000 -r 52 --numerology 0 --ssb 62 \
  --rfsim --time-sync-I 0.1 --ntn-initial-time-drift -40

Alternatively, in a single command: scripts/run_leo_rfsim.sh with EXTRA_CFG=configs/ntn/leo_nrppa.yml.

Measured results

364 rounds over one pass, producing one fix approximately every 9 s. The ground truth is the North Pole, which is also the position the UE reports.

DataFix error1-sigma (LMF)Release 18 verdict (10 km)
45 to 180 s1 to 56 km0.7 to 8.7 kmAMBIGUOUS, no verdict
431 s27.1 m17 mconsistent
646 s31.8 m10 mconsistent

Round trips against the delay the gNB was emulating: +2.7 m, sd 13.2 m over 369 rounds.

The ambiguity comes from the geometry, and is not a fault in the measurement. Before the satellite passes the UE, the range profile does not yet show which side of the ground track the UE is on, and a mirrored position fits almost as well. The LMF reports AMBIGUOUS and gives no verdict, rather than picking one of the two.

Scenario 2: cross-plane satellite switch

A satellite switch (TS 38.331 satSwitchWithReSync-r18) hands the same cell from one satellite to the next. This is still one TRP at different points in time, but the arc now spans two orbital planes, which clears the mirror ambiguity much sooner.

cd ~/ntn/OCUDU-RAN
CFG=configs/ntn/leo_satswitch_xplane.yml EXTRA_CFG=configs/ntn/leo_nrppa.yml \
  SWITCH_AT=120 DUR=620 scripts/run_leo_satswitch_rfsim.sh

The same run by hand. Both configs carry the epoch and the overlay carries the switch instant, so all three timestamps are stamped together. If they are out of step, the two orbits run on different clocks:

EPOCH=$(date -u -d '+12 seconds' '+%Y-%m-%dT%H:%M:%S')       # T+0
SWITCH=$(date -u -d '+132 seconds' '+%Y-%m-%dT%H:%M:%S')     # T+120, the switch

sudo sed -i "s/epoch_timestamp: '[^']*'/epoch_timestamp: '$EPOCH'/" \
  ~/ntn/OCUDU-RAN/configs/ntn/leo_rfsim_gnb.yml
sudo sed -i "s/epoch_timestamp: '[^']*'/epoch_timestamp: '$EPOCH'/;
             s/t_service: '[^']*'/t_service: '$SWITCH'/;
             s/t_service_start: '[^']*'/t_service_start: '$SWITCH'/" \
  ~/ntn/OCUDU-RAN/configs/ntn/leo_satswitch_xplane.yml

Then the OAM file, which now needs --switch-cfg so that it writes two satellites with servesFrom and servesUntil windows at the switch instant. The TRP-to-satellite association becomes a function of time:

~/ntn/OCUDU-RAN/scripts/lmf_ntn_oam.py \
  ~/ntn/OCUDU-RAN/configs/ntn/leo_rfsim_gnb.yml \
  ~/oai-cn5g/conf/ntn/ntn_satellites.json \
  --switch-cfg ~/ntn/OCUDU-RAN/configs/ntn/leo_satswitch_xplane.yml

Terminal 1, gNB. Three configs this time: the link, the switch overlay and the positioning overlay.

sudo ~/ntn/OCUDU-RAN/build_ntn/apps/gnb/gnb \
  -c ~/ntn/OCUDU-RAN/configs/ntn/leo_rfsim_gnb.yml \
  -c ~/ntn/OCUDU-RAN/configs/ntn/leo_satswitch_xplane.yml \
  -c ~/ntn/OCUDU-RAN/configs/ntn/leo_nrppa.yml

Terminal 2, UE. Unchanged from scenario 1:

sudo ~/ntn/OAI-RAN-rfsim/cmake_targets/ran_build/build/nr-uesoftmodem \
  -O ~/ntn/OAI-RAN-rfsim/targets/PROJECTS/GENERIC-NR-5GC/CONF/ue.ntn.leo.rfsim.conf \
  --band 256 -C 2185000000 --CO -190000000 -r 52 --numerology 0 --ssb 62 \
  --rfsim --time-sync-I 0.1 --ntn-initial-time-drift -40

Terminal 3, tracker:

~/ntn/OCUDU-RAN/scripts/leo_tracker.py /tmp/gnb_leo_nrppa.log --ue /tmp/ue_leo_nrppa.log --port 8500

Check that the gNB armed the switch before the pass gets going. Without this line the switch is only advertised in SIB19, and the cell keeps flying satellite 1 past its horizon:

sudo grep -a "Sat-switch promotion scheduled" /tmp/gnb_leo_nrppa.log
sudo grep -a "Sat-switch promotion applied"   /tmp/gnb_leo_nrppa.log   # at t_service

Satellite 2 is satellite 1's state rotated 90 degrees about the Earth's axis. The reference location is the North Pole, which sits on that rotation axis, so the range and the UE's timing advance are the same at every moment and the link stays up across the switch. The two ground tracks still cross, so the mirror position across track 1 is not a solution for track 2. The same-plane leo_satswitch.yml does not work here, because both ground tracks lie on one line and the ambiguity remains.

This geometry has one side effect worth knowing about. Because the two elevations are equal to within 0.0000 degrees, the switch does not show up on the tracker's elevation chart: satellite 2 appears exactly where satellite 1 was, with no arc of its own. That is the geometry behaving as intended, not a broken plot.

Measured results

One satelliteWith the switch at T+120 s
Ambiguity183 to 315 s before it clearednever ambiguous
Error at 198 s~50 km (wrong side)12.1 m
Error at 619 s29 to 83 m14.3 m
1-sigma at the end10 × 1 m2 × 1 m
Resolving UE position ambiguity from LEO satellite ranges, in three panels plus a measured timeline. Panel one, single-satellite range geometry: satellite 1 approaches along its ground track through points t1 to t4, and the range circles drawn around each point all intersect in a narrow vertical band, so the true fix and a mirror point on the opposite side are both consistent; the cross-track direction is almost invisible, and truth equals the UE's claim. Panel two, continuous range convergence: as satellite 1 moves away the later circles cross the earlier ones at a growing angle, and the fix walks in along the band; at the UE's zenith the range equals the altitude at a 4 millisecond round trip, reaching 32 metres at 601 seconds over 339 rounds with a fix every nine seconds. Panel three, cross-plane range disambiguation: satellite 1 rounds before T plus 120 seconds are shown with satSwitchWithReSync-r18 on the same cell with the same timing advance, then track 2 from satellite 2, being track 1 rotated 90 degrees, contributes a second family of circles after the switch, and the UE is pinned where the two families meet. The lower timeline, measured ambiguity resolution on the testbed, plots seconds from the epoch from 0 to beyond 600. The one-satellite row is ambiguous between 1 and 56 kilometres, clears somewhere between 183 and 315 seconds at roughly 50 kilometres on the wrong side, then converges along the band to 32 metres at 601 seconds marked consistent. The cross-plane switch row is one satellite until the switch at T plus 120 seconds from satellite 1 to satellite 2, after which it is unambiguous with two families of circles, clearing at 135 seconds with an error of 13.6 metres and reaching 16.5 metres at 620 seconds with a one-sigma of 2 by 1 metre. A footnote records that one satellite at 620 seconds gives 29 to 83 metres across runs, while the switch gives 16.5 metres.
Figure 3: Why one satellite is ambiguous, and why a cross-plane switch resolves it. The mirror point fits until the satellite passes; a second orbit crossing the first removes it in about 15 seconds.

Both satellites contribute: 199 rounds from the first and 280 from the second. This is the best result on the testbed, and the configuration to use when a fast, unambiguous fix is required.

Scenario 3: concurrent dual-satellite operation

Two TRPs on two satellites over the same UE, so the LMF has two ranges per instant instead of one per instant along a pass. TS 38.305 has no NTN-specific procedure for this, since Release 18's NTN addition is the single-TRP case, but it needs none: clause 5.4.2 puts a TRP on board a satellite, 5.4.4 has OAM give the LMF the TRP-to-satellite association, and LPP carries up to nrMaxTRPs-r16 = 256 TRPs with the Release 18 NTN fields per TRP. Two satellites are two ordinary TRPs.

This scenario needs one extra step after each boot. The second gNB needs its own address, because GTP-U's port 2152 is fixed and two gNBs cannot both bind it on the same address:

sudo ip addr add 192.168.70.141/26 dev oai-cn5g     # NOT .130-.140: those are the core's containers
sudo sed -i 's/^\( *num_gnb:\) *[0-9]*/\1 2/' ~/oai-cn5g/conf/config.yaml
cd ~/oai-cn5g && docker compose restart oai-lmf

Write both satellites into the OAM file, each carrying its own gNB's TRP:

~/ntn/OCUDU-RAN/scripts/lmf_ntn_oam.py \
  ~/ntn/OCUDU-RAN/configs/ntn/leo_rfsim_gnb.yml \
  ~/oai-cn5g/conf/ntn/ntn_satellites.json \
  --add ~/ntn/OCUDU-RAN/configs/ntn/leo_dualsat_sat2.yml:412

Terminal 1, satellite 1's gNB. Start it before the UE:

sudo ~/ntn/OCUDU-RAN/build_ntn/apps/gnb/gnb \
  -c ~/ntn/OCUDU-RAN/configs/ntn/leo_rfsim_gnb.yml \
  -c ~/ntn/OCUDU-RAN/configs/ntn/leo_dualsat_sat1.yml

Terminal 2, the UE, running as the rfsimulator server, which is the only way it sees both cells at once:

sudo ~/ntn/OAI-RAN-rfsim/cmake_targets/ran_build/build/nr-uesoftmodem \
  -O ~/ntn/OAI-RAN-rfsim/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

Terminal 3, satellite 2's gNB, once the UE has an IP, about 190 s:

sudo ~/ntn/OCUDU-RAN/build_ntn/apps/gnb/gnb \
  -c ~/ntn/OCUDU-RAN/configs/ntn/leo_rfsim_gnb.yml \
  -c ~/ntn/OCUDU-RAN/configs/ntn/leo_dualsat_sat2.yml

Alternatively, the complete scenario: scripts/run_leo_dualsat_rfsim.sh.

Start the gNB before the UE, even though the UE is the rfsimulator server. In the other order, the UE runs on empty samples for a second or two before any cell exists, and then has to find an SSB in a stream it has already been pacing itself against, which makes the attach unreliable. With the gNB started first, the attach succeeded on four runs out of four.

Additional requirements for two satellites

The emulated delay has to be split between the two directions. The channel used to apply the whole round trip to the gNB's receive path. With a single satellite that looks the same as the correct behaviour, but it makes both downlinks arrive at the same time, so the difference between them is always zero whatever the geometry. Setting ntn_dl_share=0.5 in ru_sdr.device_args splits it:

   gNB downlink delay = round trip × share        advertised to the peer in every block header
   gNB receive delay  = round trip × (1 − share)

The round trip is unchanged, so every measurement of scenarios 1 and 2 is unchanged: the uplink still lands on the gNB's own clock and the UE's Rx-Tx is still its whole advance. Only when each gNB's downlink reaches the UE changes, which is the quantity two satellites need. The default is 0.

Each gNB counts frames from its own boot. Two gNBs started seconds apart number the same instant differently. That offset is signalled to the UE as nr-DL-PRS-SFN0-Offset (37.355 6.4.3), and it has to be applied in three places: where the UE looks for the neighbour's PRS, which sequence it generates (c_init carries the slot number, TS 38.211 7.4.1.7.2, an incorrect value yields zero correlation rather than weak correlation), and where the LMF compares two gNBs' measurement timestamps.

The ground tracks have to cross. leo_dualsat_sat2.yml is satellite 1's orbit rotated 90 degrees about the Earth's axis, with a 0.1 s lead so that the two ranges are not identical. The cyclic prefix limits how large that lead can be, at 4.69 µs for 15 kHz. A 0.25 s lead works out at ±5.77 µs, which puts the neighbour's PRS outside the prefix, and the UE then received it 39 dB down.

Verifying a run

cd ~/ntn/OCUDU-RAN

# the fix against the truth, and the Release 18 verdict on the UE's claim
scripts/ntn_fix_check.py --ue-conf ~/ntn/OAI-RAN-rfsim/targets/PROJECTS/GENERIC-NR-5GC/CONF/ue.ntn.leo.rfsim.conf

# every round against the round trip the gNB actually emulated
sudo scripts/ntn_multirtt_check.py /tmp/gnb_dualsat_sat1.log /tmp/gnb_dualsat_sat2.log

ntn_multirtt_check.py shows whether a disagreement comes from the measurement or from the model. For each satellite it reports the LMF's range against the emulated ground truth, and the orbit model against that same ground truth:

417 Multi-RTT rounds matched to the gNB's emulated round trip
  RTT - truth: mean +0.058 us  sd 1.061 us   => range error mean +9 m, sd 159 m
  aligned to the SRS's UL subframe: mean +0.025 us  sd 0.413 us   => range error mean +4 m, sd 62 m
  LMF solver input, satellite 0: range - truth +0.2 m sd 40.0 m; orbit model - emulated truth +13.4 m sd 0.1 m

The two halves refer to different uplink subframes. The gNB's half refers to the SRS, and the UE's half to the subframe it transmits after the PRS, so the script moves both onto one subframe using the rate at which the round trip is changing. Without that correction the pair is 9 to 24 ms apart, which at 46 µs/s of drift gave a 0.8 µs bias, or 240 m of range.

Live tracker

~/ntn/OCUDU-RAN/scripts/leo_tracker.py /tmp/gnb_dualsat_sat1.log /tmp/gnb_dualsat_sat2.log \
  --ue /tmp/ue_dualsat.log --port 8500

This serves a live page on http://127.0.0.1:8500. It shows a to-scale side view of every satellite, elevation against time, the measurement tiles (gNB Rx-Tx, UL-RTOA and DL-PRS ToA per TRP), and the fix itself. Each round's range is drawn as a circle of possible positions around the satellite's ground point, in an azimuthal equidistant projection, so the circles can be seen converging on the UE. Supply every gNB log; with a single log the tracker shows only one satellite.

lmf.num_gnb must match the number of gNBs in the scenario

Scenarios 1 and 2 run one gNB, and scenario 3 runs two. If num_gnb is left at 2 after a two-satellite run, every single-gNB scenario fails in a way that looks like a positioning bug but is not:

[error] trp information request: timeout after 10000ms waiting for 2 gnb responses, but received: 1

The LMF gives up on the location session before it ever asks for a measurement, so the tracker shows no Multi-RTT rounds, no NRPPa measurement responses and "no SRS measured". DL-PRS reception and LPP capability transfers carry on as normal, because they do not depend on this. The giveaway is that the gNB is receiving SRS, with the tracker's "SRS received" tile at 100%, while the LMF has no measurement responses.

grep -m1 num_gnb ~/oai-cn5g/conf/config.yaml
sudo sed -i 's/^\( *num_gnb:\) *[0-9]*/\1 1/' ~/oai-cn5g/conf/config.yaml
cd ~/oai-cn5g && docker compose restart oai-lmf

run_leo_dualsat_rfsim.sh sets the value to 2 and restores the previous one when it exits. Running the two-satellite scenario by hand leaves it at 2.

The UE must request the slice the gNB advertises

The configs/ntn/*_rfsim_gnb.yml files serve sst 1 / sd 1. If the UE asks for nssai_sst = 1 with no differentiator, it registers successfully and both LPP and DL-PRS work, but no PDU session is ever set up. This looks like an attach failure even though the radio link is healthy. ue.ntn.leo.rfsim.conf already carries the differentiator. For GEO, use ue.ntn.geo.rfsim.slice.conf, which is ue.ntn.geo.rfsim.conf with nssai_sd = 1 added.

Files

PathPurpose
configs/ntn/leo_rfsim_gnb.ymlthe LEO link: ephemeris, k_offset, channel emulation
configs/ntn/leo_nrppa.ymlpositioning overlay: SRS, DL-PRS, TRP geo coordinates, 30-bit gNB ID
configs/ntn/leo_satswitch_xplane.ymlscenario 2: the cross-plane switch satellite
configs/ntn/leo_dualsat_sat1.yml, leo_dualsat_sat2.ymlscenario 3: two cells, two satellites, one frequency layer
scripts/lmf_ntn_oam.pywrites the LMF's OAM satellite file from the gNB config
scripts/run_leo_rfsim.sh, run_leo_satswitch_rfsim.sh, run_leo_dualsat_rfsim.shthe three scenarios
scripts/ntn_fix_check.pyscores the LMF's fixes against the truth
scripts/ntn_multirtt_check.pyscores every round against the emulated round trip
scripts/leo_tracker.pythe live page
HANDOFF_POSITIONING.mdthe full record, including the negative results

References

  • TS 38.305, NG-RAN positioning: 4.3.11, 5.4.2, 5.4.4, 8.10 (Multi-RTT), 8.12 (DL-TDOA)
  • TS 37.355, LPP: NR-Multi-RTT-MeasList-r16, NR-NTN-UE-RxTxMeasurements-r18, NR-DL-PRS-AssistanceData-r16, 6.4.3
  • TS 38.455, NRPPa: TRP Information, SFN Initialisation Time, Search-window-information
  • TS 38.211, 7.4.1.7, DL-PRS generation and mapping
  • TS 38.215, 5.1.30 UE Rx-Tx, 5.1.46 subframe offset, 5.1.47 DL timing drift
  • TS 38.331, satSwitchWithReSync-r18

Source repositories