diff --git a/xrspatial/aspect.py b/xrspatial/aspect.py index a0fd626c5..1e70b4e66 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 @@ -549,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 @@ -629,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 c4f1857aa..c3c85f805 100644 --- a/xrspatial/tests/test_aspect.py +++ b/xrspatial/tests/test_aspect.py @@ -475,3 +475,79 @@ 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 + + +# 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 +@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) + _assert_name_derived('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) + _assert_name_derived('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) + _assert_name_derived('dask+cupy', name)