Skip to content

Commit 6fe40ea

Browse files
author
Vinod Patmanathan
committed
feat: add NR NTN access detection
Detect Non-Terrestrial Network access by checking for the NRNTN-TAI-Information protocol extension (ID 287 per 3GPP TS 38.413) in UserLocationInformationNR received via NGAP, and track the result in a new NtnAccessInfo struct on AmfUe and RanUe. This is detection-only groundwork. No protocol behavior changes: nothing reads the flag yet. Future patches can extend NtnAccessInfo with satellite backhaul category, GEO satellite ID, and NTN TAI list as those fields become available in omec-project/openapi and omec-project/ngap. Signed-off-by: Vinod Patmanathan <vinod.patmanathan@forsway.com>
1 parent 963dd5f commit 6fe40ea

4 files changed

Lines changed: 151 additions & 2 deletions

File tree

context/amf_ue.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ type AmfUe struct {
101101
EventSubscriptionsInfo map[string]*AmfUeEventSubscription `json:"eventSubscriptionInfo,omitempty"`
102102
/* User Location*/
103103
RatType models.RatType `json:"ratType,omitempty"`
104+
NtnAccess *NtnAccessInfo `json:"ntnAccess,omitempty"`
104105
Location models.UserLocation `json:"location,omitempty"`
105106
Tai models.Tai `json:"tai,omitempty"`
106107
LocationChanged bool `json:"locationChanged,omitempty"`

context/ntn.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// SPDX-FileCopyrightText: 2026 Forsway Scandinavia AB
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package context
6+
7+
// NtnAccessInfo captures Non-Terrestrial Network access properties for a
8+
// UE, as derived from NGAP signaling. Only NTN-access presence is tracked
9+
// today; future 3GPP-defined fields (satelliteBackhaulCategory,
10+
// geoSatelliteId, NTN TAI list) can be added without reshaping AmfUe or
11+
// RanUe.
12+
type NtnAccessInfo struct {
13+
// Detected is set true the first time the NRNTN-TAI-Information
14+
// protocol extension is observed in a UserLocationInformationNR for
15+
// this UE. It is sticky: once set, it is not cleared while the UE
16+
// context lives.
17+
//
18+
// The extension is PRESENCE optional per 3GPP TS 38.413, and gNBs
19+
// include it at their discretion — typically on context-establishing
20+
// events (initial UE message, handover) but often not on routine
21+
// uplink/location reports. Treating an absent extension as evidence
22+
// of "not NTN" would cause IsNtn() to flap on routine NGAP traffic
23+
// for a UE that genuinely is on NTN; sticky-on avoids that.
24+
Detected bool `json:"detected"`
25+
}
26+
27+
// IsNtn reports whether NTN access has been observed for this UE during
28+
// the current UE context. The flag is sticky for the session — see
29+
// NtnAccessInfo.Detected for the rationale.
30+
func (ue *AmfUe) IsNtn() bool {
31+
return ue.NtnAccess != nil && ue.NtnAccess.Detected
32+
}
33+
34+
// updateNtnAccess records observed NTN access on this RanUe.
35+
//
36+
// detected==false is a no-op: the NRNTN-TAI-Information extension is
37+
// PRESENCE optional per 3GPP TS 38.413, so an absent extension does not
38+
// imply the UE has left NTN access. The flag is sticky for the lifetime
39+
// of the UE context.
40+
func (ranUe *RanUe) updateNtnAccess(detected bool) {
41+
if !detected {
42+
return
43+
}
44+
if ranUe.NtnAccess != nil && ranUe.NtnAccess.Detected {
45+
return
46+
}
47+
if ranUe.NtnAccess == nil {
48+
ranUe.NtnAccess = &NtnAccessInfo{}
49+
}
50+
ranUe.NtnAccess.Detected = true
51+
ranUe.Log.Debugf("NR NTN access detected (NRNTN-TAI-Information extension present)")
52+
}

context/ran_ue.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/mohae/deepcopy"
1717
"github.com/omec-project/amf/logger"
18+
"github.com/omec-project/ngap"
1819
"github.com/omec-project/ngap/ngapConvert"
1920
"github.com/omec-project/ngap/ngapType"
2021
"github.com/omec-project/openapi/models"
@@ -46,8 +47,9 @@ type RanUe struct {
4647
TargetUe *RanUe `json:"-"`
4748

4849
/* UserLocation*/
49-
Tai models.Tai
50-
Location models.UserLocation
50+
Tai models.Tai
51+
Location models.UserLocation
52+
NtnAccess *NtnAccessInfo `json:"-"`
5153
/* context about udm */
5254
SupportVoPSn3gpp bool `json:"-"`
5355
SupportVoPS bool `json:"-"`
@@ -244,12 +246,21 @@ func (ranUe *RanUe) UpdateLocation(userLocationInformation *ngapType.UserLocatio
244246
if locationInfoNR.TimeStamp != nil {
245247
ranUe.Location.NrLocation.AgeOfLocationInformation = ngapConvert.TimeStampToInt32(locationInfoNR.TimeStamp.Value)
246248
}
249+
250+
// NTN detection runs only after the NR location has parsed cleanly,
251+
// so a malformed message (early return above) does not desync NTN
252+
// state from the stored location/Tai.
253+
ranUe.updateNtnAccess(ngap.HasUserLocationInformationNRExtension(locationInfoNR, ngapType.ProtocolIEIDNRNTNTAIInformation))
254+
247255
if ranUe.AmfUe != nil {
248256
if ranUe.AmfUe.Tai != ranUe.Tai {
249257
ranUe.AmfUe.LocationChanged = true
250258
}
251259
ranUe.AmfUe.Location = deepcopy.Copy(ranUe.Location).(models.UserLocation)
252260
ranUe.AmfUe.Tai = deepcopy.Copy(*ranUe.AmfUe.Location.NrLocation.Tai).(models.Tai)
261+
if ranUe.NtnAccess != nil {
262+
ranUe.AmfUe.NtnAccess = deepcopy.Copy(ranUe.NtnAccess).(*NtnAccessInfo)
263+
}
253264
}
254265
case ngapType.UserLocationInformationPresentUserLocationInformationN3IWF:
255266
locationInfoN3IWF := userLocationInformation.UserLocationInformationN3IWF

context/ran_ue_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-FileCopyrightText: 2026 Forsway Scandinavia AB
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package context
6+
7+
import (
8+
"testing"
9+
10+
"go.uber.org/zap"
11+
)
12+
13+
func TestRanUeUpdateNtnAccess(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
initial *NtnAccessInfo
17+
detected bool
18+
wantPresent bool
19+
wantDetected bool
20+
}{
21+
{
22+
name: "nil + not-detected stays nil",
23+
initial: nil,
24+
detected: false,
25+
wantPresent: false,
26+
},
27+
{
28+
name: "nil + detected allocates true",
29+
initial: nil,
30+
detected: true,
31+
wantPresent: true,
32+
wantDetected: true,
33+
},
34+
{
35+
name: "detected + detected stays true",
36+
initial: &NtnAccessInfo{Detected: true},
37+
detected: true,
38+
wantPresent: true,
39+
wantDetected: true,
40+
},
41+
{
42+
name: "detected + not-detected stays true (sticky)",
43+
initial: &NtnAccessInfo{Detected: true},
44+
detected: false,
45+
wantPresent: true,
46+
wantDetected: true,
47+
},
48+
{
49+
name: "not-detected + detected sets true",
50+
initial: &NtnAccessInfo{Detected: false},
51+
detected: true,
52+
wantPresent: true,
53+
wantDetected: true,
54+
},
55+
{
56+
// Unreachable in production (we never allocate with Detected=false),
57+
// but documents the function's contract: sticky means a !detected
58+
// signal never modifies state.
59+
name: "not-detected + not-detected stays false",
60+
initial: &NtnAccessInfo{Detected: false},
61+
detected: false,
62+
wantPresent: true,
63+
wantDetected: false,
64+
},
65+
}
66+
for _, tc := range tests {
67+
t.Run(tc.name, func(t *testing.T) {
68+
ranUe := &RanUe{
69+
NtnAccess: tc.initial,
70+
Log: zap.NewNop().Sugar(),
71+
}
72+
ranUe.updateNtnAccess(tc.detected)
73+
74+
if got := ranUe.NtnAccess != nil; got != tc.wantPresent {
75+
t.Fatalf("NtnAccess presence: got %v, want %v", got, tc.wantPresent)
76+
}
77+
if !tc.wantPresent {
78+
return
79+
}
80+
if ranUe.NtnAccess.Detected != tc.wantDetected {
81+
t.Errorf("Detected = %v, want %v", ranUe.NtnAccess.Detected, tc.wantDetected)
82+
}
83+
})
84+
}
85+
}

0 commit comments

Comments
 (0)