Skip to content

Commit f8812c1

Browse files
committed
[uss_qualifier/astm/utm] Validate operational intent changes are always published to DSS
1 parent 74a93e0 commit f8812c1

File tree

8 files changed

+562
-2
lines changed

8 files changed

+562
-2
lines changed

monitoring/monitorlib/geo.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,28 @@ def from_f3548v21(vol: f3548v21.Polygon | dict) -> Polygon:
158158
vertices=[ImplicitDict.parse(p, LatLngPoint) for p in vol.vertices]
159159
)
160160

161+
def is_equivalent(self, other: Polygon) -> bool:
162+
if "vertices" not in self and "vertices" not in other:
163+
return True
164+
elif "vertices" not in self or "vertices" not in other:
165+
return False
166+
167+
if self.vertices == other.vertices:
168+
# covers both None and exact equality
169+
return True
170+
elif not self.vertices or not other.vertices:
171+
# covers one being None
172+
return False
173+
elif len(self.vertices) != len(other.vertices):
174+
return False
175+
176+
return all(
177+
[
178+
vertices[0].match(vertices[1])
179+
for vertices in zip(self.vertices, other.vertices)
180+
]
181+
)
182+
161183

162184
class Circle(ImplicitDict):
163185
center: LatLngPoint
@@ -181,6 +203,15 @@ def from_f3548v21(vol: f3548v21.Circle | dict) -> Circle:
181203
radius=ImplicitDict.parse(vol.radius, Radius),
182204
)
183205

206+
def is_equivalent(self, other: Circle) -> bool:
207+
if not self.center.match(other.center):
208+
return False
209+
210+
return (
211+
abs(self.radius.in_meters() - other.radius.in_meters())
212+
<= DISTANCE_TOLERANCE_M
213+
)
214+
184215

185216
class AltitudeDatum(str, Enum):
186217
W84 = "W84"
@@ -224,6 +255,14 @@ def to_w84_m(self) -> float:
224255
def from_f3548v21(vol: f3548v21.Altitude | dict) -> Altitude:
225256
return ImplicitDict.parse(vol, Altitude)
226257

258+
def is_equivalent(self, other: Altitude) -> bool:
259+
if self.reference != other.reference:
260+
return False
261+
return (
262+
abs(self.units.in_meters(self.value) - other.units.in_meters(other.value))
263+
<= DISTANCE_TOLERANCE_M
264+
)
265+
227266

228267
class Volume3D(ImplicitDict):
229268
outline_circle: Optional[Circle] = None
@@ -447,6 +486,38 @@ def s2_vertices(self) -> list[s2sphere.LatLng]:
447486
else:
448487
return get_latlngrect_vertices(make_latlng_rect(self))
449488

489+
def is_equivalent(
490+
self,
491+
other: Volume3D,
492+
) -> bool:
493+
if self.altitude_lower and other.altitude_lower:
494+
if not self.altitude_lower.is_equivalent(other.altitude_lower):
495+
return False
496+
elif self.altitude_lower or other.altitude_lower:
497+
return False
498+
499+
if self.altitude_upper and other.altitude_upper:
500+
if not self.altitude_upper.is_equivalent(other.altitude_upper):
501+
return False
502+
elif self.altitude_upper or other.altitude_upper:
503+
return False
504+
505+
if self.outline_polygon and other.outline_polygon:
506+
if not self.outline_polygon.is_equivalent(other.outline_polygon):
507+
return False
508+
elif self.outline_circle and other.outline_circle:
509+
if not self.outline_circle.is_equivalent(other.outline_circle):
510+
return False
511+
elif (
512+
self.outline_circle
513+
or self.outline_polygon
514+
or other.outline_circle
515+
or other.outline_polygon
516+
):
517+
return False
518+
519+
return True
520+
450521

451522
def make_latlng_rect(area) -> s2sphere.LatLngRect:
452523
"""Make an S2 LatLngRect from the provided input.

monitoring/monitorlib/geo_test.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import unittest
2+
13
from s2sphere import LatLng
24

35
from monitoring.monitorlib.geo import (
6+
Altitude,
7+
AltitudeDatum,
8+
Circle,
9+
DistanceUnits,
10+
LatLngPoint,
11+
Polygon,
12+
Radius,
13+
Volume3D,
414
generate_area_in_vicinity,
515
generate_slight_overlap_area,
616
)
@@ -12,6 +22,196 @@ def _points(in_points: list[tuple[float, float]]) -> list[LatLng]:
1222
return [LatLng.from_degrees(*p) for p in in_points]
1323

1424

25+
class AltitudeIsEquivalentTest(unittest.TestCase):
26+
def setUp(self):
27+
self.alt1 = Altitude(
28+
value=100, reference=AltitudeDatum.W84, units=DistanceUnits.M
29+
)
30+
31+
def test_equivalent_altitudes(self):
32+
alt2 = Altitude(value=100, reference=AltitudeDatum.W84, units=DistanceUnits.M)
33+
self.assertTrue(self.alt1.is_equivalent(alt2))
34+
35+
def test_equivalent_altitudes_within_tolerance(self):
36+
alt2 = Altitude(
37+
value=100.000001, reference=AltitudeDatum.W84, units=DistanceUnits.M
38+
)
39+
self.assertTrue(self.alt1.is_equivalent(alt2))
40+
41+
def test_equivalent_altitudes_different_units(self):
42+
alt2 = Altitude(
43+
value=328.084, reference=AltitudeDatum.W84, units=DistanceUnits.FT
44+
)
45+
self.assertTrue(self.alt1.is_equivalent(alt2))
46+
47+
def test_nonequivalent_altitudes_different_value(self):
48+
alt2 = Altitude(value=101, reference=AltitudeDatum.W84, units=DistanceUnits.M)
49+
self.assertFalse(self.alt1.is_equivalent(alt2))
50+
51+
def test_nonequivalent_altitudes_different_reference(self):
52+
alt2 = Altitude(value=100, reference=AltitudeDatum.SFC, units=DistanceUnits.M)
53+
self.assertFalse(self.alt1.is_equivalent(alt2))
54+
55+
56+
class PolygonIsEquivalentTest(unittest.TestCase):
57+
def setUp(self):
58+
self.poly1 = Polygon(
59+
vertices=[
60+
LatLngPoint(lat=10, lng=10),
61+
LatLngPoint(lat=11, lng=10),
62+
LatLngPoint(lat=11, lng=11),
63+
LatLngPoint(lat=10, lng=11),
64+
]
65+
)
66+
67+
def test_equivalent_polygons(self):
68+
poly2 = Polygon(
69+
vertices=[
70+
LatLngPoint(lat=10, lng=10),
71+
LatLngPoint(lat=11, lng=10),
72+
LatLngPoint(lat=11, lng=11),
73+
LatLngPoint(lat=10, lng=11),
74+
]
75+
)
76+
self.assertTrue(self.poly1.is_equivalent(poly2))
77+
78+
def test_equivalent_polygons_within_tolerance(self):
79+
poly2 = Polygon(
80+
vertices=[
81+
LatLngPoint(lat=10.00000001, lng=10.00000001),
82+
LatLngPoint(lat=11.00000001, lng=10.00000001),
83+
LatLngPoint(lat=11.00000001, lng=11.00000001),
84+
LatLngPoint(lat=10.00000001, lng=11.00000001),
85+
]
86+
)
87+
self.assertTrue(self.poly1.is_equivalent(poly2))
88+
89+
def test_nonequivalent_polygons(self):
90+
poly2 = Polygon(
91+
vertices=[
92+
LatLngPoint(lat=10, lng=10),
93+
LatLngPoint(lat=12, lng=10),
94+
LatLngPoint(lat=12, lng=11),
95+
LatLngPoint(lat=10, lng=11),
96+
]
97+
)
98+
self.assertFalse(self.poly1.is_equivalent(poly2))
99+
100+
def test_equivalent_polygons_none(self):
101+
poly1 = Polygon(vertices=None)
102+
poly2 = Polygon(vertices=None)
103+
self.assertTrue(poly1.is_equivalent(poly2))
104+
105+
def test_nonequivalent_polygons_one_none(self):
106+
poly1 = Polygon(vertices=[])
107+
poly2 = Polygon(vertices=None)
108+
self.assertFalse(poly1.is_equivalent(poly2))
109+
110+
111+
class Volume3DIsEquivalentTest(unittest.TestCase):
112+
def setUp(self):
113+
self.vol_poly = Volume3D(
114+
outline_polygon=Polygon(
115+
vertices=[
116+
LatLngPoint(lat=10, lng=10),
117+
LatLngPoint(lat=11, lng=10),
118+
LatLngPoint(lat=11, lng=11),
119+
LatLngPoint(lat=10, lng=11),
120+
]
121+
),
122+
altitude_lower=Altitude(
123+
value=100, reference=AltitudeDatum.W84, units=DistanceUnits.M
124+
),
125+
altitude_upper=Altitude(
126+
value=200, reference=AltitudeDatum.W84, units=DistanceUnits.M
127+
),
128+
)
129+
self.vol_circle = Volume3D(
130+
outline_circle=Circle(
131+
center=LatLngPoint(lat=10, lng=10),
132+
radius=Radius(value=100, units=DistanceUnits.M),
133+
),
134+
altitude_lower=Altitude(
135+
value=100, reference=AltitudeDatum.W84, units=DistanceUnits.M
136+
),
137+
altitude_upper=Altitude(
138+
value=200, reference=AltitudeDatum.W84, units=DistanceUnits.M
139+
),
140+
)
141+
142+
def test_equivalent_volumes_polygon(self):
143+
vol2 = Volume3D(
144+
outline_polygon=Polygon(
145+
vertices=[
146+
LatLngPoint(lat=10, lng=10),
147+
LatLngPoint(lat=11, lng=10),
148+
LatLngPoint(lat=11, lng=11),
149+
LatLngPoint(lat=10, lng=11),
150+
]
151+
),
152+
altitude_lower=Altitude(
153+
value=100, reference=AltitudeDatum.W84, units=DistanceUnits.M
154+
),
155+
altitude_upper=Altitude(
156+
value=200, reference=AltitudeDatum.W84, units=DistanceUnits.M
157+
),
158+
)
159+
self.assertTrue(self.vol_poly.is_equivalent(vol2))
160+
161+
def test_equivalent_volumes_circle(self):
162+
vol2 = Volume3D(
163+
outline_circle=Circle(
164+
center=LatLngPoint(lat=10, lng=10),
165+
radius=Radius(value=100, units=DistanceUnits.M),
166+
),
167+
altitude_lower=Altitude(
168+
value=100, reference=AltitudeDatum.W84, units=DistanceUnits.M
169+
),
170+
altitude_upper=Altitude(
171+
value=200, reference=AltitudeDatum.W84, units=DistanceUnits.M
172+
),
173+
)
174+
self.assertTrue(self.vol_circle.is_equivalent(vol2))
175+
176+
def test_nonequivalent_volumes_circle(self):
177+
vol2 = Volume3D(
178+
outline_circle=Circle(
179+
center=LatLngPoint(lat=10, lng=10),
180+
radius=Radius(value=200, units=DistanceUnits.M),
181+
),
182+
altitude_lower=Altitude(
183+
value=100, reference=AltitudeDatum.W84, units=DistanceUnits.M
184+
),
185+
altitude_upper=Altitude(
186+
value=200, reference=AltitudeDatum.W84, units=DistanceUnits.M
187+
),
188+
)
189+
self.assertFalse(self.vol_circle.is_equivalent(vol2))
190+
191+
def test_nonequivalent_volumes_different_shape(self):
192+
vol2 = Volume3D(
193+
outline_circle=Circle(
194+
center=LatLngPoint(lat=10.5, lng=10.5),
195+
radius=Radius(value=50000, units=DistanceUnits.M),
196+
)
197+
)
198+
self.assertFalse(self.vol_poly.is_equivalent(vol2))
199+
200+
def test_equivalent_volumes_none_fields(self):
201+
vol1 = Volume3D()
202+
vol2 = Volume3D()
203+
self.assertTrue(vol1.is_equivalent(vol2))
204+
205+
def test_nonequivalent_volumes_one_none_field(self):
206+
vol1 = Volume3D(
207+
altitude_lower=Altitude(
208+
value=100, reference=AltitudeDatum.W84, units=DistanceUnits.M
209+
)
210+
)
211+
vol2 = Volume3D()
212+
self.assertFalse(vol1.is_equivalent(vol2))
213+
214+
15215
def test_generate_slight_overlap_area():
16216
# Square around 0,0 of edge length 2 -> first corner at 1,1 -> expect a square with overlapping corner at 1,1
17217
assert generate_slight_overlap_area(

monitoring/monitorlib/geotemporal.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
)
3030
from monitoring.monitorlib.transformations import Transformation
3131

32+
TIME_TOLERANCE = timedelta(milliseconds=10)
33+
3234

3335
class Volume4DTemplate(ImplicitDict):
3436
outline_polygon: Optional[Polygon] = None
@@ -129,6 +131,30 @@ class Volume4D(ImplicitDict):
129131
time_start: Optional[Time] = None
130132
time_end: Optional[Time] = None
131133

134+
def is_equivalent(
135+
self,
136+
other: Volume4D,
137+
) -> bool:
138+
if not self.volume.is_equivalent(other.volume):
139+
return False
140+
141+
if (self.time_start is None) != (other.time_start is None):
142+
return False
143+
if self.time_start and other.time_start:
144+
if (
145+
abs(self.time_start.datetime - other.time_start.datetime)
146+
> TIME_TOLERANCE
147+
):
148+
return False
149+
150+
if (self.time_end is None) != (other.time_end is None):
151+
return False
152+
if self.time_end and other.time_end:
153+
if abs(self.time_end.datetime - other.time_end.datetime) > TIME_TOLERANCE:
154+
return False
155+
156+
return True
157+
132158
def offset_time(self, dt: timedelta) -> Volume4D:
133159
kwargs = {"volume": self.volume}
134160
if self.time_start:
@@ -296,6 +322,26 @@ def __iadd__(self, other):
296322
f"Cannot iadd {type(other).__name__} to {type(self).__name__}"
297323
)
298324

325+
def is_equivalent(
326+
self,
327+
other: Volume4DCollection,
328+
) -> bool:
329+
if len(self) != len(other):
330+
return False
331+
332+
# different order is acceptable
333+
other_copy = list(other)
334+
for vol in self:
335+
found = False
336+
for i, other_vol in enumerate(other_copy):
337+
if vol.is_equivalent(other_vol):
338+
other_copy.pop(i)
339+
found = True
340+
break
341+
if not found:
342+
return False
343+
return True
344+
299345
@property
300346
def time_start(self) -> Time | None:
301347
return (

0 commit comments

Comments
 (0)