|
| 1 | +# xrspatial/tests/test_hypsometric_integral.py |
| 2 | +try: |
| 3 | + import dask.array as da |
| 4 | +except ImportError: |
| 5 | + da = None |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pytest |
| 9 | +import xarray as xr |
| 10 | + |
| 11 | +from .general_checks import create_test_raster |
| 12 | + |
| 13 | +try: |
| 14 | + import cupy as cp |
| 15 | +except ImportError: |
| 16 | + cp = None |
| 17 | + |
| 18 | + |
| 19 | +def _to_numpy(result): |
| 20 | + """Extract numpy array from any backend result.""" |
| 21 | + if da and isinstance(result.data, da.Array): |
| 22 | + result = result.compute() |
| 23 | + if cp is not None and isinstance(result.data, cp.ndarray): |
| 24 | + return result.data.get() |
| 25 | + return result.values |
| 26 | + |
| 27 | + |
| 28 | +# --- fixtures --------------------------------------------------------------- |
| 29 | + |
| 30 | +@pytest.fixture |
| 31 | +def hi_zones(backend): |
| 32 | + """Two zones (1, 2) plus nodata (0). |
| 33 | +
|
| 34 | + Zone 1: 5 cells — (0,1), (0,2), (0,3), (1,1), (1,2) |
| 35 | + Zone 2: 6 cells — (1,3), (2,1), (2,2), (2,3), (3,1), (3,2) |
| 36 | + Nodata: 4 cells — column 0 and (3,3) |
| 37 | + """ |
| 38 | + data = np.array([ |
| 39 | + [0, 1, 1, 1], |
| 40 | + [0, 1, 1, 2], |
| 41 | + [0, 2, 2, 2], |
| 42 | + [0, 2, 2, 0], |
| 43 | + ], dtype=np.float64) |
| 44 | + return create_test_raster(data, backend, dims=['y', 'x'], |
| 45 | + attrs={'res': (1.0, 1.0)}, chunks=(2, 2)) |
| 46 | + |
| 47 | + |
| 48 | +@pytest.fixture |
| 49 | +def hi_values(backend): |
| 50 | + """Elevation values. |
| 51 | +
|
| 52 | + Zone 1 cells: 10, 20, 30, 40, 50 |
| 53 | + min=10, max=50, mean=30, HI=(30-10)/(50-10) = 0.5 |
| 54 | +
|
| 55 | + Zone 2 cells: 100, 60, 70, 80, 90, 95 |
| 56 | + min=60, max=100, mean=82.5, HI=(82.5-60)/(100-60) = 0.5625 |
| 57 | + """ |
| 58 | + data = np.array([ |
| 59 | + [999., 10., 20., 30.], |
| 60 | + [999., 40., 50., 100.], |
| 61 | + [999., 60., 70., 80.], |
| 62 | + [999., 90., 95., 999.], |
| 63 | + ], dtype=np.float64) |
| 64 | + return create_test_raster(data, backend, dims=['y', 'x'], |
| 65 | + attrs={'res': (1.0, 1.0)}, chunks=(2, 2)) |
| 66 | + |
| 67 | + |
| 68 | +# --- basic ------------------------------------------------------------------- |
| 69 | + |
| 70 | +@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy', 'dask+cupy']) |
| 71 | +def test_hypsometric_integral_basic(backend, hi_zones, hi_values): |
| 72 | + from xrspatial.zonal import hypsometric_integral |
| 73 | + |
| 74 | + result = hypsometric_integral(hi_zones, hi_values) |
| 75 | + |
| 76 | + assert isinstance(result, xr.DataArray) |
| 77 | + assert result.shape == hi_values.shape |
| 78 | + assert result.dims == hi_values.dims |
| 79 | + assert result.name == 'hypsometric_integral' |
| 80 | + |
| 81 | + out = _to_numpy(result) |
| 82 | + |
| 83 | + # zone 0 (nodata) cells should be NaN |
| 84 | + nodata_mask = np.array([ |
| 85 | + [True, False, False, False], |
| 86 | + [True, False, False, False], |
| 87 | + [True, False, False, False], |
| 88 | + [True, False, False, True], |
| 89 | + ]) |
| 90 | + assert np.all(np.isnan(out[nodata_mask])) |
| 91 | + |
| 92 | + # zone 1: HI = 0.5 |
| 93 | + z1_mask = np.array([ |
| 94 | + [False, True, True, True], |
| 95 | + [False, True, True, False], |
| 96 | + [False, False, False, False], |
| 97 | + [False, False, False, False], |
| 98 | + ]) |
| 99 | + np.testing.assert_allclose(out[z1_mask], 0.5, rtol=1e-10) |
| 100 | + |
| 101 | + # zone 2: HI = 0.5625 |
| 102 | + z2_mask = np.array([ |
| 103 | + [False, False, False, False], |
| 104 | + [False, False, False, True], |
| 105 | + [False, True, True, True], |
| 106 | + [False, True, True, False], |
| 107 | + ]) |
| 108 | + np.testing.assert_allclose(out[z2_mask], 0.5625, rtol=1e-10) |
| 109 | + |
| 110 | + |
| 111 | +# --- edge cases -------------------------------------------------------------- |
| 112 | + |
| 113 | +@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy', 'dask+cupy']) |
| 114 | +def test_hypsometric_integral_flat_zone(backend): |
| 115 | + """A zone with all identical values has range=0, so HI should be NaN.""" |
| 116 | + from xrspatial.zonal import hypsometric_integral |
| 117 | + |
| 118 | + zones = create_test_raster( |
| 119 | + np.array([[1, 1], [1, 1]], dtype=np.float64), backend, |
| 120 | + chunks=(2, 2)) |
| 121 | + values = create_test_raster( |
| 122 | + np.array([[5.0, 5.0], [5.0, 5.0]]), backend, |
| 123 | + chunks=(2, 2)) |
| 124 | + |
| 125 | + result = hypsometric_integral(zones, values, nodata=0) |
| 126 | + out = _to_numpy(result) |
| 127 | + assert np.all(np.isnan(out)) |
| 128 | + |
| 129 | + |
| 130 | +@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy', 'dask+cupy']) |
| 131 | +def test_hypsometric_integral_nan_in_values(backend): |
| 132 | + """NaN elevation cells should be excluded from per-zone stats.""" |
| 133 | + from xrspatial.zonal import hypsometric_integral |
| 134 | + |
| 135 | + zones = create_test_raster( |
| 136 | + np.array([[1, 1], [1, 1]], dtype=np.float64), backend, |
| 137 | + chunks=(2, 2)) |
| 138 | + values = create_test_raster( |
| 139 | + np.array([[10.0, np.nan], [20.0, 30.0]]), backend, |
| 140 | + chunks=(2, 2)) |
| 141 | + |
| 142 | + result = hypsometric_integral(zones, values, nodata=0) |
| 143 | + out = _to_numpy(result) |
| 144 | + |
| 145 | + # zone 1 finite values: 10, 20, 30 -> HI = (20-10)/(30-10) = 0.5 |
| 146 | + # the NaN cell should remain NaN in output |
| 147 | + assert np.isnan(out[0, 1]) |
| 148 | + np.testing.assert_allclose(out[0, 0], 0.5, rtol=1e-10) |
| 149 | + np.testing.assert_allclose(out[1, 0], 0.5, rtol=1e-10) |
| 150 | + np.testing.assert_allclose(out[1, 1], 0.5, rtol=1e-10) |
| 151 | + |
| 152 | + |
| 153 | +@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy', 'dask+cupy']) |
| 154 | +def test_hypsometric_integral_single_cell_zone(backend): |
| 155 | + """A zone with a single cell has range=0, so HI=NaN.""" |
| 156 | + from xrspatial.zonal import hypsometric_integral |
| 157 | + |
| 158 | + zones = create_test_raster( |
| 159 | + np.array([[1, 2]], dtype=np.float64), backend, |
| 160 | + chunks=(1, 2)) |
| 161 | + values = create_test_raster( |
| 162 | + np.array([[10.0, 20.0]]), backend, |
| 163 | + chunks=(1, 2)) |
| 164 | + |
| 165 | + result = hypsometric_integral(zones, values, nodata=0) |
| 166 | + out = _to_numpy(result) |
| 167 | + # single cell -> range=0 -> NaN |
| 168 | + assert np.all(np.isnan(out)) |
| 169 | + |
| 170 | + |
| 171 | +@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy', 'dask+cupy']) |
| 172 | +def test_hypsometric_integral_all_nan_zone(backend): |
| 173 | + """A zone whose elevation cells are all NaN should produce NaN.""" |
| 174 | + from xrspatial.zonal import hypsometric_integral |
| 175 | + |
| 176 | + zones = create_test_raster( |
| 177 | + np.array([[1, 1], [2, 2]], dtype=np.float64), backend, |
| 178 | + chunks=(2, 2)) |
| 179 | + values = create_test_raster( |
| 180 | + np.array([[np.nan, np.nan], [10.0, 20.0]]), backend, |
| 181 | + chunks=(2, 2)) |
| 182 | + |
| 183 | + result = hypsometric_integral(zones, values, nodata=0) |
| 184 | + out = _to_numpy(result) |
| 185 | + |
| 186 | + # zone 1: all NaN -> NaN |
| 187 | + assert np.all(np.isnan(out[0, :])) |
| 188 | + # zone 2: HI = (15-10)/(20-10) = 0.5 |
| 189 | + np.testing.assert_allclose(out[1, :], 0.5, rtol=1e-10) |
| 190 | + |
| 191 | + |
| 192 | +@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy', 'dask+cupy']) |
| 193 | +def test_hypsometric_integral_nodata_none(backend): |
| 194 | + """When nodata=None, all zone IDs are included (even 0).""" |
| 195 | + from xrspatial.zonal import hypsometric_integral |
| 196 | + |
| 197 | + zones = create_test_raster( |
| 198 | + np.array([[0, 0], [1, 1]], dtype=np.float64), backend, |
| 199 | + chunks=(2, 2)) |
| 200 | + values = create_test_raster( |
| 201 | + np.array([[10.0, 20.0], [30.0, 40.0]]), backend, |
| 202 | + chunks=(2, 2)) |
| 203 | + |
| 204 | + result = hypsometric_integral(zones, values, nodata=None) |
| 205 | + out = _to_numpy(result) |
| 206 | + |
| 207 | + # zone 0: HI = (15-10)/(20-10) = 0.5 |
| 208 | + np.testing.assert_allclose(out[0, :], 0.5, rtol=1e-10) |
| 209 | + # zone 1: HI = (35-30)/(40-30) = 0.5 |
| 210 | + np.testing.assert_allclose(out[1, :], 0.5, rtol=1e-10) |
| 211 | + |
| 212 | + |
| 213 | +@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy', 'dask+cupy']) |
| 214 | +def test_hypsometric_integral_accessor(backend, hi_zones, hi_values): |
| 215 | + """Verify the .xrs accessor method works.""" |
| 216 | + result = hi_values.xrs.zonal_hypsometric_integral(hi_zones) |
| 217 | + assert isinstance(result, xr.DataArray) |
| 218 | + assert result.shape == hi_values.shape |
| 219 | + |
| 220 | + out = _to_numpy(result) |
| 221 | + z1_mask = np.array([ |
| 222 | + [False, True, True, True], |
| 223 | + [False, True, True, False], |
| 224 | + [False, False, False, False], |
| 225 | + [False, False, False, False], |
| 226 | + ]) |
| 227 | + np.testing.assert_allclose(out[z1_mask], 0.5, rtol=1e-10) |
| 228 | + |
| 229 | + |
| 230 | +def test_hypsometric_integral_list_of_pairs_zones(): |
| 231 | + """Vector zones via list of (geometry, value) pairs.""" |
| 232 | + from shapely.geometry import box |
| 233 | + from xrspatial.zonal import hypsometric_integral |
| 234 | + |
| 235 | + pytest.importorskip("shapely") |
| 236 | + pytest.importorskip("rasterio") |
| 237 | + |
| 238 | + values_data = np.array([ |
| 239 | + [10., 20., 30.], |
| 240 | + [40., 50., 60.], |
| 241 | + [70., 80., 90.], |
| 242 | + ], dtype=np.float64) |
| 243 | + values = xr.DataArray(values_data, dims=['y', 'x']) |
| 244 | + values['y'] = [2.0, 1.0, 0.0] |
| 245 | + values['x'] = [0.0, 1.0, 2.0] |
| 246 | + values.attrs['res'] = (1.0, 1.0) |
| 247 | + |
| 248 | + # Zone 1 covers left half, zone 2 covers right half |
| 249 | + zones_pairs = [ |
| 250 | + (box(-0.5, -0.5, 1.5, 2.5), 1), |
| 251 | + (box(1.5, -0.5, 2.5, 2.5), 2), |
| 252 | + ] |
| 253 | + |
| 254 | + result = hypsometric_integral(zones_pairs, values, nodata=0) |
| 255 | + assert isinstance(result, xr.DataArray) |
| 256 | + assert result.shape == values.shape |
0 commit comments