Skip to content

Commit cbf3a8f

Browse files
authored
Add hypsometric_integral to zonal module (#1073)
* Add design spec for hypsometric_integral in zonal.py * Update hypsometric_integral spec with review fixes Add column/rasterize_kw params, fix accessor namespace to .xrs, clarify nodata semantics, specify float64 output dtype, add list-of-pairs zones support, note dask chunk alignment strategy. * Add implementation plan for hypsometric_integral * Add .claude/worktrees/ to .gitignore * Add failing tests for hypsometric_integral * Add hypsometric_integral with numpy backend * Add dask backends for hypsometric_integral Wire up _hi_dask_numpy (per-block aggregation + reduce + map_blocks paint-back) and _hi_dask_cupy (host transfer delegate) backends. * Wire up hypsometric_integral accessor and public export Add .xrs.zonal_hypsometric_integral() accessor method, top-level import in __init__.py, accessor test, and vector-zones test. * Fix cupy test extraction to use .data.get() instead of .values Add _to_numpy helper that handles cupy, dask, and numpy backends correctly, avoiding implicit cupy-to-numpy conversion error.
1 parent ce4f0d8 commit cbf3a8f

File tree

4 files changed

+438
-0
lines changed

4 files changed

+438
-0
lines changed

xrspatial/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@
139139
from xrspatial.zonal import crosstab as zonal_crosstab # noqa
140140
from xrspatial.zonal import regions as regions # noqa
141141
from xrspatial.zonal import stats as zonal_stats # noqa
142+
from xrspatial.zonal import hypsometric_integral # noqa
142143
from xrspatial.zonal import suggest_zonal_canvas as suggest_zonal_canvas # noqa
143144
from xrspatial.reproject import merge # noqa
144145
from xrspatial.reproject import reproject # noqa

xrspatial/accessor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,10 @@ def zonal_crosstab(self, zones, **kwargs):
358358
from .zonal import crosstab
359359
return crosstab(zones, self._obj, **kwargs)
360360

361+
def zonal_hypsometric_integral(self, zones, **kwargs):
362+
from .zonal import hypsometric_integral
363+
return hypsometric_integral(zones, self._obj, **kwargs)
364+
361365
def crop(self, zones, zones_ids, **kwargs):
362366
from .zonal import crop
363367
return crop(zones, self._obj, zones_ids, **kwargs)
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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

Comments
 (0)