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
16 changes: 0 additions & 16 deletions .basedpyright/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -5290,14 +5290,6 @@
"endColumn": 44,
"lineCount": 1
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
"startColumn": 38,
"endColumn": 69,
"lineCount": 1
}
}
],
"./monitoring/uss_qualifier/resources/flight_planning/flight_planners.py": [
Expand Down Expand Up @@ -19113,14 +19105,6 @@
"lineCount": 1
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
"startColumn": 44,
"endColumn": 58,
"lineCount": 1
}
},
{
"code": "reportCallIssue",
"range": {
Expand Down
12 changes: 12 additions & 0 deletions monitoring/uss_qualifier/resources/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,15 @@ Resources for a given test configuration are all declared in a single global res
* Example: `netrid.flight_data.nominal_flights`, which would provide flight data for nominal flights which could be injected into Service Providers under test.
2. Every type of test resource must define a "resource specification", which is a serializable data type that fully defines how to create an instance of that resource type.
3. Every type of test resource must define how to create an instance of the test resource from an instance of the resource specification.


## Resource modifiers

A `ResourceModifier` is a resource that wraps another resource and produces variants of it based on an integer index. This is useful when a test scenario needs multiple unique-but-related instances of a resource (e.g., distinct flights derived from a single base flight).

To use a `ResourceModifier`:

1. Declare it like any other resource, with its `base_resource` dependency pointing to the resource to be modified.
2. When need, call `adjust(index)` to obtain a modified copy of the base resource. Different `index` values produce different (unique) variants; the same `index` produces equivalent results.

The base resource itself remains available as `base_resource` on the modifier.
4 changes: 4 additions & 0 deletions monitoring/uss_qualifier/resources/dev/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
from .noop import NoOpResource as NoOpResource
from .test_exclusions import TestExclusionsResource as TestExclusionsResource
from .test_modifier import TestModifierModifierResource as TestModifierModifierResource
from .test_modifier import TestModifierResource as TestModifierResource
from .test_modifier import TestSquareModifier as TestSquareModifier
from .test_modifier import TestSquareResource as TestSquareResource
104 changes: 104 additions & 0 deletions monitoring/uss_qualifier/resources/dev/test_modifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from implicitdict import ImplicitDict

from monitoring.monitorlib.geo import LatLngBoundingBox
from monitoring.uss_qualifier.resources.modifiers import (
GeospatialModifier,
GeospatialResource,
)
from monitoring.uss_qualifier.resources.resource import Resource, ResourceModifier


class TestModifierSpecification(ImplicitDict):
base_id: int


class TestModifierResource(Resource[TestModifierSpecification]):
"""TestModifierResource is a simple resource returing 10 number, starting from base_id. Used for unit tests."""

_spec: TestModifierSpecification

def __init__(
self,
specification: TestModifierSpecification,
resource_origin: str,
):
super().__init__(specification, resource_origin)
self._spec = specification

def build_ids(self) -> list[int]:
return list(range(self._spec.base_id, self._spec.base_id + 10))


class TestModifierModifierSpecification(ImplicitDict):
shift_interval: int


class TestModifierModifierResource(
ResourceModifier[TestModifierModifierSpecification, TestModifierResource]
):
"""Modifier for a TestModifierResource. Used for unit tests."""

def adjust(self, index: int) -> TestModifierResource:

# 'Clone' the resource with new specs
return TestModifierResource(
TestModifierSpecification(
base_id=self.base_resource._spec.base_id
+ self._spec.shift_interval * index,
),
resource_origin=self.base_resource.resource_origin,
)


class TestSquareSpecification(ImplicitDict):
lat_center: float
lng_center: float


class TestSquareResource(Resource[TestSquareSpecification], GeospatialResource):
"""1km x 1km square centered at (lat_center, lng_center). Used for unit tests."""

SQUARE_SIDE_M = 1000.0

_spec: TestSquareSpecification

def __init__(
self,
specification: TestSquareSpecification,
resource_origin: str,
):
super().__init__(specification, resource_origin)
self._spec = specification

def get_extents(self) -> LatLngBoundingBox:
point = LatLngBoundingBox(
lat_min=self._spec.lat_center,
lat_max=self._spec.lat_center,
lng_min=self._spec.lng_center,
lng_max=self._spec.lng_center,
)
return point.expand(
north_meters=self.SQUARE_SIDE_M / 2,
east_meters=self.SQUARE_SIDE_M / 2,
south_meters=self.SQUARE_SIDE_M / 2,
west_meters=self.SQUARE_SIDE_M / 2,
)

def move(self, meters_east: float, meters_north: float) -> "TestSquareResource":
shifted = self.get_extents().expand(
north_meters=meters_north,
east_meters=meters_east,
south_meters=-meters_north,
west_meters=-meters_east,
)
return TestSquareResource(
TestSquareSpecification(
lat_center=(shifted.lat_min + shifted.lat_max) / 2,
lng_center=(shifted.lng_min + shifted.lng_max) / 2,
),
resource_origin=self.resource_origin,
)


class TestSquareModifier(GeospatialModifier[TestSquareResource]):
pass
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .flight_intents_resource import FlightIntentsModifier as FlightIntentsModifier
from .flight_intents_resource import FlightIntentsResource as FlightIntentsResource
from .flight_planners import (
FlightPlannerCombinationSelectorResource as FlightPlannerCombinationSelectorResource,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
import json
import math

import s2sphere
from implicitdict import ImplicitDict

from monitoring.monitorlib.clients.flight_planning.flight_info_template import (
FlightInfoTemplate,
)
from monitoring.monitorlib.geo import EARTH_CIRCUMFERENCE_M, LatLngBoundingBox
from monitoring.monitorlib.geotemporal import Volume4D
from monitoring.monitorlib.transformations import (
RelativeTranslation,
Transformation,
)
from monitoring.uss_qualifier.resources.files import load_dict
from monitoring.uss_qualifier.resources.flight_planning.flight_intent import (
FlightIntentCollection,
FlightIntentID,
FlightIntentsSpecification,
)
from monitoring.uss_qualifier.resources.modifiers import (
GeospatialModifier,
GeospatialResource,
)
from monitoring.uss_qualifier.resources.resource import Resource


class FlightIntentsResource(Resource[FlightIntentsSpecification]):
class FlightIntentsResource(Resource[FlightIntentsSpecification], GeospatialResource):
_spec: FlightIntentsSpecification
_intent_collection: FlightIntentCollection

def __init__(self, specification: FlightIntentsSpecification, resource_origin: str):
super().__init__(specification, resource_origin)
self._spec = specification
has_file = "file" in specification and specification.file
has_literal = (
"intent_collection" in specification and specification.intent_collection
Expand All @@ -34,17 +50,70 @@ def __init__(self, specification: FlightIntentsSpecification, resource_origin: s
load_dict(specification.file), FlightIntentCollection
)
elif has_literal:
self._intent_collection = specification.intent_collection
self._intent_collection = ImplicitDict.parse(
json.loads(
json.dumps(specification.intent_collection)
), # NB: We need a copy to avoid sharing '_intent_collection' between instances
FlightIntentCollection,
)
if "transformations" in specification and specification.transformations:
if (
"transformations" in self._intent_collection
and self._intent_collection.transformations
):
self._intent_collection.transformations.extend(
specification.transformations
specification.transformations[::]
)
else:
self._intent_collection.transformations = specification.transformations
self._intent_collection.transformations = specification.transformations[ # NB: We do a copy to be independent between instances
::
]

def get_flight_intents(self) -> dict[FlightIntentID, FlightInfoTemplate]:
return self._intent_collection.resolve()

def get_extents(self) -> LatLngBoundingBox:
rect = s2sphere.LatLngRect.empty()
for template in self.get_flight_intents().values():
transformations = (
template.transformations
if "transformations" in template and template.transformations
else []
)
for vt in template.basic_information.area:
v4d = Volume4D(volume=vt.resolve_3d())
for transformation in transformations:
v4d = v4d.transform(transformation)
rect = rect.union(v4d.rect_bounds)
return LatLngBoundingBox.from_latlng_rect(rect)

def move(self, meters_east: float, meters_north: float) -> "FlightIntentsResource":
new_spec = FlightIntentsSpecification(self._spec)

# Apply the translation as degrees, not meters. RelativeTranslation in
# meters is converted per-polygon using each polygon's vertex_average as
# the tangent-plane origin, which yields slightly different absolute
# offsets for different polygons. That sub-meter drift is enough to break
# pre-existing intent overlaps (e.g. "tiny_overlap" conflicts). Converting
# meters → degrees here using the resource's overall extents produces a
# rigid lat/lng shift applied identically to every vertex.
extents = self.get_extents()
lat0 = (extents.lat_min + extents.lat_max) / 2
longitude_length = EARTH_CIRCUMFERENCE_M * math.cos(math.radians(lat0))

transformation = Transformation(
relative_translation=RelativeTranslation(
degrees_east=meters_east * 360 / longitude_length,
degrees_north=meters_north * 360 / EARTH_CIRCUMFERENCE_M,
)
)

if "transformations" in new_spec and new_spec.transformations:
new_spec.transformations = new_spec.transformations + [transformation]
else:
new_spec.transformations = [transformation]
return FlightIntentsResource(new_spec, resource_origin=self.resource_origin)


class FlightIntentsModifier(GeospatialModifier[FlightIntentsResource]):
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import unittest

from monitoring.monitorlib.geo import area_of_latlngrect
from monitoring.uss_qualifier.resources.definitions import (
ResourceDeclaration,
ResourceID,
)
from monitoring.uss_qualifier.resources.modifiers import (
GeospatialModifierSpecification,
)
from monitoring.uss_qualifier.resources.resource import (
create_resources,
)


class TestFlightIntentsGeospatialModifier(unittest.TestCase):
def _build_declarations(self) -> dict[ResourceID, ResourceDeclaration]:
return {
"flight_intents": ResourceDeclaration(
resource_type="resources.flight_planning.FlightIntentsResource",
specification={
"file": {
"path": "file://./test_data/che/flight_intents/general_flight_auth_flights.yaml",
},
},
),
"flight_intents_modifier": ResourceDeclaration(
resource_type="resources.flight_planning.FlightIntentsModifier",
specification=GeospatialModifierSpecification(),
dependencies={
"base_resource": "flight_intents",
},
),
}

def test_overlap_only_for_same_index(self):
resources = create_resources(self._build_declarations(), "test", True)
modifier = resources["flight_intents_modifier"]

extents = [modifier.adjust(i).get_extents() for i in range(11)]
base_area = area_of_latlngrect(extents[0].to_latlngrect())

for i in range(11):
for j in range(11):
overlap = area_of_latlngrect(
extents[i].to_latlngrect().intersection(extents[j].to_latlngrect())
)
if i == j:
assert (
overlap > 0.99 * base_area
), ( # Use 99% to compensate for errors
f"index {i}: self-overlap area {overlap:.2f}m² "
f"expected ~{base_area:.2f}m²"
)
else:
assert (
overlap < 0.01 * base_area
), ( # Use 1% to compensate for errors
f"indices {i},{j}: unexpected overlap area {overlap:.2f}m²"
)
45 changes: 45 additions & 0 deletions monitoring/uss_qualifier/resources/modifiers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from abc import ABC, abstractmethod
from math import isqrt
from typing import Self

from implicitdict import ImplicitDict

from monitoring.monitorlib.geo import LatLngBoundingBox, flatten
from monitoring.uss_qualifier.resources.resource import ResourceModifier


class GeospatialResource(ABC):
@abstractmethod
def get_extents(self) -> LatLngBoundingBox:
pass

@abstractmethod
def move(self, meters_east: float, meters_north: float) -> Self:
pass


class GeospatialModifierSpecification(ImplicitDict):
meters_east_margin: float = 1000
meters_north_margin: float = 1000


class GeospatialModifier[GeospatialResourceType: GeospatialResource](
ResourceModifier[GeospatialModifierSpecification, GeospatialResourceType]
):
def adjust(self, index: int) -> GeospatialResourceType:
# Make a grid based on index:
# x ->
# y 0 1 3 6
# | 2 4 7
# v 5 8
# 9
k = (isqrt(1 + 8 * index) - 1) // 2
offset = index - k * (k + 1) // 2
x = k - offset
y = offset

rect = self.base_resource.get_extents().to_latlngrect()
width_m, height_m = flatten(rect.lo(), rect.hi())
width_m += self._spec.meters_east_margin
height_m += self._spec.meters_north_margin
return self.base_resource.move(x * width_m, y * height_m)
Loading
Loading