Skip to content

zonal: make crop() return (0, 0) on all backends when no zone matches#2580

Merged
brendancol merged 2 commits into
mainfrom
issue-2561
May 28, 2026
Merged

zonal: make crop() return (0, 0) on all backends when no zone matches#2580
brendancol merged 2 commits into
mainfrom
issue-2561

Conversation

@brendancol
Copy link
Copy Markdown
Contributor

Summary

  • Fix backend divergence in xrspatial.zonal.crop(). When the requested zone_ids are not present in the zones raster, numpy/cupy used to return shape (0, 0) while dask silently returned the full input raster unchanged. Now all four backends return (0, 0).
  • Reworked _crop (CPU) and _crop_bounds_dask to return an explicit found flag (1/0) so the call site picks the empty slice deterministically instead of relying on inverted bounds.
  • Documented the no-match behaviour in the 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
  • Manual repro from the issue body: numpy and dask both return (0, 0) for absent zones
  • CI (numpy, cupy where available, dask+numpy, dask+cupy)

…#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.
@github-actions github-actions Bot added the performance PR touches performance-sensitive code label May 28, 2026
Copy link
Copy Markdown
Contributor Author

@brendancol brendancol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:1959test_crop_partial_zone_ids_2561 only 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. Adding assert result_partial.shape == (2, 2) (the bounding box of zone_id=1) makes the regression bar explicit.

Nits (optional improvements)

  • xrspatial/zonal.py:2599 — the final return uses a literal 1 for the found flag while the docstring describes it as the variable found. Using return top, bottom, left, right, found is a touch more self-documenting; the local found is 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 found flag convention is symmetric across _crop and _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, 0 right 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_raster and gate on has_dask_array / has_cuda_and_cupy, matching the existing pattern in this file.
  • Existing test_crop_nothing_to_crop (where zone_id=0 is 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.compute was 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.
Copy link
Copy Markdown
Contributor Author

@brendancol brendancol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of zone_id=1, so the test can no longer pass by accidental co-drift of both code paths. Tests still green (20/20).
  • Nit: _crop final return uses found instead of literal 1 — fixed. Self-consistent with the docstring now.
  • Nit: _crop_backends_2561() called at module import — dismissed. Matches the existing convention in test_zonal.py and elsewhere; changing it would diverge from the project pattern.

No new findings on this pass. Ready for CI.

@brendancol brendancol merged commit 9b34d00 into main May 28, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance PR touches performance-sensitive code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

crop() returns different results by backend when requested zones are absent

1 participant