Skip to content

Commit c0a156a

Browse files
authored
Honor requested resolution exactly in from_template() (#3494) (#3495)
* Honor requested resolution exactly in from_template() (#3494) Nudge the far edges of the study-area box to an exact multiple of the cell size (anchor lower-left) so attrs['res'] matches the requested resolution instead of drifting when the bbox isn't a whole number of cells. Mirrors the reproject grid path in _compute_output_grid. * Address review: test exact res on 4326 country and dask paths (#3494)
1 parent 7f41c3c commit c0a156a

2 files changed

Lines changed: 59 additions & 5 deletions

File tree

xrspatial/templates.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ def from_template(name, resolution=None, *, preserve=None, backend="numpy",
182182
(north-up, descending ``y``) and ``res``/``crs`` attributes. It covers the
183183
study area's rectangular bounding box and is meant as a starting canvas.
184184
185+
The requested ``resolution`` is honored exactly. The study-area box is
186+
rarely a whole number of cells wide, so the far edges (right, top) are
187+
nudged out by up to half a cell to land on an exact multiple of the cell
188+
size, anchoring the lower-left corner. ``attrs['res']`` therefore matches
189+
the ``resolution`` you pass.
190+
185191
Parameters
186192
----------
187193
name : str
@@ -268,10 +274,14 @@ def from_template(name, resolution=None, *, preserve=None, backend="numpy",
268274
f"Use a coarser resolution."
269275
)
270276

271-
ys, xs = _make_output_coords(bounds, (height, width))
272-
# Realized cell size from the integer grid (may differ slightly from input).
273-
actual_res_x = (right - left) / width
274-
actual_res_y = (top - bottom) / height
277+
# Honor the requested resolution exactly: anchor the lower-left corner and
278+
# nudge the far edges to an exact multiple of the cell size, so res comes
279+
# back as (res_x, res_y) instead of drifting when the bbox extent isn't a
280+
# whole number of cells. Mirrors the reproject grid path
281+
# (_compute_output_grid in reproject/_grid.py).
282+
right = left + width * res_x
283+
top = bottom + height * res_y
284+
ys, xs = _make_output_coords((left, bottom, right, top), (height, width))
275285

276286
data = _make_data((height, width), fill, backend, chunks)
277287
unit = "degree" if crs == 4326 else "m"
@@ -281,7 +291,7 @@ def from_template(name, resolution=None, *, preserve=None, backend="numpy",
281291
name=key,
282292
coords={"y": ys, "x": xs},
283293
dims=["y", "x"],
284-
attrs={"res": (actual_res_x, actual_res_y), "crs": crs},
294+
attrs={"res": (res_x, res_y), "crs": crs},
285295
)
286296
template["x"].attrs["units"] = unit
287297
template["y"].attrs["units"] = unit

xrspatial/tests/test_templates.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,41 @@ def test_resolution_tuple():
8282
assert rx > ry
8383

8484

85+
def test_resolution_honored_exactly():
86+
# the nyc bbox is 52814 m tall, not a whole number of 10 m cells, so res_y
87+
# used to drift to ~10.0008. The far edge is nudged so res stays exact.
88+
agg = from_template("nyc", resolution=10)
89+
assert agg.attrs["res"] == (10.0, 10.0)
90+
91+
92+
def test_resolution_tuple_honored_exactly():
93+
agg = from_template("conus", resolution=(10000, 5000))
94+
assert agg.attrs["res"] == (10000.0, 5000.0)
95+
96+
97+
def test_coords_match_requested_resolution():
98+
# pixel spacing equals the requested resolution on both axes
99+
agg = from_template("nyc", resolution=10)
100+
np.testing.assert_allclose(np.diff(agg.x.values), 10.0, atol=1e-6)
101+
np.testing.assert_allclose(-np.diff(agg.y.values), 10.0, atol=1e-6)
102+
103+
104+
def test_nudge_keeps_centers_within_bbox():
105+
# nudging the far edges out by < half a cell still leaves every pixel
106+
# center inside the registry bbox.
107+
agg = from_template("nyc", resolution=10)
108+
left, bottom, right, top = _REGIONS["nyc"]["bounds"]
109+
assert left <= agg.x.values.min() and agg.x.values.max() <= right
110+
assert bottom <= agg.y.values.min() and agg.y.values.max() <= top
111+
112+
113+
def test_country_resolution_honored_exactly():
114+
# country codes come back in EPSG:4326 (degrees) but go through the same
115+
# nudge math, so a degree resolution is honored exactly too.
116+
agg = from_template("FRA", resolution=0.25)
117+
assert agg.attrs["res"] == (0.25, 0.25)
118+
119+
85120
def test_fill_and_dtype():
86121
agg = from_template("world")
87122
assert agg.dtype == np.float32
@@ -144,6 +179,10 @@ def test_dask_numpy_backend():
144179
np.testing.assert_array_equal(agg.x.values, ref.x.values)
145180
np.testing.assert_array_equal(agg.y.values, ref.y.values)
146181
assert agg.attrs == ref.attrs
182+
# resolution is honored exactly on the dask path too
183+
assert from_template("nyc", resolution=10, backend="dask+numpy").attrs[
184+
"res"
185+
] == (10.0, 10.0)
147186
# values match once computed
148187
assert np.isnan(agg.compute().values).all()
149188

@@ -264,6 +303,11 @@ def test_preserve_resolution_control():
264303
np.testing.assert_allclose(coarse.attrs["res"][0], 50000, rtol=0.05)
265304

266305

306+
def test_preserve_resolution_honored_exactly():
307+
agg = from_template("conus", preserve="area", resolution=50000)
308+
assert agg.attrs["res"] == (50000.0, 50000.0)
309+
310+
267311
def test_preserve_none_unchanged():
268312
a = from_template("conus")
269313
b = from_template("conus", preserve=None)

0 commit comments

Comments
 (0)