diff --git a/xrspatial/slope.py b/xrspatial/slope.py index 56ed0178..8fb34e7f 100644 --- a/xrspatial/slope.py +++ b/xrspatial/slope.py @@ -27,7 +27,8 @@ class cupy(object): _cpu_geodesic_slope, _run_gpu_geodesic_slope) from xrspatial.utils import (Z_UNITS, ArrayTypeFunctionMapping, _boundary_to_dask, _extract_latlon_coords, _pad_array, _validate_boundary, - _validate_raster, cuda_args, get_dataarray_resolution, ngjit) + _validate_raster, cuda_args, get_dataarray_resolution, ngjit, + warn_if_unit_mismatch) def _geodesic_cuda_dims(shape): @@ -353,6 +354,14 @@ def slope(agg: xr.DataArray, 2D array of slope values. All other input attributes are preserved. + Notes + ----- + The ``'planar'`` method uses the coordinate spacing directly as the cell + size. If the coordinates are in degrees (lat/lon) but the elevation values + are in meters, the result is wrong by orders of magnitude. When this + mismatch is detected, a ``UserWarning`` is emitted suggesting you reproject + to a projected CRS or use ``method='geodesic'``. + References ---------- - arcgis: http://desktop.arcgis.com/en/arcmap/10.3/tools/spatial-analyst-toolbox/how-slope-works.htm # noqa @@ -390,6 +399,7 @@ def slope(agg: xr.DataArray, _validate_boundary(boundary) if method == 'planar': + warn_if_unit_mismatch(agg) cellsize_x, cellsize_y = get_dataarray_resolution(agg) mapper = ArrayTypeFunctionMapping( numpy_func=_run_numpy, diff --git a/xrspatial/tests/test_slope.py b/xrspatial/tests/test_slope.py index 3cf1a1d3..6936bc1c 100644 --- a/xrspatial/tests/test_slope.py +++ b/xrspatial/tests/test_slope.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np import pytest import xarray as xr @@ -276,6 +278,45 @@ def test_degenerate_shape_geodesic(shape): assert np.all(np.isnan(result.data)) +def _degree_coord_meter_elevation_raster(): + # degree-like coords (lon/lat) with meter-scale elevation values + data = np.linspace(0, 999, 10 * 10, dtype=float).reshape(10, 10) + y = np.linspace(5.0, 5.0025, 10) + x = np.linspace(-74.93, -74.9275, 10) + return xr.DataArray( + data, + dims=('y', 'x'), + coords={'y': y, 'x': x}, + attrs={'units': 'm'}, + ) + + +def _projected_meter_raster(): + # projected coords in meters (UTM-ish) with meter-scale elevation values + data = np.linspace(0, 999, 10 * 10, dtype=float).reshape(10, 10) + y = np.arange(10) * 30.0 + x = 500_000.0 + np.arange(10) * 30.0 + return xr.DataArray( + data, + dims=('y', 'x'), + coords={'y': y, 'x': x}, + attrs={'units': 'm'}, + ) + + +def test_planar_warns_on_degree_coords_meter_elevation(): + raster = _degree_coord_meter_elevation_raster() + with pytest.warns(UserWarning, match="appears to have coordinates in degrees"): + slope(raster) # method='planar' is the default + + +def test_planar_no_warn_on_projected_meter_coords(): + raster = _projected_meter_raster() + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + slope(raster) # must not raise: no unit-mismatch warning expected + + # A NoData (NaN) center cell must stay NaN in the slope output, even when all 8 # of its neighbours are valid. The planar kernels read the center cell, so a # hole in the DEM can't masquerade as valid flat terrain. Regression for #2761.