Skip to content

Commit 6d5312a

Browse files
authored
Merge pull request #30 from NOC-MSM/24-refactor-update-open_dataset-temporal-and-geographic-subsetting
Update open_dataset() temporal and geographic subsetting.
2 parents 9524952 + 6935701 commit 6d5312a

1 file changed

Lines changed: 129 additions & 11 deletions

File tree

OceanDataStore/catalog/oceandatacatalog.py

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
Authors:
1010
- Ollie Tooth
1111
"""
12+
import os
1213
from typing import Optional
1314

14-
import os
15-
import pystac
1615
import icechunk
16+
import numpy as np
17+
import pystac
1718
import xarray as xr
1819

1920
# -- NOC brand CSS -- #
@@ -170,6 +171,126 @@
170171
</style>
171172
"""
172173

174+
# -- Utility Functions -- #
175+
def apply_bbox(ds: xr.Dataset,
176+
bbox: tuple
177+
) -> xr.Dataset:
178+
"""
179+
Apply a geographical bounding box to subset an xarray Dataset.
180+
181+
Parameters
182+
----------
183+
ds : xr.Dataset
184+
Input xarray Dataset.
185+
bbox : tuple
186+
Geographical bounding box in the format (min_lon, max_lon, min_lat, max_lat).
187+
188+
Returns
189+
-------
190+
xr.Dataset
191+
Geographically subsetted xarray Dataset.
192+
"""
193+
# -- Validate Inputs -- #
194+
if not isinstance(ds, xr.Dataset):
195+
raise ValueError("'ds' must be an xarray Dataset.")
196+
if not (isinstance(bbox, tuple) and len(bbox) == 4):
197+
raise ValueError("'bbox' must be a tuple of the form (min_lon, max_lon, min_lat, max_lat).")
198+
199+
# -- Identify geographical coordinate names & dimensions -- #
200+
# Default lat/lon coord names:
201+
lon_name, lat_name = "nav_lon", "nav_lat"
202+
# Update lat/lon coord names via standard_name attributes:
203+
for coord in ds.coords:
204+
if ds[coord].attrs.get('standard_name', '').lower() == 'longitude':
205+
lon_name = coord
206+
if ds[coord].attrs.get('standard_name', '').lower() == 'latitude':
207+
lat_name = coord
208+
209+
# -- Apply Bounding Box -- #
210+
if (ds[lon_name].ndim > 1) and (ds[lat_name].ndim > 1):
211+
# -- Case 1: 2D lat/lon coordinates -- #
212+
# Identify lat/lon coordinate dimensions:
213+
if ds[lon_name].dims != ds[lat_name].dims:
214+
raise ValueError("Longitude and latitude coordinates must have the same dimensions.")
215+
else:
216+
y_name, x_name = ds[lon_name].dims
217+
218+
# Define bbox mask:
219+
mask = (
220+
(ds[lon_name] >= bbox[0])
221+
& (ds[lon_name] <= bbox[2])
222+
& (ds[lat_name] >= bbox[1])
223+
& (ds[lat_name] <= bbox[3])
224+
)
225+
226+
# Find rows/columns containing at least one valid grid point:
227+
rows = mask.any(dim=x_name)
228+
cols = mask.any(dim=y_name)
229+
y_idx = np.where(rows.compute())[0]
230+
x_idx = np.where(cols.compute())[0]
231+
232+
if len(y_idx) == 0 or len(x_idx) == 0:
233+
raise ValueError("No grid points found inside bbox")
234+
235+
# Subset dataset to bounding box:
236+
ds_subset = (ds
237+
.where(mask, drop=False)
238+
.isel({y_name: slice(y_idx.min(), y_idx.max() + 1),
239+
x_name: slice(x_idx.min(), x_idx.max() + 1),
240+
})
241+
)
242+
else:
243+
# -- Case 2: 1D lat/lon coordinates -- #
244+
ds_subset = ds.sel({lon_name: slice(bbox[0], bbox[1]),
245+
lat_name: slice(bbox[2], bbox[3])
246+
})
247+
248+
return ds_subset
249+
250+
251+
def apply_time_bounds(ds: xr.Dataset,
252+
start_datetime: str | None = None,
253+
end_datetime: str | None = None
254+
) -> xr.Dataset:
255+
"""
256+
Apply temporal subsetting to an xarray Dataset.
257+
258+
Parameters
259+
----------
260+
ds : xr.Dataset
261+
Input xarray Dataset.
262+
start_datetime : str, optional
263+
Start datetime in ISO format (e.g., 'YYYY-MM-DDTHH:MM:SS').
264+
end_datetime : str, optional
265+
End datetime in ISO format (e.g., 'YYYY-MM-DDTHH:MM:SS').
266+
267+
Returns
268+
-------
269+
xr.Dataset
270+
Temporally subsetted xarray Dataset.
271+
"""
272+
# -- Validate Inputs -- #
273+
if not isinstance(ds, xr.Dataset):
274+
raise ValueError("'ds' must be an xarray Dataset.")
275+
if start_datetime is not None:
276+
if not isinstance(start_datetime, str):
277+
raise ValueError("'start_datetime' must be a string in ISO format (e.g., 'YYYY-MM-DDTHH:MM:SS').")
278+
if end_datetime is not None:
279+
if not isinstance(end_datetime, str):
280+
raise ValueError("'end_datetime' must be a string in ISO format (e.g., 'YYYY-MM-DDTHH:MM:SS').")
281+
282+
# -- Identify time dimension -- #
283+
for coord in ds.dims:
284+
if 'time' in coord.lower():
285+
time_name = coord
286+
break
287+
288+
# -- Apply temporal subsetting -- #
289+
ds_subset = ds.sel({time_name: slice(start_datetime, end_datetime)})
290+
291+
return ds_subset
292+
293+
173294
# -- Define CatalogSummary() class -- #
174295
class CatalogSummary:
175296
"""
@@ -1007,7 +1128,7 @@ def open_dataset(self,
10071128
variable_names: Optional[list[str]] = None,
10081129
start_datetime: Optional[str] = None,
10091130
end_datetime: Optional[str] = None,
1010-
bbox: Optional[tuple[float, float, float, float]] = None,
1131+
bbox: Optional[tuple[float | int, float | int, float | int, float | int]] = None,
10111132
branch: str = "main",
10121133
consolidated: bool = True,
10131134
asset_key: Optional[str] = None
@@ -1033,7 +1154,7 @@ def open_dataset(self,
10331154
End datetime used to subset the dataset. Should be a string
10341155
in ISO format (e.g., "2024-12-31T00:00:00Z"). Default is to use
10351156
the Item end_datetime.
1036-
bbox : tuple[float, float, float, float], optional
1157+
bbox : tuple[float | int, float | int, float | int, float | int], optional
10371158
Spatial bounding box used to subset the dataset. Should be a list of four floats
10381159
representing the bounding box in the format: (min_lon, min_lat, max_lon, max_lat).
10391160
Default is to use the Item bbox.
@@ -1073,8 +1194,8 @@ def open_dataset(self,
10731194
raise TypeError("'end_datetime' must be a string or None.")
10741195
if not isinstance(bbox, (type(None), tuple)):
10751196
raise TypeError("'bbox' must be a tuple or None.")
1076-
if bbox is not None and (len(bbox) != 4 or not all(isinstance(coord, float) for coord in bbox)):
1077-
raise TypeError("'bbox' must be a tuple of floats in the form (lon_min, lon_max, lat_min, lat_max).")
1197+
if bbox is not None and (len(bbox) != 4 or not all(isinstance(coord, (float, int)) for coord in bbox)):
1198+
raise TypeError("'bbox' must be a tuple of the form (min_lon, min_lat, max_lon, max_lat) with float or int values.")
10781199
if not isinstance(branch, str):
10791200
raise TypeError("'branch' must be a string.")
10801201
if not isinstance(consolidated, bool):
@@ -1123,12 +1244,9 @@ def open_dataset(self,
11231244

11241245
# Spatio-temporal subsetting:
11251246
if bbox:
1126-
lon = ds.nav_lon.load()
1127-
lat = ds.nav_lat.load()
1128-
ds = ds.where((lon >= bbox[0]) & (lon <= bbox[2]) &
1129-
(lat >= bbox[1]) & (lat <= bbox[3]), drop=True)
1247+
ds = apply_bbox(ds=ds, bbox=bbox)
11301248

11311249
if start_datetime or end_datetime:
1132-
ds = ds.sel(time_counter=slice(start_datetime, end_datetime))
1250+
ds = apply_time_bounds(ds=ds, start_datetime=start_datetime, end_datetime=end_datetime)
11331251

11341252
return ds

0 commit comments

Comments
 (0)