diff --git a/monitoring/mock_uss/auth.py b/monitoring/mock_uss/auth.py index 704bc0b9e0..4d4b81db39 100644 --- a/monitoring/mock_uss/auth.py +++ b/monitoring/mock_uss/auth.py @@ -7,3 +7,5 @@ webapp.config.get(config.KEY_TOKEN_PUBLIC_KEY), webapp.config.get(config.KEY_TOKEN_AUDIENCE), ) + +MOCK_USS_CONFIG_SCOPE = "interuss.mock_uss.configure" diff --git a/monitoring/mock_uss/config.py b/monitoring/mock_uss/config.py index d2371399e0..dbfd39e088 100644 --- a/monitoring/mock_uss/config.py +++ b/monitoring/mock_uss/config.py @@ -1,6 +1,5 @@ from monitoring.mock_uss import import_environment_variable from monitoring.monitorlib import auth_validation -from monitoring.monitorlib.locality import Locality KEY_TOKEN_PUBLIC_KEY = "MOCK_USS_PUBLIC_KEY" @@ -27,7 +26,5 @@ mutator=lambda s: set(svc.strip().lower() for svc in s.split(",")), ) import_environment_variable(KEY_DSS_URL, required=False) -import_environment_variable( - KEY_BEHAVIOR_LOCALITY, default="CHE", mutator=Locality.from_locale -) +import_environment_variable(KEY_BEHAVIOR_LOCALITY, default="US.IndustryCollaboration") import_environment_variable(KEY_CODE_VERSION, default="Unknown") diff --git a/monitoring/mock_uss/dynamic_configuration/__init__.py b/monitoring/mock_uss/dynamic_configuration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/monitoring/mock_uss/dynamic_configuration/configuration.py b/monitoring/mock_uss/dynamic_configuration/configuration.py new file mode 100644 index 0000000000..2b883261cc --- /dev/null +++ b/monitoring/mock_uss/dynamic_configuration/configuration.py @@ -0,0 +1,29 @@ +import json + +from implicitdict import ImplicitDict +from monitoring.mock_uss import require_config_value, webapp +from monitoring.mock_uss.config import KEY_BEHAVIOR_LOCALITY +from monitoring.monitorlib.locality import Locality, LocalityCode +from monitoring.monitorlib.multiprocessing import SynchronizedValue + + +require_config_value(KEY_BEHAVIOR_LOCALITY) + + +class DynamicConfiguration(ImplicitDict): + locale: LocalityCode + + +db = SynchronizedValue( + DynamicConfiguration(locale=LocalityCode(webapp.config[KEY_BEHAVIOR_LOCALITY])), + decoder=lambda b: ImplicitDict.parse( + json.loads(b.decode("utf-8")), DynamicConfiguration + ), + capacity_bytes=10000, +) + + +def get_locality() -> Locality: + with db as tx: + code = tx.locale + return Locality.from_locale(code) diff --git a/monitoring/mock_uss/dynamic_configuration/routes.py b/monitoring/mock_uss/dynamic_configuration/routes.py new file mode 100644 index 0000000000..a6cfc66a31 --- /dev/null +++ b/monitoring/mock_uss/dynamic_configuration/routes.py @@ -0,0 +1,48 @@ +from typing import Tuple + +import flask +from implicitdict import ImplicitDict + +from monitoring.mock_uss import webapp +from monitoring.mock_uss.auth import requires_scope, MOCK_USS_CONFIG_SCOPE +from monitoring.mock_uss.dynamic_configuration.configuration import db, get_locality +from monitoring.monitorlib.clients.mock_uss.locality import ( + PutLocalityRequest, + GetLocalityResponse, +) +from monitoring.monitorlib.locality import Locality + + +@webapp.route("/configuration/locality", methods=["GET"]) +def locality_get() -> Tuple[str, int]: + return flask.jsonify( + GetLocalityResponse(locality_code=get_locality().locality_code()) + ) + + +@webapp.route("/configuration/locality", methods=["PUT"]) +@requires_scope([MOCK_USS_CONFIG_SCOPE]) # TODO: use separate public key for this +def locality_set() -> Tuple[str, int]: + """Set the locality of the mock_uss.""" + try: + json = flask.request.json + if json is None: + raise ValueError("Request did not contain a JSON payload") + req: PutLocalityRequest = ImplicitDict.parse(json, PutLocalityRequest) + except ValueError as e: + msg = f"Change locality unable to parse JSON: {str(e)}" + return msg, 400 + + # Make sure this is a valid locality + try: + Locality.from_locale(req.locality_code) + except ValueError as e: + msg = f"Invalid locality_code: {str(e)}" + return msg, 400 + + with db as tx: + tx.locale = req.locality_code + + return flask.jsonify( + GetLocalityResponse(locality_code=get_locality().locality_code()) + ) diff --git a/monitoring/mock_uss/interaction_logging/logger.py b/monitoring/mock_uss/interaction_logging/logger.py index e09acf5b3f..106b4a2399 100644 --- a/monitoring/mock_uss/interaction_logging/logger.py +++ b/monitoring/mock_uss/interaction_logging/logger.py @@ -66,7 +66,7 @@ def interaction_log_after_request(response): datetime.datetime.utcnow() - flask.current_app.custom_profiler["start"] ).total_seconds() # TODO: Make this configurable instead of hardcoding exactly these query types - if "/uss/v1/" in flask.request.url_rule.rule: + if flask.request.url_rule is not None and "/uss/v1/" in flask.request.url_rule.rule: query = describe_flask_query(flask.request, response, elapsed_s) log_interaction(QueryDirection.Incoming, query) return response diff --git a/monitoring/mock_uss/routes.py b/monitoring/mock_uss/routes.py index f423b751d3..abac76a7e0 100644 --- a/monitoring/mock_uss/routes.py +++ b/monitoring/mock_uss/routes.py @@ -49,3 +49,6 @@ def handle_exception(e): flask.jsonify({"message": "Unhandled {}: {}".format(type(e).__name__, str(e))}), 500, ) + + +from .dynamic_configuration import routes diff --git a/monitoring/mock_uss/scdsc/flight_planning.py b/monitoring/mock_uss/scdsc/flight_planning.py index 09d63cd76f..b63b47d9b9 100644 --- a/monitoring/mock_uss/scdsc/flight_planning.py +++ b/monitoring/mock_uss/scdsc/flight_planning.py @@ -9,6 +9,7 @@ from monitoring.monitorlib.geotemporal import Volume4DCollection from monitoring.monitorlib.locality import Locality from monitoring.monitorlib.uspace import problems_with_flight_authorisation +from uas_standards.interuss.automated_testing.scd.v1.api import OperationalIntentState class PlanningError(Exception): @@ -47,6 +48,7 @@ def validate_request(req_body: scd_api.InjectFlightRequest, locality: Locality) # Validate max planning horizon for creation start_time = Volume4DCollection.from_interuss_scd_api( req_body.operational_intent.volumes + + req_body.operational_intent.off_nominal_volumes ).time_start.datetime time_delta = start_time - datetime.now(tz=start_time.tzinfo) if ( @@ -87,6 +89,13 @@ def check_for_disallowed_conflicts( if log is None: log = lambda msg: None + if req_body.operational_intent.state not in ( + OperationalIntentState.Accepted, + OperationalIntentState.Activated, + ): + # No conflicts are disallowed if the flight is not nominal + return + v1 = Volume4DCollection.from_interuss_scd_api(req_body.operational_intent.volumes) for op_intent in op_intents: diff --git a/monitoring/mock_uss/scdsc/routes_injection.py b/monitoring/mock_uss/scdsc/routes_injection.py index c75e6f8105..e899264e77 100644 --- a/monitoring/mock_uss/scdsc/routes_injection.py +++ b/monitoring/mock_uss/scdsc/routes_injection.py @@ -11,6 +11,8 @@ import requests.exceptions from monitoring.monitorlib.idempotency import idempotent_request + +from monitoring.mock_uss.dynamic_configuration.configuration import get_locality from uas_standards.interuss.automated_testing.scd.v1.api import ( InjectFlightRequest, InjectFlightResponse, @@ -41,7 +43,7 @@ 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 monitoring.mock_uss.config import KEY_BASE_URL, KEY_BEHAVIOR_LOCALITY +from monitoring.mock_uss.config import KEY_BASE_URL from monitoring.monitorlib import versioning from monitoring.monitorlib.clients import scd as scd_client from monitoring.monitorlib.fetch import QueryError @@ -55,7 +57,6 @@ require_config_value(KEY_BASE_URL) -require_config_value(KEY_BEHAVIOR_LOCALITY) DEADLOCK_TIMEOUT = timedelta(seconds=5) @@ -167,7 +168,7 @@ def scdsc_inject_flight(flight_id: str) -> Tuple[str, int]: def inject_flight(flight_id: str, req_body: InjectFlightRequest) -> Tuple[dict, int]: pid = os.getpid() - locality = webapp.config[KEY_BEHAVIOR_LOCALITY] + locality = get_locality() def log(msg: str): logger.debug(f"[inject_flight/{pid}:{flight_id}] {msg}") @@ -231,9 +232,11 @@ def log(msg: str): # Check for operational intents in the DSS step_name = "querying for operational intents" log("Obtaining latest operational intent information") - vol4 = Volume4DCollection.from_interuss_scd_api( + v1 = Volume4DCollection.from_interuss_scd_api( req_body.operational_intent.volumes - ).bounding_volume.to_f3548v21() + + req_body.operational_intent.off_nominal_volumes + ) + vol4 = v1.bounding_volume.to_f3548v21() op_intents = query_operational_intents(vol4) # Check for intersections diff --git a/monitoring/mock_uss/templates/tracer/log.html b/monitoring/mock_uss/templates/tracer/log.html index 5fac93169e..dd0158b975 100644 --- a/monitoring/mock_uss/templates/tracer/log.html +++ b/monitoring/mock_uss/templates/tracer/log.html @@ -3,6 +3,6 @@ {% block content %} {{ explorer_header() }} - {{ explorer_content(log) }} - {{ explorer_footer() }} + {{ explorer_content("top_node", log) }} + {{ explorer_footer(["top_node"]) }} {% endblock %} diff --git a/monitoring/monitorlib/clients/flight_planning/__init__.py b/monitoring/monitorlib/clients/flight_planning/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/monitoring/monitorlib/clients/flight_planning/client.py b/monitoring/monitorlib/clients/flight_planning/client.py new file mode 100644 index 0000000000..e64a242efb --- /dev/null +++ b/monitoring/monitorlib/clients/flight_planning/client.py @@ -0,0 +1,94 @@ +from abc import ABC, abstractmethod +from typing import List, Optional, Union + +from monitoring.monitorlib.clients.flight_planning.test_preparation import ( + TestPreparationActivityResponse, +) + +from monitoring.monitorlib.clients.flight_planning.flight_info import ( + FlightInfo, + FlightID, + ExecutionStyle, +) +from monitoring.monitorlib.clients.flight_planning.planning import ( + PlanningActivityResponse, +) +from monitoring.monitorlib.fetch import Query +from monitoring.monitorlib.geotemporal import Volume4D + + +class PlanningActivityError(Exception): + queries: List[Query] + + def __init__( + self, message: str, queries: Optional[Union[Query, List[Query]]] = None + ): + super(PlanningActivityError, self).__init__(message) + if queries is None: + self.queries = [] + elif isinstance(queries, Query): + self.queries = [queries] + else: + self.queries = queries + + +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.""" + + # ===== Emulation of user actions ===== + + @abstractmethod + def try_plan_flight( + self, flight_info: FlightInfo, execution_style: ExecutionStyle + ) -> PlanningActivityResponse: + """Instruct the USS to emulate a normal user trying to plan the described flight. + + Raises: + * PlanningActivityError + """ + raise NotImplementedError() + + @abstractmethod + def try_update_flight( + self, + flight_id: FlightID, + updated_flight_info: FlightInfo, + execution_style: ExecutionStyle, + ) -> PlanningActivityResponse: + """Instruct the USS to emulate a normal user trying to update the specified flight as described. + + Raises: + * PlanningActivityError + """ + raise NotImplementedError() + + @abstractmethod + def try_end_flight( + self, flight_id: FlightID, execution_style: ExecutionStyle + ) -> PlanningActivityResponse: + """Instruct the USS to emulate a normal user trying to end the specified flight. + + Raises: + * PlanningActivityError + """ + raise NotImplementedError() + + # ===== Test preparation activities ===== + + @abstractmethod + def report_readiness(self) -> TestPreparationActivityResponse: + """Acting as test director, ask the USS about its readiness to use its flight planning interface for automated testing. + + Raises: + * PlanningActivityError + """ + raise NotImplementedError() + + @abstractmethod + def clear_area(self, area: Volume4D) -> TestPreparationActivityResponse: + """Acting as test director, instruct the USS to close/end/remove all flights it manages within the specified area. + + Raises: + * PlanningActivityError + """ + raise NotImplementedError() diff --git a/monitoring/monitorlib/clients/flight_planning/client_scd.py b/monitoring/monitorlib/clients/flight_planning/client_scd.py new file mode 100644 index 0000000000..f5eb4643c7 --- /dev/null +++ b/monitoring/monitorlib/clients/flight_planning/client_scd.py @@ -0,0 +1,272 @@ +import uuid +from typing import Dict + +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 uas_standards.interuss.automated_testing.scd.v1 import api as scd_api +from uas_standards.interuss.automated_testing.scd.v1 import ( + constants as scd_api_constants, +) + +from monitoring.monitorlib.clients.flight_planning.flight_info import ( + FlightInfo, + FlightID, + AirspaceUsageState, + UasState, + ExecutionStyle, +) +from monitoring.monitorlib.clients.flight_planning.planning import ( + PlanningActivityResponse, + PlanningActivityResult, + FlightPlanStatus, +) +from monitoring.monitorlib.fetch import query_and_describe +from monitoring.monitorlib.geotemporal import Volume4D +from monitoring.monitorlib.infrastructure import UTMClientSession + + +class SCDFlightPlannerClient(FlightPlannerClient): + SCD_SCOPE = scd_api_constants.Scope.Inject + _session: UTMClientSession + _plan_statuses: Dict[FlightID, FlightPlanStatus] + + def __init__(self, session: UTMClientSession): + self._session = session + self._plan_statuses = {} + + def _inject( + self, + flight_id: FlightID, + flight_info: FlightInfo, + execution_style: ExecutionStyle, + ) -> PlanningActivityResponse: + if execution_style != ExecutionStyle.IfAllowed: + raise PlanningActivityError( + f"Legacy scd automated testing API only supports {ExecutionStyle.IfAllowed} actions; '{execution_style}' is not supported" + ) + usage_state = flight_info.basic_information.usage_state + uas_state = flight_info.basic_information.uas_state + if uas_state == UasState.Nominal: + if usage_state == AirspaceUsageState.Planned: + state = scd_api.OperationalIntentState.Accepted + elif usage_state == AirspaceUsageState.InUse: + state = scd_api.OperationalIntentState.Activated + else: + raise NotImplementedError( + f"Unsupported operator AirspaceUsageState '{usage_state}' with UasState '{uas_state}'" + ) + elif usage_state == AirspaceUsageState.InUse: + if uas_state == UasState.OffNominal: + state = scd_api.OperationalIntentState.Nonconforming + elif uas_state == UasState.Contingent: + state = scd_api.OperationalIntentState.Contingent + else: + 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_interuss_scd_api() for v in flight_info.basic_information.area + ] + + if "astm_f3548_21" in flight_info and flight_info.astm_f3548_21: + priority = flight_info.astm_f3548_21.priority + else: + priority = 0 + + operational_intent = scd_api.OperationalIntentTestInjection( + state=state, + priority=priority, + volumes=volumes, + off_nominal_volumes=off_nominal_volumes, + ) + + kwargs = {"operational_intent": operational_intent} + if ( + "uspace_flight_authorisation" in flight_info + and flight_info.uspace_flight_authorisation + ): + kwargs["flight_authorisation"] = ImplicitDict.parse( + flight_info.uspace_flight_authorisation, scd_api.FlightAuthorisationData + ) + req = scd_api.InjectFlightRequest(**kwargs) + + op = scd_api.OPERATIONS[scd_api.OperationID.InjectFlight] + url = op.path.format(flight_id=flight_id) + query = query_and_describe( + self._session, op.verb, url, json=req, scope=self.SCD_SCOPE + ) + if query.status_code != 200 and query.status_code != 201: + raise PlanningActivityError( + f"Attempt to plan flight returned status {query.status_code} rather than 200 as expected", + query, + ) + try: + resp: scd_api.InjectFlightResponse = ImplicitDict.parse( + query.response.json, scd_api.InjectFlightResponse + ) + except ValueError as e: + raise PlanningActivityError( + f"Response to plan flight could not be parsed: {str(e)}", query + ) + + old_state = ( + self._plan_statuses[flight_id] + if flight_id in self._plan_statuses + else FlightPlanStatus.NotPlanned + ) + response = PlanningActivityResponse( + flight_id=flight_id, + queries=[query], + activity_result={ + scd_api.InjectFlightResponseResult.Planned: PlanningActivityResult.Completed, + scd_api.InjectFlightResponseResult.ReadyToFly: PlanningActivityResult.Completed, + scd_api.InjectFlightResponseResult.ConflictWithFlight: PlanningActivityResult.Rejected, + scd_api.InjectFlightResponseResult.Rejected: PlanningActivityResult.Rejected, + scd_api.InjectFlightResponseResult.Failed: PlanningActivityResult.Failed, + scd_api.InjectFlightResponseResult.NotSupported: PlanningActivityResult.NotSupported, + }[resp.result], + flight_plan_status={ + scd_api.InjectFlightResponseResult.Planned: FlightPlanStatus.Planned, + scd_api.InjectFlightResponseResult.ReadyToFly: FlightPlanStatus.OkToFly, + scd_api.InjectFlightResponseResult.ConflictWithFlight: old_state, + scd_api.InjectFlightResponseResult.Rejected: old_state, + scd_api.InjectFlightResponseResult.Failed: old_state, + scd_api.InjectFlightResponseResult.NotSupported: old_state, + }[resp.result], + ) + self._plan_statuses[flight_id] = response.flight_plan_status + return response + + def try_plan_flight( + self, flight_info: FlightInfo, execution_style: ExecutionStyle + ) -> PlanningActivityResponse: + return self._inject(str(uuid.uuid4()), flight_info, execution_style) + + def try_update_flight( + self, + flight_id: FlightID, + updated_flight_info: FlightInfo, + execution_style: ExecutionStyle, + ) -> PlanningActivityResponse: + return self._inject(flight_id, updated_flight_info, execution_style) + + def try_end_flight( + self, flight_id: FlightID, execution_style: ExecutionStyle + ) -> PlanningActivityResponse: + op = scd_api.OPERATIONS[scd_api.OperationID.DeleteFlight] + url = op.path.format(flight_id=flight_id) + query = query_and_describe(self._session, op.verb, url, scope=self.SCD_SCOPE) + if query.status_code != 200: + raise PlanningActivityError( + f"Attempt to delete flight returned status {query.status_code} rather than 200 as expected", + query, + ) + try: + resp: scd_api.DeleteFlightResponse = ImplicitDict.parse( + query.response.json, scd_api.DeleteFlightResponse + ) + except ValueError as e: + raise PlanningActivityError( + f"Response to delete flight could not be parsed: {str(e)}", query + ) + + old_state = ( + self._plan_statuses[flight_id] + if flight_id in self._plan_statuses + else FlightPlanStatus.NotPlanned + ) + response = PlanningActivityResponse( + flight_id=flight_id, + queries=[query], + activity_result={ + scd_api.DeleteFlightResponseResult.Closed: PlanningActivityResult.Completed, + scd_api.DeleteFlightResponseResult.Failed: PlanningActivityResult.Failed, + }[resp.result], + flight_plan_status={ + scd_api.DeleteFlightResponseResult.Closed: FlightPlanStatus.Closed, + scd_api.DeleteFlightResponseResult.Failed: old_state, + }[resp.result], + ) + if resp.result == scd_api.DeleteFlightResponseResult.Closed: + del self._plan_statuses[flight_id] + else: + self._plan_statuses[flight_id] = response.flight_plan_status + return response + + def report_readiness(self) -> TestPreparationActivityResponse: + op = scd_api.OPERATIONS[scd_api.OperationID.GetStatus] + query = query_and_describe( + self._session, op.verb, op.path, scope=self.SCD_SCOPE + ) + if query.status_code != 200: + raise PlanningActivityError( + f"Attempt to get interface status returned status {query.status_code} rather than 200 as expected", + query, + ) + try: + resp: scd_api.StatusResponse = ImplicitDict.parse( + query.response.json, scd_api.StatusResponse + ) + except ValueError as e: + raise PlanningActivityError( + f"Response to get interface status could not be parsed: {str(e)}", query + ) + + if resp.status == scd_api.StatusResponseStatus.Ready: + errors = [] + elif resp.status == scd_api.StatusResponseStatus.Starting: + errors = ["SCD flight planning interface is still starting (not ready)"] + else: + errors = [f"Unrecognized status '{resp.status}'"] + + # Note that checking capabilities is not included because the SCD flight planning interface is deprecated and does not warrant full support + + return TestPreparationActivityResponse(errors=errors, queries=[query]) + + def clear_area(self, area: Volume4D) -> TestPreparationActivityResponse: + req = scd_api.ClearAreaRequest( + request_id=str(uuid.uuid4()), extent=area.to_interuss_scd_api() + ) + + op = scd_api.OPERATIONS[scd_api.OperationID.ClearArea] + query = query_and_describe( + self._session, op.verb, op.path, json=req, scope=self.SCD_SCOPE + ) + if query.status_code != 200: + raise PlanningActivityError( + f"Attempt to clear area returned status {query.status_code} rather than 200 as expected", + query, + ) + try: + resp: scd_api.ClearAreaResponse = ImplicitDict.parse( + query.response.json, scd_api.ClearAreaResponse + ) + except ValueError as e: + raise PlanningActivityError( + f"Response to clear area could not be parsed: {str(e)}", query + ) + + if resp.outcome.success: + errors = None + else: + errors = [f"[{resp.outcome.timestamp}]: {resp.outcome.message}"] + + return TestPreparationActivityResponse(errors=errors, queries=[query]) diff --git a/monitoring/monitorlib/clients/flight_planning/flight_info.py b/monitoring/monitorlib/clients/flight_planning/flight_info.py new file mode 100644 index 0000000000..2dfb838bc0 --- /dev/null +++ b/monitoring/monitorlib/clients/flight_planning/flight_info.py @@ -0,0 +1,236 @@ +from enum import Enum +from typing import Optional, List + +from implicitdict import ImplicitDict +from uas_standards.ansi_cta_2063_a import SerialNumber +from uas_standards.en4709_02 import OperatorRegistrationNumber + +from monitoring.monitorlib.geotemporal import Volume4D + + +# ===== ASTM F3548-21 ===== + + +Priority = int +"""Ordinal priority that the flight's operational intent should be assigned, as defined in ASTM F3548-21.""" + + +class ASTMF354821OpIntentInformation(ImplicitDict): + """Information provided about a flight plan that is necessary for ASTM F3548-21.""" + + priority: Optional[Priority] + + +# ===== U-space ===== + + +class FlightAuthorisationDataOperationCategory(str, Enum): + """Category of UAS operation (‘open’, ‘specific’, ‘certified’) as defined in COMMISSION DELEGATED REGULATION (EU) 2019/945. Required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 4.""" + + Unknown = "Unknown" + Open = "Open" + Specific = "Specific" + Certified = "Certified" + + +class OperationMode(str, Enum): + """Specify if the operation is a `VLOS` or `BVLOS` operation. Required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 2.""" + + Undeclared = "Undeclared" + Vlos = "Vlos" + Bvlos = "Bvlos" + + +class UASClass(str, Enum): + """Specify the class of the UAS to be flown, the specifition matches EASA class identification label categories. UAS aircraft class as defined in COMMISSION DELEGATED REGULATION (EU) 2019/945 (C0 to C4) and COMMISSION DELEGATED REGULATION (EU) 2020/1058 (C5 and C6). This field is required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 4.""" + + Other = "Other" + C0 = "C0" + C1 = "C1" + C2 = "C2" + C3 = "C3" + C4 = "C4" + C5 = "C5" + C6 = "C6" + + +class FlightAuthorisationData(ImplicitDict): + """The details of a UAS flight authorization request, as received from the user. + + Note that a full description of a flight authorisation must include mandatory information required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664 for an UAS flight authorisation request. Reference: https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32021R0664&from=EN#d1e32-178-1 + """ + + uas_serial_number: SerialNumber + """Unique serial number of the unmanned aircraft or, if the unmanned aircraft is privately built, the unique serial number of the add-on. This is expressed in the ANSI/CTA-2063 Physical Serial Number format. Required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 1.""" + + operation_mode: OperationMode + + operation_category: FlightAuthorisationDataOperationCategory + """Category of UAS operation (‘open’, ‘specific’, ‘certified’) as defined in COMMISSION DELEGATED REGULATION (EU) 2019/945. Required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 4.""" + + uas_class: UASClass + + identification_technologies: List[str] + """Technology used to identify the UAS. Required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 6.""" + + uas_type_certificate: Optional[str] + """Provisional field. Not applicable as of September 2021. Required only if `uas_class` is set to `other` by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 4.""" + + connectivity_methods: List[str] + """Connectivity methods. Required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 7.""" + + endurance_minutes: int + """Endurance of the UAS. This is expressed in minutes. Required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 8.""" + + emergency_procedure_url: str + """The URL at which the applicable emergency procedure in case of a loss of command and control link may be retrieved. Required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 9.""" + + operator_id: OperatorRegistrationNumber + """Registration number of the UAS operator. + The format is defined in EASA Easy Access Rules for Unmanned Aircraft Systems GM1 to AMC1 + Article 14(6) Registration of UAS operators and ‘certified’ UAS. + Required by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 10. + """ + + uas_id: Optional[str] + """When applicable, the registration number of the unmanned aircraft. + This is expressed using the nationality and registration mark of the unmanned aircraft in + line with ICAO Annex 7. + Specified by ANNEX IV of COMMISSION IMPLEMENTING REGULATION (EU) 2021/664, paragraph 10. + """ + + +# ===== RPAS Operating Rules 2.6 ===== + + +class RPAS26FlightDetailsOperatorType(str, Enum): + """The type of operator.""" + + Recreational = "Recreational" + CommercialExcluded = "CommercialExcluded" + ReOC = "ReOC" + + +class RPAS26FlightDetailsAircraftType(str, Enum): + """Type of vehicle being used as per ASTM F3411-22a.""" + + NotDeclared = "NotDeclared" + Aeroplane = "Aeroplane" + Helicopter = "Helicopter" + Gyroplane = "Gyroplane" + HybridLift = "HybridLift" + Ornithopter = "Ornithopter" + Glider = "Glider" + Kite = "Kite" + FreeBalloon = "FreeBalloon" + CaptiveBalloon = "CaptiveBalloon" + Airship = "Airship" + FreeFallOrParachute = "FreeFallOrParachute" + Rocket = "Rocket" + TetheredPoweredAircraft = "TetheredPoweredAircraft" + GroundObstacle = "GroundObstacle" + Other = "Other" + + +class RPAS26FlightDetailsFlightProfile(str, Enum): + """Type of flight profile.""" + + AutomatedGrid = "AutomatedGrid" + AutomatedWaypoint = "AutomatedWaypoint" + Manual = "Manual" + + +class RPAS26FlightDetails(ImplicitDict): + """Information about a flight necessary to plan successfully using the RPAS Platform Operating Rules version 2.6.""" + + operator_type: Optional[RPAS26FlightDetailsOperatorType] + """The type of operator.""" + + uas_serial_numbers: Optional[List[str]] + """The list of UAS/drone serial numbers that will be operated during the operation.""" + + uas_registration_numbers: Optional[List[str]] + """The list of UAS/drone registration numbers that will be operated during the operation.""" + + aircraft_type: Optional[RPAS26FlightDetailsAircraftType] + """Type of vehicle being used as per ASTM F3411-22a.""" + + flight_profile: Optional[RPAS26FlightDetailsFlightProfile] + """Type of flight profile.""" + + pilot_license_number: Optional[str] + """License number for the pilot.""" + + pilot_phone_number: Optional[str] + """Contact phone number for the pilot.""" + + operator_number: Optional[str] + """Operator number.""" + + +# ===== General flight information ===== + + +FlightID = str + + +class AirspaceUsageState(str, Enum): + """User's current usage of the airspace defined in the flight plan.""" + + Planned = "Planned" + """The user intends to fly according to this flight plan, but is not currently using the defined area with an active UAS.""" + + InUse = "InUse" + """The user is currently using the defined area with an active UAS.""" + + +class UasState(str, Enum): + """State of the user's UAS associated with a flight plan.""" + + Nominal = "Nominal" + """The user or UAS reports or implies that it is performing nominally.""" + + OffNominal = "OffNominal" + """The user or UAS reports or implies that it is temporarily not performing nominally, but expects to be able to recover to normal operation.""" + + Contingent = "Contingent" + """The user or UAS reports or implies that it is not performing nominally and may be unable to recover to normal operation.""" + + +class BasicFlightPlanInformation(ImplicitDict): + """Basic information about a flight plan that an operator and/or UAS can be expected to provide in most flight planning scenarios.""" + + usage_state: AirspaceUsageState + """User's current usage of the airspace specified in the flight plan.""" + + uas_state: UasState + """State of the user's UAS associated with this flight plan.""" + + area: List[Volume4D] + """User intends to or may fly anywhere in this entire area.""" + + +class FlightInfo(ImplicitDict): + """Details of user's intent to create or modify a flight plan.""" + + basic_information: BasicFlightPlanInformation + + astm_f3548_21: Optional[ASTMF354821OpIntentInformation] + + uspace_flight_authorisation: Optional[FlightAuthorisationData] + + rpas_operating_rules_2_6: Optional[RPAS26FlightDetails] + + additional_information: Optional[dict] + """Any information relevant to a particular jurisdiction or use case not described in the standard schema. The keys and values must be agreed upon between the test designers and USSs under test.""" + + +class ExecutionStyle(str, Enum): + Hypothetical = "Hypothetical" + """The user does not want the USS to actually perform any action regarding the actual flight plan. Instead, the user would like to know the likely outcome if the action were hypothetically attempted. The response to this request will not refer to an actual flight plan, or an actual state change in an existing flight plan, but rather a hypothetical flight plan or a hypothetical change to an existing flight plan.""" + + IfAllowed = "IfAllowed" + """The user would like to perform the requested action if it is allowed. If the requested action is allowed, the USS should actually perform the action (e.g., actually create a new ASTM F3548-21 operational intent). If the requested action is not allowed, the USS should indicate that the action is Rejected and not perform the action. The response to this request will refer to an actual flight plan when appropriate, and never refer to a hypothetical flight plan or status.""" + + InReality = "InReality" + """The user is communicating an actual state of reality. The USS should consider the user to be actually performing (or attempting to perform) this action, regardless of whether or not the action is allowed under relevant UTM rules.""" diff --git a/monitoring/monitorlib/clients/flight_planning/flight_info_template.py b/monitoring/monitorlib/clients/flight_planning/flight_info_template.py new file mode 100644 index 0000000000..eb8ffd4819 --- /dev/null +++ b/monitoring/monitorlib/clients/flight_planning/flight_info_template.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import List, Optional + +from implicitdict import ImplicitDict + +from monitoring.monitorlib.clients.flight_planning.flight_info import ( + AirspaceUsageState, + UasState, + ASTMF354821OpIntentInformation, + FlightAuthorisationData, + RPAS26FlightDetails, + BasicFlightPlanInformation, + FlightInfo, +) +from monitoring.monitorlib.geotemporal import Volume4DTemplate, resolve_volume4d + + +class BasicFlightPlanInformationTemplate(ImplicitDict): + """Template to provide (at runtime) basic information about a flight plan that an operator and/or UAS can be expected to provide in most flight planning scenarios.""" + + usage_state: AirspaceUsageState + """User's current usage of the airspace specified in the flight plan.""" + + uas_state: UasState + """State of the user's UAS associated with this flight plan.""" + + area: List[Volume4DTemplate] + """User intends to or may fly anywhere in this entire area.""" + + def resolve(self, start_of_test: datetime) -> BasicFlightPlanInformation: + kwargs = {k: v for k, v in self.items()} + kwargs["area"] = [resolve_volume4d(t, start_of_test) for t in self.area] + return ImplicitDict.parse(kwargs, BasicFlightPlanInformation) + + +class FlightInfoTemplate(ImplicitDict): + """Template to provide (at runtime) details of user's intent to create or modify a flight plan.""" + + basic_information: BasicFlightPlanInformationTemplate + + astm_f3548_21: Optional[ASTMF354821OpIntentInformation] + + uspace_flight_authorisation: Optional[FlightAuthorisationData] + + rpas_operating_rules_2_6: Optional[RPAS26FlightDetails] + + additional_information: Optional[dict] + """Any information relevant to a particular jurisdiction or use case not described in the standard schema. The keys and values must be agreed upon between the test designers and USSs under test.""" + + def resolve(self, start_of_test: datetime) -> FlightInfo: + kwargs = {k: v for k, v in self.items()} + kwargs["basic_information"] = self.basic_information.resolve(start_of_test) + return ImplicitDict.parse(kwargs, FlightInfo) diff --git a/monitoring/monitorlib/clients/flight_planning/planning.py b/monitoring/monitorlib/clients/flight_planning/planning.py new file mode 100644 index 0000000000..8f473f8575 --- /dev/null +++ b/monitoring/monitorlib/clients/flight_planning/planning.py @@ -0,0 +1,71 @@ +from enum import Enum +from typing import Optional, List + +from implicitdict import ImplicitDict + +from monitoring.monitorlib.clients.flight_planning.flight_info import FlightID +from monitoring.monitorlib.fetch import Query + + +class PlanningActivityResult(str, Enum): + """The result of the flight planning operation.""" + + Completed = "Completed" + """The user's flight plan has been updated according to the situation specified by the user.""" + + Rejected = "Rejected" + """The updates the user requested to their flight plan are not allowed according to the rules under which the flight plan is being managed. The reasons for rejection may include a disallowed conflict with another flight during preflight.""" + + Failed = "Failed" + """The USS was not able to successfully authorize or update the flight plan due to a problem with the USS or a downstream system.""" + + NotSupported = "NotSupported" + """The USS's implementation does not support the attempted interaction. For instance, if the request specified a high-priority flight and the USS does not support management of high-priority flights.""" + + +class FlightPlanStatus(str, Enum): + """Status of a user's flight plan.""" + + NotPlanned = "NotPlanned" + """The USS has not created an authorized flight plan for the user.""" + + Planned = "Planned" + """The USS has created an authorized flight plan for the user, but the user may not yet start flying (even if within the time bounds of the flight plan).""" + + OkToFly = "OkToFly" + """The flight plan is in a state such that it is ok for the user to nominally fly within the bounds (including time) of the flight plan.""" + + OffNominal = "OffNominal" + """The flight plan now reflects the operator's actions, but the flight plan is not in a nominal state (e.g., the USS has placed the ASTM F3548-21 operational intent into one of the Nonconforming or Contingent states).""" + + Closed = "Closed" + """The flight plan was closed successfully by the USS and is now out of the UTM system.""" + + +class AdvisoryInclusion(str, Enum): + """Indication of whether any advisories or conditions were provided to the user along with the result of a flight planning attempt.""" + + Unknown = "Unknown" + """It is unknown or irrelevant whether advisories or conditions were provided to the user.""" + + AtLeastOneAdvisoryOrCondition = "AtLeastOneAdvisoryOrCondition" + """At least one advisory or condition was provided to the user.""" + + NoAdvisoriesOrConditions = "NoAdvisoriesOrConditions" + """No advisories or conditions were provided to the user.""" + + +class PlanningActivityResponse(ImplicitDict): + flight_id: FlightID + """Identity of flight for which the planning activity was conducted.""" + + queries: List[Query] + """Queries used to accomplish this activity.""" + + activity_result: PlanningActivityResult + """The result of the flight planning activity.""" + + flight_plan_status: FlightPlanStatus + """Status of the flight plan following the flight planning activity.""" + + includes_advisories: Optional[AdvisoryInclusion] = AdvisoryInclusion.Unknown diff --git a/monitoring/monitorlib/clients/flight_planning/test_preparation.py b/monitoring/monitorlib/clients/flight_planning/test_preparation.py new file mode 100644 index 0000000000..e88bf6679f --- /dev/null +++ b/monitoring/monitorlib/clients/flight_planning/test_preparation.py @@ -0,0 +1,25 @@ +from typing import Optional, List + +from implicitdict import ImplicitDict + +from monitoring.monitorlib.fetch import Query + + +class ClearAreaOutcome(ImplicitDict): + success: Optional[bool] = False + """True if, and only if, all flight plans in the specified area managed by the USS were canceled and removed.""" + + message: Optional[str] + """If the USS was unable to clear the entire area, this message can provide information on the problem encountered.""" + + +class ClearAreaResponse(ImplicitDict): + outcome: ClearAreaOutcome + + +class TestPreparationActivityResponse(ImplicitDict): + errors: Optional[List[str]] = None + """If any errors occurred during this activity, a list of those errors.""" + + queries: List[Query] + """Queries used to accomplish this activity.""" diff --git a/monitoring/monitorlib/clients/mock_uss/__init__.py b/monitoring/monitorlib/clients/mock_uss/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/monitoring/monitorlib/clients/mock_uss/locality.py b/monitoring/monitorlib/clients/mock_uss/locality.py new file mode 100644 index 0000000000..839f5c034a --- /dev/null +++ b/monitoring/monitorlib/clients/mock_uss/locality.py @@ -0,0 +1,15 @@ +from implicitdict import ImplicitDict + +from monitoring.monitorlib.locality import LocalityCode + + +class PutLocalityRequest(ImplicitDict): + """API object to request a change in locality""" + + locality_code: LocalityCode + + +class GetLocalityResponse(ImplicitDict): + """API object defining a response indicating locality""" + + locality_code: LocalityCode diff --git a/monitoring/monitorlib/fetch/__init__.py b/monitoring/monitorlib/fetch/__init__.py index f9b1cadcdf..b99816b69a 100644 --- a/monitoring/monitorlib/fetch/__init__.py +++ b/monitoring/monitorlib/fetch/__init__.py @@ -6,6 +6,7 @@ from typing import Dict, Optional, List from enum import Enum +from urllib.parse import urlparse import flask from loguru import logger @@ -56,6 +57,10 @@ def timestamp(self) -> datetime.datetime: "RequestDescription missing both initiated_at and received_at" ) + @property + def url_hostname(self) -> str: + return urlparse(self.url).hostname + yaml.add_representer(RequestDescription, Representer.represent_dict) 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/monitorlib/html/templates/explorer.html b/monitoring/monitorlib/html/templates/explorer.html index 9b6b1ee03c..af2e2d2bfa 100644 --- a/monitoring/monitorlib/html/templates/explorer.html +++ b/monitoring/monitorlib/html/templates/explorer.html @@ -1,7 +1,7 @@ {# Renders the provided `obj` dict as interactive HTML #} {# Content of explorer_header() should be added to the header of the page #} -{# Content of explorer_content(obj) represents the `obj` dict as interactive HTML content #} -{# Content of explorer_footer() should be added to the page such that it is loaded after draw_node #} +{# Content of explorer_content(div_id, obj) represents the `obj` dict as interactive HTML content #} +{# Content of explorer_footer(div_ids) should be added to the page such that it is loaded after explorer_content/draw_node #} {% macro collapseable(v) %}{% if v is mapping or (v is iterable and v is not string) %}collapseable{% else %}not_collapseable{% endif %}{% endmacro %} @@ -66,13 +66,13 @@ {% endmacro %} -{% macro explorer_content(obj) %} -