diff --git a/xrspatial/rasterize.py b/xrspatial/rasterize.py index 71c2941de..9a5080ec4 100644 --- a/xrspatial/rasterize.py +++ b/xrspatial/rasterize.py @@ -3002,10 +3002,14 @@ def rasterize( ``(shapely.geometry, numeric_value)`` pair. width : int, optional Number of columns in the output raster. Required unless - ``resolution`` or ``like`` is given. + ``resolution`` or ``like`` is given. Must be passed together + with ``height``: a partial override (only one of the two) is + rejected with ``ValueError`` rather than silently filling the + missing dimension from ``like`` or ``resolution``. height : int, optional Number of rows in the output raster. Required unless - ``resolution`` or ``like`` is given. + ``resolution`` or ``like`` is given. Must be passed together + with ``width`` (see above). bounds : tuple of (xmin, ymin, xmax, ymax), optional Geographic extent of the output raster. Inferred from the geometries (or ``like``) if omitted. @@ -3048,7 +3052,9 @@ def rasterize( A single float uses the same resolution for both axes. like : xr.DataArray, optional Template raster. Width, height, bounds, and dtype are copied - from this array (any can still be overridden explicitly). + from this array. Bounds and dtype can be overridden one at a + time; width and height must be overridden together (passing + only one raises ``ValueError``). Must have uniformly spaced ``x`` and ``y`` dim coords -- the rasterizer only writes to a regular grid, so a non-uniform ``like`` is rejected with ``ValueError`` rather than silently @@ -3148,6 +3154,23 @@ def rasterize( raise TypeError( f"merge must be a string or callable, got {type(merge).__name__}") + # Reject partial width/height before any geometry or template work. + # Passing only one of the two has no well-defined meaning here. + # When ``like`` is given, the bounds also come from the template, so + # deriving the missing dimension from aspect ratio would make the x + # and y pixel resolutions diverge and the output coords would no + # longer match ``like``. When ``like`` is not given, there's + # nothing to derive from at all. Either way, the old code silently + # fell through to the ``resolution`` or ``like`` branch and + # discarded the explicit dimension without warning. + if (width is None) != (height is None): + missing = 'height' if width is not None else 'width' + given = 'width' if width is not None else 'height' + raise ValueError( + f"{given} was provided but {missing} was not. Pass both " + f"width and height together, or omit both and supply " + f"resolution or like to size the output.") + # Extract defaults from template raster like_width = like_height = like_bounds = like_dtype = None like_x_coord = like_y_coord = None diff --git a/xrspatial/tests/test_rasterize_partial_dims_2569.py b/xrspatial/tests/test_rasterize_partial_dims_2569.py new file mode 100644 index 000000000..726e00c53 --- /dev/null +++ b/xrspatial/tests/test_rasterize_partial_dims_2569.py @@ -0,0 +1,92 @@ +"""Tests for issue #2569: partial width/height overrides with ``like=`` +must raise rather than be silently ignored. + +The previous behaviour silently fell through to the ``like`` branch when +only one of ``width`` or ``height`` was provided, so +``rasterize(..., like=template, width=W)`` returned the template's +width with no warning. See the docstring change on ``rasterize`` for +the documented contract: width and height are overridden together, or +not at all. +""" + +import numpy as np +import pytest +import xarray as xr +from shapely.geometry import box + +from xrspatial.rasterize import rasterize + + +def _make_like(width=10, height=10): + """2D template DataArray with georeferenced descending-y coords.""" + x = np.linspace(0.5, width - 0.5, width) + y = np.linspace(height - 0.5, 0.5, height) + data = np.zeros((height, width), dtype=np.float64) + return xr.DataArray( + data, dims=['y', 'x'], coords={'y': y, 'x': x}, + ) + + +class TestPartialDimsWithLike: + """``like=`` + exactly one of ``width`` / ``height`` must raise.""" + + def test_like_plus_both_dims_honored(self): + # Sanity: explicit width AND height override the template size. + like = _make_like(width=10, height=10) + result = rasterize( + [(box(2, 2, 8, 8), 1.0)], like=like, width=5, height=4, + fill=0, + ) + assert result.sizes == {'y': 4, 'x': 5} + + def test_like_plus_only_width_raises(self): + like = _make_like() + with pytest.raises(ValueError, match="height was not"): + rasterize( + [(box(2, 2, 8, 8), 1.0)], like=like, width=2, fill=0, + ) + + def test_like_plus_only_height_raises(self): + like = _make_like() + with pytest.raises(ValueError, match="width was not"): + rasterize( + [(box(2, 2, 8, 8), 1.0)], like=like, height=2, fill=0, + ) + + def test_like_plus_neither_uses_like_grid(self): + like = _make_like(width=10, height=10) + result = rasterize( + [(box(2, 2, 8, 8), 1.0)], like=like, fill=0, + ) + assert result.sizes == {'y': 10, 'x': 10} + # Output coords reuse the template's coords bit-exactly. + np.testing.assert_array_equal( + result.coords['x'].values, like.coords['x'].values, + ) + np.testing.assert_array_equal( + result.coords['y'].values, like.coords['y'].values, + ) + + +class TestPartialDimsNoLike: + """Partial width/height without ``like`` must also raise -- the + fall-through used to land on ``resolution`` or the final "must + specify ..." error, both of which dropped the explicit dimension + without a clear message. + """ + + def test_only_width_with_resolution_raises(self): + # Even with resolution providing the size, a half-specified + # width/height is still a contract violation. + with pytest.raises(ValueError, match="height was not"): + rasterize( + [(box(2, 2, 8, 8), 1.0)], + bounds=(0, 0, 10, 10), width=5, resolution=1.0, fill=0, + ) + + def test_only_height_alone_raises(self): + with pytest.raises(ValueError, match="width was not"): + rasterize( + [(box(2, 2, 8, 8), 1.0)], + bounds=(0, 0, 10, 10), height=5, fill=0, + )