Repository: xCDAT/xcdat

Issue:
[Bug]: `add_missing_bounds` not working when time type is `numpy.timedelta64`
### What happened?

xarray's [tutorial sample data](https://docs.xarray.dev/en/stable/generated/xarray.tutorial.open_dataset.html) for air temperature has its time axis typed `numpy.timedelta64` while `numpy.timedelta` is expected from xcdat

### What did you expect to happen? Are there are possible answers you came across?

The issue can be addressed by converting `numpy.timedelta64` to `numpy.timedelta` if needed when checking the difference between the interval of the second and first time steps.

### Minimal Complete Verifiable Example (MVCE)

```python
import xcdat as xc
import xarray as xr

# Load xarray tutorial sample data
ds = xr.tutorial.load_dataset("air_temperature")
data_var = "air"

# Add axis attributes to lat / lon axes, which was missing in the sample data
new_y = ds.lat.assign_attrs(axis='Y')
new_x = ds.lon.assign_attrs(axis='X')
ds = ds.assign_coords(lat=new_y, lon=new_x)

# Add bounds
ds = ds.bounds.add_missing_bounds(["X", "Y", "T"])
```


### Relevant log output

```python
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[7], line 1
----> 1 ds = ds.bounds.add_missing_bounds(["X", "Y", "T"])

File ~/mambaforge/envs/xcdat_dev_20240401/lib/python3.11/site-packages/xcdat/bounds.py:189, in BoundsAccessor.add_missing_bounds(self, axes)
    187         bounds = create_bounds(axis, coord)
    188     elif axis == "T":
--> 189         bounds = self._create_time_bounds(coord)
    190 except (ValueError, TypeError) as e:
    191     logger.warning(e)

File ~/mambaforge/envs/xcdat_dev_20240401/lib/python3.11/site-packages/xcdat/bounds.py:607, in BoundsAccessor._create_time_bounds(self, time, freq, daily_subfreq, end_of_month)
    605 if daily_subfreq is None:
    606     diff = time.values[1] - time.values[0]
--> 607     hrs = diff.seconds / 3600
    608     daily_subfreq = int(24 / hrs)  # type: ignore
    610 time_bnds = self._create_daily_time_bounds(timesteps, obj_type, freq=daily_subfreq)  # type: ignore

AttributeError: 'numpy.timedelta64' object has no attribute 'seconds'
```


### Anything else we need to know?

Narrow down to the issue:
```python
time = ds.time
diff = time.values[1] - time.values[0]
diff.seconds
```

> 'numpy.timedelta64' object has no attribute 'seconds'

```python
type(diff)
```

> numpy.timedelta64

### Environment

INSTALLED VERSIONS
------------------
commit: None
python: 3.11.8 | packaged by conda-forge | (main, Feb 16 2024, 20:51:20) [Clang 16.0.6 ]
python-bits: 64
OS: Darwin
OS-release: 23.5.0
machine: x86_64
processor: i386
byteorder: little
LC_ALL: None
LANG: None
LOCALE: (None, 'UTF-8')
libhdf5: 1.14.3
libnetcdf: 4.9.2

xarray: 2024.3.0
pandas: 2.2.1
numpy: 1.26.4
scipy: 1.12.0
netCDF4: 1.6.5
pydap: None
h5netcdf: None
h5py: None
Nio: None
zarr: None
cftime: 1.6.3
nc_time_axis: 1.4.1
iris: None
bottleneck: None
dask: 2024.3.1
distributed: 2024.3.1
matplotlib: 3.8.3
cartopy: None
seaborn: None
numbagg: None
fsspec: 2024.3.1
cupy: None
pint: None
sparse: 0.15.1
flox: None
numpy_groupies: None
setuptools: 69.2.0
pip: 24.0
conda: None
pytest: 8.1.1
mypy: 1.4.0
IPython: 8.22.2
sphinx: 7.2.6

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/xCDAT/xcdat/pull/660?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`223869e`)](https://app.codecov.io/gh/xCDAT/xcdat/commit/223869e2a286ae403427e26921c749f60b0d4f6b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT) to head [(`a6e3b1c`)](https://app.codecov.io/gh/xCDAT/xcdat/commit/a6e3b1c99a977cd8d55db551aa9ea864490c949b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #660   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           15        15           
  Lines         1542      1542           
=========================================
  Hits          1542      1542           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/xCDAT/xcdat/pull/660?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xCDAT).

tomvothecoder: I pushed commit https://github.com/xCDAT/xcdat/pull/660/commits/c6b314a1c7e33df200a3bba2bd0e73ae0748acad to add test coverage for the new code. This PR should be good to go.
lee1043: @tomvothecoder thanks for the addition. 

I am merging this PR. 

Patch:
diff --git a/xcdat/bounds.py b/xcdat/bounds.py
index 13dd6e4..38e7f6f 100644
--- a/xcdat/bounds.py
+++ b/xcdat/bounds.py
@@ -504,7 +504,7 @@ class BoundsAccessor:
 
         return list(set(keys))
 
-    def _create_time_bounds(
+    def _create_time_bounds(  # noqa: C901
         self,
         time: xr.DataArray,
         freq: Optional[Literal["year", "month", "day", "hour"]] = None,
@@ -601,9 +601,16 @@ class BoundsAccessor:
         elif freq == "day":
             time_bnds = self._create_daily_time_bounds(timesteps, obj_type)
         elif freq == "hour":
-            # Determine the daily frequency for generating time  bounds.
+            # Determine the daily frequency for generating time bounds.
             if daily_subfreq is None:
                 diff = time.values[1] - time.values[0]
+
+                # Arrays with `dtype="timedelta64[ns]"` must be converted to
+                # pandas timedelta objects in order to access the `.seconds`
+                # time component.
+                if isinstance(diff, np.timedelta64):
+                    diff = pd.to_timedelta(diff)
+
                 hrs = diff.seconds / 3600
                 daily_subfreq = int(24 / hrs)  # type: ignore
 


Test patch:
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 4da71a7..81f594b 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -92,6 +92,22 @@ time_hourly = xr.DataArray(
         "standard_name": "time",
     },
 )
+time_hourly_dt = xr.DataArray(
+    data=np.array(
+        [
+            "2000-01-01T00:00:00.000000000",
+            "2000-01-01T01:00:00.000000000",
+            "2000-01-01T02:00:00.000000000",
+        ],
+        dtype="datetime64[ns]",
+    ),
+    dims=["time"],
+    attrs={
+        "axis": "T",
+        "long_name": "time",
+        "standard_name": "time",
+    },
+)
 time_subhourly = xr.DataArray(
     data=np.array(
         [
@@ -189,6 +205,30 @@ time_bnds_hourly = xr.DataArray(
         "xcdat_bounds": "True",
     },
 )
+time_bnds_hourly_dt = xr.DataArray(
+    name="time_bnds",
+    data=np.array(
+        [
+            [
+                "2000-01-01T00:00:00.000000000",
+                "2000-01-01T01:00:00.000000000",
+            ],
+            [
+                "2000-01-01T01:00:00.000000000",
+                "2000-01-01T02:00:00.000000000",
+            ],
+            [
+                "2000-01-01T02:00:00.000000000",
+                "2000-01-01T03:00:00.000000000",
+            ],
+        ],
+        dtype="datetime64[ns]",
+    ),
+    dims=["time", "bnds"],
+    attrs={
+        "xcdat_bounds": "True",
+    },
+)
 time_bnds_subhourly = xr.DataArray(
     name="time_bnds",
     data=np.array(
@@ -495,7 +535,8 @@ def generate_dataset(
 
 
 def generate_dataset_by_frequency(
-    freq: Literal["subhour", "hour", "day", "month", "year"] = "month"
+    freq: Literal["subhour", "hour", "day", "month", "year"] = "month",
+    obj_type: Literal["cftime", "datetime"] = "cftime",
 ) -> xr.Dataset:
     """Generates a dataset for a given temporal frequency.
 
@@ -523,8 +564,15 @@ def generate_dataset_by_frequency(
         time = time_daily.copy()
         time_bnds = time_bnds_daily.copy()
     elif freq == "hour":
-        time = time_hourly.copy()
-        time_bnds = time_bnds_hourly.copy()
+        # Test cftime and datetime. datetime subtraction results in
+        # dtype=timedelta64[ns] objects, which need to be converted to Pandas
+        # TimeDelta objects to use the `.seconds` time component.
+        if obj_type == "cftime":
+            time = time_hourly.copy()
+            time_bnds = time_bnds_hourly.copy()
+        else:
+            time = time_hourly_dt.copy()
+            time_bnds = time_bnds_hourly_dt.copy()
     elif freq == "subhour":
         time = time_subhourly.copy()
         time_bnds = time_bnds_subhourly.copy()
diff --git a/tests/test_bounds.py b/tests/test_bounds.py
index 4e2fd02..fc864a4 100644
--- a/tests/test_bounds.py
+++ b/tests/test_bounds.py
@@ -826,6 +826,21 @@ class TestAddTimeBounds:
         assert monthly_bounds.identical(ds_monthly_with_bnds)
         assert yearly_bounds.identical(ds_yearly_with_bnds)
 
+    def test_add_bounds_for_hourly_time_coords_as_datetime_objects(self):
+        # get reference datasets
+        ds_hrly_with_bnds = generate_dataset_by_frequency("hour", "datetime")
+
+        # drop bounds for testing
+        ds_hrly_wo_bnds = ds_hrly_with_bnds.drop_vars("time_bnds")
+
+        # test adding bounds
+        hourly_bounds = ds_hrly_wo_bnds.bounds.add_time_bounds(
+            method="freq", freq="hour"
+        )
+
+        # ensure identical
+        assert hourly_bounds.identical(ds_hrly_with_bnds)
+
     def test_add_monthly_bounds_for_end_of_month_set_to_true(self):
         ds_with_bnds = self.ds_with_bnds.copy()