Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions context/amf_ue.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ type AmfUe struct {
EventSubscriptionsInfo map[string]*AmfUeEventSubscription `json:"eventSubscriptionInfo,omitempty"`
/* User Location*/
RatType models.RatType `json:"ratType,omitempty"`
NtnAccess *NtnAccessInfo `json:"ntnAccess,omitempty"`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the latest SBI changes (openapi models), I think these changes are not applicable. Instead of creating NtnAccessInfo (Detected: true or false), should not you rely on the models.RatType such as the ones below to make the "Detected" decision? What do you think? Also, do you need NGAP rel-18 as part of these changes?

	...
	RATTYPE_NR_LEO              RatType = "NR_LEO"
	RATTYPE_NR_MEO              RatType = "NR_MEO"
	RATTYPE_NR_GEO              RatType = "NR_GEO"
	RATTYPE_NR_OTHER_SAT        RatType = "NR_OTHER_SAT"
	...

Location models.UserLocation `json:"location,omitempty"`
Tai models.Tai `json:"tai,omitempty"`
LocationChanged bool `json:"locationChanged,omitempty"`
Expand Down
52 changes: 52 additions & 0 deletions context/ntn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2026 Forsway Scandinavia AB
//
// SPDX-License-Identifier: Apache-2.0

package context

// NtnAccessInfo captures Non-Terrestrial Network access properties for a
// UE, as derived from NGAP signaling. Only NTN-access presence is tracked
// today; future 3GPP-defined fields (satelliteBackhaulCategory,
// geoSatelliteId, NTN TAI list) can be added without reshaping AmfUe or
// RanUe.
type NtnAccessInfo struct {
// Detected is set true the first time the NRNTN-TAI-Information
// protocol extension is observed in a UserLocationInformationNR for
// this UE. It is sticky: once set, it is not cleared while the UE
// context lives.
//
// The extension is PRESENCE optional per 3GPP TS 38.413, and gNBs
// include it at their discretion — typically on context-establishing
// events (initial UE message, handover) but often not on routine
// uplink/location reports. Treating an absent extension as evidence
// of "not NTN" would cause IsNtn() to flap on routine NGAP traffic
// for a UE that genuinely is on NTN; sticky-on avoids that.
Detected bool `json:"detected"`
}

// IsNtn reports whether NTN access has been observed for this UE during
// the current UE context. The flag is sticky for the session — see
// NtnAccessInfo.Detected for the rationale.
func (ue *AmfUe) IsNtn() bool {
return ue.NtnAccess != nil && ue.NtnAccess.Detected
}

// updateNtnAccess records observed NTN access on this RanUe.
//
// detected==false is a no-op: the NRNTN-TAI-Information extension is
// PRESENCE optional per 3GPP TS 38.413, so an absent extension does not
// imply the UE has left NTN access. The flag is sticky for the lifetime
// of the UE context.
func (ranUe *RanUe) updateNtnAccess(detected bool) {
if !detected {
return
}
if ranUe.NtnAccess != nil && ranUe.NtnAccess.Detected {
return
}
if ranUe.NtnAccess == nil {
ranUe.NtnAccess = &NtnAccessInfo{}
}
ranUe.NtnAccess.Detected = true
ranUe.Log.Debugf("NR NTN access detected (NRNTN-TAI-Information extension present)")
}
15 changes: 13 additions & 2 deletions context/ran_ue.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/mohae/deepcopy"
"github.com/omec-project/amf/logger"
"github.com/omec-project/ngap/v2"
"github.com/omec-project/ngap/v2/ngapConvert"
"github.com/omec-project/ngap/v2/ngapType"
"github.com/omec-project/openapi/v2"
Expand Down Expand Up @@ -47,8 +48,9 @@ type RanUe struct {
TargetUe *RanUe `json:"-"`

/* UserLocation*/
Tai models.Tai
Location models.UserLocation
Tai models.Tai
Location models.UserLocation
NtnAccess *NtnAccessInfo `json:"-"`
/* context about udm */
SupportVoPSn3gpp bool `json:"-"`
SupportVoPS bool `json:"-"`
Expand Down Expand Up @@ -237,12 +239,21 @@ func (ranUe *RanUe) UpdateLocation(userLocationInformation *ngapType.UserLocatio
if locationInfoNR.TimeStamp != nil {
ranUe.Location.NrLocation.AgeOfLocationInformation = openapi.PtrInt32(ngapConvert.TimeStampToInt32(locationInfoNR.TimeStamp.Value))
}

// NTN detection runs only after the NR location has parsed cleanly,
// so a malformed message (early return above) does not desync NTN
// state from the stored location/Tai.
ranUe.updateNtnAccess(ngap.HasUserLocationInformationNRExtension(locationInfoNR, ngapType.ProtocolIEIDNRNTNTAIInformation))

if ranUe.AmfUe != nil {
if ranUe.AmfUe.Tai != ranUe.Tai {
ranUe.AmfUe.LocationChanged = true
}
ranUe.AmfUe.Location = deepcopy.Copy(ranUe.Location).(models.UserLocation)
ranUe.AmfUe.Tai = deepcopy.Copy(ranUe.AmfUe.Location.NrLocation.Tai).(models.Tai)
if ranUe.NtnAccess != nil {
ranUe.AmfUe.NtnAccess = deepcopy.Copy(ranUe.NtnAccess).(*NtnAccessInfo)
}
}
case ngapType.UserLocationInformationPresentUserLocationInformationN3IWF:
locationInfoN3IWF := userLocationInformation.UserLocationInformationN3IWF
Expand Down
85 changes: 85 additions & 0 deletions context/ran_ue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Forsway Scandinavia AB
//
// SPDX-License-Identifier: Apache-2.0

package context

import (
"testing"

"go.uber.org/zap"
)

func TestRanUeUpdateNtnAccess(t *testing.T) {
tests := []struct {
name string
initial *NtnAccessInfo
detected bool
wantPresent bool
wantDetected bool
}{
{
name: "nil + not-detected stays nil",
initial: nil,
detected: false,
wantPresent: false,
},
{
name: "nil + detected allocates true",
initial: nil,
detected: true,
wantPresent: true,
wantDetected: true,
},
{
name: "detected + detected stays true",
initial: &NtnAccessInfo{Detected: true},
detected: true,
wantPresent: true,
wantDetected: true,
},
{
name: "detected + not-detected stays true (sticky)",
initial: &NtnAccessInfo{Detected: true},
detected: false,
wantPresent: true,
wantDetected: true,
},
{
name: "not-detected + detected sets true",
initial: &NtnAccessInfo{Detected: false},
detected: true,
wantPresent: true,
wantDetected: true,
},
{
// Unreachable in production (we never allocate with Detected=false),
// but documents the function's contract: sticky means a !detected
// signal never modifies state.
name: "not-detected + not-detected stays false",
initial: &NtnAccessInfo{Detected: false},
detected: false,
wantPresent: true,
wantDetected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ranUe := &RanUe{
NtnAccess: tc.initial,
Log: zap.NewNop().Sugar(),
}
ranUe.updateNtnAccess(tc.detected)

if got := ranUe.NtnAccess != nil; got != tc.wantPresent {
t.Fatalf("NtnAccess presence: got %v, want %v", got, tc.wantPresent)
}
if !tc.wantPresent {
return
}
if ranUe.NtnAccess.Detected != tc.wantDetected {
t.Errorf("Detected = %v, want %v", ranUe.NtnAccess.Detected, tc.wantDetected)
}
})
}
}
Loading