diff --git a/xrspatial/aspect.py b/xrspatial/aspect.py index 3025c0e7..370a28a2 100644 --- a/xrspatial/aspect.py +++ b/xrspatial/aspect.py @@ -19,7 +19,7 @@ _cpu_geodesic_aspect, _run_gpu_geodesic_aspect) from xrspatial.utils import (Z_UNITS, ArrayTypeFunctionMapping, _boundary_to_dask, _extract_latlon_coords, _pad_array, _validate_boundary, - _validate_raster, cuda_args, ngjit) + _validate_raster, cuda_args, has_dask_array, ngjit) def _geodesic_cuda_dims(shape): @@ -423,8 +423,14 @@ def aspect(agg: xr.DataArray, ) z_factor = Z_UNITS[z_unit] - rows, cols = agg.shape[-2], agg.shape[-1] - _check_geodesic_memory(rows, cols, func_name='aspect') + # The full-raster memory guard only applies to in-memory (numpy/cupy) + # arrays, which materialize the whole (3, H, W) stack at once. Dask + # backends process the raster chunk-by-chunk via map_overlap, so peak + # memory is bounded by chunk size, not full-raster size. + is_dask = has_dask_array() and isinstance(agg.data, da.Array) + if not is_dask: + rows, cols = agg.shape[-2], agg.shape[-1] + _check_geodesic_memory(rows, cols, func_name='aspect') lat_2d, lon_2d = _extract_latlon_coords(agg) diff --git a/xrspatial/tests/test_geodesic_aspect.py b/xrspatial/tests/test_geodesic_aspect.py index c51ed51e..fcf96163 100644 --- a/xrspatial/tests/test_geodesic_aspect.py +++ b/xrspatial/tests/test_geodesic_aspect.py @@ -257,6 +257,41 @@ def test_planar_method_skips_guard(self, monkeypatch): result = aspect(raster, method='planar') assert result.shape == (8, 8) + @dask_array_available + def test_dask_chunked_skips_full_raster_guard(self, monkeypatch): + """A chunked dask raster is processed chunk-by-chunk via map_overlap, + so the full-raster guard must not reject it even when available memory + is tiny (issue #2763).""" + monkeypatch.setattr( + 'xrspatial.geodesic._available_memory_bytes', lambda: 1024 * 1024 + ) + elev = _flat_surface(H=200, W=200) + raster = _make_geo_raster( + elev, 40.0, 41.0, 10.0, 11.0, + backend='dask+numpy', chunks=(40, 40), + ) + result = aspect(raster, method='geodesic') + # Force evaluation to confirm the chunked path actually runs. + computed = result.compute() + assert computed.shape == (200, 200) + + @dask_array_available + @cuda_and_cupy_available + def test_dask_cupy_chunked_skips_full_raster_guard(self, monkeypatch): + """dask+cupy goes through the same chunked map_overlap path, so the + full-raster guard must not reject it either (issue #2763).""" + monkeypatch.setattr( + 'xrspatial.geodesic._available_memory_bytes', lambda: 1024 * 1024 + ) + elev = _flat_surface(H=200, W=200) + raster = _make_geo_raster( + elev, 40.0, 41.0, 10.0, 11.0, + backend='dask+cupy', chunks=(40, 40), + ) + result = aspect(raster, method='geodesic') + computed = result.compute() + assert computed.shape == (200, 200) + # --------------------------------------------------------------------------- # Tests — cross-backend consistency