Repository: xCDAT/xcdat

Issue:
[Bug]: `add_bounds()` breaks when time coordinates are in `cftime` objects instead of `datetime`
### Bug Report Criteria

- [X] Bug is not related to a data quality issue(s) beyond the scope of xCDAT
- [X] Bug is not related to core xarray APIs (please open an issue in the xarray repo if it is)

### What happened?

`cftime` datetime objects are used to represent time coordinates for non-cf compliant calendars (360-day, noleap) and units ("months", "years"). Unlike `datetime` datetime objects, `cftime` datetime objects (e.g., `cftime.Datetime`, `cftime.DatetimeNoLeap`) don't  support arithmetic involving `timedelta64[ns]`, ints, floats, etc. 

In the formula to calculate the lower and upper bounds for each coordinate point, a subtraction and addition operation is performed respectively (example below). The `diffs` array consists of `timedelta64[ns]`, so it breaks (refer to MCV example and log outputs).

https://github.com/xCDAT/xcdat/blob/112eb58f797821f14af2934b7b2551b39912c291/xcdat/bounds.py#L255-L263

Instead of subtracting `diffs` as a `np.array` of strings with a dtype of `timedelta64[ns]`, we have to subtract using `timedelta` objects. This can be achieved by using `pd.to_timedelta(diffs)`.


```python
        # Add beginning and end points to account for lower and upper bounds.
        # np.array of string values with dtype "timedelta64[ns]""
        diffs = np.insert(diffs, 0, diffs[0])
        diffs = np.append(diffs, diffs[-1])

        # In xarray and xCDAT, `cftime` objects are used to represent time
        # coordinates for non-Cf compliant calendars (360-day, noleap) and
        # units ("months", "years"), instead of `datetime` objects. `cftime`
        # objects only support arithmetic using `timedelta`` objects, so
        # the values of `diffs` must be casted to `timedelta`.
        # FIXME: This line produces the warning: python3.9/site-packages/pandas
        # /core/arrays/datetimelike.py:1189: PerformanceWarning:
        # Adding/subtracting object-dtype array to TimedeltaArray not
        # vectorized.warnings.warn(
        diffs = pd.to_timedelta(diffs)
```
Related issue: https://github.com/Unidata/cftime/issues/198

### What did you expect to happen?

Bounds are generated regardless of the datetime object type used to represent time coordinates

### Minimal Complete Verifiable Example

```python
import xcdat

dataset_links = [
    "https://esgf-data2.llnl.gov/thredds/dodsC/user_pub_work/E3SM/1_0/amip_1850_aeroF/1deg_atm_60-30km_ocean/atmos/180x360/time-series/mon/ens2/v3/TS_187001_189412.nc",
    "https://esgf-data2.llnl.gov/thredds/dodsC/user_pub_work/E3SM/1_0/amip_1850_aeroF/1deg_atm_60-30km_ocean/atmos/180x360/time-series/mon/ens2/v3/TS_189501_191912.nc",
]

ds = xcdat.open_mfdataset(dataset_links)

# Drop the existing time bounds to demonstrate adding new bounds
ds = ds.drop_vars("time_bnds")

# Breaks here dataset_links = [
    "https://esgf-data2.llnl.gov/thredds/dodsC/user_pub_work/E3SM/1_0/amip_1850_aeroF/1deg_atm_60-30km_ocean/atmos/180x360/time-series/mon/ens2/v3/TS_187001_189412.nc",
    "https://esgf-data2.llnl.gov/thredds/dodsC/user_pub_work/E3SM/1_0/amip_1850_aeroF/1deg_atm_60-30km_ocean/atmos/180x360/time-series/mon/ens2/v3/TS_189501_191912.nc",
]

ds = ds.bounds.add_bounds("time")
```


### Relevant log output

```python
During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/vo13/miniconda3/envs/xcdat_dev/lib/python3.9/site-packages/IPython/core/interactiveshell.py", line 3397, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "/tmp/ipykernel_9974/1848296045.py", line 1, in <cell line: 1>
    ds_new.bounds.add_bounds("time")
  File "/home/vo13/XCDAT/xcdat/xcdat/bounds.py", line 207, in add_bounds
    dataset = self._add_bounds(axis, width)
  File "/home/vo13/XCDAT/xcdat/xcdat/bounds.py", line 262, in _add_bounds
    lower_bounds = da_coord - diffs[:-1] * width
  File "/home/vo13/miniconda3/envs/xcdat_dev/lib/python3.9/site-packages/xarray/core/_typed_ops.py", line 209, in __sub__
    return self._binary_op(other, operator.sub)
  File "/home/vo13/miniconda3/envs/xcdat_dev/lib/python3.9/site-packages/xarray/core/dataarray.py", line 3098, in _binary_op
    f(self.variable, other_variable)
  File "/home/vo13/miniconda3/envs/xcdat_dev/lib/python3.9/site-packages/xarray/core/_typed_ops.py", line 399, in __sub__
    return self._binary_op(other, operator.sub)
  File "/home/vo13/miniconda3/envs/xcdat_dev/lib/python3.9/site-packages/xarray/core/variable.py", line 2467, in _binary_op
    f(self_data, other_data) if not reflexive else f(other_data, self_data)
numpy.core._exceptions._UFuncBinaryResolutionError: ufunc 'subtract' cannot use operands with types dtype('O') and dtype('<m8[ns]')
```


### Anything else we need to know?

Related code:
https://github.com/Unidata/cftime/blob/dc75368cd02bbcd1352dbecfef10404a58683f94/src/cftime/_cftime.pyx#L1020-L1021

https://github.com/Unidata/cftime/blob/dc75368cd02bbcd1352dbecfef10404a58683f94/src/cftime/_cftime.pyx#L439-L472

### Environment


INSTALLED VERSIONS
------------------
commit: None
python: 3.9.12 | packaged by conda-forge | (main, Mar 24 2022, 23:22:55) 
[GCC 10.3.0]
python-bits: 64
OS: Linux
OS-release: 3.10.0-1160.45.1.el7.x86_64
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8
LOCALE: ('en_US', 'UTF-8')
libhdf5: 1.12.1
libnetcdf: 4.8.1

xarray: 2022.3.0
pandas: 1.4.1
numpy: 1.22.3
scipy: 1.8.1
netCDF4: 1.5.8
pydap: None
h5netcdf: None
h5py: None
Nio: None
zarr: None
cftime: 1.6.0
nc_time_axis: None
PseudoNetCDF: None
rasterio: None
cfgrib: None
iris: None
bottleneck: 1.3.2
dask: 2022.03.0
distributed: 2022.3.0
matplotlib: 3.5.1
cartopy: 0.20.1
seaborn: None
numbagg: None
fsspec: 2022.3.0
cupy: None
pint: None
sparse: None
setuptools: 61.2.0
pip: 22.0.4
conda: None
pytest: 7.1.1
IPython: 8.3.0
sphinx: 4.4.0

INSTALLED VERSIONS
------------------
commit: None
python: 3.9.12 | packaged by conda-forge | (main, Mar 24 2022, 23:22:55) 
[GCC 10.3.0]
python-bits: 64
OS: Linux
OS-release: 3.10.0-1160.45.1.el7.x86_64
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8
LOCALE: ('en_US', 'UTF-8')
libhdf5: 1.12.1
libnetcdf: 4.8.1

xarray: 2022.3.0
pandas: 1.4.1
numpy: 1.22.3
scipy: 1.8.1
netCDF4: 1.5.8
pydap: None
h5netcdf: None
h5py: None
Nio: None
zarr: None
cftime: 1.6.0
nc_time_axis: None
PseudoNetCDF: None
rasterio: None
cfgrib: None
iris: None
bottleneck: 1.3.2
dask: 2022.03.0
distributed: 2022.3.0
matplotlib: 3.5.1
cartopy: 0.20.1
seaborn: None
numbagg: None
fsspec: 2022.3.0
cupy: None
pint: None
sparse: None
setuptools: 61.2.0
pip: 22.0.4
conda: None
pytest: 7.1.1
IPython: 8.3.0
sphinx: 4.4.0
None


Comments:

Patch:
diff --git a/xcdat/bounds.py b/xcdat/bounds.py
index 0d81742..0b011fe 100644
--- a/xcdat/bounds.py
+++ b/xcdat/bounds.py
@@ -1,9 +1,12 @@
 """Bounds module for functions related to coordinate bounds."""
 import collections
+import warnings
 from typing import Dict, List, Literal, Optional
 
 import cf_xarray as cfxr  # noqa: F401
+import cftime
 import numpy as np
+import pandas as pd
 import xarray as xr
 
 from xcdat.axis import GENERIC_AXIS_MAP
@@ -253,13 +256,32 @@ class BoundsAccessor:
         diffs = da_coord.diff(dim).values
 
         # Add beginning and end points to account for lower and upper bounds.
+        # np.array of string values with `dtype="timedelta64[ns]"`
         diffs = np.insert(diffs, 0, diffs[0])
         diffs = np.append(diffs, diffs[-1])
 
-        # Get lower and upper bounds by using the width relative to nearest point.
+        # In xarray and xCDAT, time coordinates with non-CF compliant calendars
+        # (360-day, noleap) and/or units ("months", "years") are decoded using
+        # `cftime` objects instead of `datetime` objects. `cftime` objects only
+        # support arithmetic using `timedelta` objects, so the values of `diffs`
+        # must be casted from `dtype="timedelta64[ns]"` to `timedelta`.
+        if da_coord.name in ("T", "time") and issubclass(
+            type(da_coord.values[0]), cftime.datetime
+        ):
+            diffs = pd.to_timedelta(diffs)
+
+        # FIXME: These lines produces the warning: `PerformanceWarning:
+        # Adding/subtracting object-dtype array to TimedeltaArray not
+        # vectorized` after converting diffs to `timedelta`. I (Tom) was not
+        # able to find an alternative, vectorized solution at the time of this
+        # implementation.
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore", category=pd.errors.PerformanceWarning)
+            # Get lower and upper bounds by using the width relative to nearest point.
+            lower_bounds = da_coord - diffs[:-1] * width
+            upper_bounds = da_coord + diffs[1:] * (1 - width)
+
         # Transpose both bound arrays into a 2D array.
-        lower_bounds = da_coord - diffs[:-1] * width
-        upper_bounds = da_coord + diffs[1:] * (1 - width)
         bounds = np.array([lower_bounds, upper_bounds]).transpose()
 
         # Clip latitude bounds at (-90, 90)
diff --git a/xcdat/dataset.py b/xcdat/dataset.py
index eddc9bb..0c7e3c5 100644
--- a/xcdat/dataset.py
+++ b/xcdat/dataset.py
@@ -457,7 +457,7 @@ def _postprocess_dataset(
 
     if center_times:
         if dataset.cf.dims.get("T") is not None:
-            dataset = dataset.temporal.center_times(dataset)
+            dataset = dataset.temporal.center_times()
         else:
             raise ValueError("This dataset does not have a time coordinates to center.")
 
diff --git a/xcdat/temporal.py b/xcdat/temporal.py
index 20baec6..4a6078c 100644
--- a/xcdat/temporal.py
+++ b/xcdat/temporal.py
@@ -677,7 +677,7 @@ class TemporalAccessor:
 
         return ds_departs
 
-    def center_times(self, dataset: xr.Dataset) -> xr.Dataset:
+    def center_times(self) -> xr.Dataset:
         """Centers the time coordinates using the midpoint between time bounds.
 
         Time coordinates can be recorded using different intervals, including
@@ -695,12 +695,9 @@ class TemporalAccessor:
         xr.Dataset
             The Dataset with centered time coordinates.
         """
-        ds = dataset.copy()
-
-        if hasattr(self, "_time_bounds") is False:
-            self._time_bounds = ds.bounds.get_bounds("time")
+        ds = self._dataset.copy()
+        time_bounds = ds.bounds.get_bounds("time")
 
-        time_bounds = self._time_bounds.copy()
         lower_bounds, upper_bounds = (time_bounds[:, 0].data, time_bounds[:, 1].data)
         bounds_diffs: np.timedelta64 = (upper_bounds - lower_bounds) / 2
         bounds_mids: np.ndarray = lower_bounds + bounds_diffs
@@ -842,7 +839,7 @@ class TemporalAccessor:
         ds = self._dataset.copy()
 
         if self._center_times:
-            ds = self.center_times(ds)
+            ds = self.center_times()
 
         if (
             self._freq == "season"
@@ -1393,14 +1390,14 @@ class TemporalAccessor:
                 self._time_bounds[:, 1] - self._time_bounds[:, 0]
             )
 
-        # Must be convert dtype from timedelta64[ns] to float64, specifically
-        # when chunking DataArrays using Dask. Otherwise, the numpy warning
-        # below is thrown: `DeprecationWarning: The `dtype` and `signature`
-        # arguments to ufuncs only select the general DType and not details such
-        # as the byte order or time unit (with rare exceptions see release
-        # notes). To avoid this warning please use the scalar types
-        # `np.float64`, or string notation.`
+        # Must be cast dtype from "timedelta64[ns]" to "float64", specifically
+        # when using Dask arrays. Otherwise, the numpy warning below is thrown:
+        # `DeprecationWarning: The `dtype` and `signature` arguments to ufuncs
+        # only select the general DType and not details such as the byte order
+        # or time unit (with rare exceptions see release notes). To avoid this
+        # warning please use the scalar types `np.float64`, or string notation.`
         time_lengths = time_lengths.astype(np.float64)
+
         grouped_time_lengths = self._group_data(time_lengths)
         weights: xr.DataArray = grouped_time_lengths / grouped_time_lengths.sum()  # type: ignore
 


Test patch:
diff --git a/tests/test_bounds.py b/tests/test_bounds.py
index 2c1bfcb..92698ba 100644
--- a/tests/test_bounds.py
+++ b/tests/test_bounds.py
@@ -1,3 +1,4 @@
+import cftime
 import numpy as np
 import pytest
 import xarray as xr
@@ -115,7 +116,7 @@ class TestAddBounds:
         with pytest.raises(ValueError):
             ds.bounds.add_bounds("lat")
 
-    def test__add_bounds_raises_errors_for_data_dim_and_length(self):
+    def test_add_bounds_raises_errors_for_data_dim_and_length(self):
         # Multidimensional
         lat = xr.DataArray(
             data=np.array([[0, 1, 2], [3, 4, 5]]),
@@ -132,23 +133,23 @@ class TestAddBounds:
 
         # If coords dimensions does not equal 1.
         with pytest.raises(ValueError):
-            ds.bounds._add_bounds("lat")
+            ds.bounds.add_bounds("lat")
         # If coords are length of <=1.
         with pytest.raises(ValueError):
-            ds.bounds._add_bounds("lon")
+            ds.bounds.add_bounds("lon")
 
-    def test__add_bounds_returns_dataset_with_bounds_added(self):
+    def test_add_bounds_for_dataset_with_coords_as_datetime_objects(self):
         ds = self.ds.copy()
 
-        ds = ds.bounds._add_bounds("lat")
-        assert ds.lat_bnds.equals(lat_bnds)
-        assert ds.lat_bnds.is_generated == "True"
+        result = ds.bounds.add_bounds("lat")
+        assert result.lat_bnds.equals(lat_bnds)
+        assert result.lat_bnds.is_generated == "True"
 
-        ds = ds.bounds._add_bounds("lon")
-        assert ds.lon_bnds.equals(lon_bnds)
-        assert ds.lon_bnds.is_generated == "True"
+        result = result.bounds.add_bounds("lon")
+        assert result.lon_bnds.equals(lon_bnds)
+        assert result.lon_bnds.is_generated == "True"
 
-        ds = ds.bounds._add_bounds("time")
+        result = ds.bounds.add_bounds("time")
         # NOTE: The algorithm for generating time bounds doesn't extend the
         # upper bound into the next month.
         expected_time_bnds = xr.DataArray(
@@ -173,16 +174,61 @@ class TestAddBounds:
                 ],
                 dtype="datetime64[ns]",
             ),
-            coords={"time": ds.time},
+            coords={"time": ds.time.assign_attrs({"bounds": "time_bnds"})},
+            dims=["time", "bnds"],
+            attrs={"is_generated": "True"},
+        )
+
+        assert result.time_bnds.identical(expected_time_bnds)
+
+    def test_returns_bounds_for_dataset_with_coords_as_cftime_objects(self):
+        ds = self.ds.copy()
+        ds = ds.drop_dims("time")
+        ds["time"] = xr.DataArray(
+            name="time",
+            data=np.array(
+                [
+                    cftime.DatetimeNoLeap(1850, 1, 1),
+                    cftime.DatetimeNoLeap(1850, 2, 1),
+                    cftime.DatetimeNoLeap(1850, 3, 1),
+                ],
+            ),
+            dims=["time"],
+            attrs={
+                "axis": "T",
+                "long_name": "time",
+                "standard_name": "time",
+            },
+        )
+
+        result = ds.bounds.add_bounds("time")
+        expected_time_bnds = xr.DataArray(
+            name="time_bnds",
+            data=np.array(
+                [
+                    [
+                        cftime.DatetimeNoLeap(1849, 12, 16, 12),
+                        cftime.DatetimeNoLeap(1850, 1, 16, 12),
+                    ],
+                    [
+                        cftime.DatetimeNoLeap(1850, 1, 16, 12),
+                        cftime.DatetimeNoLeap(1850, 2, 15, 0),
+                    ],
+                    [
+                        cftime.DatetimeNoLeap(1850, 2, 15, 0),
+                        cftime.DatetimeNoLeap(1850, 3, 15, 0),
+                    ],
+                ],
+            ),
+            coords={"time": ds.time.assign_attrs({"bounds": "time_bnds"})},
             dims=["time", "bnds"],
-            attrs=ds.time_bnds.attrs,
+            attrs={"is_generated": "True"},
         )
 
-        assert ds.time_bnds.equals(expected_time_bnds)
-        assert ds.time_bnds.is_generated == "True"
+        assert result.time_bnds.identical(expected_time_bnds)
 
 
-class TestGetCoord:
+class Test_GetCoord:
     @pytest.fixture(autouse=True)
     def setup(self):
         self.ds = generate_dataset(cf_compliant=True, has_bounds=False)
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
index 4ca3438..d8e8c4d 100644
--- a/tests/test_dataset.py
+++ b/tests/test_dataset.py
@@ -264,7 +264,7 @@ class TestOpenMfDataset:
         assert result.identical(expected)
 
 
-class TestHasCFCompliantTime:
+class Test_HasCFCompliantTime:
     @pytest.fixture(autouse=True)
     def setUp(self, tmp_path):
         # Create temporary directory to save files.
@@ -668,7 +668,7 @@ class TestDecodeNonCFTimeUnits:
         assert result.time_bnds.encoding == expected.time_bnds.encoding
 
 
-class TestPostProcessDataset:
+class Test_PostProcessDataset:
     @pytest.fixture(autouse=True)
     def setup(self):
         self.ds = generate_dataset(cf_compliant=True, has_bounds=True)
@@ -868,7 +868,7 @@ class TestPostProcessDataset:
             _postprocess_dataset(ds, lon_orient=(0, 360))
 
 
-class TestKeepSingleVar:
+class Test_KeepSingleVar:
     @pytest.fixture(autouse=True)
     def setup(self):
         self.ds = generate_dataset(cf_compliant=True, has_bounds=True)
@@ -909,7 +909,7 @@ class TestKeepSingleVar:
         assert ds.get("time_bnds") is not None
 
 
-class TestPreProcessNonCFDataset:
+class Test_PreProcessNonCFDataset:
     @pytest.fixture(autouse=True)
     def setup(self):
         self.ds = generate_dataset(cf_compliant=False, has_bounds=True)
@@ -944,7 +944,7 @@ class TestPreProcessNonCFDataset:
         assert result.identical(expected)
 
 
-class TestSplitTimeUnitsAttr:
+class Test_SplitTimeUnitsAttr:
     def test_raises_error_if_units_attr_is_none(self):
         with pytest.raises(KeyError):
             _split_time_units_attr(None)  # type: ignore
diff --git a/tests/test_temporal.py b/tests/test_temporal.py
index 15f4c48..a5d5ef2 100644
--- a/tests/test_temporal.py
+++ b/tests/test_temporal.py
@@ -64,7 +64,7 @@ class TestAverage:
             attrs={"is_generated": "True"},
         )
         ds["ts"] = xr.DataArray(
-            data=np.array([[[2]], [[1]], [[1]], [[1]], [[2]]]),
+            data=np.array([[[2]], [[np.nan]], [[1]], [[1]], [[2]]]),
             coords={"lat": ds.lat, "lon": ds.lon, "time": ds.time},
             dims=["time", "lat", "lon"],
         )
@@ -74,7 +74,7 @@ class TestAverage:
         expected = ds.copy()
         expected = expected.drop_dims("time")
         expected["ts"] = xr.DataArray(
-            data=np.array([[1.4]]),
+            data=np.array([[1.5]]),
             coords={"lat": expected.lat, "lon": expected.lon},
             dims=["lat", "lon"],
             attrs={
@@ -93,7 +93,7 @@ class TestAverage:
         expected = ds.copy()
         expected = expected.drop_dims("time")
         expected["ts"] = xr.DataArray(
-            data=np.array([[1.4]]),
+            data=np.array([[1.5]]),
             coords={"lat": expected.lat, "lon": expected.lon},
             dims=["lat", "lon"],
             attrs={
@@ -120,7 +120,7 @@ class TestAverage:
                             "2000-02-01T00:00:00.000000000",
                             "2000-03-01T00:00:00.000000000",
                             "2000-04-01T00:00:00.000000000",
-                            "2000-05-01T00:00:00.000000000",
+                            "2001-02-01T00:00:00.000000000",
                         ],
                         dtype="datetime64[ns]",
                     ),
@@ -142,7 +142,7 @@ class TestAverage:
                     ["2000-02-01T00:00:00.000000000", "2000-03-01T00:00:00.000000000"],
                     ["2000-03-01T00:00:00.000000000", "2000-04-01T00:00:00.000000000"],
                     ["2000-04-01T00:00:00.000000000", "2000-05-01T00:00:00.000000000"],
-                    ["2000-05-01T00:00:00.000000000", "2000-06-01T00:00:00.000000000"],
+                    ["2001-01-01T00:00:00.000000000", "2000-03-01T00:00:00.000000000"],
                 ],
                 dtype="datetime64[ns]",
             ),
@@ -151,7 +151,7 @@ class TestAverage:
             attrs={"is_generated": "True"},
         )
         ds["ts"] = xr.DataArray(
-            data=np.array([[[2]], [[1]], [[1]], [[1]], [[1]]]),
+            data=np.array([[[2]], [[np.nan]], [[1]], [[1]], [[1]]]),
             coords={"lat": ds.lat, "lon": ds.lon, "time": ds.time},
             dims=["time", "lat", "lon"],
         )
@@ -161,7 +161,7 @@ class TestAverage:
         expected = ds.copy()
         expected = expected.drop_dims("time")
         expected["ts"] = xr.DataArray(
-            data=np.array([[1.2]]),
+            data=np.array([[1.24362357]]),
             coords={"lat": expected.lat, "lon": expected.lon},
             dims=["lat", "lon"],
             attrs={
@@ -173,14 +173,14 @@ class TestAverage:
             },
         )
 
-        assert result.identical(expected)
+        xr.testing.assert_allclose(result, expected)
 
         # Test unweighted averages
         result = ds.temporal.average("ts", weighted=False)
         expected = ds.copy()
         expected = expected.drop_dims("time")
         expected["ts"] = xr.DataArray(
-            data=np.array([[1.2]]),
+            data=np.array([[1.25]]),
             coords={"lat": expected.lat, "lon": expected.lon},
             dims=["lat", "lon"],
             attrs={
@@ -191,7 +191,7 @@ class TestAverage:
                 "center_times": "False",
             },
         )
-        assert result.identical(expected)
+        xr.testing.assert_allclose(result, expected)
 
     def test_averages_for_daily_time_series(self):
         ds = xr.Dataset(
@@ -826,6 +826,57 @@ class TestGroupAverage:
 
         assert result.identical(expected)
 
+    def test_weighted_monthly_averages_with_masked_data(self):
+        ds = self.ds.copy()
+        ds["ts"] = xr.DataArray(
+            data=np.array(
+                [[[2.0]], [[np.nan]], [[1.0]], [[1.0]], [[2.0]]], dtype="float64"
+            ),
+            coords={"time": self.ds.time, "lat": self.ds.lat, "lon": self.ds.lon},
+            dims=["time", "lat", "lon"],
+        )
+
+        result = ds.temporal.group_average("ts", "month")
+        expected = ds.copy()
+        expected = expected.drop_dims("time")
+        expected["ts"] = xr.DataArray(
+            name="ts",
+            data=np.array([[[2.0]], [[0.0]], [[1.0]], [[1.0]], [[2.0]]]),
+            coords={
+                "lat": expected.lat,
+                "lon": expected.lon,
+                "time": xr.DataArray(
+                    data=np.array(
+                        [
+                            "2000-01-01T00:00:00.000000000",
+                            "2000-03-01T00:00:00.000000000",
+                            "2000-06-01T00:00:00.000000000",
+                            "2000-09-01T00:00:00.000000000",
+                            "2001-02-01T00:00:00.000000000",
+                        ],
+                        dtype="datetime64[ns]",
+                    ),
+                    dims=["time"],
+                    attrs={
+                        "axis": "T",
+                        "long_name": "time",
+                        "standard_name": "time",
+                        "bounds": "time_bnds",
+                    },
+                ),
+            },
+            dims=["time", "lat", "lon"],
+            attrs={
+                "operation": "temporal_avg",
+                "mode": "group_average",
+                "freq": "month",
+                "weighted": "True",
+                "center_times": "False",
+            },
+        )
+
+        assert result.identical(expected)
+
     def test_weighted_daily_averages(self):
         ds = self.ds.copy()
 
@@ -1584,7 +1635,7 @@ class TestCenterTimes:
         ds = ds.drop_dims("time")
 
         with pytest.raises(KeyError):
-            ds.temporal.center_times(ds)
+            ds.temporal.center_times()
 
     def test_gets_time_as_the_midpoint_between_time_bounds(self):
         ds = self.ds.copy()
@@ -1658,7 +1709,7 @@ class TestCenterTimes:
         time_bounds["time"] = expected.time
         expected["time_bnds"] = time_bounds
 
-        result = ds.temporal.center_times(ds)
+        result = ds.temporal.center_times()
         assert result.identical(expected)