diff --git a/monitoring/mock_uss/docker-compose.yaml b/monitoring/mock_uss/docker-compose.yaml index e154166d09..e462d0be7d 100644 --- a/monitoring/mock_uss/docker-compose.yaml +++ b/monitoring/mock_uss/docker-compose.yaml @@ -227,7 +227,7 @@ services: - MOCK_USS_PUBLIC_KEY=/var/test-certs/auth2.pem - MOCK_USS_TOKEN_AUDIENCE=scdsc.log.uss6.localutm,localhost,host.docker.internal - MOCK_USS_BASE_URL=http://scdsc.log.uss6.localutm - - MOCK_USS_SERVICES=scdsc,interaction_logging + - MOCK_USS_SERVICES=scdsc,interaction_logging,flight_planning - MOCK_USS_INTERACTIONS_LOG_DIR=output/scdsc_interaction_logs - MOCK_USS_PORT=80 expose: diff --git a/monitoring/mock_uss/start_all_local_mocks.sh b/monitoring/mock_uss/start_all_local_mocks.sh index 8fb4e0d4df..e48ae74b20 100755 --- a/monitoring/mock_uss/start_all_local_mocks.sh +++ b/monitoring/mock_uss/start_all_local_mocks.sh @@ -27,3 +27,4 @@ monitoring/mock_uss/wait_for_mock_uss.sh mock_uss_riddp_v19 monitoring/mock_uss/wait_for_mock_uss.sh mock_uss_ridsp monitoring/mock_uss/wait_for_mock_uss.sh mock_uss_riddp monitoring/mock_uss/wait_for_mock_uss.sh mock_uss_tracer +monitoring/mock_uss/wait_for_mock_uss.sh mock_uss_scdsc_interaction_log diff --git a/monitoring/monitorlib/clients/flight_planning/client.py b/monitoring/monitorlib/clients/flight_planning/client.py index 2e670bee66..0975b0eda4 100644 --- a/monitoring/monitorlib/clients/flight_planning/client.py +++ b/monitoring/monitorlib/clients/flight_planning/client.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List, Optional, Union +from typing import Optional, Set from monitoring.monitorlib.clients.flight_planning.test_preparation import ( TestPreparationActivityResponse, @@ -15,6 +15,7 @@ ) from monitoring.monitorlib.fetch import QueryError from monitoring.monitorlib.geotemporal import Volume4D +from monitoring.uss_qualifier.configurations.configuration import ParticipantID class PlanningActivityError(QueryError): @@ -24,6 +25,13 @@ class PlanningActivityError(QueryError): class FlightPlannerClient(ABC): """Client to interact with a USS as a user performing flight planning activities and as the test director preparing for tests involving flight planning activities.""" + participant_id: ParticipantID + created_flight_ids: Set[FlightID] + + def __init__(self, participant_id: ParticipantID): + self.participant_id = participant_id + self.created_flight_ids: Set[FlightID] = set() + # ===== Emulation of user actions ===== @abstractmethod diff --git a/monitoring/monitorlib/clients/flight_planning/client_scd.py b/monitoring/monitorlib/clients/flight_planning/client_scd.py index f19c7eb5c9..499e23ff27 100644 --- a/monitoring/monitorlib/clients/flight_planning/client_scd.py +++ b/monitoring/monitorlib/clients/flight_planning/client_scd.py @@ -26,6 +26,7 @@ from monitoring.monitorlib.fetch import query_and_describe from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import UTMClientSession +from monitoring.uss_qualifier.configurations.configuration import ParticipantID class SCDFlightPlannerClient(FlightPlannerClient): @@ -33,7 +34,8 @@ class SCDFlightPlannerClient(FlightPlannerClient): _session: UTMClientSession _plan_statuses: Dict[FlightID, FlightPlanStatus] - def __init__(self, session: UTMClientSession): + def __init__(self, session: UTMClientSession, participant_id: ParticipantID): + super(SCDFlightPlannerClient, self).__init__(participant_id=participant_id) self._session = session self._plan_statuses = {} @@ -120,6 +122,16 @@ def _inject( scd_api.InjectFlightResponseResult.NotSupported: old_state, }[resp.result], ) + + created_status = [ + FlightPlanStatus.Planned, + FlightPlanStatus.OkToFly, + FlightPlanStatus.OffNominal, + ] + if response.activity_result == PlanningActivityResult.Completed: + if response.flight_plan_status in created_status: + self.created_flight_ids.add(flight_id) + self._plan_statuses[flight_id] = response.flight_plan_status return response @@ -183,6 +195,8 @@ def try_end_flight( ) if resp.result == scd_api.DeleteFlightResponseResult.Closed: del self._plan_statuses[flight_id] + self.created_flight_ids.discard(flight_id) + else: self._plan_statuses[flight_id] = response.flight_plan_status return response diff --git a/monitoring/monitorlib/clients/flight_planning/client_v1.py b/monitoring/monitorlib/clients/flight_planning/client_v1.py index 5e2c5be4fb..8f37cf77c6 100644 --- a/monitoring/monitorlib/clients/flight_planning/client_v1.py +++ b/monitoring/monitorlib/clients/flight_planning/client_v1.py @@ -1,16 +1,12 @@ -import json import uuid from typing import Optional - from implicitdict import ImplicitDict from monitoring.monitorlib.clients.flight_planning.client import ( FlightPlannerClient, - PlanningActivityError, ) from monitoring.monitorlib.clients.flight_planning.test_preparation import ( TestPreparationActivityResponse, ) - from monitoring.monitorlib.clients.flight_planning.flight_info import ( FlightInfo, FlightID, @@ -23,18 +19,21 @@ from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.infrastructure import UTMClientSession from monitoring.uss_qualifier.configurations.configuration import ParticipantID - +from monitoring.monitorlib.clients.flight_planning.client import PlanningActivityError +from monitoring.monitorlib.clients.flight_planning.planning import ( + PlanningActivityResult, + FlightPlanStatus, +) from uas_standards.interuss.automated_testing.flight_planning.v1 import api from uas_standards.interuss.automated_testing.flight_planning.v1.constants import Scope class V1FlightPlannerClient(FlightPlannerClient): _session: UTMClientSession - _participant_id: ParticipantID def __init__(self, session: UTMClientSession, participant_id: ParticipantID): + super(V1FlightPlannerClient, self).__init__(participant_id=participant_id) self._session = session - self._participant_id = participant_id def _inject( self, @@ -61,7 +60,7 @@ def _inject( url, json=req, scope=Scope.Plan, - participant_id=self._participant_id, + participant_id=self.participant_id, query_type=QueryType.InterUSSFlightPlanningV1UpsertFlightPlan, ) if query.status_code != 200 and query.status_code != 201: @@ -78,6 +77,15 @@ def _inject( f"Response to plan flight could not be parsed: {str(e)}", query ) + created_status = [ + FlightPlanStatus.Planned, + FlightPlanStatus.OkToFly, + FlightPlanStatus.OffNominal, + ] + if resp.planning_result == PlanningActivityResult.Completed: + if resp.flight_plan_status in created_status: + self.created_flight_ids.add(flight_plan_id) + response = PlanningActivityResponse( flight_id=flight_plan_id, queries=[query], @@ -122,7 +130,7 @@ def try_end_flight( op.verb, url, scope=Scope.Plan, - participant_id=self._participant_id, + participant_id=self.participant_id, query_type=QueryType.InterUSSFlightPlanningV1DeleteFlightPlan, ) if query.status_code != 200: @@ -138,7 +146,7 @@ def try_end_flight( raise PlanningActivityError( f"Response to delete flight plan could not be parsed: {str(e)}", query ) - + self.created_flight_ids.discard(flight_id) response = PlanningActivityResponse( flight_id=flight_id, queries=[query], @@ -154,7 +162,7 @@ def report_readiness(self) -> TestPreparationActivityResponse: op.verb, op.path, scope=Scope.DirectAutomatedTest, - participant_id=self._participant_id, + participant_id=self.participant_id, query_type=QueryType.InterUSSFlightPlanningV1GetStatus, ) if query.status_code != 200: @@ -192,7 +200,7 @@ def clear_area(self, area: Volume4D) -> TestPreparationActivityResponse: op.path, json=req, scope=Scope.DirectAutomatedTest, - participant_id=self._participant_id, + participant_id=self.participant_id, query_type=QueryType.InterUSSFlightPlanningV1ClearArea, ) if query.status_code != 200: @@ -208,7 +216,6 @@ def clear_area(self, area: Volume4D) -> TestPreparationActivityResponse: raise PlanningActivityError( f"Response to clear area could not be parsed: {str(e)}", query ) - if resp.outcome.success: errors = None else: diff --git a/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource.py b/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource.py index 9566191ae3..bffc95ffbc 100644 --- a/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource.py +++ b/monitoring/uss_qualifier/resources/flight_planning/flight_intents_resource.py @@ -1,4 +1,3 @@ -import json from typing import Dict from implicitdict import ImplicitDict diff --git a/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py b/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py index 9fe7d80c7f..0b28f7cf79 100644 --- a/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py +++ b/monitoring/uss_qualifier/resources/flight_planning/flight_planner.py @@ -1,7 +1,6 @@ from typing import Tuple, Optional, Set from urllib.parse import urlparse from implicitdict import ImplicitDict - from monitoring.monitorlib import infrastructure, fetch from monitoring.monitorlib.clients.flight_planning.client import ( PlanningActivityError, @@ -79,7 +78,7 @@ def to_client( session = infrastructure.UTMClientSession( self.scd_injection_base_url, auth_adapter, self.timeout_seconds ) - return SCDFlightPlannerClient(session) + return SCDFlightPlannerClient(session, self.participant_id) elif "v1_base_url" in self and self.v1_base_url: session = infrastructure.UTMClientSession( self.v1_base_url, auth_adapter, self.timeout_seconds @@ -123,7 +122,6 @@ def request_flight( self, request: InjectFlightRequest, flight_id: Optional[str] = None, - additional_fields: Optional[dict] = None, ) -> Tuple[InjectFlightResponse, fetch.Query, str]: usage_states = { OperationalIntentState.Accepted: AirspaceUsageState.Planned, @@ -171,7 +169,7 @@ def request_flight( if not flight_id: try: resp = self.client.try_plan_flight( - flight_info, ExecutionStyle.IfAllowed, additional_fields + flight_info, ExecutionStyle.IfAllowed ) except PlanningActivityError as e: raise QueryError(str(e), e.queries) diff --git a/monitoring/uss_qualifier/resources/interuss/mock_uss/client.py b/monitoring/uss_qualifier/resources/interuss/mock_uss/client.py index 02c9be1a3b..15c192d72e 100644 --- a/monitoring/uss_qualifier/resources/interuss/mock_uss/client.py +++ b/monitoring/uss_qualifier/resources/interuss/mock_uss/client.py @@ -4,6 +4,10 @@ from implicitdict import ImplicitDict from monitoring.monitorlib import fetch +from monitoring.monitorlib.clients.flight_planning.client import FlightPlannerClient +from monitoring.monitorlib.clients.flight_planning.client_v1 import ( + V1FlightPlannerClient, +) from monitoring.monitorlib.clients.mock_uss.locality import ( GetLocalityResponse, PutLocalityRequest, @@ -24,22 +28,28 @@ from typing import Tuple, List from implicitdict import StringBasedDateTime - MOCK_USS_CONFIG_SCOPE = "interuss.mock_uss.configure" class MockUSSClient(object): """Means to communicate with an InterUSS mock_uss instance""" + flight_planner: FlightPlannerClient + def __init__( self, participant_id: str, base_url: str, auth_adapter: AuthAdapter, + timeout_seconds: Optional[float] = None, ): self.base_url = base_url - self.session = UTMClientSession(base_url, auth_adapter) + self.session = UTMClientSession(base_url, auth_adapter, timeout_seconds) self.participant_id = participant_id + v1_base_url = base_url + "/flight_planning/v1" + self.flight_planner = V1FlightPlannerClient( + UTMClientSession(v1_base_url, auth_adapter, timeout_seconds), participant_id + ) def get_status(self) -> fetch.Query: return fetch.query_and_describe( @@ -77,7 +87,9 @@ def set_locality(self, locality_code: LocalityCode) -> fetch.Query: # TODO: Add other methods to interact with the mock USS in other ways (like starting/stopping message signing data collection) - def get_interactions(self, from_time: StringBasedDateTime) -> List[Interaction]: + def get_interactions( + self, from_time: StringBasedDateTime + ) -> Tuple[List[Interaction], fetch.Query]: """ Requesting interuss interactions from mock_uss from a given time till now Args: @@ -108,7 +120,8 @@ def get_interactions(self, from_time: StringBasedDateTime) -> List[Interaction]: msg=f"RecordedInteractionsResponse from mock_uss response contained invalid JSON: {str(e)}", queries=[query], ) - return response.interactions + + return response.interactions, query class MockUSSSpecification(ImplicitDict): @@ -124,6 +137,9 @@ class MockUSSSpecification(ImplicitDict): participant_id: ParticipantID """Test participant responsible for this mock USS.""" + timeout_seconds: Optional[float] = None + """Number of seconds to allow for requests to this mock_uss instance. If None, use default.""" + class MockUSSResource(Resource[MockUSSSpecification]): mock_uss: MockUSSClient @@ -137,6 +153,7 @@ def __init__( specification.participant_id, specification.mock_uss_base_url, auth_adapter.adapter, + specification.timeout_seconds, ) diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/get_op_data_validation.md b/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/get_op_data_validation.md index 3277dd0075..f6c3c67eb8 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/get_op_data_validation.md +++ b/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/get_op_data_validation.md @@ -78,7 +78,7 @@ No notification pushed by control_uss to tested_uss, will ensure that tested_uss while planning a nearby flight. If a notification is sent to tested_uss, the precondition for running this scenario will not be satisfied. -### [Test_uss attempts to plan flight 1, expect failure test step](test_steps/plan_flight_intent_expect_failed.md) +### [Tested_uss attempts to plan flight 1, expect failure test step](test_steps/plan_flight_intent_expect_failed.md) The test driver attempts to plan the flight 1 via the tested_uss. It checks if any conflicts with flight 2 which is of equal priority and came first. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/get_op_data_validation.py b/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/get_op_data_validation.py index 6fa47e5464..14536879b0 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/get_op_data_validation.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/get_op_data_validation.py @@ -1,38 +1,49 @@ from typing import Optional -from loguru import logger +from monitoring.monitorlib.temporal import Time +import arrow +from monitoring.monitorlib.clients.flight_planning.client import FlightPlannerClient 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 ( FlightIntentsResource, ) -from monitoring.uss_qualifier.resources.flight_planning.flight_intent import ( - FlightIntent, -) -from monitoring.uss_qualifier.resources.flight_planning.flight_planner import ( - FlightPlanner, -) from monitoring.uss_qualifier.resources.flight_planning.flight_planners import ( FlightPlannerResource, ) - -from monitoring.uss_qualifier.scenarios.scenario import ( - TestScenario, -) +from monitoring.monitorlib.geotemporal import Volume4DCollection +from monitoring.monitorlib.clients.flight_planning.flight_info import FlightInfo from monitoring.uss_qualifier.resources.interuss.mock_uss.client import ( MockUSSClient, MockUSSResource, ) +from monitoring.uss_qualifier.scenarios.astm.utm.data_exchange_validation.test_steps.invalid_op_test_steps import ( + plan_flight_intent_expect_failed, +) +from monitoring.uss_qualifier.scenarios.astm.utm.test_steps import OpIntentValidator + +from monitoring.monitorlib.clients.mock_uss.mock_uss_scd_injection_api import ( + MockUssFlightBehavior, +) +from monitoring.uss_qualifier.scenarios.scenario import ( + TestScenario, + ScenarioCannotContinueError, +) +from monitoring.uss_qualifier.scenarios.flight_planning.test_steps import ( + cleanup_flights_fp_client, + plan_flight, + delete_flight, +) class GetOpResponseDataValidationByUSS(TestScenario): - flight_1: FlightIntent - - flight_2: FlightIntent + flight_1: FlightInfo + flight_2: FlightInfo - tested_uss: FlightPlanner + tested_uss_client: FlightPlannerClient control_uss: MockUSSClient + control_uss_client: FlightPlannerClient dss: DSSInstance def __init__( @@ -43,16 +54,186 @@ def __init__( flight_intents: Optional[FlightIntentsResource] = None, ): super().__init__() - self.tested_uss = tested_uss.flight_planner + self.tested_uss_client = tested_uss.client self.control_uss = control_uss.mock_uss + self.control_uss_client = control_uss.mock_uss.flight_planner self.dss = dss.dss + if not flight_intents: + msg = f"No FlightIntentsResource was provided as input to this test, it is assumed that the jurisdiction of the tested USS ({self.tested_uss.config.participant_id}) does not allow any same priority conflicts, execution of the scenario was stopped without failure" + self.record_note( + "Jurisdiction of tested USS does not allow any same priority conflicts", + msg, + ) + raise ScenarioCannotContinueError(msg) + + t = Time(arrow.utcnow().datetime) + _flight_intents = { + k: v.resolve(t) for k, v in flight_intents.get_flight_intents().items() + } + + extents = [] + for intent in _flight_intents.values(): + extents.append(intent.basic_information.area.bounding_volume) + self._intents_extent = Volume4DCollection(extents).bounding_volume.to_f3548v21() + + try: + (self.flight_1, self.flight_2,) = ( + _flight_intents["flight_1"], + _flight_intents["flight_2"], + ) + + assert not self.flight_1.basic_information.area.intersects_vol4s( + self.flight_2.basic_information.area + ), "flight_1 and flight_2 must not intersect" + + except KeyError as e: + raise ValueError( + f"`{self.me()}` TestScenario requirements for flight_intents not met: missing flight intent {e}" + ) + except AssertionError as e: + raise ValueError( + f"`{self.me()}` TestScenario requirements for flight_intents not met: {e}" + ) + def run(self, context): self.begin_test_scenario(context) - pass + + self.record_note( + "Tested USS", + f"{self.tested_uss_client.participant_id}", + ) + self.record_note( + "Control USS", + f"{self.control_uss_client.participant_id}", + ) + + self.begin_test_case("Successfully plan flight near an existing flight") + self._tested_uss_plans_deconflicted_flight_near_existing_flight() + self.end_test_case() + + self.begin_test_case("Flight planning prevented due to invalid data sharing") + self._tested_uss_unable_to_plan_flight_near_invalid_shared_existing_flight() + self.end_test_case() + self.end_test_scenario() + def _tested_uss_plans_deconflicted_flight_near_existing_flight(self): + + with OpIntentValidator( + self, + self.control_uss_client, + self.dss, + "Validate flight 2 sharing", + self._intents_extent, + ) as validator: + _, self.flight_2_id = plan_flight( + self, + "Control_uss plans flight 2", + self.control_uss_client, + self.flight_2, + ) + + validator.expect_shared(self.flight_2) + + self.begin_test_step( + "Precondition - check tested_uss has no subscription in flight 2 area" + ) + # ToDo - Add the test step details + self.end_test_step() + + with OpIntentValidator( + self, + self.tested_uss_client, + self.dss, + "Validate flight 1 sharing", + self._intents_extent, + ) as validator: + _, self.flight_1_id = plan_flight( + self, + "Tested_uss plans flight 1", + self.tested_uss_client, + self.flight_1, + ) + + validator.expect_shared( + self.flight_1, + ) + + self.begin_test_step("Validate flight2 GET interaction") + # ToDo - Add the test step details + self.end_test_step() + + self.begin_test_step("Validate flight1 Notification sent to Control_uss") + # ToDo - Add the test step details + self.end_test_step() + + delete_flight( + self, "Delete tested_uss flight", self.tested_uss_client, self.flight_1_id + ) + delete_flight( + self, "Delete control_uss flight", self.control_uss_client, self.flight_2_id + ) + + def _tested_uss_unable_to_plan_flight_near_invalid_shared_existing_flight(self): + flight_info = self.flight_2 + # Modifying the request with invalid data + behavior = MockUssFlightBehavior( + modify_sharing_methods=["GET", "POST"], + modify_fields={ + "reference": {"state": "Flying"}, + "details": {"priority": -1}, + }, + ) + + additional_fields = {"behavior": behavior} + + _, self.flight_2_id = plan_flight( + self, + "Control_uss plans flight 2, sharing invalid operational intent data", + self.control_uss_client, + flight_info, + additional_fields, + ) + + self.begin_test_step( + "Validate flight 2 shared operational intent with invalid data" + ) + # ToDo - Add the test step details + self.end_test_step() + + self.begin_test_step( + "Precondition - check tested_uss has no subscription in flight 2 area" + ) + # ToDo - Add the test step details + self.end_test_step() + + _, self.flight_1_id = plan_flight_intent_expect_failed( + self, + "Tested_uss attempts to plan flight 1, expect failure", + self.tested_uss_client, + self.flight_1, + ) + + self.begin_test_step("Validate flight 1 not shared by tested_uss") + # ToDo - Add the test step details + self.end_test_step() + + self.begin_test_step("Validate flight 2 GET interaction") + # ToDo - Add the test step details + self.end_test_step() + + self.begin_test_step("Validate flight 1 Notification not sent to Control_uss") + # ToDo - Add the test step details + self.end_test_step() + + delete_flight( + self, "Delete Control_uss flight", self.control_uss_client, self.flight_2_id + ) + def cleanup(self): self.begin_cleanup() - pass + cleanup_flights_fp_client( + self, (self.control_uss_client, self.tested_uss_client) + ), self.end_cleanup() diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/test_steps/invalid_op_test_steps.py b/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/test_steps/invalid_op_test_steps.py new file mode 100644 index 0000000000..4dd4ba7e3b --- /dev/null +++ b/monitoring/uss_qualifier/scenarios/astm/utm/data_exchange_validation/test_steps/invalid_op_test_steps.py @@ -0,0 +1,53 @@ +from typing import Optional, Tuple +from implicitdict import ImplicitDict +from uas_standards.astm.f3548.v21.api import ( + OperationalIntentState, + OperationalIntentReference, + GetOperationalIntentDetailsResponse, +) +from loguru import logger +from uas_standards.interuss.automated_testing.scd.v1.api import ( + InjectFlightRequest, + InjectFlightResponse, +) +from monitoring.uss_qualifier.common_data_definitions import Severity +from monitoring.uss_qualifier.resources.flight_planning.flight_planner import ( + FlightPlannerClient, +) +from monitoring.uss_qualifier.scenarios.astm.utm.test_steps import OpIntentValidator +from monitoring.uss_qualifier.scenarios.flight_planning.test_steps import ( + submit_flight, + expect_flight_intent_state, +) +from monitoring.uss_qualifier.scenarios.scenario import TestScenarioType +from monitoring.monitorlib.clients.flight_planning.flight_info import FlightInfo +from monitoring.monitorlib.clients.flight_planning.planning import ( + PlanningActivityResponse, + PlanningActivityResult, + FlightPlanStatus, +) + + +def plan_flight_intent_expect_failed( + scenario: TestScenarioType, + test_step: str, + flight_planner: FlightPlannerClient, + flight_intent: FlightInfo, +) -> Tuple[PlanningActivityResponse, Optional[str]]: + """Attempt to plan a flight intent that would result in a Failed result. + + This function implements the test step described in scd_data_exchange_validation.md. + It validates requirement astm.f3548.v21.SCD00abc. + + Returns: The injection response. + """ + + return submit_flight( + scenario, + test_step, + "Plan should fail", + {(PlanningActivityResult.Failed, FlightPlanStatus.NotPlanned)}, + {}, + flight_planner, + flight_intent, + ) diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py b/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py index 4a823c912f..df805e9704 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import List, Optional +from typing import List, Optional, Union from monitoring.monitorlib import schema_validation, fetch +from monitoring.monitorlib.clients.flight_planning.client import FlightPlannerClient from monitoring.monitorlib.geotemporal import Volume4DCollection from uas_standards.astm.f3548.v21.api import ( OperationalIntentState, @@ -10,11 +11,16 @@ OperationalIntentReference, ) +from monitoring.monitorlib.clients.flight_planning.flight_info import ( + UasState, + AirspaceUsageState, +) from monitoring.uss_qualifier.common_data_definitions import Severity from monitoring.uss_qualifier.resources.astm.f3548.v21.dss import DSSInstance from monitoring.uss_qualifier.resources.flight_planning.flight_planner import ( FlightPlanner, ) +from monitoring.monitorlib.clients.flight_planning.flight_info import FlightInfo from monitoring.uss_qualifier.scenarios.astm.utm.evaluation import ( validate_op_intent_details, ) @@ -45,7 +51,7 @@ class OpIntentValidator(object): def __init__( self, scenario: TestScenarioType, - flight_planner: FlightPlanner, + flight_planner: Union[FlightPlanner, FlightPlannerClient], dss: DSSInstance, test_step: str, extent: Volume4D, @@ -60,7 +66,7 @@ def __init__( :param orig_oi_ref: if this is validating a previously existing operational intent (e.g. modification), pass the original reference. """ self._scenario: TestScenarioType = scenario - self._flight_planner: FlightPlanner = flight_planner + self._flight_planner: Union[FlightPlanner, FlightPlannerClient] = flight_planner self._dss: DSSInstance = dss self._test_step: str = test_step self._extent: Volume4D = extent @@ -140,7 +146,9 @@ def expect_not_shared(self) -> None: self._scenario.end_test_step() def expect_shared( - self, flight_intent: InjectFlightRequest, skip_if_not_found: bool = False + self, + flight_intent: Union[InjectFlightRequest, FlightInfo], + skip_if_not_found: bool = False, ) -> Optional[OperationalIntentReference]: """Validate that operational intent information was correctly shared for a flight intent. @@ -176,8 +184,23 @@ def expect_shared( if modified_oi_ref is None: if not skip_if_not_found: if ( - flight_intent.operational_intent.state - == OperationalIntentState.Activated + (isinstance(flight_intent, InjectFlightRequest)) + and ( + flight_intent.operational_intent.state + == OperationalIntentState.Activated + ) + ) or ( + isinstance(flight_intent, FlightInfo) + and ( + ( + flight_intent.basic_information.uas_state + == UasState.Nominal + ) + and ( + flight_intent.basic_information.usage_state + == AirspaceUsageState.InUse + ) + ) ): with self._scenario.check( "Operational intent for active flight not deleted", @@ -257,13 +280,25 @@ def expect_shared( with self._scenario.check( "Correct operational intent details", [self._flight_planner.participant_id] ) as check: - error_text = validate_op_intent_details( - oi_full.details, - flight_intent.operational_intent.priority, - Volume4DCollection.from_interuss_scd_api( + priority = ( + flight_intent.operational_intent.priority + if isinstance(flight_intent, InjectFlightRequest) + else flight_intent.astm_f3548_21.priority + ) + if isinstance(flight_intent, InjectFlightRequest): + priority = flight_intent.operational_intent.priority + vols = Volume4DCollection.from_interuss_scd_api( flight_intent.operational_intent.volumes + flight_intent.operational_intent.off_nominal_volumes - ).bounding_volume.to_f3548v21(), + ) + elif isinstance(flight_intent, FlightInfo): + priority = flight_intent.astm_f3548_21.priority + vols = flight_intent.basic_information.area + + error_text = validate_op_intent_details( + oi_full.details, + priority, + vols.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 4c51d97002..b3a1caffbe 100644 --- a/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py +++ b/monitoring/uss_qualifier/scenarios/flight_planning/test_steps.py @@ -1,9 +1,23 @@ import inspect from typing import Optional, Tuple, Iterable, Set, Dict, Union +from monitoring.monitorlib.clients.flight_planning.client import PlanningActivityError +from monitoring.monitorlib.clients.flight_planning.planning import ( + PlanningActivityResponse, + PlanningActivityResult, + FlightPlanStatus, +) + +from monitoring.monitorlib.clients.flight_planning.client_v1 import ( + FlightPlannerClient, +) +from monitoring.monitorlib.clients.flight_planning.flight_info import ( + FlightInfo, + ExecutionStyle, +) +from monitoring.monitorlib.fetch import QueryError, Query from uas_standards.astm.f3548.v21.api import OperationalIntentState -from monitoring.monitorlib.fetch import QueryError from uas_standards.interuss.automated_testing.scd.v1.api import ( InjectFlightRequest, InjectFlightResponseResult, @@ -39,6 +53,7 @@ def plan_flight_intent( flight_intent: InjectFlightRequest, ) -> Tuple[InjectFlightResponse, Optional[str]]: """Plan a flight intent that should result in success. + Note: This method is deprecated in favor of plan_flight This function implements the test step described in plan_flight_intent.md. @@ -188,6 +203,8 @@ def submit_flight_intent( flight_id: Optional[str] = None, ) -> Tuple[InjectFlightResponse, Optional[str]]: """Submit a flight intent with an expected result. + Note: This method is deprecated in favor of submit_flight + A check fail is considered by default of high severity and as such will raise an ScenarioCannotContinueError. The severity of each failed check may be overridden if needed. @@ -256,6 +273,8 @@ def delete_flight_intent( flight_id: str, ) -> DeleteFlightResponse: """Delete an existing flight intent that should result in success. + Note: This method is deprecated in favor of delete_flight + A check fail is considered of high severity and as such will raise an ScenarioCannotContinueError. This function implements the test step described in `delete_flight_intent.md`. @@ -300,6 +319,7 @@ def cleanup_flights( scenario: TestScenarioType, flight_planners: Iterable[FlightPlanner] ) -> None: """Remove flights during a cleanup test step. + Note: This method is deprecated in favor of cleanup_flights_fp_client This function assumes: * `scenario` is currently cleaning up (cleanup has started) @@ -337,3 +357,251 @@ def cleanup_flights( severity=Severity.Medium, query_timestamps=[query.request.timestamp], ) + + +def plan_flight( + scenario: TestScenarioType, + test_step: str, + flight_planner: FlightPlannerClient, + flight_info: FlightInfo, + additional_fields: Optional[dict] = None, +) -> Tuple[PlanningActivityResponse, Optional[str]]: + """Plan a flight intent that should result in success. + + This function implements the test step described in + plan_flight_intent.md. + + Returns: + * The injection response. + * The ID of the injected flight if it is returned, None otherwise. + """ + return submit_flight( + scenario=scenario, + test_step=test_step, + success_check="Successful planning", + expected_results={(PlanningActivityResult.Completed, FlightPlanStatus.Planned)}, + failed_checks={PlanningActivityResult.Failed: "Failure"}, + flight_planner=flight_planner, + flight_info=flight_info, + additional_fields=additional_fields, + ) + + +def submit_flight( + scenario: TestScenarioType, + test_step: str, + success_check: str, + expected_results: Set[Tuple[PlanningActivityResult, FlightPlanStatus]], + failed_checks: Dict[PlanningActivityResult, Union[str, Tuple[str, Severity]]], + flight_planner: FlightPlannerClient, + flight_info: FlightInfo, + flight_id: Optional[str] = None, + additional_fields: Optional[dict] = None, +) -> Tuple[PlanningActivityResponse, Optional[str]]: + """Submit a flight intent with an expected result. + A check fail is considered by default of high severity and as such will raise an ScenarioCannotContinueError. + The severity of each failed check may be overridden if needed. + + This function does not directly implement a test step. + + Returns: + * The injection response. + * The ID of the injected flight if it is returned, None otherwise. + """ + + scenario.begin_test_step(test_step) + with scenario.check(success_check, [flight_planner.participant_id]) as check: + try: + resp, query, flight_id = request_flight( + flight_planner, flight_info, flight_id, additional_fields + ) + except QueryError as e: + for q in e.queries: + scenario.record_query(q) + check.record_failed( + summary=f"Error from {flight_planner.participant_id} when attempting to submit a flight intent (flight ID: {flight_id})", + severity=Severity.High, + details=f"{str(e)}\n\nStack trace:\n{e.stacktrace}", + query_timestamps=[q.request.timestamp for q in e.queries], + ) + scenario.record_query(query) + notes_suffix = f': "{resp.notes}"' if "notes" in resp and resp.notes else "" + + for unexpected_result, failed_test_check in failed_checks.items(): + if isinstance(failed_test_check, str): + check_name = failed_test_check + check_severity = Severity.High + else: + check_name, check_severity = failed_test_check + + with scenario.check( + check_name, [flight_planner.participant_id] + ) as specific_failed_check: + if resp.activity_result == unexpected_result: + specific_failed_check.record_failed( + summary=f"Flight unexpectedly {resp.result}", + severity=check_severity, + details=f'{flight_planner.participant_id} indicated {resp.result} rather than the expected {" or ".join(expected_results)}{notes_suffix}', + query_timestamps=[query.request.timestamp], + ) + + if (resp.activity_result, resp.flight_plan_status) in expected_results: + scenario.end_test_step() + return resp, flight_id + else: + check.record_failed( + summary=f"Flight unexpectedly {resp.result}", + severity=Severity.High, + details=f'{flight_planner.participant_id} indicated {resp.result} rather than the expected {" or ".join(expected_results)}{notes_suffix}', + query_timestamps=[query.request.timestamp], + ) + + raise RuntimeError( + "Error with submission of flight intent, but a High Severity issue didn't interrupt execution" + ) + + +def request_flight( + flight_planner: FlightPlannerClient, + flight_info: FlightInfo, + flight_id: Optional[str], + additional_fields: Optional[dict] = None, +) -> Tuple[PlanningActivityResponse, Query, str]: + """ + Uses FlightPlannerClient to plan the flight + + Returns: + * Response from planning activity to request new flight or update existing flight + * Query used to request planning activity + * ID of flight + """ + if not flight_id: + try: + resp = flight_planner.try_plan_flight( + flight_info, ExecutionStyle.IfAllowed, additional_fields + ) + except PlanningActivityError as e: + raise QueryError(str(e), e.queries) + flight_id = resp.flight_id + else: + try: + resp = flight_planner.try_update_flight( + flight_id, flight_info, ExecutionStyle.IfAllowed + ) + except PlanningActivityError as e: + raise QueryError(str(e), e.queries) + + return resp, resp.queries[0], flight_id + + +def cleanup_flight( + flight_planner: FlightPlannerClient, flight_id: str +) -> Tuple[PlanningActivityResponse, Query]: + + try: + resp = flight_planner.try_end_flight(flight_id, ExecutionStyle.IfAllowed) + except PlanningActivityError as e: + raise QueryError(str(e), e.queries) + + flight_planner.created_flight_ids.discard(str(flight_id)) + return ( + resp, + resp.queries[0], + ) + + +def delete_flight( + scenario: TestScenarioType, + test_step: str, + flight_planner: FlightPlannerClient, + flight_id: str, +) -> PlanningActivityResponse: + """Delete an existing flight intent that should result in success. + A check fail is considered of high severity and as such will raise an ScenarioCannotContinueError. + + This function implements the test step described in `delete_flight_intent.md`. + + Returns: The deletion response. + """ + scenario.begin_test_step(test_step) + with scenario.check( + "Successful deletion", [flight_planner.participant_id] + ) as check: + try: + resp, query = cleanup_flight(flight_planner, flight_id) + except QueryError as e: + for q in e.queries: + scenario.record_query(q) + check.record_failed( + summary=f"Error from {flight_planner.participant_id} when attempting to delete a flight intent (flight ID: {flight_id})", + severity=Severity.High, + details=f"{str(e)}\n\nStack trace:\n{e.stacktrace}", + query_timestamps=[q.request.timestamp for q in e.queries], + ) + scenario.record_query(query) + notes_suffix = f': "{resp.notes}"' if "notes" in resp and resp.notes else "" + + if ( + resp.activity_result == PlanningActivityResult.Completed + and resp.flight_plan_status == FlightPlanStatus.Closed + ): + scenario.end_test_step() + return resp + else: + check.record_failed( + summary=f"Flight deletion attempt unexpectedly {(resp.activity_result,resp.flight_plan_status)}", + severity=Severity.High, + details=f"{flight_planner.participant_id} indicated {(resp.activity_result,resp.flight_plan_status)} rather than the expected {PlanningActivityResult.Completed,FlightPlanStatus.Closed}{notes_suffix}", + query_timestamps=[query.request.timestamp], + ) + + raise RuntimeError( + "Error with deletion of flight intent, but a High Severity issue didn't interrupt execution" + ) + + +def cleanup_flights_fp_client( + scenario: TestScenarioType, flight_planners: Iterable[FlightPlannerClient] +) -> None: + """Remove flights during a cleanup test step. + Note: This method should be renamed to cleanup_flights once deprecated cleanup_flights method is removed + + This function assumes: + * `scenario` is currently cleaning up (cleanup has started) + * "Successful flight deletion" check declared for cleanup phase in `scenario`'s documentation + """ + for flight_planner in flight_planners: + removed = [] + to_remove = flight_planner.created_flight_ids.copy() + for flight_id in to_remove: + with scenario.check( + "Successful flight deletion", [flight_planner.participant_id] + ) as check: + try: + resp, query = cleanup_flight(flight_planner, flight_id) + scenario.record_query(query) + except QueryError as e: + for q in e.queries: + scenario.record_query(q) + check.record_failed( + summary=f"Failed to clean up flight {flight_id} from {flight_planner.participant_id}", + severity=Severity.Medium, + details=f"{str(e)}\n\nStack trace:\n{e.stacktrace}", + query_timestamps=[q.request.timestamp for q in e.queries], + ) + continue + + if ( + resp.activity_result == PlanningActivityResult.Completed + and resp.flight_plan_status == FlightPlanStatus.Closed + ): + removed.append(flight_id) + else: + check.record_failed( + summary="Failed to delete flight", + details=f"USS indicated: {resp.notes}" + if "notes" in resp + else "See query", + severity=Severity.Medium, + query_timestamps=[query.request.timestamp], + )