Skip to content

Commit

Permalink
[mock_uss] Remove API objects from flight planning routines (#320)
Browse files Browse the repository at this point in the history
* Use flight planning business objects

* Remove API objects from clear_area

* Fix residual errors

* Address comments

* Only delete op intents that we manage
  • Loading branch information
BenjaminPelletier authored Nov 7, 2023
1 parent 0983ab4 commit d4a864c
Show file tree
Hide file tree
Showing 8 changed files with 405 additions and 310 deletions.
69 changes: 52 additions & 17 deletions monitoring/mock_uss/f3548v21/flight_planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,15 @@ class PlanningError(Exception):
pass


def validate_request(req_body: scd_api.InjectFlightRequest) -> None:
def validate_request(op_intent: f3548_v21.OperationalIntent) -> None:
"""Raise a PlannerError if the request is not valid.
Args:
req_body: Information about the requested flight.
op_intent: Information about the requested flight.
"""
# Validate max number of vertices
nb_vertices = 0
for volume in (
req_body.operational_intent.volumes
+ req_body.operational_intent.off_nominal_volumes
):
for volume in op_intent.details.volumes + op_intent.details.off_nominal_volumes:
if volume.volume.has_field_with_value("outline_polygon"):
nb_vertices += len(volume.volume.outline_polygon.vertices)
if volume.volume.has_field_with_value("outline_circle"):
Expand All @@ -49,34 +46,32 @@ def validate_request(req_body: scd_api.InjectFlightRequest) -> None:

# 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
op_intent.details.volumes + op_intent.details.off_nominal_volumes
).time_start.datetime
time_delta = start_time - datetime.now(tz=start_time.tzinfo)
if (
time_delta.days > OiMaxPlanHorizonDays
and req_body.operational_intent.state == scd_api.OperationalIntentState.Accepted
and op_intent.reference.state == scd_api.OperationalIntentState.Accepted
):
raise PlanningError(
f"Operational intent to plan is too far away in time (max OiMaxPlanHorizonDays={OiMaxPlanHorizonDays})"
)

# Validate no off_nominal_volumes if in Accepted or Activated state
if len(req_body.operational_intent.off_nominal_volumes) > 0 and (
req_body.operational_intent.state == scd_api.OperationalIntentState.Accepted
or req_body.operational_intent.state == scd_api.OperationalIntentState.Activated
if len(op_intent.details.off_nominal_volumes) > 0 and (
op_intent.reference.state == scd_api.OperationalIntentState.Accepted
or op_intent.reference.state == scd_api.OperationalIntentState.Activated
):
raise PlanningError(
f"Operational intent specifies an off-nominal volume while being in {req_body.operational_intent.state} state"
f"Operational intent specifies an off-nominal volume while being in {op_intent.reference.state} state"
)

# Validate intent is currently active if in Activated state
# I.e. at least one volume has start time in the past and end time in the future
if req_body.operational_intent.state == scd_api.OperationalIntentState.Activated:
if op_intent.reference.state == scd_api.OperationalIntentState.Activated:
now = arrow.utcnow().datetime
active_volume = Volume4DCollection.from_interuss_scd_api(
req_body.operational_intent.volumes
+ req_body.operational_intent.off_nominal_volumes
op_intent.details.volumes + op_intent.details.off_nominal_volumes
).has_active_volume(now)
if not active_volume:
raise PlanningError(
Expand Down Expand Up @@ -247,10 +242,15 @@ def op_intent_from_flightinfo(
uss_base_url="{}/mock/scd".format(webapp.config[KEY_BASE_URL]),
subscription_id="UNKNOWN",
)
if "astm_f3548_21" in flight_info and flight_info.astm_f3548_21:
priority = flight_info.astm_f3548_21.priority
else:
# TODO: Ensure this function is only called when sufficient information is available, or raise ValueError
priority = 0
details = f3548_v21.OperationalIntentDetails(
volumes=volumes,
off_nominal_volumes=off_nominal_volumes,
priority=flight_info.astm_f3548_21.priority,
priority=priority,
)
return f3548_v21.OperationalIntent(
reference=reference,
Expand Down Expand Up @@ -436,3 +436,38 @@ def share_op_intent(
utm_client, subscriber.uss_base_url, update
)
return record


def delete_op_intent(
op_intent_ref: f3548_v21.OperationalIntentReference, log: Callable[[str], None]
):
"""Remove the operational intent reference from the DSS in compliance with ASTM F3548-21.
Args:
op_intent_ref: Operational intent reference to remove.
log: Means of indicating debugging information.
Raises:
* QueryError
* ConnectionError
* requests.exceptions.ConnectionError
"""
result = scd_client.delete_operational_intent_reference(
utm_client,
op_intent_ref.id,
op_intent_ref.ovn,
)

base_url = "{}/mock/scd".format(webapp.config[KEY_BASE_URL])
for subscriber in result.subscribers:
if subscriber.uss_base_url == base_url:
# Do not notify ourselves
continue
update = f3548_v21.PutOperationalIntentDetailsParameters(
operational_intent_id=result.operational_intent_reference.id,
subscriptions=subscriber.subscriptions,
)
log(f"Notifying {subscriber.uss_base_url}")
scd_client.notify_operational_intent_details_changed(
utm_client, subscriber.uss_base_url, update
)
121 changes: 41 additions & 80 deletions monitoring/mock_uss/flight_planning/routes.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import os
import uuid
from datetime import timedelta
from typing import Tuple

import flask
from implicitdict import ImplicitDict
from loguru import logger

from monitoring.mock_uss.f3548v21.flight_planning import op_intent_from_flightinfo
from monitoring.mock_uss.flights.database import FlightRecord
from monitoring.mock_uss.scd_injection.routes_injection import (
inject_flight,
lock_flight,
Expand All @@ -15,12 +18,11 @@
)
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,
MockUSSUpsertFlightPlanRequest,
)
from monitoring.monitorlib.geotemporal import Volume4D
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
Expand Down Expand Up @@ -66,95 +68,53 @@ def log(msg: str) -> None:
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
req_body: MockUSSUpsertFlightPlanRequest = ImplicitDict.parse(
json, MockUSSUpsertFlightPlanRequest
)
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)
info = FlightInfo.from_flight_plan(req_body.flight_plan)
op_intent = op_intent_from_flightinfo(info, str(uuid.uuid4()))
new_flight = FlightRecord(
flight_info=info,
op_intent=op_intent,
mod_op_sharing_behavior=req_body.behavior
if "behavior" in req_body and req_body.behavior
else None,
)

inject_resp = inject_flight(flight_plan_id, new_flight, 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,
planning_result=api.PlanningActivityResult(inject_resp.activity_result),
flight_plan_status=api.FlightPlanStatus(inject_resp.flight_plan_status),
)
for k, v in scd_resp.items():
if k not in {"result", "notes", "operational_intent_id"}:
for k, v in inject_resp.items():
if k not in {"planning_result", "flight_plan_status"}:
resp[k] = v
return flask.jsonify(resp), code
return flask.jsonify(resp), 200


@webapp.route("/flight_planning/v1/flight_plans/<flight_plan_id>", 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)
del_resp = delete_flight(flight_plan_id)

return flask.jsonify(resp), code
resp = api.DeleteFlightPlanResponse(
planning_result=api.PlanningActivityResult(del_resp.activity_result),
flight_plan_status=api.FlightPlanStatus(del_resp.flight_plan_status),
)
for k, v in del_resp.items():
if k not in {"planning_result", "flight_plan_status"}:
resp[k] = v
return flask.jsonify(resp), 200


@webapp.route("/flight_planning/v1/clear_area_requests", methods=["POST"])
Expand All @@ -170,13 +130,14 @@ def flight_planning_v1_clear_area() -> Tuple[str, int]:
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)
clear_resp = clear_area(Volume4D.from_flight_planning_api(req.extent))

resp = ImplicitDict.parse(scd_resp, api.ClearAreaResponse)
resp = api.ClearAreaResponse(
outcome=api.ClearAreaOutcome(
success=clear_resp.success,
message="See `details`",
details=clear_resp,
)
)

return flask.jsonify(resp), code
return flask.jsonify(resp), 200
23 changes: 22 additions & 1 deletion monitoring/mock_uss/flights/planning.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import time
from datetime import datetime
from typing import Callable
from typing import Callable, Optional

from monitoring.mock_uss.flights.database import FlightRecord, db, DEADLOCK_TIMEOUT

Expand Down Expand Up @@ -45,3 +45,24 @@ def release_flight_lock(flight_id: str, log: Callable[[str], None]) -> None:
# 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 delete_flight_record(flight_id: str) -> Optional[FlightRecord]:
deadline = datetime.utcnow() + DEADLOCK_TIMEOUT
while True:
with db as tx:
if flight_id in tx.flights:
flight = tx.flights[flight_id]
if flight and not flight.locked:
# FlightRecord was a true existing flight not being mutated anywhere else
del tx.flights[flight_id]
return flight
else:
# No FlightRecord found
return None
# There is a race condition with another handler to create or modify the requested flight; wait for that to resolve
time.sleep(0.5)
if datetime.utcnow() > deadline:
raise RuntimeError(
f"Deadlock in delete_flight while attempting to gain access to flight {flight_id} (now: {datetime.utcnow()}, deadline: {deadline})"
)
Loading

0 comments on commit d4a864c

Please sign in to comment.