Repository: xCDAT/xcdat

Issue:
[Bug]: ValueError: Cannot generate bounds for a coordinate of length <= 1. Using decode_times optional arg
### What happened?

Attempting to open a dataset with `time:units = "months since 1955-01-01 00:00:00" ;` has problems, adding `decode_times=False` gets around the pandas issue (#282), but this leads to a `xcdat/bounds.py` issue instead: `ValueError: Cannot generate bounds for a coordinate of length <= 1.`

### What did you expect to happen?

xcdat could handle the poorly formed file and open the dataset.

### Minimal Complete Verifiable Example

```python
In [2]: from xcdat import open_dataset

In [3]: f = "~/Shared/obs_data/WOD18/190312/woa18_decav_t00_04.nc"

In [4]: wH = open_dataset(f)

Or 

In [5]: wH = open_dataset(f, decode_times=False)
```


### Relevant log output

```python
Traceback (most recent call last):

  File "/tmp/ipykernel_177169/1773024523.py", line 1, in <cell line: 1>
    wH = open_dataset(f)

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/dataset.py", line 89, in open_dataset
    ds = decode_non_cf_time(ds)

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/dataset.py", line 320, in decode_non_cf_time
    data = [ref_date + pd.DateOffset(**{units: offset}) for offset in time.data]

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/dataset.py", line 320, in <listcomp>
    data = [ref_date + pd.DateOffset(**{units: offset}) for offset in time.data]

  File "pandas/_libs/tslibs/offsets.pyx", line 444, in pandas._libs.tslibs.offsets.BaseOffset.__add__

  File "pandas/_libs/tslibs/offsets.pyx", line 450, in pandas._libs.tslibs.offsets.BaseOffset.__add__

  File "pandas/_libs/tslibs/offsets.pyx", line 180, in pandas._libs.tslibs.offsets.apply_wraps.wrapper

  File "pandas/_libs/tslibs/offsets.pyx", line 1092, in pandas._libs.tslibs.offsets.RelativeDeltaOffset._apply

  File "pandas/_libs/tslibs/timestamps.pyx", line 1399, in pandas._libs.tslibs.timestamps.Timestamp.__new__

  File "pandas/_libs/tslibs/conversion.pyx", line 436, in pandas._libs.tslibs.conversion.convert_to_tsobject

  File "pandas/_libs/tslibs/conversion.pyx", line 517, in pandas._libs.tslibs.conversion.convert_datetime_to_tsobject

  File "pandas/_libs/tslibs/np_datetime.pyx", line 120, in pandas._libs.tslibs.np_datetime.check_dts_bounds

OutOfBoundsDatetime: Out of bounds nanosecond timestamp: 2315-07-01 00:00:00


In [5]: wH = open_dataset(f, decode_times=False)
Traceback (most recent call last):

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/bounds.py", line 178, in get_bounds
    bounds_key = coord_var.attrs["bounds"]

KeyError: 'bounds'


During handling of the above exception, another exception occurred:

Traceback (most recent call last):

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/bounds.py", line 145, in add_missing_bounds
    self.get_bounds(axis)

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/bounds.py", line 180, in get_bounds
    raise KeyError(

KeyError: "The coordinate variable 'time' has no 'bounds' attr. Set the 'bounds' attr to the name of the bounds data variable."


During handling of the above exception, another exception occurred:

Traceback (most recent call last):

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/bounds.py", line 178, in get_bounds
    bounds_key = coord_var.attrs["bounds"]

KeyError: 'bounds'


During handling of the above exception, another exception occurred:

Traceback (most recent call last):

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/bounds.py", line 220, in add_bounds
    self.get_bounds(axis)

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/bounds.py", line 180, in get_bounds
    raise KeyError(

KeyError: "The coordinate variable 'time' has no 'bounds' attr. Set the 'bounds' attr to the name of the bounds data variable."


During handling of the above exception, another exception occurred:

Traceback (most recent call last):

  File "/tmp/ipykernel_177169/170555424.py", line 1, in <cell line: 1>
    wH = open_dataset(f, decode_times=False)

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/dataset.py", line 95, in open_dataset
    ds = _postprocess_dataset(ds, data_var, center_times, add_bounds, lon_orient)

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/dataset.py", line 482, in _postprocess_dataset
    dataset = dataset.bounds.add_missing_bounds()

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/bounds.py", line 147, in add_missing_bounds
    self._dataset = self.add_bounds(axis, width)

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/bounds.py", line 225, in add_bounds
    dataset = self._add_bounds(axis, width)

  File "~/mambaforge/envs/xcdat030spy532/lib/python3.10/site-packages/xcdat/bounds.py", line 271, in _add_bounds
    raise ValueError("Cannot generate bounds for a coordinate of length <= 1.")

ValueError: Cannot generate bounds for a coordinate of length <= 1.
```


### Anything else we need to know?

Adding the additional argument
```python
wH = open_dataset(f, decode_times=False, add_bounds=False)
```
opens the file fine

### Environment

0.3.0 - conda-forge build

In [12]: xr.show_versions()

INSTALLED VERSIONS
------------------
commit: None
python: 3.10.5 | packaged by conda-forge | (main, Jun 14 2022, 07:04:59) [GCC 10.3.0]
python-bits: 64
OS: Linux
OS-release: 3.10.0-1160.62.1.el7.x86_64
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: en_US.UTF-8
LANG: en_US.UTF-8
LOCALE: ('en_US', 'UTF-8')
libhdf5: 1.12.2
libnetcdf: 4.8.1

xarray: 2022.6.0
pandas: 1.4.3
numpy: 1.22.4
scipy: 1.9.0
netCDF4: 1.6.0
pydap: None
h5netcdf: None
h5py: None
Nio: None
zarr: None
cftime: 1.6.1
nc_time_axis: None
PseudoNetCDF: None
rasterio: None
cfgrib: None
iris: None
bottleneck: None
dask: 2022.8.0
distributed: 2022.8.0
matplotlib: None
cartopy: None
seaborn: None
numbagg: None
fsspec: 2022.7.1
cupy: None
pint: None
sparse: 0.13.0
flox: None
numpy_groupies: None
setuptools: 63.4.2
pip: 22.2.2
conda: None
pytest: None
IPython: 7.33.0
sphinx: 5.1.1
~/mambaforge/envs/xcdat031spy532/lib/python3.10/site-packages/_distutils_hack/__init__.py:33: UserWarning: Setuptools is replacing distutils.
  warnings.warn("Setuptools is replacing distutils.")

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/xCDAT/xcdat/pull/313?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT) Report
> Merging [#313](https://codecov.io/gh/xCDAT/xcdat/pull/313?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT) (d509949) into [main](https://codecov.io/gh/xCDAT/xcdat/commit/4c51879a65992e08b29eabd2d31f7332aca78e61?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT) (4c51879) will **decrease** coverage by `0.17%`.
> The diff coverage is `0.00%`.

```diff
@@             Coverage Diff             @@
##              main     #313      +/-   ##
===========================================
- Coverage   100.00%   99.82%   -0.18%     
===========================================
  Files           14       14              
  Lines         1170     1171       +1     
===========================================
- Hits          1170     1169       -1     
- Misses           0        2       +2     
```


| [Impacted Files](https://codecov.io/gh/xCDAT/xcdat/pull/313?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT) | Coverage Δ | |
|---|---|---|
| [xcdat/bounds.py](https://codecov.io/gh/xCDAT/xcdat/pull/313/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT#diff-eGNkYXQvYm91bmRzLnB5) | `98.01% <0.00%> (-1.99%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT)


Patch:
diff --git a/xcdat/bounds.py b/xcdat/bounds.py
index b9a5053..06f5508 100644
--- a/xcdat/bounds.py
+++ b/xcdat/bounds.py
@@ -117,8 +117,16 @@ class BoundsAccessor:
     def add_missing_bounds(self, width: float = 0.5) -> xr.Dataset:
         """Adds missing coordinate bounds for supported axes in the Dataset.
 
-        This function loops through the Dataset's axes and adds coordinate
-        bounds for an axis that doesn't have any.
+        This function loops through the Dataset's axes and attempts to adds
+        bounds to its coordinates if they don't exist. The coordinates must meet
+        the following criteria in order to add bounds:
+
+          1. The axis for the coordinates are "X", "Y", "T", or "Z"
+          2. Coordinates are a single dimension, not multidimensional
+          3. Coordinates are a length > 1 (not singleton)
+          4. Bounds must not already exist.
+             * Determined by attempting to map the coordinate variable's
+             "bounds" attr (if set) to the bounds data variable of the same key.
 
         Parameters
         ----------
@@ -133,24 +141,31 @@ class BoundsAccessor:
         axes = CF_NAME_MAP.keys()
 
         for axis in axes:
-            coord_var = None
+            # Check if the axis coordinates can be mapped to.
+            try:
+                get_axis_coord(self._dataset, axis)
+            except KeyError:
+                continue
 
+            # Determine if the axis is also a dimension by determining if there
+            # is overlap between the CF axis names and the dimension names. If
+            # not, skip over axis for validation.
+            if len(set(CF_NAME_MAP[axis]) & set(self._dataset.dims.keys())) == 0:
+                continue
+
+            # Check if bounds also exist using the "bounds" attribute.
+            # Otherwise, try to add bounds if it meets the function's criteria.
             try:
-                coord_var = get_axis_coord(self._dataset, axis)
+                self.get_bounds(axis)
+                continue
             except KeyError:
                 pass
 
-            # determine if the axis is also a dimension by determining
-            # if there is overlap between the CF axis names and the dimension
-            # names. If not, skip over axis for validation.
-            if len(set(CF_NAME_MAP[axis]) & set(self._dataset.dims.keys())) == 0:
+            try:
+                self._dataset = self.add_bounds(axis, width)
+            except ValueError:
                 continue
 
-            if coord_var is not None:
-                try:
-                    self.get_bounds(axis)
-                except KeyError:
-                    self._dataset = self.add_bounds(axis, width)
         return self._dataset
 
     def get_bounds(self, axis: CFAxisName) -> xr.DataArray:
@@ -201,6 +216,16 @@ class BoundsAccessor:
     def add_bounds(self, axis: CFAxisName, width: float = 0.5) -> xr.Dataset:
         """Add bounds for an axis using its coordinate points.
 
+        The coordinates must meet the following criteria in order to add
+        bounds:
+
+          1. The axis for the coordinates are "X", "Y", "T", or "Z"
+          2. Coordinates are a single dimension, not multidimensional
+          3. Coordinates are a length > 1 (not singleton)
+          4. Bounds must not already exist.
+             * Determined by attempting to map the coordinate variable's
+             "bounds" attr (if set) to the bounds data variable of the same key.
+
         Parameters
         ----------
         axis : CFAxisName
@@ -252,8 +277,6 @@ class BoundsAccessor:
         ------
         ValueError
             If coords dimensions does not equal 1.
-        ValueError
-            If coords are length of <=1.
 
         Notes
         -----
@@ -272,9 +295,15 @@ class BoundsAccessor:
 
         # Validate coordinate shape and dimensions
         if coord_var.ndim != 1:
-            raise ValueError("Cannot generate bounds for multidimensional coordinates.")
+            raise ValueError(
+                f"Cannot generate bounds for coordinate variable '{coord_var.name}'"
+                " because it is multidimensional coordinates."
+            )
         if coord_var.shape[0] <= 1:
-            raise ValueError("Cannot generate bounds for a coordinate of length <= 1.")
+            raise ValueError(
+                f"Cannot generate bounds for coordinate variable '{coord_var.name}'"
+                " which has a length <= 1."
+            )
 
         # Retrieve coordinate dimension to calculate the diffs between points.
         dim = coord_var.dims[0]


Test patch:
diff --git a/tests/test_bounds.py b/tests/test_bounds.py
index 52a95d6..0e101cf 100644
--- a/tests/test_bounds.py
+++ b/tests/test_bounds.py
@@ -80,6 +80,25 @@ class TestAddMissingBounds:
         # and added height coordinate
         assert result.identical(ds)
 
+    def test_skips_adding_bounds_for_coords_that_are_multidimensional_or_len_of_1(self):
+        # Multidimensional
+        lat = xr.DataArray(
+            data=np.array([[0, 1, 2], [3, 4, 5]]),
+            dims=["placeholder_1", "placeholder_2"],
+            attrs={"units": "degrees_north", "axis": "Y"},
+        )
+        # Length <=1
+        lon = xr.DataArray(
+            data=np.array([0]),
+            dims=["lon"],
+            attrs={"units": "degrees_east", "axis": "X"},
+        )
+        ds = xr.Dataset(coords={"lat": lat, "lon": lon})
+
+        result = ds.bounds.add_missing_bounds("Y")
+
+        assert result.identical(ds)
+
 
 class TestGetBounds:
     @pytest.fixture(autouse=True)
@@ -166,9 +185,6 @@ class TestAddBounds:
         # If coords dimensions does not equal 1.
         with pytest.raises(ValueError):
             ds.bounds.add_bounds("Y")
-        # If coords are length of <=1.
-        with pytest.raises(ValueError):
-            ds.bounds.add_bounds("X")
 
     def test_raises_error_if_lat_coord_var_units_is_not_in_degrees(self):
         lat = xr.DataArray(