diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 8868380ec..5b0e3bda7 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -10,4 +10,5 @@ polygonize,2026-05-27,2537,MEDIUM,4,"Pass 2 (2026-05-27): added test_polygonize_ rasterize,2026-05-21,2255,HIGH,1;2;3,"Pass 2 (2026-05-21): added test_rasterize_coverage_2026_05_21.py with 58 tests, all passing on a CUDA host. Closes Cat 2 HIGH +/-Inf and NaN burn-value gaps that pass-1 left untouched: pin +Inf / -Inf / Inf+(-Inf)/NaN polygon, point, and line burn behaviour across numpy / cupy / dask+numpy / dask+cupy, plus Inf+finite under sum stays Inf, Inf+(-Inf) under sum collapses to NaN, min(Inf, 1.0) and max(-Inf, 1.0) pick the finite value, and Inf-as-bound is rejected with the same ValueError as NaN-as-bound (pass-1 only tested the NaN-bound rejection). Closes Cat 1 MEDIUM nested GeometryCollection on all four backends: a GC inside a GC has no direct test today even though rasterize.py:1995 documents recursive unpacking, and the deeply-nested-3-levels eager test pins the recursion depth limit isn't 1 or 2. Closes Cat 1 MEDIUM columns= (multi-column) parity on cupy and dask+cupy (TestMultiColumn covered numpy/dask+numpy only); pin three columns of props on GPU so the (N, P) loop survives the kernel boundary. Closes Cat 3 LOW rectangular-pixel parity with resolution=(rx, ry) across backends. Filed source-bug issue #2255: GPU max/min merge silently suppresses NaN burn values -- CPU returns NaN (1.0 > NaN is False, keeps NaN); GPU returns 1.0 because the kernel inits the output buffer to -inf for max (or +inf for min) and atomicMax/Min is NaN-suppressing under IEEE device semantics. Pinned both the CPU NaN-propagating behaviour and the GPU NaN-suppressing behaviour as paired tests (test_nan_burn_overlaps_max_cpu_propagates vs test_nan_burn_overlaps_max_gpu_suppresses_nan, plus test_nan_burn_single_geom_max_gpu_returns_neg_inf for the single-write-on-GPU-returns-buffer-init case) so the divergence is visible in CI until the GPU kernels are aligned. Source untouched. Pass 1 (2026-05-17): added test_rasterize_coverage_2026_05_17.py with 34 tests, all passing on a CUDA host. Closes four documented public-API gaps left after the pass-0 audit. (1) Cat 3 HIGH 1x1 single-pixel raster -- test_rasterize.py covers 1xN strips and Nx1 strips but never width=1 AND height=1, so the polygon scanline / line Bresenham / point burn kernels all ship without the single-cell degenerate case; the new TestSinglePixelRaster class pins polygon/point/line on eager numpy plus polygon parity across cupy / dask+numpy / dask+cupy. (2) Cat 4 HIGH like= template-raster parameter is documented at rasterize.py:2038 and implemented by _extract_grid_from_like (line 1930) but no test exercises it; TestLikeParameter pins dtype/bounds/coords inheritance, the three override branches (dtype, bounds, width/height), the three validation branches (not-DataArray, 3D, wrong dim names) and like= on all four backends. Mutation against the like-dtype branch (rasterize.py:2183-2184) flipped the inheritance test red. (3) Cat 4 HIGH resolution= happy path -- only the oversize-rejection error path was tested (line 304); TestResolutionParameter pins the scalar branch, the tuple branch, the ceil-and-clamp-to-1 semantics, and resolution= on all four backends. (4) Cat 4 HIGH non-empty GeometryCollection unpacking is documented at rasterize.py:1995 and implemented by _classify_geometries_loop (line 228) but only the empty-GC case was tested (line 269); TestGeometryCollection pins polygon+point and polygon+line+point collections on eager numpy plus parity across cupy / dask+numpy / dask+cupy so the loop classifier's polygon/line/point sub-bucketing has direct coverage. Cat 1 MEDIUM gap closed: eager cupy all_touched=True parity vs eager numpy (TestEagerCupyAllTouched) -- the existing test only covered dask+cupy all_touched, leaving the direct GPU all_touched kernel untested. Cat 2 MEDIUM gap closed: int32 dtype with default NaN fill silently casts to the int32-min sentinel (TestIntegerDtypeNanFill) -- pin the cast so any future ValueError-raises switch is visible as a code-review diff. Pre-existing 143 passing + 2 skipped tests in test_rasterize.py untouched." reproject,2026-05-27,,MEDIUM,1,"Pass 2 (2026-05-27): added test_reproject_coverage_2026_05_27.py with 10 tests, all passing on a CUDA host. Closes Cat 1 MEDIUM backend-coverage gaps left after pass 1: (a) bounds_policy=#2187 had numpy + dask+numpy coverage but no cupy / dask+cupy tests -- a regression dropping the kwarg from the GPU dispatchers would ship undetected; TestBoundsPolicyCupy and TestBoundsPolicyDaskCupy pin raw/clamp/bogus on both GPU backends and assert clamp-grid parity with numpy. (b) test_reproject_handles_inf_input only covered eager numpy; the dask, cupy, and dask+cupy chunk workers each ship their own bilinear/cubic resampler so a regression raising on +/-Inf in any one backend would not surface from the existing test. Four new tests close the matrix (dask+numpy, cupy, dask+cupy with scattered +/-Inf cells; cupy with all-Inf raster checking no spurious finite cells appear). Note carried forward from pass 1: _merge_arrays_cupy is imported but unused -- no cupy merge dispatch in merge(); feature gap not test gap. Added 39 tests: LiteCRS direct coverage, itrf_transform behaviour/roundtrip/array, itrf_frames, geoid_height numerical correctness + raster happy-path, vertical helpers (ellipsoidal<->orthometric/depth), reproject() lat/lon and latitude/longitude dim propagation. Note: _merge_arrays_cupy is imported but unused (no cupy merge dispatch in merge()); flagged as feature gap not test gap." reproject,2026-05-10,,HIGH,1;4;5,"Added 39 tests: LiteCRS direct coverage, itrf_transform behaviour/roundtrip/array, itrf_frames, geoid_height numerical correctness + raster happy-path, vertical helpers (ellipsoidal<->orthometric/depth), reproject() lat/lon and latitude/longitude dim propagation. Note: _merge_arrays_cupy is imported but unused (no cupy merge dispatch in merge()); flagged as feature gap not test gap." +resample,2026-05-27,2547,HIGH,2;3;5,"Pass 1 (2026-05-27): added test_resample_coverage_2026_05_27.py with 70 tests (68 passing, 2 skipped). Closes Cat 3 HIGH Nx1 single-column gap across numpy/cupy/dask+numpy/dask+cupy x 8 methods (nearest/bilinear/cubic/average/min/max/median/mode) plus Nx1 upsample-nearest parity and Nx1 cross-backend aggregate parity. Closes Cat 2 MEDIUM NaN-parity gap on cupy and dask+cupy (existing TestCuPyParity/TestDaskCuPyParity used random data without NaN; the weight-mask gate and spline-prepad had no GPU NaN coverage). Closes Cat 3 MEDIUM all-equal-value raster across 8 methods (downsample) and 3 interp methods (upsample) plus a constant-with-NaN aggregate variant. Closes Cat 5 MEDIUM non-default dim-name propagation: lat/lon, latitude/longitude, and (channel, lat, lon) 3D round-trip without being renamed to y/x; per-dim attrs (units) preserved. Closes Cat 3 MEDIUM empty-raster behaviour pin: 0-row and 0-col rasters raise (currently IndexError) -- contract covered. Filed source-bug issue #2547: cubic on dask backends fails for Nx1 / arrays smaller than depth=16; the 2 skipped tests in this file gate on that fix landing. Source untouched." zonal,2026-05-27,,HIGH,1;3;4;5,"Pass 1 (2026-05-27): added test_zonal_backend_coverage_2026_05_27.py with 32 tests, all passing on a CUDA host. Closes Cat 1 HIGH backend-coverage gaps: crosstab cupy + dask+cupy (_crosstab_cupy / _crosstab_dask_cupy were dispatched but never invoked by tests), regions cupy + dask+cupy (_regions_cupy via cupyx.scipy.ndimage + _regions_dask_cupy), trim dask+numpy + cupy + dask+cupy (_trim_bounds_dask isnan path and cupy data.get() path), crop dask+numpy + cupy + dask+cupy (_crop_bounds_dask + cupy data.get() path), apply 3D cupy + dask+cupy (per-layer kernel launch over the third axis in _apply_cupy and _apply_dask_cupy). Existing test_zonal.py covered only numpy + dask+numpy for crosstab/regions/trim/crop and 2D-only for cupy apply. Closes Cat 3 MEDIUM 1x1 / 1xN / Nx1 strip edge cases for trim, crop, and regions. Closes Cat 4 LOW pins: regions(neighborhood=6) ValueError, suggest_zonal_canvas(crs='Geographic') aspect-ratio pin and invalid-crs KeyError, crosstab cupy zone_ids/cat_ids filter, crosstab cupy agg='percentage'. Closes Cat 5 MEDIUM: regions coords/attrs propagation across numpy + dask+numpy, trim/crop name='trim'/'crop' default + attrs preservation. Also pins the documented numpy-vs-dask trim asymmetry on NaN sentinel (numpy _trim does equality which never matches NaN; dask _trim_bounds_dask has dedicated isnan branch). Mutation against the cupy.asnumpy() conversion in _crosstab_cupy flipped test_crosstab_cupy_matches_numpy red. Source untouched." diff --git a/xrspatial/tests/test_resample_coverage_2026_05_27.py b/xrspatial/tests/test_resample_coverage_2026_05_27.py new file mode 100644 index 000000000..a6b55005a --- /dev/null +++ b/xrspatial/tests/test_resample_coverage_2026_05_27.py @@ -0,0 +1,388 @@ +"""Test coverage gap closures for xrspatial.resample (deep-sweep 2026-05-27). + +Adds tests for gaps the deep-sweep test-coverage audit surfaced: + +- Cat 3 HIGH: Nx1 single-column raster across all four backends + all + methods (existing tests covered 1x1 and 1xN but not Nx1). +- Cat 2 MEDIUM: NaN parity between numpy and cupy / dask+cupy + backends (existing parity tests used random data without NaNs). +- Cat 3 MEDIUM: all-equal-value raster across methods (zero-variance + input that could expose divide-by-zero in weight-aware kernels). +- Cat 5 MEDIUM: non-default dim names (lat/lon) propagate through + resample without being renamed to y/x. +- Cat 3 MEDIUM: empty raster behavior pin (currently raises + IndexError -- documents the contract). + +Source untouched -- this is a test-only coverage closure. +""" +from __future__ import annotations + +import numpy as np +import pytest +import xarray as xr + +from xrspatial.resample import resample +from xrspatial.tests.general_checks import ( + create_test_raster, + cuda_and_cupy_available, + dask_array_available, +) + + +# --------------------------------------------------------------------------- +# Cat 3 HIGH -- Nx1 single-column raster +# --------------------------------------------------------------------------- + +def _backend_available(backend): + if backend == 'numpy': + return True + if backend == 'cupy': + from xrspatial.utils import has_cuda_and_cupy + return has_cuda_and_cupy() + if backend == 'dask+numpy': + from xrspatial.utils import has_dask_array + return has_dask_array() + if backend == 'dask+cupy': + from xrspatial.utils import has_cuda_and_cupy, has_dask_array + return has_cuda_and_cupy() and has_dask_array() + return False + + +def _to_numpy(arr): + data = arr.data + if hasattr(data, 'compute'): + data = data.compute() + if hasattr(data, 'get'): + data = data.get() + return np.asarray(data) + + +class TestSingleColumnRaster: + """Nx1 single-column input. Kernel boundary degeneracy: width is 1 + so every window has unit horizontal extent. Existing tests covered + 1x1 and 1xN strips but no Nx1 case until now.""" + + @pytest.mark.parametrize( + 'backend', ['numpy', 'cupy', 'dask+numpy', 'dask+cupy'] + ) + @pytest.mark.parametrize( + 'method', ['nearest', 'bilinear', 'cubic', 'average', + 'min', 'max', 'median', 'mode'] + ) + def test_nx1_downsample(self, backend, method): + if not _backend_available(backend): + pytest.skip(f"backend {backend} unavailable") + if method == 'cubic' and backend.startswith('dask'): + # The dask path uses a depth-16 cubic-prefilter overlap, which + # ensure_minimum_chunksize cannot accommodate for a width-1 + # array. The eager numpy and cupy backends handle Nx1 cubic + # fine; the dask limitation is documented in the audit notes + # and tracked as a separate issue rather than a sweep fix. + pytest.skip("dask cubic depth>chunk-width; tracked in #2547") + data = np.array([[1.0], [2.0], [3.0], [4.0]], dtype=np.float32) + agg = create_test_raster( + data, backend=backend, dims=['y', 'x'], + attrs={'res': (1.0, 1.0)}, chunks=(2, 1), + ) + out = resample(agg, scale_factor=0.5, method=method) + out_np = _to_numpy(out) + assert out.shape == (2, 1) + assert np.all(np.isfinite(out_np)) + # Output bounded by input range (no overflow / extrapolation) + assert out_np.min() >= 1.0 and out_np.max() <= 4.0 + + @pytest.mark.parametrize( + 'backend', ['numpy', 'cupy', 'dask+numpy', 'dask+cupy'] + ) + def test_nx1_upsample_nearest(self, backend): + if not _backend_available(backend): + pytest.skip(f"backend {backend} unavailable") + data = np.array([[1.0], [2.0], [3.0], [4.0]], dtype=np.float32) + agg = create_test_raster( + data, backend=backend, dims=['y', 'x'], + attrs={'res': (1.0, 1.0)}, chunks=(2, 1), + ) + # scale_factor=(2.0, 1.0) keeps the single column width fixed and + # only upsamples along y, otherwise scale=2.0 would also double + # the x axis to width 2 (since the formula rounds 1*2 = 2). + out = resample(agg, scale_factor=(2.0, 1.0), method='nearest') + out_np = _to_numpy(out) + assert out.shape == (8, 1) + # Nearest upsample preserves source values + for v in out_np.ravel(): + assert v in data.ravel() + + def test_nx1_parity_numpy_vs_backends(self): + """Pin Nx1 output equality across all available backends.""" + data = np.array([[10.0], [20.0], [30.0], [40.0], [50.0], [60.0]], + dtype=np.float32) + np_agg = create_test_raster( + data, backend='numpy', dims=['y', 'x'], + attrs={'res': (1.0, 1.0)}, + ) + np_out = _to_numpy(resample(np_agg, scale_factor=0.5, method='average')) + + for backend in ['cupy', 'dask+numpy', 'dask+cupy']: + if not _backend_available(backend): + continue + agg = create_test_raster( + data, backend=backend, dims=['y', 'x'], + attrs={'res': (1.0, 1.0)}, chunks=(3, 1), + ) + out = _to_numpy(resample(agg, scale_factor=0.5, method='average')) + np.testing.assert_allclose( + out, np_out, atol=1e-5, equal_nan=True, + err_msg=f"Nx1 average parity failed for {backend}", + ) + + +# --------------------------------------------------------------------------- +# Cat 2 MEDIUM -- NaN parity across cupy / dask+cupy backends +# --------------------------------------------------------------------------- + +@cuda_and_cupy_available +class TestCuPyNaNParity: + """Existing TestCuPyParity used random data without NaN cells. The + weight-aware NaN handling has independent numpy and cupy code paths + (_nan_aware_interp_np vs _nan_aware_interp_cupy); a divergence in + the weight-mask gate or spline-prefilter prepad would not surface + from the no-NaN parity tests.""" + + @pytest.fixture + def numpy_and_cupy_nan_rasters(self): + rng = np.random.RandomState(2026) + data = rng.rand(12, 12).astype(np.float32) + # Scatter NaNs so both interior and near-edge cells are masked. + data[1, 1] = np.nan + data[6, 6] = np.nan + data[0, 4] = np.nan # edge cell + data[11, 11] = np.nan # corner cell + np_agg = create_test_raster( + data, backend='numpy', attrs={'res': (1.0, 1.0)}, + ) + cp_agg = create_test_raster( + data, backend='cupy', attrs={'res': (1.0, 1.0)}, + ) + return np_agg, cp_agg + + @pytest.mark.parametrize('method', ['nearest', 'bilinear', 'cubic']) + @pytest.mark.parametrize('sf', [0.5, 2.0]) + def test_interp_nan_parity(self, numpy_and_cupy_nan_rasters, method, sf): + np_agg, cp_agg = numpy_and_cupy_nan_rasters + np_out = resample(np_agg, scale_factor=sf, method=method).values + cp_out = resample(cp_agg, scale_factor=sf, method=method).data.get() + np.testing.assert_allclose( + cp_out, np_out, atol=1e-4, equal_nan=True, + err_msg=f"cupy NaN parity failed for {method} sf={sf}", + ) + + @pytest.mark.parametrize('method', ['average', 'min', 'max']) + def test_aggregate_nan_parity(self, numpy_and_cupy_nan_rasters, method): + np_agg, cp_agg = numpy_and_cupy_nan_rasters + np_out = resample(np_agg, scale_factor=0.5, method=method).values + cp_out = resample(cp_agg, scale_factor=0.5, method=method).data.get() + np.testing.assert_allclose( + cp_out, np_out, atol=1e-5, equal_nan=True, + err_msg=f"cupy NaN aggregate parity failed for {method}", + ) + + +@cuda_and_cupy_available +@dask_array_available +class TestDaskCuPyNaNParity: + """Same NaN-parity check on the dask+cupy backend so the chunked + GPU map_blocks path is also exercised with masked cells.""" + + @pytest.fixture + def numpy_and_dask_cupy_nan_rasters(self): + rng = np.random.RandomState(2027) + data = rng.rand(20, 20).astype(np.float32) + data[2, 2] = np.nan + data[10, 10] = np.nan + data[0, 9] = np.nan + data[19, 19] = np.nan + np_agg = create_test_raster( + data, backend='numpy', attrs={'res': (1.0, 1.0)}, + ) + dc_agg = create_test_raster( + data, backend='dask+cupy', attrs={'res': (1.0, 1.0)}, + chunks=(8, 8), + ) + return np_agg, dc_agg + + @pytest.mark.parametrize('method', ['nearest', 'bilinear', 'cubic']) + def test_interp_nan_parity(self, numpy_and_dask_cupy_nan_rasters, method): + np_agg, dc_agg = numpy_and_dask_cupy_nan_rasters + np_out = resample(np_agg, scale_factor=0.5, method=method).values + dc_out = resample(dc_agg, scale_factor=0.5, + method=method).data.compute().get() + np.testing.assert_allclose( + dc_out, np_out, atol=1e-4, equal_nan=True, + err_msg=f"dask+cupy NaN parity failed for {method}", + ) + + @pytest.mark.parametrize('method', ['average', 'min', 'max']) + def test_aggregate_nan_parity(self, numpy_and_dask_cupy_nan_rasters, + method): + np_agg, dc_agg = numpy_and_dask_cupy_nan_rasters + np_out = resample(np_agg, scale_factor=0.5, method=method).values + dc_out = resample(dc_agg, scale_factor=0.5, + method=method).data.compute().get() + np.testing.assert_allclose( + dc_out, np_out, atol=1e-5, equal_nan=True, + err_msg=f"dask+cupy NaN aggregate parity failed for {method}", + ) + + +# --------------------------------------------------------------------------- +# Cat 3 MEDIUM -- all-equal-value raster (zero variance / zero gradient) +# --------------------------------------------------------------------------- + +class TestAllEqualRaster: + """Constant-value input has zero gradient: spline coefficient pre-filter + should produce zeros except for the DC term, the weight-aware divisor + should stay at 1.0, and aggregate kernels should return the constant. + A divide-by-zero in the weight gate (e.g. dropping np.maximum(z_wt, + 1e-10)) would surface as NaN here.""" + + @pytest.mark.parametrize( + 'method', ['nearest', 'bilinear', 'cubic', 'average', + 'min', 'max', 'median', 'mode'] + ) + def test_constant_input_constant_output(self, method): + data = np.full((8, 8), 5.0, dtype=np.float32) + agg = create_test_raster(data, attrs={'res': (1.0, 1.0)}) + out = resample(agg, scale_factor=0.5, method=method) + np.testing.assert_allclose(out.values, 5.0, atol=1e-5) + + @pytest.mark.parametrize( + 'method', ['nearest', 'bilinear', 'cubic'] + ) + def test_constant_upsample(self, method): + # Aggregate methods reject scale > 1.0, so only interpolation + # methods upsample. + data = np.full((4, 4), 3.0, dtype=np.float32) + agg = create_test_raster(data, attrs={'res': (1.0, 1.0)}) + out = resample(agg, scale_factor=2.0, method=method) + np.testing.assert_allclose(out.values, 3.0, atol=1e-5) + + def test_constant_with_nan_block_aggregate(self): + # The average aggregate is NaN-aware (see _agg_mean / _nan_aware_* + # in resample.py): a 2x2 window containing one NaN and three 7s + # collapses to 7, not NaN. Other cells are pure 7. + data = np.full((4, 4), 7.0, dtype=np.float32) + data[0, 0] = np.nan + agg = create_test_raster(data, attrs={'res': (1.0, 1.0)}) + out = resample(agg, scale_factor=0.5, method='average').values + np.testing.assert_allclose(out, 7.0, atol=1e-5) + + +# --------------------------------------------------------------------------- +# Cat 5 MEDIUM -- non-default dim names (lat/lon) propagate +# --------------------------------------------------------------------------- + +class TestNonDefaultDimNames: + """resample should not silently rename `lat`/`lon` to `y`/`x`. The + public API uses agg.dims and rebuilds the output with the same names + -- this test locks the contract so a regression would surface.""" + + def test_lat_lon_dims_preserved(self): + data = np.arange(64, dtype=np.float32).reshape(8, 8) + agg = xr.DataArray( + data, + dims=['lat', 'lon'], + coords={ + 'lat': np.linspace(7, 0, 8), + 'lon': np.linspace(0, 7, 8), + }, + attrs={'res': (1.0, 1.0)}, + ) + out = resample(agg, scale_factor=0.5, method='nearest') + assert out.dims == ('lat', 'lon') + assert 'lat' in out.coords and 'lon' in out.coords + assert out.shape == (4, 4) + + def test_latitude_longitude_dims_preserved(self): + # CF-style long names. + data = np.arange(64, dtype=np.float32).reshape(8, 8) + agg = xr.DataArray( + data, + dims=['latitude', 'longitude'], + coords={ + 'latitude': np.linspace(7, 0, 8), + 'longitude': np.linspace(0, 7, 8), + }, + attrs={'res': (1.0, 1.0)}, + ) + out = resample(agg, scale_factor=0.5, method='average') + assert out.dims == ('latitude', 'longitude') + + def test_3d_band_lat_lon_dims_preserved(self): + # 3D path should preserve the leading-dim name too. + data = np.arange(3 * 4 * 4, dtype=np.float32).reshape(3, 4, 4) + agg = xr.DataArray( + data, + dims=['channel', 'lat', 'lon'], + coords={ + 'channel': [1, 2, 3], + 'lat': np.linspace(3, 0, 4), + 'lon': np.linspace(0, 3, 4), + }, + attrs={'res': (1.0, 1.0)}, + ) + out = resample(agg, scale_factor=0.5, method='nearest') + assert out.dims == ('channel', 'lat', 'lon') + np.testing.assert_array_equal(out['channel'].values, [1, 2, 3]) + + def test_dim_attrs_preserved(self): + # Per-dim attrs (e.g. units) should round-trip. + data = np.arange(64, dtype=np.float32).reshape(8, 8) + agg = xr.DataArray( + data, + dims=['lat', 'lon'], + coords={ + 'lat': np.linspace(7, 0, 8), + 'lon': np.linspace(0, 7, 8), + }, + attrs={'res': (1.0, 1.0)}, + ) + agg['lat'].attrs['units'] = 'degrees_north' + agg['lon'].attrs['units'] = 'degrees_east' + + out = resample(agg, scale_factor=0.5, method='nearest') + assert out['lat'].attrs.get('units') == 'degrees_north' + assert out['lon'].attrs.get('units') == 'degrees_east' + + +# --------------------------------------------------------------------------- +# Cat 3 MEDIUM -- empty raster (0 rows or 0 cols) behavior pin +# --------------------------------------------------------------------------- + +class TestEmptyRasterRejected: + """Resample on a zero-area raster currently raises IndexError when + rebuilding output coords (vals[0] index access). Pin this so any + future ValueError-raises-with-clear-message change is visible.""" + + def test_zero_rows_raises(self): + data = np.zeros((0, 4), dtype=np.float32) + agg = xr.DataArray( + data, dims=['y', 'x'], + coords={'y': np.zeros(0), 'x': np.arange(4, dtype=np.float64)}, + attrs={'res': (1.0, 1.0)}, + ) + # Current behaviour: IndexError when building output coords. + # If a future PR converts this to a friendlier ValueError, update + # this pin -- the goal is that the contract is *covered*, not + # that the current error type is preserved forever. + with pytest.raises((IndexError, ValueError)): + resample(agg, scale_factor=0.5, method='nearest') + + def test_zero_cols_raises(self): + data = np.zeros((4, 0), dtype=np.float32) + agg = xr.DataArray( + data, dims=['y', 'x'], + coords={'y': np.arange(4, dtype=np.float64), 'x': np.zeros(0)}, + attrs={'res': (1.0, 1.0)}, + ) + with pytest.raises((IndexError, ValueError)): + resample(agg, scale_factor=0.5, method='nearest')