Repository: pydata/xarray

Issue:
infer_freq not working (yet) with datetime64[s]
### What happened?

We have upstream dev CI testing on geocat-comp that recently failed (https://github.com/NCAR/geocat-comp/issues/681) because the dtype='datetime64[s]` isn't handled by `infer_freq` yet in xarray.  In particular this if statement looks like it needs to be generalized:
https://github.com/pydata/xarray/blob/2bedca4f32d555734b982a71856cc0b56077d37e/xarray/coding/frequencies.py#L91



### What did you expect to happen?

`infer_freq` to handle a broader range of dtypes

### Minimal Complete Verifiable Example

```Python
import xarray as xr
import numpy as np
import pandas as pd

time = pd.to_datetime(['2020-01-01', '2020-01-02', '2020-01-04'])
non_uniform = xr.Dataset(data_vars={'data': (('time'), np.arange(3))}, coords={'time': time})
xr.infer_freq(non_uniform['time'])
```

### MVCE confirmation

- [x] Minimal example — the example is as focused as reasonably possible to demonstrate the underlying issue in xarray.
- [x] Complete example — the example is self-contained, including all data and the text of any traceback.
- [x] Verifiable example — the example copy & pastes into an IPython prompt or [Binder notebook](https://mybinder.org/v2/gh/pydata/xarray/main?urlpath=lab/tree/doc/examples/blank_template.ipynb), returning the result.
- [x] New issue — a search of GitHub Issues suggests this is not a duplicate.
- [x] Recent environment — the issue occurs with the latest version of xarray and its dependencies.

### Relevant log output

```Python
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/katelynw/miniconda3/envs/geocat_comp_build/lib/python3.12/site-packages/xarray/coding/frequencies.py", line 96, in infer_freq
    index = CFTimeIndex(index.values)
            ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/katelynw/miniconda3/envs/geocat_comp_build/lib/python3.12/site-packages/xarray/coding/cftimeindex.py", line 259, in __new__
    assert_all_valid_date_type(data)
  File "/Users/katelynw/miniconda3/envs/geocat_comp_build/lib/python3.12/site-packages/xarray/coding/cftimeindex.py", line 170, in assert_all_valid_date_type
    raise TypeError(
TypeError: CFTimeIndex requires cftime.datetime objects. Got object of <class 'numpy.datetime64'>.
```

### Anything else we need to know?

Only happens with the dev versions of both pandas and xarray.

### Environment

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.12.8 | packaged by conda-forge | (main, Dec  5 2024, 14:19:53) [Clang 18.1.8 ]
python-bits: 64
OS: Darwin
OS-release: 21.6.0
machine: arm64
processor: arm
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8
LOCALE: ('en_US', 'UTF-8')
libhdf5: 1.14.4
libnetcdf: 4.9.2

xarray: 2025.1.2.dev19+g2bedca4f
pandas: 3.0.0.dev0+1871.gc0c778bdb7
numpy: 1.26.4
scipy: 1.14.1
netCDF4: 1.7.2
pydap: None
h5netcdf: None
h5py: None
zarr: None
cftime: 1.6.4
nc_time_axis: None
iris: None
bottleneck: 1.4.2
dask: 2025.1.0
distributed: 2025.1.0
matplotlib: 3.10.0
cartopy: 0.24.0
seaborn: None
numbagg: None
fsspec: 2024.12.0
cupy: None
pint: 0.24.4
sparse: None
flox: None
numpy_groupies: None
setuptools: 75.8.0
pip: 25.0
conda: None
pytest: 8.3.4
mypy: None
IPython: None
sphinx: None


</details>


Discussion:

Patch:
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 70b5bf2c..3810c806 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -63,7 +63,7 @@ eventually be deprecated.
 
 New Features
 ~~~~~~~~~~~~
-- Relax nanosecond datetime / timedelta restriction in CF time decoding (:issue:`7493`, :pull:`9618`, :pull:`9966`, :pull:`9977`).
+- Relax nanosecond datetime restriction in CF time decoding (:issue:`7493`, :pull:`9618`, :pull:`9977`, :pull:`9966`, :pull:`9999`).
   By `Kai Mühlbauer <https://github.com/kmuehlbauer>`_ and `Spencer Clark <https://github.com/spencerkclark>`_.
 - Enable the ``compute=False`` option in :py:meth:`DataTree.to_zarr`. (:pull:`9958`).
   By `Sam Levang <https://github.com/slevang>`_.
diff --git a/xarray/coding/frequencies.py b/xarray/coding/frequencies.py
index 8629c491..cf137839 100644
--- a/xarray/coding/frequencies.py
+++ b/xarray/coding/frequencies.py
@@ -48,6 +48,7 @@ import pandas as pd
 from xarray.coding.cftime_offsets import _MONTH_ABBREVIATIONS, _legacy_to_new_freq
 from xarray.coding.cftimeindex import CFTimeIndex
 from xarray.core.common import _contains_datetime_like_objects
+from xarray.core.dtypes import _is_numpy_subdtype
 
 _ONE_MICRO = 1
 _ONE_MILLI = _ONE_MICRO * 1000
@@ -88,9 +89,10 @@ def infer_freq(index):
         elif not _contains_datetime_like_objects(Variable("dim", index)):
             raise ValueError("'index' must contain datetime-like objects")
         dtype = np.asarray(index).dtype
-        if dtype == "datetime64[ns]":
+
+        if _is_numpy_subdtype(dtype, "datetime64"):
             index = pd.DatetimeIndex(index.values)
-        elif dtype == "timedelta64[ns]":
+        elif _is_numpy_subdtype(dtype, "timedelta64"):
             index = pd.TimedeltaIndex(index.values)
         else:
             index = CFTimeIndex(index.values)
diff --git a/xarray/core/groupby.py b/xarray/core/groupby.py
index 291632f9..ceae7903 100644
--- a/xarray/core/groupby.py
+++ b/xarray/core/groupby.py
@@ -1357,12 +1357,7 @@ class GroupBy(Generic[T_Xarray]):
         """
         return ops.where_method(self, cond, other)
 
-    def _first_or_last(
-        self,
-        op: Literal["first" | "last"],
-        skipna: bool | None,
-        keep_attrs: bool | None,
-    ):
+    def _first_or_last(self, op, skipna, keep_attrs):
         if all(
             isinstance(maybe_slice, slice)
             and (maybe_slice.stop == maybe_slice.start + 1)
@@ -1373,30 +1368,17 @@ class GroupBy(Generic[T_Xarray]):
             return self._obj
         if keep_attrs is None:
             keep_attrs = _get_keep_attrs(default=True)
-        if (
-            module_available("flox", minversion="0.9.16")
-            and OPTIONS["use_flox"]
-            and contains_only_chunked_or_numpy(self._obj)
-        ):
-            result, *_ = self._flox_reduce(
-                dim=None, func=op, skipna=skipna, keep_attrs=keep_attrs
-            )
-        else:
-            result = self.reduce(
-                getattr(duck_array_ops, op),
-                dim=[self._group_dim],
-                skipna=skipna,
-                keep_attrs=keep_attrs,
-            )
-        return result
+        return self.reduce(
+            op, dim=[self._group_dim], skipna=skipna, keep_attrs=keep_attrs
+        )
 
     def first(self, skipna: bool | None = None, keep_attrs: bool | None = None):
         """Return the first element of each group along the group dimension"""
-        return self._first_or_last("first", skipna, keep_attrs)
+        return self._first_or_last(duck_array_ops.first, skipna, keep_attrs)
 
     def last(self, skipna: bool | None = None, keep_attrs: bool | None = None):
         """Return the last element of each group along the group dimension"""
-        return self._first_or_last("last", skipna, keep_attrs)
+        return self._first_or_last(duck_array_ops.last, skipna, keep_attrs)
 
     def assign_coords(self, coords=None, **coords_kwargs):
         """Assign coordinates by group.
diff --git a/xarray/core/resample.py b/xarray/core/resample.py
index ebd3d46e..5cc98c96 100644
--- a/xarray/core/resample.py
+++ b/xarray/core/resample.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import warnings
 from collections.abc import Callable, Hashable, Iterable, Sequence
-from typing import TYPE_CHECKING, Any, Literal
+from typing import TYPE_CHECKING, Any
 
 from xarray.core._aggregations import (
     DataArrayResampleAggregations,
@@ -103,21 +103,6 @@ class Resample(GroupBy[T_Xarray]):
         (grouper,) = self.groupers
         return self._shuffle_obj(chunks).drop_vars(RESAMPLE_DIM)
 
-    def _first_or_last(
-        self, op: Literal["first", "last"], skipna: bool | None, keep_attrs: bool | None
-    ) -> T_Xarray:
-        from xarray.core.dataset import Dataset
-
-        result = super()._first_or_last(op=op, skipna=skipna, keep_attrs=keep_attrs)
-        if isinstance(result, Dataset):
-            # Can't do this in the base class because group_dim is RESAMPLE_DIM
-            # which is not present in the original object
-            for var in result.data_vars:
-                result._variables[var] = result._variables[var].transpose(
-                    *self._obj._variables[var].dims
-                )
-        return result
-
     def _drop_coords(self) -> T_Xarray:
         """Drop non-dimension coordinates along the resampled dimension."""
         obj = self._obj


Test patch:
diff --git a/xarray/tests/test_cftimeindex.py b/xarray/tests/test_cftimeindex.py
index 85622d54..9531f3fb 100644
--- a/xarray/tests/test_cftimeindex.py
+++ b/xarray/tests/test_cftimeindex.py
@@ -19,6 +19,7 @@ from xarray.coding.times import (
     _parse_iso8601,
     parse_iso8601_like,
 )
+from xarray.core.types import PDDatetimeUnitOptions
 from xarray.tests import (
     assert_array_equal,
     assert_identical,
@@ -1393,16 +1394,16 @@ def test_asi8_empty_cftimeindex():
 
 
 @requires_cftime
-def test_infer_freq_valid_types():
+def test_infer_freq_valid_types(time_unit: PDDatetimeUnitOptions) -> None:
     cf_indx = xr.cftime_range("2000-01-01", periods=3, freq="D")
     assert xr.infer_freq(cf_indx) == "D"
     assert xr.infer_freq(xr.DataArray(cf_indx)) == "D"
 
-    pd_indx = pd.date_range("2000-01-01", periods=3, freq="D")
+    pd_indx = pd.date_range("2000-01-01", periods=3, freq="D").as_unit(time_unit)
     assert xr.infer_freq(pd_indx) == "D"
     assert xr.infer_freq(xr.DataArray(pd_indx)) == "D"
 
-    pd_td_indx = pd.timedelta_range(start="1D", periods=3, freq="D")
+    pd_td_indx = pd.timedelta_range(start="1D", periods=3, freq="D").as_unit(time_unit)
     assert xr.infer_freq(pd_td_indx) == "D"
     assert xr.infer_freq(xr.DataArray(pd_td_indx)) == "D"
 
diff --git a/xarray/tests/test_groupby.py b/xarray/tests/test_groupby.py
index 2287130e..d42f86f5 100644
--- a/xarray/tests/test_groupby.py
+++ b/xarray/tests/test_groupby.py
@@ -1618,8 +1618,6 @@ class TestDataArrayGroupBy:
         expected = array  # should be a no-op
         assert_identical(expected, actual)
 
-        # TODO: groupby_bins too
-
     def make_groupby_multidim_example_array(self) -> DataArray:
         return DataArray(
             [[[0, 1], [2, 3]], [[5, 10], [15, 20]]],