Skip to content

Commit cb261b9

Browse files
authored
Add .cf.add_bounds to guess bounds for 1D coord. (#53)
* Add .cf.add_bounds to guess bounds for 1D coord. Adds a bounds variable assuming equal spacing on either side of a coordinate label. The variable is named f"{dim}_bounds" and raises an error if there is an existing variable with the same name. Also adds appropriate "bounds" attribute to the coordinate variable. Fixes #35
1 parent 7e22adc commit cb261b9

File tree

3 files changed

+129
-1
lines changed

3 files changed

+129
-1
lines changed

cf_xarray/accessor.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import (
99
Callable,
1010
Hashable,
11+
Iterable,
1112
List,
1213
Mapping,
1314
MutableMapping,
@@ -321,6 +322,25 @@ def _get_list_standard_names(obj: xr.Dataset) -> List[str]:
321322
)
322323

323324

325+
def _guess_bounds_dim(da):
326+
"""
327+
Guess bounds values given a 1D coordinate variable.
328+
Assumes equal spacing on either side of the coordinate label.
329+
"""
330+
assert da.ndim == 1
331+
332+
dim = da.dims[0]
333+
diff = da.diff(dim)
334+
lower = da - diff / 2
335+
upper = da + diff / 2
336+
bounds = xr.concat([lower, upper], dim="bounds")
337+
338+
first = (bounds.isel({dim: 0}) - diff[0]).assign_coords({dim: da[dim][0]})
339+
result = xr.concat([first, bounds], dim=dim)
340+
341+
return result
342+
343+
324344
def _getattr(
325345
obj: Union[DataArray, Dataset],
326346
attr: str,
@@ -805,6 +825,63 @@ def __getitem__(self, key: Union[str, List[str]]):
805825
f"Use {kind}.cf.describe() to see a list of key names that can be interpreted."
806826
)
807827

828+
def _maybe_to_dataset(self, obj=None) -> xr.Dataset:
829+
if obj is None:
830+
obj = self._obj
831+
if isinstance(self._obj, xr.DataArray):
832+
return obj._to_temp_dataset()
833+
else:
834+
return obj
835+
836+
def _maybe_to_dataarray(self, obj=None):
837+
if obj is None:
838+
obj = self._obj
839+
if isinstance(self._obj, xr.DataArray):
840+
return self._obj._from_temp_dataset(obj)
841+
else:
842+
return obj
843+
844+
def add_bounds(self, dims: Union[Hashable, Iterable[Hashable]]):
845+
"""
846+
Returns a new object with bounds variables. The bounds values are guessed assuming
847+
equal spacing on either side of a coordinate label.
848+
849+
Parameters
850+
----------
851+
dims: Hashable or Iterable[Hashable]
852+
Either a single dimension name or a list of dimension names.
853+
854+
Returns
855+
-------
856+
DataArray or Dataset with bounds variables added and appropriate "bounds" attribute set.
857+
858+
Notes
859+
-----
860+
861+
The bounds variables are automatically named f"{dim}_bounds" where ``dim``
862+
is a dimension name.
863+
"""
864+
if isinstance(dims, Hashable):
865+
dimensions = (dims,)
866+
else:
867+
dimensions = dims
868+
869+
bad_dims: Set[Hashable] = set(dimensions) - set(self._obj.dims)
870+
if bad_dims:
871+
raise ValueError(
872+
f"{bad_dims!r} are not dimensions in the underlying object."
873+
)
874+
875+
obj = self._maybe_to_dataset(self._obj.copy(deep=True))
876+
for dim in dimensions:
877+
bname = f"{dim}_bounds"
878+
if bname in obj.variables:
879+
raise ValueError(f"Bounds variable name {bname!r} will conflict!")
880+
obj.coords[bname] = _guess_bounds_dim(obj[dim].reset_coords(drop=True))
881+
obj[dim].attrs["bounds"] = bname
882+
883+
return self._maybe_to_dataarray(obj)
884+
808885

809886
@xr.register_dataset_accessor("cf")
810887
class CFDatasetAccessor(CFAccessor):

cf_xarray/tests/test_accessor.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import matplotlib as mpl
2+
import numpy as np
3+
import pandas as pd
24
import pytest
35
import xarray as xr
46
from matplotlib import pyplot as plt
5-
from xarray.testing import assert_identical
7+
from xarray.testing import assert_allclose, assert_identical
68

79
import cf_xarray # noqa
810

@@ -287,3 +289,47 @@ def test_plot_xincrease_yincrease():
287289

288290
for lim in [ax.get_xlim(), ax.get_ylim()]:
289291
assert lim[0] > lim[1]
292+
293+
294+
@pytest.mark.parametrize("dims", ["lat", "time", ["lat", "lon"]])
295+
@pytest.mark.parametrize("obj", [airds, airds.air])
296+
def test_add_bounds(obj, dims):
297+
expected = dict()
298+
expected["lat"] = xr.concat(
299+
[
300+
obj.lat.copy(data=np.arange(76.25, 16.0, -2.5)),
301+
obj.lat.copy(data=np.arange(73.75, 13.6, -2.5)),
302+
],
303+
dim="bounds",
304+
)
305+
expected["lon"] = xr.concat(
306+
[
307+
obj.lon.copy(data=np.arange(198.75, 325 - 1.25, 2.5)),
308+
obj.lon.copy(data=np.arange(201.25, 325 + 1.25, 2.5)),
309+
],
310+
dim="bounds",
311+
)
312+
t0 = pd.Timestamp("2013-01-01")
313+
t1 = pd.Timestamp("2013-01-01 18:00")
314+
dt = "6h"
315+
dtb2 = pd.Timedelta("3h")
316+
expected["time"] = xr.concat(
317+
[
318+
obj.time.copy(data=pd.date_range(start=t0 - dtb2, end=t1 - dtb2, freq=dt)),
319+
obj.time.copy(data=pd.date_range(start=t0 + dtb2, end=t1 + dtb2, freq=dt)),
320+
],
321+
dim="bounds",
322+
)
323+
expected["lat"].attrs.clear()
324+
expected["lon"].attrs.clear()
325+
expected["time"].attrs.clear()
326+
327+
added = obj.cf.add_bounds(dims)
328+
if isinstance(dims, str):
329+
dims = (dims,)
330+
331+
for dim in dims:
332+
name = f"{dim}_bounds"
333+
assert name in added.coords
334+
assert added[dim].attrs["bounds"] == name
335+
assert_allclose(added[name].reset_coords(drop=True), expected[dim])

doc/whats-new.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
What's New
22
----------
33

4+
v0.1.6
5+
======
6+
7+
- Added ```.cf.add_bounds`` to add guessed bounds for 1D coorindates. (:pr:`53`) `Deepak Cherian`_.
8+
49
v0.1.5
510
======
611
- Wrap ``.sizes`` and ``.chunks``. (:pr:`42`) `Deepak Cherian`_.

0 commit comments

Comments
 (0)