Skip to content

Commit 478fc5f

Browse files
committed
Add missing tests and features
1 parent c7c3e6f commit 478fc5f

2 files changed

Lines changed: 179 additions & 63 deletions

File tree

pyresample/future/spherical/extent.py

Lines changed: 90 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def _get_list_extents_from_args(args):
5353
if len(list_extents) == 4 and isinstance(list_extents[0], numbers.Number):
5454
list_extents = [list_extents]
5555
# Ensure that here after is a list of extents
56-
if not isinstance(list_extents[0], (tuple, list)):
56+
if not isinstance(list_extents[0], (tuple, list, np.ndarray)):
5757
raise ValueError("SExtent expects a single extent or a list of extents.")
5858
return list_extents
5959

@@ -70,6 +70,7 @@ def _check_extent_values(extent, use_radians=False):
7070
"""Check extent value validity."""
7171
lat_valid_range = [-90.0, 90.0]
7272
lon_valid_range = [-180.0, 180.0]
73+
extent = np.array(extent)
7374
if use_radians:
7475
lat_valid_range = np.deg2rad(lat_valid_range)
7576
lon_valid_range = np.deg2rad(lon_valid_range)
@@ -79,11 +80,20 @@ def _check_extent_values(extent, use_radians=False):
7980
raise ValueError(f'extent latitude values must be within {lat_valid_range}.')
8081

8182

83+
def _check_is_not_point_extent(extent):
84+
"""Check the extent does not represent a point."""
85+
if extent[0] == extent[1] and extent[2] == extent[3]:
86+
raise ValueError("An extent can not be defined by a point.")
87+
88+
89+
def _check_is_not_line_extent(extent):
90+
"""Check the extent does not represent a line."""
91+
if extent[0] == extent[1] or extent[2] == extent[3]:
92+
raise ValueError("An extent can not be defined by a line.")
93+
94+
8295
def _check_valid_extent(extent, use_radians=False):
8396
"""Check lat/lon extent validity."""
84-
# Check extent dtype
85-
if not isinstance(extent, (tuple, list, np.ndarray)):
86-
raise TypeError("'extent' must be a list, tuple or np.array. [lon_min, lon_max, lat_min, lat_max].")
8797
# Check extent length
8898
if len(extent) != 4:
8999
raise ValueError("'extent' must have length 4: [lon_min, lon_max, lat_min, lat_max].")
@@ -92,38 +102,67 @@ def _check_valid_extent(extent, use_radians=False):
92102
# Check order and values
93103
_check_extent_order(extent)
94104
_check_extent_values(extent, use_radians=use_radians)
105+
# Check is not point or line
106+
_check_is_not_point_extent(extent)
107+
_check_is_not_line_extent(extent)
95108
# Ensure is a list
96109
extent = extent.tolist()
97110
return extent
98111

99112

100-
def _check_topology_validity(polygons):
101-
"""Check that the extents polygons do not overlap."""
102-
from shapely.topology import TopologicalError
103-
try:
104-
# TODO: improve to raise error also when duplicate geometries or within on another
105-
# TopologicalError: The operation 'GEOSIntersects_r' could not be performed.
106-
# Likely cause is invalidity of the geometry
107-
polygons.intersects(polygons)
108-
except TopologicalError:
109-
raise ValueError("The extent list is not valid. The composing extents must not overlap each other.")
113+
def _check_non_overlapping_extents(polygons):
114+
"""Check that the extents polygons are non-overlapping.
115+
116+
Note: Touching extents are considered valids.
117+
"""
118+
for poly in polygons:
119+
# Intersects includes within/contain/equals/touches !
120+
n_intersects = np.sum([p.intersects(poly) and not p.touches(poly) for p in polygons])
121+
if n_intersects >= 2:
122+
raise ValueError("The extents composing SExtent can not be duplicates or overlapping.")
123+
124+
125+
def _is_global_extent(polygons):
126+
"""Check if a list of extents composes a global extent."""
127+
from shapely.geometry import Polygon
128+
from shapely.ops import unary_union
129+
130+
# Try union the polygons
131+
unioned_polygon = unary_union(polygons)
132+
# If still a MultiPolygon, not a global extent
133+
if not isinstance(unioned_polygon, Polygon):
134+
return False
135+
# Define global polygon
136+
global_polygon = Polygon.from_bounds(-180, -90, 180, 90)
137+
# Assess if global
138+
is_global = global_polygon.equals(unioned_polygon)
139+
return is_global
110140

111141

112142
class SExtent(object):
113143
"""Spherical Extent.
114144
115145
SExtent longitudes are defined between -180 and 180 degree.
146+
Geometrical operations are performed on a planar coordinate system.
147+
116148
A spherical geometry crossing the anti-meridian will have an SExtent
117149
composed of [lon_start, 180, ...,...] and [-180, lon_end, ..., ...]
118150
119-
The extents composing an SExtent:
120-
- can not intersect/overlap each other
121-
- can touch each other
151+
Important notes:
152+
- SExtents touching at the anti-meridian are considered to not touching !
153+
- SExtents.intersects predicate includes also contains/within cases.
154+
- In comparison to shapely polygon behaviour, SExtent.intersects is
155+
False if SExtents are just touching.
122156
123-
There is not an upper limit on the number of extents composing SExtent.
124-
The only conditions is that the extents do not intersect/overlap.
157+
There is not an upper limit on the number of extents composing SExtent !!!
158+
The only conditions is that the extents do not intersect/overlap each other.
125159
126-
Examples of valid SExtent inputs
160+
More specifically, extents composing an SExtent:
161+
- must represent a polygon (not a point or a line),
162+
- can touch each other,
163+
- but can not intersect/contain/overlap each other.
164+
165+
Examples of valid SExtent definitions:
127166
extent = [x_min, x_max, y_min, ymax]
128167
sext = SExtent(extent)
129168
sext = SExtent([extent, extent])
@@ -139,8 +178,8 @@ def __init__(self, *args):
139178
# Pre-compute shapely polygon
140179
list_polygons = [Polygon.from_bounds(*bounds_from_extent(ext)) for ext in self.list]
141180
self.polygons = MultiPolygon(list_polygons)
142-
# Check topological validitiy of extent polygons
143-
_check_topology_validity(self.polygons)
181+
# Check topological validity of extents polygons
182+
_check_non_overlapping_extents(self.polygons)
144183

145184
def to_shapely(self):
146185
"""Return the shapely extent rectangle(s) polygon(s)."""
@@ -158,31 +197,45 @@ def __iter__(self):
158197
"""Get extents iterator."""
159198
return self.list.__iter__()
160199

200+
def _repr_svg_(self):
201+
"""Display the SExtent in the Ipython terminal."""
202+
return self.to_shapely()._repr_svg_()
203+
161204
@property
162205
def is_global(self):
163206
"""Check if the extent is global."""
164-
if len(self.list) != 1:
165-
return False
166-
if self.list[0] == [-180, 180, -90, 90]:
167-
return True
168-
else:
207+
from shapely.geometry import Polygon
208+
from shapely.ops import unary_union
209+
210+
# Try union the polygons
211+
unioned_polygon = unary_union(self.polygons)
212+
# If still a MultiPolygon, not a global extent
213+
if not isinstance(unioned_polygon, Polygon):
169214
return False
215+
# Define global polygon
216+
global_polygon = Polygon.from_bounds(-180.0, -90.0, 180.0, 90.0)
217+
# Assess if global
218+
is_global = global_polygon.equals(unioned_polygon)
219+
return is_global
170220

171221
def intersects(self, other):
172222
"""Check if SExtent is intersecting the other SExtent.
173223
174-
Touching extent are considered to not intersect !
224+
Touching SExtent are considered to not intersect !
225+
SExtents intersection also includes contains/within occurence.
175226
"""
176227
if not isinstance(other, SExtent):
177228
raise TypeError("SExtent.intersects() expects a SExtent class instance.")
229+
# Important caveat in comparison to shapely !
230+
# In shapely, intersects includes touching geometries !
178231
bl = (self.to_shapely().intersects(other.to_shapely()) and
179232
not self.to_shapely().touches(other.to_shapely()))
180233
return bl
181234

182235
def disjoint(self, other):
183236
"""Check if SExtent does not intersect (and do not touch) the other SExtent."""
184237
if not isinstance(other, SExtent):
185-
raise TypeError("SExtent.intersects() expects a SExtent class instance.")
238+
raise TypeError("SExtent.disjoint() expects a SExtent class instance.")
186239
return self.to_shapely().disjoint(other.to_shapely())
187240

188241
def within(self, other):
@@ -198,7 +251,14 @@ def contains(self, other):
198251
return self.to_shapely().contains(other.to_shapely())
199252

200253
def touches(self, other):
201-
"""Check if SExtent external touches another SExtent."""
254+
"""Check if SExtent external touches another SExtent.
255+
256+
Important notes:
257+
- Touching SExtents are not disjoint !
258+
- Touching SExtents are considered to not intersect !
259+
- SExtents which are contained/within each other AND are "touching in the interior"
260+
are not considered touching !
261+
"""
202262
if not isinstance(other, SExtent):
203263
raise TypeError("SExtent.touches() expects a SExtent class instance.")
204264
return self.to_shapely().touches(other.to_shapely())

pyresample/test/test_sgeom/test_extent.py

Lines changed: 89 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ def test_sextent_with_bad_value(self):
5050
SExtent(extent)
5151

5252
# lat_min < -90
53-
extent = [-180, 181, -91, 40]
53+
extent = [50, 60, -91, 40]
5454
with pytest.raises(ValueError):
5555
SExtent(extent)
5656

5757
# lat_max > 90
58-
extent = [-180, 181, -40, 91]
58+
extent = [50, 60, -40, 91]
5959
with pytest.raises(ValueError):
6060
SExtent(extent)
6161

@@ -92,59 +92,87 @@ def test_sextent_with_bad_input(self):
9292
with pytest.raises(ValueError):
9393
SExtent(list_extent)
9494

95-
def test_sextent_with_correct_values(self):
96-
"""Test it creates the SExtent when providing correct values."""
97-
# - Classic
95+
def test_sextent_with_correct_format(self):
96+
"""Test SExtent when providing correct extent(s) format and values."""
97+
# Accept list
9898
extent = [-180, -175, -40, 40]
9999
assert list(SExtent(extent))[0] == extent
100-
# - Same lon
101-
extent = [0, 0, -40, 40]
102-
assert list(SExtent(extent))[0] == extent
103-
# - Same lat
104-
extent = [-10, 0, 40, 40]
105-
assert list(SExtent(extent))[0] == extent
106-
# - Same lat, lon (--> point extent)
107-
extent = [0, 0, 40, 40]
108-
assert list(SExtent(extent))[0] == extent
109-
# Accept numpy array
110-
extent = np.array([-180, -175, -40, 40])
111-
assert list(SExtent(extent))[0] == extent.tolist()
112100
# Accept tuple
113101
extent = (-180, -175, -40, 40)
114102
assert list(SExtent(extent))[0] == list(extent)
115-
# Accept list of extents
103+
# Accept numpy array
104+
extent = np.array([-180, -175, -40, 40])
105+
assert list(SExtent(extent))[0] == extent.tolist()
106+
107+
# Accept list of extents (list)
116108
extent1 = [50, 60, -40, 40]
117109
extent2 = [175, 180, -40, 40]
118110
list_extent = [extent1, extent2]
119111
assert np.allclose(list(SExtent(list_extent)), list_extent)
120-
# Accept multiple extents
112+
# Accept list of extents (tuple)
113+
extent1 = (50, 60, -40, 40)
114+
extent2 = (175, 180, -40, 40)
115+
list_extent = [extent1, extent2]
116+
assert np.allclose(list(SExtent(list_extent)), list_extent)
117+
# Accept list of extents (np.array)
118+
extent1 = np.array([0, 60, -40, 40])
119+
extent2 = np.array([175, 180, -40, 40])
120+
list_extent = [extent1, extent2]
121+
assert np.allclose(list(SExtent(list_extent)), list_extent)
122+
# Accept multiple extents (list)
121123
extent1 = [50, 60, -40, 40]
122124
extent2 = [175, 180, -40, 40]
123125
list_extent = [extent1, extent2]
124-
assert np.allclose(list(SExtent(list_extent)), list_extent)
126+
assert np.allclose(list(SExtent(extent1, extent2)), list_extent)
127+
# Accept multiple extents (tuple)
128+
extent1 = (50, 60, -40, 40)
129+
extent2 = (175, 180, -40, 40)
130+
list_extent = [extent1, extent2]
131+
assert np.allclose(list(SExtent(extent1, extent2)), list_extent)
132+
# Accept multiple extents (np.array)
133+
extent1 = np.array([0, 60, -40, 40])
134+
extent2 = np.array([175, 180, -40, 40])
135+
list_extent = [extent1, extent2]
136+
assert np.allclose(list(SExtent(extent1, extent2)), list_extent)
137+
138+
def test_single_sextent_bad_topology(self):
139+
"""Test that raise error when the extents is a point or a line."""
140+
# - Point extent
141+
extent = [0, 0, 40, 40]
142+
with pytest.raises(ValueError):
143+
SExtent(extent)
144+
# - Line extent
145+
extent = [0, 10, 40, 40]
146+
with pytest.raises(ValueError):
147+
SExtent(extent)
148+
extent = [0, 0, -40, 50]
149+
with pytest.raises(ValueError):
150+
SExtent(extent)
125151

126-
def test_sextent_bad_topology(self):
127-
"""Test that capture error when the extents composing SExtent overlaps."""
128-
# Touching does not raise errors
152+
def test_multple_touching_extents(self):
153+
"""Test that touching extents composing SExtent do not raise error."""
129154
extent1 = [0, 40, 0, 40]
130155
extent2 = [0, 40, -40, 0]
131156
_ = SExtent(extent1, extent2)
132157

158+
def test_multple_overlapping_extents(self):
159+
"""Test that raise error when the extents composing SExtent overlaps."""
133160
# Intersecting raise error
134161
extent1 = [0, 40, 0, 40]
135162
extent2 = [20, 60, 20, 60]
136163
with pytest.raises(ValueError):
137164
SExtent(extent1, extent2)
138165

139-
# TODO: Duplicate extent do not raise errors
166+
# Duplicate extent raise errors
140167
extent1 = [0, 40, 0, 40]
141-
extent2 = extent1
142-
SExtent(extent1, extent2)
168+
with pytest.raises(ValueError):
169+
SExtent(extent1, extent1)
143170

144-
# TODO: Within extent do not raise errors
171+
# Within extent raise errors
145172
extent1 = [0, 40, 0, 40]
146173
extent2 = [10, 20, 10, 20]
147-
SExtent(extent1, extent2)
174+
with pytest.raises(ValueError):
175+
SExtent(extent1, extent2)
148176

149177
def test_to_shapely(self):
150178
"""Test shapely conversion."""
@@ -174,16 +202,22 @@ def test_is_global(self):
174202
sext = SExtent(extent)
175203
assert sext.is_global
176204

177-
# Is not global
205+
# Is clearly not global
178206
extent = [-175, 180, -90, 90]
179207
sext = SExtent(extent)
180208
assert not sext.is_global
181209

182210
# Is global, but with multiple extents
183-
extent = [-180, 0, -90, 90]
184-
extent1 = [0, 180, -90, 90]
185-
sext = SExtent(extent, extent1)
186-
assert not sext.is_global # TODO IMPROVE !!!
211+
extent1 = [-180, 0, -90, 90]
212+
extent2 = [0, 180, -90, 90]
213+
sext = SExtent(extent1, extent2)
214+
assert sext.is_global
215+
216+
# Is not global, but polgon bounds ...
217+
extent1 = [-180, 0, -90, 90]
218+
extent2 = [0, 180, 0, 90]
219+
sext = SExtent(extent1, extent2)
220+
assert not sext.is_global
187221

188222
def test_SExtent_single_not_intersect(self):
189223
"""Check disjoint extents."""
@@ -496,3 +530,25 @@ def test_SExtent_multiple_equals(self):
496530
# Equal extents do not touches !
497531
assert not sext1.touches(sext2)
498532
assert not sext2.touches(sext1)
533+
534+
def test_SExtent_binary_predicates_bad_input(self):
535+
extent = [160, 180, -40, 40]
536+
sext = SExtent(extent)
537+
# Disjoint
538+
with pytest.raises(TypeError):
539+
sext.disjoint("bad_dtype")
540+
# Intersects
541+
with pytest.raises(TypeError):
542+
sext.intersects("bad_dtype")
543+
# Within
544+
with pytest.raises(TypeError):
545+
sext.within("bad_dtype")
546+
# Contains
547+
with pytest.raises(TypeError):
548+
sext.contains("bad_dtype")
549+
# Touches
550+
with pytest.raises(TypeError):
551+
sext.touches("bad_dtype")
552+
# Equals
553+
with pytest.raises(TypeError):
554+
sext.equals("bad_dtype")

0 commit comments

Comments
 (0)