diff --git a/docs/api.rst b/docs/api.rst index 5e5d10a4..1abd877b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -27,6 +27,7 @@ Below is a list of top-level API functions that are available in ``xcdat``. compare_datasets get_dim_coords get_dim_keys + get_coords_by_name create_bounds create_axis create_gaussian_grid diff --git a/docs/demos/1-25-23-cwss-seminar/introduction-to-xcdat.ipynb b/docs/demos/1-25-23-cwss-seminar/introduction-to-xcdat.ipynb index c2af7b0b..50923916 100644 --- a/docs/demos/1-25-23-cwss-seminar/introduction-to-xcdat.ipynb +++ b/docs/demos/1-25-23-cwss-seminar/introduction-to-xcdat.ipynb @@ -56,7 +56,7 @@ "source": [ "### Notebook Kernel Setup\n", "\n", - "Users can [install their own instance of xcdat](../getting-started-guide/installation.rst) and follow these examples using their own environment (e.g., with VS Code, Jupyter, Spyder, iPython) or [enable xcdat with existing JupyterHub instances](../getting-started-guide/getting-started-hpc-jupyter.rst).\n", + "Users can [install their own instance of xcdat](../../getting-started-guide/installation.rst) and follow these examples using their own environment (e.g., with VS Code, Jupyter, Spyder, iPython) or [enable xcdat with existing JupyterHub instances](../../getting-started-guide/getting-started-hpc-jupyter.rst).\n", "\n", "First, create the conda environment:\n", "\n", diff --git a/tests/fixtures.py b/tests/fixtures.py index d238b3b9..bf28b807 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -4,6 +4,7 @@ import cftime import numpy as np +import pandas as pd import xarray as xr # If the fixture is an xarray object, make sure to use .copy() to create a @@ -600,3 +601,133 @@ def generate_dataset_by_frequency( ds["time"].attrs["bounds"] = "time_bnds" return ds + + +def generate_curvilinear_dataset() -> xr.Dataset: + """Generate a curvilinear Dataset with CF-compliant metadata. + + The dataset includes variables for time, latitude, longitude, and their + respective bounds. It also contains a synthetic data variable (``test_var``) + and additional coordinate information. + + Returns + ------- + xr.Dataset + A curvilinear xarray Dataset with the following structure: + - Dimensions: time, nlat, nlon, vertices, d2, bnds + - Coordinates: + - lat (nlat, nlon): 2D latitude values + - lon (nlat, nlon): 2D longitude values + - nlat (nlat): Cell indices along the second dimension + - nlon (nlon): Cell indices along the first dimension + - time (time): Time values + - Data variables: + - test_var (time, nlat, nlon): Synthetic data array + - time_bnds (time, d2): Time bounds + - lat_bnds (nlat, nlon, vertices): Latitude bounds for each grid cell + - lon_bnds (nlat, nlon, vertices): Longitude bounds for each grid cell + - nlat_bnds (nlat, bnds): Bounds for nlat indices + - nlon_bnds (nlon, bnds): Bounds for nlon indices + + Notes + ----- + - The latitude and longitude bounds are calculated assuming uniform spacing. + - The time bounds are generated with a fixed 30-day offset. + - Metadata attributes are added to ensure CF-compliance. + """ + # Define the dimensions + n_time = 4 + n_lat = 4 + n_lon = 4 + n_vertices = 4 + + # Create a time range with 12 monthly points + time_vals = pd.date_range("2015-01-15", periods=n_time, freq="MS") + + # Create a simple 1D lat/lon array and meshgrid for 2D lat/lon + lat_1d = np.linspace(-90, 90, n_lat) + lon_1d = np.linspace(-180, 180, n_lon) + lat_2d, lon_2d = np.meshgrid(lat_1d, lon_1d, indexing="ij") # shape: (n_lat, n_lon) + + # Create random data array for var. + var_data = np.zeros((n_time, n_lat, n_lon)) + + # Create time bounds (shape: (time, d2)) + time_bnds_vals = np.stack( + [time_vals.values, (time_vals + pd.DateOffset(days=30)).values], axis=1 + ) + + # Add bounds for lat_2d and lon_2d + lat_bnds_data = np.zeros((n_lat, n_lon, n_vertices)) + lon_bnds_data = np.zeros((n_lat, n_lon, n_vertices)) + + # Calculate bounds for each grid cell + for i in range(n_lat): + for j in range(n_lon): + lat_bnds_data[i, j] = [ + lat_2d[i, j] - (90 / n_lat), + lat_2d[i, j] - (90 / n_lat), + lat_2d[i, j] + (90 / n_lat), + lat_2d[i, j] + (90 / n_lat), + ] + lon_bnds_data[i, j] = [ + lon_2d[i, j] - (180 / n_lon), + lon_2d[i, j] + (180 / n_lon), + lon_2d[i, j] + (180 / n_lon), + lon_2d[i, j] - (180 / n_lon), + ] + + # Build the Dataset + ds = xr.Dataset( + data_vars={ + "test_var": (("time", "nlat", "nlon"), var_data), + "time_bnds": (("time", "d2"), time_bnds_vals), + "lat_bnds": (("nlat", "nlon", "vertices"), lat_bnds_data), + "lon_bnds": (("nlat", "nlon", "vertices"), lon_bnds_data), + "nlat_bnds": ( + ("nlat", "bnds"), + np.stack([np.arange(n_lat) - 0.5, np.arange(n_lat) + 0.5], axis=1), + ), + "nlon_bnds": ( + ("nlon", "bnds"), + np.stack([np.arange(n_lon) - 0.5, np.arange(n_lon) + 0.5], axis=1), + ), + }, + coords={ + # 2D lat/lon fields + "lat": (("nlat", "nlon"), lat_2d), + "lon": (("nlat", "nlon"), lon_2d), + # Main coordinates + "nlat": ("nlat", np.arange(n_lat)), + "nlon": ("nlon", np.arange(n_lon)), + "time": ("time", time_vals), + }, + ) + + # Add CF metadata to time, lat, and lon + ds["time"].attrs = { + "standard_name": "time", + "long_name": "time", + "bounds": "time_bnds", + "units": "days since 2015-01-01", + "calendar": "gregorian", + } + + ds["lat"].attrs = { + "standard_name": "latitude", + "long_name": "latitude", + "units": "degrees_north", + "bounds": "lat_bnds", + } + + ds["lon"].attrs = { + "standard_name": "longitude", + "long_name": "longitude", + "units": "degrees_east", + "bounds": "lon_bnds", + } + + ds["nlat"].attrs = {"long_name": "cell index along second dimension", "units": "1"} + ds["nlon"].attrs = {"long_name": "cell index along first dimension", "units": "1"} + + return ds diff --git a/tests/test_axis.py b/tests/test_axis.py index ab3c3a92..252bc890 100644 --- a/tests/test_axis.py +++ b/tests/test_axis.py @@ -6,6 +6,7 @@ from xcdat.axis import ( CFAxisKey, center_times, + get_coords_by_name, get_dim_coords, get_dim_keys, swap_lon_axis, @@ -247,6 +248,70 @@ def test_returns_dataarray_dimension_coordinate_var_using_standard_name_attr(sel assert result.identical(expected) +class TestGetCoordsByName: + def test_raises_error_if_coordinate_not_found(self): + ds = xr.Dataset( + coords={ + "lat": xr.DataArray(data=np.ones(3), dims="lat"), + "lon": xr.DataArray(data=np.ones(3), dims="lon"), + } + ) + + with pytest.raises( + KeyError, match="Coordinate with name 'T' not found in the dataset." + ): + get_coords_by_name(ds, "T") + + def test_returns_coordinate_from_dataset(self): + ds = xr.Dataset( + coords={ + "lat": xr.DataArray(data=np.ones(3), dims="lat"), + "lon": xr.DataArray(data=np.ones(3), dims="lon"), + "time": xr.DataArray(data=np.arange(3), dims="time"), + } + ) + + coord = get_coords_by_name(ds, "T") + assert coord.identical(ds["time"]) + + def test_returns_coordinate_from_dataarray(self): + da = xr.DataArray( + data=np.random.rand(3, 3), + dims=["lat", "lon"], + coords={ + "lat": xr.DataArray(data=np.arange(3), dims="lat"), + "lon": xr.DataArray(data=np.arange(3), dims="lon"), + }, + ) + + coord = get_coords_by_name(da, "X") + assert coord.identical(da["lon"]) + + def test_returns_coordinate_from_curvilinear_dataset(self): + ds = xr.Dataset( + coords={ + "nlat": xr.DataArray(data=np.arange(3), dims="nlat"), + "nlon": xr.DataArray(data=np.arange(3), dims="nlon"), + "lat": xr.DataArray( + data=np.random.rand(3, 3), + dims=["nlat", "nlon"], + attrs={"standard_name": "latitude"}, + ), + "lon": xr.DataArray( + data=np.random.rand(3, 3), + dims=["nlat", "nlon"], + attrs={"standard_name": "longitude"}, + ), + } + ) + + coord = get_coords_by_name(ds, "X") + assert coord.identical(ds["lon"]) + + coord = get_coords_by_name(ds, "Y") + assert coord.identical(ds["lat"]) + + class TestCenterTimes: @pytest.fixture(autouse=True) def setup(self): diff --git a/tests/test_regrid.py b/tests/test_regrid.py index 61c44d6a..1161f960 100644 --- a/tests/test_regrid.py +++ b/tests/test_regrid.py @@ -1251,9 +1251,14 @@ def test_grid(self): assert "lat_bnds" in grid assert "lon_bnds" in grid + # Test that axes with multiple sets of coordinates raise an error. ds_multi = fixtures.generate_multiple_variable_dataset( 1, separate_dims=True, decode_times=True, cf_compliant=True, has_bounds=True ) + # "lat" and "lon" need to be renamed because they can be mapped to + # directly by their name, which means other sets of coordinates + # are ignored and no error is raised. + ds_multi = ds_multi.rename({"lat": "lat2", "lon": "lon2"}) with pytest.raises( ValueError, @@ -1261,12 +1266,54 @@ def test_grid(self): ): ds_multi.regridder.grid # noqa: B018 + def test_grid_curvilinear(self): + ds = fixtures.generate_curvilinear_dataset() + + result = ds.regridder.grid + expected = xr.Dataset( + coords={"lat": ds.lat, "lon": ds.lon, "nlon": ds.nlon, "nlat": ds.nlat}, + data_vars={ + "lat_bnds": ds.lat_bnds, + "lon_bnds": ds.lon_bnds, + }, + ) + + xr.testing.assert_identical(result, expected) + + def test_grid_curvilinear_ignores_singleton_for_Z_axis(self): + ds = fixtures.generate_curvilinear_dataset() + ds = ds.assign_coords( + height=xr.DataArray( + 2.0, + attrs={ + "axis": "Z", + "units": "m", + "positive": "up", + "long_name": "height", + }, + ) + ) + + result = ds.regridder.grid + + # The resulting grid should not have a Z axis. + expected = xr.Dataset( + coords={"lat": ds.lat, "lon": ds.lon, "nlon": ds.nlon, "nlat": ds.nlat}, + data_vars={ + "lat_bnds": ds.lat_bnds, + "lon_bnds": ds.lon_bnds, + }, + ) + + xr.testing.assert_identical(result, expected) + def test_grid_raises_error_when_dataset_has_multiple_dims_for_an_axis(self): ds_bounds = fixtures.generate_dataset( decode_times=True, cf_compliant=True, has_bounds=True ) - ds_bounds.coords["lat2"] = xr.DataArray( - data=[], dims="lat2", attrs={"axis": "Y"} + ds_bounds = ds_bounds.rename({"lat": "lat_test"}) + ds_bounds.coords["lat_test2"] = xr.DataArray( + data=[], dims="lat_test2", attrs={"axis": "Y"} ) with pytest.raises(ValueError): diff --git a/xcdat/__init__.py b/xcdat/__init__.py index 9137c0f0..85b3162d 100644 --- a/xcdat/__init__.py +++ b/xcdat/__init__.py @@ -3,6 +3,7 @@ from xcdat import tutorial # noqa: F401 from xcdat.axis import ( # noqa: F401 center_times, + get_coords_by_name, get_dim_coords, get_dim_keys, swap_lon_axis, diff --git a/xcdat/axis.py b/xcdat/axis.py index 9071dbdf..09c1755b 100644 --- a/xcdat/axis.py +++ b/xcdat/axis.py @@ -3,7 +3,9 @@ coordinates. """ -from typing import Dict, List, Literal, Optional, Tuple, Union +from __future__ import annotations + +from typing import Dict, List, Literal, Optional, Tuple import numpy as np import xarray as xr @@ -22,16 +24,14 @@ # we can fetch specific `cf_xarray` mapping tables such as `ds.cf.axes["X"]` # or `ds.cf.coordinates["longitude"]`. # More information: https://cf-xarray.readthedocs.io/en/latest/coord_axes.html -CF_ATTR_MAP: Dict[CFAxisKey, Dict[str, Union[CFAxisKey, CFStandardNameKey]]] = { +CF_ATTR_MAP: Dict[CFAxisKey, Dict[str, CFAxisKey | CFStandardNameKey]] = { "X": {"axis": "X", "coordinate": "longitude"}, "Y": {"axis": "Y", "coordinate": "latitude"}, "T": {"axis": "T", "coordinate": "time"}, "Z": {"axis": "Z", "coordinate": "vertical"}, } -COORD_DEFAULT_ATTRS: Dict[ - CFAxisKey, Dict[str, Union[str, CFAxisKey, CFStandardNameKey]] -] = { +COORD_DEFAULT_ATTRS: Dict[CFAxisKey, Dict[str, str | CFAxisKey | CFStandardNameKey]] = { "X": dict(units="degrees_east", **CF_ATTR_MAP["X"]), "Y": dict(units="degrees_north", **CF_ATTR_MAP["Y"]), "T": dict(calendar="standard", **CF_ATTR_MAP["T"]), @@ -49,9 +49,7 @@ } -def get_dim_keys( - obj: Union[xr.Dataset, xr.DataArray], axis: CFAxisKey -) -> Union[str, List[str]]: +def get_dim_keys(obj: xr.Dataset | xr.DataArray, axis: CFAxisKey) -> str | List[str]: """Gets the dimension key(s) for an axis. Each dimension should have a corresponding dimension coordinate variable, @@ -61,14 +59,14 @@ def get_dim_keys( Parameters ---------- - obj : Union[xr.Dataset, xr.DataArray] + obj : xr.Dataset | xr.DataArray The Dataset or DataArray object. axis : CFAxisKey The CF axis key ("X", "Y", "T", or "Z") Returns ------- - Union[str, List[str]] + str | List[str] The dimension string or a list of dimensions strings for an axis. """ dims = sorted([str(dim) for dim in get_dim_coords(obj, axis).dims]) @@ -77,8 +75,8 @@ def get_dim_keys( def get_dim_coords( - obj: Union[xr.Dataset, xr.DataArray], axis: CFAxisKey -) -> Union[xr.Dataset, xr.DataArray]: + obj: xr.Dataset | xr.DataArray, axis: CFAxisKey +) -> xr.Dataset | xr.DataArray: """Gets the dimension coordinates for an axis. This function uses ``cf_xarray`` to attempt to map the axis to its @@ -94,14 +92,14 @@ def get_dim_coords( Parameters ---------- - obj : Union[xr.Dataset, xr.DataArray] + obj : xr.Dataset | xr.DataArray The Dataset or DataArray object. axis : CFAxisKey The CF axis key ("X", "Y", "T", "Z"). Returns ------- - Union[xr.Dataset, xr.DataArray] + xr.Dataset | xr.DataArray A Dataset of dimension coordinate variables or a DataArray for the single dimension coordinate variable. @@ -156,6 +154,52 @@ def get_dim_coords( return dim_coords +def get_coords_by_name(obj: xr.Dataset | xr.DataArray, axis: CFAxisKey) -> xr.DataArray: + """Retrieve the coordinate variable based on its name. + + This method is useful for returning the desired coordinate in the following + cases: + + - Coordinates that are not CF-compliant (e.g., missing CF attributes like + "axis", "standard_name", or "long_name") but use common names. + - Axes with multiple sets of coordinates. For example, curvilinear grids may + have multiple coordinates for the same axis (e.g., (nlat, lat) for X and + (nlon, lon) for Y). In most cases, "lat" and "lon" are the desired + coordinates, which this function will return. + + Common variable names for each axis (from ``VAR_NAME_MAP``): + + - "X" axis: ["longitude", "lon"] + - "Y" axis: ["latitude", "lat"] + - "T" axis: ["time"] + - "Z" axis: ["vertical", "height", "pressure", "lev", "plev"] + + Parameters + ---------- + axis : CFAxisKey + The CF axis key ("X", "Y", "T", or "Z"). + + Returns + ------- + xr.DataArray + The coordinate variable. + + Raises + ------ + KeyError + If the coordinate variable is not found in the dataset. + """ + coord_names = VAR_NAME_MAP[axis] + + for coord_name in coord_names: + if coord_name in obj.coords: + coord = obj.coords[coord_name] + + return coord + + raise KeyError(f"Coordinate with name '{axis}' not found in the dataset.") + + def center_times(dataset: xr.Dataset) -> xr.Dataset: """Centers time coordinates using the midpoint between time bounds. @@ -275,9 +319,7 @@ def swap_lon_axis( return ds -def _get_all_coord_keys( - obj: Union[xr.Dataset, xr.DataArray], axis: CFAxisKey -) -> List[str]: +def _get_all_coord_keys(obj: xr.Dataset | xr.DataArray, axis: CFAxisKey) -> List[str]: """Gets all dimension and non-dimension coordinate keys for an axis. This function uses ``cf_xarray`` to interpret CF axis and coordinate name @@ -289,7 +331,7 @@ def _get_all_coord_keys( Parameters ---------- - obj : Union[xr.Dataset, xr.DataArray] + obj : xr.Dataset | xr.DataArray The Dataset or DataArray object. axis : CFAxisKey The CF axis key ("X", "Y", "T", or "Z"). diff --git a/xcdat/regridder/accessor.py b/xcdat/regridder/accessor.py index 881da70e..e8ac12f5 100644 --- a/xcdat/regridder/accessor.py +++ b/xcdat/regridder/accessor.py @@ -1,10 +1,10 @@ from __future__ import annotations -from typing import Any, List, Literal, Tuple +from typing import Any, Dict, List, Literal, Tuple import xarray as xr -from xcdat.axis import CFAxisKey, get_dim_coords +from xcdat.axis import CFAxisKey, get_coords_by_name, get_dim_coords from xcdat.regridder import regrid2, xesmf, xgcm from xcdat.regridder.grid import _validate_grid_has_single_axis_dim @@ -81,38 +81,67 @@ def grid(self) -> xr.Dataset: >>> grid = ds.regridder.grid """ - with xr.set_options(keep_attrs=True): - coords = {} - axis_names: List[CFAxisKey] = ["X", "Y", "Z"] - - for axis in axis_names: - try: - data, bnds = self._get_axis_data(axis) - except KeyError: - continue + axis_names: List[CFAxisKey] = ["X", "Y", "Z"] - coords[data.name] = data.copy() + axis_coords: Dict[str, xr.DataArray] = {} + axis_bounds: Dict[str, xr.DataArray] = {} + axis_has_bounds: Dict[CFAxisKey, bool] = {} - if bnds is not None: - coords[bnds.name] = bnds.copy() - - ds = xr.Dataset(coords, attrs=self._ds.attrs) + with xr.set_options(keep_attrs=True): + for axis in axis_names: + coord, bounds = self._get_axis_coord_and_bounds(axis) + + if coord is not None: + axis_coords[str(coord.name)] = coord + + if bounds is not None: + axis_bounds[str(bounds.name)] = bounds + axis_has_bounds[axis] = True + else: + axis_has_bounds[axis] = False + + # Create a new dataset with coordinates and bounds + ds = xr.Dataset( + coords=axis_coords, + data_vars=axis_bounds, + attrs=self._ds.attrs, + ) - ds = ds.bounds.add_missing_bounds(axes=["X", "Y", "Z"]) + # Add bounds only for axes that do not already have them. This + # prevents multiple sets of bounds being added for the same axis. + # For example, curvilinear grids can have multiple coordinates for the + # same axis (e.g., (nlat, lat) for X and (nlon, lon) for Y). We only + # need lat_bnds and lon_bnds for the X and Y axes, respectively, and not + # nlat_bnds and nlon_bnds. + for axis, has_bounds in axis_has_bounds.items(): + if not has_bounds: + ds = ds.bounds.add_bounds(axis=axis) return ds - def _get_axis_data( - self, name: CFAxisKey - ) -> Tuple[xr.DataArray | xr.Dataset, xr.DataArray]: - coord_var = get_dim_coords(self._ds, name) - - _validate_grid_has_single_axis_dim(name, coord_var) - + def _get_axis_coord_and_bounds( + self, axis: CFAxisKey + ) -> Tuple[xr.DataArray | None, xr.DataArray | None]: try: - bounds_var = self._ds.bounds.get_bounds(name, coord_var.name) - except KeyError: - bounds_var = None + coord_var = get_coords_by_name(self._ds, axis) + if coord_var.size == 1: + raise ValueError( + f"Coordinate '{coord_var}' is a singleton and cannot be used." + ) + except (ValueError, KeyError): + try: + coord_var = get_dim_coords(self._ds, axis) # type: ignore + _validate_grid_has_single_axis_dim(axis, coord_var) + except KeyError: + coord_var = None + + if coord_var is None: + return None, None + + bounds_var = None + bounds_key = coord_var.attrs.get("bounds") + if bounds_key: + bounds_var = self._ds.get(bounds_key) return coord_var, bounds_var diff --git a/xcdat/tutorial.py b/xcdat/tutorial.py index 957f7abd..1d2e1380 100644 --- a/xcdat/tutorial.py +++ b/xcdat/tutorial.py @@ -50,39 +50,37 @@ def open_dataset( add_bounds: List[CFAxisKey] | Tuple[CFAxisKey, ...] | None = ("X", "Y"), **kargs, ) -> xr.Dataset: - """ - Open a dataset from the online repository (requires internet). + """Open a dataset from the online repository (requires internet). This function is mostly based on ``xarray.tutorial.open_dataset()`` with some modifications, including adding missing bounds to the dataset. If a local copy is found then always use that to avoid network traffic. - Available xCDAT datasets: - - * ``"pr_amon_access"``: Monthly precipitation data from the ACCESS-ESM1-5 model. - * ``"so_omon_cesm2"``: Monthly ocean salinity data from the CESM2 model. - * ``"tas_amon_access"``: Monthly near-surface air temperature from the ACCESS-ESM1-5 model. - * ``"tas_3hr_access"``: 3-hourly near-surface air temperature from the ACCESS-ESM1-5 model. - * ``"tas_amon_canesm5"``: Monthly near-surface air temperature from the CanESM5 model. - * ``"thetao_omon_cesm2"``: Monthly ocean potential temperature from the CESM2 model. - * ``"cl_amon_e3sm2"``: Monthly cloud fraction data from the E3SM-2-0 model. - * ``"ta_amon_e3sm2"``: Monthly air temperature data from the E3SM-2-0 model. - - Parameters - ---------- - name : str - Name of the file containing the dataset. - e.g. 'tas_amon_access' - cache_dir : path-like, optional - The directory in which to search for and write cached data. - cache : bool, optional - If True, then cache data locally for use on subsequent calls - add_bounds : List[CFAxisKey] | Tuple[CFAxisKey] | None, optional - List or tuple of axis keys for which to add bounds, by default - ("X", "Y"). - **kargs : dict, optional - Passed to ``xcdat.open_dataset``. + Available xCDAT datasets: + + - ``"pr_amon_access"``: Monthly precipitation data from the ACCESS-ESM1-5 model. + - ``"so_omon_cesm2"``: Monthly ocean salinity data from the CESM2 model. + - ``"tas_amon_access"``: Monthly near-surface air temperature from the ACCESS-ESM1-5 model. + - ``"tas_3hr_access"``: 3-hourly near-surface air temperature from the ACCESS-ESM1-5 model. + - ``"tas_amon_canesm5"``: Monthly near-surface air temperature from the CanESM5 model. + - ``"thetao_omon_cesm2"``: Monthly ocean potential temperature from the CESM2 model. + - ``"cl_amon_e3sm2"``: Monthly cloud fraction data from the E3SM-2-0 model. + - ``"ta_amon_e3sm2"``: Monthly air temperature data from the E3SM-2-0 model. + + Parameters + ---------- + name : str + Name of the file containing the dataset (e.g., "tas_amon_access"). + cache_dir : path-like, optional + The directory in which to search for and write cached data. + cache : bool, optional + If True, then cache data locally for use on subsequent calls + add_bounds : List[CFAxisKey] | Tuple[CFAxisKey] | None, optional + List or tuple of axis keys for which to add bounds, by default + ("X", "Y"). + **kargs : dict, optional + Passed to ``xcdat.open_dataset``. """ try: import pooch