diff --git a/monitoring/monitorlib/geo.py b/monitoring/monitorlib/geo.py index 753db2dfa4..f63bdabd71 100644 --- a/monitoring/monitorlib/geo.py +++ b/monitoring/monitorlib/geo.py @@ -121,6 +121,14 @@ def from_coords(coords: List[Tuple[float, float]]) -> Polygon: vertices=[LatLngPoint(lat=lat, lng=lng) for (lat, lng) in coords] ) + @staticmethod + def from_latlng_coords(coords: List[LatLng]) -> Polygon: + return Polygon( + vertices=[ + LatLngPoint(lat=p.lat().degrees, lng=p.lng().degrees) for p in coords + ] + ) + @staticmethod def from_latlng_rect(latlngrect: s2sphere.LatLngRect) -> Polygon: return Polygon( @@ -651,3 +659,53 @@ def generate_slight_overlap_area(in_points: List[LatLng]) -> List[LatLng]: ) return [overlap_corner, same_lat_point, opposite_corner, same_lng_point] + + +def generate_area_in_vicinity( + in_points: List[LatLng], relative_distance: float +) -> List[LatLng]: + """ + Takes a list of LatLng points and returns a list of LatLng points that represents + a non-contiguous area in the vicinity of the input. + + The returned polygon is built as such: + - draw a line from the center of the input polygon to the first point of the input polygon + - continue on the line for a distance equal to 'relative_distance' multiplied by the distance between the center and the first point + - from this point, draw a square in the direction opposite of the center of the input polygon + + The square will have a size comparable to half of the input polygon's diameter. + """ + starting_point = in_points[0] # our starting point + + # Compute the center of mass of the input polygon + center = LatLng.from_degrees( + sum([point.lat().degrees for point in in_points]) / len(in_points), + sum([point.lng().degrees for point in in_points]) / len(in_points), + ) + + # Compute the distance between the center and the starting point, as a 2D vector + delta_lat = center.lat().degrees - starting_point.lat().degrees + delta_lng = center.lng().degrees - starting_point.lng().degrees + + # Multiply the vector by the relative distance + distance_lat = delta_lat * relative_distance + distance_lng = delta_lng * relative_distance + + closest_corner = LatLng.from_degrees( + starting_point.lat().degrees - distance_lat, + starting_point.lng().degrees - distance_lng, + ) + + same_lat_point = LatLng.from_degrees( + closest_corner.lat().degrees, closest_corner.lng().degrees - delta_lng + ) + same_lng_point = LatLng.from_degrees( + closest_corner.lat().degrees - delta_lat, closest_corner.lng().degrees + ) + + opposite_corner = LatLng.from_degrees( + closest_corner.lat().degrees - delta_lat, + closest_corner.lng().degrees - delta_lng, + ) + + return [closest_corner, same_lat_point, opposite_corner, same_lng_point] diff --git a/monitoring/monitorlib/geo_test.py b/monitoring/monitorlib/geo_test.py index ba2b52af17..c9922b518b 100644 --- a/monitoring/monitorlib/geo_test.py +++ b/monitoring/monitorlib/geo_test.py @@ -2,7 +2,12 @@ from s2sphere import LatLng -from monitoring.monitorlib.geo import generate_slight_overlap_area +from monitoring.monitorlib.geo import ( + generate_slight_overlap_area, + generate_area_in_vicinity, +) + +MAX_DIFFERENCE = 0.001 def _points(in_points: List[Tuple[float, float]]) -> List[LatLng]: @@ -34,3 +39,30 @@ def test_generate_slight_overlap_area(): assert generate_slight_overlap_area( _points([(1, -1), (1, 0), (0, 0), (0, -1)]) ) == _points([(1, -1), (1, -1.5), (1.5, -1.5), (1.5, -1)]) + + +def _approx_equals(p1: List[LatLng], p2: List[LatLng]) -> bool: + return all([p1[i].approx_equals(p2[i], MAX_DIFFERENCE) for i in range(len(p1))]) + + +def test_generate_area_in_vicinity(): + # Square around 0,0 of edge length 2 -> first corner at 1,1. rel_distance of 2: + # expect a 1 by 1 square with the closest corner at 3,3 + assert _approx_equals( + generate_area_in_vicinity(_points([(1, 1), (1, -1), (-1, -1), (-1, 1)]), 2), + _points([(3.0, 3.0), (3.0, 4.0), (4.0, 4.0), (4.0, 3.0)]), + ) + + # Square around 0,0 of edge length 2 -> first corner at 1,-1. rel_distance of 2: + # expect a 1 by 1 square with the closest corner at 3,-3 + assert _approx_equals( + generate_area_in_vicinity(_points([(1, -1), (-1, -1), (-1, 1), (1, 1)]), 2), + _points([(3.0, -3.0), (3.0, -4.0), (4.0, -4.0), (4.0, -3.0)]), + ) + + # Square with diagonal from 0,0 to -1,-1 -> first corner at -1,-1. rel_distance of 2: + # expect a .5 by .5 square with the closest corner at -2,-2 + assert _approx_equals( + generate_area_in_vicinity(_points([(-1, -1), (0, -1), (0, 0), (-1, 0)]), 2), + _points([(-2.0, -2.0), (-2.0, -2.5), (-2.5, -2.5), (-2.5, -2.0)]), + ) diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/sub/sync.md b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/sub/sync.md index 4a8e3e2f05..fe52647a8e 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/sub/sync.md +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/sub/sync.md @@ -41,3 +41,13 @@ either one of the instances at which the subscription was created or the one tha If the subscription returned by a DSS to which the subscription was synchronized to does not contain the expected notification count, either one of the instances at which the subscription was created or the one that was queried, may be failing to implement **[astm.f3548.v21.DSS0210,1i](../../../../../../requirements/astm/f3548/v21.md)**. + +## 🛑 Secondary DSS returns the subscription in searches for area that contains it check + +The secondary DSS should be aware of the subscription's area: when a search query is issued for an area that encompasses the created subscription, +the secondary DSS should return the subscription in its search results. Otherwise, it is in violation of **[astm.f3548.v21.DSS0210,1d](../../../../../../requirements/astm/f3548/v21.md)**. + +## 🛑 Secondary DSS does not return the subscription in searches not encompassing the general area of the subscription check + +The secondary DSS should be aware of the subscription's area: when a search query is issued for an area not in the vicinity of the created subscription, +the secondary DSS should not return it in its search results. Otherwise, it is in violation of **[astm.f3548.v21.DSS0210,1d](../../../../../../requirements/astm/f3548/v21.md)**. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/subscription_synchronization.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/subscription_synchronization.py index cc53be9073..6cb84e84be 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/subscription_synchronization.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/subscription_synchronization.py @@ -5,6 +5,8 @@ from uas_standards.astm.f3548.v21.api import Subscription, SubscriptionID from uas_standards.astm.f3548.v21.constants import Scope +from monitoring.monitorlib import geo +from monitoring.monitorlib.geo import Volume3D from monitoring.monitorlib.geotemporal import Volume4D from monitoring.monitorlib.mutate.scd import MutatedSubscription from monitoring.prober.infrastructure import register_resource_type @@ -93,6 +95,26 @@ def __init__( volume=self._planning_area.volume, ) + # Get a list of vertices enclosing the area + enclosing_area = geo.get_latlngrect_vertices( + geo.make_latlng_rect(self._planning_area_volume4d.volume) + ) + + self._enclosing_sub_area_volume4d = Volume4D( + volume=Volume3D( + outline_polygon=geo.Polygon.from_latlng_coords(enclosing_area) + ) + ) + + # Get a list of vertices outside the subscription's area + outside_area = geo.generate_area_in_vicinity(enclosing_area, 2) + + self._outside_sub_area_volume4d = Volume4D( + volume=Volume3D( + outline_polygon=geo.Polygon.from_latlng_coords(outside_area) + ) + ) + self._sub_params = self._planning_area.get_new_subscription_params( subscription_id=self._sub_id, # Set this slightly in the past: we will update the subscriptions @@ -204,20 +226,91 @@ def _create_sub_with_params(self, creation_params: SubscriptionParams): def _query_secondaries_and_compare(self, expected_sub_params: SubscriptionParams): for secondary_dss in self._dss_read_instances: - self._validate_sub_from_secondary( + self._validate_get_sub_from_secondary( secondary_dss=secondary_dss, expected_sub_params=expected_sub_params, involved_participants=list( {self._primary_pid, secondary_dss.participant_id} ), ) + self._validate_sub_area_from_secondary( + secondary_dss=secondary_dss, + expected_sub_id=expected_sub_params.sub_id, + involved_participants=list( + {self._primary_pid, secondary_dss.participant_id} + ), + ) + + def _validate_sub_area_from_secondary( + self, + secondary_dss: DSSInstance, + expected_sub_id: str, + involved_participants: List[str], + ): + """Checks that the secondary DSS is also aware of the proper subscription's area: + - searching for the subscription's area should yield the subscription + - searching outside the subscription's area should not yield the subscription""" + + # Query the subscriptions inside the enclosing area + sub_included = secondary_dss.query_subscriptions( + self._enclosing_sub_area_volume4d.to_f3548v21() + ) + + with self.check( + "Successful subscription search query", secondary_dss.participant_id + ) as check: + if sub_included.status_code != 200: + check.record_failed( + "Subscription search query failed", + details=f"Subscription search query failed with status code {sub_included.status_code}", + query_timestamps=[sub_included.request.timestamp], + ) + + with self.check( + "Secondary DSS returns the subscription in searches for area that contains it", + involved_participants, + ) as check: + if expected_sub_id not in sub_included.subscriptions: + check.record_failed( + "Secondary DSS did not return the subscription", + details=f"Secondary DSS did not return the subscription {expected_sub_id} " + f"although the search volume covered the subscription's area", + query_timestamps=[sub_included.request.timestamp], + ) + + sub_not_included = secondary_dss.query_subscriptions( + self._outside_sub_area_volume4d.to_f3548v21() + ) + + with self.check( + "Successful subscription search query", secondary_dss.participant_id + ) as check: + if sub_not_included.status_code != 200: + check.record_failed( + summary="Subscription search query failed", + details=f"Subscription search query failed with status code {sub_included.status_code}", + query_timestamps=[sub_included.request.timestamp], + ) + + with self.check( + "Secondary DSS does not return the subscription in searches not encompassing the general area of the subscription", + involved_participants, + ) as check: + if expected_sub_id in sub_not_included.subscriptions: + check.record_failed( + summary="Secondary DSS returned the subscription", + details=f"Secondary DSS returned the subscription {expected_sub_id} " + f"although the search volume did not cover the subscription's general area", + query_timestamps=[sub_not_included.request.timestamp], + ) - def _validate_sub_from_secondary( + def _validate_get_sub_from_secondary( self, secondary_dss: DSSInstance, expected_sub_params: SubscriptionParams, involved_participants: List[str], ): + """Fetches the subscription from the secondary DSS and validates it.""" with self.check( "Subscription can be found at every DSS", involved_participants, diff --git a/monitoring/uss_qualifier/suites/astm/utm/dss_probing.md b/monitoring/uss_qualifier/suites/astm/utm/dss_probing.md index 1df3720f41..c9d4e631c4 100644 --- a/monitoring/uss_qualifier/suites/astm/utm/dss_probing.md +++ b/monitoring/uss_qualifier/suites/astm/utm/dss_probing.md @@ -21,7 +21,7 @@