Skip to content

Commit 2d2248c

Browse files
authored
Merge pull request #154 from csiro-coasts/fix-rivers
Fix generated bounds for 'river' cells in CFGrid2D datasets
2 parents f18cafb + 0ac9e74 commit 2d2248c

File tree

4 files changed

+110
-19
lines changed

4 files changed

+110
-19
lines changed

docs/releases/development.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
Next release (in development)
33
=============================
44

5-
* ...
5+
* Fix invalid geometry being generated for 'river' cells
6+
in CFGrid2D datasets with no cell bounds (:pr:`154`).

src/emsarray/conventions/grid.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,17 @@ def _get_or_make_bounds(self, coordinate: xarray.DataArray) -> xarray.DataArray:
497497
# On the edges where there are fewer 'surrounding' cells, the cell centres are used.
498498
# Edge and corner cells will be smaller than the surrounding cells because of this.
499499

500+
# Discard cell coordinates where the cell is bound by nan on both sides.
501+
# This can occur when the grid tracks a river which might be only one cell wide.
502+
coordinate_values = coordinate.values.copy()
503+
nan_coordinates = numpy.isnan(coordinate_values)
504+
j_pad = numpy.pad(nan_coordinates, ((1, 1), (0, 0)), constant_values=False)
505+
j_bound_by_nan = j_pad[:-2, :] & j_pad[2:, :]
506+
i_pad = numpy.pad(nan_coordinates, ((0, 0), (1, 1)), constant_values=False)
507+
i_bound_by_nan = i_pad[:, :-2] & i_pad[:, 2:]
508+
bound_by_nan = j_bound_by_nan | i_bound_by_nan
509+
coordinate_values[bound_by_nan] = numpy.nan
510+
500511
# numpy.nanmean will return nan for an all-nan column.
501512
# This is the exact behaviour that we want.
502513
# numpy emits a warning that can not be silenced when this happens,
@@ -505,7 +516,7 @@ def _get_or_make_bounds(self, coordinate: xarray.DataArray) -> xarray.DataArray:
505516
warnings.filterwarnings(
506517
"ignore", "Mean of empty slice", category=RuntimeWarning)
507518
grid = numpy.nanmean([
508-
numpy.pad(coordinate.values, pad, constant_values=numpy.nan)
519+
numpy.pad(coordinate_values, pad, constant_values=numpy.nan)
509520
for pad in itertools.product([(1, 0), (0, 1)], [(1, 0), (0, 1)])
510521
], axis=0)
511522

@@ -517,7 +528,8 @@ def _get_or_make_bounds(self, coordinate: xarray.DataArray) -> xarray.DataArray:
517528
]
518529
for y in range(y_size)
519530
])
520-
# Any cell that has a `nan` in its bounds will be set to all nan
531+
532+
# Set nan bounds for all cells that have any `nan` in its bounds.
521533
cells_with_nans = numpy.isnan(bounds).any(axis=2)
522534
bounds[cells_with_nans] = numpy.nan
523535

tests/conventions/test_cfgrid2d.py

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
from emsarray.exceptions import NoSuchCoordinateError
2525
from emsarray.operations import geometry
2626
from tests.utils import (
27-
DiagonalShocGrid, ShocGridGenerator, ShocLayerGenerator,
28-
assert_property_not_cached
27+
AxisAlignedShocGrid, DiagonalShocGrid, ShocGridGenerator,
28+
ShocLayerGenerator, assert_property_not_cached, plot_geometry
2929
)
3030

3131

@@ -266,7 +266,7 @@ def test_polygons_no_bounds():
266266
assert polygons[20 * 9 + 3] is not None
267267

268268

269-
def test_polygons_with_bounds():
269+
def test_polygons_with_bounds() -> None:
270270
dataset = make_dataset(
271271
j_size=10, i_size=20,
272272
include_bounds=True, corner_size=3)
@@ -289,7 +289,7 @@ def test_polygons_with_bounds():
289289
assert polygons[20 * 9 + 3] is not None
290290

291291

292-
def test_holes():
292+
def test_holes() -> None:
293293
dataset = make_dataset(j_size=10, i_size=20, corner_size=5)
294294
only_polygons = dataset.ems.polygons[dataset.ems.mask]
295295

@@ -313,15 +313,57 @@ def test_holes():
313313
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)
314314

315315

316-
def test_bounds():
316+
def test_bounds() -> None:
317317
dataset = make_dataset(
318318
j_size=10, i_size=20, corner_size=5, include_bounds=True)
319319
# The corner cuts out some of the upper bounds
320320
assert_allclose(dataset.ems.bounds, (0, 0, 3, 2.5))
321321
assert_property_not_cached(dataset.ems, 'geometry')
322322

323323

324-
def test_face_centres():
324+
@pytest.mark.parametrize("include_bounds", [True, False], ids=["with-bounds", "without-bounds"])
325+
def test_bounds_river(
326+
include_bounds: bool,
327+
tmp_path: pathlib.Path,
328+
) -> None:
329+
# Test calculated bounds for grids with one cell wide 'rivers'.
330+
# The river cells have cell centres but insufficient information to
331+
# construct any bounds for those cells.
332+
dataset = make_dataset(
333+
j_size=7, i_size=7, include_bounds=include_bounds, grid_type=AxisAlignedShocGrid)
334+
# Clear out a corner on both sides, leaving a one cell wide river.
335+
dataset['longitude'].values[:3, :3] = numpy.nan
336+
dataset['latitude'].values[:3, :3] = numpy.nan
337+
dataset['longitude'].values[-3:, :3] = numpy.nan
338+
dataset['latitude'].values[-3:, :3] = numpy.nan
339+
if include_bounds:
340+
dataset['longitude_bounds'].values[:3, :3] = numpy.nan
341+
dataset['latitude_bounds'].values[:3, :3] = numpy.nan
342+
dataset['longitude_bounds'].values[-3:, :3] = numpy.nan
343+
dataset['latitude_bounds'].values[-3:, :3] = numpy.nan
344+
345+
# Useful to see what the geometry looks like, not part of the test.
346+
plot_geometry(dataset, tmp_path / 'river.png', extent=(-0.05, 0.75, -0.05, 0.75))
347+
348+
convention: ShocSimple = dataset.ems
349+
polygons = convention.wind(xarray.DataArray(convention.polygons)).values
350+
assert numpy.all(polygons[:, 3:] != None) # noqa: E711
351+
assert numpy.all(polygons[:3, :3] == None) # noqa: E711
352+
assert numpy.all(polygons[-3:, :3] == None) # noqa: E711
353+
river_polygons = polygons[3, :3]
354+
if include_bounds:
355+
assert numpy.all(river_polygons != None) # noqa: E711
356+
else:
357+
assert numpy.all(river_polygons == None) # noqa: E711
358+
359+
if not include_bounds:
360+
# All the 'coast' cells should have the same minimum x coordinate,
361+
# even the one next to the river.
362+
min_x = [min(p.exterior.coords.xy[0]) for p in polygons[:, 3]]
363+
numpy.testing.assert_allclose(min_x, 0.35)
364+
365+
366+
def test_face_centres() -> None:
325367
# SHOC simple face centres are taken directly from the coordinates,
326368
# not calculated from polygon centres.
327369
dataset = make_dataset(j_size=10, i_size=20, corner_size=3)
@@ -338,7 +380,7 @@ def test_face_centres():
338380
numpy.testing.assert_equal(face_centres[linear_index], [lon, lat])
339381

340382

341-
def test_selector_for_index():
383+
def test_selector_for_index() -> None:
342384
dataset = make_dataset(j_size=5, i_size=7)
343385
convention: ShocSimple = dataset.ems
344386

@@ -348,13 +390,13 @@ def test_selector_for_index():
348390
assert selector == convention.selector_for_index(index)
349391

350392

351-
def test_make_geojson_geometry():
393+
def test_make_geojson_geometry() -> None:
352394
dataset = make_dataset(j_size=10, i_size=10, corner_size=3)
353395
out = json.dumps(geometry.to_geojson(dataset))
354396
assert isinstance(out, str)
355397

356398

357-
def test_ravel_index():
399+
def test_ravel_index() -> None:
358400
dataset = make_dataset(j_size=5, i_size=7)
359401
convention: ShocSimple = dataset.ems
360402

@@ -365,15 +407,15 @@ def test_ravel_index():
365407
assert convention.wind_index(linear_index, grid_kind=CFGridKind.face) == index
366408

367409

368-
def test_grid_kinds():
410+
def test_grid_kinds() -> None:
369411
dataset = make_dataset(j_size=3, i_size=5)
370412
convention: ShocSimple = dataset.ems
371413

372414
assert convention.grid_kinds == frozenset({CFGridKind.face})
373415
assert convention.default_grid_kind == CFGridKind.face
374416

375417

376-
def test_grid_kind_and_size():
418+
def test_grid_kind_and_size() -> None:
377419
dataset = make_dataset(j_size=5, i_size=7)
378420
convention: ShocSimple = dataset.ems
379421

@@ -382,7 +424,7 @@ def test_grid_kind_and_size():
382424
assert size == 5 * 7
383425

384426

385-
def test_ravel():
427+
def test_ravel() -> None:
386428
dataset = make_dataset(j_size=5, i_size=7)
387429
convention: ShocSimple = dataset.ems
388430

@@ -394,7 +436,7 @@ def test_ravel():
394436
temp.values.reshape(temp.shape[:2] + (-1,)))
395437

396438

397-
def test_wind():
439+
def test_wind() -> None:
398440
dataset = make_dataset(j_size=5, i_size=7)
399441
convention: ShocSimple = dataset.ems
400442

@@ -413,7 +455,7 @@ def test_wind():
413455
values.reshape(time_size, 5, 7))
414456

415457

416-
def test_drop_geometry(datasets: pathlib.Path):
458+
def test_drop_geometry(datasets: pathlib.Path) -> None:
417459
dataset = xarray.open_dataset(datasets / 'cfgrid2d.nc')
418460

419461
dropped = dataset.ems.drop_geometry()
@@ -426,7 +468,7 @@ def test_drop_geometry(datasets: pathlib.Path):
426468
assert topology.longitude_name not in dropped.variables
427469

428470

429-
def test_values():
471+
def test_values() -> None:
430472
dataset = make_dataset(j_size=10, i_size=20, corner_size=5)
431473
eta = dataset.data_vars["eta"].isel(time=0)
432474
values = dataset.ems.ravel(eta)
@@ -439,7 +481,7 @@ def test_values():
439481

440482

441483
@pytest.mark.matplotlib
442-
def test_plot_on_figure():
484+
def test_plot_on_figure() -> None:
443485
# Not much to test here, mostly that it doesn't throw an error
444486
dataset = make_dataset(j_size=10, i_size=20)
445487
surface_temp = dataset.data_vars["temp"].isel(k=-1, time=0)

tests/utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@
77
from functools import cached_property
88
from typing import Any
99

10+
import matplotlib.pyplot as plt
1011
import numpy
1112
import pytest
1213
import shapely
1314
import xarray
15+
from cartopy.mpl.geoaxes import GeoAxes
1416
from packaging.requirements import Requirement
1517

1618
from emsarray.conventions.arakawa_c import (
1719
ArakawaCGridKind, c_mask_from_centres
1820
)
21+
from emsarray.types import Bounds, Pathish
1922

2023

2124
@contextlib.contextmanager
@@ -349,6 +352,14 @@ def y_back(self):
349352
return reduce_axes(self.y_grid, (False, True))
350353

351354

355+
class AxisAlignedShocGrid(ShocGridGenerator):
356+
def make_x_grid(self, j: numpy.ndarray, i: numpy.ndarray) -> numpy.ndarray:
357+
return 0.1 * i # type: ignore
358+
359+
def make_y_grid(self, j: numpy.ndarray, i: numpy.ndarray) -> numpy.ndarray:
360+
return 0.1 * j # type: ignore
361+
362+
352363
class DiagonalShocGrid(ShocGridGenerator):
353364
def make_x_grid(self, j: numpy.ndarray, i: numpy.ndarray) -> numpy.ndarray:
354365
return 0.1 * (i + j) # type: ignore
@@ -426,3 +437,28 @@ def only_versions(*requirements: str):
426437
f'{requirement.name}=={version} does not satisfy {requirement}')
427438

428439
return pytest.mark.skipif(len(invalid_versions) > 0, reason='\n'.join(invalid_versions))
440+
441+
442+
def plot_geometry(
443+
dataset: xarray.Dataset,
444+
out: Pathish,
445+
*,
446+
figsize: tuple[float, float] = (10, 10),
447+
extent: Bounds | None = None,
448+
title: str | None = None
449+
) -> None:
450+
figure = plt.figure(layout='constrained', figsize=figsize)
451+
axes: GeoAxes = figure.add_subplot(projection=dataset.ems.data_crs)
452+
axes.set_aspect(aspect='equal', adjustable='datalim')
453+
axes.gridlines(draw_labels=['left', 'bottom'], linestyle='dashed')
454+
455+
axes.add_collection(dataset.ems.make_poly_collection(
456+
edgecolors='black', linewidth=0.5, facecolors='lightcyan'))
457+
axes.scatter(dataset.ems.face_centres[:, 0], dataset.ems.face_centres[:, 1], c='red')
458+
459+
if title is not None:
460+
axes.set_title(title)
461+
if extent is not None:
462+
axes.set_extent(extent)
463+
464+
figure.savefig(out)

0 commit comments

Comments
 (0)