Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions xrspatial/resample.py
Original file line number Diff line number Diff line change
Expand Up @@ -1305,7 +1305,21 @@ def _resolve_nodata(agg, nodata):
f"nodata={nodata!r} is not representable in integer dtype "
f"{agg.dtype}; pass an integer sentinel instead."
)
return np.asarray(nodata).astype(agg.dtype).item()
# Integer inputs: an out-of-range sentinel wraps on cast (e.g. 999
# becomes 231 for uint8), masking the wrong cells. Require the value
# to round-trip exactly into agg.dtype before trusting the cast.
info = np.iinfo(agg.dtype)
nd_int = int(nodata)
# A sentinel beyond the dtype range either wraps (numpy fixed-width
# cast) or overflows the C-long conversion for very large Python
# ints. Range-check up front so both surface the same ValueError
# instead of a raw OverflowError.
if nd_int < info.min or nd_int > info.max:
raise ValueError(
f"nodata={nodata!r} is out of range for integer dtype "
f"{agg.dtype} (valid range [{info.min}, {info.max}])."
)
return np.asarray(nd_int).astype(agg.dtype).item()


def _apply_nodata_mask(agg, nodata):
Expand Down Expand Up @@ -1415,9 +1429,11 @@ def resample(
of ``scale_factor`` and ``target_resolution`` are given; if either
is a sequence whose length is not 2; if any component is zero,
negative, NaN, or infinite; if ``method`` is not in
:data:`ALL_METHODS`; or if the spatial coordinates of ``agg`` are
:data:`ALL_METHODS`; if the spatial coordinates of ``agg`` are
not strictly monotonic and evenly spaced (``resample`` only
supports regular monotonic rasters).
supports regular monotonic rasters); or if ``nodata`` does not
round-trip exactly into an integer ``agg.dtype`` (a fractional
or out-of-range sentinel that would wrap on the cast).
"""
_validate_raster(agg, func_name='resample', name='agg', ndim=(2, 3))
_validate_monotonic_regular_coords(agg)
Expand Down
83 changes: 83 additions & 0 deletions xrspatial/tests/test_resample.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
dask_array_available,
cuda_and_cupy_available,
)
from xrspatial.utils import has_dask_array


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1722,3 +1723,85 @@ def test_fractional_float_sentinel_on_int_input_raises(self):
agg = create_test_raster(data, attrs={'res': (1.0, 1.0)})
with pytest.raises(ValueError, match="not representable"):
resample(agg, scale_factor=0.5, method='nearest', nodata=-9999.5)


# ---------------------------------------------------------------------------
# Out-of-range integer nodata sentinels (issue #2660)
# ---------------------------------------------------------------------------

backends = ['numpy']
if has_dask_array():
backends.append('dask+numpy')


class TestNodataOutOfRange:
"""Regression coverage for #2660 -- an out-of-range integer sentinel
used to wrap silently on the dtype cast (e.g. 999 -> 231 for uint8),
masking the wrong cells. It must raise instead."""

@pytest.mark.parametrize('backend', backends)
def test_uint8_sentinel_above_max_raises(self, backend):
# 999 wraps to 231 on a uint8 cast; a real 231 pixel would then
# be masked. Reject the sentinel up front.
data = np.full((4, 4), 231, dtype=np.uint8)
agg = create_test_raster(data, attrs={'res': (1.0, 1.0)},
backend=backend)
with pytest.raises(ValueError, match="out of range"):
resample(agg, scale_factor=0.5, method='nearest', nodata=999)

@pytest.mark.parametrize('backend', backends)
def test_uint8_negative_sentinel_raises(self, backend):
# -1 is not representable in an unsigned dtype; it wraps to 255.
data = np.zeros((4, 4), dtype=np.uint8)
agg = create_test_raster(data, attrs={'res': (1.0, 1.0)},
backend=backend)
with pytest.raises(ValueError, match="out of range"):
resample(agg, scale_factor=0.5, method='nearest', nodata=-1)

def test_int8_sentinel_above_max_raises(self):
# 200 wraps to -56 on an int8 cast.
data = np.zeros((4, 4), dtype=np.int8)
agg = create_test_raster(data, attrs={'res': (1.0, 1.0)})
with pytest.raises(ValueError, match="out of range"):
resample(agg, scale_factor=0.5, method='nearest', nodata=200)

def test_sentinel_beyond_int64_raises_valueerror(self):
# A Python int past the C-long range would raise OverflowError on
# the numpy cast; the range check must turn it into the same
# ValueError as any other out-of-range sentinel.
data = np.zeros((4, 4), dtype=np.int64)
agg = create_test_raster(data, attrs={'res': (1.0, 1.0)})
with pytest.raises(ValueError, match="out of range"):
resample(agg, scale_factor=0.5, method='nearest', nodata=2 ** 70)

def test_out_of_range_sentinel_via_fillvalue_attr_raises(self):
# Same defect when the sentinel arrives through _FillValue rather
# than the explicit kwarg.
data = np.zeros((4, 4), dtype=np.uint8)
agg = create_test_raster(
data, attrs={'res': (1.0, 1.0), '_FillValue': 999}
)
with pytest.raises(ValueError, match="out of range"):
resample(agg, scale_factor=0.5, method='nearest')

@pytest.mark.parametrize('backend', backends)
def test_in_range_sentinel_at_dtype_limit_still_masks(self, backend):
# The boundary value (uint8 max) is representable and must still
# work -- the new check rejects only values that do not round-trip.
sentinel = 255
data = np.array([
[sentinel, sentinel, 10, 10],
[sentinel, sentinel, 10, 10],
[20, 20, 30, 30],
[20, 20, 30, 30],
], dtype=np.uint8)
agg = create_test_raster(data, attrs={'res': (1.0, 1.0)},
backend=backend)
out = resample(agg, scale_factor=0.5, method='nearest',
nodata=sentinel)
# Output type matches input backend (numpy stays numpy, dask
# stays dask) and the resampled corners mask as expected.
assert isinstance(out.data, type(agg.data))
result = out.data.compute() if backend.startswith('dask') else out.data
assert np.isnan(result[0, 0])
assert np.isfinite(result[1, 1])
Loading