diff --git a/doc/recipe/preprocessor.rst b/doc/recipe/preprocessor.rst index c608b2739b..0eb6e42c5a 100644 --- a/doc/recipe/preprocessor.rst +++ b/doc/recipe/preprocessor.rst @@ -1223,8 +1223,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. * timeseries_filter_: Allows application of a filter to the time-series data. * local_solar_time_: Convert cube with UTC time to local solar time. @@ -1617,13 +1616,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 5ea3199812..fbb6040d04 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 e9ae61499f..8d4c395fc6 100644 --- a/esmvalcore/cmor/_fixes/shared.py +++ b/esmvalcore/cmor/_fixes/shared.py @@ -446,11 +446,11 @@ 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. + of the next month. For yearly data, use 1 January of the current year and 1 + January of the next year. For decadal data, use 1 January 5 years + before/after the current year. 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 ---------- @@ -475,36 +475,44 @@ def get_time_bounds(time: Coord, freq: str) -> np.ndarray: for step, date in enumerate(dates): month = date.month year = date.year - if freq in ['mon', 'mo']: + if 'mon' in freq or freq == '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': + elif 'yr' in freq: 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), + elif 'dec' in freq: + min_bound = date2num(datetime(year - 5, 1, 1, 0, 0), time.units, time.dtype) - max_bound = date2num(datetime(year + 10, 1, 1, 0, 0), + max_bound = date2num(datetime(year + 5, 1, 1, 0, 0), time.units, time.dtype) else: - delta = { + deltas = { 'day': 12.0 / 24, + '12hr': 6.0 / 24, + '8hr': 4.0 / 24, '6hr': 3.0 / 24, + '4hr': 2.0 / 24, '3hr': 1.5 / 24, + '2hr': 1.0 / 24, '1hr': 0.5 / 24, + 'hr': 0.5 / 24, } - if freq not in delta: + for (freq_str, delta) in deltas.items(): + if freq_str in freq: + point = time.points[step] + min_bound = point - delta + max_bound = point + delta + break + else: raise NotImplementedError( f"Cannot guess time bounds for frequency '{freq}'" ) - point = time.points[step] - min_bound = point - delta[freq] - max_bound = point + delta[freq] bounds.append([min_bound, max_bound]) return np.array(bounds) diff --git a/esmvalcore/preprocessor/_time.py b/esmvalcore/preprocessor/_time.py index ca4e95ce56..5600012389 100644 --- a/esmvalcore/preprocessor/_time.py +++ b/esmvalcore/preprocessor/_time.py @@ -990,82 +990,184 @@ 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 - ] - 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 + + # 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.""" + years = [p.year for p in coord.units.num2date(coord.points)] + months = [p.month for p in coord.units.num2date(coord.points)] + days = [p.day for p in coord.units.num2date(coord.points)] + + if 'dec' in frequency: + dates = [datetime.datetime(year, 1, 1, 0, 0, 0) for year in years] + + elif 'yr' in frequency: + dates = [datetime.datetime(year, 7, 1, 0, 0, 0) for year in years] + + elif 'mon' in frequency: + dates = [ + datetime.datetime(year, month, 15, 0, 0, 0) + for (year, month) in zip(years, months) ] - elif frequency == '6hr': - time_cells = [ - datetime.datetime( - t.year, t.month, t.day, t.hour - t.hour % 6, 0, 0) - for t in time_c + + elif 'day' in frequency: + dates = [ + datetime.datetime(year, month, day, 12, 0, 0) + for (year, month, day) in zip(years, months, days) ] - 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') + 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}'" + ) + hours = [p.hour for p in coord.units.num2date(coord.points)] + half_interval = datetime.timedelta(hours=n_hours / 2.0) + dates = [ + datetime.datetime(year, month, day, hour - hour % n_hours, 0, 0) + + half_interval + for (year, month, day, hour) in zip(years, months, days, hours) + ] + else: + raise NotImplementedError(f"Frequency '{frequency}' is not supported") - return cube + return dates def low_pass_weights(window, cutoff): diff --git a/tests/integration/cmor/_fixes/test_shared.py b/tests/integration/cmor/_fixes/test_shared.py index 8158865aa6..c5d33db287 100644 --- a/tests/integration/cmor/_fixes/test_shared.py +++ b/tests/integration/cmor/_fixes/test_shared.py @@ -645,7 +645,7 @@ def time_coord(): ('mon', [[0, 31], [334, 365]]), ('mo', [[0, 31], [334, 365]]), ('yr', [[0, 365], [0, 365]]), - ('dec', [[0, 3652], [0, 3652]]), + ('dec', [[-1826, 1826], [-1826, 1826]]), ('day', [[14.5, 15.5], [349.5, 350.5]]), ('6hr', [[14.875, 15.125], [349.875, 350.125]]), ('3hr', [[14.9375, 15.0625], [349.9375, 350.0625]]), diff --git a/tests/unit/preprocessor/_time/test_time.py b/tests/unit/preprocessor/_time/test_time.py index a89c0ba760..16728bbfc0 100644 --- a/tests/unit/preprocessor/_time/test_time.py +++ b/tests/unit/preprocessor/_time/test_time.py @@ -15,6 +15,8 @@ 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 @@ ) import tests -from esmvalcore.iris_helpers import date2num from esmvalcore.preprocessor._time import ( annual_statistics, anomalies, @@ -1148,344 +1149,224 @@ def test_sum(self): 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)]), + ('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)], + ), + ( + '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']) +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()