diff --git a/monitoring/monitorlib/clients/flight_planning/client_scd.py b/monitoring/monitorlib/clients/flight_planning/client_scd.py index a62569acdd..712e83a7a0 100644 --- a/monitoring/monitorlib/clients/flight_planning/client_scd.py +++ b/monitoring/monitorlib/clients/flight_planning/client_scd.py @@ -61,11 +61,6 @@ def _inject( raise NotImplementedError( f"Unsupported operator AirspaceUsageState '{usage_state}' with UasState '{uas_state}'" ) - volumes = [ - v.to_scd_automated_testing_api() - for v in flight_info.basic_information.area - ] - off_nominal_volumes = [] elif usage_state == AirspaceUsageState.InUse: if uas_state == UasState.OffNominal: state = scd_api.OperationalIntentState.Nonconforming @@ -75,15 +70,23 @@ def _inject( raise NotImplementedError( f"Unsupported operator UasState '{uas_state}' with AirspaceUsageState '{usage_state}'" ) + else: + raise NotImplementedError( + f"Unsupported combination of operator AirspaceUsageState '{usage_state}' and UasState '{uas_state}'" + ) + + if uas_state == UasState.Nominal: + volumes = [ + v.to_interuss_scd_api() + for v in flight_info.basic_information.area + ] + off_nominal_volumes = [] + else: volumes = [] off_nominal_volumes = [ v.to_scd_automated_testing_api() for v in flight_info.basic_information.area ] - else: - raise NotImplementedError( - f"Unsupported combination of operator AirspaceUsageState '{usage_state}' and UasState '{uas_state}'" - ) if "astm_f3548_21" in flight_info and flight_info.astm_f3548_21: priority = flight_info.astm_f3548_21.priority @@ -242,7 +245,7 @@ def report_readiness(self) -> TestPreparationActivityResponse: def clear_area(self, area: Volume4D) -> TestPreparationActivityResponse: req = scd_api.ClearAreaRequest( - request_id=str(uuid.uuid4()), extent=area.to_scd_automated_testing_api() + request_id=str(uuid.uuid4()), extent=area.to_interuss_scd_api() ) op = scd_api.OPERATIONS[scd_api.OperationID.ClearArea] @@ -264,7 +267,7 @@ def clear_area(self, area: Volume4D) -> TestPreparationActivityResponse: ) if resp.outcome.success: - errors = [] + errors = None else: errors = [f"[{resp.outcome.timestamp}]: {resp.outcome.message}"] diff --git a/monitoring/monitorlib/geotemporal.py b/monitoring/monitorlib/geotemporal.py index 3d38b90823..ec7d06a8b1 100644 --- a/monitoring/monitorlib/geotemporal.py +++ b/monitoring/monitorlib/geotemporal.py @@ -404,6 +404,26 @@ def resolve_volume4d(template: Volume4DTemplate, start_of_test: datetime) -> Vol class Volume4DCollection(ImplicitDict): volumes: List[Volume4D] + def __add__(self, other): + if isinstance(other, Volume4D): + return Volume4DCollection(volumes=self.volumes + [other]) + elif isinstance(other, Volume4DCollection): + return Volume4DCollection(volumes=self.volumes + other.volumes) + else: + raise NotImplementedError( + f"Cannot add {type(other).__name__} to {type(self).__name__}" + ) + + def __iadd__(self, other): + if isinstance(other, Volume4D): + self.volumes.append(other) + elif isinstance(other, Volume4DCollection): + self.volumes.extend(other.volumes) + else: + raise NotImplementedError( + f"Cannot iadd {type(other).__name__} to {type(self).__name__}" + ) + @property def time_start(self) -> Optional[Time]: return ( @@ -547,3 +567,6 @@ def from_interuss_scd_api( def to_f3548v21(self) -> List[f3548v21.Volume4D]: return [v.to_f3548v21() for v in self.volumes] + + def to_interuss_scd_api(self) -> List[interuss_scd_api.Volume4D]: + return [v.to_interuss_scd_api() for v in self.volumes] diff --git a/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py b/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py index 7b57506605..097afa0c39 100644 --- a/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py +++ b/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py @@ -5,8 +5,25 @@ from implicitdict import ImplicitDict from monitoring.monitorlib import infrastructure, fetch +from monitoring.monitorlib.clients.flight_planning.client import PlanningActivityError +from monitoring.monitorlib.clients.flight_planning.client_scd import ( + SCDFlightPlannerClient, +) +from monitoring.monitorlib.clients.flight_planning.flight_info import ( + ExecutionStyle, + FlightInfo, + BasicFlightPlanInformation, + ASTMF354821OpIntentInformation, + FlightAuthorisationData, + AirspaceUsageState, + UasState, +) +from monitoring.monitorlib.clients.flight_planning.planning import ( + PlanningActivityResult, + FlightPlanStatus, +) from monitoring.monitorlib.fetch import QueryError, Query -from monitoring.monitorlib.geotemporal import Volume4D +from monitoring.monitorlib.geotemporal import Volume4D, Volume4DCollection from uas_standards.interuss.automated_testing.scd.v1.api import ( InjectFlightResponseResult, DeleteFlightResponseResult, @@ -15,6 +32,8 @@ InjectFlightRequest, ClearAreaResponse, ClearAreaRequest, + OperationalIntentState, + ClearAreaOutcome, ) from monitoring.monitorlib.scd_automated_testing.scd_injection_api import ( SCOPE_SCD_QUALIFIER_INJECT, @@ -45,7 +64,9 @@ def __init__(self, *args, **kwargs): class FlightPlanner: - """Manages the state and the interactions with flight planner USS""" + """Manages the state and the interactions with flight planner USS. + + Note: this class will be deprecated in favor of FlightPlannerClient.""" def __init__( self, @@ -53,9 +74,10 @@ def __init__( auth_adapter: infrastructure.AuthAdapter, ): self.config = config - self.client = infrastructure.UTMClientSession( + session = infrastructure.UTMClientSession( self.config.injection_base_url, auth_adapter, config.timeout_seconds ) + self.scd_client = SCDFlightPlannerClient(session) # Flights injected by this target. self.created_flight_ids: Set[str] = set() @@ -78,114 +100,131 @@ def request_flight( request: InjectFlightRequest, flight_id: Optional[str] = None, ) -> Tuple[InjectFlightResponse, fetch.Query, str]: + usage_states = { + OperationalIntentState.Accepted: AirspaceUsageState.Planned, + OperationalIntentState.Activated: AirspaceUsageState.InUse, + OperationalIntentState.Nonconforming: AirspaceUsageState.InUse, + OperationalIntentState.Contingent: AirspaceUsageState.InUse, + } + uas_states = { + OperationalIntentState.Accepted: UasState.Nominal, + OperationalIntentState.Activated: UasState.Nominal, + OperationalIntentState.Nonconforming: UasState.OffNominal, + OperationalIntentState.Contingent: UasState.Contingent, + } + if request.operational_intent.state in (OperationalIntentState.Accepted, OperationalIntentState.Activated) and request.operational_intent.off_nominal_volumes: + # This invalid request can no longer be represented with a standard flight planning request; reject it at the client level instead + raise ValueError(f"Request for nominal {request.operational_intent.state} operational intent is invalid because it contains off-nominal volumes") + v4c = Volume4DCollection.from_interuss_scd_api( + request.operational_intent.volumes + ) + Volume4DCollection.from_interuss_scd_api( + request.operational_intent.off_nominal_volumes + ) + basic_information = BasicFlightPlanInformation( + usage_state=usage_states[request.operational_intent.state], + uas_state=uas_states[request.operational_intent.state], + area=v4c.volumes, + ) + astm_f3548v21 = ASTMF354821OpIntentInformation( + priority=request.operational_intent.priority + ) + uspace_flight_authorisation = ImplicitDict.parse( + request.flight_authorisation, FlightAuthorisationData + ) + flight_info = FlightInfo( + basic_information=basic_information, + astm_f3548_21=astm_f3548v21, + uspace_flight_authorisation=uspace_flight_authorisation, + ) + if not flight_id: flight_id = str(uuid.uuid4()) - url = "{}/v1/flights/{}".format(self.config.injection_base_url, flight_id) - - query = fetch.query_and_describe( - self.client, - "PUT", - url, - json=request, - scope=SCOPE_SCD_QUALIFIER_INJECT, - server_id=self.config.participant_id, - ) - if query.status_code != 200: - raise QueryError( - f"Inject flight query to {url} returned {query.status_code}", [query] - ) - try: - result = ImplicitDict.parse( - query.response.get("json", {}), InjectFlightResponse - ) - except ValueError as e: - raise QueryError( - f"Inject flight response from {url} could not be decoded: {str(e)}", - [query], + try: + resp = self.scd_client.try_plan_flight( + flight_info, ExecutionStyle.IfAllowed + ) + except PlanningActivityError as e: + raise QueryError(str(e), e.queries) + else: + try: + resp = self.scd_client.try_update_flight( + flight_id, flight_info, ExecutionStyle.IfAllowed + ) + except PlanningActivityError as e: + raise QueryError(str(e), e.queries) + + if resp.activity_result == PlanningActivityResult.Failed: + result = InjectFlightResponseResult.Failed + elif resp.activity_result == PlanningActivityResult.NotSupported: + result = InjectFlightResponseResult.NotSupported + elif resp.activity_result == PlanningActivityResult.Rejected: + result = InjectFlightResponseResult.Rejected + elif resp.activity_result == PlanningActivityResult.Completed: + if resp.flight_plan_status == FlightPlanStatus.Planned: + result = InjectFlightResponseResult.Planned + elif resp.flight_plan_status == FlightPlanStatus.OkToFly: + result = InjectFlightResponseResult.ReadyToFly + elif resp.flight_plan_status == FlightPlanStatus.OffNominal: + result = InjectFlightResponseResult.ReadyToFly + else: + raise NotImplementedError( + f"Unable to handle '{resp.flight_plan_status}' FlightPlanStatus with {resp.activity_result} PlanningActivityResult" + ) + self.created_flight_ids.add(flight_id) + else: + raise NotImplementedError( + f"Unable to handle '{resp.activity_result}' PlanningActivityResult" ) - if result.result == InjectFlightResponseResult.Planned: - self.created_flight_ids.add(flight_id) + response = InjectFlightResponse( + result=result, + operational_intent_id="", + ) - return result, query, flight_id + return response, resp.queries[0], flight_id def cleanup_flight( self, flight_id: str ) -> Tuple[DeleteFlightResponse, fetch.Query]: - url = "{}/v1/flights/{}".format(self.config.injection_base_url, flight_id) - query = fetch.query_and_describe( - self.client, - "DELETE", - url, - scope=SCOPE_SCD_QUALIFIER_INJECT, - server_id=self.config.participant_id, - ) - if query.status_code != 200: - raise QueryError( - f"Delete flight query to {url} returned {query.status_code}", [query] - ) try: - result = ImplicitDict.parse( - query.response.get("json", {}), DeleteFlightResponse - ) - except ValueError as e: - raise QueryError( - f"Delete flight response from {url} could not be decoded: {str(e)}", - [query], - ) - - if result.result == DeleteFlightResponseResult.Closed: + resp = self.scd_client.try_end_flight(flight_id, ExecutionStyle.IfAllowed) + except PlanningActivityError as e: + raise QueryError(str(e), e.queries) + + if ( + resp.activity_result == PlanningActivityResult.Completed + and resp.flight_plan_status == FlightPlanStatus.Closed + ): self.created_flight_ids.remove(flight_id) - return result, query - - 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, - "GET", - url_status, - scope=SCOPE_SCD_QUALIFIER_INJECT, - server_id=self.config.participant_id, - ) - if version_query.status_code != 200: return ( - f"Status query to {url_status} returned {version_query.status_code}", - version_query, + DeleteFlightResponse(result=DeleteFlightResponseResult.Closed), + resp.queries[0], ) - try: - ImplicitDict.parse(version_query.response.get("json", {}), StatusResponse) - except ValueError as e: + else: return ( - f"Status response from {url_status} could not be decoded: {str(e)}", - version_query, + DeleteFlightResponse(result=DeleteFlightResponseResult.Failed), + resp.queries[0], ) - return None, version_query + def get_readiness(self) -> Tuple[Optional[str], Query]: + try: + resp = self.scd_client.report_readiness() + except PlanningActivityError as e: + return str(e), e.queries[0] + return None, resp.queries[0] def clear_area(self, extent: Volume4D) -> Tuple[ClearAreaResponse, fetch.Query]: - req = ClearAreaRequest( - request_id=str(uuid.uuid4()), extent=extent.to_f3548v21() - ) - url = f"{self.config.injection_base_url}/v1/clear_area_requests" - query = fetch.query_and_describe( - self.client, - "POST", - url, - scope=SCOPE_SCD_QUALIFIER_INJECT, - json=req, - server_id=self.config.participant_id, - ) - if query.status_code != 200: - raise QueryError( - f"Clear area query to {url} returned {query.status_code}", [query] - ) try: - result = ImplicitDict.parse( - query.response.get("json", {}), ClearAreaResponse - ) - except ValueError as e: - raise QueryError( - f"Clear area response from {url} could not be decoded: {str(e)}", - [query], - ) - return result, query + resp = self.scd_client.clear_area(extent) + except PlanningActivityError as e: + raise QueryError(str(e), e.queries) + success = False if resp.errors else True + return ( + ClearAreaResponse( + outcome=ClearAreaOutcome( + success=success, + timestamp=resp.queries[0].response.reported, + ) + ), + resp.queries[0], + ) diff --git a/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py b/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py index 502a7f2eff..69a1ccfcc8 100644 --- a/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py +++ b/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py @@ -65,7 +65,7 @@ def clear_area( check.record_failed( summary="Area could not be cleared", severity=Severity.High, - details=f'Participant indicated "{resp.outcome.message}"', + details=f'Participant indicated "{resp.outcome.message}"' if "message" in resp.outcome else "See query", query_timestamps=[query.request.timestamp], ) @@ -385,7 +385,7 @@ def cleanup_flights( else: check.record_failed( summary="Failed to delete flight", - details=f"USS indicated: {resp.notes}", + details=f"USS indicated: {resp.notes}" if "notes" in resp else "See query", severity=Severity.Medium, query_timestamps=[query.request.timestamp], )