From e7194e950cc31c7883d16b408d3242f560fa59fc Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Mon, 1 Jun 2026 10:31:34 -0700 Subject: [PATCH 1/2] Skip full-raster geodesic memory guard for dask aspect (#2763) The geodesic aspect path called _check_geodesic_memory against the full raster before backend dispatch, even for dask arrays. The dask backends process the raster chunk-by-chunk via map_overlap, so peak memory tracks chunk size, not full-raster size. The guard now applies only to in-memory numpy/cupy arrays. Adds a dask test (200x200, small chunks, low memory) and keeps the in-memory guard test. --- xrspatial/aspect.py | 12 +++++++++--- xrspatial/tests/test_geodesic_aspect.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) 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..5d32629c 100644 --- a/xrspatial/tests/test_geodesic_aspect.py +++ b/xrspatial/tests/test_geodesic_aspect.py @@ -257,6 +257,24 @@ 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) + # --------------------------------------------------------------------------- # Tests — cross-backend consistency From 204a20c0037cf219479f067b1e3ea359085f74d6 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Mon, 1 Jun 2026 10:33:28 -0700 Subject: [PATCH 2/2] Add dask+cupy guard-skip test for geodesic aspect (#2763) Addresses review suggestion: the dask+cupy path shares the is_dask branch but had no test asserting it skips the full-raster guard under low memory. --- xrspatial/tests/test_geodesic_aspect.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/xrspatial/tests/test_geodesic_aspect.py b/xrspatial/tests/test_geodesic_aspect.py index 5d32629c..fcf96163 100644 --- a/xrspatial/tests/test_geodesic_aspect.py +++ b/xrspatial/tests/test_geodesic_aspect.py @@ -275,6 +275,23 @@ def test_dask_chunked_skips_full_raster_guard(self, monkeypatch): 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