|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import io |
4 | | -from typing import Any, Dict, Optional |
| 4 | +from typing import Any, Dict, Optional, Sequence |
5 | 5 |
|
| 6 | +import numpy as np |
6 | 7 | import requests |
7 | 8 | import xarray as xr |
8 | 9 | from xarray.backends.plugins import list_engines |
|
12 | 13 | _AVAILABLE_ENGINES = list_engines() |
13 | 14 |
|
14 | 15 |
|
| 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 | + |
15 | 63 | def _select_stream_engine() -> Optional[str]: |
16 | 64 | """Pick the best available xarray engine for streaming gridMET files.""" |
17 | 65 |
|
@@ -162,10 +210,26 @@ def stream_gridmet_to_cube( |
162 | 210 |
|
163 | 211 | # 3) Spatial subset using the AOI bbox |
164 | 212 | 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 | + ) |
169 | 233 |
|
170 | 234 | # 4) Optional resampling in time (e.g., to monthly) |
171 | 235 | if freq != "D": |
|
0 commit comments