Repository: carbonplan/ndpyramid (language: python)

Issue:
Add unit tests for compatibility with @carbonplan/maps
As referenced in https://github.com/carbonplan/maps/issues/14#issuecomment-930787242 and https://github.com/carbonplan/ndpyramid/issues/87, we should add a utility function and unit tests for compatibility with @carbonplan/maps, based on the requirements documented in https://ndpyramid.readthedocs.io/en/latest/schema.html.


Discussion / hints:
Even just a python validator that uses `zarr-python` to check metadata compliance would be very useful.

Fix patch:
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
index 27c2e0b..1f9ec80 100644
--- a/.github/workflows/main.yaml
+++ b/.github/workflows/main.yaml
@@ -23,7 +23,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.9", "3.10"] #TODO: add 3.11 once sparse/numba support it
+        python-version: ["3.9", "3.10", "3.11"]
     timeout-minutes: 20
     defaults:
       run:
diff --git a/ci/environment.yml b/ci/environment.yml
index b9a4937..3403ddf 100644
--- a/ci/environment.yml
+++ b/ci/environment.yml
@@ -5,6 +5,7 @@ channels:
 dependencies:
   - dask
   - esmpy>=8.2.0
+  - mercantile
   - mpich
   - netcdf4
   - numpy
diff --git a/pyproject.toml b/pyproject.toml
index 2a583fd..11d2775 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,12 +35,17 @@ dependencies = [
 
 [project.optional-dependencies]
 test = [
+    "dask",
+    "mercantile",
+    "pooch",
     "pre-commit",
     "pytest-benchmark",
     "pytest-codspeed",
     "pytest-cov",
     "pytest-mypy",
     "pytest",
+    "rioxarray",
+    "scipy",
 ]
 
 


Test patch:
diff --git a/ndpyramid/testing.py b/ndpyramid/testing.py
new file mode 100644
index 0000000..740ba32
--- /dev/null
+++ b/ndpyramid/testing.py
@@ -0,0 +1,34 @@
+import mercantile
+import numpy as np
+import xarray as xr
+
+
+def _bounds(ds):
+    left = ds.x[0] - (ds.x[1] - ds.x[0]) / 2
+    right = ds.x[-1] + (ds.x[-1] - ds.x[-2]) / 2
+    top = ds.y[0] + (ds.y[1] - ds.y[0]) / 2
+    bottom = ds.y[-1] - (ds.y[-1] - ds.y[-2]) / 2
+    return (left.data, bottom.data, right.data, top.data)
+
+
+def verify_xy_bounds(ds, zoom):
+    """
+    Verifies that the bounds of a chunk conforms to expectations for a WebMercatorQuad.
+    """
+    tile = mercantile.tile(ds.x[0], ds.y[0], zoom)
+    expected = mercantile.xy_bounds(tile)
+    np.testing.assert_allclose(expected, _bounds(ds))
+    return ds
+
+
+def verify_bounds(pyramid):
+    for level in pyramid:
+        if pyramid[level].attrs['multiscales'][0]['datasets'][0]['crs'] == 'EPSG:3857':
+            xr.map_blocks(
+                verify_xy_bounds,
+                pyramid[level].ds,
+                template=pyramid[level].ds,
+                kwargs={'zoom': int(level)},
+            )
+        else:
+            raise ValueError('Tile boundary verification has only been implemented for EPSG:3857')
diff --git a/tests/test_pyramids.py b/tests/test_pyramids.py
index 9cd5f2c..540d28e 100644
--- a/tests/test_pyramids.py
+++ b/tests/test_pyramids.py
@@ -5,6 +5,7 @@
 
 from ndpyramid import pyramid_coarsen, pyramid_regrid, pyramid_reproject
 from ndpyramid.regrid import generate_weights_pyramid, make_grid_ds
+from ndpyramid.testing import verify_bounds
 
 
 @pytest.fixture
@@ -29,6 +30,7 @@ def test_reprojected_pyramid(temperature, benchmark):
     levels = 2
     temperature = temperature.rio.write_crs('EPSG:4326')
     pyramid = benchmark(lambda: pyramid_reproject(temperature, levels=levels))
+    verify_bounds(pyramid)
     assert pyramid.ds.attrs['multiscales']
     assert len(pyramid.ds.attrs['multiscales'][0]['datasets']) == levels
     assert pyramid.attrs['multiscales'][0]['datasets'][0]['crs'] == 'EPSG:3857'
@@ -54,6 +56,7 @@ def test_regridded_pyramid(temperature, regridder_apply_kws, benchmark):
             temperature, levels=2, regridder_apply_kws=regridder_apply_kws, other_chunks={'time': 2}
         )
     )
+    verify_bounds(pyramid)
     assert pyramid.ds.attrs['multiscales']
     assert pyramid.attrs['multiscales'][0]['datasets'][0]['crs'] == 'EPSG:3857'
     assert pyramid['0'].attrs['multiscales'][0]['datasets'][0]['crs'] == 'EPSG:3857'
@@ -76,6 +79,7 @@ def test_regridded_pyramid_with_weights(temperature, benchmark):
             temperature, levels=levels, weights_pyramid=weights_pyramid, other_chunks={'time': 2}
         )
     )
+    verify_bounds(pyramid)
     assert pyramid.ds.attrs['multiscales']
     assert len(pyramid.ds.attrs['multiscales'][0]['datasets']) == levels
     pyramid.to_zarr(MemoryStore())