Skip to content

Commit 725a837

Browse files
committed
Set slope output units to 'degrees' instead of leaking input units (#2894)
slope() returned degrees but copied the input DataArray's attrs verbatim, so a DEM with units='m' produced a slope array still labelled 'm'. That stale label made _infer_vertical_unit_type misclassify slope output as elevation. Override attrs['units'] to 'degrees' in the shared public wrapper (covers numpy, cupy, dask+numpy, dask+cupy and Dataset inputs), preserving all other attrs. Add a verify_attrs passthrough to the cross-backend equality helpers and use it in the slope tests whose attrs now legitimately differ. Add tests asserting the degrees override on every backend and the geodesic path.
1 parent 1f343e5 commit 725a837

3 files changed

Lines changed: 100 additions & 22 deletions

File tree

xrspatial/slope.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,8 @@ def slope(agg: xr.DataArray,
351351
If `agg` is a DataArray, returns a DataArray of the same type.
352352
If `agg` is a Dataset, returns a Dataset with slope computed
353353
for each data variable.
354-
2D array of slope values.
355-
All other input attributes are preserved.
354+
2D array of slope values in degrees. The output ``attrs['units']``
355+
is set to ``'degrees'``; all other input attributes are preserved.
356356
357357
Notes
358358
-----
@@ -429,11 +429,17 @@ def slope(agg: xr.DataArray,
429429
)
430430
out = mapper(agg)(agg.data, lat_2d, lon_2d, WGS84_A2, WGS84_B2, z_factor, boundary)
431431

432+
# slope is reported in degrees, so override the input elevation 'units'
433+
# (e.g. 'm') with the angle unit the result actually carries. Other input
434+
# attrs are preserved.
435+
attrs = dict(agg.attrs)
436+
attrs['units'] = 'degrees'
437+
432438
result = xr.DataArray(out,
433439
name=name,
434440
coords=agg.coords,
435441
dims=agg.dims,
436-
attrs=agg.attrs)
442+
attrs=attrs)
437443
# On dask backends, xr.DataArray keeps the dask array's internal graph
438444
# token as .name when name=None, so reset it post-construction to match
439445
# the numpy/cupy backends. (Same fix as zonal #2611, focal #2733.)

xrspatial/tests/general_checks.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,34 +174,36 @@ def assert_boundary_mode_correctness(numpy_agg, dask_agg, func, depth=1, rtol=1e
174174
)
175175

176176

177-
def assert_numpy_equals_dask_numpy(numpy_agg, dask_agg, func, nan_edges=True):
177+
def assert_numpy_equals_dask_numpy(numpy_agg, dask_agg, func, nan_edges=True,
178+
verify_attrs=True):
178179
numpy_result = func(numpy_agg)
179180
if nan_edges:
180181
assert_nan_edges_effect(numpy_result)
181182

182183
dask_result = func(dask_agg)
183-
general_output_checks(dask_agg, dask_result)
184+
general_output_checks(dask_agg, dask_result, verify_attrs=verify_attrs)
184185
np.testing.assert_allclose(numpy_result.data, dask_result.data.compute(), equal_nan=True)
185186

186187

187-
def assert_numpy_equals_cupy(numpy_agg, cupy_agg, func, nan_edges=True, atol=0, rtol=1e-7):
188+
def assert_numpy_equals_cupy(numpy_agg, cupy_agg, func, nan_edges=True, atol=0, rtol=1e-7,
189+
verify_attrs=True):
188190
numpy_result = func(numpy_agg)
189191
if nan_edges:
190192
assert_nan_edges_effect(numpy_result)
191193

192194
cupy_result = func(cupy_agg)
193-
general_output_checks(cupy_agg, cupy_result)
195+
general_output_checks(cupy_agg, cupy_result, verify_attrs=verify_attrs)
194196
np.testing.assert_allclose(
195197
numpy_result.data, cupy_result.data.get(), equal_nan=True, atol=atol, rtol=rtol)
196198

197199

198200
def assert_numpy_equals_dask_cupy(numpy_agg, dask_cupy_agg, func,
199-
nan_edges=True, atol=0, rtol=1e-7):
201+
nan_edges=True, atol=0, rtol=1e-7, verify_attrs=True):
200202
numpy_result = func(numpy_agg)
201203
if nan_edges:
202204
assert_nan_edges_effect(numpy_result)
203205

204206
dask_cupy_result = func(dask_cupy_agg)
205-
general_output_checks(dask_cupy_agg, dask_cupy_result)
207+
general_output_checks(dask_cupy_agg, dask_cupy_result, verify_attrs=verify_attrs)
206208
np.testing.assert_allclose(numpy_result.data, dask_cupy_result.data.compute().get(),
207209
equal_nan=True, atol=atol, rtol=rtol)

xrspatial/tests/test_slope.py

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ def test_numpy_equals_qgis(elevation_raster, qgis_slope):
4242
# slope by xrspatial
4343
numpy_agg = input_data(elevation_raster, backend='numpy')
4444
xrspatial_slope_numpy = slope(numpy_agg, name='slope_numpy')
45-
general_output_checks(numpy_agg, xrspatial_slope_numpy)
45+
# slope overrides attrs['units'] to 'degrees', so skip exact-attrs check
46+
general_output_checks(numpy_agg, xrspatial_slope_numpy, verify_attrs=False)
4647
assert xrspatial_slope_numpy.name == 'slope_numpy'
4748
print('numpy_agg', numpy_agg)
4849
print('xrspatial_slope_numpy', xrspatial_slope_numpy)
@@ -61,15 +62,15 @@ def test_numpy_equals_dask_qgis_data(elevation_raster):
6162
# compare using the data run through QGIS
6263
numpy_agg = input_data(elevation_raster, 'numpy')
6364
dask_agg = input_data(elevation_raster, 'dask+numpy')
64-
assert_numpy_equals_dask_numpy(numpy_agg, dask_agg, slope)
65+
assert_numpy_equals_dask_numpy(numpy_agg, dask_agg, slope, verify_attrs=False)
6566

6667

6768
@cuda_and_cupy_available
6869
def test_numpy_equals_cupy_qgis_data(elevation_raster):
6970
# compare using the data run through QGIS
7071
numpy_agg = input_data(elevation_raster, 'numpy')
7172
cupy_agg = input_data(elevation_raster, 'cupy')
72-
assert_numpy_equals_cupy(numpy_agg, cupy_agg, slope)
73+
assert_numpy_equals_cupy(numpy_agg, cupy_agg, slope, verify_attrs=False)
7374

7475

7576
@dask_array_available
@@ -80,7 +81,8 @@ def test_numpy_equals_cupy_qgis_data(elevation_raster):
8081
def test_numpy_equals_dask_cupy_random_data(random_data):
8182
numpy_agg = create_test_raster(random_data, backend='numpy')
8283
dask_cupy_agg = create_test_raster(random_data, backend='dask+cupy')
83-
assert_numpy_equals_dask_cupy(numpy_agg, dask_cupy_agg, slope, atol=1e-6, rtol=1e-6)
84+
assert_numpy_equals_dask_cupy(numpy_agg, dask_cupy_agg, slope, atol=1e-6, rtol=1e-6,
85+
verify_attrs=False)
8486

8587

8688
@dask_array_available
@@ -330,7 +332,7 @@ def test_degenerate_shape_numpy(shape):
330332
agg = create_test_raster(_degenerate_data(shape), backend='numpy',
331333
attrs={'res': (1, 1)})
332334
result = slope(agg)
333-
general_output_checks(agg, result)
335+
general_output_checks(agg, result, verify_attrs=False)
334336
assert result.shape == shape
335337
assert np.all(np.isnan(result.data))
336338

@@ -342,7 +344,8 @@ def test_degenerate_shape_dask_numpy(shape):
342344
numpy_agg = create_test_raster(data, backend='numpy', attrs={'res': (1, 1)})
343345
dask_agg = create_test_raster(data, backend='dask+numpy',
344346
attrs={'res': (1, 1)}, chunks=shape)
345-
assert_numpy_equals_dask_numpy(numpy_agg, dask_agg, slope, nan_edges=False)
347+
assert_numpy_equals_dask_numpy(numpy_agg, dask_agg, slope, nan_edges=False,
348+
verify_attrs=False)
346349

347350

348351
@cuda_and_cupy_available
@@ -351,7 +354,8 @@ def test_degenerate_shape_cupy(shape):
351354
data = _degenerate_data(shape)
352355
numpy_agg = create_test_raster(data, backend='numpy', attrs={'res': (1, 1)})
353356
cupy_agg = create_test_raster(data, backend='cupy', attrs={'res': (1, 1)})
354-
assert_numpy_equals_cupy(numpy_agg, cupy_agg, slope, nan_edges=False)
357+
assert_numpy_equals_cupy(numpy_agg, cupy_agg, slope, nan_edges=False,
358+
verify_attrs=False)
355359

356360

357361
@dask_array_available
@@ -362,7 +366,8 @@ def test_degenerate_shape_dask_cupy(shape):
362366
numpy_agg = create_test_raster(data, backend='numpy', attrs={'res': (1, 1)})
363367
dask_cupy_agg = create_test_raster(data, backend='dask+cupy',
364368
attrs={'res': (1, 1)}, chunks=shape)
365-
assert_numpy_equals_dask_cupy(numpy_agg, dask_cupy_agg, slope, nan_edges=False)
369+
assert_numpy_equals_dask_cupy(numpy_agg, dask_cupy_agg, slope, nan_edges=False,
370+
verify_attrs=False)
366371

367372

368373
@pytest.mark.parametrize("shape", DEGENERATE_SHAPES)
@@ -376,7 +381,7 @@ def test_degenerate_shape_geodesic(shape):
376381
coords={'lat': lat, 'lon': lon},
377382
)
378383
result = slope(raster, method='geodesic')
379-
general_output_checks(raster, result)
384+
general_output_checks(raster, result, verify_attrs=False)
380385
assert result.shape == shape
381386
assert np.all(np.isnan(result.data))
382387

@@ -432,7 +437,7 @@ def _center_nan_data():
432437
def test_center_nan_propagates_numpy():
433438
agg = create_test_raster(_center_nan_data(), backend='numpy', attrs={'res': (1, 1)})
434439
result = slope(agg)
435-
general_output_checks(agg, result)
440+
general_output_checks(agg, result, verify_attrs=False)
436441
assert np.isnan(result.data[2, 2])
437442

438443

@@ -442,7 +447,8 @@ def test_center_nan_propagates_dask_numpy():
442447
numpy_agg = create_test_raster(data, backend='numpy', attrs={'res': (1, 1)})
443448
dask_agg = create_test_raster(data, backend='dask+numpy',
444449
attrs={'res': (1, 1)}, chunks=(3, 3))
445-
assert_numpy_equals_dask_numpy(numpy_agg, dask_agg, slope, nan_edges=False)
450+
assert_numpy_equals_dask_numpy(numpy_agg, dask_agg, slope, nan_edges=False,
451+
verify_attrs=False)
446452
assert np.isnan(slope(dask_agg).data.compute()[2, 2])
447453

448454

@@ -451,7 +457,8 @@ def test_center_nan_propagates_cupy():
451457
data = _center_nan_data()
452458
numpy_agg = create_test_raster(data, backend='numpy', attrs={'res': (1, 1)})
453459
cupy_agg = create_test_raster(data, backend='cupy', attrs={'res': (1, 1)})
454-
assert_numpy_equals_cupy(numpy_agg, cupy_agg, slope, nan_edges=False)
460+
assert_numpy_equals_cupy(numpy_agg, cupy_agg, slope, nan_edges=False,
461+
verify_attrs=False)
455462
assert np.isnan(slope(cupy_agg).data.get()[2, 2])
456463

457464

@@ -462,4 +469,67 @@ def test_center_nan_propagates_dask_cupy():
462469
numpy_agg = create_test_raster(data, backend='numpy', attrs={'res': (1, 1)})
463470
dask_cupy_agg = create_test_raster(data, backend='dask+cupy',
464471
attrs={'res': (1, 1)}, chunks=(3, 3))
465-
assert_numpy_equals_dask_cupy(numpy_agg, dask_cupy_agg, slope, nan_edges=False)
472+
assert_numpy_equals_dask_cupy(numpy_agg, dask_cupy_agg, slope, nan_edges=False,
473+
verify_attrs=False)
474+
475+
476+
# slope is reported in degrees, so the output must advertise units='degrees'
477+
# rather than leaking the input elevation units (e.g. 'm'). Regression for #2894.
478+
def test_units_overridden_to_degrees_numpy():
479+
agg = create_test_raster(_degenerate_data((5, 5)), backend='numpy',
480+
attrs={'res': (1, 1), 'units': 'm', 'crs': 'EPSG:5070'})
481+
result = slope(agg)
482+
assert result.attrs['units'] == 'degrees'
483+
# other attrs are preserved untouched
484+
assert result.attrs['res'] == (1, 1)
485+
assert result.attrs['crs'] == 'EPSG:5070'
486+
# input is not mutated
487+
assert agg.attrs['units'] == 'm'
488+
489+
490+
@dask_array_available
491+
def test_units_overridden_to_degrees_dask_numpy():
492+
agg = create_test_raster(_degenerate_data((5, 5)), backend='dask+numpy',
493+
attrs={'res': (1, 1), 'units': 'm'}, chunks=(3, 3))
494+
result = slope(agg)
495+
assert result.attrs['units'] == 'degrees'
496+
assert agg.attrs['units'] == 'm'
497+
498+
499+
@cuda_and_cupy_available
500+
def test_units_overridden_to_degrees_cupy():
501+
agg = create_test_raster(_degenerate_data((5, 5)), backend='cupy',
502+
attrs={'res': (1, 1), 'units': 'm'})
503+
result = slope(agg)
504+
assert result.attrs['units'] == 'degrees'
505+
assert agg.attrs['units'] == 'm'
506+
507+
508+
@dask_array_available
509+
@cuda_and_cupy_available
510+
def test_units_overridden_to_degrees_dask_cupy():
511+
agg = create_test_raster(_degenerate_data((5, 5)), backend='dask+cupy',
512+
attrs={'res': (1, 1), 'units': 'm'}, chunks=(3, 3))
513+
result = slope(agg)
514+
assert result.attrs['units'] == 'degrees'
515+
assert agg.attrs['units'] == 'm'
516+
517+
518+
def test_units_set_to_degrees_when_input_has_no_units():
519+
agg = create_test_raster(_degenerate_data((5, 5)), backend='numpy',
520+
attrs={'res': (1, 1)})
521+
result = slope(agg)
522+
assert result.attrs['units'] == 'degrees'
523+
524+
525+
def test_units_overridden_to_degrees_geodesic():
526+
H, W = 5, 5
527+
raster = xr.DataArray(
528+
_degenerate_data((H, W)),
529+
dims=['lat', 'lon'],
530+
coords={'lat': np.linspace(40.0, 41.0, H), 'lon': np.linspace(10.0, 11.0, W)},
531+
attrs={'units': 'm'},
532+
)
533+
result = slope(raster, method='geodesic')
534+
assert result.attrs['units'] == 'degrees'
535+
assert raster.attrs['units'] == 'm'

0 commit comments

Comments
 (0)