From cc0ef2c9870d021d1124eb6538657e5c633729e6 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Tue, 2 Jun 2026 14:46:53 -0700 Subject: [PATCH 1/2] Fix aspect() .name leak on dask backends when name=None (#2841) xr.DataArray keeps a dask array's internal graph token as .name when name=None, so aspect() returned e.g. '_trim-' (planar) or 'getitem-' (geodesic) on the dask backends while numpy and cupy returned None. Reset result.name after construction so all four backends agree, matching the fix used for zonal (#2611), focal (#2733), and slope (#2838). Adds parametrized name-consistency tests over numpy, cupy, dask+numpy, and dask+cupy for both planar and geodesic methods. --- xrspatial/aspect.py | 16 ++++++--- xrspatial/tests/test_aspect.py | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/xrspatial/aspect.py b/xrspatial/aspect.py index a0fd626c5..42110fa37 100644 --- a/xrspatial/aspect.py +++ b/xrspatial/aspect.py @@ -469,11 +469,17 @@ def aspect(agg: xr.DataArray, ) out = mapper(agg)(agg.data, lat_2d, lon_2d, WGS84_A2, WGS84_B2, z_factor, boundary) - return xr.DataArray(out, - name=name, - coords=agg.coords, - dims=agg.dims, - attrs=agg.attrs) + result = xr.DataArray(out, + name=name, + coords=agg.coords, + dims=agg.dims, + attrs=agg.attrs) + # On dask backends, xr.DataArray keeps the dask array's internal graph + # token as .name when name=None, so reset it post-construction to match + # the numpy/cupy backends. (Same fix as zonal #2611, focal #2733, + # slope #2838.) + result.name = name + return result @supports_dataset diff --git a/xrspatial/tests/test_aspect.py b/xrspatial/tests/test_aspect.py index c4f1857aa..08ac14298 100644 --- a/xrspatial/tests/test_aspect.py +++ b/xrspatial/tests/test_aspect.py @@ -475,3 +475,65 @@ 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) + + +# The output .name must agree across backends. xr.DataArray keeps a dask +# array's internal graph token (e.g. '_trim-' planar, 'getitem-' +# geodesic) as .name when name=None, so the dask backends used to disagree +# with numpy/cupy. Regression for the aspect .name leak (same class as zonal +# #2611, focal #2733, slope #2838). +def _assert_name_planar(backend, name): + data = np.random.default_rng(0).random((8, 10)).astype(np.float64) * 100 + agg = create_test_raster(data, backend=backend, attrs={'res': (1, 1)}, + chunks=(3, 4)) + assert aspect(agg, name=name).name == name + + +def _geodesic_raster(backend): + H, W = 8, 10 + lat = np.linspace(40.0, 41.0, H) + lon = np.linspace(10.0, 11.0, W) + data = np.random.default_rng(1).random((H, W)) * 100 + raster = xr.DataArray( + data, dims=['lat', 'lon'], coords={'lat': lat, 'lon': lon}, + ) + if 'cupy' in backend: + import cupy + raster.data = cupy.asarray(raster.data) + if 'dask' in backend: + import dask.array as da + raster.data = da.from_array(raster.data, chunks=(4, 5)) + return raster + + +def _assert_name_geodesic(backend, name): + result = aspect(_geodesic_raster(backend), method='geodesic', name=name) + assert result.name == name + + +@pytest.mark.parametrize("name", [None, 'aspect']) +def test_name_consistent_numpy(name): + _assert_name_planar('numpy', name) + _assert_name_geodesic('numpy', name) + + +@dask_array_available +@pytest.mark.parametrize("name", [None, 'aspect']) +def test_name_consistent_dask_numpy(name): + _assert_name_planar('dask+numpy', name) + _assert_name_geodesic('dask+numpy', name) + + +@cuda_and_cupy_available +@pytest.mark.parametrize("name", [None, 'aspect']) +def test_name_consistent_cupy(name): + _assert_name_planar('cupy', name) + _assert_name_geodesic('cupy', name) + + +@dask_array_available +@cuda_and_cupy_available +@pytest.mark.parametrize("name", [None, 'aspect']) +def test_name_consistent_dask_cupy(name): + _assert_name_planar('dask+cupy', name) + _assert_name_geodesic('dask+cupy', name) From 34fe2514d9ebe0e158bb91707ca4c23b8a0113b1 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Tue, 2 Jun 2026 14:48:53 -0700 Subject: [PATCH 2/2] Address review: fix same .name leak in northness/eastness (#2841) northness() and eastness() wrap aspect() and build their own output DataArray, so they leaked the dask graph token ('where-') when name=None just like aspect() did. Reset result.name in both, and extend the name-consistency tests to cover them across all four backends. --- xrspatial/aspect.py | 28 ++++++++++++++++++---------- xrspatial/tests/test_aspect.py | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/xrspatial/aspect.py b/xrspatial/aspect.py index 42110fa37..1e70b4e66 100644 --- a/xrspatial/aspect.py +++ b/xrspatial/aspect.py @@ -555,11 +555,15 @@ def northness(agg: xr.DataArray, else: trig = np.cos(np.deg2rad(asp_data)) out = np.where(asp_data == -1, np.nan, trig) - return xr.DataArray(out, - name=name, - coords=agg.coords, - dims=agg.dims, - attrs=agg.attrs) + result = xr.DataArray(out, + name=name, + coords=agg.coords, + dims=agg.dims, + attrs=agg.attrs) + # Reset .name post-construction so dask backends don't leak the graph + # token when name=None, matching aspect()/slope() (#2841, #2838). + result.name = name + return result @supports_dataset @@ -635,8 +639,12 @@ def eastness(agg: xr.DataArray, else: trig = np.sin(np.deg2rad(asp_data)) out = np.where(asp_data == -1, np.nan, trig) - return xr.DataArray(out, - name=name, - coords=agg.coords, - dims=agg.dims, - attrs=agg.attrs) + result = xr.DataArray(out, + name=name, + coords=agg.coords, + dims=agg.dims, + attrs=agg.attrs) + # Reset .name post-construction so dask backends don't leak the graph + # token when name=None, matching aspect()/slope() (#2841, #2838). + result.name = name + return result diff --git a/xrspatial/tests/test_aspect.py b/xrspatial/tests/test_aspect.py index 08ac14298..c3c85f805 100644 --- a/xrspatial/tests/test_aspect.py +++ b/xrspatial/tests/test_aspect.py @@ -511,10 +511,21 @@ def _assert_name_geodesic(backend, name): assert result.name == name +# northness() and eastness() wrap aspect() and build their own DataArray, so +# they leak the same dask graph token (e.g. 'where-') when name=None. +def _assert_name_derived(backend, name): + data = np.random.default_rng(2).random((8, 10)).astype(np.float64) * 100 + agg = create_test_raster(data, backend=backend, attrs={'res': (1, 1)}, + chunks=(3, 4)) + assert northness(agg, name=name).name == name + assert eastness(agg, name=name).name == name + + @pytest.mark.parametrize("name", [None, 'aspect']) def test_name_consistent_numpy(name): _assert_name_planar('numpy', name) _assert_name_geodesic('numpy', name) + _assert_name_derived('numpy', name) @dask_array_available @@ -522,6 +533,7 @@ def test_name_consistent_numpy(name): def test_name_consistent_dask_numpy(name): _assert_name_planar('dask+numpy', name) _assert_name_geodesic('dask+numpy', name) + _assert_name_derived('dask+numpy', name) @cuda_and_cupy_available @@ -529,6 +541,7 @@ def test_name_consistent_dask_numpy(name): def test_name_consistent_cupy(name): _assert_name_planar('cupy', name) _assert_name_geodesic('cupy', name) + _assert_name_derived('cupy', name) @dask_array_available @@ -537,3 +550,4 @@ def test_name_consistent_cupy(name): def test_name_consistent_dask_cupy(name): _assert_name_planar('dask+cupy', name) _assert_name_geodesic('dask+cupy', name) + _assert_name_derived('dask+cupy', name)