diff --git a/monitoring/mock_uss/scdsc/database.py b/monitoring/mock_uss/scdsc/database.py index d59669ce25..156cdf2dd6 100644 --- a/monitoring/mock_uss/scdsc/database.py +++ b/monitoring/mock_uss/scdsc/database.py @@ -1,10 +1,13 @@ import json from typing import Dict, Optional -from monitoring.monitorlib import scd from monitoring.monitorlib.multiprocessing import SynchronizedValue from monitoring.monitorlib.scd_automated_testing import scd_injection_api from implicitdict import ImplicitDict +from uas_standards.astm.f3548.v21.api import ( + OperationalIntentReference, + OperationalIntent, +) class FlightRecord(ImplicitDict): @@ -12,7 +15,7 @@ class FlightRecord(ImplicitDict): op_intent_injection: scd_injection_api.OperationalIntentTestInjection flight_authorisation: scd_injection_api.FlightAuthorisationData - op_intent_reference: scd.OperationalIntentReference + op_intent_reference: OperationalIntentReference locked: bool = False @@ -20,7 +23,7 @@ class Database(ImplicitDict): """Simple in-memory pseudo-database tracking the state of the mock system""" flights: Dict[str, Optional[FlightRecord]] = {} - cached_operations: Dict[str, scd.OperationalIntent] = {} + cached_operations: Dict[str, OperationalIntent] = {} db = SynchronizedValue( diff --git a/monitoring/mock_uss/scdsc/routes_injection.py b/monitoring/mock_uss/scdsc/routes_injection.py index 0254d1c57b..7a55826268 100644 --- a/monitoring/mock_uss/scdsc/routes_injection.py +++ b/monitoring/mock_uss/scdsc/routes_injection.py @@ -2,7 +2,7 @@ import traceback from datetime import datetime, timedelta import time -from typing import List, Tuple, Dict, Optional +from typing import List, Tuple import uuid import flask @@ -10,6 +10,14 @@ import requests.exceptions from monitoring.mock_uss.scdsc.routes_scdsc import op_intent_from_flightrecord +from monitoring.monitorlib.geo import Polygon +from monitoring.monitorlib.geotemporal import Volume4D, Volume4DCollection +from uas_standards.astm.f3548.v21.api import ( + OperationalIntent, + PutOperationalIntentDetailsParameters, + ImplicitSubscriptionParameters, + PutOperationalIntentReferenceParameters, +) from uas_standards.astm.f3548.v21.constants import OiMaxPlanHorizonDays, OiMaxVertices from monitoring.mock_uss.config import KEY_BASE_URL, KEY_BEHAVIOR_LOCALITY @@ -37,7 +45,7 @@ from monitoring.mock_uss import webapp, require_config_value from monitoring.mock_uss.auth import requires_scope from monitoring.mock_uss.scdsc import database, utm_client -from monitoring.mock_uss.scdsc.database import db, FlightRecord +from monitoring.mock_uss.scdsc.database import db from monitoring.monitorlib.uspace import problems_with_flight_authorisation @@ -55,7 +63,7 @@ def _make_stacktrace(e) -> str: def query_operational_intents( area_of_interest: scd.Volume4D, -) -> List[scd.OperationalIntent]: +) -> List[OperationalIntent]: """Retrieve a complete set of operational intents in an area, including details. :param area_of_interest: Area where intersecting operational intents must be discovered @@ -190,7 +198,9 @@ def inject_flight(flight_id: str, req_body: InjectFlightRequest) -> Tuple[dict, ) # Validate max planning horizon for creation - start_time = scd.start_of(req_body.operational_intent.volumes) + start_time = Volume4DCollection.from_f3548v21( + req_body.operational_intent.volumes + ).time_start.datetime time_delta = start_time - datetime.now(tz=start_time.tzinfo) if ( time_delta.days > OiMaxPlanHorizonDays @@ -269,19 +279,9 @@ def inject_flight(flight_id: str, req_body: InjectFlightRequest) -> Tuple[dict, logger.debug( f"[inject_flight/{pid}:{flight_id}] Obtaining latest operational intent information" ) - start_time = scd.start_of(req_body.operational_intent.volumes) - end_time = scd.end_of(req_body.operational_intent.volumes) - area = scd.rect_bounds_of(req_body.operational_intent.volumes) - alt_lo, alt_hi = scd.meter_altitude_bounds_of( + vol4 = Volume4DCollection.from_f3548v21( req_body.operational_intent.volumes - ) - vol4 = scd.make_vol4( - start_time, - end_time, - alt_lo, - alt_hi, - polygon=scd.make_polygon(latlngrect=area), - ) + ).bounding_volume.to_f3548v21() op_intents = query_operational_intents(vol4) # Check for intersections @@ -289,7 +289,7 @@ def inject_flight(flight_id: str, req_body: InjectFlightRequest) -> Tuple[dict, logger.debug( f"[inject_flight/{pid}:{flight_id}] Checking for intersections with {', '.join(op_intent.reference.id for op_intent in op_intents)}" ) - v1 = req_body.operational_intent.volumes + v1 = Volume4DCollection.from_f3548v21(req_body.operational_intent.volumes) for op_intent in op_intents: if ( existing_flight @@ -315,7 +315,9 @@ def inject_flight(flight_id: str, req_body: InjectFlightRequest) -> Tuple[dict, ) continue - v2 = op_intent.details.volumes + op_intent.details.off_nominal_volumes + v2 = Volume4DCollection.from_f3548v21( + op_intent.details.volumes + op_intent.details.off_nominal_volumes + ) if ( existing_flight @@ -323,16 +325,16 @@ def inject_flight(flight_id: str, req_body: InjectFlightRequest) -> Tuple[dict, == OperationalIntentState.Activated and req_body.operational_intent.state == OperationalIntentState.Activated - and ( - scd.vol4s_intersect(existing_flight.op_intent_injection.volumes, v2) - ) + and Volume4DCollection.from_f3548v21( + existing_flight.op_intent_injection.volumes + ).intersects_vol4s(v2) ): logger.debug( f"[inject_flight/{pid}:{flight_id}] intersection with {op_intent.reference.id} not considered: modification of Activated operational intent with a pre-existing conflict" ) continue - if scd.vol4s_intersect(v1, v2): + if v1.intersects_vol4s(v2): notes = f"Requested flight (priority {req_body.operational_intent.priority}) intersected {op_intent.reference.manager}'s operational intent {op_intent.reference.id} (priority {op_intent.details.priority})" return ( InjectFlightResponse( @@ -347,13 +349,13 @@ def inject_flight(flight_id: str, req_body: InjectFlightRequest) -> Tuple[dict, f"[inject_flight/{pid}:{flight_id}] Sharing operational intent with DSS" ) base_url = "{}/mock/scd".format(webapp.config[KEY_BASE_URL]) - req = scd.PutOperationalIntentReferenceParameters( + req = PutOperationalIntentReferenceParameters( extents=req_body.operational_intent.volumes + req_body.operational_intent.off_nominal_volumes, key=[op.reference.ovn for op in op_intents], state=req_body.operational_intent.state, uss_base_url=base_url, - new_subscription=scd.ImplicitSubscriptionParameters(uss_base_url=base_url), + new_subscription=ImplicitSubscriptionParameters(uss_base_url=base_url), ) if existing_flight: id = existing_flight.op_intent_reference.id @@ -372,7 +374,7 @@ def inject_flight(flight_id: str, req_body: InjectFlightRequest) -> Tuple[dict, # Notify subscribers subscriber_list = ", ".join(s.uss_base_url for s in result.subscribers) step_name = f"notifying subscribers {{{subscriber_list}}}" - operational_intent = scd.OperationalIntent( + operational_intent = OperationalIntent( reference=result.operational_intent_reference, details=req_body.operational_intent, ) @@ -380,7 +382,7 @@ def inject_flight(flight_id: str, req_body: InjectFlightRequest) -> Tuple[dict, if subscriber.uss_base_url == base_url: # Do not notify ourselves continue - update = scd.PutOperationalIntentDetailsParameters( + update = PutOperationalIntentDetailsParameters( operational_intent_id=result.operational_intent_reference.id, operational_intent=operational_intent, subscriptions=subscriber.subscriptions, @@ -512,7 +514,7 @@ def delete_flight(flight_id) -> Tuple[dict, int]: if subscriber.uss_base_url == base_url: # Do not notify ourselves continue - update = scd.PutOperationalIntentDetailsParameters( + update = PutOperationalIntentDetailsParameters( operational_intent_id=result.operational_intent_reference.id, subscriptions=subscriber.subscriptions, ) @@ -579,17 +581,20 @@ def make_result(success: bool, msg: str) -> ClearAreaResponse: try: # Find operational intents in the DSS step_name = "constructing DSS operational intent query" - start_time = scd.start_of([req.extent]) - end_time = scd.end_of([req.extent]) - area = scd.rect_bounds_of([req.extent]) - alt_lo, alt_hi = scd.meter_altitude_bounds_of([req.extent]) - vol4 = scd.make_vol4( + # TODO: Simply use the req.extent 4D volume more directly + extent = Volume4D.from_f3548v21(req.extent) + start_time = extent.time_start.datetime + end_time = extent.time_end.datetime + area = extent.rect_bounds + alt_lo = extent.volume.altitude_lower_wgs84_m() + alt_hi = extent.volume.altitude_upper_wgs84_m() + vol4 = Volume4D.from_values( start_time, end_time, alt_lo, alt_hi, - polygon=scd.make_polygon(latlngrect=area), - ) + polygon=Polygon.from_latlng_rect(latlngrect=area), + ).to_f3548v21() step_name = "finding operational intents in the DSS" op_intent_refs = scd_client.query_operational_intent_references( utm_client, vol4 diff --git a/monitoring/mock_uss/scdsc/routes_scdsc.py b/monitoring/mock_uss/scdsc/routes_scdsc.py index 4fa22b9e0c..a998b0dbba 100644 --- a/monitoring/mock_uss/scdsc/routes_scdsc.py +++ b/monitoring/mock_uss/scdsc/routes_scdsc.py @@ -4,6 +4,12 @@ from monitoring.mock_uss import webapp from monitoring.mock_uss.auth import requires_scope from monitoring.mock_uss.scdsc.database import db, FlightRecord +from uas_standards.astm.f3548.v21.api import ( + GetOperationalIntentDetailsResponse, + ErrorResponse, + OperationalIntent, + OperationalIntentDetails, +) @webapp.route("/mock/scd/uss/v1/operational_intents/", methods=["GET"]) @@ -23,7 +29,7 @@ def scdsc_get_operational_intent_details(entityid: str): if flight is None: return ( flask.jsonify( - scd.ErrorResponse( + ErrorResponse( message="Operational intent {} not known by this USS".format( entityid ) @@ -33,16 +39,16 @@ def scdsc_get_operational_intent_details(entityid: str): ) # Return nominal response with details - response = scd.GetOperationalIntentDetailsResponse( + response = GetOperationalIntentDetailsResponse( operational_intent=op_intent_from_flightrecord(flight), ) return flask.jsonify(response), 200 -def op_intent_from_flightrecord(flight: FlightRecord) -> scd.OperationalIntent: - return scd.OperationalIntent( +def op_intent_from_flightrecord(flight: FlightRecord) -> OperationalIntent: + return OperationalIntent( reference=flight.op_intent_reference, - details=scd.OperationalIntentDetails( + details=OperationalIntentDetails( volumes=flight.op_intent_injection.volumes, off_nominal_volumes=flight.op_intent_injection.off_nominal_volumes, priority=flight.op_intent_injection.priority, diff --git a/monitoring/mock_uss/tracer/observation_areas.py b/monitoring/mock_uss/tracer/observation_areas.py index 6a48207225..899bb5314d 100644 --- a/monitoring/mock_uss/tracer/observation_areas.py +++ b/monitoring/mock_uss/tracer/observation_areas.py @@ -2,11 +2,10 @@ from implicitdict import ImplicitDict -from monitoring.monitorlib.geo import Volume4D +from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import AuthSpec from monitoring.monitorlib.rid import RIDVersion - ObservationAreaID = str """Unique identifier of observation area.""" diff --git a/monitoring/mock_uss/tracer/routes/observation_areas.py b/monitoring/mock_uss/tracer/routes/observation_areas.py index 6ffae21ddd..d01a4a1795 100644 --- a/monitoring/mock_uss/tracer/routes/observation_areas.py +++ b/monitoring/mock_uss/tracer/routes/observation_areas.py @@ -27,8 +27,8 @@ from monitoring.mock_uss.tracer.tracer_poll import TASK_POLL_OBSERVATION_AREAS from monitoring.monitorlib import fetch import monitoring.monitorlib.fetch.rid -import monitoring.monitorlib.fetch.scd -from monitoring.monitorlib.geo import Volume4D, Volume3D +from monitoring.monitorlib.geo import Volume3D +from monitoring.monitorlib.geotemporal import Volume4D @webapp.route("/tracer/observation_areas", methods=["GET"]) diff --git a/monitoring/monitorlib/clients/scd.py b/monitoring/monitorlib/clients/scd.py index 89f27e16e4..563daeaca8 100644 --- a/monitoring/monitorlib/clients/scd.py +++ b/monitoring/monitorlib/clients/scd.py @@ -5,18 +5,18 @@ from monitoring.monitorlib.fetch import QueryError, Query, QueryType from monitoring.monitorlib.infrastructure import UTMClientSession from implicitdict import ImplicitDict -from loguru import logger +from uas_standards.astm.f3548.v21 import api # === DSS operations defined in ASTM API === def query_operational_intent_references( - utm_client: UTMClientSession, area_of_interest: scd.Volume4D -) -> List[scd.OperationalIntentReference]: + utm_client: UTMClientSession, area_of_interest: api.Volume4D +) -> List[api.OperationalIntentReference]: url = "/dss/v1/operational_intent_references/query" subject = f"queryOperationalIntentReferences from {url}" - req = scd.QueryOperationalIntentReferenceParameters( + req = api.QueryOperationalIntentReferenceParameters( area_of_interest=area_of_interest ) query = fetch.query_and_describe( @@ -31,7 +31,7 @@ def query_operational_intent_references( ) try: resp_body = ImplicitDict.parse( - query.response.json, scd.QueryOperationalIntentReferenceResponse + query.response.json, api.QueryOperationalIntentReferenceResponse ) except KeyError: raise QueryError( @@ -47,8 +47,8 @@ def query_operational_intent_references( def create_operational_intent_reference( utm_client: UTMClientSession, id: str, - req: scd.PutOperationalIntentReferenceParameters, -) -> scd.ChangeOperationalIntentReferenceResponse: + req: api.PutOperationalIntentReferenceParameters, +) -> api.ChangeOperationalIntentReferenceResponse: url = "/dss/v1/operational_intent_references/{}".format(id) subject = f"createOperationalIntentReference to {url}" query = fetch.query_and_describe( @@ -63,7 +63,7 @@ def create_operational_intent_reference( ) try: return ImplicitDict.parse( - query.response.json, scd.ChangeOperationalIntentReferenceResponse + query.response.json, api.ChangeOperationalIntentReferenceResponse ) except KeyError: raise QueryError( @@ -79,8 +79,8 @@ def update_operational_intent_reference( utm_client: UTMClientSession, id: str, ovn: str, - req: scd.PutOperationalIntentReferenceParameters, -) -> scd.ChangeOperationalIntentReferenceResponse: + req: api.PutOperationalIntentReferenceParameters, +) -> api.ChangeOperationalIntentReferenceResponse: url = "/dss/v1/operational_intent_references/{}/{}".format(id, ovn) subject = f"updateOperationalIntentReference to {url}" query = fetch.query_and_describe( @@ -95,7 +95,7 @@ def update_operational_intent_reference( ) try: return ImplicitDict.parse( - query.response.json, scd.ChangeOperationalIntentReferenceResponse + query.response.json, api.ChangeOperationalIntentReferenceResponse ) except KeyError: raise QueryError( @@ -109,7 +109,7 @@ def update_operational_intent_reference( def delete_operational_intent_reference( utm_client: UTMClientSession, id: str, ovn: str -) -> scd.ChangeOperationalIntentReferenceResponse: +) -> api.ChangeOperationalIntentReferenceResponse: url = f"/dss/v1/operational_intent_references/{id}/{ovn}" subject = f"deleteOperationalIntentReference from {url}" query = fetch.query_and_describe(utm_client, "DELETE", url, scope=scd.SCOPE_SC) @@ -122,7 +122,7 @@ def delete_operational_intent_reference( ) try: return ImplicitDict.parse( - query.response.json, scd.ChangeOperationalIntentReferenceResponse + query.response.json, api.ChangeOperationalIntentReferenceResponse ) except KeyError: raise QueryError( @@ -139,7 +139,7 @@ def delete_operational_intent_reference( def get_operational_intent_details( utm_client: UTMClientSession, uss_base_url: str, id: str -) -> Tuple[scd.OperationalIntent, Query]: +) -> Tuple[api.OperationalIntent, Query]: url = f"{uss_base_url}/uss/v1/operational_intents/{id}" subject = f"getOperationalIntentDetails from {url}" query = fetch.query_and_describe( @@ -159,7 +159,7 @@ def get_operational_intent_details( ) try: resp_body = ImplicitDict.parse( - query.response.json, scd.GetOperationalIntentDetailsResponse + query.response.json, api.GetOperationalIntentDetailsResponse ) except KeyError: raise QueryError( @@ -175,7 +175,7 @@ def get_operational_intent_details( def notify_operational_intent_details_changed( utm_client: UTMClientSession, uss_base_url: str, - update: scd.PutOperationalIntentDetailsParameters, + update: api.PutOperationalIntentDetailsParameters, ) -> Query: url = f"{uss_base_url}/uss/v1/operational_intents" subject = f"notifyOperationalIntentDetailsChanged to {url}" @@ -204,8 +204,8 @@ def notify_operational_intent_details_changed( def notify_subscribers( utm_client: UTMClientSession, id: str, - operational_intent: Optional[scd.OperationalIntent], - subscribers: List[scd.SubscriberToNotify], + operational_intent: Optional[api.OperationalIntent], + subscribers: List[api.SubscriberToNotify], ): for subscriber in subscribers: kwargs = { @@ -214,7 +214,7 @@ def notify_subscribers( } if operational_intent is not None: kwargs["operational_intent"] = operational_intent - update = scd.PutOperationalIntentDetailsParameters(**kwargs) + update = api.PutOperationalIntentDetailsParameters(**kwargs) notify_operational_intent_details_changed( utm_client, subscriber.uss_base_url, update ) diff --git a/monitoring/monitorlib/fetch/scd.py b/monitoring/monitorlib/fetch/scd.py index a8d8272f68..3d933a7b2b 100644 --- a/monitoring/monitorlib/fetch/scd.py +++ b/monitoring/monitorlib/fetch/scd.py @@ -7,6 +7,8 @@ from implicitdict import ImplicitDict from monitoring.monitorlib import fetch, infrastructure, scd +from monitoring.monitorlib.geo import Polygon +from monitoring.monitorlib.geotemporal import Volume4D class FetchedEntityReferences(fetch.Query): @@ -83,13 +85,13 @@ def _entity_references( ) -> FetchedEntityReferences: # Query DSS for Entities in 4D volume of interest request_body = { - "area_of_interest": scd.make_vol4( + "area_of_interest": Volume4D.from_values( start_time, end_time, alt_min_m, alt_max_m, - polygon=scd.make_polygon(latlngrect=area), - ) + polygon=Polygon.from_latlng_rect(latlngrect=area), + ).to_f3548v21() } url = "/dss/v1/{}/query".format(dss_resource_name) scope = scd.SCOPE_CP if "constraint" in dss_resource_name else scd.SCOPE_SC diff --git a/monitoring/monitorlib/geo.py b/monitoring/monitorlib/geo.py index 9aefb6a04f..5d6c138bcb 100644 --- a/monitoring/monitorlib/geo.py +++ b/monitoring/monitorlib/geo.py @@ -1,8 +1,12 @@ +from __future__ import annotations import math from enum import Enum from typing import List, Tuple, Union, Optional + +from implicitdict import ImplicitDict import s2sphere -from implicitdict import ImplicitDict, StringBasedDateTime +import shapely.geometry +from uas_standards.astm.f3548.v21 import api as f3548v21 EARTH_CIRCUMFERENCE_KM = 40075 EARTH_CIRCUMFERENCE_M = EARTH_CIRCUMFERENCE_KM * 1000 @@ -39,11 +43,62 @@ class Radius(ImplicitDict): class Polygon(ImplicitDict): vertices: List[LatLngPoint] + @staticmethod + def from_coords(coords: List[Tuple[float, float]]) -> Polygon: + return Polygon( + vertices=[LatLngPoint(lat=lat, lng=lng) for (lat, lng) in coords] + ) + + @staticmethod + def from_latlng_rect(latlngrect: s2sphere.LatLngRect) -> Polygon: + return Polygon( + vertices=[ + LatLngPoint( + lat=latlngrect.lat_lo().degrees, lng=latlngrect.lng_lo().degrees + ), + LatLngPoint( + lat=latlngrect.lat_lo().degrees, lng=latlngrect.lng_hi().degrees + ), + LatLngPoint( + lat=latlngrect.lat_hi().degrees, lng=latlngrect.lng_hi().degrees + ), + LatLngPoint( + lat=latlngrect.lat_hi().degrees, lng=latlngrect.lng_lo().degrees + ), + ] + ) + + @staticmethod + def from_f3548v21(vol: Union[f3548v21.Polygon, dict]) -> Polygon: + if not isinstance(vol, f3548v21.Polygon) and isinstance(vol, dict): + vol = ImplicitDict.parse(vol, f3548v21.Polygon) + return Polygon( + vertices=[ImplicitDict.parse(p, LatLngPoint) for p in vol.vertices] + ) + class Circle(ImplicitDict): center: LatLngPoint radius: Radius + @staticmethod + def from_meters( + lat_degrees: float, lng_degrees: float, radius_meters: float + ) -> Circle: + return Circle( + center=LatLngPoint(lat=lat_degrees, lng=lng_degrees), + radius=Radius(value=radius_meters, units="M"), + ) + + @staticmethod + def from_f3548v21(vol: Union[f3548v21.Circle, dict]) -> Circle: + if not isinstance(vol, f3548v21.Circle) and isinstance(vol, dict): + vol = ImplicitDict.parse(vol, f3548v21.Circle) + return Circle( + center=ImplicitDict.parse(vol.center, LatLngPoint), + radius=ImplicitDict.parse(vol.radius, Radius), + ) + class AltitudeDatum(str, Enum): W84 = "W84" @@ -59,11 +114,15 @@ class Altitude(ImplicitDict): units: DistanceUnits @staticmethod - def w84m(value: Optional[float]): - if not value: + def w84m(value: Optional[float]) -> Optional[Altitude]: + if value is None: return None return Altitude(value=value, reference=AltitudeDatum.W84, units=DistanceUnits.M) + @staticmethod + def from_f3548v21(vol: Union[f3548v21.Altitude, dict]) -> Altitude: + return ImplicitDict.parse(vol, Altitude) + class Volume3D(ImplicitDict): outline_circle: Optional[Circle] = None @@ -103,13 +162,70 @@ def altitude_upper_wgs84_m(self, default_value: Optional[float] = None) -> float ) return self.altitude_upper.value + def intersects_vol3(self, vol3_2: Volume3D) -> bool: + vol3_1 = self + if vol3_1.altitude_upper.value < vol3_2.altitude_lower.value: + return False + if vol3_1.altitude_lower.value > vol3_2.altitude_upper.value: + return False + + if vol3_1.outline_circle: + circle = vol3_1.outline_circle + if circle.radius.units != "M": + raise NotImplementedError( + "Unsupported circle radius units: {}".format(circle.radius.units) + ) + ref = s2sphere.LatLng.from_degrees(circle.center.lat, circle.center.lng) + footprint1 = shapely.geometry.Point(0, 0).buffer( + vol3_1.outline_circle.radius.value + ) + elif vol3_1.outline_polygon: + p = vol3_1.outline_polygon.vertices[0] + ref = s2sphere.LatLng.from_degrees(p.lat, p.lng) + footprint1 = shapely.geometry.Polygon( + flatten(ref, s2sphere.LatLng.from_degrees(v.lat, v.lng)) + for v in vol3_1.outline_polygon.vertices + ) + else: + raise ValueError("Neither outline_circle nor outline_polygon specified") + + if vol3_2.outline_circle: + circle = vol3_2.outline_circle + if circle.radius.units != "M": + raise NotImplementedError( + "Unsupported circle radius units: {}".format(circle.radius.units) + ) + xy = flatten( + ref, s2sphere.LatLng.from_degrees(circle.center.lat, circle.center.lng) + ) + footprint2 = shapely.geometry.Point(*xy).buffer(circle.radius.value) + elif vol3_2.outline_polygon: + footprint2 = shapely.geometry.Polygon( + flatten(ref, s2sphere.LatLng.from_degrees(v.lat, v.lng)) + for v in vol3_2.outline_polygon.vertices + ) + else: + raise ValueError("Neither outline_circle nor outline_polygon specified") -class Volume4D(ImplicitDict): - """Generic representation of a 4D volume, usable across multiple standards and formats.""" + return footprint1.intersects(footprint2) - volume: Volume3D - time_start: Optional[StringBasedDateTime] = None - time_end: Optional[StringBasedDateTime] = None + @staticmethod + def from_f3548v21(vol: Union[f3548v21.Volume3D, dict]) -> Volume3D: + if not isinstance(vol, f3548v21.Volume3D) and isinstance(vol, dict): + vol = ImplicitDict.parse(vol, f3548v21.Volume3D) + kwargs = {} + if "outline_circle" in vol and vol.outline_circle: + kwargs["outline_circle"] = Circle.from_f3548v21(vol.outline_circle) + if "outline_polygon" in vol and vol.outline_polygon: + kwargs["outline_polygon"] = Polygon.from_f3548v21(vol.outline_polygon) + if "altitude_lower" in vol and vol.altitude_lower: + kwargs["altitude_lower"] = Altitude.from_f3548v21(vol.altitude_lower) + if "altitude_upper" in vol and vol.altitude_upper: + kwargs["altitude_upper"] = Altitude.from_f3548v21(vol.altitude_upper) + return Volume3D(**kwargs) + + def to_f3548v21(self) -> f3548v21.Volume3D: + return ImplicitDict.parse(self, f3548v21.Volume3D) def make_latlng_rect(area) -> s2sphere.LatLngRect: @@ -265,3 +381,7 @@ def to_vertices(self) -> List[s2sphere.LatLng]: s2sphere.LatLng.from_degrees(self.lat_max, self.lng_max), s2sphere.LatLng.from_degrees(self.lat_min, self.lng_max), ] + + +def latitude_degrees(distance_meters: float) -> float: + return 360 * distance_meters / EARTH_CIRCUMFERENCE_M diff --git a/monitoring/monitorlib/geotemporal.py b/monitoring/monitorlib/geotemporal.py index 248b56bdd7..51f4d1b861 100644 --- a/monitoring/monitorlib/geotemporal.py +++ b/monitoring/monitorlib/geotemporal.py @@ -1,14 +1,20 @@ from __future__ import annotations +import math +from ctypes import Union from datetime import datetime, timedelta from enum import Enum -from typing import Optional, List +from typing import Optional, List, Tuple import arrow from implicitdict import ImplicitDict, StringBasedTimeDelta, StringBasedDateTime from pvlib.solarposition import get_solarposition -from monitoring.monitorlib.geo import LatLngPoint -from monitoring.monitorlib.scd import Polygon, Circle, Altitude, Volume3D, Volume4D +import s2sphere as s2sphere +from uas_standards.astm.f3411.v22a.api import Polygon +from uas_standards.astm.f3548.v21 import api as f3548v21 + +from monitoring.monitorlib import geo +from monitoring.monitorlib.geo import LatLngPoint, Circle, Altitude, Volume3D class OffsetTime(ImplicitDict): @@ -139,6 +145,14 @@ def _sun_elevation(t: datetime, lat_deg: float, lng_deg: float) -> float: return get_solarposition(t, lat_deg, lng_deg).elevation.values[0] +class Time(StringBasedDateTime): + def offset(self, dt: timedelta) -> Time: + return Time(self.datetime + dt) + + def to_f3548v21(self) -> f3548v21.Time: + return f3548v21.Time(value=self) + + def resolve_time(test_time: TestTime, start_of_test: datetime) -> datetime: """Resolve TestTime into specific datetime.""" result = None @@ -221,6 +235,107 @@ def resolve_time(test_time: TestTime, start_of_test: datetime) -> datetime: return result +class Volume4D(ImplicitDict): + """Generic representation of a 4D volume, usable across multiple standards and formats.""" + + volume: Volume3D + time_start: Optional[Time] = None + time_end: Optional[Time] = None + + def offset_time(self, dt: timedelta) -> Volume4D: + kwargs = {"volume": self.volume} + if self.time_start: + kwargs["time_start"] = self.time_start.offset(dt) + if self.time_end: + kwargs["time_end"] = self.time_end.offset(dt) + return Volume4D(**kwargs) + + def intersects_vol4(self, vol4_2: Volume4D) -> bool: + vol4_1 = self + if vol4_1.time_end.datetime < vol4_2.time_start.datetime: + return False + if vol4_1.time_start.datetime > vol4_2.time_end.datetime: + return False + return self.volume.intersects_vol3(vol4_2.volume) + + @property + def rect_bounds(self) -> s2sphere.LatLngRect: + lat_min = 90 + lat_max = -90 + lng_min = 360 + lng_max = -360 + if self.volume.outline_polygon: + for v in self.volume.outline_polygon.vertices: + lat_min = min(lat_min, v.lat) + lat_max = max(lat_max, v.lat) + lng_min = min(lng_min, v.lng) + lng_max = max(lng_max, v.lng) + if self.volume.outline_circle: + circle = self.volume.outline_circle + if circle.radius.units != "M": + raise NotImplementedError( + "Unsupported circle radius units: {}".format(circle.radius.units) + ) + lat_radius = 360 * circle.radius.value / geo.EARTH_CIRCUMFERENCE_M + lng_radius = ( + 360 + * circle.radius.value + / (geo.EARTH_CIRCUMFERENCE_M * math.cos(math.radians(lat_radius))) + ) + lat_min = min(lat_min, circle.center.lat - lat_radius) + lat_max = max(lat_max, circle.center.lat + lat_radius) + lng_min = min(lng_min, circle.center.lng - lng_radius) + lng_max = max(lng_max, circle.center.lng + lng_radius) + p1 = s2sphere.LatLng.from_degrees(lat_min, lng_min) + p2 = s2sphere.LatLng.from_degrees(lat_max, lng_max) + return s2sphere.LatLngRect.from_point_pair(p1, p2) + + @staticmethod + def from_values( + t0: Optional[datetime] = None, + t1: Optional[datetime] = None, + alt0: Optional[float] = None, + alt1: Optional[float] = None, + circle: Optional[Circle] = None, + polygon: Optional[Polygon] = None, + ) -> Volume4D: + kwargs = dict() + if circle is not None: + kwargs["outline_circle"] = circle + if polygon is not None: + kwargs["outline_polygon"] = polygon + if alt0 is not None: + kwargs["altitude_lower"] = Altitude.w84m(alt0) + if alt1 is not None: + kwargs["altitude_upper"] = Altitude.w84m(alt1) + vol3 = Volume3D(**kwargs) + kwargs = {"volume": vol3} + if t0 is not None: + kwargs["time_start"] = Time(t0) + if t1 is not None: + kwargs["time_end"] = Time(t1) + return Volume4D(**kwargs) + + @staticmethod + def from_f3548v21(vol: Union[f3548v21.Volume4D, dict]) -> Volume4D: + if not isinstance(vol, f3548v21.Volume4D) and isinstance(vol, dict): + vol = ImplicitDict.parse(vol, f3548v21.Volume4D) + kwargs = {"volume": Volume3D.from_f3548v21(vol.volume)} + if "time_start" in vol and vol.time_start: + kwargs["time_start"] = Time(vol.time_start.value) + if "time_end" in vol and vol.time_end: + kwargs["time_end"] = Time(vol.time_end.value) + return Volume4D(**kwargs) + + def to_f3548v21(self) -> f3548v21.Volume4D: + kwargs = {"volume": self.volume.to_f3548v21()} + if "time_start" in self and self.time_start: + kwargs["time_start"] = self.time_start.to_f3548v21() + if "time_end" in self and self.time_end: + kwargs["time_end"] = self.time_end.to_f3548v21() + return f3548v21.Volume4D(**kwargs) + + def resolve_volume4d(template: Volume4DTemplate, start_of_test: datetime) -> Volume4D: """Resolve Volume4DTemplate into concrete Volume4D.""" # Make 3D volume @@ -274,3 +389,144 @@ def resolve_volume4d(template: Volume4DTemplate, start_of_test: datetime) -> Vol kwargs["time_end"] = time_end return Volume4D(**kwargs) + + +class Volume4DCollection(ImplicitDict): + volumes: List[Volume4D] + + @property + def time_start(self) -> Optional[Time]: + return ( + Time(value=min(v.time_start.datetime for v in self.volumes)) + if all("time_start" in v and v.time_start for v in self.volumes) + else None + ) + + @property + def time_end(self) -> Optional[Time]: + return ( + Time(value=min(v.time_end.datetime for v in self.volumes)) + if all("time_end" in v and v.time_end for v in self.volumes) + else None + ) + + def offset_times(self, dt: timedelta) -> Volume4DCollection: + return Volume4DCollection(volumes=[v.offset_time(dt) for v in self.volumes]) + + @property + def rect_bounds(self) -> s2sphere.LatLngRect: + if not self.volumes: + raise ValueError( + "Cannot compute rectangular bounds when no volumes are present" + ) + lat_min = math.inf + lat_max = -math.inf + lng_min = math.inf + lng_max = -math.inf + for vol4 in self.volumes: + if "outline_polygon" in vol4.volume and vol4.volume.outline_polygon: + for v in vol4.volume.outline_polygon.vertices: + lat_min = min(lat_min, v.lat) + lat_max = max(lat_max, v.lat) + lng_min = min(lng_min, v.lng) + lng_max = max(lng_max, v.lng) + if "outline_circle" in vol4.volume and vol4.volume.outline_circle: + circle = vol4.volume.outline_circle + if circle.radius.units != "M": + raise NotImplementedError( + "Unsupported circle radius units: {}".format( + circle.radius.units + ) + ) + lat_radius = 360 * circle.radius.value / geo.EARTH_CIRCUMFERENCE_M + lng_radius = ( + 360 + * circle.radius.value + / (geo.EARTH_CIRCUMFERENCE_M * math.cos(math.radians(lat_radius))) + ) + lat_min = min(lat_min, circle.center.lat - lat_radius) + lat_max = max(lat_max, circle.center.lat + lat_radius) + lng_min = min(lng_min, circle.center.lng - lng_radius) + lng_max = max(lng_max, circle.center.lng + lng_radius) + p1 = s2sphere.LatLng.from_degrees(lat_min, lng_min) + p2 = s2sphere.LatLng.from_degrees(lat_max, lng_max) + return s2sphere.LatLngRect.from_point_pair(p1, p2) + + @property + def bounding_volume(self) -> Volume4D: + v_min, v_max = self.meter_altitude_bounds + rect_bound = self.rect_bounds + lat_lo = rect_bound.lat_lo().degrees + lng_lo = rect_bound.lng_lo().degrees + lat_hi = rect_bound.lat_hi().degrees + lng_hi = rect_bound.lng_hi().degrees + kwargs = { + "volume": Volume3D( + altitude_lower=Altitude.w84m(v_min), + altitude_upper=Altitude.w84m(v_max), + outline_polygon=Polygon( + vertices=[ + LatLngPoint(lat=lat_lo, lng=lng_lo), + LatLngPoint(lat=lat_hi, lng=lng_lo), + LatLngPoint(lat=lat_hi, lng=lng_hi), + LatLngPoint(lat=lat_lo, lng=lng_hi), + ] + ), + ) + } + if self.time_start is not None: + kwargs["time_start"] = self.time_start + if self.time_end is not None: + kwargs["time_end"] = self.time_end + return Volume4D(**kwargs) + + @property + def meter_altitude_bounds(self) -> Tuple[float, float]: + alt_lo = min( + vol4.volume.altitude_lower.value + for vol4 in self.volumes + if "altitude_lower" in vol4.volume + ) + alt_hi = max( + vol4.volume.altitude_upper.value + for vol4 in self.volumes + if "altitude_upper" in vol4.volume + ) + units = [ + vol4.volume.altitude_lower.units + for vol4 in self.volumes + if "altitude_lower" in vol4.volume + and vol4.volume.altitude_lower.units != "M" + ] + if units: + raise NotImplementedError( + f"altitude_lower units must currently be M; found instead {', '.join(units)}" + ) + units = [ + vol4.volume.altitude_upper.units + for vol4 in self.volumes + if "altitude_upper" in vol4.volume + and vol4.volume.altitude_upper.units != "M" + ] + if units: + raise NotImplementedError( + f"altitude_upper units must currently be M; found instead {', '.join(units)}" + ) + return alt_lo, alt_hi + + def intersects_vol4s(self, vol4s_2: Volume4DCollection) -> bool: + for v1 in self.volumes: + for v2 in vol4s_2.volumes: + if v1.intersects_vol4(v2): + return True + return False + + @staticmethod + def from_f3548v21( + vol4s: List[Union[f3548v21.Volume4D, dict]] + ) -> Volume4DCollection: + volumes = [Volume4D.from_f3548v21(v) for v in vol4s] + return Volume4DCollection(volumes=volumes) + + def to_f3548v21(self) -> List[f3548v21.Volume4D]: + return [v.to_f3548v21() for v in self.volumes] diff --git a/monitoring/monitorlib/mutate/scd.py b/monitoring/monitorlib/mutate/scd.py index 378dc8ca90..179e2b29e0 100644 --- a/monitoring/monitorlib/mutate/scd.py +++ b/monitoring/monitorlib/mutate/scd.py @@ -7,6 +7,8 @@ from monitoring.monitorlib import infrastructure, scd from monitoring.monitorlib import fetch +from monitoring.monitorlib.geo import Polygon +from monitoring.monitorlib.geotemporal import Volume4D class MutatedSubscription(fetch.Query): @@ -58,13 +60,13 @@ def put_subscription( server_id: Optional[str] = None, ) -> MutatedSubscription: body = { - "extents": scd.make_vol4( + "extents": Volume4D.from_values( start_time, end_time, min_alt_m, max_alt_m, - polygon=scd.make_polygon(latlngrect=area), - ), + polygon=Polygon.from_latlng_rect(latlngrect=area), + ).to_f3548v21(), "uss_base_url": base_url, "notify_for_operational_intents": notify_for_op_intents, "notify_for_constraints": notify_for_constraints, diff --git a/monitoring/monitorlib/scd.py b/monitoring/monitorlib/scd.py index 71c0b389c1..0917fb6b88 100644 --- a/monitoring/monitorlib/scd.py +++ b/monitoring/monitorlib/scd.py @@ -7,14 +7,22 @@ import arrow import s2sphere import shapely.geometry -from uas_standards.astm.f3548.v21.api import OperationalIntentState + +from monitoring.monitorlib.geo import LatLngPoint, Radius +from uas_standards.astm.f3548.v21.api import ( + OperationalIntentState, + Polygon, + Volume4D, + Volume3D, + Time, + Altitude, + Circle, +) from monitoring.monitorlib import geo -TIME_FORMAT_CODE = "RFC3339" DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" -EARTH_CIRCUMFERENCE_M = 40.075e6 # API version 0.3.17 is programmatically identical to version 1.0.0, so both these versions can be used interchangeably. API_1_0_0 = "1.0.0" @@ -29,22 +37,6 @@ NO_OVN_PHRASES = {"", "Available from USS"} -def latitude_degrees(distance_meters: float) -> float: - return 360 * distance_meters / EARTH_CIRCUMFERENCE_M - - -def parse_time(time: Dict) -> datetime: - t_str = time["value"] - return arrow.get(t_str).datetime - - -def offset_time(vol4s: List[Dict], dt: timedelta) -> List[Dict]: - for vol4 in vol4s: - vol4["time_start"] = make_time(parse_time(vol4["time_start"]) + dt) - vol4["time_end"] = make_time(parse_time(vol4["time_end"]) + dt) - return vol4s - - class Subscription(dict): @property def valid(self) -> bool: @@ -57,392 +49,6 @@ def version(self) -> Optional[str]: return self.get("version", None) -################################################################################ -#################### Start of ASTM-standard definitions ##################### -#################### interfaces/astm-utm/Protocol/utm.yaml ##################### -################################################################################ - - -class LatLngPoint(ImplicitDict): - """A class to hold information about a location as Latitude / Longitude pair""" - - lat: float - lng: float - - -class Radius(ImplicitDict): - """A class to hold the radius of a circle for the outline_circle object""" - - value: float - units: str - - -class Polygon(ImplicitDict): - """A class to hold the polygon object, used in the outline_polygon of the Volume3D object""" - - vertices: List[LatLngPoint] # A minimum of three LatLngPoints are required - - -class Circle(ImplicitDict): - """A class the details of a circle object used in the outline_circle object""" - - center: LatLngPoint - radius: Radius - - -class AltitudeDatum(str, Enum): - W84 = "W84" - """WGS84 reference ellipsoid""" - - SFC = "SFC" - """Surface of the ground""" - - -class Altitude(ImplicitDict): - """A class to hold altitude information""" - - value: float - reference: AltitudeDatum - units: str - - -class Time(ImplicitDict): - """A class to hold Time details""" - - value: StringBasedDateTime - format: Literal["RFC3339"] - - -class Volume3D(ImplicitDict): - """A class to hold Volume3D objects""" - - outline_circle: Optional[Circle] - outline_polygon: Optional[Polygon] - altitude_lower: Optional[Altitude] - altitude_upper: Optional[Altitude] - - -class Volume4D(ImplicitDict): - """A class to hold Volume4D objects""" - - volume: Volume3D - time_start: Optional[Time] - time_end: Optional[Time] - - -class OperationalIntentReference(ImplicitDict): - id: str - manager: str - uss_availability: str - version: int - state: str - ovn: str - time_start: Time - time_end: Time - uss_base_url: str - subscription_id: str - - -class ErrorResponse(ImplicitDict): - message: str - - -class QueryOperationalIntentReferenceParameters(ImplicitDict): - area_of_interest: Volume4D - - -class QueryOperationalIntentReferenceResponse(ImplicitDict): - operational_intent_references: List[OperationalIntentReference] - - -class ImplicitSubscriptionParameters(ImplicitDict): - uss_base_url: str - notify_for_constraints: Optional[bool] - - -class PutOperationalIntentReferenceParameters(ImplicitDict): - extents: List[Volume4D] - key: List[str] - state: str - uss_base_url: str - subscription_id: Optional[str] - new_subscription: Optional[ImplicitSubscriptionParameters] - - -class SubscriptionState(ImplicitDict): - subscription_id: str - notification_index: int - - -class SubscriberToNotify(ImplicitDict): - uss_base_url: str - subscriptions: List[SubscriptionState] - - -class ChangeOperationalIntentReferenceResponse(ImplicitDict): - subscribers: List[SubscriberToNotify] - operational_intent_reference: OperationalIntentReference - - -class OperationalIntentDetails(ImplicitDict): - volumes: List[Volume4D] - off_nominal_volumes: List[Volume4D] - priority: int - - -class OperationalIntent(ImplicitDict): - reference: OperationalIntentReference - details: OperationalIntentDetails - - -class GetOperationalIntentDetailsResponse(ImplicitDict): - operational_intent: OperationalIntent - - -class PutOperationalIntentDetailsParameters(ImplicitDict): - operational_intent_id: str - operational_intent: Optional[OperationalIntent] - subscriptions: List[SubscriptionState] - - -################################################################################ -#################### End of ASTM-standard definitions ##################### -#################### interfaces/astm-utm/Protocol/utm.yaml ##################### -################################################################################ - - -class DeleteAllFlightsRequest(ImplicitDict): - extents: List[Volume4D] - - -def make_vol4( - t0: Optional[datetime] = None, - t1: Optional[datetime] = None, - alt0: Optional[float] = None, - alt1: Optional[float] = None, - circle: Dict = None, - polygon: Dict = None, -) -> Volume4D: - kwargs = dict() - if circle is not None: - kwargs["outline_circle"] = circle - if polygon is not None: - kwargs["outline_polygon"] = polygon - if alt0 is not None: - kwargs["altitude_lower"] = make_altitude(alt0) - if alt1 is not None: - kwargs["altitude_upper"] = make_altitude(alt1) - vol3 = Volume3D(**kwargs) - kwargs = {"volume": vol3} - if t0 is not None: - kwargs["time_start"] = make_time(t0) - if t1 is not None: - kwargs["time_end"] = make_time(t1) - return Volume4D(**kwargs) - - -def make_time(t: datetime) -> Time: - s = t.isoformat() - if t.tzinfo is None: - s += "Z" - return Time(value=StringBasedDateTime(s), format="RFC3339") - - -def make_altitude(alt_meters: float) -> Altitude: - return Altitude(value=alt_meters, reference="W84", units="M") - - -def make_circle(lat: float, lng: float, radius: float) -> Circle: - return Circle( - center=LatLngPoint(lat=lat, lng=lng), radius=Radius(value=radius, units="M") - ) - - -def make_polygon( - coords: List[Tuple[float, float]] = None, latlngrect: s2sphere.LatLngRect = None -) -> Polygon: - if coords is not None: - return Polygon( - vertices=[LatLngPoint(lat=lat, lng=lng) for (lat, lng) in coords] - ) - - return Polygon( - vertices=[ - LatLngPoint( - lat=latlngrect.lat_lo().degrees, lng=latlngrect.lng_lo().degrees - ), - LatLngPoint( - lat=latlngrect.lat_lo().degrees, lng=latlngrect.lng_hi().degrees - ), - LatLngPoint( - lat=latlngrect.lat_hi().degrees, lng=latlngrect.lng_hi().degrees - ), - LatLngPoint( - lat=latlngrect.lat_hi().degrees, lng=latlngrect.lng_lo().degrees - ), - ] - ) - - -def start_of(vol4s: List[Volume4D]) -> datetime: - return min([parse_time(vol4["time_start"]) for vol4 in vol4s]) - - -def end_of(vol4s: List[Volume4D]) -> datetime: - return max([parse_time(vol4["time_end"]) for vol4 in vol4s]) - - -def rect_bounds_of(vol4s: List[Volume4D]) -> s2sphere.LatLngRect: - lat_min = 90 - lat_max = -90 - lng_min = 360 - lng_max = -360 - for vol4 in vol4s: - if "outline_polygon" in vol4.volume: - for v in vol4.volume.outline_polygon.vertices: - lat_min = min(lat_min, v.lat) - lat_max = max(lat_max, v.lat) - lng_min = min(lng_min, v.lng) - lng_max = max(lng_max, v.lng) - if "outline_circle" in vol4.volume: - circle = vol4.volume.outline_circle - if circle.radius.units != "M": - raise ValueError( - "Unsupported circle radius units: {}".format(circle.radius.units) - ) - lat_radius = 360 * circle.radius.value / geo.EARTH_CIRCUMFERENCE_M - lng_radius = ( - 360 - * circle.radius.value - / (geo.EARTH_CIRCUMFERENCE_M * math.cos(math.radians(lat_radius))) - ) - lat_min = min(lat_min, circle.center.lat - lat_radius) - lat_max = max(lat_max, circle.center.lat + lat_radius) - lng_min = min(lng_min, circle.center.lng - lng_radius) - lng_max = max(lng_max, circle.center.lng + lng_radius) - p1 = s2sphere.LatLng.from_degrees(lat_min, lng_min) - p2 = s2sphere.LatLng.from_degrees(lat_max, lng_max) - return s2sphere.LatLngRect.from_point_pair(p1, p2) - - -def meter_altitude_bounds_of(vol4s: List[Volume4D]) -> Tuple[float, float]: - alt_lo = min( - vol4.volume.altitude_lower.value - for vol4 in vol4s - if "altitude_lower" in vol4.volume - ) - alt_hi = max( - vol4.volume.altitude_upper.value - for vol4 in vol4s - if "altitude_upper" in vol4.volume - ) - units = [ - vol4.volume.altitude_lower.units - for vol4 in vol4s - if "altitude_lower" in vol4.volume and vol4.volume.altitude_lower.units != "M" - ] - if units: - raise ValueError( - f"altitude_lower units must always be M; found instead {', '.join(units)}" - ) - units = [ - vol4.volume.altitude_upper.units - for vol4 in vol4s - if "altitude_upper" in vol4.volume and vol4.volume.altitude_upper.units != "M" - ] - if units: - raise ValueError( - f"altitude_upper units must always be M; found instead {', '.join(units)}" - ) - return alt_lo, alt_hi - - -def bounding_vol4(vol4s: List[Volume4D]) -> Volume4D: - t_start = start_of(vol4s) - t_end = end_of(vol4s) - v_min, v_max = meter_altitude_bounds_of(vol4s) - rect_bound = rect_bounds_of(vol4s) - lat_lo = rect_bound.lat_lo().degrees - lng_lo = rect_bound.lng_lo().degrees - lat_hi = rect_bound.lat_hi().degrees - lng_hi = rect_bound.lng_hi().degrees - return Volume4D( - time_start=make_time(t_start), - time_end=make_time(t_end), - volume=Volume3D( - altitude_lower=make_altitude(v_min), - altitude_upper=make_altitude(v_max), - outline_polygon=Polygon( - vertices=[ - LatLngPoint(lat=lat_lo, lng=lng_lo), - LatLngPoint(lat=lat_hi, lng=lng_lo), - LatLngPoint(lat=lat_hi, lng=lng_hi), - LatLngPoint(lat=lat_lo, lng=lng_hi), - ] - ), - ), - ) - - -def vol4_intersect(vol4_1: Volume4D, vol4_2: Volume4D) -> bool: - if parse_time(vol4_1.time_end) < parse_time(vol4_2.time_start): - return False - if parse_time(vol4_1.time_start) > parse_time(vol4_2.time_end): - return False - if vol4_1.volume.altitude_upper.value < vol4_2.volume.altitude_lower.value: - return False - if vol4_1.volume.altitude_lower.value > vol4_2.volume.altitude_upper.value: - return False - - if "outline_circle" in vol4_1.volume: - circle = vol4_1.volume.outline_circle - if circle.radius.units != "M": - raise ValueError( - "Unsupported circle radius units: {}".format(circle.radius.units) - ) - ref = s2sphere.LatLng.from_degrees(circle.center.lat, circle.center.lng) - footprint1 = shapely.geometry.Point(0, 0).buffer( - vol4_1.volume.outline_circle.radius.value - ) - elif "outline_polygon" in vol4_1.volume: - p = vol4_1.volume.outline_polygon.vertices[0] - ref = s2sphere.LatLng.from_degrees(p.lat, p.lng) - footprint1 = shapely.geometry.Polygon( - geo.flatten(ref, s2sphere.LatLng.from_degrees(v.lat, v.lng)) - for v in vol4_1.volume.outline_polygon.vertices - ) - else: - raise ValueError("Neither outline_circle nor outline_polygon specified") - - if "outline_circle" in vol4_2.volume: - circle = vol4_2.volume.outline_circle - if circle.radius.units != "M": - raise ValueError( - "Unsupported circle radius units: {}".format(circle.radius.units) - ) - xy = geo.flatten( - ref, s2sphere.LatLng.from_degrees(circle.center.lat, circle.center.lng) - ) - footprint2 = shapely.geometry.Point(*xy).buffer(circle.radius.value) - elif "outline_polygon" in vol4_1.volume: - footprint2 = shapely.geometry.Polygon( - geo.flatten(ref, s2sphere.LatLng.from_degrees(v.lat, v.lng)) - for v in vol4_2.volume.outline_polygon.vertices - ) - else: - raise ValueError("Neither outline_circle nor outline_polygon specified") - - return footprint1.intersects(footprint2) - - -def vol4s_intersect(vol4s_1: List[Volume4D], vol4s_2: List[Volume4D]) -> bool: - for v1 in vol4s_1: - for v2 in vol4s_2: - if vol4_intersect(v1, v2): - return True - return False - - def op_intent_transition_valid( transition_from: Optional[OperationalIntentState], transition_to: Optional[OperationalIntentState], diff --git a/monitoring/prober/run_locally.sh b/monitoring/prober/run_locally.sh index 7051a2f82f..2babedc80f 100755 --- a/monitoring/prober/run_locally.sh +++ b/monitoring/prober/run_locally.sh @@ -38,7 +38,6 @@ done OUTPUT_DIR="monitoring/prober/output" mkdir -p "$OUTPUT_DIR" -# TODO(#17): Remove F3411_22A_ALTITUDE_REFERENCE environment variable once DSS behaves correctly if ! docker run \ -u "$(id -u):$(id -g)" \ --network interop_ecosystem_network \ diff --git a/monitoring/prober/scd/test_constraint_simple.py b/monitoring/prober/scd/test_constraint_simple.py index 314e922170..fd823bcb09 100644 --- a/monitoring/prober/scd/test_constraint_simple.py +++ b/monitoring/prober/scd/test_constraint_simple.py @@ -10,6 +10,8 @@ import datetime +from monitoring.monitorlib.geo import Circle +from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib import scd from monitoring.monitorlib.scd import ( @@ -39,7 +41,9 @@ def _make_c1_request(): time_end = time_start + datetime.timedelta(minutes=60) return { "extents": [ - scd.make_vol4(time_start, time_end, 0, 120, scd.make_circle(-56, 178, 50)) + Volume4D.from_values( + time_start, time_end, 0, 120, Circle.from_meters(-56, 178, 50) + ).to_f3548v21() ], "old_version": 0, "uss_base_url": BASE_URL, @@ -77,9 +81,9 @@ def test_constraint_does_not_exist_query(ids, scd_api, scd_session): resp = scd_session.post( "/constraint_references/query", json={ - "area_of_interest": scd.make_vol4( - time_now, time_now, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + time_now, time_now, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, scope=scope, ) @@ -197,9 +201,9 @@ def test_get_constraint_by_search(ids, scd_api, scd_session): resp = scd_session.post( "/constraint_references/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, scope=scope, ) @@ -217,9 +221,9 @@ def test_get_constraint_by_search_earliest_time_included(ids, scd_api, scd_sessi resp = scd_session.post( "/constraint_references/query", json={ - "area_of_interest": scd.make_vol4( - earliest_time, None, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + earliest_time, None, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -236,9 +240,9 @@ def test_get_constraint_by_search_earliest_time_excluded(ids, scd_api, scd_sessi resp = scd_session.post( "/constraint_references/query", json={ - "area_of_interest": scd.make_vol4( - earliest_time, None, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + earliest_time, None, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -255,9 +259,9 @@ def test_get_constraint_by_search_latest_time_included(ids, scd_api, scd_session resp = scd_session.post( "/constraint_references/query", json={ - "area_of_interest": scd.make_vol4( - None, latest_time, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + None, latest_time, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -274,9 +278,9 @@ def test_get_constraint_by_search_latest_time_excluded(ids, scd_api, scd_session resp = scd_session.post( "/constraint_references/query", json={ - "area_of_interest": scd.make_vol4( - None, latest_time, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + None, latest_time, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -401,9 +405,9 @@ def test_get_deleted_constraint_by_search(ids, scd_api, scd_session): resp = scd_session.post( "/constraint_references/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content diff --git a/monitoring/prober/scd/test_constraints_with_subscriptions.py b/monitoring/prober/scd/test_constraints_with_subscriptions.py index 5a58c2ce78..e228c5081c 100644 --- a/monitoring/prober/scd/test_constraints_with_subscriptions.py +++ b/monitoring/prober/scd/test_constraints_with_subscriptions.py @@ -11,6 +11,8 @@ import datetime from typing import Dict +from monitoring.monitorlib.geo import Circle +from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib import scd from monitoring.monitorlib.scd import SCOPE_CM, SCOPE_SC, SCOPE_CP @@ -35,9 +37,13 @@ def _make_c1_request(): time_end = time_start + datetime.timedelta(minutes=60) return { "extents": [ - scd.make_vol4( - time_start, time_end, 0, 120, scd.make_circle(-12.00001, 33.99999, 50) - ) + Volume4D.from_values( + time_start, + time_end, + 0, + 120, + Circle.from_meters(-12.00001, 33.99999, 50), + ).to_f3548v21() ], "old_version": 0, "uss_base_url": CONSTRAINT_BASE_URL_1, @@ -48,9 +54,9 @@ def _make_sub_req(base_url: str, notify_ops: bool, notify_constraints: bool) -> time_start = datetime.datetime.utcnow() time_end = time_start + datetime.timedelta(minutes=60) return { - "extents": scd.make_vol4( - time_start, time_end, 0, 1000, scd.make_circle(-12, 34, 300) - ), + "extents": Volume4D.from_values( + time_start, time_end, 0, 1000, Circle.from_meters(-12, 34, 300) + ).to_f3548v21(), "old_version": 0, "uss_base_url": base_url, "notify_for_operations": notify_ops, diff --git a/monitoring/prober/scd/test_operation_references_error_cases.py b/monitoring/prober/scd/test_operation_references_error_cases.py index 692b660ccd..5a5419e4ea 100644 --- a/monitoring/prober/scd/test_operation_references_error_cases.py +++ b/monitoring/prober/scd/test_operation_references_error_cases.py @@ -7,6 +7,7 @@ import arrow import yaml +from monitoring.monitorlib.geotemporal import Volume4D, Volume4DCollection from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib import scd from monitoring.monitorlib.scd import SCOPE_SC @@ -258,8 +259,9 @@ def test_missing_conflicted_operation(ids, scd_api, scd_session): # Emplace the initial version of Operation 1 with open("./scd/resources/op_missing_initial.yaml", "r") as f: req = yaml.full_load(f) - dt = arrow.utcnow().datetime - scd.start_of(req["extents"]) - req["extents"] = scd.offset_time(req["extents"], dt) + extents = Volume4DCollection.from_f3548v21(req["extents"]) + dt = arrow.utcnow().datetime - extents.time_start.datetime + req["extents"] = extents.offset_times(dt).to_f3548v21() resp = scd_session.put( "/operational_intent_references/{}".format(ids(OP_TYPE)), json=req ) @@ -270,7 +272,8 @@ def test_missing_conflicted_operation(ids, scd_api, scd_session): # Emplace the pre-existing Operation that conflicted in the original observation with open("./scd/resources/op_missing_preexisting_unknown.yaml", "r") as f: req = yaml.full_load(f) - req["extents"] = scd.offset_time(req["extents"], dt) + extents = Volume4DCollection.from_f3548v21(req["extents"]) + req["extents"] = extents.offset_times(dt).to_f3548v21() req["key"] = [ovn1a] resp = scd_session.put( "/operational_intent_references/{}".format(ids(OP_TYPE2)), json=req @@ -280,7 +283,9 @@ def test_missing_conflicted_operation(ids, scd_api, scd_session): # Attempt to update Operation 1 without OVN for the pre-existing Operation with open("./scd/resources/op_missing_update.json", "r") as f: req = json.load(f) - req["extents"] = scd.offset_time(req["extents"], dt) + req["extents"] = ( + Volume4DCollection.from_f3548v21(req["extents"]).offset_times(dt).to_f3548v21() + ) req["key"] = [ovn1a] req["subscription_id"] = sub_id resp = scd_session.put( @@ -299,7 +304,9 @@ def test_missing_conflicted_operation(ids, scd_api, scd_session): # Perform an area-based query on the area occupied by Operation 1 with open("./scd/resources/op_missing_query.json", "r") as f: req = json.load(f) - req["area_of_interest"] = scd.offset_time([req["area_of_interest"]], dt)[0] + req["area_of_interest"] = ( + Volume4D.from_f3548v21(req["area_of_interest"]).offset_time(dt).to_f3548v21() + ) resp = scd_session.post("/operational_intent_references/query", json=req) assert resp.status_code == 200, resp.content ops = [op["id"] for op in resp.json()["operational_intent_references"]] @@ -315,8 +322,9 @@ def test_missing_conflicted_operation(ids, scd_api, scd_session): def test_big_operation_search(scd_api, scd_session): with open("./scd/resources/op_big_operation.json", "r") as f: req = json.load(f) - dt = arrow.utcnow().datetime - scd.start_of([req["area_of_interest"]]) - req["area_of_interest"] = scd.offset_time([req["area_of_interest"]], dt)[0] + aoi = Volume4D.from_f3548v21(req["area_of_interest"]) + dt = arrow.utcnow().datetime - aoi.time_start.datetime + req["area_of_interest"] = aoi.offset_time(dt).to_f3548v21() resp = scd_session.post("/operational_intent_references/query", json=req) assert resp.status_code == 400, resp.content diff --git a/monitoring/prober/scd/test_operation_simple.py b/monitoring/prober/scd/test_operation_simple.py index ea99296944..3f803ae1ce 100644 --- a/monitoring/prober/scd/test_operation_simple.py +++ b/monitoring/prober/scd/test_operation_simple.py @@ -10,6 +10,8 @@ import datetime +from monitoring.monitorlib.geo import Circle +from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib import scd from monitoring.monitorlib.scd import SCOPE_SC, SCOPE_CM, SCOPE_CP @@ -36,7 +38,9 @@ def _make_op1_request(): time_end = time_start + datetime.timedelta(minutes=60) return { "extents": [ - scd.make_vol4(time_start, time_end, 0, 120, scd.make_circle(-56, 178, 50)) + Volume4D.from_values( + time_start, time_end, 0, 120, Circle.from_meters(-56, 178, 50) + ).to_f3548v21() ], "old_version": 0, "state": "Accepted", @@ -62,9 +66,9 @@ def test_op_does_not_exist_query( resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - time_now, end_time, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + time_now, end_time, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, scope=SCOPE_SC, ) @@ -77,9 +81,9 @@ def test_op_does_not_exist_query( resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - time_now, end_time, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + time_now, end_time, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, scope=SCOPE_CP, ) @@ -89,9 +93,9 @@ def test_op_does_not_exist_query( resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - time_now, end_time, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + time_now, end_time, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, scope=SCOPE_CM, ) @@ -218,9 +222,9 @@ def test_get_op_by_search(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -236,9 +240,9 @@ def test_get_op_by_search_earliest_time_included(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - earliest_time, None, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + earliest_time, None, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -254,9 +258,9 @@ def test_get_op_by_search_earliest_time_excluded(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - earliest_time, None, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + earliest_time, None, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -272,9 +276,9 @@ def test_get_op_by_search_latest_time_included(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - None, latest_time, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + None, latest_time, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -310,9 +314,9 @@ def test_get_op_by_query_other_uss(ids, scd_session2): resp = scd_session2.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -342,9 +346,9 @@ def test_get_op_by_search_latest_time_excluded(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - None, latest_time, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + None, latest_time, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -445,9 +449,9 @@ def test_get_deleted_op_by_search(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(-56, 178, 300) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(-56, 178, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content diff --git a/monitoring/prober/scd/test_operation_simple_heavy_traffic.py b/monitoring/prober/scd/test_operation_simple_heavy_traffic.py index 7a8d6e5f16..7653e5fba9 100644 --- a/monitoring/prober/scd/test_operation_simple_heavy_traffic.py +++ b/monitoring/prober/scd/test_operation_simple_heavy_traffic.py @@ -12,11 +12,12 @@ import datetime from monitoring.monitorlib import scd +from monitoring.monitorlib.geo import Circle +from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.scd import SCOPE_SC from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib.testing import assert_datetimes_are_equal from monitoring.prober.infrastructure import ( - depends_on, for_api_versions, register_resource_type, ) @@ -39,7 +40,9 @@ def _make_op_request(idx): lat = -56 - 0.001 * idx return { "extents": [ - scd.make_vol4(time_start, time_end, 0, 120, scd.make_circle(lat, 178, 50)) + Volume4D.from_values( + time_start, time_end, 0, 120, Circle.from_meters(lat, 178, 50) + ).to_f3548v21() ], "old_version": 0, "state": "Accepted", @@ -75,9 +78,9 @@ def test_ops_do_not_exist_query(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - time_now, end_time, 0, 5000, scd.make_circle(-56, 178, 12000) - ) + "area_of_interest": Volume4D.from_values( + time_now, end_time, 0, 5000, Circle.from_meters(-56, 178, 12000) + ).to_f3548v21() }, scope=SCOPE_SC, ) @@ -140,9 +143,9 @@ def test_get_ops_by_search(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(-56, 178, 12000) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(-56, 178, 12000) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -160,9 +163,9 @@ def test_get_ops_by_search_earliest_time_included(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - earliest_time, None, 0, 5000, scd.make_circle(-56, 178, 12000) - ) + "area_of_interest": Volume4D.from_values( + earliest_time, None, 0, 5000, Circle.from_meters(-56, 178, 12000) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -179,9 +182,9 @@ def test_get_ops_by_search_earliest_time_excluded(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - earliest_time, None, 0, 5000, scd.make_circle(-56, 178, 12000) - ) + "area_of_interest": Volume4D.from_values( + earliest_time, None, 0, 5000, Circle.from_meters(-56, 178, 12000) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -196,9 +199,9 @@ def test_get_ops_by_search_latest_time_included(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - None, latest_time, 0, 5000, scd.make_circle(-56, 178, 12000) - ) + "area_of_interest": Volume4D.from_values( + None, latest_time, 0, 5000, Circle.from_meters(-56, 178, 12000) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -215,9 +218,9 @@ def test_get_ops_by_search_latest_time_excluded(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - None, latest_time, 0, 5000, scd.make_circle(-56, 178, 12000) - ) + "area_of_interest": Volume4D.from_values( + None, latest_time, 0, 5000, Circle.from_meters(-56, 178, 12000) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -302,9 +305,9 @@ def test_get_deleted_ops_by_search(ids, scd_api, scd_session): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(-56, 178, 12000) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(-56, 178, 12000) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content diff --git a/monitoring/prober/scd/test_operation_simple_heavy_traffic_concurrent.py b/monitoring/prober/scd/test_operation_simple_heavy_traffic_concurrent.py index 92621be2af..8a9d8d21e0 100644 --- a/monitoring/prober/scd/test_operation_simple_heavy_traffic_concurrent.py +++ b/monitoring/prober/scd/test_operation_simple_heavy_traffic_concurrent.py @@ -16,6 +16,8 @@ import json import inspect +from monitoring.monitorlib.geo import Circle +from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib import scd from monitoring.monitorlib.scd import SCOPE_SC @@ -66,7 +68,9 @@ def _make_op_request_differ_in_2d(idx): time_end = time_start + datetime.timedelta(minutes=60) lat = _calculate_lat(idx) - vol4 = scd.make_vol4(time_start, time_end, 0, 120, scd.make_circle(lat, 178, 50)) + vol4 = Volume4D.from_values( + time_start, time_end, 0, 120, Circle.from_meters(lat, 178, 50) + ).to_f3548v21() return _make_op_request_with_extents(vol4) @@ -80,9 +84,9 @@ def _make_op_request_differ_in_altitude(idx): alt0 = delta * idx alt1 = alt0 + delta - 1 - vol4 = scd.make_vol4( - time_start, time_end, alt0, alt1, scd.make_circle(-56, 178, 50) - ) + vol4 = Volume4D.from_values( + time_start, time_end, alt0, alt1, Circle.from_meters(-56, 178, 50) + ).to_f3548v21() return _make_op_request_with_extents(vol4) @@ -96,7 +100,9 @@ def _make_op_request_differ_in_time(idx, time_gap): ) time_end = time_start + datetime.timedelta(minutes=delta - 1) - vol4 = scd.make_vol4(time_start, time_end, 0, 120, scd.make_circle(-56, 178, 50)) + vol4 = Volume4D.from_values( + time_start, time_end, 0, 120, Circle.from_meters(-56, 178, 50) + ).to_f3548v21() return _make_op_request_with_extents(vol4) @@ -169,9 +175,9 @@ async def _get_operation_async(op_id, scd_session_async, scd_api): async def _query_operation_async(idx, scd_session_async, scd_api): lat = _calculate_lat(idx) req_json = { - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(lat, 178, 12000) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(lat, 178, 12000) + ).to_f3548v21() } async with SEMAPHORE: if scd_api == scd.API_0_3_17: diff --git a/monitoring/prober/scd/test_operations_simple.py b/monitoring/prober/scd/test_operations_simple.py index aa78d6a04a..e7292875dd 100644 --- a/monitoring/prober/scd/test_operations_simple.py +++ b/monitoring/prober/scd/test_operations_simple.py @@ -12,6 +12,8 @@ import datetime from typing import Dict, Tuple +from monitoring.monitorlib.geo import Circle, Altitude +from monitoring.monitorlib.geotemporal import Volume4D, Time from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib import scd from monitoring.monitorlib.scd import SCOPE_SC @@ -41,7 +43,9 @@ def _make_op1_request(): time_end = time_start + datetime.timedelta(minutes=60) return { "extents": [ - scd.make_vol4(time_start, time_end, 0, 120, scd.make_circle(90, 0, 200)) + Volume4D.from_values( + time_start, time_end, 0, 120, Circle.from_meters(90, 0, 200) + ).to_f3548v21() ], "old_version": 0, "state": "Accepted", @@ -55,7 +59,9 @@ def _make_op2_request(): time_end = time_start + datetime.timedelta(minutes=60) return { "extents": [ - scd.make_vol4(time_start, time_end, 0, 120, scd.make_circle(89.999, 0, 200)) + Volume4D.from_values( + time_start, time_end, 0, 120, Circle.from_meters(89.999, 0, 200) + ).to_f3548v21() ], "old_version": 0, "state": "Accepted", @@ -129,9 +135,9 @@ def test_op1_does_not_exist_query_1(ids, scd_api, scd_session, scd_session2): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - time_now, end_time, 0, 5000, scd.make_circle(89.999, 180, 300) - ) + "area_of_interest": Volume4D.from_values( + time_now, end_time, 0, 5000, Circle.from_meters(89.999, 180, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -153,9 +159,9 @@ def test_op1_does_not_exist_query_2(ids, scd_api, scd_session, scd_session2): resp = scd_session2.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - time_now, end_time, 0, 5000, scd.make_circle(89.999, 180, 300) - ) + "area_of_interest": Volume4D.from_values( + time_now, end_time, 0, 5000, Circle.from_meters(89.999, 180, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -256,9 +262,9 @@ def test_create_op2sub(ids, scd_api, scd_session, scd_session2): time_start = datetime.datetime.utcnow() time_end = time_start + datetime.timedelta(minutes=70) req = { - "extents": scd.make_vol4( - time_start, time_end, 0, 1000, scd.make_circle(89.999, 0, 250) - ), + "extents": Volume4D.from_values( + time_start, time_end, 0, 1000, Circle.from_meters(89.999, 0, 250) + ).to_f3548v21(), "uss_base_url": URL_SUB2, "notify_for_constraints": False, } @@ -367,9 +373,9 @@ def test_read_ops_from_uss1(ids, scd_api, scd_session, scd_session2): resp = scd_session.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - time_now, end_time, 0, 5000, scd.make_circle(89.999, 180, 300) - ) + "area_of_interest": Volume4D.from_values( + time_now, end_time, 0, 5000, Circle.from_meters(89.999, 180, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -399,9 +405,9 @@ def test_read_ops_from_uss2(ids, scd_api, scd_session, scd_session2): resp = scd_session2.post( "/operational_intent_references/query", json={ - "area_of_interest": scd.make_vol4( - time_now, end_time, 0, 5000, scd.make_circle(89.999, 180, 300) - ) + "area_of_interest": Volume4D.from_values( + time_now, end_time, 0, 5000, Circle.from_meters(89.999, 180, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -550,8 +556,8 @@ def test_mutate_sub2(ids, scd_api, scd_session, scd_session2): req["extents"] = req["extents"][0] del req["state"] req["notify_for_constraints"] = False - req["extents"]["time_start"] = scd.make_time(time_start) - req["extents"]["time_end"] = scd.make_time(time_end) + req["extents"]["time_start"] = Time(time_start).to_f3548v21() + req["extents"]["time_end"] = Time(time_end).to_f3548v21() req["notify_for_operational_intents"] = False resp = scd_session2.put("/subscriptions/{}".format(ids(SUB2_TYPE)), json=req) @@ -560,38 +566,38 @@ def test_mutate_sub2(ids, scd_api, scd_session, scd_session2): # Attempt mutation with start time that doesn't cover Op2 - req["extents"]["time_start"] = scd.make_time( + req["extents"]["time_start"] = Time( time_now + datetime.timedelta(minutes=5) - ) + ).to_f3548v21() resp = scd_session2.put( "/subscriptions/{}/{}".format(ids(SUB2_TYPE), sub2_version), json=req ) assert resp.status_code == 400, resp.content - req["extents"]["time_start"] = scd.make_time(time_start) + req["extents"]["time_start"] = Time(time_start).to_f3548v21() # Attempt mutation with end time that doesn't cover Op2 - req["extents"]["time_end"] = scd.make_time(time_now) + req["extents"]["time_end"] = Time(time_now).to_f3548v21() resp = scd_session2.put( "/subscriptions/{}/{}".format(ids(SUB2_TYPE), sub2_version), json=req ) assert resp.status_code == 400, resp.content - req["extents"]["time_end"] = scd.make_time(time_end) + req["extents"]["time_end"] = Time(time_end).to_f3548v21() # # Attempt mutation with minimum altitude that doesn't cover Op2 - req["extents"]["volume"]["altitude_lower"] = scd.make_altitude(10) + req["extents"]["volume"]["altitude_lower"] = Altitude.w84m(10) resp = scd_session2.put( "/subscriptions/{}/{}".format(ids(SUB2_TYPE), sub2_version), json=req ) assert resp.status_code == 400, resp.content - req["extents"]["volume"]["altitude_lower"] = scd.make_altitude(0) + req["extents"]["volume"]["altitude_lower"] = Altitude.w84m(0) # Attempt mutation with maximum altitude that doesn't cover Op2 - req["extents"]["volume"]["altitude_upper"] = scd.make_altitude(10) + req["extents"]["volume"]["altitude_upper"] = Altitude.w84m(10) resp = scd_session2.put( "/subscriptions/{}/{}".format(ids(SUB2_TYPE), sub2_version), json=req ) assert resp.status_code == 400, resp.content - req["extents"]["volume"]["altitude_upper"] = scd.make_altitude(200) + req["extents"]["volume"]["altitude_upper"] = Altitude.w84m(200) # # Attempt mutation with outline that doesn't cover Op2 old_lat = req["extents"]["volume"]["outline_circle"]["center"]["lat"] diff --git a/monitoring/prober/scd/test_subscription_queries.py b/monitoring/prober/scd/test_subscription_queries.py index a7a813047c..befa993e1d 100644 --- a/monitoring/prober/scd/test_subscription_queries.py +++ b/monitoring/prober/scd/test_subscription_queries.py @@ -6,6 +6,8 @@ import datetime +from monitoring.monitorlib.geo import latitude_degrees, Circle +from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib import scd from monitoring.monitorlib.scd import SCOPE_SC @@ -29,11 +31,11 @@ def _make_sub1_req(scd_api): time_start = datetime.datetime.utcnow() time_end = time_start + datetime.timedelta(minutes=60) - lat = LAT0 - scd.latitude_degrees(FOOTPRINT_SPACING_M) + lat = LAT0 - latitude_degrees(FOOTPRINT_SPACING_M) req = { - "extents": scd.make_vol4( - None, time_end, 0, 300, scd.make_circle(lat, LNG0, 100) - ), + "extents": Volume4D.from_values( + None, time_end, 0, 300, Circle.from_meters(lat, LNG0, 100) + ).to_f3548v21(), "uss_base_url": "https://example.com/foo", "notify_for_constraints": False, } @@ -45,9 +47,9 @@ def _make_sub2_req(scd_api): time_start = datetime.datetime.utcnow() + datetime.timedelta(hours=2) time_end = time_start + datetime.timedelta(minutes=60) req = { - "extents": scd.make_vol4( - time_start, time_end, 350, 650, scd.make_circle(LAT0, LNG0, 100) - ), + "extents": Volume4D.from_values( + time_start, time_end, 350, 650, Circle.from_meters(LAT0, LNG0, 100) + ).to_f3548v21(), "old_version": 0, "uss_base_url": "https://example.com/foo", "notify_for_operations": True, @@ -60,11 +62,11 @@ def _make_sub2_req(scd_api): def _make_sub3_req(scd_api): time_start = datetime.datetime.utcnow() + datetime.timedelta(hours=4) time_end = time_start + datetime.timedelta(minutes=60) - lat = LAT0 + scd.latitude_degrees(FOOTPRINT_SPACING_M) + lat = LAT0 + latitude_degrees(FOOTPRINT_SPACING_M) req = { - "extents": scd.make_vol4( - time_start, time_end, 700, 1000, scd.make_circle(lat, LNG0, 100) - ), + "extents": Volume4D.from_values( + time_start, time_end, 700, 1000, Circle.from_meters(lat, LNG0, 100) + ).to_f3548v21(), "uss_base_url": "https://example.com/foo", "notify_for_constraints": False, } @@ -96,9 +98,9 @@ def test_subs_do_not_exist_query(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(LAT0, LNG0, FOOTPRINT_SPACING_M) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(LAT0, LNG0, FOOTPRINT_SPACING_M) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -136,9 +138,9 @@ def test_search_find_all_subs(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 3000, scd.make_circle(LAT0, LNG0, FOOTPRINT_SPACING_M) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 3000, Circle.from_meters(LAT0, LNG0, FOOTPRINT_SPACING_M) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -152,14 +154,14 @@ def test_search_find_all_subs(ids, scd_api, scd_session): @for_api_versions(scd.API_0_3_17) @default_scope(SCOPE_SC) def test_search_footprint(ids, scd_api, scd_session): - lat = LAT0 - scd.latitude_degrees(FOOTPRINT_SPACING_M) + lat = LAT0 - latitude_degrees(FOOTPRINT_SPACING_M) print(lat) resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 3000, scd.make_circle(lat, LNG0, 50) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 3000, Circle.from_meters(lat, LNG0, 50) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -171,9 +173,9 @@ def test_search_footprint(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 3000, scd.make_circle(LAT0, LNG0, 50) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 3000, Circle.from_meters(LAT0, LNG0, 50) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -194,13 +196,13 @@ def test_search_time(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( + "area_of_interest": Volume4D.from_values( time_start, time_end, 0, 3000, - scd.make_circle(LAT0, LNG0, FOOTPRINT_SPACING_M), - ) + Circle.from_meters(LAT0, LNG0, FOOTPRINT_SPACING_M), + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -212,13 +214,13 @@ def test_search_time(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( + "area_of_interest": Volume4D.from_values( None, time_end, 0, 3000, - scd.make_circle(LAT0, LNG0, FOOTPRINT_SPACING_M), - ) + Circle.from_meters(LAT0, LNG0, FOOTPRINT_SPACING_M), + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -233,13 +235,13 @@ def test_search_time(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( + "area_of_interest": Volume4D.from_values( time_start, time_end, 0, 3000, - scd.make_circle(LAT0, LNG0, FOOTPRINT_SPACING_M), - ) + Circle.from_meters(LAT0, LNG0, FOOTPRINT_SPACING_M), + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -251,13 +253,13 @@ def test_search_time(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( + "area_of_interest": Volume4D.from_values( time_start, None, 0, 3000, - scd.make_circle(LAT0, LNG0, FOOTPRINT_SPACING_M), - ) + Circle.from_meters(LAT0, LNG0, FOOTPRINT_SPACING_M), + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -274,17 +276,17 @@ def test_search_time(ids, scd_api, scd_session): def test_search_time_footprint(ids, scd_api, scd_session): time_start = datetime.datetime.utcnow() time_end = time_start + datetime.timedelta(hours=2.5) - lat = LAT0 + scd.latitude_degrees(FOOTPRINT_SPACING_M) + lat = LAT0 + latitude_degrees(FOOTPRINT_SPACING_M) resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( + "area_of_interest": Volume4D.from_values( time_start, time_end, 0, 3000, - scd.make_circle(lat, LNG0, FOOTPRINT_SPACING_M), - ) + Circle.from_meters(lat, LNG0, FOOTPRINT_SPACING_M), + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content diff --git a/monitoring/prober/scd/test_subscription_query_time.py b/monitoring/prober/scd/test_subscription_query_time.py index 599ced0f3f..695155a826 100644 --- a/monitoring/prober/scd/test_subscription_query_time.py +++ b/monitoring/prober/scd/test_subscription_query_time.py @@ -5,6 +5,8 @@ import datetime +from monitoring.monitorlib.geo import Circle +from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib import scd from monitoring.monitorlib.scd import SCOPE_SC @@ -17,9 +19,13 @@ def _make_sub_req(time_start, time_end, alt_start, alt_end, radius, scd_api): req = { - "extents": scd.make_vol4( - time_start, time_end, alt_start, alt_end, scd.make_circle(-56, 178, radius) - ), + "extents": Volume4D.from_values( + time_start, + time_end, + alt_start, + alt_end, + Circle.from_meters(-56, 178, radius), + ).to_f3548v21(), "old_version": 0, "uss_base_url": BASE_URL, "notify_for_constraints": False, diff --git a/monitoring/prober/scd/test_subscription_simple.py b/monitoring/prober/scd/test_subscription_simple.py index 56e8fe2c31..4e1c80fafe 100644 --- a/monitoring/prober/scd/test_subscription_simple.py +++ b/monitoring/prober/scd/test_subscription_simple.py @@ -12,6 +12,10 @@ import datetime +from monitoring.monitorlib.geo import Circle +from monitoring.monitorlib.geotemporal import Volume4D +from uas_standards.astm.f3548.v21 import api + from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib import scd from monitoring.monitorlib.scd import SCOPE_SC @@ -32,9 +36,9 @@ def _make_sub1_req(scd_api): time_start = datetime.datetime.utcnow() time_end = time_start + datetime.timedelta(minutes=60) req = { - "extents": scd.make_vol4( - time_start, time_end, 0, 1000, scd.make_circle(12, -34, 300) - ), + "extents": Volume4D.from_values( + time_start, time_end, 0, 1000, Circle.from_meters(12, -34, 300) + ).to_f3548v21(), "uss_base_url": "https://example.com/foo", "notify_for_constraints": False, } @@ -48,8 +52,8 @@ def _check_sub1(data, sub_id, scd_api): data["subscription"]["notification_index"] == 0 ) assert data["subscription"]["uss_base_url"] == "https://example.com/foo" - assert data["subscription"]["time_start"]["format"] == scd.TIME_FORMAT_CODE - assert data["subscription"]["time_end"]["format"] == scd.TIME_FORMAT_CODE + assert data["subscription"]["time_start"]["format"] == api.TimeFormat.RFC3339 + assert data["subscription"]["time_end"]["format"] == api.TimeFormat.RFC3339 assert ("notify_for_constraints" not in data["subscription"]) or ( data["subscription"]["notify_for_constraints"] == False ) @@ -79,9 +83,9 @@ def test_sub_does_not_exist_query(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( - None, None, 0, 5000, scd.make_circle(12, -34, 300) - ) + "area_of_interest": Volume4D.from_values( + None, None, 0, 5000, Circle.from_meters(12, -34, 300) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content @@ -133,9 +137,9 @@ def test_get_sub_by_search(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( - time_now, time_now, 0, 120, scd.make_circle(12.00001, -34.00001, 50) - ) + "area_of_interest": Volume4D.from_values( + time_now, time_now, 0, 120, Circle.from_meters(12.00001, -34.00001, 50) + ).to_f3548v21() }, ) if resp.status_code != 200: @@ -217,9 +221,9 @@ def test_get_deleted_sub_by_search(ids, scd_api, scd_session): resp = scd_session.post( "/subscriptions/query", json={ - "area_of_interest": scd.make_vol4( - time_now, time_now, 0, 120, scd.make_circle(12.00001, -34.00001, 50) - ) + "area_of_interest": Volume4D.from_values( + time_now, time_now, 0, 120, Circle.from_meters(12.00001, -34.00001, 50) + ).to_f3548v21() }, ) assert resp.status_code == 200, resp.content diff --git a/monitoring/prober/scd/test_subscription_update_validation.py b/monitoring/prober/scd/test_subscription_update_validation.py index 55fc82d4af..964c8bd156 100644 --- a/monitoring/prober/scd/test_subscription_update_validation.py +++ b/monitoring/prober/scd/test_subscription_update_validation.py @@ -14,6 +14,8 @@ import datetime from monitoring.monitorlib import scd +from monitoring.monitorlib.geo import Circle +from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import default_scope from monitoring.monitorlib.scd import SCOPE_SC from monitoring.monitorlib.testing import assert_datetimes_are_equal @@ -35,7 +37,9 @@ def _make_op_req(): time_end = time_start + datetime.timedelta(minutes=60) return { "extents": [ - scd.make_vol4(time_start, time_end, 0, 1000, scd.make_circle(-56, 178, 500)) + Volume4D.from_values( + time_start, time_end, 0, 1000, Circle.from_meters(-56, 178, 500) + ).to_f3548v21() ], "old_version": 0, "state": "Accepted", @@ -46,9 +50,13 @@ def _make_op_req(): def _make_sub_req(time_start, time_end, alt_start, alt_end, radius, scd_api): req = { - "extents": scd.make_vol4( - time_start, time_end, alt_start, alt_end, scd.make_circle(-56, 178, radius) - ), + "extents": Volume4D.from_values( + time_start, + time_end, + alt_start, + alt_end, + Circle.from_meters(-56, 178, radius), + ).to_f3548v21(), "old_version": 0, "uss_base_url": BASE_URL, "notify_for_constraints": False, diff --git a/monitoring/uss_qualifier/resources/astm/f3548/v21/dss.py b/monitoring/uss_qualifier/resources/astm/f3548/v21/dss.py index a9e8b923ad..ed937b0694 100644 --- a/monitoring/uss_qualifier/resources/astm/f3548/v21/dss.py +++ b/monitoring/uss_qualifier/resources/astm/f3548/v21/dss.py @@ -1,21 +1,20 @@ -from datetime import datetime from typing import Tuple, List from urllib.parse import urlparse from implicitdict import ImplicitDict from monitoring.monitorlib import infrastructure, fetch -from monitoring.monitorlib.scd import ( - Volume4D, +from monitoring.monitorlib.scd import SCOPE_SC +from monitoring.uss_qualifier.resources.resource import Resource +from monitoring.uss_qualifier.resources.communications import AuthAdapterResource +from uas_standards.astm.f3548.v21.api import ( QueryOperationalIntentReferenceParameters, - SCOPE_SC, - QueryOperationalIntentReferenceResponse, + Volume4D, OperationalIntentReference, + QueryOperationalIntentReferenceResponse, OperationalIntent, GetOperationalIntentDetailsResponse, ) -from monitoring.uss_qualifier.resources.resource import Resource -from monitoring.uss_qualifier.resources.communications import AuthAdapterResource class DSSInstanceSpecification(ImplicitDict): diff --git a/monitoring/uss_qualifier/resources/flight_planning/automated_test.py b/monitoring/uss_qualifier/resources/flight_planning/automated_test.py deleted file mode 100644 index 0033ca27bf..0000000000 --- a/monitoring/uss_qualifier/resources/flight_planning/automated_test.py +++ /dev/null @@ -1,131 +0,0 @@ -from typing import Optional, List, Dict -from monitoring.monitorlib.locality import LocalityCode -from implicitdict import ( - ImplicitDict, - StringBasedTimeDelta, - StringBasedDateTime, -) -from monitoring.monitorlib.scd_automated_testing.scd_injection_api import ( - InjectFlightRequest, - Capability, -) -from monitoring.uss_qualifier.common_data_definitions import Severity - - -class KnownIssueFields(ImplicitDict): - """Information, which can be defined at the time of test design, about a problem detected by an automated test when a USS provides a response that is not same as the expected result""" - - test_code: str - """Code corresponding to check generating this issue""" - - relevant_requirements: List[str] = [] - """Requirements that this issue relates to""" - - severity: Severity - """How severe the issue is""" - - subject: Optional[str] - """Identifier of the subject of this issue, if applicable. This may be a UAS serial number, or any field or other object central to the issue.""" - - summary: str - """Human-readable summary of the issue""" - - details: str - """Human-readable description of the issue""" - - -class KnownResponses(ImplicitDict): - """Mapping of the flight injection attempt's USS response to test outcome""" - - acceptable_results: List[str] - """Acceptable values in the result data field of InjectFlightResponse. The flight injection attempt will be considered successful if the USS under test reports one of these as the result of attempting to inject the flight.""" - - incorrect_result_details: Dict[str, KnownIssueFields] - """For each case where the USS provides an InjectFlightResponse `result` value that is not in the acceptable results, this field contains information about how the Issue should be described""" - - -class InjectionTarget(ImplicitDict): - """The means to identify a particular USS within an AutomatedTest""" - - uss_role: str - """The role of the USS that is the target of a flight injection attempt (e.g., 'Querying USS'). The test executor will assign a USS from the pool of USSs to be tested to each role defined in an AutomatedTest before executing that AutomatedTest.""" - - -class FlightInjectionAttempt(ImplicitDict): - """All information necessary to attempt to create a flight in a USS and to evaluate the outcome of that attempt""" - - name: str - """Name of this flight, used to refer to the flight later in the automated test""" - - test_injection: Optional[InjectFlightRequest] - """Definition of the flight to be injected""" - - planning_time: StringBasedTimeDelta - """Time delta between the time uss_qualifier initiates this FlightInjectionAttempt and when a timestamp within the test_injection equal to reference_time occurs""" - - reference_time: StringBasedDateTime - """The time that all other times in the FlightInjectionAttempt are relative to. If this FlightInjectionAttempt is initiated by uss_qualifier at t_test, then each t_volume_original timestamp within test_injection should be adjusted to t_volume_adjusted such that t_volume_adjusted = t_test + planning_time when t_volume_original = reference_time""" - - known_responses: KnownResponses - """Details about what the USS under test should report after processing the test data""" - - injection_target: InjectionTarget - """The particular USS to which the flight injection attempt should be directed""" - - -class FlightDeletionAttempt(ImplicitDict): - """All information necessary to attempt to close a flight previously injected into a USS""" - - flight_name: str - """Name of the flight previously injected into the USS to delete""" - - -class TestStep(ImplicitDict): - """The action taken in one step of a sequence of steps constituting an automated test""" - - name: str - """Human-readable name/summary of this step""" - - inject_flight: Optional[FlightInjectionAttempt] - """If populated, the test driver should attempt to inject a flight for this step""" - - delete_flight: Optional[FlightDeletionAttempt] - """If populated, the test driver should attempt to delete the specified flight for this step""" - - -class RequiredUSSCapabilities(ImplicitDict): - capabilities: List[Capability] - """The set of capabilities a particular USS in the test must support""" - - injection_target: InjectionTarget - """The USS which must support the specified capabilities""" - - generate_issue: Optional[KnownIssueFields] = None - """If specified, generate an issue with the specified characteristics when the specified injection target does not support the specified capabilities.""" - - -class AutomatedTest(ImplicitDict): - """Definition of a complete automated test involving some subset of USSs under test""" - - name: str - """Human-readable name of this test (e.g., 'Nominal planning')""" - - uss_capabilities: Optional[List[RequiredUSSCapabilities]] = [] - """List of required USS capabilities for this test and what to do when they are not supported""" - - steps: List[TestStep] - """Actions to be performed for this test""" - - -class AutomatedTestContext(ImplicitDict): - test_id: str - """ID of test""" - - test_name: str - """Name of test""" - - locale: LocalityCode - """Locale of test""" - - targets_combination: Dict[str, str] - """Mapping of target role and target name used for this test.""" diff --git a/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py b/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py index 96aee599ab..47c3fd2615 100644 --- a/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py +++ b/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py @@ -5,7 +5,7 @@ from implicitdict import ImplicitDict from monitoring.monitorlib import infrastructure, fetch -from monitoring.monitorlib.fetch import QueryError +from monitoring.monitorlib.fetch import QueryError, Query from monitoring.monitorlib.scd import Volume4D from monitoring.monitorlib.scd_automated_testing.scd_injection_api import ( InjectFlightResult, @@ -44,13 +44,6 @@ def __init__(self, *args, **kwargs): ) -class FlightPlannerInformation(ImplicitDict): - version: str - capabilities: List[Capability] - version_query: fetch.Query - capabilities_query: fetch.Query - - class FlightPlanner: """Manages the state and the interactions with flight planner USS""" @@ -145,7 +138,7 @@ def cleanup_flight( self.created_flight_ids.remove(flight_id) return result, query - def get_target_information(self) -> FlightPlannerInformation: + def get_readiness(self) -> Tuple[Optional[str], Query]: url_status = "{}/v1/status".format(self.config.injection_base_url) version_query = fetch.query_and_describe( self.client, @@ -155,50 +148,19 @@ def get_target_information(self) -> FlightPlannerInformation: server_id=self.config.participant_id, ) if version_query.status_code != 200: - raise QueryError( + return ( f"Status query to {url_status} returned {version_query.status_code}", - [version_query], + version_query, ) try: - status_body = ImplicitDict.parse( - version_query.response.get("json", {}), StatusResponse - ) + ImplicitDict.parse(version_query.response.get("json", {}), StatusResponse) except ValueError as e: - raise QueryError( + return ( f"Status response from {url_status} could not be decoded: {str(e)}", - [version_query], + version_query, ) - version = status_body.version if status_body.version is not None else "Unknown" - url_capabilities = "{}/v1/capabilities".format(self.config.injection_base_url) - capabilities_query = fetch.query_and_describe( - self.client, - "GET", - url_capabilities, - scope=SCOPE_SCD_QUALIFIER_INJECT, - server_id=self.config.participant_id, - ) - if capabilities_query.status_code != 200: - raise QueryError( - f"Capabilities query to {url_capabilities} returned {capabilities_query.status_code}", - [version_query, capabilities_query], - ) - try: - capabilities_body = ImplicitDict.parse( - capabilities_query.response.get("json", {}), CapabilitiesResponse - ) - except ValueError as e: - raise QueryError( - f"Capabilities response from {url_capabilities} could not be decoded: {str(e)}", - [version_query], - ) - - return FlightPlannerInformation( - version=version, - capabilities=capabilities_body.capabilities, - version_query=version_query, - capabilities_query=capabilities_query, - ) + return None, version_query def clear_area(self, extent: Volume4D) -> Tuple[ClearAreaResponse, fetch.Query]: req = ClearAreaRequest(request_id=str(uuid.uuid4()), extent=extent) diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/evaluation.py b/monitoring/uss_qualifier/scenarios/astm/utm/evaluation.py index b4d9325d91..a4a2afc85d 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/evaluation.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/evaluation.py @@ -1,8 +1,9 @@ from datetime import timedelta from typing import Optional, List -from monitoring.monitorlib import scd -from monitoring.monitorlib.scd import OperationalIntentDetails, Volume4D +from monitoring.monitorlib.geotemporal import Volume4DCollection +from uas_standards.astm.f3548.v21.api import OperationalIntentDetails, Volume4D + NUMERIC_PRECISION = 0.001 @@ -24,9 +25,10 @@ def validate_op_intent_details( # Check that the USS is providing reasonable volume 4D resp_vol4s = op_intent_details.volumes + op_intent_details.off_nominal_volumes - resp_alts = scd.meter_altitude_bounds_of(resp_vol4s) - resp_start = scd.start_of(resp_vol4s) - resp_end = scd.end_of(resp_vol4s) + vol4c = Volume4DCollection.from_f3548v21(resp_vol4s) + resp_alts = vol4c.meter_altitude_bounds + resp_start = vol4c.time_start.datetime + resp_end = vol4c.time_end.datetime if resp_alts[0] > expected_extent.volume.altitude_lower.value + NUMERIC_PRECISION: errors_text.append( "Lower altitude specified by USS in operational intent details ({} m WGS84) is above the lower altitude in the injected flight ({} m WGS84)".format( diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/flight_intent_validation/flight_intent_validation.md b/monitoring/uss_qualifier/scenarios/astm/utm/flight_intent_validation/flight_intent_validation.md index 88fd9fbf9e..9d572c3d46 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/flight_intent_validation/flight_intent_validation.md +++ b/monitoring/uss_qualifier/scenarios/astm/utm/flight_intent_validation/flight_intent_validation.md @@ -26,16 +26,11 @@ FlightPlannerResource that will be tested for its validation of operational inte DSSInstanceResource that provides access to a DSS instance where flight creation/sharing can be verified. ## Setup test case -### Check for necessary capabilities test step -The USSs is queried for its capabilities to ensure this test can proceed. +### Check for flight planning readiness test step +Both USSs are queried for their readiness to ensure this test can proceed. -#### Valid responses check -If the USS does not respond appropriately to the endpoint queried to determine capability, this check will fail. - -#### Support BasicStrategicConflictDetection check -This check will fail if the flight planner does not support BasicStrategicConflictDetection per -**[astm.f3548.v21.GEN0310](../../../../requirements/astm/f3548/v21.md)** as the USS does not support the InterUSS -implementation of that requirement. +#### Flight planning USS not ready check +If the USS does not respond appropriately to the endpoint queried to determine readiness, this check will fail and the USS will have failed to meet **[astm.f3548.v21.GEN0310](../../../../requirements/astm/f3548/v21.md)** as the USS does not support the InterUSS implementation of that requirement. ### Area clearing test step The tested USS is requested to remove all flights from the area under test. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/flight_intent_validation/flight_intent_validation.py b/monitoring/uss_qualifier/scenarios/astm/utm/flight_intent_validation/flight_intent_validation.py index d2b3362585..b47add0750 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/flight_intent_validation/flight_intent_validation.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/flight_intent_validation/flight_intent_validation.py @@ -1,10 +1,9 @@ +from monitoring.monitorlib.geotemporal import Volume4DCollection +from monitoring.uss_qualifier.common_data_definitions import Severity from uas_standards.astm.f3548.v21.api import OperationalIntentState from uas_standards.astm.f3548.v21.constants import OiMaxPlanHorizonDays -from monitoring.monitorlib import scd -from monitoring.monitorlib.scd import bounding_vol4 from monitoring.monitorlib.scd_automated_testing.scd_injection_api import ( - Capability, InjectFlightResult, ) from monitoring.uss_qualifier.resources.astm.f3548.v21 import DSSInstanceResource @@ -27,7 +26,6 @@ from monitoring.uss_qualifier.scenarios.scenario import TestScenario from monitoring.uss_qualifier.scenarios.flight_planning.test_steps import ( clear_area, - check_capabilities, plan_flight_intent, cleanup_flights, activate_flight_intent, @@ -67,7 +65,9 @@ def __init__( for intent in flight_intents.values(): extents.extend(intent.request.operational_intent.volumes) extents.extend(intent.request.operational_intent.off_nominal_volumes) - self._intents_extent = bounding_vol4(extents) + self._intents_extent = Volume4DCollection.from_f3548v21( + extents + ).bounding_volume.to_f3548v21() try: ( @@ -112,9 +112,9 @@ def __init__( ), "valid_conflict_tiny_overlap must have state Accepted" time_delta = ( - scd.start_of( + Volume4DCollection.from_f3548v21( self.invalid_too_far_away.request.operational_intent.volumes - ) + ).time_start.datetime - self.invalid_too_far_away.reference_time.datetime ) assert ( @@ -134,9 +134,12 @@ def __init__( > 0 ), "invalid_activated_offnominal must have at least one off-nominal volume" - assert scd.vol4s_intersect( - self.valid_flight.request.operational_intent.volumes, - self.valid_conflict_tiny_overlap.request.operational_intent.volumes, + assert Volume4DCollection.from_f3548v21( + self.valid_flight.request.operational_intent.volumes + ).intersects_vol4s( + Volume4DCollection.from_f3548v21( + self.valid_conflict_tiny_overlap.request.operational_intent.volumes + ) ), "valid_flight and valid_conflict_tiny_overlap must intersect" except KeyError as e: @@ -181,14 +184,22 @@ def run(self): self.end_test_scenario() def _setup(self) -> bool: - if not check_capabilities( - self, - "Check for necessary capabilities", - required_capabilities=[ - ([self.tested_uss], Capability.BasicStrategicConflictDetection) - ], - ): - return False + self.begin_test_step("Check for flight planning readiness") + + error, query = self.tested_uss.get_readiness() + self.record_query(query) + with self.check( + "Flight planning USS not ready", [self.tested_uss.participant_id] + ) as check: + if error: + check.record_failed( + "Error determining readiness", + Severity.High, + "Error: " + error, + query_timestamps=[query.request.timestamp], + ) + + self.end_test_step() clear_area( self, diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_equal_priority_not_permitted/conflict_equal_priority_not_permitted.md b/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_equal_priority_not_permitted/conflict_equal_priority_not_permitted.md index b5af5767d1..4e5f4896c6 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_equal_priority_not_permitted/conflict_equal_priority_not_permitted.md +++ b/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_equal_priority_not_permitted/conflict_equal_priority_not_permitted.md @@ -52,16 +52,11 @@ DSSInstanceResource that provides access to a DSS instance where flight creation ## Setup test case -### Check for necessary capabilities test step -Both USSs are queried for their capabilities to ensure this test can proceed. +### Check for flight planning readiness test step +Both USSs are queried for their readiness to ensure this test can proceed. -#### Valid responses check -If either USS does not respond appropriately to the endpoint queried to determine capability, this check will fail. - -#### Support BasicStrategicConflictDetection check -This check will fail if the first flight planner does not support BasicStrategicConflictDetection per -**[astm.f3548.v21.GEN0310](../../../../../requirements/astm/f3548/v21.md)** as the USS does not support the InterUSS -implementation of that requirement. +#### Flight planning USS not ready check +If either USS does not respond appropriately to the endpoint queried to determine readiness, this check will fail and the USS will have failed to meet **[astm.f3548.v21.GEN0310](../../../../../requirements/astm/f3548/v21.md)** as the USS does not support the InterUSS implementation of that requirement. ### Area clearing test step Both USSs are requested to remove all flights from the area under test. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_equal_priority_not_permitted/conflict_equal_priority_not_permitted.py b/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_equal_priority_not_permitted/conflict_equal_priority_not_permitted.py index b97bea8b4d..147a0ec01e 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_equal_priority_not_permitted/conflict_equal_priority_not_permitted.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_equal_priority_not_permitted/conflict_equal_priority_not_permitted.py @@ -1,14 +1,13 @@ from typing import Optional from uas_standards.astm.f3548.v21.api import ( - OperationalIntentState, OperationalIntentReference, ) +from monitoring.monitorlib.geotemporal import Volume4DCollection +from monitoring.uss_qualifier.common_data_definitions import Severity +from uas_standards.astm.f3548.v21.api import OperationalIntentState -from monitoring.monitorlib import scd -from monitoring.monitorlib.scd import bounding_vol4 from monitoring.monitorlib.scd_automated_testing.scd_injection_api import ( - Capability, InjectFlightResult, ) from monitoring.uss_qualifier.resources.astm.f3548.v21 import DSSInstanceResource @@ -40,7 +39,6 @@ ) from monitoring.uss_qualifier.scenarios.flight_planning.test_steps import ( clear_area, - check_capabilities, plan_flight_intent, cleanup_flights, activate_flight_intent, @@ -91,7 +89,9 @@ def __init__( for intent in flight_intents.values(): extents.extend(intent.request.operational_intent.volumes) extents.extend(intent.request.operational_intent.off_nominal_volumes) - self._intents_extent = bounding_vol4(extents) + self._intents_extent = Volume4DCollection.from_f3548v21( + extents + ).bounding_volume.to_f3548v21() try: ( @@ -151,21 +151,33 @@ def __init__( self.flight_2_equal_prio_planned_time_range_B.request.operational_intent.priority == self.flight_1_planned_time_range_A.request.operational_intent.priority ), "flight_2 must have priority equal to flight_1" - assert not scd.vol4s_intersect( - self.flight_1_planned_time_range_A.request.operational_intent.volumes, - self.flight_2_equal_prio_planned_time_range_B.request.operational_intent.volumes, + assert not Volume4DCollection.from_f3548v21( + self.flight_1_planned_time_range_A.request.operational_intent.volumes + ).intersects_vol4s( + Volume4DCollection.from_f3548v21( + self.flight_2_equal_prio_planned_time_range_B.request.operational_intent.volumes + ) ), "flight_1_planned_time_range_A and flight_2_equal_prio_planned_time_range_B must not intersect" - assert not scd.vol4s_intersect( - self.flight_1_planned_time_range_A.request.operational_intent.volumes, - self.flight_1_activated_time_range_B.request.operational_intent.volumes, + assert not Volume4DCollection.from_f3548v21( + self.flight_1_planned_time_range_A.request.operational_intent.volumes + ).intersects_vol4s( + Volume4DCollection.from_f3548v21( + self.flight_1_activated_time_range_B.request.operational_intent.volumes + ) ), "flight_1_planned_time_range_A and flight_1_activated_time_range_B must not intersect" - assert scd.vol4s_intersect( - self.flight_1_activated_time_range_B.request.operational_intent.volumes, - self.flight_2_equal_prio_activated_time_range_B.request.operational_intent.volumes, + assert Volume4DCollection.from_f3548v21( + self.flight_1_activated_time_range_B.request.operational_intent.volumes + ).intersects_vol4s( + Volume4DCollection.from_f3548v21( + self.flight_2_equal_prio_activated_time_range_B.request.operational_intent.volumes + ) ), "flight_1_activated_time_range_B and flight_2_equal_prio_activated_time_range_B must intersect" - assert scd.vol4s_intersect( - self.flight_1_activated_time_range_A.request.operational_intent.volumes, - self.flight_2_equal_prio_nonconforming_time_range_A.request.operational_intent.off_nominal_volumes, + assert Volume4DCollection.from_f3548v21( + self.flight_1_activated_time_range_A.request.operational_intent.volumes + ).intersects_vol4s( + Volume4DCollection.from_f3548v21( + self.flight_2_equal_prio_nonconforming_time_range_A.request.operational_intent.off_nominal_volumes + ) ), "flight_1_activated_time_range_A.volumes and flight_2_equal_prio_nonconforming_time_range_A.off_nominal_volumes must intersect" assert ( @@ -228,17 +240,23 @@ def run(self): self.end_test_scenario() def _setup(self) -> bool: - if not check_capabilities( - self, - "Check for necessary capabilities", - required_capabilities=[ - ( - [self.tested_uss, self.control_uss], - Capability.BasicStrategicConflictDetection, - ) - ], - ): - return False + self.begin_test_step("Check for flight planning readiness") + + for uss in (self.tested_uss, self.control_uss): + error, query = uss.get_readiness() + self.record_query(query) + with self.check( + "Flight planning USS not ready", [uss.participant_id] + ) as check: + if error: + check.record_failed( + "Error determining readiness", + Severity.High, + "Error: " + error, + query_timestamps=[query.request.timestamp], + ) + + self.end_test_step() clear_area( self, diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_higher_priority/conflict_higher_priority.md b/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_higher_priority/conflict_higher_priority.md index 7b45b31345..1f7ae2a47a 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_higher_priority/conflict_higher_priority.md +++ b/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_higher_priority/conflict_higher_priority.md @@ -45,17 +45,11 @@ DSSInstanceResource that provides access to a DSS instance where flight creation ## Setup test case -### Check for necessary capabilities test step -Both USSs are queried for their capabilities to ensure this test can proceed. +### Check for flight planning readiness test step +Both USSs are queried for their readiness to ensure this test can proceed. -#### Valid responses check -If either USS does not respond appropriately to the endpoint queried to determine capability, this check will fail. - -#### Support BasicStrategicConflictDetection check -This check will fail if the first flight planner does not support BasicStrategicConflictDetection per -**[astm.f3548.v21.GEN0310](../../../../../requirements/astm/f3548/v21.md)** as the USS does not support the InterUSS -implementation of that requirement. If the second flight planner does not support HighPriorityFlights, this scenario -will end normally at this point. +#### Flight planning USS not ready check +If either USS does not respond appropriately to the endpoint queried to determine readiness, this check will fail and the USS will have failed to meet **[astm.f3548.v21.GEN0310](../../../../../requirements/astm/f3548/v21.md)** as the USS does not support the InterUSS implementation of that requirement. ### Area clearing test step Both USSs are requested to remove all flights from the area under test. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_higher_priority/conflict_higher_priority.py b/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_higher_priority/conflict_higher_priority.py index 35c0548248..5ba3d6dccb 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_higher_priority/conflict_higher_priority.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/nominal_planning/conflict_higher_priority/conflict_higher_priority.py @@ -1,15 +1,13 @@ from typing import Optional, Tuple from uas_standards.astm.f3548.v21.api import ( - OperationalIntentState, OperationalIntentReference, ) -from monitoring.monitorlib import scd -from monitoring.monitorlib.scd import bounding_vol4 -from monitoring.monitorlib.scd_automated_testing.scd_injection_api import ( - Capability, -) +from monitoring.monitorlib.geotemporal import Volume4DCollection +from monitoring.uss_qualifier.common_data_definitions import Severity +from uas_standards.astm.f3548.v21.api import OperationalIntentState + from monitoring.uss_qualifier.resources.astm.f3548.v21 import DSSInstanceResource from monitoring.uss_qualifier.resources.astm.f3548.v21.dss import DSSInstance from monitoring.uss_qualifier.resources.flight_planning import ( @@ -36,7 +34,6 @@ from monitoring.uss_qualifier.scenarios.scenario import TestScenario from monitoring.uss_qualifier.scenarios.flight_planning.test_steps import ( clear_area, - check_capabilities, plan_flight_intent, cleanup_flights, activate_flight_intent, @@ -80,7 +77,9 @@ def __init__( for intent in flight_intents.values(): extents.extend(intent.request.operational_intent.volumes) extents.extend(intent.request.operational_intent.off_nominal_volumes) - self._intents_extent = bounding_vol4(extents) + self._intents_extent = Volume4DCollection.from_f3548v21( + extents + ).bounding_volume.to_f3548v21() try: ( @@ -143,17 +142,26 @@ def __init__( self.flight_2_planned_time_range_A.request.operational_intent.priority > self.flight_1_planned_time_range_A.request.operational_intent.priority ), "flight_2 must have higher priority than flight_1" - assert scd.vol4s_intersect( - self.flight_1_planned_time_range_A.request.operational_intent.volumes, - self.flight_2_planned_time_range_A.request.operational_intent.volumes, + assert Volume4DCollection.from_f3548v21( + self.flight_1_planned_time_range_A.request.operational_intent.volumes + ).intersects_vol4s( + Volume4DCollection.from_f3548v21( + self.flight_2_planned_time_range_A.request.operational_intent.volumes + ) ), "flight_1_planned_time_range_A and flight_2_planned_time_range_A must intersect" - assert scd.vol4s_intersect( - self.flight_1_planned_time_range_A.request.operational_intent.volumes, - self.flight_1_planned_time_range_A_extended.request.operational_intent.volumes, + assert Volume4DCollection.from_f3548v21( + self.flight_1_planned_time_range_A.request.operational_intent.volumes + ).intersects_vol4s( + Volume4DCollection.from_f3548v21( + self.flight_1_planned_time_range_A_extended.request.operational_intent.volumes + ) ), "flight_1_planned_time_range_A and flight_1_planned_time_range_A_extended must intersect" - assert not scd.vol4s_intersect( - self.flight_1_planned_time_range_A.request.operational_intent.volumes, - self.flight_1_activated_time_range_B.request.operational_intent.volumes, + assert not Volume4DCollection.from_f3548v21( + self.flight_1_planned_time_range_A.request.operational_intent.volumes + ).intersects_vol4s( + Volume4DCollection.from_f3548v21( + self.flight_1_activated_time_range_B.request.operational_intent.volumes + ) ), "flight_1_planned_time_range_A and flight_1_activated_time_range_B must not intersect" except KeyError as e: @@ -208,20 +216,23 @@ def run(self): self.end_test_scenario() def _setup(self) -> bool: - if not check_capabilities( - self, - "Check for necessary capabilities", - required_capabilities=[ - ( - [self.tested_uss, self.control_uss], - Capability.BasicStrategicConflictDetection, - ) - ], - prerequisite_capabilities=[ - (self.control_uss, Capability.HighPriorityFlights) - ], - ): - return False + self.begin_test_step("Check for flight planning readiness") + + for uss in (self.tested_uss, self.control_uss): + error, query = uss.get_readiness() + self.record_query(query) + with self.check( + "Flight planning USS not ready", [uss.participant_id] + ) as check: + if error: + check.record_failed( + "Error determining readiness", + Severity.High, + "Error: " + error, + query_timestamps=[query.request.timestamp], + ) + + self.end_test_step() clear_area( self, diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py b/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py index 52aeb7e159..4199f88f55 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py @@ -3,13 +3,13 @@ from typing import List, Optional from monitoring.monitorlib import schema_validation, fetch -from uas_standards.astm.f3548.v21.api import OperationalIntentState - -from monitoring.monitorlib.scd import ( - bounding_vol4, - OperationalIntentReference, +from monitoring.monitorlib.geotemporal import Volume4DCollection +from uas_standards.astm.f3548.v21.api import ( + OperationalIntentState, Volume4D, + OperationalIntentReference, ) + from monitoring.monitorlib.scd_automated_testing.scd_injection_api import ( InjectFlightRequest, ) @@ -241,10 +241,9 @@ def expect_shared( error_text = validate_op_intent_details( oi_full.details, flight_intent.operational_intent.priority, - bounding_vol4( - flight_intent.operational_intent.volumes - + flight_intent.operational_intent.off_nominal_volumes - ), + Volume4DCollection.from_f3548v21( + flight_intent.operational_intent.volumes + flight_intent.operational_intent.off_nominal_volumes + ).bounding_volume.to_f3548v21(), ) if error_text: check.record_failed( diff --git a/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py b/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py index 9bf85ad654..9c9f48b9b1 100644 --- a/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py +++ b/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py @@ -2,10 +2,10 @@ from datetime import datetime from typing import List, Union, Optional, Tuple, Iterable, Set, Dict +from monitoring.monitorlib.geotemporal import Volume4DCollection from uas_standards.astm.f3548.v21.api import OperationalIntentState from monitoring.monitorlib.fetch import QueryError -from monitoring.monitorlib.scd import bounding_vol4 from monitoring.monitorlib.scd_automated_testing.scd_injection_api import ( InjectFlightRequest, Capability, @@ -48,7 +48,7 @@ def clear_area( for flight_intent in flight_intents: volumes += flight_intent.request.operational_intent.volumes volumes += flight_intent.request.operational_intent.off_nominal_volumes - extent = bounding_vol4(volumes) + extent = Volume4DCollection.from_f3548v21(volumes).bounding_volume.to_f3548v21() for uss in flight_planners: with scenario.check("Area cleared successfully", [uss.participant_id]) as check: try: @@ -74,149 +74,6 @@ def clear_area( scenario.end_test_step() -OneOrMoreFlightPlanners = Union[FlightPlanner, List[FlightPlanner]] -OneOrMoreCapabilities = Union[Capability, List[Capability]] - - -def check_capabilities( - scenario: TestScenarioType, - test_step: str, - required_capabilities: Optional[ - List[Tuple[OneOrMoreFlightPlanners, OneOrMoreCapabilities]] - ] = None, - prerequisite_capabilities: Optional[ - List[Tuple[OneOrMoreFlightPlanners, OneOrMoreCapabilities]] - ] = None, -) -> bool: - """Perform a check that flight planners support certain capabilities. - - This function assumes: - * `scenario` is ready to execute a test step - * If `required_capabilities` is specified: - * "Valid responses" check declared for specified test step in `scenario`'s documentation - * "Support {required_capability}" check declared for specified test in step`scenario`'s documentation - - Args: - scenario: Scenario in which this step is being executed - test_step: Name of this test step (according to scenario's documentation) - required_capabilities: The specified USSs must support these capabilities. - If a capability is not supported, a "Valid responses" failed check will - be created. - prerequisite_capabilities: If any of the specified USSs do not support - these capabilities, a "Prerequisite capabilities" note will be added and - the scenario will be indicated to stop, but no failed check will be - created. - """ - scenario.begin_test_step(test_step) - - if required_capabilities is None: - required_capabilities = [] - if prerequisite_capabilities is None: - prerequisite_capabilities = [] - - # Collect all the flight planners that need to be queried - all_flight_planners: List[FlightPlanner] = [] - for flight_planner_list in [p for p, _ in required_capabilities] + [ - p for p, _ in prerequisite_capabilities - ]: - if not isinstance(flight_planner_list, list): - flight_planner_list = [flight_planner_list] - for flight_planner in flight_planner_list: - if flight_planner not in all_flight_planners: - all_flight_planners.append(flight_planner) - - # Query all the flight planners and collect key results - flight_planner_capabilities: List[Tuple[FlightPlanner, List[Capability]]] = [] - flight_planner_capability_query_timestamps: List[ - Tuple[FlightPlanner, datetime] - ] = [] - for flight_planner in all_flight_planners: - check = scenario.check("Valid responses", [flight_planner.participant_id]) - try: - uss_info = flight_planner.get_target_information() - check.record_passed() - except QueryError as e: - for q in e.queries: - scenario.record_query(q) - check.record_failed( - summary=f"Failed to query {flight_planner.participant_id} for information", - severity=Severity.Medium, - details=f"{str(e)}\n\nStack trace:\n{e.stacktrace}", - query_timestamps=[q.request.timestamp for q in e.queries], - ) - continue - scenario.record_query(uss_info.version_query) - scenario.record_query(uss_info.capabilities_query) - flight_planner_capabilities.append((flight_planner, uss_info.capabilities)) - flight_planner_capability_query_timestamps.append( - (flight_planner, uss_info.capabilities_query.request.timestamp) - ) - - # Check for required capabilities - for flight_planners, capabilities in required_capabilities: - if not isinstance(flight_planners, list): - flight_planners = [flight_planners] - if not isinstance(capabilities, list): - capabilities = [capabilities] - for flight_planner in flight_planners: - for required_capability in capabilities: - available_capabilities = [ - c for p, c in flight_planner_capabilities if p is flight_planner - ] - if not available_capabilities: - available_capabilities = [] - else: - available_capabilities = available_capabilities[0] - with scenario.check( - f"Support {required_capability}", [flight_planner.participant_id] - ) as check: - if required_capability not in available_capabilities: - timestamp = [ - t - for p, t in flight_planner_capability_query_timestamps - if p is flight_planner - ] - if timestamp: - timestamps = [timestamp[0]] - else: - timestamps = [] - check.record_failed( - summary=f"Flight planner {flight_planner.participant_id} does not support {required_capability}", - severity=Severity.High, - details=f"Reported capabilities: ({', '.join(available_capabilities)})", - query_timestamps=timestamps, - ) - return False - - # Check for prerequisite capabilities - unsupported_prerequisites: List[str] = [] - for flight_planners, capabilities in prerequisite_capabilities: - if not isinstance(flight_planners, list): - flight_planners = [flight_planners] - if not isinstance(capabilities, list): - capabilities = [capabilities] - for flight_planner in flight_planners: - available_capabilities = [ - c for p, c in flight_planner_capabilities if p is flight_planner - ][0] - unmet_capabilities = ", ".join( - c for c in capabilities if c not in available_capabilities - ) - if unmet_capabilities: - unsupported_prerequisites.append( - f"* {flight_planner.participant_id}: {unmet_capabilities}" - ) - if unsupported_prerequisites: - scenario.record_note( - "Unsupported prerequisite capabilities", - "\n".join(unsupported_prerequisites), - ) - return False - - scenario.end_test_step() - return True - - def expect_flight_intent_state( flight_intent: InjectFlightRequest, expected_state: OperationalIntentState, diff --git a/monitoring/uss_qualifier/scenarios/uspace/flight_auth/validation.md b/monitoring/uss_qualifier/scenarios/uspace/flight_auth/validation.md index c162d7c8ff..6d52556390 100644 --- a/monitoring/uss_qualifier/scenarios/uspace/flight_auth/validation.md +++ b/monitoring/uss_qualifier/scenarios/uspace/flight_auth/validation.md @@ -22,17 +22,11 @@ FlightPlannerResource that provides the flight planner (USSP) which should be te ## Setup test case -### Check for necessary capabilities test step +### Check for flight planning readiness test step +Both USSs are queried for their readiness to ensure this test can proceed. -USSP is queried for their capabilities to ensure this test can proceed. - -#### Valid responses check - -If the USSP does not respond appropriately to the endpoint queried to determine capability, this check will fail. - -#### Support FlightAuthorisationValidation check - -If the USSP does not support FlightAuthorisationValidation, then this check will fail. +#### Flight planning USSP ready check +If the USS does not respond appropriately to the endpoint queried to determine readiness, this check will fail and the test cannot proceed. ### Area clearing test step diff --git a/monitoring/uss_qualifier/scenarios/uspace/flight_auth/validation.py b/monitoring/uss_qualifier/scenarios/uspace/flight_auth/validation.py index 98531abf61..7793d30d0a 100644 --- a/monitoring/uss_qualifier/scenarios/uspace/flight_auth/validation.py +++ b/monitoring/uss_qualifier/scenarios/uspace/flight_auth/validation.py @@ -1,9 +1,8 @@ -from typing import List, Dict +from typing import List from monitoring.monitorlib.fetch import QueryError from monitoring.monitorlib.scd_automated_testing.scd_injection_api import ( InjectFlightResult, - Capability, ) from monitoring.monitorlib.uspace import problems_with_flight_authorisation from monitoring.uss_qualifier.common_data_definitions import Severity @@ -23,7 +22,6 @@ from monitoring.uss_qualifier.scenarios.scenario import TestScenario from monitoring.uss_qualifier.scenarios.flight_planning.test_steps import ( clear_area, - check_capabilities, plan_flight_intent, cleanup_flights, ) @@ -92,14 +90,22 @@ def run(self): self.end_test_scenario() def _setup(self) -> bool: - if not check_capabilities( - self, - "Check for necessary capabilities", - required_capabilities=[ - (self.ussp, Capability.FlightAuthorisationValidation) - ], - ): - return False + self.begin_test_step("Check for flight planning readiness") + + error, query = self.ussp.get_readiness() + self.record_query(query) + with self.check( + "Flight planning USSP ready", [self.ussp.participant_id] + ) as check: + if error: + check.record_failed( + "Error determining readiness", + Severity.High, + "Error: " + error, + query_timestamps=[query.request.timestamp], + ) + + self.end_test_step() clear_area( self,