Skip to content

Commit 3ab70c2

Browse files
authored
Merge pull request #83 from csiro-coasts/convention-bounds
Add Convention.geometry and Convention.bounds
2 parents d4b1bbf + de0c78b commit 3ab70c2

File tree

10 files changed

+184
-40
lines changed

10 files changed

+184
-40
lines changed

docs/releases/development.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ Next release (in development)
99
for significant speed improvements
1010
(:pr:`77`).
1111
* Added :func:`emsarray.utils.timed_func` for easily logging some performance metrics (:pr:`79`).
12+
* Add :attr:`.Convention.bounds` and :attr:`.Convention.geometry` attributes (:pr:`83`).

src/emsarray/conventions/_base.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import numpy as np
1515
import xarray as xr
16+
from shapely import unary_union
1617
from shapely.geometry import Point, Polygon
1718
from shapely.geometry.base import BaseGeometry
1819

@@ -23,7 +24,7 @@
2324
_requires_plot, animate_on_figure, plot_on_figure, polygons_to_collection
2425
)
2526
from emsarray.state import State
26-
from emsarray.types import Pathish
27+
from emsarray.types import Bounds, Pathish
2728

2829
if TYPE_CHECKING:
2930
# Import these optional dependencies only during type checking
@@ -941,6 +942,26 @@ def mask(self) -> np.ndarray:
941942
dtype=bool, count=self.polygons.size)
942943
return cast(np.ndarray, mask)
943944

945+
@cached_property
946+
def geometry(self) -> Polygon:
947+
"""
948+
A shapely :class:`Polygon` that represents the geometry of the entire dataset.
949+
950+
This is equivalent to the union of all polygons in the dataset,
951+
although specific conventions may have a simpler way of constructing this.
952+
"""
953+
return unary_union(self.polygons[self.mask])
954+
955+
@cached_property
956+
def bounds(self) -> Bounds:
957+
"""
958+
Returns minimum bounding region (minx, miny, maxx, maxy) of the entire dataset.
959+
960+
This is equivalent to the bounds of the dataset :attr:`geometry`,
961+
although specific conventons may have a simpler way of constructing this.
962+
"""
963+
return cast(Bounds, self.geometry.bounds)
964+
944965
@cached_property
945966
@utils.timed_func
946967
def spatial_index(self) -> SpatialIndex[SpatialIndexItem[Index]]:

src/emsarray/conventions/grid.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616

1717
import numpy as np
1818
import xarray as xr
19+
from shapely.geometry import Polygon, box
1920
from shapely.geometry.base import BaseGeometry
2021

2122
from emsarray import masking, utils
2223
from emsarray.exceptions import ConventionViolationWarning
23-
from emsarray.types import Pathish
24+
from emsarray.types import Bounds, Pathish
2425

2526
from ._base import Convention, Specificity
2627

@@ -223,6 +224,16 @@ def topology(self) -> Topology:
223224
"""A :class:`CFGridTopology` helper."""
224225
return self.topology_class(self.dataset)
225226

227+
@cached_property
228+
def bounds(self) -> Bounds:
229+
# This can be computed easily from the coordinate bounds
230+
topology = self.topology
231+
min_x = np.nanmin(topology.longitude_bounds)
232+
max_x = np.nanmax(topology.longitude_bounds)
233+
min_y = np.nanmin(topology.latitude_bounds)
234+
max_y = np.nanmax(topology.latitude_bounds)
235+
return (min_x, min_y, max_x, max_y)
236+
226237
def unravel_index(
227238
self,
228239
index: int,
@@ -414,6 +425,12 @@ def face_centres(self) -> np.ndarray:
414425
centres = np.column_stack((xx.flatten(), yy.flatten()))
415426
return cast(np.ndarray, centres)
416427

428+
@cached_property
429+
def geometry(self) -> Polygon:
430+
# As CFGrid1D is axis aligned,
431+
# the geometry can be constructed from the bounds.
432+
return box(*self.bounds)
433+
417434

418435
# 2D coordinate grids
419436

src/emsarray/conventions/ugrid.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from emsarray.exceptions import (
3030
ConventionViolationError, ConventionViolationWarning
3131
)
32-
from emsarray.types import Pathish
32+
from emsarray.types import Bounds, Pathish
3333

3434
from ._base import Convention, Specificity
3535

@@ -1103,6 +1103,15 @@ def face_centres(self) -> np.ndarray:
11031103
return cast(np.ndarray, face_centres)
11041104
return super().face_centres
11051105

1106+
@cached_property
1107+
def bounds(self) -> Bounds:
1108+
topology = self.topology
1109+
min_x = np.nanmin(topology.node_x)
1110+
max_x = np.nanmax(topology.node_x)
1111+
min_y = np.nanmin(topology.node_y)
1112+
max_y = np.nanmax(topology.node_y)
1113+
return (min_x, min_y, max_x, max_y)
1114+
11061115
def selector_for_index(self, index: UGridIndex) -> Dict[Hashable, int]:
11071116
kind, i = index
11081117
if kind is UGridKind.face:

src/emsarray/types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Collection of type aliases used across the library."""
22

33
import os
4-
from typing import Union
4+
from typing import Tuple, Union
55

66
Pathish = Union[os.PathLike, str]
7+
Bounds = Tuple[float, float, float, float]

tests/conventions/test_base.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import numpy as np
1010
import pytest
1111
import xarray as xr
12-
from shapely.geometry import LineString, Point, box
12+
from shapely.geometry import LineString, Point, Polygon, box
1313
from shapely.geometry.base import BaseGeometry
1414

1515
from emsarray import masking, utils
@@ -84,6 +84,8 @@ def drop_geometry(self) -> xr.Dataset:
8484
@cached_property
8585
def polygons(self) -> np.ndarray:
8686
height, width = self.shape
87+
# Each polygon is a box from (x, y, x+1, y+1),
88+
# however the polygons around the edge are masked out with None.
8789
return np.array([
8890
box(x, y, x + 1, y + 1)
8991
if (0 < x < width - 1) and (0 < y < height - 1)
@@ -126,6 +128,33 @@ def test_mask():
126128
)
127129

128130

131+
def test_geometry():
132+
dataset = xr.Dataset({
133+
'values': (['z', 'y', 'x'], np.random.standard_normal((5, 10, 20))),
134+
'botz': (['y', 'x'], np.random.standard_normal((10, 20)) - 10),
135+
})
136+
convention = SimpleConvention(dataset)
137+
138+
# The geometry will be the union of all the polygons,
139+
# which results in some 'extra' vertices along the edge.
140+
assert convention.geometry == Polygon(
141+
[(1, x) for x in range(1, 10)]
142+
+ [(y, 9) for y in range(2, 20)]
143+
+ [(19, x) for x in reversed(range(1, 9))]
144+
+ [(y, 1) for y in reversed(range(1, 19))]
145+
)
146+
147+
148+
def test_bounds():
149+
dataset = xr.Dataset({
150+
'values': (['z', 'y', 'x'], np.random.standard_normal((5, 10, 20))),
151+
'botz': (['y', 'x'], np.random.standard_normal((10, 20)) - 10),
152+
})
153+
convention = SimpleConvention(dataset)
154+
155+
assert convention.bounds == (1, 1, 19, 9)
156+
157+
129158
def test_spatial_index():
130159
dataset = xr.Dataset({
131160
'values': (['z', 'y', 'x'], np.random.standard_normal((5, 10, 20))),

tests/conventions/test_cfgrid1d.py

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from emsarray.conventions import get_dataset_convention
1616
from emsarray.conventions.grid import CFGrid1D, CFGridKind, CFGridTopology
1717
from emsarray.operations import geometry
18-
from tests.utils import box, mask_from_strings
18+
from tests.utils import assert_property_not_cached, box, mask_from_strings
1919

2020

2121
def make_dataset(
@@ -24,6 +24,7 @@ def make_dataset(
2424
height: int,
2525
depth: int = 5,
2626
time_size: int = 4,
27+
bounds: bool = False,
2728
) -> xr.Dataset:
2829
longitude_name = 'lon'
2930
latitude_name = 'lat'
@@ -116,18 +117,39 @@ def make_dataset(
116117
},
117118
)
118119

120+
data_vars = [
121+
time, lat, lon, depth_var,
122+
botz, eta, temp,
123+
]
124+
125+
if bounds:
126+
lon_grid = np.concatenate([
127+
lon.values - 0.08,
128+
[lon.values[-1] + 0.02]
129+
])
130+
lat_grid = np.concatenate([
131+
lat.values - 0.07,
132+
[lat.values[-1] + 0.03]
133+
])
134+
lon_bounds = xr.DataArray(
135+
np.c_[lon_grid[:-1], lon_grid[1:]],
136+
dims=[longitude_name, 'bounds'],
137+
name="lon_bounds",
138+
)
139+
lat_bounds = xr.DataArray(
140+
np.c_[lat_grid[:-1], lat_grid[1:]],
141+
dims=[latitude_name, 'bounds'],
142+
name="lat_bounds",
143+
)
144+
lon.attrs['bounds'] = lon_bounds.name
145+
lat.attrs['bounds'] = lat_bounds.name
146+
data_vars += [lon_bounds, lat_bounds]
147+
119148
dataset = xr.Dataset(
120-
data_vars={var.name: var for var in [
121-
time, lat, lon, depth_var,
122-
botz, eta, temp
123-
]},
149+
data_vars={var.name: var for var in data_vars},
124150
attrs={
125-
'title': "COMPAS defalt version",
126-
'paramhead': "Example COMPAS grid",
127-
'paramfile': "in.prm",
128-
'version': "v1.0 rev(1234)",
129-
'Conventions': "UGRID-1.0",
130-
'start_index': 0,
151+
'title': "Example CFGrid1D",
152+
'Conventions': "CF-1.4",
131153
},
132154
)
133155
dataset.encoding['unlimited_dims'] = {'time'}
@@ -157,7 +179,7 @@ def test_varnames():
157179

158180

159181
def test_polygons_no_bounds():
160-
dataset = make_dataset(width=3, height=4)
182+
dataset = make_dataset(width=3, height=4, bounds=False)
161183
polygons = dataset.ems.polygons
162184

163185
# Should be one item for every face
@@ -175,27 +197,7 @@ def test_polygons_no_bounds():
175197

176198

177199
def test_polygons_bounds():
178-
dataset = make_dataset(width=3, height=4)
179-
lon_grid = np.concatenate([
180-
dataset['lon'].values - 0.08,
181-
[dataset['lon'].values[-1] + 0.02]
182-
])
183-
lat_grid = np.concatenate([
184-
dataset['lat'].values - 0.07,
185-
[dataset['lat'].values[-1] + 0.03]
186-
])
187-
dataset = dataset.assign({
188-
'lon_bounds': xr.DataArray(
189-
np.c_[lon_grid[:-1], lon_grid[1:]],
190-
dims=[dataset['lon'].dims[0], 'bounds'],
191-
),
192-
'lat_bounds': xr.DataArray(
193-
np.c_[lat_grid[:-1], lat_grid[1:]],
194-
dims=[dataset['lat'].dims[0], 'bounds'],
195-
),
196-
})
197-
dataset['lon'].attrs['bounds'] = 'lon_bounds'
198-
dataset['lat'].attrs['bounds'] = 'lat_bounds'
200+
dataset = make_dataset(width=3, height=4, bounds=True)
199201
assert_allclose(dataset.ems.topology.longitude_bounds, dataset['lon_bounds'])
200202
assert_allclose(dataset.ems.topology.latitude_bounds, dataset['lat_bounds'])
201203

@@ -210,6 +212,32 @@ def test_polygons_bounds():
210212
tolerance=1e-6)
211213

212214

215+
def test_bounds_no_bounds():
216+
dataset = make_dataset(width=3, height=4, bounds=False)
217+
assert_allclose(dataset.ems.bounds, (-0.05, -0.05, 0.25, 0.35))
218+
assert_property_not_cached(dataset.ems, 'geometry')
219+
220+
221+
def test_bounds_with_bounds():
222+
dataset = make_dataset(width=3, height=4, bounds=True)
223+
assert_allclose(dataset.ems.bounds, (-0.08, -0.07, 0.22, 0.33))
224+
assert_property_not_cached(dataset.ems, 'geometry')
225+
226+
227+
def test_geometry():
228+
dataset = make_dataset(width=3, height=4, bounds=False)
229+
assert_geometries_equal(
230+
dataset.ems.geometry,
231+
Polygon([
232+
(0.25, -0.05),
233+
(0.25, 0.35),
234+
(-0.05, 0.35),
235+
(-0.05, -0.05),
236+
(0.25, -0.05),
237+
]),
238+
tolerance=1e-6)
239+
240+
213241
def test_selector_for_index():
214242
dataset = make_dataset(width=11, height=7, depth=5)
215243
convention: CFGrid1D = dataset.ems

tests/conventions/test_cfgrid2d.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@
1717
import pytest
1818
import xarray as xr
1919
from matplotlib.figure import Figure
20+
from numpy.testing import assert_allclose
2021
from shapely.geometry import Polygon
2122
from shapely.testing import assert_geometries_equal
2223

2324
from emsarray.conventions import get_dataset_convention
2425
from emsarray.conventions.grid import CFGridKind
2526
from emsarray.conventions.shoc import ShocSimple
2627
from emsarray.operations import geometry
27-
from tests.utils import DiagonalShocGrid, ShocGridGenerator, ShocLayerGenerator
28+
from tests.utils import (
29+
DiagonalShocGrid, ShocGridGenerator, ShocLayerGenerator,
30+
assert_property_not_cached
31+
)
2832

2933

3034
def make_dataset(
@@ -234,6 +238,14 @@ def test_holes():
234238
assert poly.equals_exact(Polygon([(0.3, 1.9), (0.4, 1.8), (0.5, 1.9), (0.4, 2.0), (0.3, 1.9)]), 1e-6)
235239

236240

241+
def test_bounds():
242+
dataset = make_dataset(
243+
j_size=10, i_size=20, corner_size=5, include_bounds=True)
244+
# The corner cuts out some of the upper bounds
245+
assert_allclose(dataset.ems.bounds, (0, 0, 3, 2.5))
246+
assert_property_not_cached(dataset.ems, 'geometry')
247+
248+
237249
def test_face_centres():
238250
# SHOC simple face centres are taken directly from the coordinates,
239251
# not calculated from polygon centres.

tests/conventions/test_ugrid.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
ConventionViolationError, ConventionViolationWarning
2424
)
2525
from emsarray.operations import geometry
26+
from tests.utils import assert_property_not_cached
2627

2728

2829
def make_faces(width: int, height, fill_value: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
@@ -401,6 +402,15 @@ def test_face_centres_from_centroids():
401402
np.testing.assert_equal(face_centres[linear_index], [lon, lat])
402403

403404

405+
def test_bounds(datasets: pathlib.Path):
406+
dataset = xr.open_dataset(datasets / 'ugrid_mesh2d.nc')
407+
r = 3.6
408+
assert_allclose(dataset.ems.bounds, (-r, -r, r, r))
409+
410+
# We also want to check that we didn't make the geometry to calculate this
411+
assert_property_not_cached(dataset.ems, 'geometry')
412+
413+
404414
@pytest.mark.parametrize(
405415
['index', 'selector'],
406416
(

0 commit comments

Comments
 (0)