Repository: ESMValGroup/ESMValCore

Issue:
regrid_time does not have promised functionality
The stated goal of `regrid_time` is 'Align time axis for cubes so they can be subtracted', but it does not accomplish this goal. It takes two inputs: a `cube`, and a `frequency`. Since it does not accept two cubes, I imagine it's meant to simply remove evidence of time information that could conflict between cubes, specified by `frequency`.

I would like to subtract the value at one month from another. To accomplish this, I call `extract_month` using two different months. Then, I understand I should apply `regrid_time` to each cube, and I imagine the correct choice of frequency is `yr`, though it's not perfectly clear from the documentation. When I follow this procedure, I am still unable to subtract one from the other, getting the error `Coordinate 'month_number' has different points for the LHS cube 'surface_temperature' and RHS cube 'surface_temperature'`.

Examining one cube at a time, I see that using `extract_month` creates an Auxiliary coordinate called `month_number`. No matter what `frequency` I use, `regrid_time` adds additional coordinates `day_of_month` and `day_of_year` rather than removing auxiliary coordinate `month_number`. 

@valeriupredoi? :upside_down_face: 


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2311?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) Report
Attention: `1 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`989839f`)](https://app.codecov.io/gh/ESMValGroup/ESMValCore/commit/989839f596a6b4433731c0efcb6b5cb4f3ca7045?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) 93.74% compared to head [(`a233b64`)](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2311?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) 93.74%.

| [Files](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) | Patch % | Lines |
|---|---|---|
| [esmvalcore/preprocessor/\_time.py](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup#diff-ZXNtdmFsY29yZS9wcmVwcm9jZXNzb3IvX3RpbWUucHk=) | 97.67% | [1 Missing :warning: ](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) |

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


```diff
@@           Coverage Diff           @@
##             main    #2311   +/-   ##
=======================================
  Coverage   93.74%   93.74%           
=======================================
  Files         238      238           
  Lines       13153    13171   +18     
=======================================
+ Hits        12330    12347   +17     
- Misses        823      824    +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2311?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup).   
: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=ESMValGroup).

schlunma: No, the other frequencies also have +/- 1/2 of the input frequency as bounds, see here:

```py
        if 'dec' in freq:
            min_bound = datetime(date.year - 5, 1, 1, 0, 0)
            max_bound = datetime(date.year + 5, 1, 1, 0, 0)
        elif 'yr' in freq:
            min_bound = datetime(date.year, 1, 1, 0, 0)
            max_bound = datetime(date.year + 1, 1, 1, 0, 0)
        elif 'mon' in freq or freq == 'mo':
            next_month, next_year = get_next_month(date.month, date.year)
            min_bound = datetime(date.year, date.month, 1, 0, 0)
            max_bound = datetime(next_year, next_month, 1, 0, 0)
        elif 'day' in freq:
            min_bound = date - timedelta(hours=12.0)
            max_bound = date + timedelta(hours=12.0)
        elif 'hr' in freq:
            (n_hours_str, _, _) = freq.partition('hr')
            if not n_hours_str:
                n_hours = 1
            else:
                n_hours = int(n_hours_str)
            if 24 % n_hours:
                raise NotImplementedError(
                    f"For `n`-hourly data, `n` must be a divisor of 24, got "
                    f"'{freq}'"
                )
            min_bound = date - timedelta(hours=n_hours / 2.0)
            max_bound = date + timedelta(hours=n_hours / 2.0)
```

E.g, for year it's 1 Jan for the current year and 1 Jan for the next year, etc. So I think decadal data should be +/-5 years as well.
valeriupredoi: you're totally right, Manu! My silly overstepping on time in me head :horse: Will merge this then, let me give it another once over
valeriupredoi: good to merge! Would you like to get a review from @axel-lauer as well? I see him listed in the reviewers' list
schlunma: Yes, if he's fine with that! I think you already tested that, right @axel-lauer?
valeriupredoi: great stuff, cheers, guys!

Patch:
diff --git a/doc/recipe/preprocessor.rst b/doc/recipe/preprocessor.rst
index 4d7ef23c5..972d49001 100644
--- a/doc/recipe/preprocessor.rst
+++ b/doc/recipe/preprocessor.rst
@@ -1248,8 +1248,7 @@ The ``_time.py`` module contains the following preprocessor functions:
 * resample_time_: Resample data
 * resample_hours_: Convert between N-hourly frequencies by resampling
 * anomalies_: Compute (standardized) anomalies
-* regrid_time_: Aligns the time axis of each dataset to have common time
-  points and calendars.
+* regrid_time_: Aligns the time coordinate of each dataset, against a standardized time axis.
 * timeseries_filter_: Allows application of a filter to the time-series data.
 * local_solar_time_: Convert cube with UTC time to local solar time.
 
@@ -1642,13 +1641,59 @@ See also :func:`esmvalcore.preprocessor.anomalies`.
 ``regrid_time``
 ---------------
 
-This function aligns the time points of each component dataset so that the Iris
-cubes from different datasets can be subtracted. The operation makes the
-datasets time points common; it also resets the time
-bounds and auxiliary coordinates to reflect the artificially shifted time
-points. Current implementation for monthly and daily data; the ``frequency`` is
-set automatically from the variable CMOR table unless a custom ``frequency`` is
-set manually by the user in recipe.
+This function aligns the time points and bounds of an input dataset according
+to the following rules:
+
+* Decadal data: 1 January 00:00:00 for the given year.
+  Example: 1 January 2005 00:00:00 for given year 2005 (decade 2000-2010).
+* Yearly data: 1 July 00:00:00 for each year.
+  Example: 1 July 1993 00:00:00 for the year 1993.
+* Monthly data: 15th day 00:00:00 for each month.
+  Example: 15 October 1993 00:00:00 for the month October 1993.
+* Daily data: 12:00:00 for each day.
+  Example: 14 March 1996 12:00:00 for the day 14 March 1996.
+* `n`-hourly data where `n` is a divisor of 24: center of each time interval.
+  Example: 03:00:00 for interval 00:00:00-06:00:00 (6-hourly data), 16:30:00
+  for interval 15:00:00-18:00:00 (3-hourly data), or 09:30:00 for interval
+  09:00:00-10:00:00 (hourly data).
+
+The frequency of the input data is automatically determined from the CMOR table
+of the corresponding variable, but can be overwritten in the recipe if
+necessary.
+This function does not alter the data in any way.
+
+.. note::
+
+  By default, this preprocessor will not change the calendar of the input time
+  coordinate.
+  For decadal, yearly, and monthly data, it is possible to change the calendar
+  using the optional `calendar` argument.
+  Be aware that changing the calendar might introduce (small) errors to your
+  data, especially for extensive quantities (those that depend on the period
+  length).
+
+Parameters:
+    * `frequency`: Data frequency.
+      If not given, use the one from the CMOR tables of the corresponding
+      variable.
+    * `calendar`: If given, transform the calendar to the one specified
+      (examples: `standard`, `365_day`, etc.).
+      This only works for decadal, yearly and monthly data, and will raise an
+      error for other frequencies.
+      If not set, the calendar will not be changed.
+    * `units` (default: `days since 1850-01-01 00:00:00`): Reference time units
+      used if the calendar of the data is changed.
+      Ignored if `calendar` is not set.
+
+Examples:
+
+Change the input calendar to `standard` and use custom units:
+
+.. code-block:: yaml
+
+  regrid_time:
+    calendar: standard
+    units: days since 2000-01-01
 
 See also :func:`esmvalcore.preprocessor.regrid_time`.
 
diff --git a/esmvalcore/_recipe/recipe.py b/esmvalcore/_recipe/recipe.py
index ea4286937..e0423fd49 100644
--- a/esmvalcore/_recipe/recipe.py
+++ b/esmvalcore/_recipe/recipe.py
@@ -147,13 +147,11 @@ def _update_target_grid(dataset, datasets, settings):
             _spec_to_latlonvals(**target_grid)
 
 
-def _update_regrid_time(dataset, settings):
+def _update_regrid_time(dataset: Dataset, settings: dict) -> None:
     """Input data frequency automatically for regrid_time preprocessor."""
-    regrid_time = settings.get('regrid_time')
-    if regrid_time is None:
+    if 'regrid_time' not in settings:
         return
-    frequency = settings.get('regrid_time', {}).get('frequency')
-    if not frequency:
+    if 'frequency' not in settings['regrid_time']:
         settings['regrid_time']['frequency'] = dataset.facets['frequency']
 
 
diff --git a/esmvalcore/cmor/_fixes/shared.py b/esmvalcore/cmor/_fixes/shared.py
index b658fa5ca..3ec14d05a 100644
--- a/esmvalcore/cmor/_fixes/shared.py
+++ b/esmvalcore/cmor/_fixes/shared.py
@@ -1,7 +1,7 @@
 """Shared functions for fixes."""
 import logging
 import os
-from datetime import datetime
+from datetime import datetime, timedelta
 from functools import lru_cache
 
 import dask.array as da
@@ -453,12 +453,12 @@ def get_next_month(month: int, year: int) -> tuple[int, int]:
 def get_time_bounds(time: Coord, freq: str) -> np.ndarray:
     """Get bounds for time coordinate.
 
-    For monthly data, use the first day of the current month and the first day
-    of the next month. For yearly or decadal data, use 1 January of the current
-    year and 1 January of the next year or 10 years from the current year. For
-    other frequencies (daily, 6-hourly, 3-hourly, hourly), half of the
-    frequency is subtracted/added from the current point in time to get the
-    bounds.
+    For decadal data, use 1 January 5 years before/after the current year. For
+    yearly data, use 1 January of the current year and 1 January of the next
+    year. For monthly data, use the first day of the current month and the
+    first day of the next month. For other frequencies (daily or `n`-hourly,
+    where `n` is a divisor of 24), half of the frequency is subtracted/added
+    from the current point in time to get the bounds.
 
     Parameters
     ----------
@@ -480,39 +480,38 @@ def get_time_bounds(time: Coord, freq: str) -> np.ndarray:
     """
     bounds = []
     dates = time.units.num2date(time.points)
-    for step, date in enumerate(dates):
-        month = date.month
-        year = date.year
-        if freq in ['mon', 'mo']:
-            next_month, next_year = get_next_month(month, year)
-            min_bound = date2num(datetime(year, month, 1, 0, 0),
-                                 time.units, time.dtype)
-            max_bound = date2num(datetime(next_year, next_month, 1, 0, 0),
-                                 time.units, time.dtype)
-        elif freq == 'yr':
-            min_bound = date2num(datetime(year, 1, 1, 0, 0),
-                                 time.units, time.dtype)
-            max_bound = date2num(datetime(year + 1, 1, 1, 0, 0),
-                                 time.units, time.dtype)
-        elif freq == 'dec':
-            min_bound = date2num(datetime(year, 1, 1, 0, 0),
-                                 time.units, time.dtype)
-            max_bound = date2num(datetime(year + 10, 1, 1, 0, 0),
-                                 time.units, time.dtype)
-        else:
-            delta = {
-                'day': 12.0 / 24,
-                '6hr': 3.0 / 24,
-                '3hr': 1.5 / 24,
-                '1hr': 0.5 / 24,
-            }
-            if freq not in delta:
+
+    for date in dates:
+        if 'dec' in freq:
+            min_bound = datetime(date.year - 5, 1, 1, 0, 0)
+            max_bound = datetime(date.year + 5, 1, 1, 0, 0)
+        elif 'yr' in freq:
+            min_bound = datetime(date.year, 1, 1, 0, 0)
+            max_bound = datetime(date.year + 1, 1, 1, 0, 0)
+        elif 'mon' in freq or freq == 'mo':
+            next_month, next_year = get_next_month(date.month, date.year)
+            min_bound = datetime(date.year, date.month, 1, 0, 0)
+            max_bound = datetime(next_year, next_month, 1, 0, 0)
+        elif 'day' in freq:
+            min_bound = date - timedelta(hours=12.0)
+            max_bound = date + timedelta(hours=12.0)
+        elif 'hr' in freq:
+            (n_hours_str, _, _) = freq.partition('hr')
+            if not n_hours_str:
+                n_hours = 1
+            else:
+                n_hours = int(n_hours_str)
+            if 24 % n_hours:
                 raise NotImplementedError(
-                    f"Cannot guess time bounds for frequency '{freq}'"
+                    f"For `n`-hourly data, `n` must be a divisor of 24, got "
+                    f"'{freq}'"
                 )
-            point = time.points[step]
-            min_bound = point - delta[freq]
-            max_bound = point + delta[freq]
+            min_bound = date - timedelta(hours=n_hours / 2.0)
+            max_bound = date + timedelta(hours=n_hours / 2.0)
+        else:
+            raise NotImplementedError(
+                f"Cannot guess time bounds for frequency '{freq}'"
+            )
         bounds.append([min_bound, max_bound])
 
-    return np.array(bounds)
+    return date2num(np.array(bounds), time.units, time.dtype)
diff --git a/esmvalcore/preprocessor/_time.py b/esmvalcore/preprocessor/_time.py
index ea3aec861..c6e302741 100644
--- a/esmvalcore/preprocessor/_time.py
+++ b/esmvalcore/preprocessor/_time.py
@@ -994,82 +994,176 @@ def _get_period_coord(cube, period, seasons):
     raise ValueError(f"Period '{period}' not supported")
 
 
-def regrid_time(cube: Cube, frequency: str) -> Cube:
-    """Align time axis for cubes so they can be subtracted.
+def regrid_time(
+    cube: Cube,
+    frequency: str,
+    calendar: Optional[str] = None,
+    units: str = 'days since 1850-01-01 00:00:00',
+) -> Cube:
+    """Align time coordinate for cubes.
+
+    Sets datetimes to common values:
+
+    * Decadal data (e.g., ``frequency='dec'``): 1 January 00:00:00 for the
+      given year. Example: 1 January 2005 00:00:00 for given year 2005 (decade
+      2000-2010).
+    * Yearly data (e.g., ``frequency='yr'``): 1 July 00:00:00 for each year.
+      Example: 1 July 1993 00:00:00 for the year 1993.
+    * Monthly data (e.g., ``frequency='mon'``): 15th day 00:00:00 for each
+      month. Example: 15 October 1993 00:00:00 for the month October 1993.
+    * Daily data (e.g., ``frequency='day'``): 12:00:00 for each day. Example:
+      14 March 1996 12:00:00 for the day 14 March 1996.
+    * `n`-hourly data where `n` is a divisor of 24
+      (e.g., ``frequency='3hr'``): center of each time interval. Example:
+      03:00:00 for interval 00:00:00-06:00:00 (6-hourly data), 16:30:00 for
+      interval 15:00:00-18:00:00 (3-hourly data), or 09:30:00 for interval
+      09:00:00-10:00:00 (hourly data).
+
+    The corresponding time bounds will be set according to the rules described
+    in :func:`esmvalcore.cmor.fixes.get_time_bounds`. The data type of the new
+    time coordinate will be set to `float64` (CMOR default for coordinates).
+    Potential auxiliary time coordinates (e.g., `day_of_year`) are also changed
+    if present.
+
+    This function does not alter the data in any way.
 
-    Operations on time units, time points and auxiliary
-    coordinates so that any cube from cubes can be subtracted from any
-    other cube from cubes. Currently this function supports
-    yearly (frequency=yr), monthly (frequency=mon),
-    daily (frequency=day), 6-hourly (frequency=6hr),
-    3-hourly (frequency=3hr) and hourly (frequency=1hr) data time frequencies.
+    Note
+    ----
+    By default, this will not change the calendar of the input time coordinate.
+    For decadal, yearly, and monthly data, it is possible to change the
+    calendar using the `calendar` argument. Be aware that changing the calendar
+    might introduce (small) errors to your data, especially for extensive
+    quantities (those that depend on the period length).
 
     Parameters
     ----------
     cube:
-        Input cube.
+        Input cube. This input cube will not be modified.
     frequency:
-        Data frequency: `mon`, `day`, `1hr`, `3hr` or `6hr`.
+        Data frequency. Allowed are
+
+        * Decadal data (`frequency` must include `dec`, e.g., `dec`)
+        * Yearly data (`frequency` must include `yr`, e.g., `yrPt`)
+        * Monthly data (`frequency` must include `mon`, e.g., `monC`)
+        * Daily data (`frequency` must include `day`, e.g., `day`)
+        * `n`-hourly data, where `n` must be a divisor of 24 (`frequency` must
+          include `nhr`, e.g., `6hrPt`)
+    calendar:
+        If given, transform the calendar to the one specified (examples:
+        `standard`, `365_day`, etc.). This only works for decadal, yearly and
+        monthly data, and will raise an error for other frequencies. If not
+        set, the calendar will not be changed.
+    units:
+        Reference time units used if the calendar of the data is changed.
+        Ignored if `calendar` is not set.
 
     Returns
     -------
     iris.cube.Cube
-        Cube with converted time axis and units.
+        Cube with converted time coordinate.
+
+    Raises
+    ------
+    NotImplementedError
+        An invalid `frequency` is given or `calendar` is set for a
+        non-supported frequency.
 
     """
-    # standardize time points
+    # Do not overwrite input cube
+    cube = cube.copy()
     coord = cube.coord('time')
-    time_c = coord.units.num2date(coord.points)
-    if frequency == 'yr':
-        time_cells = [datetime.datetime(t.year, 7, 1, 0, 0, 0) for t in time_c]
-    elif frequency == 'mon':
-        time_cells = [
-            datetime.datetime(t.year, t.month, 15, 0, 0, 0) for t in time_c
-        ]
-    elif frequency == 'day':
-        time_cells = [
-            datetime.datetime(t.year, t.month, t.day, 0, 0, 0) for t in time_c
-        ]
-    elif frequency == '1hr':
-        time_cells = [
-            datetime.datetime(t.year, t.month, t.day, t.hour, 0, 0)
-            for t in time_c
+
+    # Raise an error if calendar is used for a non-supported frequency
+    if calendar is not None and ('day' in frequency or 'hr' in frequency):
+        raise NotImplementedError(
+            f"Setting a fixed calendar is not supported for frequency "
+            f"'{frequency}'"
+        )
+
+    # Setup new time coordinate
+    new_dates = _get_new_dates(frequency, coord)
+    if calendar is not None:
+        new_coord = DimCoord(
+            coord.points,
+            standard_name='time',
+            long_name='time',
+            var_name='time',
+            units=Unit(units, calendar=calendar),
+        )
+    else:
+        new_coord = coord
+    new_coord.points = date2num(new_dates, new_coord.units, np.float64)
+    new_coord.bounds = get_time_bounds(new_coord, frequency)
+
+    # Replace old time coordinate with new one
+    time_dims = cube.coord_dims(coord)
+    cube.remove_coord(coord)
+    cube.add_dim_coord(new_coord, time_dims)
+
+    # Adapt auxiliary time coordinates if necessary
+    aux_coord_names = [
+        'day_of_month',
+        'day_of_year',
+        'hour',
+        'month',
+        'month_fullname',
+        'month_number',
+        'season',
+        'season_number',
+        'season_year',
+        'weekday',
+        'weekday_fullname',
+        'weekday_number',
+        'year',
+    ]
+    for coord_name in aux_coord_names:
+        if cube.coords(coord_name):
+            cube.remove_coord(coord_name)
+            getattr(iris.coord_categorisation, f'add_{coord_name}')(
+                cube, new_coord
+            )
+
+    return cube
+
+
+def _get_new_dates(frequency: str, coord: Coord) -> list[datetime.datetime]:
+    """Get transformed dates."""
+    dates = coord.units.num2date(coord.points)
+
+    if 'dec' in frequency:
+        dates = [datetime.datetime(d.year, 1, 1, 0, 0, 0) for d in dates]
+    elif 'yr' in frequency:
+        dates = [datetime.datetime(d.year, 7, 1, 0, 0, 0) for d in dates]
+    elif 'mon' in frequency:
+        dates = [
+            datetime.datetime(d.year, d.month, 15, 0, 0, 0) for d in dates
         ]
-    elif frequency == '3hr':
-        time_cells = [
-            datetime.datetime(
-                t.year, t.month, t.day, t.hour - t.hour % 3, 0, 0)
-            for t in time_c
+    elif 'day' in frequency:
+        dates = [
+            datetime.datetime(d.year, d.month, d.day, 12, 0, 0) for d in dates
         ]
-    elif frequency == '6hr':
-        time_cells = [
+    elif 'hr' in frequency:
+        (n_hours_str, _, _) = frequency.partition('hr')
+        if not n_hours_str:
+            n_hours = 1
+        else:
+            n_hours = int(n_hours_str)
+        if 24 % n_hours:
+            raise NotImplementedError(
+                f"For `n`-hourly data, `n` must be a divisor of 24, got "
+                f"'{frequency}'"
+            )
+        half_interval = datetime.timedelta(hours=n_hours / 2.0)
+        dates = [
             datetime.datetime(
-                t.year, t.month, t.day, t.hour - t.hour % 6, 0, 0)
-            for t in time_c
+                d.year, d.month, d.day, d.hour - d.hour % n_hours, 0, 0
+            ) + half_interval
+            for d in dates
         ]
+    else:
+        raise NotImplementedError(f"Frequency '{frequency}' is not supported")
 
-    coord = cube.coord('time')
-    cube.coord('time').points = date2num(time_cells, coord.units, coord.dtype)
-
-    # uniformize bounds
-    cube.coord('time').bounds = None
-    cube.coord('time').bounds = get_time_bounds(cube.coord('time'), frequency)
-
-    # remove aux coords that will differ
-    reset_aux = ['day_of_month', 'day_of_year']
-    for auxcoord in cube.aux_coords:
-        if auxcoord.long_name in reset_aux:
-            cube.remove_coord(auxcoord)
-
-    # re-add the converted aux coords
-    iris.coord_categorisation.add_day_of_month(cube,
-                                               cube.coord('time'),
-                                               name='day_of_month')
-    iris.coord_categorisation.add_day_of_year(cube,
-                                              cube.coord('time'),
-                                              name='day_of_year')
-
-    return cube
+    return dates
 
 
 def low_pass_weights(window, cutoff):


Test patch:
diff --git a/tests/integration/cmor/_fixes/test_shared.py b/tests/integration/cmor/_fixes/test_shared.py
index 8158865aa..b51b3839c 100644
--- a/tests/integration/cmor/_fixes/test_shared.py
+++ b/tests/integration/cmor/_fixes/test_shared.py
@@ -632,7 +632,7 @@ def test_fix_ocean_depth_coord():
 def time_coord():
     """Time coordinate."""
     time_coord = AuxCoord(
-        [15, 350],
+        [15.0, 350.0],
         standard_name='time',
         units='days since 1850-01-01'
     )
@@ -644,12 +644,23 @@ def time_coord():
     [
         ('mon', [[0, 31], [334, 365]]),
         ('mo', [[0, 31], [334, 365]]),
+        ('monC', [[0, 31], [334, 365]]),
         ('yr', [[0, 365], [0, 365]]),
-        ('dec', [[0, 3652], [0, 3652]]),
+        ('yrPt', [[0, 365], [0, 365]]),
+        ('dec', [[-1826, 1826], [-1826, 1826]]),
         ('day', [[14.5, 15.5], [349.5, 350.5]]),
+        ('24hr', [[14.5, 15.5], [349.5, 350.5]]),
+        ('12hr', [[14.75, 15.25], [349.75, 350.25]]),
+        ('8hr', [[14.83333333, 15.16666667], [349.83333333, 350.16666667]]),
         ('6hr', [[14.875, 15.125], [349.875, 350.125]]),
+        ('6hrCM', [[14.875, 15.125], [349.875, 350.125]]),
+        ('4hr', [[14.91666667, 15.08333333], [349.91666667, 350.08333333]]),
         ('3hr', [[14.9375, 15.0625], [349.9375, 350.0625]]),
+        ('3hrPt', [[14.9375, 15.0625], [349.9375, 350.0625]]),
+        ('2hr', [[14.95833333, 15.04166667], [349.95833333, 350.04166667]]),
         ('1hr', [[14.97916666, 15.020833333], [349.97916666, 350.020833333]]),
+        ('1hrC', [[14.97916666, 15.020833333], [349.97916666, 350.020833333]]),
+        ('hr', [[14.97916666, 15.020833333], [349.97916666, 350.020833333]]),
     ]
 )
 def test_get_time_bounds(time_coord, freq, expected_bounds):
@@ -660,5 +671,14 @@ def test_get_time_bounds(time_coord, freq, expected_bounds):
 
 def test_get_time_bounds_invalid_freq_fail(time_coord):
     """Test ``get_time_bounds`."""
-    with pytest.raises(NotImplementedError):
+    msg = "Cannot guess time bounds for frequency 'invalid_freq'"
+    with pytest.raises(NotImplementedError, match=msg):
         get_time_bounds(time_coord, 'invalid_freq')
+
+
+@pytest.mark.parametrize('freq', ['5hr', '7hrPt', '9hrCM', '10hr', '21hrPt'])
+def test_get_time_bounds_invalid_hr_fail(time_coord, freq):
+    """Test ``get_time_bounds`."""
+    msg = f"For `n`-hourly data, `n` must be a divisor of 24, got '{freq}'"
+    with pytest.raises(NotImplementedError, match=msg):
+        get_time_bounds(time_coord, freq)
diff --git a/tests/unit/preprocessor/_time/test_time.py b/tests/unit/preprocessor/_time/test_time.py
index 03ca53dd2..6cb05a366 100644
--- a/tests/unit/preprocessor/_time/test_time.py
+++ b/tests/unit/preprocessor/_time/test_time.py
@@ -15,6 +15,8 @@ import iris.fileformats
 import numpy as np
 import pytest
 from cf_units import Unit
+from cftime import DatetimeNoLeap
+from iris.common.metadata import DimCoordMetadata
 from iris.cube import Cube
 from numpy.testing import (
     assert_array_almost_equal,
@@ -23,7 +25,6 @@ from numpy.testing import (
 )
 
 import tests
-from esmvalcore.iris_helpers import date2num
 from esmvalcore.preprocessor._time import (
     annual_statistics,
     anomalies,
@@ -1148,344 +1149,231 @@ class TestDailyStatistics(tests.Test):
         assert_array_equal(result.data, expected)
 
 
-class TestRegridTimeYearly(tests.Test):
-    """Tests for regrid_time with monthly frequency."""
-    def setUp(self):
-        """Prepare tests."""
-        self.cube_1 = _create_sample_cube()
-        self.cube_2 = _create_sample_cube()
-        self.cube_2.data = self.cube_2.data * 2.
-        self.cube_2.remove_coord('time')
-        self.cube_1.remove_coord('time')
-        self.cube_1.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(11., 8770., 365.),
-                standard_name='time',
-                units=Unit('days since 1950-01-01 00:00:00',
-                           calendar='gregorian'),
-            ),
-            0,
-        )
-        self.cube_2.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(91., 8851., 365.),
-                standard_name='time',
-                units=Unit('days since 1950-01-01 00:00:00',
-                           calendar='gregorian'),
-            ),
-            0,
-        )
-        add_auxiliary_coordinate([self.cube_1, self.cube_2])
-
-    def test_regrid_time_year(self):
-        """Test changes to cubes."""
-        # test yearly
-        newcube_1 = regrid_time(self.cube_1, frequency='yr')
-        newcube_2 = regrid_time(self.cube_2, frequency='yr')
-        # no changes to core data
-        assert_array_equal(newcube_1.data, self.cube_1.data)
-        assert_array_equal(newcube_2.data, self.cube_2.data)
-        # no changes to number of coords and aux_coords
-        assert len(newcube_1.coords()) == len(self.cube_1.coords())
-        assert len(newcube_1.aux_coords) == len(self.cube_1.aux_coords)
-        # test difference; also diff is zero
-        expected = self.cube_1.data
-        diff_cube = newcube_2 - newcube_1
-        assert_array_equal(diff_cube.data, expected)
-        # test bounds are set at [01-01-YEAR 00:00, 01-01-NEXT_YEAR 00:00]
-        timeunit_1 = newcube_1.coord('time').units
-        for i, time in enumerate(newcube_1.coord('time').points):
-            year_1 = timeunit_1.num2date(time).year
-            expected_minbound = date2num(datetime(year_1, 1, 1),
-                                         timeunit_1)
-            expected_maxbound = date2num(datetime(year_1 + 1, 1, 1),
-                                         timeunit_1)
-            assert_array_equal(
-                newcube_1.coord('time').bounds[i],
-                np.array([expected_minbound, expected_maxbound]))
+@pytest.fixture
+def cube_1d_time():
+    """Simple 1D cube with time coordinate of length one."""
+    units = Unit('days since 2000-01-01', calendar='standard')
+    time_coord = iris.coords.DimCoord(
+        units.date2num(datetime(2024, 1, 26, 14, 57, 28)),
+        bounds=[
+            units.date2num(datetime(2024, 1, 26, 13, 57, 28)),
+            units.date2num(datetime(2024, 1, 26, 15, 57, 28)),
+        ],
+        standard_name='time',
+        attributes={'test': 1},
+        units=units,
+    )
+    cube = Cube([1], var_name='tas', dim_coords_and_dims=[(time_coord, 0)])
+    return cube
 
 
-class TestRegridTimeMonthly(tests.Test):
-    """Tests for regrid_time with monthly frequency."""
-    def setUp(self):
-        """Prepare tests."""
-        self.cube_1 = _create_sample_cube()
-        self.cube_2 = _create_sample_cube()
-        self.cube_2.data = self.cube_2.data * 2.
-        self.cube_2.remove_coord('time')
-        self.cube_2.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(14., 719., 30.),
-                standard_name='time',
-                units=Unit('days since 1950-01-01 00:00:00',
-                           calendar='gregorian'),
-            ),
-            0,
-        )
-        add_auxiliary_coordinate([self.cube_1, self.cube_2])
-
-    def test_regrid_time_mon(self):
-        """Test changes to cubes."""
-        # test monthly
-        newcube_1 = regrid_time(self.cube_1, frequency='mon')
-        newcube_2 = regrid_time(self.cube_2, frequency='mon')
-        # no changes to core data
-        assert_array_equal(newcube_1.data, self.cube_1.data)
-        assert_array_equal(newcube_2.data, self.cube_2.data)
-        # no changes to number of coords and aux_coords
-        assert len(newcube_1.coords()) == len(self.cube_1.coords())
-        assert len(newcube_1.aux_coords) == len(self.cube_1.aux_coords)
-        # test difference; also diff is zero
-        expected = self.cube_1.data
-        diff_cube = newcube_2 - newcube_1
-        assert_array_equal(diff_cube.data, expected)
-        # test bounds are set at
-        # [01-MONTH-YEAR 00:00, 01-NEXT_MONTH-YEAR 00:00]
-        timeunit_1 = newcube_1.coord('time').units
-        for i, time in enumerate(newcube_1.coord('time').points):
-            month_1 = timeunit_1.num2date(time).month
-            year_1 = timeunit_1.num2date(time).year
-            next_month = month_1 + 1
-            next_year = year_1
-            if month_1 == 12:
-                next_month = 1
-                next_year += 1
-            expected_minbound = date2num(datetime(year_1, month_1, 1),
-                                         timeunit_1)
-            expected_maxbound = date2num(datetime(next_year, next_month, 1),
-                                         timeunit_1)
-            assert_array_equal(
-                newcube_1.coord('time').bounds[i],
-                np.array([expected_minbound, expected_maxbound]))
-
-    def test_regrid_time_different_calendar_bounds(self):
-        """Test bounds in different calendars."""
-        cube_360 = _create_sample_cube(calendar='360_day')
-        # Same cubes but differing time units
-        newcube_360 = regrid_time(cube_360, frequency='mon')
-        newcube_gregorian = regrid_time(self.cube_1, frequency='mon')
-        bounds_360 = newcube_360.coord('time').bounds
-        bounds_gregorian = newcube_gregorian.coord('time').bounds
-        # test value of the bounds is not the same
-        assert (bounds_360 != bounds_gregorian).any()
-        # assert length of the 360_day bounds interval is 30 days
-        assert (bounds_360[:, 1] - bounds_360[:, 0] == 30).all()
-
-
-class TestRegridTimeDaily(tests.Test):
-    """Tests for regrid_time with daily frequency."""
-    def setUp(self):
-        """Prepare tests."""
-        self.cube_1 = _create_sample_cube()
-        self.cube_2 = _create_sample_cube()
-        self.cube_2.data = self.cube_2.data * 2.
-        self.cube_1.remove_coord('time')
-        self.cube_2.remove_coord('time')
-        self.cube_1.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(14. * 24. + 6., 38. * 24. + 6., 24.),
-                standard_name='time',
-                units=Unit('hours since 1950-01-01 00:00:00',
-                           calendar='gregorian'),
-            ),
-            0,
-        )
-        self.cube_2.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(14. * 24. + 3., 38. * 24. + 3., 24.),
-                standard_name='time',
-                units=Unit('hours since 1950-01-01 00:00:00',
-                           calendar='gregorian'),
-            ),
-            0,
-        )
-        add_auxiliary_coordinate([self.cube_1, self.cube_2])
-
-    def test_regrid_time_day(self):
-        """Test changes to cubes."""
-        # test daily
-        newcube_1 = regrid_time(self.cube_1, frequency='day')
-        newcube_2 = regrid_time(self.cube_2, frequency='day')
-        # no changes to core data
-        self.assert_array_equal(newcube_1.data, self.cube_1.data)
-        self.assert_array_equal(newcube_2.data, self.cube_2.data)
-        # no changes to number of coords and aux_coords
-        assert len(newcube_1.coords()) == len(self.cube_1.coords())
-        assert len(newcube_1.aux_coords) == len(self.cube_1.aux_coords)
-        # test difference; also diff is zero
-        expected = self.cube_1.data
-        diff_cube = newcube_2 - newcube_1
-        self.assert_array_equal(diff_cube.data, expected)
-        # test bounds are set with a dt = 12/24 days
-        for i, time in enumerate(newcube_1.coord('time').points):
-            expected_minbound = time - 12 / 24
-            expected_maxbound = time + 12 / 24
-            assert_array_equal(
-                newcube_1.coord('time').bounds[i],
-                np.array([expected_minbound, expected_maxbound]))
+@pytest.mark.parametrize(
+    'frequency,calendar,new_date,new_bounds',
+    [
+        ('dec', None, (2024, 1, 1), [(2019, 1, 1), (2029, 1, 1)]),
+        ('dec', '365_day', (2024, 1, 1), [(2019, 1, 1), (2029, 1, 1)]),
+        ('yr', None, (2024, 7, 1), [(2024, 1, 1), (2025, 1, 1)]),
+        ('yr', '365_day', (2024, 7, 1), [(2024, 1, 1), (2025, 1, 1)]),
+        ('yrPt', None, (2024, 7, 1), [(2024, 1, 1), (2025, 1, 1)]),
+        ('yrPt', '365_day', (2024, 7, 1), [(2024, 1, 1), (2025, 1, 1)]),
+        ('mon', None, (2024, 1, 15), [(2024, 1, 1), (2024, 2, 1)]),
+        ('mon', '365_day', (2024, 1, 15), [(2024, 1, 1), (2024, 2, 1)]),
+        ('monC', None, (2024, 1, 15), [(2024, 1, 1), (2024, 2, 1)]),
+        ('monC', '365_day', (2024, 1, 15), [(2024, 1, 1), (2024, 2, 1)]),
+        ('monPt', None, (2024, 1, 15), [(2024, 1, 1), (2024, 2, 1)]),
+        ('monPt', '365_day', (2024, 1, 15), [(2024, 1, 1), (2024, 2, 1)]),
+        ('day', None, (2024, 1, 26, 12), [(2024, 1, 26), (2024, 1, 27)]),
+        ('24hr', None, (2024, 1, 26, 12), [(2024, 1, 26), (2024, 1, 27)]),
+        ('12hr', None, (2024, 1, 26, 18), [(2024, 1, 26, 12), (2024, 1, 27)]),
+        (
+            '8hr',
+            None,
+            (2024, 1, 26, 12),
+            [(2024, 1, 26, 8), (2024, 1, 26, 16)],
+        ),
+        (
+            '6hr',
+            None,
+            (2024, 1, 26, 15),
+            [(2024, 1, 26, 12), (2024, 1, 26, 18)],
+        ),
+        (
+            '6hrPt',
+            None,
+            (2024, 1, 26, 15),
+            [(2024, 1, 26, 12), (2024, 1, 26, 18)],
+        ),
+        (
+            '6hrCM',
+            None,
+            (2024, 1, 26, 15),
+            [(2024, 1, 26, 12), (2024, 1, 26, 18)],
+        ),
+        (
+            '4hr',
+            None,
+            (2024, 1, 26, 14),
+            [(2024, 1, 26, 12), (2024, 1, 26, 16)],
+        ),
+        (
+            '3hr',
+            None,
+            (2024, 1, 26, 13, 30),
+            [(2024, 1, 26, 12), (2024, 1, 26, 15)],
+        ),
+        (
+            '3hrPt',
+            None,
+            (2024, 1, 26, 13, 30),
+            [(2024, 1, 26, 12), (2024, 1, 26, 15)],
+        ),
+        (
+            '3hrCM',
+            None,
+            (2024, 1, 26, 13, 30),
+            [(2024, 1, 26, 12), (2024, 1, 26, 15)],
+        ),
+        (
+            '2hr',
+            None,
+            (2024, 1, 26, 15),
+            [(2024, 1, 26, 14), (2024, 1, 26, 16)],
+        ),
+        (
+            '1hr',
+            None,
+            (2024, 1, 26, 14, 30),
+            [(2024, 1, 26, 14), (2024, 1, 26, 15)],
+        ),
+        (
+            '1hrPt',
+            None,
+            (2024, 1, 26, 14, 30),
+            [(2024, 1, 26, 14), (2024, 1, 26, 15)],
+        ),
+        (
+            '1hrCM',
+            None,
+            (2024, 1, 26, 14, 30),
+            [(2024, 1, 26, 14), (2024, 1, 26, 15)],
+        ),
+        (
+            'hr',
+            None,
+            (2024, 1, 26, 14, 30),
+            [(2024, 1, 26, 14), (2024, 1, 26, 15)],
+        ),
+    ]
+)
+def test_regrid_time(cube_1d_time, frequency, calendar, new_date, new_bounds):
+    """Test ``regrid_time``."""
+    cube = cube_1d_time.copy()
 
+    new_cube = regrid_time(cube, frequency, calendar=calendar)
 
-class TestRegridTime6Hourly(tests.Test):
-    """Tests for regrid_time with 6-hourly frequency."""
-    def setUp(self):
-        """Prepare tests."""
-        self.cube_1 = _create_sample_cube()
-        self.cube_2 = _create_sample_cube()
-        self.cube_2.data = self.cube_2.data * 2.
-        self.cube_1.remove_coord('time')
-        self.cube_2.remove_coord('time')
-        self.cube_1.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(10. * 6. + 5., 34. * 6. + 5., 6.),
-                standard_name='time',
-                units=Unit('hours since 1950-01-01 00:00:00',
-                           calendar='360_day'),
-            ),
-            0,
-        )
-        self.cube_2.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(10. * 6. + 2., 34. * 6. + 2., 6.),
-                standard_name='time',
-                units=Unit('hours since 1950-01-01 00:00:00',
-                           calendar='360_day'),
-            ),
-            0,
+    assert cube == cube_1d_time
+    assert new_cube.data == cube.data
+    assert new_cube.metadata == cube.metadata
+
+    time = new_cube.coord('time')
+    if calendar is None:
+        assert time.metadata == cube.coord('time').metadata
+    else:
+        assert time.metadata == DimCoordMetadata(
+            'time',
+            'time',
+            'time',
+            Unit('days since 1850-01-01 00:00:00', calendar=calendar),
+            {},
+            None,
+            False,
+            False,
         )
-        add_auxiliary_coordinate([self.cube_1, self.cube_2])
-
-    def test_regrid_time_6hour(self):
-        """Test changes to cubes."""
-        # test 6-hourly
-        newcube_1 = regrid_time(self.cube_1, frequency='6hr')
-        newcube_2 = regrid_time(self.cube_2, frequency='6hr')
-        # no changes to core data
-        self.assert_array_equal(newcube_1.data, self.cube_1.data)
-        self.assert_array_equal(newcube_2.data, self.cube_2.data)
-        # no changes to number of coords and aux_coords
-        assert len(newcube_1.coords()) == len(self.cube_1.coords())
-        assert len(newcube_1.aux_coords) == len(self.cube_1.aux_coords)
-        # test difference; also diff is zero
-        expected = self.cube_1.data
-        diff_cube = newcube_2 - newcube_1
-        self.assert_array_equal(diff_cube.data, expected)
-        # test bounds are set with a dt = 3/24 days
-        for i, time in enumerate(newcube_1.coord('time').points):
-            expected_minbound = time - 3 / 24
-            expected_maxbound = time + 3 / 24
-            assert_array_equal(
-                newcube_1.coord('time').bounds[i],
-                np.array([expected_minbound, expected_maxbound]))
 
+    assert time.points.dtype == np.float64
+    assert time.bounds.dtype == np.float64
+    date = time.units.num2date(time.points)
+    date_bounds = time.units.num2date(time.bounds)
+    if calendar is None:
+        dt_mod = datetime
+    else:
+        dt_mod = DatetimeNoLeap
+    np.testing.assert_array_equal(date, np.array(dt_mod(*new_date)))
+    np.testing.assert_array_equal(
+        date_bounds,
+        np.array([[dt_mod(*new_bounds[0]), dt_mod(*new_bounds[1])]]),
+    )
 
-class TestRegridTime3Hourly(tests.Test):
-    """Tests for regrid_time with 3-hourly frequency."""
-    def setUp(self):
-        """Prepare tests."""
-        self.cube_1 = _create_sample_cube()
-        self.cube_2 = _create_sample_cube()
-        self.cube_2.data = self.cube_2.data * 2.
-        self.cube_1.remove_coord('time')
-        self.cube_2.remove_coord('time')
-        self.cube_1.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(17. * 180. + 40., 41. * 180. + 40., 180.),
-                standard_name='time',
-                units=Unit('minutes since 1950-01-01 00:00:00',
-                           calendar='gregorian'),
-            ),
-            0,
-        )
-        self.cube_2.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(17. * 180. + 150., 41. * 180. + 150., 180.),
-                standard_name='time',
-                units=Unit('minutes since 1950-01-01 00:00:00',
-                           calendar='gregorian'),
-            ),
-            0,
-        )
-        add_auxiliary_coordinate([self.cube_1, self.cube_2])
-
-    def test_regrid_time_3hour(self):
-        """Test changes to cubes."""
-        # test 3-hourly
-        newcube_1 = regrid_time(self.cube_1, frequency='3hr')
-        newcube_2 = regrid_time(self.cube_2, frequency='3hr')
-        # no changes to core data
-        self.assert_array_equal(newcube_1.data, self.cube_1.data)
-        self.assert_array_equal(newcube_2.data, self.cube_2.data)
-        # no changes to number of coords and aux_coords
-        assert len(newcube_1.coords()) == len(self.cube_1.coords())
-        assert len(newcube_1.aux_coords) == len(self.cube_1.aux_coords)
-        # test difference; also diff is zero
-        expected = self.cube_1.data
-        diff_cube = newcube_2 - newcube_1
-        self.assert_array_equal(diff_cube.data, expected)
-        # test bounds are set with a dt = 1.5/24 days
-        for i, time in enumerate(newcube_1.coord('time').points):
-            expected_minbound = time - 1.5 / 24
-            expected_maxbound = time + 1.5 / 24
-            assert_array_equal(
-                newcube_1.coord('time').bounds[i],
-                np.array([expected_minbound, expected_maxbound]))
+    assert not new_cube.coords(dim_coords=False)
+
+
+def test_regrid_time_aux_coords(cube_1d_time):
+    """Test ``regrid_time``."""
+    iris.coord_categorisation.add_day_of_month(cube_1d_time, 'time')
+    iris.coord_categorisation.add_day_of_year(cube_1d_time, 'time')
+    iris.coord_categorisation.add_hour(cube_1d_time, 'time')
+    iris.coord_categorisation.add_month(cube_1d_time, 'time')
+    iris.coord_categorisation.add_month_fullname(cube_1d_time, 'time')
+    iris.coord_categorisation.add_month_number(cube_1d_time, 'time')
+    iris.coord_categorisation.add_season(cube_1d_time, 'time')
+    iris.coord_categorisation.add_season_number(cube_1d_time, 'time')
+    iris.coord_categorisation.add_season_year(cube_1d_time, 'time')
+    iris.coord_categorisation.add_weekday(cube_1d_time, 'time')
+    iris.coord_categorisation.add_weekday_fullname(cube_1d_time, 'time')
+    iris.coord_categorisation.add_weekday_number(cube_1d_time, 'time')
+    iris.coord_categorisation.add_year(cube_1d_time, 'time')
+    cube = cube_1d_time.copy()
+
+    new_cube = regrid_time(cube, 'yr')
+
+    assert cube == cube_1d_time
+    assert new_cube.data == cube.data
+    assert new_cube.metadata == cube.metadata
+
+    np.testing.assert_array_equal(new_cube.coord('day_of_month').points, [1])
+    np.testing.assert_array_equal(new_cube.coord('day_of_year').points, [183])
+    np.testing.assert_array_equal(new_cube.coord('hour').points, [0])
+    np.testing.assert_array_equal(new_cube.coord('month').points, ['Jul'])
+    np.testing.assert_array_equal(
+        new_cube.coord('month_fullname').points, ['July']
+    )
+    np.testing.assert_array_equal(new_cube.coord('month_number').points, [7])
+    np.testing.assert_array_equal(new_cube.coord('season').points, ['jja'])
+    np.testing.assert_array_equal(new_cube.coord('season_number').points, [2])
+    np.testing.assert_array_equal(new_cube.coord('season_year').points, [2024])
+    np.testing.assert_array_equal(new_cube.coord('weekday').points, ['Mon'])
+    np.testing.assert_array_equal(
+        new_cube.coord('weekday_fullname').points, ['Monday']
+    )
+    np.testing.assert_array_equal(new_cube.coord('weekday_number').points, [0])
+    np.testing.assert_array_equal(new_cube.coord('year').points, [2024])
 
 
-class TestRegridTime1Hourly(tests.Test):
-    """Tests for regrid_time with hourly frequency."""
-    def setUp(self):
-        """Prepare tests."""
-        self.cube_1 = _create_sample_cube()
-        self.cube_2 = _create_sample_cube()
-        self.cube_2.data = self.cube_2.data * 2.
-        self.cube_1.remove_coord('time')
-        self.cube_2.remove_coord('time')
-        self.cube_1.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(14. * 60. + 6., 38. * 60. + 6., 60.),
-                standard_name='time',
-                units=Unit('minutes since 1950-01-01 00:00:00',
-                           calendar='360_day'),
-            ),
-            0,
-        )
-        self.cube_2.add_dim_coord(
-            iris.coords.DimCoord(
-                np.arange(14. * 60. + 34., 38. * 60. + 34., 60.),
-                standard_name='time',
-                units=Unit('minutes since 1950-01-01 00:00:00',
-                           calendar='360_day'),
-            ),
-            0,
-        )
-        add_auxiliary_coordinate([self.cube_1, self.cube_2])
-
-    def test_regrid_time_hour(self):
-        """Test changes to cubes."""
-        # test hourly
-        newcube_1 = regrid_time(self.cube_1, frequency='1hr')
-        newcube_2 = regrid_time(self.cube_2, frequency='1hr')
-        # no changes to core data
-        self.assert_array_equal(newcube_1.data, self.cube_1.data)
-        self.assert_array_equal(newcube_2.data, self.cube_2.data)
-        # no changes to number of coords and aux_coords
-        assert len(newcube_1.coords()) == len(self.cube_1.coords())
-        assert len(newcube_1.aux_coords) == len(self.cube_1.aux_coords)
-        # test difference; also diff is zero
-        expected = self.cube_1.data
-        diff_cube = newcube_2 - newcube_1
-        self.assert_array_equal(diff_cube.data, expected)
-        # test bounds are set with a dt = 0.5/24 days
-        for i, time in enumerate(newcube_1.coord('time').points):
-            expected_minbound = time - 0.5 / 24
-            expected_maxbound = time + 0.5 / 24
-            assert_array_equal(
-                newcube_1.coord('time').bounds[i],
-                np.array([expected_minbound, expected_maxbound]))
+def test_regrid_time_invalid_freq(cube_1d_time):
+    """Test ``regrid_time``."""
+    msg = "Frequency 'invalid' is not supported"
+    with pytest.raises(NotImplementedError, match=msg):
+        regrid_time(cube_1d_time, 'invalid')
+
+
+@pytest.mark.parametrize('freq', ['day', '6hr', '3hrPt', '1hrCM', 'hr'])
+def test_regrid_time_invalid_freq_for_calendar(cube_1d_time, freq):
+    """Test ``regrid_time``."""
+    msg = f"Setting a fixed calendar is not supported for frequency '{freq}'"
+    with pytest.raises(NotImplementedError, match=msg):
+        regrid_time(cube_1d_time, freq, calendar='365_day')
+
+
+@pytest.mark.parametrize('freq', ['5hr', '7hrPt', '9hrCM', '10hr', '21hrPt'])
+def test_regrid_time_hour_no_divisor_of_24(cube_1d_time, freq):
+    """Test ``regrid_time``."""
+    msg = f"For `n`-hourly data, `n` must be a divisor of 24, got '{freq}'"
+    with pytest.raises(NotImplementedError, match=msg):
+        regrid_time(cube_1d_time, freq)
 
 
 class TestTimeseriesFilter(tests.Test):
-    """Tests for regrid_time with hourly frequency."""
+    """Tests for timeseries filter."""
     def setUp(self):
         """Prepare tests."""
         self.cube = _create_sample_cube()