Skip to content

Commit 4d38069

Browse files
authored
Merge pull request #22 from CU-ESIIL/codex/fix-spatial-subsetting-bug-in-gridmet-xjaa0t
Fix gridMET bbox slicing
2 parents 3900b1c + ce7b97f commit 4d38069

File tree

2 files changed

+165
-5
lines changed

2 files changed

+165
-5
lines changed

src/cubedynamics/streaming/gridmet.py

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

33
import io
4-
from typing import Any, Dict, Optional
4+
from typing import Any, Dict, Optional, Sequence
55

6+
import numpy as np
67
import requests
78
import xarray as xr
89
from xarray.backends.plugins import list_engines
@@ -12,6 +13,53 @@
1213
_AVAILABLE_ENGINES = list_engines()
1314

1415

16+
def _axis_slice(coord: Sequence[float], bound_a: float, bound_b: float) -> slice:
17+
"""
18+
Return a slice that spans ``[bound_a, bound_b]`` regardless of axis order.
19+
20+
gridMET tiles have a resolution of roughly 1/24th of a degree. When an AOI
21+
bounding box is smaller than that resolution, its numeric bounds can fall
22+
entirely between adjacent coordinate centers. To avoid empty selections we
23+
expand the slice bounds by half the native grid spacing before subsetting.
24+
"""
25+
26+
values = np.asarray(coord, dtype=float)
27+
if values.size == 0:
28+
val = float(min(bound_a, bound_b))
29+
return slice(val, val)
30+
31+
lo = float(min(bound_a, bound_b))
32+
hi = float(max(bound_a, bound_b))
33+
34+
if values.size > 1:
35+
diffs = np.diff(values)
36+
diffs = diffs[np.nonzero(diffs)]
37+
if diffs.size:
38+
spacing = float(np.min(np.abs(diffs)))
39+
span = hi - lo
40+
if spacing > 0 and span < spacing:
41+
padding = (spacing - span) / 2.0
42+
lo -= padding
43+
hi += padding
44+
45+
descending = values[0] > values[-1]
46+
if descending:
47+
return slice(hi, lo)
48+
return slice(lo, hi)
49+
50+
51+
def _lat_slice(lat_coord: Sequence[float], south: float, north: float) -> slice:
52+
"""Return a latitude slice that works for ascending or descending axes."""
53+
54+
return _axis_slice(lat_coord, south, north)
55+
56+
57+
def _lon_slice(lon_coord: Sequence[float], west: float, east: float) -> slice:
58+
"""Return a longitude slice that works for ascending or descending axes."""
59+
60+
return _axis_slice(lon_coord, west, east)
61+
62+
1563
def _select_stream_engine() -> Optional[str]:
1664
"""Pick the best available xarray engine for streaming gridMET files."""
1765

@@ -162,10 +210,26 @@ def stream_gridmet_to_cube(
162210

163211
# 3) Spatial subset using the AOI bbox
164212
bbox = _bbox_from_geojson(aoi_geojson)
165-
da = ds[variable].sel(
166-
lat=slice(bbox["south"], bbox["north"]),
167-
lon=slice(bbox["west"], bbox["east"]),
168-
)
213+
lat_coord = ds.coords.get("lat")
214+
if lat_coord is None:
215+
raise KeyError("gridMET dataset is missing the 'lat' coordinate")
216+
lon_coord = ds.coords.get("lon")
217+
if lon_coord is None:
218+
raise KeyError("gridMET dataset is missing the 'lon' coordinate")
219+
220+
lat_slice = _lat_slice(lat_coord, bbox["south"], bbox["north"])
221+
lon_slice = _lon_slice(lon_coord, bbox["west"], bbox["east"])
222+
da = ds[variable].sel(lat=lat_slice, lon=lon_slice)
223+
224+
empty_dims = [dim for dim in ("lat", "lon") if da.sizes.get(dim, 0) == 0]
225+
if empty_dims:
226+
raise ValueError(
227+
"gridMET subset is empty along "
228+
+ ", ".join(f"'{dim}'" for dim in empty_dims)
229+
+ ". "
230+
f"Requested south={bbox['south']}, north={bbox['north']}, "
231+
f"west={bbox['west']}, east={bbox['east']}"
232+
)
169233

170234
# 4) Optional resampling in time (e.g., to monthly)
171235
if freq != "D":

src/cubedynamics/tests/test_gridmet_streaming.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
"""Tests for the gridMET streaming helper."""
22

3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import pandas as pd
7+
import xarray as xr
8+
39
import cubedynamics as cd
10+
from cubedynamics.streaming import gridmet as gridmet_mod
411

512

613
def test_stream_gridmet_to_cube_default_source_smoke():
@@ -32,3 +39,92 @@ def test_stream_gridmet_to_cube_default_source_smoke():
3239

3340
assert "time" in cube.dims
3441
assert cube.name == "pr"
42+
43+
44+
def test_stream_gridmet_to_cube_descending_lat(monkeypatch):
45+
"""Descending latitude axes should still produce non-empty subsets."""
46+
47+
times = pd.date_range("2000-01-01", periods=3, freq="D")
48+
lat = xr.DataArray([50.0, 49.5, 49.0], dims="lat")
49+
lon = xr.DataArray([-120.0, -119.5, -119.0], dims="lon")
50+
51+
def _fake_year(variable: str, year: int, chunks=None) -> xr.Dataset: # pragma: no cover - test helper
52+
data = xr.DataArray(
53+
np.arange(times.size * lat.size * lon.size, dtype="float32").reshape(times.size, lat.size, lon.size),
54+
coords={"time": times, "lat": lat, "lon": lon},
55+
dims=("time", "lat", "lon"),
56+
name=variable,
57+
)
58+
return xr.Dataset({variable: data})
59+
60+
monkeypatch.setattr(gridmet_mod, "_open_gridmet_year", _fake_year)
61+
62+
aoi = {
63+
"type": "Feature",
64+
"geometry": {
65+
"type": "Polygon",
66+
"coordinates": [[
67+
[-120.0, 49.1],
68+
[-120.0, 49.6],
69+
[-119.4, 49.6],
70+
[-119.4, 49.1],
71+
[-120.0, 49.1],
72+
]],
73+
},
74+
}
75+
76+
cube = cd.stream_gridmet_to_cube(
77+
aoi_geojson=aoi,
78+
variable="pr",
79+
start="2000-01-01",
80+
end="2000-01-03",
81+
)
82+
83+
assert cube.sizes["lat"] == 1
84+
assert cube.sizes["lon"] == 2
85+
assert cube.lat.item() == 49.5
86+
87+
88+
def test_stream_gridmet_to_cube_bbox_padding(monkeypatch):
89+
"""Tiny AOI bounding boxes should still capture at least one grid cell."""
90+
91+
times = pd.date_range("2000-01-01", periods=2, freq="D")
92+
lat = xr.DataArray([40.020833, 39.979167], dims="lat")
93+
lon = xr.DataArray([-105.375, -105.333333, -105.291666], dims="lon")
94+
95+
def _fake_year(variable: str, year: int, chunks=None) -> xr.Dataset: # pragma: no cover - test helper
96+
data = xr.DataArray(
97+
np.arange(times.size * lat.size * lon.size, dtype="float32").reshape(times.size, lat.size, lon.size),
98+
coords={"time": times, "lat": lat, "lon": lon},
99+
dims=("time", "lat", "lon"),
100+
name=variable,
101+
)
102+
return xr.Dataset({variable: data})
103+
104+
monkeypatch.setattr(gridmet_mod, "_open_gridmet_year", _fake_year)
105+
106+
aoi = {
107+
"type": "Feature",
108+
"geometry": {
109+
"type": "Polygon",
110+
"coordinates": [[
111+
[-105.35, 40.00],
112+
[-105.35, 40.01],
113+
[-105.34, 40.01],
114+
[-105.34, 40.00],
115+
[-105.35, 40.00],
116+
]],
117+
},
118+
}
119+
120+
cube = cd.stream_gridmet_to_cube(
121+
aoi_geojson=aoi,
122+
variable="pr",
123+
start="2000-01-01",
124+
end="2000-01-02",
125+
)
126+
127+
assert cube.sizes["lat"] == 1
128+
assert cube.sizes["lon"] == 1
129+
assert np.isclose(cube.lat.item(), float(lat.isel(lat=0)))
130+
assert np.isclose(cube.lon.item(), float(lon.isel(lon=1)))

0 commit comments

Comments
 (0)