Repository: Ouranosinc/xclim

Issue:
Circular imports
### Description
<!--Describe what you were trying to get done.
Tell us what happened, what went wrong, and what you expected to happen.-->

`core.missing` imports `indices.generic`, while some indices import `core.missing`, which causes circular import problems. 

The function required in `core.missing` is `select_time`. I suggest it is moved somewhere in `core/`, e.g. utils.py.


Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 13d253ca..378d7eeb 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -6,6 +6,10 @@ History
 -------------------
 Contributors to this version: Trevor James Smith (:user:`Zeitsperre`).
 
+Breaking changes
+^^^^^^^^^^^^^^^^
+* To reduce import complexity, `select_time` has been refactored/moved from ``xclim.indices.generic`` to ``xclim.core.calendar``. (:issue:`949`, :pull:`969`).
+
 Internal changes
 ^^^^^^^^^^^^^^^^
 * Added a CI hook in ``.pre-commit-config.yaml`` to perform automated `pre-commit` corrections with GitHub CI. (:pull:`965`).
diff --git a/setup.cfg b/setup.cfg
index 9f792fc7..705d2b39 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.32.2-beta
+current_version = 0.32.3-beta
 commit = True
 tag = False
 parse = (?P<major>\d+)\.(?P<minor>\d+).(?P<patch>\d+)(\-(?P<release>[a-z]+))?
diff --git a/setup.py b/setup.py
index 221f07dc..3a12d809 100755
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,7 @@ URL = "https://github.com/Ouranosinc/xclim"
 AUTHOR = "Travis Logan"
 AUTHOR_EMAIL = "logan.travis@ouranos.ca"
 REQUIRES_PYTHON = ">=3.7.0"
-VERSION = "0.32.2-beta"
+VERSION = "0.32.3-beta"
 LICENSE = "Apache Software License 2.0"
 
 with open("README.rst") as readme_file:
diff --git a/xclim/__init__.py b/xclim/__init__.py
index 54b308af..926b76d5 100644
--- a/xclim/__init__.py
+++ b/xclim/__init__.py
@@ -10,7 +10,7 @@ from xclim.indicators import atmos, land, seaIce  # noqa
 
 __author__ = """Travis Logan"""
 __email__ = "logan.travis@ouranos.ca"
-__version__ = "0.32.2-beta"
+__version__ = "0.32.3-beta"
 
 
 # Load official locales
diff --git a/xclim/core/bootstrapping.py b/xclim/core/bootstrapping.py
index ef1d80b7..6321a538 100644
--- a/xclim/core/bootstrapping.py
+++ b/xclim/core/bootstrapping.py
@@ -7,7 +7,7 @@ import xarray
 from boltons.funcutils import wraps
 from xarray.core.dataarray import DataArray
 
-from xclim.core.calendar import convert_calendar, parse_offset, percentile_doy
+from .calendar import convert_calendar, parse_offset, percentile_doy
 
 
 def percentile_bootstrap(func):
diff --git a/xclim/core/calendar.py b/xclim/core/calendar.py
index 5d665efc..e5322f13 100644
--- a/xclim/core/calendar.py
+++ b/xclim/core/calendar.py
@@ -8,7 +8,7 @@ Helper function to handle dates, times and different calendars with xarray.
 """
 import datetime as pydt
 import re
-from typing import Any, Optional, Sequence, Tuple, Union
+from typing import Any, NewType, Optional, Sequence, Tuple, Union
 
 import cftime
 import numpy as np
@@ -21,16 +21,43 @@ from xarray.coding.cftime_offsets import (
     QuarterEnd,
     YearBegin,
     YearEnd,
+    to_cftime_datetime,
     to_offset,
 )
 from xarray.coding.cftimeindex import CFTimeIndex
 from xarray.core.resample import DataArrayResample
 
-from xclim.core.formatting import update_xclim_history
-from xclim.core.utils import DayOfYearStr, calc_perc
+from .formatting import update_xclim_history
+
+__all__ = [
+    "DayOfYearStr",
+    "adjust_doy_calendar",
+    "cfindex_end_time",
+    "cfindex_start_time",
+    "cftime_end_time",
+    "cftime_start_time",
+    "climatological_mean_doy",
+    "compare_offsets",
+    "convert_calendar",
+    "date_range",
+    "date_range_like",
+    "datetime_to_decimal_year",
+    "days_in_year",
+    "days_since_to_doy",
+    "doy_to_days_since",
+    "ensure_cftime_array",
+    "get_calendar",
+    "interp_calendar",
+    "max_doy",
+    "parse_offset",
+    "percentile_doy",
+    "resample_doy",
+    "select_time",
+    "time_bnds",
+    "within_bnds_doy",
+    "uniform_calendars",
+]
 
-# cftime and datetime classes to use for each calendar name
-datetime_classes = {"default": pydt.datetime, **cftime._cftime.DATE_TYPES}  # noqa
 
 # Maximum day of year in each calendar.
 max_doy = {
@@ -46,10 +73,34 @@ max_doy = {
     "360_day": 360,
 }
 
+# Some xclim.core.utils functions made accessible here for backwards compatibility reasons.
+datetime_classes = {"default": pydt.datetime, **cftime._cftime.DATE_TYPES}  # noqa
+
+#: Type annotation for strings representing dates without a year (MM-DD).
+DayOfYearStr = NewType("DayOfYearStr", str)
+
 # Names of calendars that have the same number of days for all years
 uniform_calendars = ("noleap", "all_leap", "365_day", "366_day", "360_day")
 
 
+def days_in_year(year: int, calendar: str = "default") -> int:
+    """Return the number of days in the input year according to the input calendar."""
+    return (
+        (datetime_classes[calendar](year + 1, 1, 1) - pydt.timedelta(days=1))
+        .timetuple()
+        .tm_yday
+    )
+
+
+def date_range(
+    *args, calendar: str = "default", **kwargs
+) -> Union[pd.DatetimeIndex, CFTimeIndex]:
+    """Wrap pd.date_range (if calendar == 'default') or xr.cftime_range (otherwise)."""
+    if calendar == "default":
+        return pd.date_range(*args, **kwargs)
+    return xr.cftime_range(*args, calendar=calendar, **kwargs)
+
+
 def get_calendar(obj: Any, dim: str = "time") -> str:
     """Return the calendar of an object.
 
@@ -334,124 +385,6 @@ def interp_calendar(
     return out
 
 
-def date_range(
-    *args, calendar: str = "default", **kwargs
-) -> Union[pd.DatetimeIndex, CFTimeIndex]:
-    """Wrap pd.date_range (if calendar == 'default') or xr.cftime_range (otherwise)."""
-    if calendar == "default":
-        return pd.date_range(*args, **kwargs)
-    return xr.cftime_range(*args, calendar=calendar, **kwargs)
-
-
-def date_range_like(source: xr.DataArray, calendar: str) -> xr.DataArray:
-    """Generate a datetime array with the same frequency, start and end as another one, but in a different calendar.
-
-    Parameters
-    ----------
-    source : xr.DataArray
-      1D datetime coordinate DataArray
-    calendar : str
-      New calendar name.
-
-    Raises
-    ------
-    ValueError
-      If the source's frequency was not found.
-
-    Returns
-    -------
-    xr.DataArray
-      1D datetime coordinate with the same start, end and frequency as the source, but in the new calendar.
-        The start date is assumed to exist in the target calendar.
-        If the end date doesn't exist, the code tries 1 and 2 calendar days before.
-        Exception when the source is in 360_day and the end of the range is the 30th of a 31-days month,
-        then the 31st is appended to the range.
-    """
-    freq = xr.infer_freq(source)
-    if freq is None:
-        raise ValueError(
-            "`date_range_like` was unable to generate a range as the source frequency was not inferrable."
-        )
-
-    src_cal = get_calendar(source)
-    if src_cal == calendar:
-        return source
-
-    index = source.indexes[source.dims[0]]
-    end_src = index[-1]
-    end = _convert_datetime(end_src, calendar=calendar)
-    if end is np.nan:  # Day is invalid, happens at the end of months.
-        end = _convert_datetime(end_src.replace(day=end_src.day - 1), calendar=calendar)
-        if end is np.nan:  # Still invalid : 360_day to non-leap february.
-            end = _convert_datetime(
-                end_src.replace(day=end_src.day - 2), calendar=calendar
-            )
-    if src_cal == "360_day" and end_src.day == 30 and end.daysinmonth == 31:
-        # For the specific case of daily data from 360_day source, the last day is expected to be "missing"
-        end = end.replace(day=31)
-
-    return xr.DataArray(
-        date_range(
-            _convert_datetime(index[0], calendar=calendar),
-            end,
-            freq=freq,
-            calendar=calendar,
-        ),
-        dims=source.dims,
-        name=source.dims[0],
-    )
-
-
-def _convert_datetime(
-    datetime: Union[pydt.datetime, cftime.datetime],
-    new_doy: Optional[Union[float, int]] = None,
-    calendar: str = "default",
-) -> Union[cftime.datetime, pydt.datetime, float]:
-    """Convert a datetime object to another calendar.
-
-    Nanosecond information are lost as cftime.datetime doesn't support them.
-
-    Parameters
-    ----------
-    datetime: Union[datetime.datetime, cftime.datetime]
-      A datetime object to convert.
-    new_doy:  Optional[Union[float, int]]
-      Allows for redefining the day of year (thus ignoring month and day information from the source datetime).
-      -1 is understood as a nan.
-    calendar: str
-      The target calendar
-
-    Returns
-    -------
-    Union[cftime.datetime, datetime.datetime, np.nan]
-      A datetime object of the target calendar with the same year, month, day and time
-      as the source (month and day according to `new_doy` if given).
-      If the month and day doesn't exist in the target calendar, returns np.nan. (Ex. 02-29 in "noleap")
-    """
-    if new_doy in [np.nan, -1]:
-        return np.nan
-    if new_doy is not None:
-        new_date = cftime.num2date(
-            new_doy - 1,
-            f"days since {datetime.year}-01-01",
-            calendar=calendar if calendar != "default" else "standard",
-        )
-    else:
-        new_date = datetime
-    try:
-        return datetime_classes[calendar](
-            datetime.year,
-            new_date.month,
-            new_date.day,
-            datetime.hour,
-            datetime.minute,
-            datetime.second,
-            datetime.microsecond,
-        )
-    except ValueError:
-        return np.nan
-
-
 def ensure_cftime_array(time: Sequence) -> np.ndarray:
     """Convert an input 1D array to a numpy array of cftime objects.
 
@@ -499,15 +432,6 @@ def datetime_to_decimal_year(times: xr.DataArray, calendar: str = "") -> xr.Data
     return times.groupby("time.year").map(_make_index)
 
 
-def days_in_year(year: int, calendar: str = "default") -> int:
-    """Return the number of days in the input year according to the input calendar."""
-    return (
-        (datetime_classes[calendar](year + 1, 1, 1) - pydt.timedelta(days=1))
-        .timetuple()
-        .tm_yday
-    )
-
-
 @update_xclim_history
 def percentile_doy(
     arr: xr.DataArray,
@@ -544,6 +468,7 @@ def percentile_doy(
     ----------
     .. [HyndmanFan] Hyndman, R. J., & Fan, Y. (1996). Sample quantiles in statistical packages. The American Statistician, 50(4), 361-365.
     """
+    from .utils import calc_perc
 
     # Ensure arr sampling frequency is daily or coarser
     # but cowardly escape the non-inferrable case.
@@ -1148,3 +1073,226 @@ def days_since_to_doy(
     )
     out.attrs.update(calendar=calendar, is_dayofyear=1)
     return convert_calendar(out, base_calendar).rename(da.name)
+
+
+def date_range_like(source: xr.DataArray, calendar: str) -> xr.DataArray:
+    """Generate a datetime array with the same frequency, start and end as another one, but in a different calendar.
+
+    Parameters
+    ----------
+    source : xr.DataArray
+      1D datetime coordinate DataArray
+    calendar : str
+      New calendar name.
+
+    Raises
+    ------
+    ValueError
+      If the source's frequency was not found.
+
+    Returns
+    -------
+    xr.DataArray
+      1D datetime coordinate with the same start, end and frequency as the source, but in the new calendar.
+        The start date is assumed to exist in the target calendar.
+        If the end date doesn't exist, the code tries 1 and 2 calendar days before.
+        Exception when the source is in 360_day and the end of the range is the 30th of a 31-days month,
+        then the 31st is appended to the range.
+    """
+    freq = xr.infer_freq(source)
+    if freq is None:
+        raise ValueError(
+            "`date_range_like` was unable to generate a range as the source frequency was not inferrable."
+        )
+
+    src_cal = get_calendar(source)
+    if src_cal == calendar:
+        return source
+
+    index = source.indexes[source.dims[0]]
+    end_src = index[-1]
+    end = _convert_datetime(end_src, calendar=calendar)
+    if end is np.nan:  # Day is invalid, happens at the end of months.
+        end = _convert_datetime(end_src.replace(day=end_src.day - 1), calendar=calendar)
+        if end is np.nan:  # Still invalid : 360_day to non-leap february.
+            end = _convert_datetime(
+                end_src.replace(day=end_src.day - 2), calendar=calendar
+            )
+    if src_cal == "360_day" and end_src.day == 30 and end.daysinmonth == 31:
+        # For the specific case of daily data from 360_day source, the last day is expected to be "missing"
+        end = end.replace(day=31)
+
+    return xr.DataArray(
+        date_range(
+            _convert_datetime(index[0], calendar=calendar),
+            end,
+            freq=freq,
+            calendar=calendar,
+        ),
+        dims=source.dims,
+        name=source.dims[0],
+    )
+
+
+def _convert_datetime(
+    datetime: Union[pydt.datetime, cftime.datetime],
+    new_doy: Optional[Union[float, int]] = None,
+    calendar: str = "default",
+) -> Union[cftime.datetime, pydt.datetime, float]:
+    """Convert a datetime object to another calendar.
+
+    Nanosecond information are lost as cftime.datetime doesn't support them.
+
+    Parameters
+    ----------
+    datetime: Union[datetime.datetime, cftime.datetime]
+      A datetime object to convert.
+    new_doy:  Optional[Union[float, int]]
+      Allows for redefining the day of year (thus ignoring month and day information from the source datetime).
+      -1 is understood as a nan.
+    calendar: str
+      The target calendar
+
+    Returns
+    -------
+    Union[cftime.datetime, datetime.datetime, np.nan]
+      A datetime object of the target calendar with the same year, month, day and time
+      as the source (month and day according to `new_doy` if given).
+      If the month and day doesn't exist in the target calendar, returns np.nan. (Ex. 02-29 in "noleap")
+    """
+    if new_doy in [np.nan, -1]:
+        return np.nan
+    if new_doy is not None:
+        new_date = cftime.num2date(
+            new_doy - 1,
+            f"days since {datetime.year}-01-01",
+            calendar=calendar if calendar != "default" else "standard",
+        )
+    else:
+        new_date = datetime
+    try:
+        return datetime_classes[calendar](
+            datetime.year,
+            new_date.month,
+            new_date.day,
+            datetime.hour,
+            datetime.minute,
+            datetime.second,
+            datetime.microsecond,
+        )
+    except ValueError:
+        return np.nan
+
+
+def select_time(
+    da: Union[xr.DataArray, xr.Dataset],
+    drop: bool = False,
+    season: Union[str, Sequence[str]] = None,
+    month: Union[int, Sequence[int]] = None,
+    doy_bounds: Tuple[int, int] = None,
+    date_bounds: Tuple[str, str] = None,
+):
+    """Select entries according to a time period.
+
+    This conveniently improves xarray's :py:meth:`xarray.DataArray.where` and
+    :py:meth:`xarray.DataArray.sel` with fancier ways of indexing over time elements.
+    In addition to the data `da` and argument `drop`, only one of `season`, `month`,
+    `doy_bounds` or `date_bounds` may be passed.
+
+    Parameters
+    ----------
+    da : xr.DataArray or xr.Dataset
+      Input data.
+    drop: boolean
+      Whether to drop elements outside the period of interest or
+      to simply mask them (default).
+    season: string or sequence of strings
+      One or more of 'DJF', 'MAM', 'JJA' and 'SON'.
+    month: integer or sequence of integers
+      Sequence of month numbers (January = 1 ... December = 12)
+    doy_bounds: 2-tuple of integers
+      The bounds as (start, end) of the period of interest expressed in day-of-year,
+      integers going from 1 (January 1st) to 365 or 366 (December 31st). If calendar
+      awareness is needed, consider using ``date_bounds`` instead.
+      Bounds are inclusive.
+    date_bounds: 2-tuple of strings
+      The bounds as (start, end) of the period of interest expressed as dates in the
+      month-day (%m-%d) format.
+      Bounds are inclusive.
+
+    Returns
+    -------
+    xr.DataArray or xr.Dataset
+      Selected input values. If ``drop=False``, this has the same length as ``da``
+      (along dimension 'time'), but with masked (NaN) values outside the period of
+      interest.
+
+    Examples
+    --------
+    Keep only the values of fall and spring.
+
+    >>> ds = open_dataset("ERA5/daily_surface_cancities_1990-1993.nc")
+    >>> ds.time.size
+    1461
+    >>> out = select_time(ds, drop=True, season=['MAM', 'SON'])
+    >>> out.time.size
+    732
+
+    Or all values between two dates (included).
+
+    >>> out = select_time(ds, drop=True, date_bounds=('02-29', '03-02'))
+    >>> out.time.values
+    array(['1990-03-01T00:00:00.000000000', '1990-03-02T00:00:00.000000000',
+           '1991-03-01T00:00:00.000000000', '1991-03-02T00:00:00.000000000',
+           '1992-02-29T00:00:00.000000000', '1992-03-01T00:00:00.000000000',
+           '1992-03-02T00:00:00.000000000', '1993-03-01T00:00:00.000000000',
+           '1993-03-02T00:00:00.000000000'], dtype='datetime64[ns]')
+    """
+    N = sum(arg is not None for arg in [season, month, doy_bounds, date_bounds])
+    if N > 1:
+        raise ValueError(f"Only one method of indexing may be given, got {N}.")
+
+    if N == 0:
+        return da
+
+    def get_doys(start, end):
+        if start <= end:
+            return np.arange(start, end + 1)
+        return np.concatenate((np.arange(start, 367), np.arange(0, end + 1)))
+
+    if season is not None:
+        if isinstance(season, str):
+            season = [season]
+        mask = da.time.dt.season.isin(season)
+
+    elif month is not None:
+        if isinstance(month, int):
+            month = [month]
+        mask = da.time.dt.month.isin(month)
+
+    elif doy_bounds is not None:
+        mask = da.time.dt.dayofyear.isin(get_doys(*doy_bounds))
+
+    elif date_bounds is not None:
+        # This one is a bit trickier.
+        start, end = date_bounds
+        time = da.time
+        calendar = get_calendar(time)
+        if calendar not in uniform_calendars:
+            # For non-uniform calendars, we can't simply convert dates to doys
+            # conversion to all_leap is safe for all non-uniform calendar as it doesn't remove any date.
+            time = convert_calendar(time, "all_leap")
+            # values of time are the _old_ calendar
+            # and the new calendar is in the coordinate
+            calendar = "all_leap"
+
+        # Get doy of date, this is now safe because the calendar is uniform.
+        doys = get_doys(
+            to_cftime_datetime("2000-" + start, calendar).dayofyr,
+            to_cftime_datetime("2000-" + end, calendar).dayofyr,
+        )
+        mask = time.time.dt.dayofyear.isin(doys)
+        # Needed if we converted calendar, this puts back the correct coord
+        mask["time"] = da.time
+
+    return da.where(mask, drop=drop)
diff --git a/xclim/core/formatting.py b/xclim/core/formatting.py
index 3e3977c2..ee83e25f 100644
--- a/xclim/core/formatting.py
+++ b/xclim/core/formatting.py
@@ -10,7 +10,7 @@ import re
 import string
 from ast import literal_eval
 from fnmatch import fnmatch
-from inspect import _empty
+from inspect import _empty  # noqa
 from typing import Dict, Mapping, Optional, Sequence, Union
 
 import xarray as xr
diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py
index 51350040..3100d98c 100644
--- a/xclim/core/indicator.py
+++ b/xclim/core/indicator.py
@@ -95,7 +95,7 @@ from dataclasses import asdict, dataclass
 from functools import reduce
 from inspect import Parameter as _Parameter
 from inspect import Signature
-from inspect import _empty as _empty_default
+from inspect import _empty as _empty_default  # noqa
 from inspect import signature
 from os import PathLike
 from pathlib import Path
@@ -108,7 +108,7 @@ from yaml import safe_load
 
 from .. import indices
 from . import datachecks
-from .calendar import parse_offset
+from .calendar import parse_offset, select_time
 from .cfchecks import cfcheck_from_name
 from .formatting import (
     AttrFormatter,
@@ -1375,7 +1375,7 @@ class ResamplingIndicatorWithIndexing(ResamplingIndicator):
 
         indxr = params.get("indexer")
         if indxr:
-            das = {k: indices.generic.select_time(da, **indxr) for k, da in das.items()}
+            das = {k: select_time(da, **indxr) for k, da in das.items()}
         return das, params
 
 
diff --git a/xclim/core/missing.py b/xclim/core/missing.py
index 56175a26..536fa1f7 100644
--- a/xclim/core/missing.py
+++ b/xclim/core/missing.py
@@ -29,15 +29,14 @@ from typing import Any, Tuple, Union
 import numpy as np
 import xarray as xr
 
-from xclim.core.calendar import date_range, get_calendar
-from xclim.core.options import (
+from .calendar import date_range, get_calendar, select_time
+from .options import (
     CHECK_MISSING,
     MISSING_METHODS,
     MISSING_OPTIONS,
     OPTIONS,
     register_missing_method,
 )
-from xclim.indices import generic
 
 __all__ = [
     "missing_wmo",
@@ -88,7 +87,7 @@ class MissingBase:
     @staticmethod
     def is_null(da, freq, **indexer):
         """Return a boolean array indicating which values are null."""
-        selected = generic.select_time(da, drop=True, **indexer)
+        selected = select_time(da, drop=True, **indexer)
         if selected.time.size == 0:
             raise ValueError("No data for selected period.")
 
@@ -154,7 +153,7 @@ class MissingBase:
             )
 
             sda = xr.DataArray(data=np.ones(len(t)), coords={"time": t}, dims=("time",))
-            st = generic.select_time(sda, drop=True, **indexer)
+            st = select_time(sda, drop=True, **indexer)
             if freq:
                 count = st.notnull().resample(time=freq).sum(dim="time")
             else:
diff --git a/xclim/core/units.py b/xclim/core/units.py
index 5368f020..31814314 100644
--- a/xclim/core/units.py
+++ b/xclim/core/units.py
@@ -23,6 +23,7 @@ from .options import datacheck
 from .utils import ValidationError
 
 __all__ = [
+    "check_units",
     "convert_units_to",
     "declare_units",
     "infer_sampling_units",
diff --git a/xclim/core/utils.py b/xclim/core/utils.py
index ade22deb..dafd5f50 100644
--- a/xclim/core/utils.py
+++ b/xclim/core/utils.py
@@ -28,7 +28,6 @@ from yaml import safe_dump, safe_load
 
 logger = logging.getLogger("xclim")
 
-
 #: Type annotation for strings representing full dates (YYYY-MM-DD), may include time.
 DateStr = NewType("DateStr", str)
 
diff --git a/xclim/indices/generic.py b/xclim/indices/generic.py
index 19670528..1d793126 100644
--- a/xclim/indices/generic.py
+++ b/xclim/indices/generic.py
@@ -6,21 +6,23 @@ Generic indices submodule
 
 Helper functions for common generic actions done in the computation of indices.
 """
+import warnings
 from typing import Optional, Sequence, Tuple, Union
 
 import cftime
 import numpy as np
 import xarray
 import xarray as xr
-from xarray.coding.cftime_offsets import _MONTH_ABBREVIATIONS, to_cftime_datetime
+from xarray.coding.cftime_offsets import _MONTH_ABBREVIATIONS
 
 from xclim.core.calendar import (
+    DayOfYearStr,
     convert_calendar,
     days_in_year,
     doy_to_days_since,
     get_calendar,
-    uniform_calendars,
 )
+from xclim.core.calendar import select_time as _select_time
 from xclim.core.units import (
     convert_units_to,
     declare_units,
@@ -29,7 +31,6 @@ from xclim.core.units import (
     to_agg_units,
 )
 
-from ..core.utils import DayOfYearStr
 from . import run_length as rl
 
 __all__ = [
@@ -49,7 +50,6 @@ __all__ = [
     "interday_diurnal_temperature_range",
     "last_occurrence",
     "select_resample_op",
-    "select_time",
     "statistics",
     "temperature_sum",
     "threshold_count",
@@ -67,110 +67,20 @@ def select_time(
     doy_bounds: Tuple[int, int] = None,
     date_bounds: Tuple[str, str] = None,
 ):
-    """Select entries according to a time period.
-
-    This conveniently improves xarray's :py:meth:`xarray.DataArray.where` and
-    :py:meth:`xarray.DataArray.sel` with fancier ways of indexing over time elements.
-    In addition to the data `da` and argument `drop`, only one of `season`, `month`,
-    `doy_bounds` or `date_bounds` may be passed.
-
-    Parameters
-    ----------
-    da : xr.DataArray or xr.Dataset
-      Input data.
-    drop: boolean
-      Whether to drop elements outside the period of interest or
-      to simply mask them (default).
-    season: string or sequence of strings
-      One or more of 'DJF', 'MAM', 'JJA' and 'SON'.
-    month: integer or sequence of integers
-      Sequence of month numbers (January = 1 ... December = 12)
-    doy_bounds: 2-tuple of integers
-      The bounds as (start, end) of the period of interest expressed in day-of-year,
-      integers going from 1 (January 1st) to 365 or 366 (December 31st). If calendar
-      awareness is needed, consider using ``date_bounds`` instead.
-      Bounds are inclusive.
-    date_bounds: 2-tuple of strings
-      The bounds as (start, end) of the period of interest expressed as dates in the
-      month-day (%m-%d) format.
-      Bounds are inclusive.
-
-    Returns
-    -------
-    xr.DataArray or xr.Dataset
-      Selected input values. If ``drop=False``, this has the same length as ``da``
-      (along dimension 'time'), but with masked (NaN) values outside the period of
-      interest.
-
-    Examples
-    --------
-    Keep only the values of fall and spring.
-
-    >>> ds = open_dataset("ERA5/daily_surface_cancities_1990-1993.nc")
-    >>> ds.time.size
-    1461
-    >>> out = select_time(ds, drop=True, season=['MAM', 'SON'])
-    >>> out.time.size
-    732
-
-    Or all values between two dates (included).
-
-    >>> out = select_time(ds, drop=True, date_bounds=('02-29', '03-02'))
-    >>> out.time.values
-    array(['1990-03-01T00:00:00.000000000', '1990-03-02T00:00:00.000000000',
-           '1991-03-01T00:00:00.000000000', '1991-03-02T00:00:00.000000000',
-           '1992-02-29T00:00:00.000000000', '1992-03-01T00:00:00.000000000',
-           '1992-03-02T00:00:00.000000000', '1993-03-01T00:00:00.000000000',
-           '1993-03-02T00:00:00.000000000'], dtype='datetime64[ns]')
-    """
-    N = sum(arg is not None for arg in [season, month, doy_bounds, date_bounds])
-    if N > 1:
-        raise ValueError(f"Only one method of indexing may be given, got {N}.")
-
-    if N == 0:
-        return da
-
-    def get_doys(start, end):
-        if start <= end:
-            return np.arange(start, end + 1)
-        return np.concatenate((np.arange(start, 367), np.arange(0, end + 1)))
-
-    if season is not None:
-        if isinstance(season, str):
-            season = [season]
-        mask = da.time.dt.season.isin(season)
-
-    elif month is not None:
-        if isinstance(month, int):
-            month = [month]
-        mask = da.time.dt.month.isin(month)
-
-    elif doy_bounds is not None:
-        mask = da.time.dt.dayofyear.isin(get_doys(*doy_bounds))
-
-    elif date_bounds is not None:
-        # This one is a bit trickier.
-        start, end = date_bounds
-        time = da.time
-        calendar = get_calendar(time)
-        if calendar not in uniform_calendars:
-            # For non-uniform calendars, we can't simply convert dates to doys
-            # conversion to all_leap is safe for all non-uniform calendar as it doesn't remove any date.
-            time = convert_calendar(time, "all_leap")
-            # values of time are the _old_ calendar
-            # and the new calendar is in the coordinate
-            calendar = "all_leap"
-
-        # Get doy of date, this is now safe because the calendar is uniform.
-        doys = get_doys(
-            to_cftime_datetime("2000-" + start, calendar).dayofyr,
-            to_cftime_datetime("2000-" + end, calendar).dayofyr,
-        )
-        mask = time.time.dt.dayofyear.isin(doys)
-        # Needed if we converted calendar, this puts back the correct coord
-        mask["time"] = da.time
-
-    return da.where(mask, drop=drop)
+    """Select entries according to a time period."""
+    warnings.warn(
+        "'select_time()' has moved from `xclim.indices.generic` to `xclim.core.calendar`. "
+        "Please update your scripts accordingly.",
+        DeprecationWarning,
+    )
+    return _select_time(
+        da,
+        drop=drop,
+        season=season,
+        month=month,
+        doy_bounds=doy_bounds,
+        date_bounds=date_bounds,
+    )
 
 
 def select_resample_op(da: xr.DataArray, op: str, freq: str = "YS", **indexer):
diff --git a/xclim/sdba/base.py b/xclim/sdba/base.py
index 1d1d2e21..96b94778 100644
--- a/xclim/sdba/base.py
+++ b/xclim/sdba/base.py
@@ -2,7 +2,6 @@
 Base classes and developper tools
 ---------------------------------
 """
-import warnings
 from inspect import signature
 from types import FunctionType
 from typing import Callable, Mapping, Optional, Sequence, Set, Union
@@ -13,7 +12,7 @@ import numpy as np
 import xarray as xr
 from boltons.funcutils import wraps
 
-from xclim.core.calendar import days_in_year, get_calendar, max_doy, parse_offset
+from xclim.core.calendar import days_in_year, get_calendar
 from xclim.core.options import OPTIONS, SDBA_ENCODE_CF
 from xclim.core.utils import uses_dask
 


Test patch:
diff --git a/xclim/testing/tests/test_generic.py b/xclim/testing/tests/test_generic.py
index de2ab452..a40ef3eb 100644
--- a/xclim/testing/tests/test_generic.py
+++ b/xclim/testing/tests/test_generic.py
@@ -1,11 +1,8 @@
-import cftime
 import numpy as np
-import pandas as pd
 import pytest
 import xarray as xr
 
-import xclim as xc
-from xclim.core.calendar import date_range
+from xclim.core.calendar import date_range, doy_to_days_since, select_time
 from xclim.indices import generic
 
 
@@ -98,8 +95,8 @@ class TestAggregateBetweenDates:
         )
 
         # expected output
-        s = xc.core.calendar.doy_to_days_since(start_std)
-        e = xc.core.calendar.doy_to_days_since(end_std)
+        s = doy_to_days_since(start_std)
+        e = doy_to_days_since(end_std)
         expected = e - s
         expected = xr.where(((s > e) | (s.isnull()) | (e.isnull())), np.nan, expected)
 
@@ -151,8 +148,8 @@ class TestAggregateBetweenDates:
         out = generic.aggregate_between_dates(data, start, end, op="sum", freq="YS")
 
         # expected output
-        s = xc.core.calendar.doy_to_days_since(start)
-        e = xc.core.calendar.doy_to_days_since(end)
+        s = doy_to_days_since(start)
+        e = doy_to_days_since(end)
         expected = e - s
         expected[1, 1] = np.nan
 
@@ -222,8 +219,8 @@ class TestAggregateBetweenDates:
         out = generic.aggregate_between_dates(data, start, end, op="sum", freq="QS-DEC")
 
         # expected output
-        s = xc.core.calendar.doy_to_days_since(start)
-        e = xc.core.calendar.doy_to_days_since(end)
+        s = doy_to_days_since(start)
+        e = doy_to_days_since(end)
         expected = e - s
         expected = xr.where(expected < 0, np.nan, expected)
 
@@ -316,7 +313,7 @@ def series(start, end, calendar):
 def test_select_time_month():
     da = series("1993-01-05", "1994-12-31", "default")
 
-    out = generic.select_time(da, drop=True, month=1)
+    out = select_time(da, drop=True, month=1)
     exp = xr.concat(
         (
             series("1993-01-05", "1993-01-31", "default"),
@@ -326,12 +323,12 @@ def test_select_time_month():
     )
     xr.testing.assert_equal(out, exp)
 
-    out = generic.select_time(da, month=1)
+    out = select_time(da, month=1)
     xr.testing.assert_equal(out.time, da.time)
     assert out.sum() == 58
 
     da = series("1993-01-05", "1994-12-30", "360_day")
-    out = generic.select_time(da, drop=True, month=[3, 6])
+    out = select_time(da, drop=True, month=[3, 6])
     exp = xr.concat(
         (
             series("1993-03-01", "1993-03-30", "360_day"),
@@ -347,7 +344,7 @@ def test_select_time_month():
 def test_select_time_season():
     da = series("1993-01-05", "1994-12-31", "default")
 
-    out = generic.select_time(da, drop=True, season="DJF")
+    out = select_time(da, drop=True, season="DJF")
     exp = xr.concat(
         (
             series("1993-01-05", "1993-02-28", "default"),
@@ -359,7 +356,7 @@ def test_select_time_season():
     xr.testing.assert_equal(out, exp)
 
     da = series("1993-01-05", "1994-12-31", "365_day")
-    out = generic.select_time(da, drop=True, season=["MAM", "SON"])
+    out = select_time(da, drop=True, season=["MAM", "SON"])
     exp = xr.concat(
         (
             series("1993-03-01", "1993-05-31", "365_day"),
@@ -375,7 +372,7 @@ def test_select_time_season():
 def test_select_time_doys():
     da = series("2003-02-13", "2004-12-31", "default")
 
-    out = generic.select_time(da, drop=True, doy_bounds=(360, 75))
+    out = select_time(da, drop=True, doy_bounds=(360, 75))
     exp = xr.concat(
         (
             series("2003-02-13", "2003-03-16", "default"),
@@ -388,7 +385,7 @@ def test_select_time_doys():
 
     da = series("2003-02-13", "2004-12-31", "proleptic_gregorian")
 
-    out = generic.select_time(da, drop=True, doy_bounds=(25, 80))
+    out = select_time(da, drop=True, doy_bounds=(25, 80))
     exp = xr.concat(
         (
             series("2003-02-13", "2003-03-21", "proleptic_gregorian"),
@@ -403,7 +400,7 @@ def test_select_time_dates():
     da = series("2003-02-13", "2004-11-01", "all_leap")
     da = da.where(da.time.dt.dayofyear != 92, drop=True)  # no 04-01
 
-    out = generic.select_time(da, drop=True, date_bounds=("04-01", "12-04"))
+    out = select_time(da, drop=True, date_bounds=("04-01", "12-04"))
     exp = xr.concat(
         (
             series("2003-04-02", "2003-12-04", "all_leap"),
@@ -415,7 +412,7 @@ def test_select_time_dates():
 
     da = series("2003-02-13", "2005-11-01", "standard")
 
-    out = generic.select_time(da, drop=True, date_bounds=("10-05", "02-29"))
+    out = select_time(da, drop=True, date_bounds=("10-05", "02-29"))
     exp = xr.concat(
         (
             series("2003-02-13", "2003-02-28", "standard"),
@@ -431,16 +428,16 @@ def test_select_time_dates():
 def test_select_time_errors():
     da = series("2003-01-01", "2004-01-01", "standard")
 
-    xr.testing.assert_identical(da, generic.select_time(da))
+    xr.testing.assert_identical(da, select_time(da))
 
     with pytest.raises(ValueError, match="Only one method of indexing may be given"):
-        generic.select_time(da, season="DJF", month=[3, 4, 5])
+        select_time(da, season="DJF", month=[3, 4, 5])
 
     with pytest.raises(ValueError, match="invalid day number provided in cftime."):
-        generic.select_time(da, date_bounds=("02-30", "03-03"))
+        select_time(da, date_bounds=("02-30", "03-03"))
 
     with pytest.raises(ValueError):
-        generic.select_time(da, date_bounds=("02-30",))
+        select_time(da, date_bounds=("02-30",))
 
     with pytest.raises(TypeError):
-        generic.select_time(da, doy_bounds=(300, 203, 202))
+        select_time(da, doy_bounds=(300, 203, 202))
diff --git a/xclim/testing/tests/test_indicators.py b/xclim/testing/tests/test_indicators.py
index e0267dc9..c7dd494d 100644
--- a/xclim/testing/tests/test_indicators.py
+++ b/xclim/testing/tests/test_indicators.py
@@ -13,6 +13,7 @@ import xarray as xr
 
 import xclim
 from xclim import __version__, atmos
+from xclim.core.calendar import select_time
 from xclim.core.formatting import (
     AttrFormatter,
     default_formatter,
@@ -24,7 +25,6 @@ from xclim.core.indicator import Daily, Indicator, ResamplingIndicator, registry
 from xclim.core.units import convert_units_to, declare_units, units
 from xclim.core.utils import InputKind, MissingVariableError
 from xclim.indices import tg_mean
-from xclim.indices.generic import select_time
 from xclim.testing import open_dataset