diff --git a/monitoring/monitorlib/geo.py b/monitoring/monitorlib/geo.py index db205bebf9..a6cbc4faab 100644 --- a/monitoring/monitorlib/geo.py +++ b/monitoring/monitorlib/geo.py @@ -208,7 +208,7 @@ def to_flight_planning_api(self) -> fp_api.Altitude: units=fp_api.AltitudeUnits.M, ) - def to_w84_m(self): + def to_w84_m(self) -> float: """This altitude expressed in WGS84 meters, if possible to convert to it.""" if self.reference != AltitudeDatum.W84: raise NotImplementedError( diff --git a/monitoring/monitorlib/geotemporal.py b/monitoring/monitorlib/geotemporal.py index bf08cc224c..e4bf3f9843 100644 --- a/monitoring/monitorlib/geotemporal.py +++ b/monitoring/monitorlib/geotemporal.py @@ -13,7 +13,15 @@ from uas_standards.interuss.automated_testing.scd.v1 import api as interuss_scd_api from monitoring.monitorlib import geo -from monitoring.monitorlib.geo import Altitude, Circle, LatLngPoint, Polygon, Volume3D +from monitoring.monitorlib.geo import ( + Altitude, + AltitudeDatum, + Circle, + DistanceUnits, + LatLngPoint, + Polygon, + Volume3D, +) from monitoring.monitorlib.temporal import ( TestTime, TestTimeContext, @@ -304,6 +312,26 @@ def time_end(self) -> Time | None: else None ) + @property + def altitude_lower(self) -> Altitude | None: + return Altitude( + value=min(v.volume.altitude_lower_wgs84_m() for v in self) + if all(v.volume.altitude_lower_wgs84_m() is not None for v in self) + else None, + reference=AltitudeDatum.W84, + units=DistanceUnits.M, + ) + + @property + def altitude_upper(self) -> Altitude | None: + return Altitude( + value=min(v.volume.altitude_upper_wgs84_m() for v in self) + if all(v.volume.altitude_upper_wgs84_m() is not None for v in self) + else None, + reference=AltitudeDatum.W84, + units=DistanceUnits.M, + ) + def offset_times(self, dt: timedelta) -> Volume4DCollection: return Volume4DCollection([v.offset_time(dt) for v in self]) diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py b/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py index 25cad907f5..942008864b 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/test_steps.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime from enum import Enum from implicitdict import ImplicitDict, Optional @@ -38,6 +39,9 @@ TestScenarioType, ) +NUMERIC_PRECISION_TIME = datetime.timedelta(milliseconds=1) +NUMERIC_PRECISION_DISTANCE = 0.001 # meters + class OpIntentValidator: """ @@ -421,7 +425,7 @@ def _check_op_intent_details( details=f"Received status code {oi_full_query.status_code} from {self._flight_planner.participant_id} when querying for details of operational intent {oi_ref.id}; {e}", query_timestamps=[oi_full_query.request.timestamp], ) - return + raise ScenarioDidNotStopError(check) validation_failures = self._evaluate_op_intent_validation(oi_full_query) with self._scenario.check( @@ -476,6 +480,97 @@ def _check_op_intent_details( query_timestamps=[oi_full_query.request.timestamp], ) + with self._scenario.check( + "Operational intent details extents are contained within reference extents", + [self._flight_planner.participant_id], + ) as check: + all_volumes = Volume4DCollection.from_f3548v21( + oi_full.details.get("volumes", []) + + oi_full.details.get("off_nominal_volumes", []) + ) + + # Time start check + v_time_start = all_volumes.time_start + ref_time_start = oi_ref.get("time_start") + if not v_time_start: + if ref_time_start: + check.record_failed( + summary="Details volume starts before reference", + details="A volume in the operational intent details has no start time (infinite past), but the operational intent reference specifies a start time.", + query_timestamps=[oi_full_query.request.timestamp], + ) + elif ref_time_start: + if ( + v_time_start.datetime + < ref_time_start.value.datetime - NUMERIC_PRECISION_TIME + ): + check.record_failed( + summary="Details volume starts before reference", + details=f"A volume in the operational intent details starts at {v_time_start}, which is before the operational intent reference start time {ref_time_start.value.datetime}.", + query_timestamps=[oi_full_query.request.timestamp], + ) + + # Time end check + v_time_end = all_volumes.time_end + ref_time_end = oi_ref.get("time_end") + if not v_time_end: + if ref_time_end: + check.record_failed( + summary="Details volume ends after reference", + details="A volume in the operational intent details has no end time (infinite future), but the operational intent reference specifies an end time.", + query_timestamps=[oi_full_query.request.timestamp], + ) + elif ref_time_end: + if ( + v_time_end.datetime + > ref_time_end.value.datetime + NUMERIC_PRECISION_TIME + ): + check.record_failed( + summary="Details volume ends after reference", + details=f"A volume in the operational intent details ends at {v_time_end.datetime}, which is after the operational intent reference end time {ref_time_end.value.datetime}.", + query_timestamps=[oi_full_query.request.timestamp], + ) + + # Altitude check (if reference specifies altitude, which it typically doesn't in F3548, but implemented defensively just in case) + v_altitude_lower = all_volumes.altitude_lower + ref_altitude_lower = oi_ref.get("altitude_lower") + if not v_altitude_lower: + if ref_altitude_lower: + check.record_failed( + summary="Details volume lower altitude below reference", + details="A volume in the operational intent details has no lower altitude bound (infinite downward), but the operational intent reference specifies a lower altitude.", + query_timestamps=[oi_full_query.request.timestamp], + ) + elif ref_altitude_lower: + if ( + v_altitude_lower.value + < ref_altitude_lower.value - NUMERIC_PRECISION_DISTANCE + ): + check.record_failed( + summary="Details volume lower altitude below reference", + details=f"A volume in the operational intent details has lower altitude {v_altitude_lower.value}, which is below the operational intent reference lower altitude {ref_altitude_lower.value}.", + query_timestamps=[oi_full_query.request.timestamp], + ) + v_altitude_upper = all_volumes.altitude_upper + ref_altitude_upper = oi_ref.get("altitude_upper") + if not v_altitude_upper: + if ref_altitude_upper: + check.record_failed( + summary="Details volume upper altitude above reference", + details="A volume in the operational intent details has no upper altitude bound (infinite upward), but the operational intent reference specifies an upper altitude.", + query_timestamps=[oi_full_query.request.timestamp], + ) + elif ref_altitude_upper: + if ( + v_altitude_upper.value + > ref_altitude_upper.value + NUMERIC_PRECISION_DISTANCE + ): + check.record_failed( + summary="Details volume upper altitude above reference", + details=f"A volume in the operational intent details has upper altitude {v_altitude_upper.value}, which is above the operational intent reference upper altitude {ref_altitude_upper.value}.", + query_timestamps=[oi_full_query.request.timestamp], + ) + with self._scenario.check( "Off-nominal volumes", [self._flight_planner.participant_id] ) as check: diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/validate_shared_operational_intent.md b/monitoring/uss_qualifier/scenarios/astm/utm/validate_shared_operational_intent.md index 5a356b3545..0cafa7e1f3 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/validate_shared_operational_intent.md +++ b/monitoring/uss_qualifier/scenarios/astm/utm/validate_shared_operational_intent.md @@ -37,6 +37,10 @@ If any of the values in the operational intent reference reported by the USS do If the operational intent details reported by the USS do not match the user's flight intent, this check will fail per **[interuss.automated_testing.flight_planning.ExpectedBehavior](../../../requirements/interuss/automated_testing/flight_planning.md)** and **[astm.f3548.v21.OPIN0025](../../../requirements/astm/f3548/v21.md)**. +## 🛑 Operational intent details extents are contained within reference extents check + +If the 4D extents (start time, end time, and altitude if specified) of any of the operational intent detail volumes are not fully contained within the 4D extents of the operational intent reference, this check will fail per **[astm.f3548.v21.USS0105,1](../../../requirements/astm/f3548/v21.md)**. + ## ⚠️ Off-nominal volumes check **[astm.f3548.v21.OPIN0015](../../../requirements/astm/f3548/v21.md)** specifies that nominal operational intents (Accepted and Activated) must not include any off-nominal 4D volumes, so this check will fail if an Accepted or Activated operational intent includes off-nominal volumes.