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
29 changes: 26 additions & 3 deletions xrspatial/rasterize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions xrspatial/tests/test_rasterize_partial_dims_2569.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading