From 389f57877101c7aabe95e14b6bd2d2f22d19a0f7 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Tue, 2 Jun 2026 14:46:47 -0700 Subject: [PATCH] Warn on degree/meter unit mismatch in planar aspect() (#2839) aspect()'s planar path went straight to get_dataarray_resolution without checking whether horizontal units (degrees) match vertical units (meters). slope() already calls warn_if_unit_mismatch() before computing resolution. Reuse that same helper in aspect so degree/meter rasters get a UserWarning. Adds tests asserting the warning fires on a degree-coord meter-elevation raster and stays silent on projected meter coords, mirroring the slope tests. --- xrspatial/aspect.py | 11 ++++++++- xrspatial/tests/test_aspect.py | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/xrspatial/aspect.py b/xrspatial/aspect.py index a0fd626c..e3ffe614 100644 --- a/xrspatial/aspect.py +++ b/xrspatial/aspect.py @@ -20,7 +20,7 @@ from xrspatial.utils import (Z_UNITS, ArrayTypeFunctionMapping, _boundary_to_dask, _extract_latlon_coords, _pad_array, _validate_boundary, _validate_raster, cuda_args, get_dataarray_resolution, has_dask_array, - ngjit) + ngjit, warn_if_unit_mismatch) def _geodesic_cuda_dims(shape): @@ -399,6 +399,14 @@ def aspect(agg: xr.DataArray, 2D aggregate array of calculated aspect 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 ---------- @@ -433,6 +441,7 @@ def aspect(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_aspect.py b/xrspatial/tests/test_aspect.py index c4f1857a..4610029d 100644 --- a/xrspatial/tests/test_aspect.py +++ b/xrspatial/tests/test_aspect.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np import pytest import xarray as xr @@ -475,3 +477,42 @@ def test_near_360_aspect_cupy_matches_numpy(backend): gpu_interior = _aspect_interior(_near_360_plane(backend)) np.testing.assert_allclose( gpu_interior, np_interior, rtol=1e-6, atol=1e-3) + + +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"): + aspect(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) + aspect(raster) # must not raise: no unit-mismatch warning expected