zonal: make crop() return (0, 0) on all backends when no zone matches#2580
Merged
Conversation
…#2561) The numpy/cupy `_crop` helper fell through its scan loops with inverted bounds when none of the requested zone_ids existed in the input, yielding an empty `(0, 0)` slice. The dask `_crop_bounds_dask` path explicitly returned full-raster bounds in the same situation, so the caller silently got back the entire input raster instead. Both helpers now return an extra `found` flag (1/0). `crop()` slices to `[0:0, 0:0]` when nothing matched, so all four backends agree on shape `(0, 0)`. Documented in the docstring Notes section. Added cross-backend regression tests covering the absent, partially present, and standard cases.
brendancol
commented
May 28, 2026
Contributor
Author
brendancol
left a comment
There was a problem hiding this comment.
PR Review: zonal: make crop() return (0, 0) on all backends when no zone matches
Blockers (must fix before merge)
None.
Suggestions (should fix, not blocking)
xrspatial/tests/test_zonal.py:1959—test_crop_partial_zone_ids_2561only checks that partial matches behave the same as the "present-only" subset. It does not pin an expected shape, so if both code paths drifted together the test would still pass. Addingassert result_partial.shape == (2, 2)(the bounding box ofzone_id=1) makes the regression bar explicit.
Nits (optional improvements)
xrspatial/zonal.py:2599— the final return uses a literal1for thefoundflag while the docstring describes it as the variablefound. Usingreturn top, bottom, left, right, foundis a touch more self-documenting; the localfoundis already 1 by the time we reach this line. Cosmetic only.xrspatial/tests/test_zonal.py:1916—_crop_backends_2561()runs at module import time inside@pytest.mark.parametrize(...). Other tests in this file build backend lists the same way, so this is consistent. Flagging it only as a heads-up for future readers.
What looks good
- The
foundflag convention is symmetric across_cropand_crop_bounds_dask, so the call site is uniform. - Slicing with
values[0:0, 0:0]preserves underlying array type (numpy / cupy / dask) and dim names. Verified by manual repro; no special-casing per backend needed. - The numpy branch short-circuits with
if found == 0: return 0, 0, 0, 0, 0right after the top-pass, so it does not waste work scanning for bottom / left / right. - The
crop()docstring Notes section spells out the new(0, 0)contract for all four backends. - Cross-backend tests use
create_test_rasterand gate onhas_dask_array/has_cuda_and_cupy, matching the existing pattern in this file. - Existing
test_crop_nothing_to_crop(wherezone_id=0is actually present in the raster) still passes; the behavior change only affects the genuinely-absent case.
Checklist
- Algorithm matches reference/paper — N/A, this is a behaviour contract change
- All implemented backends produce consistent results —
(0, 0)everywhere - NaN handling is correct — unchanged from prior behaviour
- Edge cases are covered by tests — absent, partial, happy path
- Dask chunk boundaries handled correctly — uses the existing reduction approach
- No premature materialization or unnecessary copies —
dask.computewas already there - Benchmark exists or is not needed — bug fix, no perf impact
- README feature matrix updated (if applicable) — no API surface change
- Docstrings present and accurate — Notes section updated
- test_crop_partial_zone_ids_2561: pin `result_partial.shape == (2, 2)` so the regression bar is explicit and the test cannot pass by drifting both code paths the same way. - _crop: return the `found` local instead of the literal `1`, matching the docstring.
brendancol
commented
May 28, 2026
Contributor
Author
brendancol
left a comment
There was a problem hiding this comment.
Follow-up review (after ca1cae2)
Re-reviewed the diff after the follow-up commit. Both addressable findings from the first pass are resolved.
Disposition
- Suggestion: pin shape in
test_crop_partial_zone_ids_2561— fixed.assert result_partial.shape == (2, 2)now anchors the bounding box ofzone_id=1, so the test can no longer pass by accidental co-drift of both code paths. Tests still green (20/20). - Nit:
_cropfinal return usesfoundinstead of literal1— fixed. Self-consistent with the docstring now. - Nit:
_crop_backends_2561()called at module import — dismissed. Matches the existing convention intest_zonal.pyand elsewhere; changing it would diverge from the project pattern.
No new findings on this pass. Ready for CI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
xrspatial.zonal.crop(). When the requestedzone_idsare not present in thezonesraster, numpy/cupy used to return shape(0, 0)while dask silently returned the full input raster unchanged. Now all four backends return(0, 0)._crop(CPU) and_crop_bounds_daskto return an explicitfoundflag (1/0) so the call site picks the empty slice deterministically instead of relying on inverted bounds.crop()docstring Notes section.Backend coverage: numpy, cupy, dask+numpy, dask+cupy (parametrized regression tests).
Closes #2561
Test plan
pytest xrspatial/tests/test_zonal.py -k crop, 20/20 pass (existing 11 + 9 new parametrized cases)pytest xrspatial/tests/test_zonal.py, full file 148/148 pass(0, 0)for absent zones