Skip to content

Commit 9f1ce26

Browse files
committed
feat: add enclose_poles to antimeridian module
1 parent d5c3f35 commit 9f1ce26

File tree

2 files changed

+96
-15
lines changed

2 files changed

+96
-15
lines changed

src/stactools/core/utils/antimeridian.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,62 @@ def normalize_multipolygon(multi_polygon: MultiPolygon) -> Optional[MultiPolygon
235235
return MultiPolygon(polygons)
236236
else:
237237
return None
238+
239+
240+
def enclose_poles(polygon: Polygon) -> Polygon:
241+
"""Updates an anti-meridian-crossing polygon to enclose the poles.
242+
243+
This works by detecting anti-meridian crossings and adding points to extend
244+
the geometry up to the north (or down to the south) pole. This is useful for
245+
(e.g.) polar-orbiting satellites who have swaths that go over the poles.
246+
247+
This function will raise a value error if the polygon has any interior rings.
248+
249+
Args:
250+
polygon (:py:class:`shapely.geometry.Polygon`): An input polygon.
251+
252+
Returns:
253+
:py:class:`shapely.geometry.Polygon`: The same polygon, modified to
254+
enclose the poles.
255+
"""
256+
if bool(polygon.interiors):
257+
raise ValueError("cannot enclose poles if there is an interior ring")
258+
coords = list(polygon.exterior.coords)
259+
new_coords = list()
260+
for start, end in zip(coords, coords[1:]):
261+
new_coords.append(start)
262+
longitude_delta = end[0] - start[0]
263+
if abs(longitude_delta) > 180:
264+
latitude_delta = end[1] - start[1]
265+
if start[1] > 0:
266+
latitude = 90
267+
else:
268+
latitude = -90
269+
if start[0] > 0:
270+
split_latitude = round(
271+
start[1]
272+
+ (180.0 - start[0]) * latitude_delta / (end[0] + 360 - start[0]),
273+
7,
274+
)
275+
box = (
276+
(180.0, split_latitude),
277+
(180.0, latitude),
278+
(-180.0, latitude),
279+
(-180.0, split_latitude),
280+
)
281+
new_coords.extend(box)
282+
else:
283+
split_latitude = round(
284+
start[1]
285+
+ (start[0] + 180.0) * latitude_delta / (start[0] + 360 - end[0]),
286+
7,
287+
)
288+
box = (
289+
(-180.0, split_latitude),
290+
(-180.0, latitude),
291+
(180.0, latitude),
292+
(180.0, split_latitude),
293+
)
294+
new_coords.extend(box)
295+
new_coords.append(coords[-1])
296+
return Polygon(new_coords)

tests/core/utils/test_antimeridian.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import shapely.geometry
44
from pystac import Item
55
from shapely.geometry import MultiPolygon, Polygon
6-
76
from stactools.core.utils import antimeridian
87

98

@@ -16,7 +15,7 @@ def test_antimeridian_split() -> None:
1615
(
1716
shapely.geometry.box(170, 40, 180, 50),
1817
shapely.geometry.box(-180, 40, -170, 50),
19-
)
18+
),
2019
)
2120
for actual, expected in zip(split.geoms, expected.geoms):
2221
assert actual.exterior.is_ccw
@@ -27,15 +26,15 @@ def test_antimeridian_split() -> None:
2726
assert split is None
2827

2928
canonical_other_way = Polygon(
30-
((-170, 40), (170, 40), (170, 50), (-170, 50), (-170, 40))
29+
((-170, 40), (170, 40), (170, 50), (-170, 50), (-170, 40)),
3130
)
3231
split = antimeridian.split(canonical_other_way)
3332
assert split
3433
expected = MultiPolygon(
3534
(
3635
shapely.geometry.box(-180, 40, -170, 50),
3736
shapely.geometry.box(170, 40, 180, 50),
38-
)
37+
),
3938
)
4039
for actual, expected in zip(split.geoms, expected.geoms):
4140
assert actual.exterior.is_ccw
@@ -44,7 +43,7 @@ def test_antimeridian_split() -> None:
4443

4544
def test_antimeridian_split_complicated() -> None:
4645
complicated = Polygon(
47-
((170, 40), (170, 50), (-170, 50), (170, 45), (-170, 40), (170, 40))
46+
((170, 40), (170, 50), (-170, 50), (170, 45), (-170, 40), (170, 40)),
4847
)
4948
split = antimeridian.split(complicated)
5049
assert split
@@ -57,7 +56,7 @@ def test_antimeridian_split_complicated() -> None:
5756
(170.0, 45.0),
5857
(170.0, 40.0),
5958
(180.0, 40.0),
60-
]
59+
],
6160
),
6261
Polygon([(-180.0, 42.5), (-180.0, 40.0), (-170.0, 40.0), (-180.0, 42.5)]),
6362
Polygon(
@@ -67,10 +66,10 @@ def test_antimeridian_split_complicated() -> None:
6766
(170.0, 50.0),
6867
(170.0, 45.0),
6968
(180.0, 47.5),
70-
]
69+
],
7170
),
7271
Polygon([(-180.0, 50.0), (-180.0, 47.5), (-170.0, 50.0), (-180.0, 50.0)]),
73-
)
72+
),
7473
)
7574
for actual, expected in zip(split.geoms, expected.geoms):
7675
assert actual.exterior.is_ccw
@@ -86,7 +85,7 @@ def test_antimeridian_normalize() -> None:
8685
assert normalized.equals(expected), f"actual={normalized}, expected={expected}"
8786

8887
canonical_other_way = Polygon(
89-
((-170, 40), (170, 40), (170, 50), (-170, 50), (-170, 40))
88+
((-170, 40), (170, 40), (170, 50), (-170, 50), (-170, 40)),
9089
)
9190
normalized = antimeridian.normalize(canonical_other_way)
9291
assert normalized
@@ -129,10 +128,11 @@ def test_item_fix_antimeridian_split() -> None:
129128
(
130129
shapely.geometry.box(170, 40, 180, 50),
131130
shapely.geometry.box(-180, 40, -170, 50),
132-
)
131+
),
133132
)
134133
for actual, expected in zip(
135-
shapely.geometry.shape(fix.geometry).geoms, expected.geoms
134+
shapely.geometry.shape(fix.geometry).geoms,
135+
expected.geoms,
136136
):
137137
assert actual.equals(expected)
138138
assert fix.bbox == [170, 40, -170, 50]
@@ -159,7 +159,7 @@ def test_item_fix_antimeridian_multipolygon_ok() -> None:
159159
(
160160
shapely.geometry.box(170, 40, 180, 50),
161161
shapely.geometry.box(-180, 40, -170, 50),
162-
)
162+
),
163163
)
164164
item = Item(
165165
"an-id",
@@ -177,7 +177,7 @@ def test_antimeridian_multipolygon() -> None:
177177
[
178178
Polygon(((170, 40), (170, 42), (-170, 42), (-170, 40), (170, 40))),
179179
Polygon(((170, 48), (170, 50), (-170, 50), (-170, 48), (170, 48))),
180-
]
180+
],
181181
)
182182
split = antimeridian.split_multipolygon(multi_polygon)
183183
assert split
@@ -187,7 +187,7 @@ def test_antimeridian_multipolygon() -> None:
187187
shapely.geometry.box(-180, 40, -170, 42),
188188
shapely.geometry.box(170, 48, 180, 50),
189189
shapely.geometry.box(-180, 48, -170, 50),
190-
)
190+
),
191191
)
192192
for actual, expected in zip(split.geoms, expected.geoms):
193193
assert actual.exterior.is_ccw
@@ -199,8 +199,30 @@ def test_antimeridian_multipolygon() -> None:
199199
(
200200
Polygon(((170, 40), (170, 42), (190, 42), (190, 40), (170, 40))),
201201
Polygon(((170, 48), (170, 50), (190, 50), (190, 48), (170, 48))),
202-
)
202+
),
203203
)
204204
for actual, expected in zip(normalized.geoms, expected.geoms):
205205
assert actual.exterior.is_ccw
206206
assert actual.equals(expected), f"actual={actual}, expected={expected}"
207+
208+
209+
def test_antimeridian_enclose_poles() -> None:
210+
before = Polygon(((170, 40), (-170, 50), (-170, -50), (170, -40), (170, 40)))
211+
after = antimeridian.enclose_poles(before)
212+
assert after == Polygon(
213+
(
214+
(170, 40),
215+
(180, 45),
216+
(180, 90),
217+
(-180, 90),
218+
(-180, 45),
219+
(-170, 50),
220+
(-170, -50),
221+
(-180, -45),
222+
(-180, -90),
223+
(180, -90),
224+
(180, -45),
225+
(170, -40),
226+
(170, 40),
227+
)
228+
)

0 commit comments

Comments
 (0)