Commit 125ff6db authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

Fix location gaps T1-T5 and I1 from the NEF coresim location test

- A location query raises SubscriberNotFound, UnsupportedIdentifier,
  TargetNotLocatable, MaxAgeUnsatisfiable or MaxSurfaceUnsatisfiable, the
  names SRM maps to CAMARA 404 and 422 (T1).
- A subscriber the NEF does not know is SubscriberNotFound whether it was
  looked up by IPv4 or by network access identifier (T2).
- The OAI client no longer reports a cell-only answer as a circle at (0, 0);
  the adapter raises TargetNotLocatable (T3).
- DeviceTarget carries ipv4_private and ipv4_port (srm.params/v1). The OAI
  location lookup and the Open5GS/Open5GCore QoD request use the private
  address, the one the core knows the UE by; without it nothing changes (T4).
- A location older than max_age_seconds or larger than max_surface_sqm is
  refused with MaxAgeUnsatisfiable or MaxSurfaceUnsatisfiable (T5).
- The location fix time is the NEF's ueLocationTimestamp when present (I1).

Original-Author: Igli <kociaj4@gmail.com>
Reviewed-Commit: 908134ee3b13b61302772ea99c7e4a2ced92c2a3
parent ef59a2ac
Loading
Loading
Loading
Loading
+59 −7
Changes for src/openop_tfsdk/contract/adapters/network.py: 59 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -6,6 +6,8 @@
Contract adapters for the network cores of the previous codebase. They translate
contract calls to the existing NetworkManager clients.
"""
import math
from datetime import datetime, timezone
from typing import Optional

import pydantic
@@ -15,7 +17,12 @@ import structlog
from openop_tfsdk._diagnostics import backend_diagnostics, validation_diagnostics
from openop_tfsdk.contract.errors import (
    DomainExecutionFailed,
    MaxAgeUnsatisfiable,
    MaxSurfaceUnsatisfiable,
    ResourceNotFound,
    SubscriberNotFound,
    TargetNotLocatable,
    UnsupportedIdentifier,
    ValidationError,
)
from openop_tfsdk.contract.execution_models import (
@@ -33,6 +40,10 @@ from openop_tfsdk.contract.execution_models import (
    RuntimeState,
)
from openop_tfsdk.contract.models import SupportedAction
from openop_tfsdk.network.adapters.errors import (
    NetworkPlatformError,
    NoGeographicLocation,
)
from openop_tfsdk.network.adapters.oai.client import NetworkManager as OaiClient
from openop_tfsdk.network.adapters.oai.client import OaiValidationError
from openop_tfsdk.network.adapters.open5gcore.client import (
@@ -71,6 +82,8 @@ _RUNTIME_STATES = {
    "UNAVAILABLE": RuntimeState.FAILED,
}

_EARTH_RADIUS_M = 6_371_000


def _surface(client_class, capability_kind: str):
    feature, actions = _SURFACES.get(capability_kind, (None, []))
@@ -86,10 +99,13 @@ def _device(device) -> dict:
    if device.network_access_id:
        camara["networkAccessIdentifier"] = device.network_access_id
    if device.ipv4:
        # Without NAT the private address is the public one (CAMARA DeviceIpv4Addr).
        camara["ipv4Address"] = {
            "publicAddress": str(device.ipv4),
            "privateAddress": str(device.ipv4),
            "privateAddress": str(device.ipv4_private or device.ipv4),
        }
        if device.ipv4_port is not None:
            camara["ipv4Address"]["publicPort"] = device.ipv4_port
    if device.ipv6:
        camara["ipv6Address"] = str(device.ipv6)
    return camara
@@ -174,9 +190,6 @@ def _location(location) -> LocationQueryResult:
                GeoPoint(latitude=p.latitude, longitude=p.longitude) for p in area.boundary.root
            ]
        )
    elif area.center.latitude == 0 and area.center.longitude == 0:
        # The OAI client reports a cell-only fix as a circle at (0, 0).
        raise DomainExecutionFailed("no geographic location for the device")
    else:
        canonical = CircleArea(
            center=GeoPoint(latitude=area.center.latitude, longitude=area.center.longitude),
@@ -185,6 +198,34 @@ def _location(location) -> LocationQueryResult:
    return LocationQueryResult(last_location_time=location.lastLocationTime.root, area=canonical)


def _area_sqm(area) -> float:
    if isinstance(area, CircleArea):
        return math.pi * area.radius_m**2
    # Shoelace formula on a flat projection around the polygon; exact enough at city scale.
    lat0 = sum(p.latitude for p in area.boundary) / len(area.boundary)
    lon0 = area.boundary[0].longitude
    metres_per_degree = math.pi / 180 * _EARTH_RADIUS_M
    points = [
        (
            (p.longitude - lon0) * metres_per_degree * math.cos(math.radians(lat0)),
            (p.latitude - lat0) * metres_per_degree,
        )
        for p in area.boundary
    ]
    pairs = zip(points, points[1:] + points[:1])
    return abs(sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in pairs)) / 2


def _check_limits(result: LocationQueryResult, parameters) -> None:
    if parameters.max_age_seconds is not None:
        age = (datetime.now(timezone.utc) - result.last_location_time).total_seconds()
        if age > parameters.max_age_seconds:
            raise MaxAgeUnsatisfiable("no location fix is as fresh as max_age_seconds")
    if parameters.max_surface_sqm is not None:
        if _area_sqm(result.area) > parameters.max_surface_sqm:
            raise MaxSurfaceUnsatisfiable("no location area is as tight as max_surface_sqm")


def _reason(error) -> str:
    # Pydantic messages can echo request values, so only their fields and rules are kept.
    if isinstance(error, pydantic.ValidationError):
@@ -295,7 +336,7 @@ class NetworkCapabilityAdapter:
            (name for name in self.location_identifiers if device and getattr(device, name)), None
        )
        if identifier is None:
            raise ValidationError(
            raise UnsupportedIdentifier(
                f"{self.adapter_type} locates a device by {' or '.join(self.location_identifiers)}"
            )
        parameters = request.parameters.parameters
@@ -315,10 +356,17 @@ class NetworkCapabilityAdapter:
            raise ValidationError(
                f"location_context: invalid request for {self.adapter_type}: {_reason(error)}"
            ) from error
        try:
            location = self._call("create_monitoring_event_subscription", location_request)
        except ResourceNotFound as error:
            raise SubscriberNotFound(
                f"{self.adapter_type} has no subscriber with this {identifier}"
            ) from error
        result = _location(location)
        _check_limits(result, parameters)
        return CapabilityQueryResult(
            capability_kind=request.capability_kind,
            result=_location(location),
            result=result,
            srm_resolved_identifier=identifier,
        )

@@ -340,7 +388,11 @@ class NetworkCapabilityAdapter:
            raise ValidationError(
                f"{method}: invalid request for {self.adapter_type}: {_reason(error)}"
            ) from error
        except CoreHttpError as error:
        except NoGeographicLocation as error:
            raise TargetNotLocatable(
                f"{method}: the network returned no geographic location for the device"
            ) from error
        except (CoreHttpError, NetworkPlatformError) as error:
            cause = error.__cause__
            if isinstance(cause, requests.HTTPError) and cause.response is not None:
                if cause.response.status_code == 404:
+26 −0
Changes for src/openop_tfsdk/contract/errors.py: 26 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -54,3 +54,29 @@ class DomainExecutionFailed(TFSDKError):
class ResourceNotFound(TFSDKError):
    error_code = "RESOURCE_NOT_FOUND"
    retryable = False


# Location query refusals; SRM maps them by class name to CAMARA 404 or 422.
class SubscriberNotFound(TFSDKError):
    error_code = "SUBSCRIBER_NOT_FOUND"
    retryable = False


class UnsupportedIdentifier(TFSDKError):
    error_code = "UNSUPPORTED_IDENTIFIER"
    retryable = False


class TargetNotLocatable(TFSDKError):
    error_code = "TARGET_NOT_LOCATABLE"
    retryable = False


class MaxAgeUnsatisfiable(TFSDKError):
    error_code = "MAX_AGE_UNSATISFIABLE"
    retryable = False


class MaxSurfaceUnsatisfiable(TFSDKError):
    error_code = "MAX_SURFACE_UNSATISFIABLE"
    retryable = False
+2 −0
Changes for src/openop_tfsdk/contract/execution_models.py: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -30,6 +30,8 @@ class LifecycleState(str, Enum):
class DeviceTarget(BaseModel):
    phone_number: Optional[str] = None
    ipv4: Optional[IPv4Address] = None
    ipv4_port: Optional[int] = Field(default=None, ge=0, le=65535)
    ipv4_private: Optional[IPv4Address] = None
    ipv6: Optional[IPv6Address] = None
    network_access_id: Optional[str] = None

+4 −0
Changes for src/openop_tfsdk/network/adapters/errors.py: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
# -*- coding: utf-8 -*-
class NetworkPlatformError(Exception):
    pass


class NoGeographicLocation(NetworkPlatformError):
    pass
+18 −23
Changes for src/openop_tfsdk/network/adapters/oai/client.py: 18 added lines, 23 removed lines.
Original line number Diff line number Diff line
@@ -11,7 +11,10 @@ from datetime import datetime, timedelta, timezone
import requests

from openop_tfsdk import logger
from openop_tfsdk.network.adapters.errors import NetworkPlatformError
from openop_tfsdk.network.adapters.errors import (
    NetworkPlatformError,
    NoGeographicLocation,
)
from openop_tfsdk.network.core import schemas
from openop_tfsdk.network.core.base_network_client import BaseNetworkClient
from openop_tfsdk.network.core.schemas import (
@@ -262,7 +265,9 @@ class NetworkManager(BaseNetworkClient):
                f"{subscription.externalId}"
            )
        elif device.ipv4Address is not None:
            ip_str = str(device.ipv4Address.root.publicAddress.root)
            # The core knows the UE by its PDU session address, the private one behind NAT.
            ipv4 = device.ipv4Address.root
            ip_str = str((ipv4.privateAddress or ipv4.publicAddress).root)
            subscription.externalId = self._resolve_ue_external_id(ip_str)
        else:
            raise OaiValidationError(
@@ -283,8 +288,7 @@ class NetworkManager(BaseNetworkClient):

        Location data may be:
        - Geographic area (Point with uncertainty → CAMARA Circle, or Polygon)
        - Cell-level only (cellId, nrLocation with NCGI/TAI → CAMARA Circle
          centred at 0,0 with cellId in metadata – best-effort)
        - Cell-level only (cellId, nrLocation with NCGI/TAI): raises NoGeographicLocation

        args:
            response: Raw JSON response dict from the OAI NEF.
@@ -340,6 +344,13 @@ class NetworkManager(BaseNetworkClient):
            if isinstance(nr_age, (int, float)):
                age_minutes = int(nr_age)

        # Prefer the NEF's own fix time; the age is only given in whole minutes.
        ue_location_time = nr_loc.get("ueLocationTimestamp")
        if ue_location_time:
            last_location_time = datetime.fromisoformat(ue_location_time.replace("Z", "+00:00"))
            if last_location_time.tzinfo is None:
                last_location_time = last_location_time.replace(tzinfo=timezone.utc)
        else:
            last_location_time = self._compute_camara_last_location_time(event_time, age_minutes)
        log.debug(f"OAI Last Location time is {last_location_time}")

@@ -351,25 +362,9 @@ class NetworkManager(BaseNetworkClient):
            area = self._parse_geographic_area(geo_area)
            return schemas.Location(area=area, lastLocationTime=last_location_time)

        # Option 2: Cell-level location only (cellId / nrLocation)
        # The core simulator (and some real deployments) may only return cell
        # identifiers without geographic coordinates. We return a CAMARA Circle
        # at (0, 0) with a large radius to signal cell-level accuracy. In a
        # production deployment with a real core the geographicArea path above
        # would be taken instead.
        cell_id = location_info.get("cellId")
        if nr_loc or cell_id:
            log.warning(
                "OAI NEF returned cell-level location only (no geographic coordinates). "
                f"cellId={cell_id}, ncgi={nr_loc.get('ncgi')}, tai={nr_loc.get('tai')}. "
                "Returning CAMARA Circle with cell-level accuracy."
            )
            area = schemas.Circle(
                areaType=schemas.AreaType.circle,
                center=schemas.Point(latitude=0.0, longitude=0.0),
                radius=50000.0,  # ~50 km – coarse cell-level accuracy
            )
            return schemas.Location(area=area, lastLocationTime=last_location_time)
        # Option 2: a cell ID without coordinates cannot become a CAMARA area.
        if nr_loc or location_info.get("cellId"):
            raise NoGeographicLocation("The NEF located the device to a cell only")

        raise NetworkPlatformError(
            f"No usable location data in OAI NEF response. " f"locationInfo: {location_info}"
Loading