diff --git a/monitoring/mock_uss/riddp/routes_observation.py b/monitoring/mock_uss/riddp/routes_observation.py index 1220e04359..d4a1f3b5e6 100644 --- a/monitoring/mock_uss/riddp/routes_observation.py +++ b/monitoring/mock_uss/riddp/routes_observation.py @@ -94,7 +94,7 @@ def riddp_display_data() -> Tuple[str, int]: t = arrow.utcnow().datetime isa_list: FetchedISAs = fetch.isas(view, t, t, rid_version, utm_client) if not isa_list.success: - msg = f"Error fetching ISAs from DSS: {isa_list.error}" + msg = f"Error fetching ISAs from DSS: {isa_list.errors}" logger.error(msg) response = ErrorResponse(message=msg) response["fetched_isas"] = isa_list diff --git a/monitoring/mock_uss/ridsp/routes_injection.py b/monitoring/mock_uss/ridsp/routes_injection.py index ede444ba2d..c4bdc05228 100644 --- a/monitoring/mock_uss/ridsp/routes_injection.py +++ b/monitoring/mock_uss/ridsp/routes_injection.py @@ -17,7 +17,7 @@ from monitoring.mock_uss.ridsp import utm_client from . import database from .database import db - +from monitoring.monitorlib import geo require_config_value(KEY_BASE_URL) require_config_value(KEY_RID_VERSION) @@ -64,7 +64,9 @@ def ridsp_create_test(test_id: str) -> Tuple[str, int]: f"Unable to determine base URL for RID version {rid_version}" ) mutated_isa = mutate.put_isa( - area=rect, + area_vertices=geo.get_latlngrect_vertices(rect), + alt_lo=0, + alt_hi=3048, start_time=t0, end_time=t1, uss_base_url=uss_base_url, diff --git a/monitoring/mock_uss/tracer/context.py b/monitoring/mock_uss/tracer/context.py index b427c21c30..1e9f4bee21 100644 --- a/monitoring/mock_uss/tracer/context.py +++ b/monitoring/mock_uss/tracer/context.py @@ -14,7 +14,7 @@ TASK_POLL_CONSTRAINTS, ) from monitoring.mock_uss.tracer.config import KEY_RID_VERSION -from monitoring.monitorlib import ids, versioning +from monitoring.monitorlib import ids, versioning, geo from monitoring.monitorlib import fetch import monitoring.monitorlib.fetch.rid import monitoring.monitorlib.fetch.scd @@ -141,7 +141,9 @@ def _subscribe_rid(resources: ResourceSet, uss_base_url: str) -> None: _clear_existing_rid_subscription(resources, "old") create_result = mutate.rid.upsert_subscription( - area=resources.area, + area_vertices=geo.get_latlngrect_vertices(resources.area), + alt_lo=0, + alt_hi=3048, start_time=resources.start_time, end_time=resources.end_time, uss_base_url=uss_base_url, diff --git a/monitoring/monitorlib/fetch/rid.py b/monitoring/monitorlib/fetch/rid.py index 9a4d824ce0..ca2f2d7e81 100644 --- a/monitoring/monitorlib/fetch/rid.py +++ b/monitoring/monitorlib/fetch/rid.py @@ -12,7 +12,7 @@ import yaml from yaml.representer import Representer -from monitoring.monitorlib import fetch, rid_v1 +from monitoring.monitorlib import fetch, rid_v1, rid_v2, geo from monitoring.monitorlib.fetch import Query from monitoring.monitorlib.infrastructure import UTMClientSession from monitoring.monitorlib.rid import RIDVersion @@ -346,6 +346,14 @@ def query(self) -> Query: def status_code(self): return self.query.status_code + @property + def success(self) -> bool: + return not self.errors + + @property + def errors(self) -> List[str]: + raise NotImplementedError("RIDQuery.errors must be overriden") + class FetchedISAs(RIDQuery): """Version-independent representation of a list of F3411 identification service areas.""" @@ -369,33 +377,37 @@ def _v22a_response( ) @property - def error(self) -> Optional[str]: + def errors(self) -> List[str]: # Overall errors if self.status_code != 200: - return f"Failed to search ISAs in DSS ({self.status_code})" + return [f"Failed to search ISAs in DSS ({self.status_code})"] if self.query.response.json is None: - return "DSS response to search ISAs did not contain valid JSON" + return ["DSS response to search ISAs did not contain valid JSON"] if self.rid_version == RIDVersion.f3411_19: try: if not self._v19_response: - return "Unknown error with F3411-19 SearchIdentificationServiceAreasResponse" + return [ + "Unknown error with F3411-19 SearchIdentificationServiceAreasResponse" + ] except ValueError as e: - return f"Error parsing F3411-19 DSS SearchIdentificationServiceAreasResponse: {str(e)}" + return [ + f"Error parsing F3411-19 DSS SearchIdentificationServiceAreasResponse: {str(e)}" + ] if self.rid_version == RIDVersion.f3411_22a: try: if not self._v22a_response: - return "Unknown error with F3411-22a SearchIdentificationServiceAreasResponse" + return [ + "Unknown error with F3411-22a SearchIdentificationServiceAreasResponse" + ] except ValueError as e: - return f"Error parsing F3411-22a DSS SearchIdentificationServiceAreasResponse: {str(e)}" - - return None + return [ + f"Error parsing F3411-22a DSS SearchIdentificationServiceAreasResponse: {str(e)}" + ] - @property - def success(self) -> bool: - return self.error is None + return [] @property def isas(self) -> Dict[str, ISA]: @@ -424,7 +436,7 @@ def flights_urls(self) -> Dict[str, str]: def has_different_content_than(self, other: Any) -> bool: if not isinstance(other, FetchedISAs): return True - if self.error != other.error: + if self.errors != other.errors: return True if self.rid_version != other.rid_version: return True @@ -451,7 +463,7 @@ def isas( t1 = rid_version.format_time(end_time) if rid_version == RIDVersion.f3411_19: op = v19.api.OPERATIONS[v19.api.OperationID.SearchIdentificationServiceAreas] - area = rid_v1.geo_polygon_string(rid_v1.vertices_from_latlng_rect(box)) + area = rid_v1.geo_polygon_string_from_s2(geo.get_latlngrect_vertices(box)) url = f"{dss_base_url}{op.path}?area={area}&earliest_time={t0}&latest_time={t1}" return FetchedISAs( v19_query=fetch.query_and_describe( @@ -460,7 +472,7 @@ def isas( ) elif rid_version == RIDVersion.f3411_22a: op = v22a.api.OPERATIONS[v22a.api.OperationID.SearchIdentificationServiceAreas] - area = rid_v1.geo_polygon_string(rid_v1.vertices_from_latlng_rect(box)) + area = rid_v2.geo_polygon_string_from_s2(geo.get_latlngrect_vertices(box)) url = f"{dss_base_url}{op.path}?area={area}&earliest_time={t0}&latest_time={t1}" return FetchedISAs( v22a_query=fetch.query_and_describe( @@ -494,10 +506,6 @@ def _v22a_response( v22a.api.GetFlightsResponse, ) - @property - def success(self) -> bool: - return not self.errors - @property def errors(self) -> List[str]: if self.status_code != 200: @@ -611,10 +619,6 @@ def _v22a_response( v22a.api.GetFlightDetailsResponse, ) - @property - def success(self) -> bool: - return not self.errors - @property def errors(self) -> List[str]: if self.status_code != 200: @@ -700,14 +704,10 @@ class FetchedFlights(ImplicitDict): uss_flight_queries: Dict[str, FetchedUSSFlights] uss_flight_details_queries: Dict[str, FetchedUSSFlightDetails] - @property - def success(self): - return not self.errors - @property def errors(self) -> List[str]: if not self.dss_isa_query.success: - return ["Failed to obtain ISAs: " + self.dss_isa_query.error] + return self.dss_isa_query.errors result = [] for flights in self.uss_flight_queries.values(): result.extend(flights.errors) @@ -785,14 +785,6 @@ def _v22a_response( v22a.api.GetSubscriptionResponse, ) - @property - def id(self) -> str: - return self.raw.id - - @property - def success(self) -> bool: - return not self.errors - @property def errors(self) -> List[str]: if self.status_code == 404: diff --git a/monitoring/monitorlib/fetch/summarize.py b/monitoring/monitorlib/fetch/summarize.py index f02d66d3d9..7a55d807ea 100644 --- a/monitoring/monitorlib/fetch/summarize.py +++ b/monitoring/monitorlib/fetch/summarize.py @@ -35,7 +35,7 @@ def isas(fetched: rid.FetchedISAs) -> Dict: isa_key = "{} ({})".format(isa.id, isa.owner) summary[isa.flights_url][isa_key] = isa_summary else: - summary["error"] = fetched.error + summary["error"] = fetched.errors return summary diff --git a/monitoring/monitorlib/geo.py b/monitoring/monitorlib/geo.py index 19d8e9d454..2e0c692f8b 100644 --- a/monitoring/monitorlib/geo.py +++ b/monitoring/monitorlib/geo.py @@ -90,3 +90,13 @@ def bounding_rect(latlngs: List[Tuple[float, float]]) -> s2sphere.LatLngRect: def get_latlngrect_diagonal_km(rect: s2sphere.LatLngRect) -> float: """Compute the distance in km between two opposite corners of the rect""" return rect.lo().get_distance(rect.hi()).degrees * EARTH_CIRCUMFERENCE_KM / 360 + + +def get_latlngrect_vertices(rect: s2sphere.LatLngRect) -> List[s2sphere.LatLng]: + """Returns the rect as a list of vertices""" + return [ + s2sphere.LatLng.from_angles(lat=rect.lat_lo(), lng=rect.lng_lo()), + s2sphere.LatLng.from_angles(lat=rect.lat_lo(), lng=rect.lng_hi()), + s2sphere.LatLng.from_angles(lat=rect.lat_hi(), lng=rect.lng_hi()), + s2sphere.LatLng.from_angles(lat=rect.lat_hi(), lng=rect.lng_lo()), + ] diff --git a/monitoring/monitorlib/mutate/rid.py b/monitoring/monitorlib/mutate/rid.py index 106890ba36..5eb935bd99 100644 --- a/monitoring/monitorlib/mutate/rid.py +++ b/monitoring/monitorlib/mutate/rid.py @@ -36,10 +36,6 @@ def _v22a_response(self) -> v22a.api.PutSubscriptionResponse: v22a.api.PutSubscriptionResponse, ) - @property - def success(self) -> bool: - return not self.errors - @property def errors(self) -> List[str]: if self.status_code != 200: @@ -82,7 +78,9 @@ def subscription(self) -> Optional[Subscription]: def upsert_subscription( - area: s2sphere.LatLngRect, + area_vertices: List[s2sphere.LatLng], + alt_lo: float, + alt_hi: float, start_time: datetime.datetime, end_time: datetime.datetime, uss_base_url: str, @@ -94,15 +92,13 @@ def upsert_subscription( mutation = "create" if subscription_version is None else "update" if rid_version == RIDVersion.f3411_19: body = { - "extents": { - "spatial_volume": { - "footprint": {"vertices": rid_v1.vertices_from_latlng_rect(area)}, - "altitude_lo": 0, - "altitude_hi": 3048, - }, - "time_start": start_time.strftime(rid_v1.DATE_FORMAT), - "time_end": end_time.strftime(rid_v1.DATE_FORMAT), - }, + "extents": rid_v1.make_volume_4d( + area_vertices, + alt_lo, + alt_hi, + start_time, + end_time, + ), "callbacks": { "identification_service_area_url": uss_base_url + v19.api.OPERATIONS[ @@ -124,15 +120,13 @@ def upsert_subscription( ) elif rid_version == RIDVersion.f3411_22a: body = { - "extents": { - "volume": { - "outline_polygon": rid_v2.make_polygon_outline(area), - "altitude_lower": rid_v2.make_altitude(0), - "altitude_upper": rid_v2.make_altitude(3048), - }, - "time_start": rid_v2.make_time(start_time), - "time_end": rid_v2.make_time(end_time), - }, + "extents": rid_v2.make_volume_4d( + area_vertices, + alt_lo, + alt_hi, + start_time, + end_time, + ), "uss_base_url": uss_base_url, } if subscription_version is None: @@ -191,9 +185,11 @@ class ISAChangeNotification(RIDQuery): """Version-independent representation of response to a USS notification following an ISA change in the DSS.""" @property - def success(self) -> bool: + def errors(self) -> List[str]: # Tolerate not-strictly-correct 200 response - return self.status_code == 204 or self.status_code == 200 + if self.status_code != 204 and self.status_code != 200: + return ["Failed to notify ({})".format(self.status_code)] + return [] class SubscriberToNotify(ImplicitDict): @@ -299,10 +295,6 @@ def _v22a_response( v22a.api.PutIdentificationServiceAreaResponse, ) - @property - def success(self) -> bool: - return not self.errors - @property def errors(self) -> List[str]: if self.status_code != 200: @@ -375,7 +367,9 @@ class ISAChange(ImplicitDict): def put_isa( - area: s2sphere.LatLngRect, + area_vertices: List[s2sphere.LatLng], + alt_lo: float, + alt_hi: float, start_time: datetime.datetime, end_time: datetime.datetime, uss_base_url: str, @@ -387,15 +381,13 @@ def put_isa( mutation = "create" if isa_version is None else "update" if rid_version == RIDVersion.f3411_19: body = { - "extents": { - "spatial_volume": { - "footprint": {"vertices": rid_v1.vertices_from_latlng_rect(area)}, - "altitude_lo": 0, - "altitude_hi": 3048, - }, - "time_start": start_time.strftime(rid_v1.DATE_FORMAT), - "time_end": end_time.strftime(rid_v1.DATE_FORMAT), - }, + "extents": rid_v1.make_volume_4d( + area_vertices, + alt_lo, + alt_hi, + start_time, + end_time, + ), "flights_url": uss_base_url + v19.api.OPERATIONS[v19.api.OperationID.SearchFlights].path, } @@ -413,15 +405,13 @@ def put_isa( ) elif rid_version == RIDVersion.f3411_22a: body = { - "extents": { - "volume": { - "outline_polygon": rid_v2.make_polygon_outline(area), - "altitude_lower": rid_v2.make_altitude(0), - "altitude_upper": rid_v2.make_altitude(3048), - }, - "time_start": rid_v2.make_time(start_time), - "time_end": rid_v2.make_time(end_time), - }, + "extents": rid_v2.make_volume_4d( + area_vertices, + alt_lo, + alt_hi, + start_time, + end_time, + ), "uss_base_url": uss_base_url, } if isa_version is None: diff --git a/monitoring/monitorlib/rid_v1.py b/monitoring/monitorlib/rid_v1.py index ea23d4a188..60780ce1d9 100644 --- a/monitoring/monitorlib/rid_v1.py +++ b/monitoring/monitorlib/rid_v1.py @@ -1,5 +1,9 @@ from typing import Dict, List, Optional import s2sphere +import datetime + +from uas_standards.astm.f3411.v19.api import Volume4D +from implicitdict import ImplicitDict, StringBasedDateTime DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" @@ -11,13 +15,34 @@ def geo_polygon_string(vertices: List[Dict[str, float]]) -> str: return ",".join("{},{}".format(v["lat"], v["lng"]) for v in vertices) -def vertices_from_latlng_rect(rect: s2sphere.LatLngRect) -> List[Dict[str, float]]: - return [ - {"lat": rect.lat_lo().degrees, "lng": rect.lng_lo().degrees}, - {"lat": rect.lat_lo().degrees, "lng": rect.lng_hi().degrees}, - {"lat": rect.lat_hi().degrees, "lng": rect.lng_hi().degrees}, - {"lat": rect.lat_hi().degrees, "lng": rect.lng_lo().degrees}, - ] +def geo_polygon_string_from_s2(vertices: List[s2sphere.LatLng]) -> str: + return ",".join("{},{}".format(v.lat().degrees, v.lng().degrees) for v in vertices) + + +def make_volume_4d( + vertices: List[s2sphere.LatLng], + alt_lo: float, + alt_hi: float, + start_time: datetime.datetime, + end_time: datetime.datetime, +) -> Volume4D: + return ImplicitDict.parse( + { + "spatial_volume": { + "footprint": { + "vertices": [ + {"lat": vertex.lat().degrees, "lng": vertex.lng().degrees} + for vertex in vertices + ] + }, + "altitude_lo": alt_lo, + "altitude_hi": alt_hi, + }, + "time_start": StringBasedDateTime(start_time), + "time_end": StringBasedDateTime(end_time), + }, + Volume4D, + ) class ISA(dict): diff --git a/monitoring/monitorlib/rid_v2.py b/monitoring/monitorlib/rid_v2.py index 3285e3d481..fbad7f5947 100644 --- a/monitoring/monitorlib/rid_v2.py +++ b/monitoring/monitorlib/rid_v2.py @@ -1,29 +1,60 @@ import datetime +from typing import List import s2sphere -from uas_standards.astm.f3411.v22a.api import Time, Altitude, Polygon, LatLngPoint +from implicitdict import ImplicitDict, StringBasedDateTime +from uas_standards.astm.f3411.v22a.api import ( + Time, + Altitude, + LatLngPoint, + Volume4D, +) from . import rid_v1 as rid_v1 def make_time(t: datetime.datetime) -> Time: - return Time(format="RFC3339", value=t.strftime(DATE_FORMAT)) + return Time(format="RFC3339", value=StringBasedDateTime(t)) def make_altitude(altitude_meters: float) -> Altitude: return Altitude(reference="W84", units="M", value=altitude_meters) -def make_polygon_outline(area: s2sphere.LatLngRect) -> Polygon: - return Polygon( - vertices=[ - LatLngPoint(lat=area.lat_lo().degrees, lng=area.lng_lo().degrees), - LatLngPoint(lat=area.lat_lo().degrees, lng=area.lng_hi().degrees), - LatLngPoint(lat=area.lat_hi().degrees, lng=area.lng_hi().degrees), - LatLngPoint(lat=area.lat_hi().degrees, lng=area.lng_lo().degrees), - ] +def make_lat_lng_point(lat: float, lng: float) -> LatLngPoint: + return LatLngPoint(lat=lat, lng=lng) + + +def make_lat_lng_point_from_s2(point: s2sphere.LatLng) -> LatLngPoint: + return make_lat_lng_point(point.lat().degrees, point.lng().degrees) + + +def make_volume_4d( + vertices: List[s2sphere.LatLng], + alt_lo: float, + alt_hi: float, + start_time: datetime.datetime, + end_time: datetime.datetime, +) -> Volume4D: + return ImplicitDict.parse( + { + "volume": { + "outline_polygon": { + "vertices": [ + {"lat": vertex.lat().degrees, "lng": vertex.lng().degrees} + for vertex in vertices + ], + }, + "altitude_lower": make_altitude(alt_lo), + "altitude_upper": make_altitude(alt_hi), + }, + "time_start": make_time(start_time), + "time_end": make_time(end_time), + }, + Volume4D, ) DATE_FORMAT = rid_v1.DATE_FORMAT geo_polygon_string = rid_v1.geo_polygon_string +geo_polygon_string_from_s2 = rid_v1.geo_polygon_string_from_s2