diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index fbebc2a460..eb665e5a18 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -5128,14 +5128,6 @@ "endColumn": 44, "lineCount": 1 } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 38, - "endColumn": 69, - "lineCount": 1 - } } ], "./monitoring/uss_qualifier/resources/flight_planning/flight_planners.py": [ diff --git a/monitoring/uss_qualifier/resources/flight_planning/__init__.py b/monitoring/uss_qualifier/resources/flight_planning/__init__.py index 35397e9f6c..d64273370e 100644 --- a/monitoring/uss_qualifier/resources/flight_planning/__init__.py +++ b/monitoring/uss_qualifier/resources/flight_planning/__init__.py @@ -1,4 +1,7 @@ from .flight_intents_resource import FlightIntentsResource as FlightIntentsResource +from .flight_intents_resource import ( + FlightIntentsTriangularCascadeSoutheastResource as FlightIntentsTriangularCascadeSoutheastResource, +) from .flight_planners import ( FlightPlannerCombinationSelectorResource as FlightPlannerCombinationSelectorResource, ) diff --git a/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource.py b/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource.py index e4189a1109..cf0aa3b138 100644 --- a/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource.py +++ b/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource.py @@ -1,22 +1,37 @@ +import json + +import s2sphere from implicitdict import ImplicitDict from monitoring.monitorlib.clients.flight_planning.flight_info_template import ( FlightInfoTemplate, ) +from monitoring.monitorlib.geo import ( + LatLngBoundingBox, + RelativeTranslation, + Transformation, +) +from monitoring.monitorlib.geotemporal import Volume4D 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.geospatial import ( + GeospatialResource, + TriangularCascadeSoutheastResource, +) from monitoring.uss_qualifier.resources.resource import Resource -class FlightIntentsResource(Resource[FlightIntentsSpecification]): +class FlightIntentsResource(GeospatialResource, Resource[FlightIntentsSpecification]): + _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 @@ -34,17 +49,61 @@ 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) + + transformation = Transformation( + relative_translation=RelativeTranslation( + meters_east=meters_east, + meters_north=meters_north, + ) + ) + + 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 FlightIntentsTriangularCascadeSoutheastResource( + TriangularCascadeSoutheastResource[FlightIntentsResource] +): + pass diff --git a/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource_test.py b/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource_test.py new file mode 100644 index 0000000000..c4e9022d62 --- /dev/null +++ b/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource_test.py @@ -0,0 +1,64 @@ +import unittest + +from monitoring.monitorlib.geo import area_of_latlngrect +from monitoring.uss_qualifier.resources.definitions import ( + ResourceDeclaration, + ResourceID, +) +from monitoring.uss_qualifier.resources.geospatial import ( + TriangularCascadeSoutheastSpecification, +) +from monitoring.uss_qualifier.resources.resource import ( + create_resources, +) + + +class TestFlightIntentsTriangularCascadeSoutheastResource(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.FlightIntentsTriangularCascadeSoutheastResource", + specification=TriangularCascadeSoutheastSpecification( + meters_east_margin=1000, meters_north_margin=1000 + ), + 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.provide_resource_for(index=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²" + )