From 1199553d56fa7d2724d7298d0d2014883a873d82 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Thu, 26 Oct 2023 11:03:11 -0700 Subject: [PATCH] [mock_uss] Add flight_planning API implementation to mock_uss (#287) * Add flight_planning API implementation to mock_uss * Address comments --- monitoring/mock_uss/__init__.py | 5 + monitoring/mock_uss/docker-compose.yaml | 4 +- .../mock_uss/flight_planning/__init__.py | 0 monitoring/mock_uss/flight_planning/routes.py | 182 ++++++++++++++++++ monitoring/mock_uss/scdsc/routes_injection.py | 97 ++++++---- monitoring/mock_uss/scdsc/routes_scdsc.py | 2 +- .../clients/flight_planning/client_scd.py | 43 +---- .../clients/flight_planning/client_v1.py | 3 +- .../clients/flight_planning/flight_info.py | 113 +++++++++++ monitoring/monitorlib/geo.py | 49 +++++ monitoring/monitorlib/geotemporal.py | 25 +++ .../dev/f3548_self_contained.yaml | 4 +- .../dev/library/environment_containers.yaml | 4 +- 13 files changed, 442 insertions(+), 89 deletions(-) create mode 100644 monitoring/mock_uss/flight_planning/__init__.py create mode 100644 monitoring/mock_uss/flight_planning/routes.py diff --git a/monitoring/mock_uss/__init__.py b/monitoring/mock_uss/__init__.py index faa8e4edfa..7733a0829b 100644 --- a/monitoring/mock_uss/__init__.py +++ b/monitoring/mock_uss/__init__.py @@ -13,6 +13,7 @@ SERVICE_TRACER = "tracer" SERVICE_INTERACTION_LOGGING = "interaction_logging" SERVICE_VERSIONING = "versioning" +SERVICE_FLIGHT_PLANNING = "flight_planning" webapp = MockUSS(__name__) enabled_services = set() @@ -102,6 +103,10 @@ def require_config_value(config_key: str) -> None: enabled_services.add(SERVICE_VERSIONING) from monitoring.mock_uss.versioning import routes as versioning_routes +if SERVICE_FLIGHT_PLANNING in webapp.config[config.KEY_SERVICES]: + enabled_services.add(SERVICE_FLIGHT_PLANNING) + from monitoring.mock_uss.flight_planning import routes as flight_planning_routes + msg = ( "################################################################################\n" + "################################ Configuration ################################\n" diff --git a/monitoring/mock_uss/docker-compose.yaml b/monitoring/mock_uss/docker-compose.yaml index d092c5362b..e154166d09 100644 --- a/monitoring/mock_uss/docker-compose.yaml +++ b/monitoring/mock_uss/docker-compose.yaml @@ -19,7 +19,7 @@ services: - MOCK_USS_TOKEN_AUDIENCE=scdsc.uss1.localutm,localhost,host.docker.internal - MOCK_USS_BASE_URL=http://scdsc.uss1.localutm # TODO: remove interaction_logging once dedicated mock_uss is involved in tests - - MOCK_USS_SERVICES=scdsc,versioning,interaction_logging + - MOCK_USS_SERVICES=scdsc,versioning,interaction_logging,flight_planning - MOCK_USS_INTERACTIONS_LOG_DIR=output/scdsc_a_interaction_logs - MOCK_USS_PORT=80 expose: @@ -47,7 +47,7 @@ services: - MOCK_USS_PUBLIC_KEY=/var/test-certs/auth2.pem - MOCK_USS_TOKEN_AUDIENCE=scdsc.uss2.localutm,localhost,host.docker.internal - MOCK_USS_BASE_URL=http://scdsc.uss2.localutm - - MOCK_USS_SERVICES=scdsc,versioning + - MOCK_USS_SERVICES=scdsc,versioning,flight_planning - MOCK_USS_PORT=80 expose: - 80 diff --git a/monitoring/mock_uss/flight_planning/__init__.py b/monitoring/mock_uss/flight_planning/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/monitoring/mock_uss/flight_planning/routes.py b/monitoring/mock_uss/flight_planning/routes.py new file mode 100644 index 0000000000..54f21caf15 --- /dev/null +++ b/monitoring/mock_uss/flight_planning/routes.py @@ -0,0 +1,182 @@ +import os +from datetime import timedelta +from typing import Tuple + +import flask +from implicitdict import ImplicitDict +from loguru import logger + +from monitoring.mock_uss.scdsc.routes_injection import ( + inject_flight, + lock_flight, + release_flight_lock, + delete_flight, + clear_area, +) +from monitoring.monitorlib.clients.flight_planning.flight_info import ( + FlightInfo, + AirspaceUsageState, +) +from monitoring.monitorlib.clients.mock_uss.mock_uss_scd_injection_api import ( + MockUSSInjectFlightRequest, + MockUssFlightBehavior, +) +from uas_standards.interuss.automated_testing.flight_planning.v1 import api +from uas_standards.interuss.automated_testing.flight_planning.v1.constants import Scope +from uas_standards.interuss.automated_testing.scd.v1 import api as scd_api + +from monitoring.mock_uss import webapp, require_config_value +from monitoring.mock_uss.auth import requires_scope +from monitoring.mock_uss.config import KEY_BASE_URL +from monitoring.monitorlib.idempotency import idempotent_request + + +require_config_value(KEY_BASE_URL) + +DEADLOCK_TIMEOUT = timedelta(seconds=5) + + +@webapp.route("/flight_planning/v1/status", methods=["GET"]) +@requires_scope(Scope.DirectAutomatedTest) +def flight_planning_v1_status() -> Tuple[str, int]: + json, code = injection_status() + return flask.jsonify(json), code + + +def injection_status() -> Tuple[dict, int]: + return ( + api.StatusResponse( + status=api.StatusResponseStatus.Ready, + api_name="Flight Planning Automated Testing Interface", + api_version="v0.3.0", + ), + 200, + ) + + +@webapp.route("/flight_planning/v1/flight_plans/", methods=["PUT"]) +@requires_scope(Scope.Plan) +@idempotent_request() +def flight_planning_v1_upsert_flight_plan(flight_plan_id: str) -> Tuple[str, int]: + def log(msg: str) -> None: + logger.debug(f"[upsert_plan/{os.getpid()}:{flight_plan_id}] {msg}") + + log("Starting handler") + try: + json = flask.request.json + if json is None: + raise ValueError("Request did not contain a JSON payload") + req_body: api.UpsertFlightPlanRequest = ImplicitDict.parse( + json, api.UpsertFlightPlanRequest + ) + except ValueError as e: + msg = "Create flight {} unable to parse JSON: {}".format(flight_plan_id, e) + return msg, 400 + info = FlightInfo.from_flight_plan(req_body.flight_plan) + req = MockUSSInjectFlightRequest(info.to_scd_inject_flight_request()) + if "behavior" in json: + try: + req.behavior = ImplicitDict.parse(json["behavior"], MockUssFlightBehavior) + except ValueError as e: + msg = f"Create flight {flight_plan_id} unable to parse `behavior` field: {str(e)}" + return msg, 400 + + existing_flight = lock_flight(flight_plan_id, log) + try: + if existing_flight: + usage_state = existing_flight.flight_info.basic_information.usage_state + if usage_state == AirspaceUsageState.Planned: + old_status = api.FlightPlanStatus.Planned + elif usage_state == AirspaceUsageState.InUse: + old_status = api.FlightPlanStatus.OkToFly + else: + raise ValueError(f"Unrecognized usage_state '{usage_state}'") + else: + old_status = api.FlightPlanStatus.NotPlanned + + scd_resp, code = inject_flight(flight_plan_id, req, existing_flight) + finally: + release_flight_lock(flight_plan_id, log) + + if scd_resp.result == scd_api.InjectFlightResponseResult.Planned: + result = api.PlanningActivityResult.Completed + plan_status = api.FlightPlanStatus.Planned + notes = None + elif scd_resp.result == scd_api.InjectFlightResponseResult.ReadyToFly: + result = api.PlanningActivityResult.Completed + plan_status = api.FlightPlanStatus.OkToFly + notes = None + elif ( + scd_resp.result == scd_api.InjectFlightResponseResult.ConflictWithFlight + or scd_resp.result == scd_api.InjectFlightResponseResult.Rejected + ): + result = api.PlanningActivityResult.Rejected + plan_status = old_status + notes = scd_resp.notes if "notes" in scd_resp else None + elif scd_resp.result == scd_api.InjectFlightResponseResult.Failed: + result = api.PlanningActivityResult.Failed + plan_status = old_status + notes = scd_resp.notes if "notes" in scd_resp else None + else: + raise ValueError(f"Unexpected scd inject_flight result '{scd_resp.result}'") + + resp = api.UpsertFlightPlanResponse( + planning_result=result, + notes=notes, + flight_plan_status=plan_status, + ) + for k, v in scd_resp.items(): + if k not in {"result", "notes", "operational_intent_id"}: + resp[k] = v + return flask.jsonify(resp), code + + +@webapp.route("/flight_planning/v1/flight_plans/", methods=["DELETE"]) +@requires_scope(Scope.Plan) +def flight_planning_v1_delete_flight(flight_plan_id: str) -> Tuple[str, int]: + """Implements flight deletion in SCD automated testing injection API.""" + scd_resp, code = delete_flight(flight_plan_id) + if code != 200: + raise RuntimeError( + f"DELETE flight plan endpoint expected code 200 from scd handler but received {code} instead" + ) + + if scd_resp.result == scd_api.DeleteFlightResponseResult.Closed: + result = api.PlanningActivityResult.Completed + status = api.FlightPlanStatus.Closed + else: + result = api.PlanningActivityResult.Failed + status = ( + api.FlightPlanStatus.NotPlanned + ) # delete_flight only fails like this when the flight doesn't exist + kwargs = {"planning_result": result, "flight_plan_status": status} + if "notes" in scd_resp: + kwargs["notes"] = scd_resp.notes + resp = api.DeleteFlightPlanResponse(**kwargs) + + return flask.jsonify(resp), code + + +@webapp.route("/flight_planning/v1/clear_area_requests", methods=["POST"]) +@requires_scope(Scope.DirectAutomatedTest) +@idempotent_request() +def flight_planning_v1_clear_area() -> Tuple[str, int]: + try: + json = flask.request.json + if json is None: + raise ValueError("Request did not contain a JSON payload") + req: api.ClearAreaRequest = ImplicitDict.parse(json, api.ClearAreaRequest) + except ValueError as e: + msg = "Unable to parse ClearAreaRequest JSON request: {}".format(e) + return msg, 400 + + scd_req = scd_api.ClearAreaRequest( + request_id=req.request_id, + extent=ImplicitDict.parse(req.extent, scd_api.Volume4D), + ) + + scd_resp, code = clear_area(scd_req) + + resp = ImplicitDict.parse(scd_resp, api.ClearAreaResponse) + + return flask.jsonify(resp), code diff --git a/monitoring/mock_uss/scdsc/routes_injection.py b/monitoring/mock_uss/scdsc/routes_injection.py index 3e858975b8..1622fa76cc 100644 --- a/monitoring/mock_uss/scdsc/routes_injection.py +++ b/monitoring/mock_uss/scdsc/routes_injection.py @@ -2,7 +2,7 @@ import traceback from datetime import datetime, timedelta import time -from typing import List, Tuple, Optional +from typing import List, Tuple, Callable, Optional import uuid import flask @@ -37,7 +37,7 @@ from monitoring.mock_uss.config import KEY_BASE_URL from monitoring.mock_uss.dynamic_configuration.configuration import get_locality from monitoring.mock_uss.scdsc import database, utm_client -from monitoring.mock_uss.scdsc.database import db +from monitoring.mock_uss.scdsc.database import db, FlightRecord from monitoring.mock_uss.scdsc.flight_planning import ( validate_request, check_for_disallowed_conflicts, @@ -158,7 +158,11 @@ def scd_capabilities() -> Tuple[dict, int]: @idempotent_request() def scdsc_inject_flight(flight_id: str) -> Tuple[str, int]: """Implements flight injection in SCD automated testing injection API.""" - logger.debug(f"[inject_flight/{os.getpid()}:{flight_id}] Starting handler") + + def log(msg): + logger.debug(f"[inject_flight/{os.getpid()}:{flight_id}] {msg}") + + log("Starting handler") try: json = flask.request.json if json is None: @@ -167,7 +171,11 @@ def scdsc_inject_flight(flight_id: str) -> Tuple[str, int]: except ValueError as e: msg = "Create flight {} unable to parse JSON: {}".format(flight_id, e) return msg, 400 - json, code = inject_flight(flight_id, req_body) + existing_flight = lock_flight(flight_id, log) + try: + json, code = inject_flight(flight_id, req_body, existing_flight) + finally: + release_flight_lock(flight_id, log) return flask.jsonify(json), code @@ -180,29 +188,9 @@ def _mock_uss_flight_behavior_in_req( return None -def inject_flight( - flight_id: str, req_body: MockUSSInjectFlightRequest -) -> Tuple[dict, int]: - pid = os.getpid() - locality = get_locality() - - def log(msg: str): - logger.debug(f"[inject_flight/{pid}:{flight_id}] {msg}") - - # Validate request - log("Validating request") - try: - validate_request(req_body, locality) - except PlanningError as e: - return ( - InjectFlightResponse( - result=InjectFlightResponseResult.Rejected, notes=str(e) - ), - 200, - ) - +def lock_flight(flight_id: str, log: Callable[[str], None]) -> FlightRecord: # If this is a change to an existing flight, acquire lock to that flight - log("Acquiring lock for flight") + log(f"Acquiring lock for flight {flight_id}") deadline = datetime.utcnow() + DEADLOCK_TIMEOUT while True: with db as tx: @@ -221,14 +209,49 @@ def log(msg: str): # We found an existing flight but it was locked; wait for it to become # available time.sleep(0.5) - log( - f"Waiting for flight lock resolution; now: {datetime.utcnow()} deadline: {deadline}" - ) + if datetime.utcnow() > deadline: - log(f"Deadlock (now: {datetime.utcnow()}, deadline: {deadline})") raise RuntimeError( f"Deadlock in inject_flight while attempting to gain access to flight {flight_id}" ) + return existing_flight + + +def release_flight_lock(flight_id: str, log: Callable[[str], None]) -> None: + with db as tx: + if flight_id in tx.flights: + if tx.flights[flight_id]: + # FlightRecord was a true existing flight + log(f"Releasing lock on existing flight_id {flight_id}") + tx.flights[flight_id].locked = False + else: + # FlightRecord was just a placeholder for a new flight + log(f"Releasing placeholder for existing flight_id {flight_id}") + del tx.flights[flight_id] + + +def inject_flight( + flight_id: str, + req_body: MockUSSInjectFlightRequest, + existing_flight: Optional[FlightRecord], +) -> Tuple[InjectFlightResponse, int]: + pid = os.getpid() + locality = get_locality() + + def log(msg: str): + logger.debug(f"[inject_flight/{pid}:{flight_id}] {msg}") + + # Validate request + log("Validating request") + try: + validate_request(req_body, locality) + except PlanningError as e: + return ( + InjectFlightResponse( + result=InjectFlightResponseResult.Rejected, notes=str(e) + ), + 200, + ) step_name = "performing unknown operation" try: @@ -390,16 +413,6 @@ def log(msg: str): response["queries"] = e.queries response["stacktrace"] = e.stacktrace return response, 200 - finally: - with db as tx: - if tx.flights[flight_id]: - # FlightRecord was a true existing flight - log(f"Releasing placeholder for flight_id {flight_id}") - tx.flights[flight_id].locked = False - else: - # FlightRecord was just a placeholder for a new flight - log("Releasing lock on existing flight_id {flight_id}") - del tx.flights[flight_id] @webapp.route("/scdsc/v1/flights/", methods=["DELETE"]) @@ -410,7 +423,7 @@ def scdsc_delete_flight(flight_id: str) -> Tuple[str, int]: return flask.jsonify(json), code -def delete_flight(flight_id) -> Tuple[dict, int]: +def delete_flight(flight_id) -> Tuple[DeleteFlightResponse, int]: pid = os.getpid() logger.debug(f"[delete_flight/{pid}:{flight_id}] Acquiring flight") deadline = datetime.utcnow() + DEADLOCK_TIMEOUT @@ -519,7 +532,7 @@ def scdsc_clear_area() -> Tuple[str, int]: return flask.jsonify(json), code -def clear_area(req: ClearAreaRequest) -> Tuple[dict, int]: +def clear_area(req: ClearAreaRequest) -> Tuple[ClearAreaResponse, int]: def make_result(success: bool, msg: str) -> ClearAreaResponse: return ClearAreaResponse( outcome=ClearAreaOutcome( diff --git a/monitoring/mock_uss/scdsc/routes_scdsc.py b/monitoring/mock_uss/scdsc/routes_scdsc.py index 9842d01f17..4b2157a51e 100644 --- a/monitoring/mock_uss/scdsc/routes_scdsc.py +++ b/monitoring/mock_uss/scdsc/routes_scdsc.py @@ -27,7 +27,7 @@ def scdsc_get_operational_intent_details(entityid: str): tx = db.value flight = None for f in tx.flights.values(): - if f.op_intent.reference.id == entityid: + if f and f.op_intent.reference.id == entityid: flight = f break diff --git a/monitoring/monitorlib/clients/flight_planning/client_scd.py b/monitoring/monitorlib/clients/flight_planning/client_scd.py index 829c523e36..f19c7eb5c9 100644 --- a/monitoring/monitorlib/clients/flight_planning/client_scd.py +++ b/monitoring/monitorlib/clients/flight_planning/client_scd.py @@ -16,8 +16,6 @@ from monitoring.monitorlib.clients.flight_planning.flight_info import ( FlightInfo, FlightID, - AirspaceUsageState, - UasState, ExecutionStyle, ) from monitoring.monitorlib.clients.flight_planning.planning import ( @@ -50,41 +48,8 @@ def _inject( 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 - ] + req = flight_info.to_scd_inject_flight_request() if "astm_f3548_21" in flight_info and flight_info.astm_f3548_21: priority = flight_info.astm_f3548_21.priority @@ -92,10 +57,10 @@ def _inject( priority = 0 operational_intent = scd_api.OperationalIntentTestInjection( - state=state, + state=req.operational_intent.state, priority=priority, - volumes=volumes, - off_nominal_volumes=off_nominal_volumes, + volumes=req.operational_intent.volumes, + off_nominal_volumes=req.operational_intent.off_nominal_volumes, ) kwargs = {"operational_intent": operational_intent} diff --git a/monitoring/monitorlib/clients/flight_planning/client_v1.py b/monitoring/monitorlib/clients/flight_planning/client_v1.py index 2bfb8dd8c8..8fa3cf32bb 100644 --- a/monitoring/monitorlib/clients/flight_planning/client_v1.py +++ b/monitoring/monitorlib/clients/flight_planning/client_v1.py @@ -1,3 +1,4 @@ +import json import uuid from typing import Optional @@ -39,7 +40,7 @@ def _inject( execution_style: ExecutionStyle, additional_fields: Optional[dict] = None, ) -> PlanningActivityResponse: - flight_plan = ImplicitDict.parse(flight_info, api.FlightPlan) + flight_plan = flight_info.to_flight_plan() req = api.UpsertFlightPlanRequest( flight_plan=flight_plan, execution_style=execution_style, diff --git a/monitoring/monitorlib/clients/flight_planning/flight_info.py b/monitoring/monitorlib/clients/flight_planning/flight_info.py index f780aadd5b..1df3136909 100644 --- a/monitoring/monitorlib/clients/flight_planning/flight_info.py +++ b/monitoring/monitorlib/clients/flight_planning/flight_info.py @@ -6,6 +6,7 @@ from uas_standards.ansi_cta_2063_a import SerialNumber from uas_standards.en4709_02 import OperatorRegistrationNumber from uas_standards.interuss.automated_testing.scd.v1 import api as scd_api +from uas_standards.interuss.automated_testing.flight_planning.v1 import api as fp_api from monitoring.monitorlib.geotemporal import Volume4D, Volume4DCollection @@ -210,6 +211,23 @@ class BasicFlightPlanInformation(ImplicitDict): area: List[Volume4D] """User intends to or may fly anywhere in this entire area.""" + @staticmethod + def from_flight_planning_api( + info: fp_api.BasicFlightPlanInformation, + ) -> BasicFlightPlanInformation: + return BasicFlightPlanInformation( + usage_state=AirspaceUsageState(info.usage_state), + uas_state=UasState(info.uas_state), + area=[Volume4D.from_flight_planning_api(v) for v in info.area], + ) + + def to_flight_planning_api(self) -> fp_api.BasicFlightPlanInformation: + return fp_api.BasicFlightPlanInformation( + usage_state=fp_api.BasicFlightPlanInformationUsageState(self.usage_state), + uas_state=fp_api.BasicFlightPlanInformationUasState(self.uas_state), + area=[v.to_flight_planning_api() for v in self.area], + ) + class FlightInfo(ImplicitDict): """Details of user's intent to create or modify a flight plan.""" @@ -225,6 +243,47 @@ class FlightInfo(ImplicitDict): 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.""" + @staticmethod + def from_flight_plan(plan: fp_api.FlightPlan) -> FlightInfo: + kwargs = { + "basic_information": BasicFlightPlanInformation.from_flight_planning_api( + plan.basic_information + ) + } + if "astm_f3548_21" in plan and plan.astm_f3548_21: + kwargs["astm_f3548_21"] = ImplicitDict.parse( + plan.astm_f3548_21, ASTMF354821OpIntentInformation + ) + if "uspace_flight_authorisation" in plan and plan.uspace_flight_authorisation: + kwargs["uspace_flight_authorisation"] = ImplicitDict.parse( + plan.uspace_flight_authorisation, FlightAuthorisationData + ) + if "rpas_operating_rules_2_6" in plan and plan.rpas_operating_rules_2_6: + kwargs["rpas_operating_rules_2_6"] = ImplicitDict.parse( + plan.rpas_operating_rules_2_6, RPAS26FlightDetails + ) + if "additional_information" in plan and plan.additional_information: + kwargs["additional_information"] = plan.additional_information + return FlightInfo(**kwargs) + + def to_flight_plan(self) -> fp_api.FlightPlan: + kwargs = {"basic_information": self.basic_information.to_flight_planning_api()} + if "astm_f3548_21" in self and self.astm_f3548_21: + kwargs["astm_f3548_21"] = ImplicitDict.parse( + self.astm_f3548_21, fp_api.ASTMF354821OpIntentInformation + ) + if "uspace_flight_authorisation" in self and self.uspace_flight_authorisation: + kwargs["uspace_flight_authorisation"] = ImplicitDict.parse( + self.uspace_flight_authorisation, fp_api.FlightAuthorisationData + ) + if "rpas_operating_rules_2_6" in self and self.rpas_operating_rules_2_6: + kwargs["rpas_operating_rules_2_6"] = ImplicitDict.parse( + self.rpas_operating_rules_2_6, fp_api.RPAS26FlightDetails + ) + if "additional_information" in self and self.additional_information: + kwargs["additional_information"] = self.additional_information + return fp_api.FlightPlan(**kwargs) + @staticmethod def from_scd_inject_flight_request( request: scd_api.InjectFlightRequest, @@ -276,6 +335,60 @@ def from_scd_inject_flight_request( ) return flight_info + def to_scd_inject_flight_request(self) -> scd_api.InjectFlightRequest: + usage_state = self.basic_information.usage_state + uas_state = self.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 self.basic_information.area] + off_nominal_volumes = [] + else: + volumes = [] + off_nominal_volumes = [ + v.to_interuss_scd_api() for v in self.basic_information.area + ] + + if "astm_f3548_21" in self and self.astm_f3548_21: + priority = self.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 self and self.uspace_flight_authorisation: + kwargs["flight_authorisation"] = ImplicitDict.parse( + self.uspace_flight_authorisation, scd_api.FlightAuthorisationData + ) + return scd_api.InjectFlightRequest(**kwargs) + class ExecutionStyle(str, Enum): Hypothetical = "Hypothetical" diff --git a/monitoring/monitorlib/geo.py b/monitoring/monitorlib/geo.py index f06be05c73..c95db7fe2e 100644 --- a/monitoring/monitorlib/geo.py +++ b/monitoring/monitorlib/geo.py @@ -12,11 +12,13 @@ from uas_standards.interuss.automated_testing.rid.v1 import ( injection as f3411testing_injection, ) +from uas_standards.interuss.automated_testing.flight_planning.v1 import api as fp_api EARTH_CIRCUMFERENCE_KM = 40075 EARTH_CIRCUMFERENCE_M = EARTH_CIRCUMFERENCE_KM * 1000 EARTH_RADIUS_M = 40075 * 1000 / (2 * math.pi) EARTH_AREA_M2 = 4 * math.pi * math.pow(EARTH_RADIUS_M, 2) +METERS_PER_FOOT = 0.3048 DISTANCE_TOLERANCE_M = 0.01 COORD_TOLERANCE_DEG = 360 / EARTH_CIRCUMFERENCE_M * DISTANCE_TOLERANCE_M @@ -29,6 +31,14 @@ class DistanceUnits(str, Enum): FT = "FT" """Feet""" + def in_meters(self, value: float) -> float: + if self == DistanceUnits.M: + return value + elif self == DistanceUnits.FT: + return value * METERS_PER_FOOT + else: + raise NotImplementedError(f"Cannot convert from '{self}' to meters") + class LatLngPoint(ImplicitDict): """Vertex in latitude and longitude""" @@ -52,6 +62,9 @@ def from_f3411( lng=position.lng, ) + def to_flight_planning_api(self) -> fp_api.LatLngPoint: + return fp_api.LatLngPoint(lat=self.lat, lng=self.lng) + def as_s2sphere(self) -> s2sphere.LatLng: return s2sphere.LatLng.from_degrees(self.lat, self.lng) @@ -67,6 +80,9 @@ class Radius(ImplicitDict): value: float units: DistanceUnits + def in_meters(self) -> float: + return self.units.in_meters(self.value) + class Polygon(ImplicitDict): vertices: List[LatLngPoint] @@ -147,6 +163,13 @@ def w84m(value: Optional[float]) -> Optional[Altitude]: return None return Altitude(value=value, reference=AltitudeDatum.W84, units=DistanceUnits.M) + def to_flight_planning_api(self) -> fp_api.Altitude: + return fp_api.Altitude( + value=self.units.in_meters(self.value), + reference=fp_api.AltitudeReference(self.reference), + units=fp_api.AltitudeUnits.M, + ) + @staticmethod def from_f3548v21(vol: Union[f3548v21.Altitude, dict]) -> Altitude: return ImplicitDict.parse(vol, Altitude) @@ -237,6 +260,32 @@ def intersects_vol3(self, vol3_2: Volume3D) -> bool: return footprint1.intersects(footprint2) + @staticmethod + def from_flight_planning_api(vol: fp_api.Volume3D) -> Volume3D: + return ImplicitDict.parse(vol, Volume3D) + + def to_flight_planning_api(self) -> fp_api.Volume3D: + kwargs = {} + if self.outline_circle: + kwargs["outline_circle"] = fp_api.Circle( + center=self.outline_circle.center.to_flight_planning_api(), + radius=fp_api.Radius( + value=self.outline_circle.radius.in_meters(), + units=fp_api.RadiusUnits.M, + ), + ) + if self.outline_polygon: + kwargs["outline_polygon"] = fp_api.Polygon( + vertices=[ + v.to_flight_planning_api() for v in self.outline_polygon.vertices + ] + ) + if self.altitude_lower: + kwargs["altitude_lower"] = self.altitude_lower.to_flight_planning_api() + if self.altitude_upper: + kwargs["altitude_upper"] = self.altitude_upper.to_flight_planning_api() + return fp_api.Volume3D(**kwargs) + @staticmethod def from_f3548v21(vol: Union[f3548v21.Volume3D, dict]) -> Volume3D: if not isinstance(vol, f3548v21.Volume3D) and isinstance(vol, dict): diff --git a/monitoring/monitorlib/geotemporal.py b/monitoring/monitorlib/geotemporal.py index a7c00018a1..ee8e652e30 100644 --- a/monitoring/monitorlib/geotemporal.py +++ b/monitoring/monitorlib/geotemporal.py @@ -12,6 +12,7 @@ import s2sphere as s2sphere from uas_standards.astm.f3548.v21 import api as f3548v21 from uas_standards.interuss.automated_testing.scd.v1 import api as interuss_scd_api +from uas_standards.interuss.automated_testing.flight_planning.v1 import api as fp_api from monitoring.monitorlib import geo from monitoring.monitorlib.geo import LatLngPoint, Circle, Altitude, Volume3D, Polygon @@ -344,6 +345,30 @@ def to_interuss_scd_api(self) -> interuss_scd_api.Volume4D: # InterUSS SCD API is field-compatible with ASTM F3548-21 return ImplicitDict.parse(self.to_f3548v21(), interuss_scd_api.Volume4D) + @staticmethod + def from_flight_planning_api(vol: fp_api.Volume4D): + if "time_start" in vol and vol.time_start: + t_start = Time(vol.time_start.value) + else: + t_start = None + if "time_end" in vol and vol.time_end: + t_end = Time(vol.time_end.value) + else: + t_end = None + return Volume4D( + volume=Volume3D.from_flight_planning_api(vol.volume), + time_start=t_start, + time_end=t_end, + ) + + def to_flight_planning_api(self) -> fp_api.Volume4D: + kwargs = {"volume": self.volume.to_flight_planning_api()} + if self.time_start: + kwargs["time_start"] = fp_api.Time(value=self.time_start) + if self.time_end: + kwargs["time_end"] = fp_api.Time(value=self.time_end) + return fp_api.Volume4D(**kwargs) + def resolve_volume4d(template: Volume4DTemplate, start_of_test: datetime) -> Volume4D: """Resolve Volume4DTemplate into concrete Volume4D.""" diff --git a/monitoring/uss_qualifier/configurations/dev/f3548_self_contained.yaml b/monitoring/uss_qualifier/configurations/dev/f3548_self_contained.yaml index 5df2d2bcb6..e2f6665e06 100644 --- a/monitoring/uss_qualifier/configurations/dev/f3548_self_contained.yaml +++ b/monitoring/uss_qualifier/configurations/dev/f3548_self_contained.yaml @@ -42,10 +42,10 @@ v1: flight_planners: # uss1 is the mock_uss directly exposing flight planning functionality - participant_id: uss1 - scd_injection_base_url: http://scdsc.uss1.localutm/scdsc + v1_base_url: http://scdsc.uss1.localutm/flight_planning/v1 # uss2 is another mock_uss directly exposing flight planning functionality - participant_id: uss2 - scd_injection_base_url: http://scdsc.uss2.localutm/scdsc + v1_base_url: http://scdsc.uss2.localutm/flight_planning/v1 # Details of conflicting flights (used in nominal planning scenario) conflicting_flights: diff --git a/monitoring/uss_qualifier/configurations/dev/library/environment_containers.yaml b/monitoring/uss_qualifier/configurations/dev/library/environment_containers.yaml index 3070efcafd..d0d448d9d4 100644 --- a/monitoring/uss_qualifier/configurations/dev/library/environment_containers.yaml +++ b/monitoring/uss_qualifier/configurations/dev/library/environment_containers.yaml @@ -119,7 +119,7 @@ all_flight_planners: specification: flight_planners: - participant_id: uss1 - scd_injection_base_url: http://scdsc.uss1.localutm/scdsc + v1_base_url: http://scdsc.uss1.localutm/flight_planning/v1 local_debug: true - participant_id: uss2 @@ -134,7 +134,7 @@ uss1_flight_planner: specification: flight_planner: participant_id: uss1 - scd_injection_base_url: http://scdsc.uss1.localutm/scdsc + v1_base_url: http://scdsc.uss1.localutm/flight_planning/v1 local_debug: true uss2_flight_planner: