Skip to content

Reject NaN and negative max_distance in the surface distance functions - #3720

Merged
brendancol merged 2 commits into
mainfrom
deep-sweep-error-handling-surface_distance-2026-08-16
Aug 17, 2026
Merged

Reject NaN and negative max_distance in the surface distance functions#3720
brendancol merged 2 commits into
mainfrom
deep-sweep-error-handling-surface_distance-2026-08-16

Conversation

@brendancol

Copy link
Copy Markdown
Contributor

Fixes #3711.

surface_distance, surface_allocation and surface_direction accepted any
max_distance value and any target_values shape. Three problems came out of
that, all found by running the failure paths on all four backends.

max_distance=nan returned different rasters per backend. The numba kernel
bounds the search with if cost_u > max_distance: break, which is False for
NaN, so it ran to completion and returned a full unbounded distance surface.
The CUDA kernel uses if best < current and best <= max_distance, also False
for NaN, so no relaxation was ever accepted and only the seed pixels came back
finite. Same call, 36/36 finite pixels on numpy and dask, 1/36 on cupy, no
exception and no warning either way.

max_distance negative masked even the zero-distance sources on numpy and
cupy (_finalize_dist compares dist > max_distance), and reached dask as a
negative map_overlap depth, surfacing as ValueError: length should not be negative with no mention of the parameter the caller set.

Non-1D target_values reached _seed_sources as a 0-d array. target_values=1
instead of [1] produced a numba TypingError with about a hundred lines of
overload candidates on numpy and dask, and TypeError: len() of unsized object
on cupy.

Change

Three guards in _compute(), next to the existing connectivity and method
checks. The max_distance message is copied from proximity()
(xrspatial/proximity.py:1396), which takes the same parameter and has had this
guard since #2850 for the same reason.

if np.isnan(max_distance_f) or max_distance_f < 0:
    raise ValueError(
        "max_distance must be non-negative, got {0!r}.".format(max_distance))

Tests

26 new parametrized error-path tests: NaN and negative max_distance across
the three public functions and all four backends, plus 0-d and 2-D
target_values. Both assert the exception type and the message text.

$ pytest xrspatial/tests/test_surface_distance.py -q
63 passed in 3.56s

cupy and dask+cupy ran for real on this host, they are not skipped.

Compatibility

These are new exceptions on inputs that previously gave silent
backend-dependent output or an internal dask error. No documented usage
changes, and the 37 pre-existing tests are untouched and still green.

Found by /sweep-error-handling. The state CSV row for surface_distance is
updated in the second commit.

#3711)

max_distance=nan slipped past the numba kernel's rejection-form bound
test (cost_u > max_distance) so the search ran unbounded, while the CUDA
kernel's acceptance-form test (best <= max_distance) rejected every
relaxation and returned a seeds-only raster. The same call therefore
produced a full distance surface on numpy and dask and an almost
entirely NaN one on cupy, with no error and no warning.

A negative max_distance masked even the zero-distance sources on
numpy and cupy, and reached dask as a negative map_overlap depth
(ValueError: length should not be negative).

Both are now rejected in _compute() with proximity()'s message, along
with non-1D target_values, which previously reached _seed_sources as a
0-d array and failed with a numba TypingError that never named the
parameter.
@brendancol

Copy link
Copy Markdown
Contributor Author

Verification trail for this branch, run on a CUDA host (cupy and dask+cupy execute, they are not skipped):

Before the change, max_distance=np.nan on a 6x6 flat raster with one source:

numpy       finite=36/36 max=7.071067810058594
cupy        finite= 1/36 max=0.0
dask+numpy  finite=36/36 max=7.071067810058594

and max_distance=-5.0:

numpy       finite= 0/36 (all NaN)
cupy        finite= 0/36 (all NaN)
dask+numpy  ValueError: length should not be negative

After:

numpy       ValueError: max_distance must be non-negative, got nan.
cupy        ValueError: max_distance must be non-negative, got nan.
dask+numpy  ValueError: max_distance must be non-negative, got nan.

target_values=1 went from a ~100 line numba TypingError (numpy/dask) and TypeError: len() of unsized object (cupy) to ValueError: target_values must be a 1-D sequence of numbers, got 0-D input 1.0. on every backend.

pytest xrspatial/tests/test_surface_distance.py -q gives 63 passed, up from 37, with no pre-existing test modified. flake8 on xrspatial/surface_distance.py is clean; the one F841 in the test file at line 234 predates this branch and is left alone.

@brendancol

Copy link
Copy Markdown
Contributor Author

Integration note: PR #3718 (api-consistency sweep) and PR #3720 (error-handling sweep) both add target_values guards to _compute() in the same /deep-sweep run, within about twenty lines of each other. The intents are complementary, not competing, but the order matters:

Whichever merges second will need a rebase, and the composed result must keep the None normalization before the dimensionality check. If the check runs first, None coerces to a 0-d object array and gets rejected by the very guard meant to catch scalars, which would break #3718's 24-case None parity test.

Worth running both PRs' tests against the merged tree rather than trusting each PR's own green run.

@brendancol
brendancol merged commit 737f9b4 into main Aug 17, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

surface_distance: max_distance=nan and negative max_distance give backend-dependent results with no error

1 participant