Skip to content

Commit 93b3f22

Browse files
committed
feat: add enclose_poles to antimeridian module
1 parent d5c3f35 commit 93b3f22

File tree

2 files changed

+199
-15
lines changed

2 files changed

+199
-15
lines changed

src/stactools/core/utils/antimeridian.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
"""`Antimeridian <https://en.wikipedia.org/wiki/180th_meridian>`_ utilities."""
2+
3+
from __future__ import annotations
4+
25
import math
6+
from dataclasses import dataclass
37
from enum import Enum, auto
4-
from typing import Optional
8+
from typing import List, Optional, Tuple
59

610
import shapely.affinity
711
import shapely.geometry
@@ -235,3 +239,113 @@ def normalize_multipolygon(multi_polygon: MultiPolygon) -> Optional[MultiPolygon
235239
return MultiPolygon(polygons)
236240
else:
237241
return None
242+
243+
244+
def enclose_poles(polygon: Polygon) -> Polygon:
245+
"""Updates an anti-meridian-crossing polygon to enclose the poles.
246+
247+
This works by detecting anti-meridian crossings and adding points to extend
248+
the geometry up to the north (or down to the south) pole. This is useful for
249+
(e.g.) polar-orbiting satellites who have swaths that go over the poles.
250+
251+
This function will raise a value error if the polygon has any interior rings.
252+
253+
Args:
254+
polygon (:py:class:`shapely.geometry.Polygon`): An input polygon.
255+
256+
Returns:
257+
:py:class:`shapely.geometry.Polygon`: The same polygon, modified to
258+
enclose the poles.
259+
"""
260+
if bool(polygon.interiors):
261+
raise ValueError("cannot enclose poles if there is an interior ring")
262+
coords = list(polygon.exterior.coords)
263+
crossings = list()
264+
265+
# First pass is to detect all antimeridian crossings, without actually
266+
# modifying the coordinates. This is to protect against the case when there
267+
# are additional crossings in between the pole crossings.
268+
for i, (start, end) in enumerate(zip(coords, coords[1:])):
269+
longitude_delta = end[0] - start[0]
270+
if abs(longitude_delta) > 180:
271+
crossings.append(_Crossing.from_points(i, start, end))
272+
273+
# We only want the southernmost and northernmost crossings.
274+
crossings = sorted(crossings, key=lambda c: c.latitude)
275+
if len(crossings) > 2:
276+
crossings = [crossings[0], crossings[-1]]
277+
278+
# If there are two crossings, we know the southernmost one is around the
279+
# south pole and the northernmost is around the north pole, even if the
280+
# crossing latitude is in the other hemisphere.
281+
#
282+
# If we only have one crossing, we just guess that it's crossing on the
283+
# closer pole.
284+
if len(crossings) == 2:
285+
crossings[0].north_pole = False
286+
crossings[1].north_pole = True
287+
288+
# We work from the back of the list so we can use the indices.
289+
crossings = sorted(crossings, key=lambda c: c.index, reverse=True)
290+
for crossing in crossings:
291+
coords = (
292+
coords[0 : crossing.index + 1]
293+
+ crossing.enclosure()
294+
+ coords[crossing.index + 1 :]
295+
)
296+
297+
return Polygon(coords)
298+
299+
300+
@dataclass
301+
class _Crossing:
302+
index: int
303+
latitude: float
304+
positive_to_negative: bool
305+
north_pole: bool
306+
307+
@classmethod
308+
def from_points(
309+
cls, index: int, start: Tuple[float, float], end: Tuple[float, float]
310+
) -> _Crossing:
311+
latitude_delta = end[1] - start[1]
312+
if start[0] > 0:
313+
split_latitude = round(
314+
start[1]
315+
+ (180.0 - start[0]) * latitude_delta / (end[0] + 360.0 - start[0]),
316+
7,
317+
)
318+
return cls(
319+
index=index,
320+
latitude=split_latitude,
321+
positive_to_negative=True,
322+
north_pole=split_latitude > 0,
323+
)
324+
else:
325+
split_latitude = round(
326+
start[1]
327+
+ (start[0] + 180.0) * latitude_delta / (start[0] + 360.0 - end[0]),
328+
7,
329+
)
330+
return cls(
331+
index=index,
332+
latitude=split_latitude,
333+
positive_to_negative=False,
334+
north_pole=split_latitude > 0,
335+
)
336+
337+
def enclosure(self) -> List[Tuple[float, float]]:
338+
if self.positive_to_negative:
339+
longitudes = (180.0, -180.0)
340+
else:
341+
longitudes = (-180.0, 180.0)
342+
if self.north_pole:
343+
pole_latitude = 90.0
344+
else:
345+
pole_latitude = -90.0
346+
return [
347+
(longitudes[0], self.latitude),
348+
(longitudes[0], pole_latitude),
349+
(longitudes[1], pole_latitude),
350+
(longitudes[1], self.latitude),
351+
]

tests/core/utils/test_antimeridian.py

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def test_antimeridian_split() -> None:
1616
(
1717
shapely.geometry.box(170, 40, 180, 50),
1818
shapely.geometry.box(-180, 40, -170, 50),
19-
)
19+
),
2020
)
2121
for actual, expected in zip(split.geoms, expected.geoms):
2222
assert actual.exterior.is_ccw
@@ -27,15 +27,15 @@ def test_antimeridian_split() -> None:
2727
assert split is None
2828

2929
canonical_other_way = Polygon(
30-
((-170, 40), (170, 40), (170, 50), (-170, 50), (-170, 40))
30+
((-170, 40), (170, 40), (170, 50), (-170, 50), (-170, 40)),
3131
)
3232
split = antimeridian.split(canonical_other_way)
3333
assert split
3434
expected = MultiPolygon(
3535
(
3636
shapely.geometry.box(-180, 40, -170, 50),
3737
shapely.geometry.box(170, 40, 180, 50),
38-
)
38+
),
3939
)
4040
for actual, expected in zip(split.geoms, expected.geoms):
4141
assert actual.exterior.is_ccw
@@ -44,7 +44,7 @@ def test_antimeridian_split() -> None:
4444

4545
def test_antimeridian_split_complicated() -> None:
4646
complicated = Polygon(
47-
((170, 40), (170, 50), (-170, 50), (170, 45), (-170, 40), (170, 40))
47+
((170, 40), (170, 50), (-170, 50), (170, 45), (-170, 40), (170, 40)),
4848
)
4949
split = antimeridian.split(complicated)
5050
assert split
@@ -57,7 +57,7 @@ def test_antimeridian_split_complicated() -> None:
5757
(170.0, 45.0),
5858
(170.0, 40.0),
5959
(180.0, 40.0),
60-
]
60+
],
6161
),
6262
Polygon([(-180.0, 42.5), (-180.0, 40.0), (-170.0, 40.0), (-180.0, 42.5)]),
6363
Polygon(
@@ -67,10 +67,10 @@ def test_antimeridian_split_complicated() -> None:
6767
(170.0, 50.0),
6868
(170.0, 45.0),
6969
(180.0, 47.5),
70-
]
70+
],
7171
),
7272
Polygon([(-180.0, 50.0), (-180.0, 47.5), (-170.0, 50.0), (-180.0, 50.0)]),
73-
)
73+
),
7474
)
7575
for actual, expected in zip(split.geoms, expected.geoms):
7676
assert actual.exterior.is_ccw
@@ -86,7 +86,7 @@ def test_antimeridian_normalize() -> None:
8686
assert normalized.equals(expected), f"actual={normalized}, expected={expected}"
8787

8888
canonical_other_way = Polygon(
89-
((-170, 40), (170, 40), (170, 50), (-170, 50), (-170, 40))
89+
((-170, 40), (170, 40), (170, 50), (-170, 50), (-170, 40)),
9090
)
9191
normalized = antimeridian.normalize(canonical_other_way)
9292
assert normalized
@@ -129,10 +129,11 @@ def test_item_fix_antimeridian_split() -> None:
129129
(
130130
shapely.geometry.box(170, 40, 180, 50),
131131
shapely.geometry.box(-180, 40, -170, 50),
132-
)
132+
),
133133
)
134134
for actual, expected in zip(
135-
shapely.geometry.shape(fix.geometry).geoms, expected.geoms
135+
shapely.geometry.shape(fix.geometry).geoms,
136+
expected.geoms,
136137
):
137138
assert actual.equals(expected)
138139
assert fix.bbox == [170, 40, -170, 50]
@@ -159,7 +160,7 @@ def test_item_fix_antimeridian_multipolygon_ok() -> None:
159160
(
160161
shapely.geometry.box(170, 40, 180, 50),
161162
shapely.geometry.box(-180, 40, -170, 50),
162-
)
163+
),
163164
)
164165
item = Item(
165166
"an-id",
@@ -177,7 +178,7 @@ def test_antimeridian_multipolygon() -> None:
177178
[
178179
Polygon(((170, 40), (170, 42), (-170, 42), (-170, 40), (170, 40))),
179180
Polygon(((170, 48), (170, 50), (-170, 50), (-170, 48), (170, 48))),
180-
]
181+
],
181182
)
182183
split = antimeridian.split_multipolygon(multi_polygon)
183184
assert split
@@ -187,7 +188,7 @@ def test_antimeridian_multipolygon() -> None:
187188
shapely.geometry.box(-180, 40, -170, 42),
188189
shapely.geometry.box(170, 48, 180, 50),
189190
shapely.geometry.box(-180, 48, -170, 50),
190-
)
191+
),
191192
)
192193
for actual, expected in zip(split.geoms, expected.geoms):
193194
assert actual.exterior.is_ccw
@@ -199,8 +200,77 @@ def test_antimeridian_multipolygon() -> None:
199200
(
200201
Polygon(((170, 40), (170, 42), (190, 42), (190, 40), (170, 40))),
201202
Polygon(((170, 48), (170, 50), (190, 50), (190, 48), (170, 48))),
202-
)
203+
),
203204
)
204205
for actual, expected in zip(normalized.geoms, expected.geoms):
205206
assert actual.exterior.is_ccw
206207
assert actual.equals(expected), f"actual={actual}, expected={expected}"
208+
209+
210+
def test_antimeridian_enclose_poles() -> None:
211+
before = Polygon(((170, 40), (-170, 50), (-170, -50), (170, -40), (170, 40)))
212+
after = antimeridian.enclose_poles(before)
213+
assert after == Polygon(
214+
(
215+
(170, 40),
216+
(180, 45),
217+
(180, 90),
218+
(-180, 90),
219+
(-180, 45),
220+
(-170, 50),
221+
(-170, -50),
222+
(-180, -45),
223+
(-180, -90),
224+
(180, -90),
225+
(180, -45),
226+
(170, -40),
227+
(170, 40),
228+
)
229+
)
230+
231+
232+
def test_antimeridian_enclose_poles_extra_crossing() -> None:
233+
before = Polygon(
234+
((170, 40), (-170, 50), (-170, -50), (170, -40), (-175, 0), (170, 40))
235+
)
236+
after = antimeridian.enclose_poles(before)
237+
assert after == Polygon(
238+
(
239+
(170, 40),
240+
(180, 45),
241+
(180, 90),
242+
(-180, 90),
243+
(-180, 45),
244+
(-170, 50),
245+
(-170, -50),
246+
(-180, -45),
247+
(-180, -90),
248+
(180, -90),
249+
(180, -45),
250+
(170, -40),
251+
(-175, 0),
252+
(170, 40),
253+
)
254+
)
255+
256+
257+
def test_antimeridian_enclose_poles_both_northern_hemisphere() -> None:
258+
before = Polygon(((170, 80), (-170, 80), (-170, 10), (170, 10), (170, 80)))
259+
after = antimeridian.enclose_poles(before)
260+
assert after == Polygon(
261+
(
262+
(170, 80),
263+
(180, 80),
264+
(180, 90),
265+
(-180, 90),
266+
(-180, 80),
267+
(-170, 80),
268+
(-170, 10),
269+
(-180, 10),
270+
(-180, -90),
271+
(180, -90),
272+
(180, 10),
273+
(170, 10),
274+
(170, 80),
275+
)
276+
)

0 commit comments

Comments
 (0)