Repository: ESMValGroup/ESMValCore

Issue:
Dataset problem: EC-Earth3-Veg, EC-Earth3
When doing an ensemble_statistics, metadata has to be the same between the single ensemble members. Depending on #673 for the error to appear. Currently tested for CMIP6 Amon TAS runs having data for this 5 experiments: historical, ssp126, ssp245, ssp370, ssp585.

This leads to problems with (luckily on 2 so far):
- [x]  EC-Earth3-Veg: #836
- [x]  EC-Earth3: #837



In the case of EC-Earth3-Veg and TAS there are differences in latitude points and bounds of -9.9475983e-14. So some simple rounding should do the trick.


```
iris.exceptions.MergeError: failed to merge into a single cube.
  Coordinates in cube.dim_coords differ: latitude.
```

- Project: CMIP6
- Full dataset description (fill out as many as you know, please):
  - dataset: EC-Earth3-Veg
  - ensemble: [r(1:4)i1p1f1]
  - experiment: [historical, ssp245]
  - mip: [CMIP, ScenarioMIP]
  - grid: gr
  - data version: [v20200225, v20200425]
  - variable used: tas
- Problems encountered (please describe what the actual problems are: e.g. wrong standard name, issue with dimensions):
- Pointer to existing copy of data on ESGF node (it would be very useful if you could provide a physical
fullpath to the file(s) that are causing the problem, e.g. on CEDA Jasmin or DKRZ):
CMIP/EC-Earth-Consortium/EC-Earth3-Veg/historical/r1i1p1f1/Amon/tas/gr/v20200225/tas_Amon_EC-Earth3-Veg_historical_r1i1p1f1_gr_201401-201412.nc
CMIP/EC-Earth-Consortium/EC-Earth3-Veg/historical/r4i1p1f1/Amon/tas/gr/v20200425/tas_Amon_EC-Earth3-Veg_historical_r4i1p1f1_gr_201401-201412.nc
or 
ScenarioMIP/EC-Earth-Consortium/EC-Earth3-Veg/ssp245/r1i1p1f1/Amon/tas/gr/v20200225/tas_Amon_EC-Earth3-Veg_ssp245_r1i1p1f1_gr_201501-201512.nc
ScenarioMIP/EC-Earth-Consortium/EC-Earth3-Veg/ssp245/r4i1p1f1/Amon/tas/gr/v20200425/tas_Amon_EC-Earth3-Veg_ssp245_r4i1p1f1_gr_201501-201512.nc
- Other notes and mentions:

**Please attach**
  - The recipe that you are trying to run, you can find a copy in the `run` directory in the output directory
[CMIP6_EC-Earth3-Veg_ensemble_statistic.yml.txt](https://github.com/ESMValGroup/ESMValCore/files/5400288/CMIP6_EC-Earth3-Veg_ensemble_statistic.yml.txt)

Cheers :beer:


Discussion:

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index b537f152a..723de62d7 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2,7 +2,7 @@
 version: 2.1
 
 orbs:
-  coverage-reporter: codacy/coverage-reporter@11.1.1
+  coverage-reporter: codacy/coverage-reporter@11.4.1
 
 commands:
   check_changes:
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 606315c24..f70f58a1b 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,24 +1,44 @@
-<!---
-Please do not delete this text completely, but read the text below and keep items that seem relevant.
-If in doubt, just keep everything and add your own text at the top.
---->
+<!--
+    Thank you for contributing to our project!
+-->
 
-Before you start, please read our [contribution guidelines](https://docs.esmvaltool.org/projects/ESMValCore/en/latest/contributing.html).
+## Description
 
-**Tasks**
+<!--
+    Please describe your changes here, especially focusing on why this PR makes
+    ESMValCore better and what problem it solves.
 
--   [ ] [Create an issue](https://github.com/ESMValGroup/ESMValCore/issues) to discuss what you are going to do, if you haven't done so already (and add the link at the bottom)
--   [ ] This pull request has a descriptive title that can be used in a changelog
--   [ ] Add unit tests
--   [ ] Public functions should have a numpy-style docstring so they appear properly in the [API documentation](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/api/esmvalcore.html). For all other functions a one line docstring is sufficient.
--   [ ] If writing a new/modified preprocessor function, please update the [documentation](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/recipe/preprocessor.html)
--   [ ] Circle/CI tests pass. Status can be seen below your pull request. If the tests are failing, click the link to find out why.
--   [ ] Codacy code quality checks pass. Status can be seen below your pull request. If there is an error, click the link to find out why. If you suspect Codacy may be wrong, please ask by commenting.
--   [ ] Please use `yamllint` to check that your YAML files do not contain mistakes
--   [ ] If you make backward incompatible changes to the recipe format, make a new pull request in the [ESMValTool repository](https://github.com/ESMValGroup/ESMValTool) and add the link below
+    Before you start, please read our [contribution guidelines](https://docs.esmvaltool.org/projects/ESMValCore/en/latest/contributing.html).
 
-If you need help with any of the tasks above, please do not hesitate to ask by commenting in the issue or pull request.
+    Please fill in the GitHub issue that is closed by this pull request, e.g. Closes #1903
+-->
 
-* * *
+-   Closes/Fixes: #issue_number
+-   Link to documentation:
 
-Closes #issue_number
+***
+
+## Before you get started
+
+-   \[ \] [☝ Create an issue](https://github.com/ESMValGroup/ESMValCore/issues) to discuss what you are going to do
+
+## Checklist
+
+-   \[ \] PR has a descriptive title for the [changelog](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/contributing.html#branches-pull-requests-and-code-review)
+-   \[ \] Code follows the [style guide](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/contributing.html#code-style)
+-   \[ \] [Documentation](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/contributing.html#documentation) is available for new functionality
+-   \[ \] YAML files pass [`pre-commit`](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/contributing.html#pre-commit) or [`yamllint`](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/community/introduction.html#yaml) checks
+-   \[ \] [Circle/CI tests pass](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/contributing.html#branches-pull-requests-and-code-review)
+-   \[ \] [Codacy code quality checks pass](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/contributing.html#branches-pull-requests-and-code-review)
+-   \[ \] [Documentation builds successfully](https://docs.esmvaltool.org/projects/esmvalcore/en/latest/contributing.html#branches-pull-requests-and-code-review) on readthedocs
+-   \[ \] [Unit tests](https://docs.esmvaltool.org/projects/esmvalcore/projects/esmvalcore/en/latest/contributing.html#contributing-to-the-esmvalcore-package) are available
+
+If you make backwards incompatible changes to the recipe format:
+
+-   \[ \] Update [ESMValTool](https://github.com/esmvalgroup/esmvaltool) and link the pull request(s) in the description
+
+***
+
+To help with the number pull requests:
+
+-   🙏 We kindly ask you to [review](https://docs.esmvaltool.org/en/latest/community/review.html#review-of-pull-requests) two other [open pull requests](https://github.com/ESMValGroup/ESMValTool/pulls) in this repository
diff --git a/README.md b/README.md
index ad1d461bb..8cb5ec3dc 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
 [![CircleCI](https://circleci.com/gh/ESMValGroup/ESMValCore/tree/master.svg?style=svg)](https://circleci.com/gh/ESMValGroup/ESMValCore/tree/master)
 [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/5d496dea9ef64ec68e448a6df5a65783)](https://www.codacy.com/gh/ESMValGroup/ESMValCore?utm_source=github.com&utm_medium=referral&utm_content=ESMValGroup/ESMValCore&utm_campaign=Badge_Coverage)
 [![Codacy Badge](https://app.codacy.com/project/badge/Grade/5d496dea9ef64ec68e448a6df5a65783)](https://www.codacy.com/gh/ESMValGroup/ESMValCore?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=ESMValGroup/ESMValCore&amp;utm_campaign=Badge_Grade)
-[![Docker Build Status](https://img.shields.io/docker/build/esmvalgroup/esmvalcore.svg)](https://hub.docker.com/r/esmvalgroup/esmvalcore/)
+[![Docker Build Status](https://img.shields.io/docker/cloud/build/esmvalgroup/esmvalcore)](https://hub.docker.com/r/esmvalgroup/esmvalcore/)
 [![Anaconda-Server Badge](https://anaconda.org/esmvalgroup/esmvalcore/badges/installer/conda.svg)](https://conda.anaconda.org/esmvalgroup)
 
 ESMValCore: core functionalities for the ESMValTool, a community diagnostic
diff --git a/doc/api/esmvalcore.api.config.rst b/doc/api/esmvalcore.api.config.rst
new file mode 100644
index 000000000..5c694c8d7
--- /dev/null
+++ b/doc/api/esmvalcore.api.config.rst
@@ -0,0 +1,141 @@
+.. _api_config:
+
+Configuration
+=============
+
+This section describes the config submodule of the API.
+
+Config
+******
+
+Configuration of ESMValCore/Tool is done via the ``Config`` object.
+The global configuration can be imported from the ``esmvalcore.experimental`` module as ``CFG``:
+
+.. code-block:: python
+
+    >>> from esmvalcore.experimental import CFG
+    >>> CFG
+    Config({'auxiliary_data_dir': PosixPath('/home/user/auxiliary_data'),
+            'compress_netcdf': False,
+            'config_developer_file': None,
+            'config_file': PosixPath('/home/user/.esmvaltool/config-user.yml'),
+            'drs': {'CMIP5': 'default', 'CMIP6': 'default'},
+            'exit_on_warning': False,
+            'log_level': 'info',
+            'max_parallel_tasks': None,
+            'output_dir': PosixPath('/home/user/esmvaltool_output'),
+            'output_file_type': 'png',
+            'profile_diagnostic': False,
+            'remove_preproc_dir': True,
+            'rootpath': {'CMIP5': '~/default_inputpath',
+                         'CMIP6': '~/default_inputpath',
+                         'default': '~/default_inputpath'},
+            'save_intermediary_cubes': False,
+            'write_netcdf': True,
+            'write_plots': True})
+
+The parameters for the user configuration file are listed `here <https://docs.esmvaltool.org/projects/ESMValCore/en/latest/quickstart/configure.html#user-configuration-file>`__.
+
+``CFG`` is essentially a python dictionary with a few extra functions, similar to ``matplotlib.rcParams``.
+This means that values can be updated like this:
+
+.. code-block:: python
+
+    >>> CFG['output_dir'] = '~/esmvaltool_output'
+    >>> CFG['output_dir']
+    PosixPath('/home/user/esmvaltool_output')
+
+Notice that ``CFG`` automatically converts the path to an instance of ``pathlib.Path`` and expands the home directory.
+All values entered into the config are validated to prevent mistakes, for example, it will warn you if you make a typo in the key:
+
+.. code-block:: python
+
+    >>> CFG['otoptu_dri'] = '~/esmvaltool_output'
+    InvalidConfigParameter: `otoptu_dri` is not a valid config parameter.
+
+Or, if the value entered cannot be converted to the expected type:
+
+.. code-block:: python
+
+    >>> CFG['max_years'] = '🐜'
+    InvalidConfigParameter: Key `max_years`: Could not convert '🐜' to int
+
+``Config`` is also flexible, so it tries to correct the type of your input if possible:
+
+.. code-block:: python
+
+    >>> CFG['max_years'] = '123'  # str
+    >>> type(CFG['max_years'])
+    int
+
+By default, the config is loaded from the default location (``/home/user/.esmvaltool/config-user.yml``).
+If it does not exist, it falls back to the default values.
+to load a different file:
+
+.. code-block:: python
+
+    >>> CFG.load_from_file('~/my-config.yml')
+
+Or to reload the current config:
+
+.. code-block:: python
+
+    >>> CFG.reload()
+
+
+Session
+*******
+
+Recipes and diagnostics will be run in their own directories.
+This behaviour can be controlled via the ``Session`` object.
+A ``Session`` can be initiated from the global ``Config``.
+
+.. code-block:: python
+
+    >>> session = CFG.start_session(name='my_session')
+
+A ``Session`` is very similar to the config.
+It is also a dictionary, and copies all the keys from the ``Config``.
+At this moment, ``session`` is essentially a copy of ``CFG``:
+
+.. code-block:: python
+
+    >>> print(session == CFG)
+    True
+    >>> session['output_dir'] = '~/my_output_dir'
+    >>> print(session == CFG)  # False
+    False
+
+A ``Session`` also knows about the directories where the data will stored.
+The session name is used to prefix the directories.
+
+.. code-block:: python
+
+    >>> session.session_dir
+    /home/user/my_output_dir/my_session_20201203_155821
+    >>> session.run_dir
+    /home/user/my_output_dir/my_session_20201203_155821/run
+    >>> session.work_dir
+    /home/user/my_output_dir/my_session_20201203_155821/work
+    >>> session.preproc_dir
+    /home/user/my_output_dir/my_session_20201203_155821/preproc
+    >>> session.plot_dir
+    /home/user/my_output_dir/my_session_20201203_155821/plots
+
+Unlike the global configuration, of which only one can exist, multiple sessions can be initiated from the ``Config``.
+
+
+API reference
+*************
+
+.. autoclass:: esmvalcore.experimental.config.CFG
+    :no-inherited-members:
+    :no-show-inheritance:
+
+.. autoclass:: esmvalcore.experimental.config.Config
+    :no-inherited-members:
+    :no-show-inheritance:
+
+.. autoclass:: esmvalcore.experimental.config.Session
+    :no-inherited-members:
+    :no-show-inheritance:
diff --git a/doc/api/esmvalcore.api.recipe_info.rst b/doc/api/esmvalcore.api.recipe_info.rst
new file mode 100644
index 000000000..e73ceb25a
--- /dev/null
+++ b/doc/api/esmvalcore.api.recipe_info.rst
@@ -0,0 +1,22 @@
+.. _api_recipe_info:
+
+Recipe Metadata
+===============
+
+This section describes the :py:mod:`esmvalcore.experimental.recipe_info` submodule of the API.
+
+Recipe metadata
+***************
+
+:py:class:`esmvalcore.experimental.recipe_info.RecipeInfo` info is a class that holds metadata from a recipe.
+
+.. code-block:: python
+
+    >>> RecipeInfo('path/to/my_recipe.yml')
+    recipe = RecipeInfo('My recipe')
+
+
+API reference
+*************
+
+.. automodule:: esmvalcore.experimental.recipe_info
diff --git a/doc/api/esmvalcore.api.rst b/doc/api/esmvalcore.api.rst
index 8870ef67e..0f23a0ebd 100644
--- a/doc/api/esmvalcore.api.rst
+++ b/doc/api/esmvalcore.api.rst
@@ -7,137 +7,8 @@ This page describes the new ESMValCore API.
 The API module is available in the submodule ``esmvalcore.experimental``.
 The API is under development, so use at your own risk!
 
-Config
-******
+.. toctree::
 
-Configuration of ESMValCore/Tool is done via the ``Config`` object.
-The global configuration can be imported from the ``esmvalcore.experimental`` module as ``CFG``:
-
-.. code-block:: python
-
-    >>> from esmvalcore.experimental import CFG
-    >>> CFG
-    Config({'auxiliary_data_dir': PosixPath('/home/user/auxiliary_data'),
-            'compress_netcdf': False,
-            'config_developer_file': None,
-            'config_file': PosixPath('/home/user/.esmvaltool/config-user.yml'),
-            'drs': {'CMIP5': 'default', 'CMIP6': 'default'},
-            'exit_on_warning': False,
-            'log_level': 'info',
-            'max_parallel_tasks': None,
-            'output_dir': PosixPath('/home/user/esmvaltool_output'),
-            'output_file_type': 'png',
-            'profile_diagnostic': False,
-            'remove_preproc_dir': True,
-            'rootpath': {'CMIP5': '~/default_inputpath',
-                         'CMIP6': '~/default_inputpath',
-                         'default': '~/default_inputpath'},
-            'save_intermediary_cubes': False,
-            'write_netcdf': True,
-            'write_plots': True})
-
-The parameters for the user configuration file are listed `here <https://docs.esmvaltool.org/projects/ESMValCore/en/latest/quickstart/configure.html#user-configuration-file>`__.
-
-``CFG`` is essentially a python dictionary with a few extra functions, similar to ``matplotlib.rcParams``.
-This means that values can be updated like this:
-
-.. code-block:: python
-
-    >>> CFG['output_dir'] = '~/esmvaltool_output'
-    >>> CFG['output_dir']
-    PosixPath('/home/user/esmvaltool_output')
-
-Notice that ``CFG`` automatically converts the path to an instance of ``pathlib.Path`` and expands the home directory.
-All values entered into the config are validated to prevent mistakes, for example, it will warn you if you make a typo in the key:
-
-.. code-block:: python
-
-    >>> CFG['otoptu_dri'] = '~/esmvaltool_output'
-    InvalidConfigParameter: `otoptu_dri` is not a valid config parameter.
-
-Or, if the value entered cannot be converted to the expected type:
-
-.. code-block:: python
-
-    >>> CFG['max_years'] = '🐜'
-    InvalidConfigParameter: Key `max_years`: Could not convert '🐜' to int
-
-``Config`` is also flexible, so it tries to correct the type of your input if possible:
-
-.. code-block:: python
-
-    >>> CFG['max_years'] = '123'  # str
-    >>> type(CFG['max_years'])
-    int
-
-By default, the config is loaded from the default location (``/home/user/.esmvaltool/config-user.yml``).
-If it does not exist, it falls back to the default values.
-to load a different file:
-
-.. code-block:: python
-
-    >>> CFG.load_from_file('~/my-config.yml')
-
-Or to reload the current config:
-
-.. code-block:: python
-
-    >>> CFG.reload()
-
-
-Session
-*******
-
-Recipes and diagnostics will be run in their own directories.
-This behaviour can be controlled via the ``Session`` object.
-A ``Session`` can be initiated from the global ``Config``.
-
-.. code-block:: python
-
-    >>> session = CFG.start_session(name='my_session')
-
-A ``Session`` is very similar to the config.
-It is also a dictionary, and copies all the keys from the ``Config``.
-At this moment, ``session`` is essentially a copy of ``CFG``:
-
-.. code-block:: python
-
-    >>> print(session == CFG)
-    True
-    >>> session['output_dir'] = '~/my_output_dir'
-    >>> print(session == CFG)  # False
-    False
-
-A ``Session`` also knows about the directories where the data will stored.
-The session name is used to prefix the directories.
-
-.. code-block:: python
-
-    >>> session.session_dir
-    /home/user/my_output_dir/my_session_20201203_155821
-    >>> session.run_dir
-    /home/user/my_output_dir/my_session_20201203_155821/run
-    >>> session.work_dir
-    /home/user/my_output_dir/my_session_20201203_155821/work
-    >>> session.preproc_dir
-    /home/user/my_output_dir/my_session_20201203_155821/preproc
-    >>> session.plot_dir
-    /home/user/my_output_dir/my_session_20201203_155821/plots
-
-Unlike the global configuration, of which only one can exist, multiple sessions can be initiated from the ``Config``.
-
-
-API reference
-*************
-
-.. autoclass:: esmvalcore.experimental.config.CFG
-    :no-inherited-members:
-    :no-show-inheritance:
-
-.. autoclass:: esmvalcore.experimental.config.Config
-    :no-inherited-members:
-    :no-show-inheritance:
-
-.. autoclass:: esmvalcore.experimental.config.Session
-    :no-inherited-members:
-    :no-show-inheritance:
+   esmvalcore.api.config
+   esmvalcore.api.recipe_info
+   esmvalcore.api.utils
diff --git a/doc/api/esmvalcore.api.utils.rst b/doc/api/esmvalcore.api.utils.rst
new file mode 100644
index 000000000..554f14148
--- /dev/null
+++ b/doc/api/esmvalcore.api.utils.rst
@@ -0,0 +1,63 @@
+.. _api_utils:
+
+
+
+Utils
+=====
+
+This section describes the utilities submodule of the API.
+
+Finding recipes
+***************
+
+One of the first thing we may want to do, is to simply get one of the recipes available in ``ESMValTool``
+
+If you already know which recipe you want to load, call :py:func:`esmvalcore.experimental.utils.get_recipe`.
+
+.. code-block:: python
+
+    from esmvalcore.experimental import get_recipe
+    >>> get_recipe('examples/recipe_python')
+    RecipeInfo('Recipe python')
+
+Call the :py:func:`esmvalcore.experimental.utils.get_all_recipes` function to get a list of all available recipes.
+
+.. code-block:: python
+
+    >>> from esmvalcore.experimental import get_all_recipes
+    >>> recipes = get_all_recipes()
+    >>> recipes
+    [RecipeInfo('Recipe perfmetrics cmip5 4cds'),
+     RecipeInfo('Recipe martin18grl'),
+     ...
+     RecipeInfo('Recipe wflow'),
+     RecipeInfo('Recipe pcrglobwb')]
+
+To search for a specific recipe, you can use the :py:meth:`esmvalcore.experimental.utils.RecipeList.find` method. This takes a search query that looks through the recipe metadata and returns any matches. The query can be a regex pattern, so you can make it as complex as you like.
+
+.. code-block:: python
+
+    >>> results = recipes.find('climwip')
+    [RecipeInfo('Recipe climwip')]
+
+The recipes are loaded in a :py:class:`esmvalcore.experimental.recipe_info.RecipeInfo` object, which knows about the documentation, authors, project, and related references of the recipe. It resolves all the tags, so that it knows which institute an author belongs to and which references are associated with the recipe.
+
+This means you can search for something like this:
+
+.. code-block:: python
+
+    >>> recipes.find('Geophysical Research Letters')
+    [RecipeInfo('Recipe martin18grl'),
+     RecipeInfo('Recipe climwip'),
+     RecipeInfo('Recipe ecs constraints'),
+     RecipeInfo('Recipe ecs scatter'),
+     RecipeInfo('Recipe ecs'),
+     RecipeInfo('Recipe seaice')]
+
+
+API reference
+*************
+
+.. automodule:: esmvalcore.experimental.utils
+    :no-inherited-members:
+    :no-show-inheritance:
diff --git a/doc/recipe/preprocessor.rst b/doc/recipe/preprocessor.rst
index 5cb8cb852..52c54291b 100644
--- a/doc/recipe/preprocessor.rst
+++ b/doc/recipe/preprocessor.rst
@@ -716,12 +716,15 @@ The ``_time.py`` module contains the following preprocessor functions:
 * extract_time_: Extract a time range from a cube.
 * extract_season_: Extract only the times that occur within a specific season.
 * extract_month_: Extract only the times that occur within a specific month.
+* hourly_statistics_: Compute intra-day statistics
 * daily_statistics_: Compute statistics for each day
 * monthly_statistics_: Compute statistics for each month
 * seasonal_statistics_: Compute statistics for each season
 * annual_statistics_: Compute statistics for each year
 * decadal_statistics_: Compute statistics for each decade
 * climate_statistics_: Compute statistics for the full period
+* 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.
@@ -798,6 +801,22 @@ between 1 and 12 as the named month string will not be accepted.
 
 See also :func:`esmvalcore.preprocessor.extract_month`.
 
+.. _hourly_statistics:
+
+``hourly_statistics``
+---------------------
+
+This function produces statistics at a x-hourly frequency.
+
+Parameters:
+    * every_n_hours: frequency to use to compute the statistics. Must be a divisor of
+      24.
+
+    * operator: operation to apply. Accepted values are 'mean',
+      'median', 'std_dev', 'min', 'max' and 'sum'. Default is 'mean'
+
+See also :func:`esmvalcore.preprocessor.daily_statistics`.
+
 .. _daily_statistics:
 
 ``daily_statistics``
@@ -920,6 +939,80 @@ Examples:
 
 See also :func:`esmvalcore.preprocessor.climate_statistics`.
 
+.. _resample_time:
+
+``resample_time``
+-----------------
+
+This function changes the frequency of the data in the cube by extracting the
+timesteps that meet the criteria. It is important to note that it is mainly
+meant to be used with instantaneous data.
+
+Parameters:
+    * month: Extract only timesteps from the given month or do nothing if None.
+      Default is `None`
+    * day: Extract only timesteps from the given day of month or do nothing if
+      None. Default is `None`
+    * hour: Extract only timesteps from the given hour or do nothing if None.
+      Default is `None`
+
+Examples:
+    * Hourly data to daily:
+
+        .. code-block:: yaml
+
+            resample_time:
+              hour: 12
+
+    * Hourly data to monthly:
+
+        .. code-block:: yaml
+
+            resample_time:
+              hour: 12
+              day: 15
+
+    * Daily data to monthly:
+
+        .. code-block:: yaml
+
+            resample_time:
+              day: 15
+
+See also :func:`esmvalcore.preprocessor.resample_time`.
+
+
+resample_hours:
+
+``resample_hours``
+------------------
+
+This function changes the frequency of the data in the cube by extracting the
+timesteps that belongs to the desired frequency. It is important to note that
+it is mainly mean to be used with instantaneous data
+
+Parameters:
+    * interval: New frequency of the data. Must be a divisor of 24
+    * offset: First desired hour. Default 0. Must be lower than the interval
+
+Examples:
+    * Convert to 12-hourly, by getting timesteps at 0:00 and 12:00:
+
+        .. code-block:: yaml
+
+            resample_hours:
+              hours: 12
+
+    * Convert to 12-hourly, by getting timesteps at 6:00 and 18:00:
+
+        .. code-block:: yaml
+
+            resample_hours:
+              hours: 12
+	      offset: 6
+
+See also :func:`esmvalcore.preprocessor.resample_hours`.
+
 .. _anomalies:
 
 ``anomalies``
diff --git a/doc/requirements.txt b/doc/requirements.txt
index 7d67ccad6..0094f9fec 100644
--- a/doc/requirements.txt
+++ b/doc/requirements.txt
@@ -6,6 +6,7 @@ numpy
 pandas
 pillow
 prov[dot]
+pybtex
 pyyaml
 scipy
 shapely[vectorized]
diff --git a/esmvalcore/cmor/_fixes/cmip6/awi_cm_1_1_mr.py b/esmvalcore/cmor/_fixes/cmip6/awi_cm_1_1_mr.py
new file mode 100644
index 000000000..56d8dac0f
--- /dev/null
+++ b/esmvalcore/cmor/_fixes/cmip6/awi_cm_1_1_mr.py
@@ -0,0 +1,31 @@
+"""Fixes for AWI-CM-1-1-MR model."""
+
+from ..fix import Fix
+
+
+class AllVars(Fix):
+    """Fixes for all variables."""
+
+    def fix_metadata(self, cubes):
+        """Rename ``long_name`` of latitude to latitude (may be Latitude).
+
+        Parameters
+        ----------
+        cubes : iris.cube.CubeList
+            Input cubes.
+
+        Returns
+        -------
+        iris.cube.Cube
+        """
+        coords_longnames_to_change = {
+            'latitude': 'latitude',
+        }
+
+        for cube in cubes:
+            for (std_name, long_name) in coords_longnames_to_change.items():
+                coord = cube.coord(std_name)
+                if coord.long_name != long_name:
+                    coord.long_name = long_name
+
+        return cubes
diff --git a/esmvalcore/cmor/_fixes/cmip6/ec_earth3_veg.py b/esmvalcore/cmor/_fixes/cmip6/ec_earth3_veg.py
index d6f1fb9fa..175db44a4 100644
--- a/esmvalcore/cmor/_fixes/cmip6/ec_earth3_veg.py
+++ b/esmvalcore/cmor/_fixes/cmip6/ec_earth3_veg.py
@@ -1,10 +1,11 @@
 """Fixes for EC-Earth3-Veg model."""
+import numpy as np
+
 from ..fix import Fix
 
 
 class Siconca(Fix):
     """Fixes for siconca."""
-
     def fix_data(self, cube):
         """Fix data.
 
@@ -18,7 +19,33 @@ class Siconca(Fix):
         Returns
         -------
         iris.cube.Cube
-
         """
         cube.data = cube.core_data() * 100.
         return cube
+
+
+class Tas(Fix):
+    """Fixes for tas."""
+
+    def fix_metadata(self, cubes):
+        """Fix latitude points and bounds.
+
+        Fix latitude_bounds and longitude_bounds data type and round to 8 d.p.
+
+        Parameters
+        ----------
+        cubes : iris.cube.CubeList
+            Input cubes.
+
+        Returns
+        -------
+        iris.cube.CubeList
+        """
+        cube = self.get_cube_from_list(cubes)
+
+        for cube in cubes:
+            latitude = cube.coord('latitude')
+            latitude.points = np.round(latitude.points, 8)
+            latitude.bounds = np.round(latitude.bounds, 8)
+
+        return cubes
diff --git a/esmvalcore/experimental/__init__.py b/esmvalcore/experimental/__init__.py
index 5614ce777..e7078ae06 100644
--- a/esmvalcore/experimental/__init__.py
+++ b/esmvalcore/experimental/__init__.py
@@ -8,7 +8,13 @@ warnings.warn(
     '\n  More info: https://github.com/ESMValGroup/ESMValCore/issues/498', )
 
 from .config import CFG  # noqa: E402
+from .recipe_info import RecipeInfo  # noqa: E402
+from .utils import RecipeList, get_all_recipes, get_recipe  # noqa: E402
 
 __all__ = [
     'CFG',
+    'get_all_recipes',
+    'get_recipe',
+    'RecipeInfo',
+    'RecipeList',
 ]
diff --git a/esmvalcore/experimental/recipe_info.py b/esmvalcore/experimental/recipe_info.py
new file mode 100644
index 000000000..5fe7ac830
--- /dev/null
+++ b/esmvalcore/experimental/recipe_info.py
@@ -0,0 +1,275 @@
+"""Recipe metadata."""
+
+import textwrap
+from pathlib import Path
+
+import pybtex
+import yaml
+from pybtex.database.input import bibtex
+
+from esmvalcore._citation import REFERENCES_PATH
+from esmvalcore._config import TAGS
+
+
+class RenderError(BaseException):
+    """Error during rendering of object."""
+
+
+class Contributor:
+    """Contains contributor (author or maintainer) information."""
+
+    def __init__(self, name: str, institute: str, orcid: str = None):
+        self.name = name
+        self.institute = institute
+        self.orcid = orcid
+
+    def __repr__(self) -> str:
+        """Return canonical string representation."""
+        return (f'{self.__class__.__name__}({self.name!r},'
+                f' institute={self.institute!r}, orcid={self.orcid!r})')
+
+    def __str__(self) -> str:
+        """Return string representation."""
+        string = f'{self.name} ({self.institute}'
+        if self.orcid:
+            string += f'; {self.orcid}'
+        string += ')'
+        return string
+
+    def _repr_markdown_(self):
+        """Represent using markdown renderer in a notebook environment."""
+        return str(self)
+
+    @classmethod
+    def from_tag(cls, tag: str):
+        """Return an instance of Contributor from a tag (``TAGS``).
+
+        Contributors are defined by author tags in ``config-
+        references.yml``.
+        """
+        mapping = TAGS['authors'][tag]
+
+        name = ' '.join(reversed(mapping['name'].split(', ')))
+        institute = mapping.get('institute', 'No affiliation')
+        orcid = mapping['orcid']
+
+        return cls(name=name, institute=institute, orcid=orcid)
+
+
+class Project:
+    """Contains project information."""
+
+    def __init__(self, project: str):
+        self.project = project
+
+    def __repr__(self) -> str:
+        """Return canonical string representation."""
+        return f'{self.__class__.__name__}({self.project!r})'
+
+    def __str__(self) -> str:
+        """Return string representation."""
+        string = f'{self.project}'
+        return string
+
+    @classmethod
+    def from_tag(cls, tag: str):
+        """Return an instance of Project from a tag (``TAGS``).
+
+        The project tags are defined in ``config-references.yml``.
+        """
+        project = TAGS['projects'][tag]
+        return cls(project=project)
+
+
+class Reference:
+    """Contains reference information."""
+
+    def __init__(self, filename):
+        parser = bibtex.Parser(strict=False)
+        bib_data = parser.parse_file(filename)
+
+        if len(bib_data.entries) > 1:
+            raise NotImplementedError(
+                f'{self.__class__.__name__} cannot handle bibtex files '
+                'with more than 1 entry.')
+
+        self._bib_data = bib_data
+        self._key, self._entry = list(bib_data.entries.items())[0]
+        self._filename = filename
+
+    @classmethod
+    def from_tag(cls, tag: str):
+        """Return an instance of Reference from a bibtex tag.
+
+        The bibtex tags resolved as
+        ``esmvaltool/references/{tag}.bibtex``.
+        """
+        filename = Path(REFERENCES_PATH, f'{tag}.bibtex')
+        return cls(filename)
+
+    def __repr__(self):
+        """Return canonical string representation."""
+        return f'{self.__class__.__name__}({self._key!r})'
+
+    def __str__(self):
+        """Return string representation."""
+        return self.render(renderer='plaintext')
+
+    def _repr_markdown_(self):
+        """Represent using markdown renderer in a notebook environment."""
+        return self.render(renderer='markdown')
+
+    def render(self, renderer: str = 'plaintext') -> str:
+        """Render the reference.
+
+        Parameters
+        ----------
+        renderer : str
+            Choose the renderer for the string representation.
+            Must be one of: 'plaintext', 'markdown', 'html', 'latex'
+
+        Returns
+        -------
+        str
+            Rendered reference
+        """
+        style = 'plain'  # alpha, plain, unsrt, unsrtalpha
+        backend = pybtex.plugin.find_plugin('pybtex.backends', renderer)()
+        style = pybtex.plugin.find_plugin('pybtex.style.formatting', style)()
+
+        try:
+            formatter = style.format_entry(self._key, self._entry)
+            rendered = formatter.text.render(backend)
+        except Exception as err:
+            raise RenderError(
+                f'Could not render {self._key!r}: {err}') from None
+
+        return rendered
+
+
+class RecipeInfo():
+    """Contains Recipe metadata.
+
+    Parameters
+    ----------
+    path : pathlike
+        Path to the recipe.
+    """
+
+    def __init__(self, path: str):
+        self.path = Path(path)
+        if not self.path.exists():
+            raise FileNotFoundError(f'Cannot find recipe: `{path}`.')
+
+        self._data = None
+        self._authors = None
+        self._maintainers = None
+        self._projects = None
+        self._references = None
+        self._description = None
+
+    def __repr__(self) -> str:
+        """Return canonical string representation."""
+        return f'{self.__class__.__name__}({self.name!r})'
+
+    def _repr_markdown_(self) -> str:
+        """Represent using markdown renderer in a notebook environment."""
+        return self.render('markdown')
+
+    def __str__(self) -> str:
+        """Return string representation."""
+        return self.render('plaintext')
+
+    def to_markdown(self) -> str:
+        """Return markdown formatted string."""
+        return self.render('markdown')
+
+    def render(self, renderer: str = 'plaintext') -> str:
+        """Return formatted string.
+
+        Parameters
+        ----------
+        renderer : str
+            Choose the renderer for the string representation.
+            Must be one of 'plaintext', 'markdown'
+        """
+        bullet = '\n - '
+        string = f'## {self.name}'
+
+        string += '\n\n'
+        string += f'{self.description}'
+
+        string += '\n\n### Authors'
+        for author in self.authors:
+            string += f'{bullet}{author}'
+
+        string += '\n\n### Maintainers'
+        for maintainer in self.maintainers:
+            string += f'{bullet}{maintainer}'
+
+        if self.projects:
+            string += '\n\n### Projects'
+            for project in self.projects:
+                string += f'{bullet}{project}'
+
+        if self.references:
+            string += '\n\n### References'
+            for reference in self.references:
+                string += bullet + reference.render(renderer)
+
+        string += '\n'
+
+        return string
+
+    @property
+    def data(self) -> dict:
+        """Return dictionary representation of the recipe."""
+        if self._data is None:
+            self._data = yaml.safe_load(open(self.path, 'r'))
+        return self._data
+
+    @property
+    def name(self) -> str:
+        """Name of the recipe."""
+        return self.path.stem.replace('_', ' ').capitalize()
+
+    @property
+    def description(self) -> str:
+        """Recipe description."""
+        if self._description is None:
+            description = self.data['documentation']['description']
+            self._description = '\n'.join(textwrap.wrap(description))
+        return self._description
+
+    @property
+    def authors(self) -> tuple:
+        """List of recipe authors."""
+        if self._authors is None:
+            tags = self.data['documentation']['authors']
+            self._authors = tuple(Contributor.from_tag(tag) for tag in tags)
+        return self._authors
+
+    @property
+    def maintainers(self) -> tuple:
+        """List of recipe maintainers."""
+        if self._maintainers is None:
+            tags = self.data['documentation']['maintainer']
+            self._maintainers = tuple(
+                Contributor.from_tag(tag) for tag in tags)
+        return self._maintainers
+
+    @property
+    def projects(self) -> tuple:
+        """List of recipe projects."""
+        if self._projects is None:
+            tags = self.data['documentation'].get('projects', [])
+            self._projects = tuple(Project.from_tag(tag) for tag in tags)
+        return self._projects
+
+    @property
+    def references(self) -> tuple:
+        """List of project references."""
+        if self._references is None:
+            tags = self.data['documentation'].get('references', [])
+            self._references = tuple(Reference.from_tag(tag) for tag in tags)
+        return self._references
diff --git a/esmvalcore/experimental/utils.py b/esmvalcore/experimental/utils.py
new file mode 100644
index 000000000..3e2edb864
--- /dev/null
+++ b/esmvalcore/experimental/utils.py
@@ -0,0 +1,85 @@
+"""ESMValCore utilities."""
+
+import re
+from pathlib import Path
+
+from esmvalcore._config import DIAGNOSTICS_PATH
+
+from .recipe_info import RecipeInfo
+
+
+class RecipeList(list):
+    """Container for recipes."""
+
+    def find(self, query: str):
+        """Search for recipes matching the search query or pattern.
+
+        This function will search the recipe description, author list, and
+        project information.
+
+        Parameters
+        ----------
+        query : str
+            String to search for, e.g. ``find_recipes('righi')`` will return
+            all matching that author. Can be a `regex` pattern.
+
+        Returns
+        -------
+        RecipeList
+            List of recipes matching the search query.
+        """
+        query = re.compile(query, flags=re.IGNORECASE)
+
+        matches = RecipeList()
+
+        for recipe in self:
+            match = re.search(query, str(recipe))
+            if match:
+                matches.append(recipe)
+
+        return matches
+
+
+def get_all_recipes(subdir: str = None) -> list:
+    """Return a list of all available recipes.
+
+    Parameters
+    ----------
+    subdir : str
+        Sub-directory of the ``DIAGNOSTICS_PATH`` to look for
+        recipes, e.g. ``get_all_recipes(subdir='examples')``.
+
+    Returns
+    -------
+    RecipeList
+        List of available recipes
+    """
+    if not subdir:
+        subdir = '**'
+    rootdir = Path(DIAGNOSTICS_PATH, 'recipes')
+    files = rootdir.glob(f'{subdir}/*.yml')
+    return RecipeList(RecipeInfo(file) for file in files)
+
+
+def get_recipe(name: str) -> 'RecipeInfo':
+    """Get a recipe by its name.
+
+    Parameters
+    ----------
+    name : str
+        Name of the recipe, i.e. ``examples/recipe_python.yml``
+
+    Returns
+    -------
+    RecipeInfo
+    """
+    locations = Path(), Path(DIAGNOSTICS_PATH, 'recipes')
+    filenames = name, name + '.yml'
+
+    for location in locations:
+        for filename in filenames:
+            try_path = Path(location, filename)
+            if try_path.exists():
+                return RecipeInfo(try_path)
+
+    raise FileNotFoundError(f'Could not find `{name}')
diff --git a/esmvalcore/preprocessor/__init__.py b/esmvalcore/preprocessor/__init__.py
index f4ca0a921..9aa580439 100644
--- a/esmvalcore/preprocessor/__init__.py
+++ b/esmvalcore/preprocessor/__init__.py
@@ -53,8 +53,11 @@ from ._time import (
     extract_month,
     extract_season,
     extract_time,
+    hourly_statistics,
     monthly_statistics,
     regrid_time,
+    resample_hours,
+    resample_time,
     seasonal_statistics,
     timeseries_filter,
 )
@@ -86,6 +89,12 @@ __all__ = [
     'cmor_check_metadata',
     # Extract years given by dataset keys (start_year and end_year)
     'clip_start_end_year',
+    # Time extraction
+    'extract_time',
+    'extract_season',
+    'extract_month',
+    'resample_hours',
+    'resample_time',
     # Data reformatting/CMORization
     'fix_data',
     'cmor_check_data',
@@ -136,6 +145,7 @@ __all__ = [
     'amplitude',
     'zonal_statistics',
     'meridional_statistics',
+    'hourly_statistics',
     'daily_statistics',
     'monthly_statistics',
     'seasonal_statistics',
@@ -239,9 +249,10 @@ def _check_multi_model_settings(products):
             elif reference.settings[step] != settings:
                 raise ValueError(
                     "Unable to combine differing multi-dataset settings for "
-                    "{} and {}, {} and {}".format(
-                        reference.filename, product.filename,
-                        reference.settings[step], settings))
+                    "{} and {}, {} and {}".format(reference.filename,
+                                                  product.filename,
+                                                  reference.settings[step],
+                                                  settings))
 
 
 def _get_multi_model_settings(products, step):
@@ -283,8 +294,7 @@ def preprocess(items, step, **settings):
 
     items = []
     for item in result:
-        if isinstance(item,
-                      (PreprocessorFile, Cube, str)):
+        if isinstance(item, (PreprocessorFile, Cube, str)):
             items.append(item)
         else:
             items.extend(item)
@@ -309,7 +319,6 @@ def get_step_blocks(steps, order):
 
 class PreprocessorFile(TrackedFile):
     """Preprocessor output file."""
-
     def __init__(self, attributes, settings, ancestors=None):
         super(PreprocessorFile, self).__init__(attributes['filename'],
                                                attributes, ancestors)
@@ -398,8 +407,8 @@ def _apply_multimodel(products, step, debug):
     """Apply multi model step to products."""
     settings, exclude = _get_multi_model_settings(products, step)
 
-    logger.debug("Applying %s to\n%s", step, '\n'.join(
-        str(p) for p in products - exclude))
+    logger.debug("Applying %s to\n%s", step,
+                 '\n'.join(str(p) for p in products - exclude))
     result = preprocess(products - exclude, step, **settings)
     products = set(result) | exclude
 
@@ -415,15 +424,14 @@ def _apply_multimodel(products, step, debug):
 
 class PreprocessingTask(BaseTask):
     """Task for running the preprocessor."""
-
     def __init__(
-            self,
-            products,
-            ancestors=None,
-            name='',
-            order=DEFAULT_ORDER,
-            debug=None,
-            write_ncl_interface=False,
+        self,
+        products,
+        ancestors=None,
+        name='',
+        order=DEFAULT_ORDER,
+        debug=None,
+        write_ncl_interface=False,
     ):
         """Initialize."""
         _check_multi_model_settings(products)
diff --git a/esmvalcore/preprocessor/_derive/_shared.py b/esmvalcore/preprocessor/_derive/_shared.py
index 17556fc9b..38162b6f3 100644
--- a/esmvalcore/preprocessor/_derive/_shared.py
+++ b/esmvalcore/preprocessor/_derive/_shared.py
@@ -5,6 +5,7 @@ from copy import deepcopy
 
 import iris
 import numpy as np
+from scipy import constants
 
 from esmvalcore.iris_helpers import var_name_constraint
 
@@ -29,6 +30,82 @@ def cloud_area_fraction(cubes, tau_constraint, plev_constraint):
     return new_cube
 
 
+def column_average(cube, hus_cube, zg_cube, ps_cube):
+    """Calculate column-averaged mole fractions.
+
+    The calculation follows the method described in the obs4MIPs technical note
+    "Merged SCIMACHY/ENVISAT and TANSO-FTS/GOSAT atmospheric column-average
+    dry-air mole fraction of CH4 (XCH4)".
+    by M. Buchwitz and M. Reuter, available at:
+    http://esgf-data1.ceda.ac.uk/thredds/fileServer/esg_obs4mips/
+        esacci/ghg/data/obs4mips/crdp_3/CH4/v100/
+        TechNote_SCIAGOSAT_L3_CRDP3_001_XCH4_FINAL.pdf
+
+    Parameters
+    ----------
+    cube : iris.cube.Cube
+        Cube containing a mole fraction of a gas (must be 4D with coordinates
+        ``time``, ``air_pressure``, ``latitude`` and ``longitude``).
+        ``latitude`` and ``air_pressure``).
+    hus_cube : iris.cube.Cube
+        Cube containing ``specific_humidity`` (must be 4D with coordinates
+        ``time``, ``air_pressure``, ``latitude`` and ``longitude``).
+        ``latitude`` and ``air_pressure``).
+    zg_cube : iris.cube.Cube
+        Cube containing ``geopotential_height`` (must be 3D with coordinates
+        ``time``, ``latitude`` and ``longitude``).
+    ps_cube : iris.cube.Cube
+        Cube containing ``surface_air_pressure`` (must be 3D with coordinates
+        ``time``, ``latitude`` and ``longitude``).
+
+    Returns
+    -------
+    iris.cube.Cube
+        Column-averaged mole fraction of a gas.
+
+    """
+    # Convert units of data
+    hus_cube.convert_units('1')
+    zg_cube.convert_units('m')
+    ps_cube.convert_units('Pa')
+
+    # Level thickness (note: Buchwitz & Reuter use hPa but we use Pa; in fact,
+    # this does not matter as units cancel out when calculating column-average
+    p_layer_widths = pressure_level_widths(cube, ps_cube, top_limit=0.0)
+
+    # Latitudes (1-dim array)
+    lat = cube.coord('latitude').points
+
+    # Gravitational acceleration g_0 on the geoid approximated by the
+    # international gravity formula depending only on the latitude
+    g_0 = np.array(lat)
+    g_0 = 9.780327 * (1.0 + 0.0053024 * (np.sin(lat / 180.0 * np.pi))**2 -
+                      0.0000058 * (np.sin(2.0 * lat / 180.0 * np.pi))**2)
+
+    # Approximation of the gravitational acceleration including the
+    # free air correction
+    # Note: the formula for g given in Buchwitz & Reuter contains a typo and
+    # should read: g_0**2 - 2*f*zg (i.e. minus instead of +)
+    fair_cor = 3.0825958e-6
+    g_4d_array = iris.util.broadcast_to_shape(g_0**2, zg_cube.shape, [2])
+    g_4d_array = np.sqrt(g_4d_array.data - 2.0 * fair_cor * zg_cube.data)
+
+    # Number of dry air particles (air molecules excluding water vapor) within
+    # each layer
+    mw_air = 28.9644e-3
+    n_dry = ((hus_cube * -1.0 + 1.0) * constants.value('Avogadro constant') *
+             p_layer_widths.data / (mw_air * g_4d_array))
+
+    # Number of gas molecules per layer
+    cube = cube * n_dry
+
+    # Column-average
+    x_cube = (
+        cube.collapsed('air_pressure', iris.analysis.SUM) /
+        n_dry.collapsed('air_pressure', iris.analysis.SUM))
+    return x_cube
+
+
 def pressure_level_widths(cube, ps_cube, top_limit=0.0):
     """Create a cube with pressure level widths.
 
@@ -37,7 +114,7 @@ def pressure_level_widths(cube, ps_cube, top_limit=0.0):
     Parameters
     ----------
     cube : iris.cube.Cube
-        Cube containing ``air_pressure`` coordiante.
+        Cube containing ``air_pressure`` coordinate.
     ps_cube : iris.cube.Cube
         Cube containing ``surface_air_pressure``.
     top_limit : float
diff --git a/esmvalcore/preprocessor/_derive/xch4.py b/esmvalcore/preprocessor/_derive/xch4.py
new file mode 100644
index 000000000..8082c46ec
--- /dev/null
+++ b/esmvalcore/preprocessor/_derive/xch4.py
@@ -0,0 +1,36 @@
+"""Derivation of variable ``xch4``."""
+
+from iris import Constraint
+
+from ._baseclass import DerivedVariableBase
+from ._shared import column_average
+
+
+class DerivedVariable(DerivedVariableBase):
+    """Derivation of variable ``xch4``."""
+
+    @staticmethod
+    def required(project):
+        """Declare the variables needed for derivation."""
+        required = [
+            {'short_name': 'ch4'},
+            {'short_name': 'hus'},
+            {'short_name': 'zg'},
+            {'short_name': 'ps'},
+        ]
+        return required
+
+    @staticmethod
+    def calculate(cubes):
+        """Calculate the column-averaged atmospheric CH4 [1e-9]."""
+        ch4_cube = cubes.extract_strict(
+            Constraint(name='mole_fraction_of_methane_in_air'))
+        hus_cube = cubes.extract_strict(Constraint(name='specific_humidity'))
+        zg_cube = cubes.extract_strict(Constraint(name='geopotential_height'))
+        ps_cube = cubes.extract_strict(Constraint(name='surface_air_pressure'))
+
+        # Column-averaged CH4
+        xch4_cube = column_average(ch4_cube, hus_cube, zg_cube, ps_cube)
+        xch4_cube.convert_units('1')
+
+        return xch4_cube
diff --git a/esmvalcore/preprocessor/_time.py b/esmvalcore/preprocessor/_time.py
index d32d12596..e0101bf0f 100644
--- a/esmvalcore/preprocessor/_time.py
+++ b/esmvalcore/preprocessor/_time.py
@@ -127,7 +127,6 @@ def clip_start_end_year(cube, start_year, end_year):
     ------
     ValueError
         Time ranges are outside the cube's time limits.
-
     """
     return extract_time(cube, start_year, 1, 1, end_year + 1, 1, 1)
 
@@ -220,6 +219,50 @@ def get_time_weights(cube):
     return time_weights
 
 
+def hourly_statistics(cube, hours, operator='mean'):
+    """Compute hourly statistics.
+
+    Chunks time in x hours periods and computes statistics over them.
+
+    Parameters
+    ----------
+    cube: iris.cube.Cube
+        input cube.
+
+    hours: int
+        Number of hours per period. Must be a divisor of 24
+        (1, 2, 3, 4, 6, 8, 12)
+
+    operator: str, optional
+        Select operator to apply.
+        Available operators: 'mean', 'median', 'std_dev', 'sum', 'min', 'max'
+
+    Returns
+    -------
+    iris.cube.Cube
+        Hourly statistics cube
+    """
+    if not cube.coords('hour_group'):
+        iris.coord_categorisation.add_categorised_coord(
+            cube,
+            'hour_group',
+            'time',
+            lambda coord, value: coord.units.num2date(value).hour // hours,
+            units='1')
+    if not cube.coords('day_of_year'):
+        iris.coord_categorisation.add_day_of_year(cube, 'time')
+    if not cube.coords('year'):
+        iris.coord_categorisation.add_year(cube, 'time')
+
+    operator = get_iris_analysis_operation(operator)
+    cube = cube.aggregated_by(['hour_group', 'day_of_year', 'year'], operator)
+
+    cube.remove_coord('hour_group')
+    cube.remove_coord('day_of_year')
+    cube.remove_coord('year')
+    return cube
+
+
 def daily_statistics(cube, operator='mean'):
     """Compute daily statistics.
 
@@ -803,3 +846,107 @@ def timeseries_filter(cube,
                                weights=wgts)
 
     return cube
+
+
+def resample_hours(cube, interval, offset=0):
+    """Convert x-hourly data to y-hourly by eliminating extra timesteps.
+
+    Convert x-hourly data to y-hourly (y > x) by eliminating the extra
+    timesteps. This is intended to be used only with instantaneous values.
+
+    For example:
+
+    - resample_hours(cube, interval=6): Six-hourly intervals at 0:00, 6:00,
+      12:00, 18:00.
+
+    - resample_hours(cube, interval=6, offset=3): Six-hourly intervals at
+      3:00, 9:00, 15:00, 21:00.
+
+    - resample_hours(cube, interval=12, offset=6): Twelve-hourly intervals
+      at 6:00, 18:00.
+
+    Parameters
+    ----------
+    cube: iris.cube.Cube
+        Input cube.
+    interval: int
+        The period (hours) of the desired data.
+    offset: int, optional
+        The firs hour (hours) of the desired data.
+
+    Returns
+    -------
+    iris.cube.Cube
+        Cube with the new frequency.
+
+    Raises
+    ------
+    ValueError:
+        The specified frequency is not a divisor of 24.
+    """
+    allowed_intervals = (1, 2, 3, 4, 6, 12)
+    if interval not in allowed_intervals:
+        raise ValueError(
+            f'The number of hours must be one of {allowed_intervals}')
+    if offset >= interval:
+        raise ValueError(f'The offset ({offset}) must be lower than '
+                         f'the interval ({interval})')
+    time = cube.coord('time')
+    cube_period = time.cell(1).point - time.cell(0).point
+    if cube_period.total_seconds() / 3600 >= interval:
+        raise ValueError(f"Data period ({cube_period}) should be lower than "
+                         f"the interval ({interval})")
+    hours = range(0 + offset, 24, interval)
+    select_hours = iris.Constraint(time=lambda cell: cell.point.hour in hours)
+    return cube.extract(select_hours)
+
+
+def resample_time(cube, month=None, day=None, hour=None):
+    """Change frequency of data by resampling it.
+
+    Converts data from one frequency to another by extracting the timesteps
+    that match the provided month, day and/or hour. This is meant to be used
+    with instantaneous values when computing statistics is not desired.
+
+    For example:
+
+    - resample_time(cube, hour=6): Daily values taken at 6:00.
+
+    - resample_time(cube, day=15, hour=6): Monthly values taken at 15th
+      6:00.
+
+    - resample_time(cube, month=6): Yearly values, taking in June
+
+    - resample_time(cube, month=6, day=1): Yearly values, taking 1st June
+
+    The condition must yield only one value per interval: the last two samples
+    above will produce yearly data, but the first one is meant to be used to
+    sample from monthly output and the second one will work better with daily.
+
+    Parameters
+    ----------
+    cube: iris.cube.Cube
+        Input cube.
+    month: int, optional
+        Month to extract
+    day: int, optional
+        Day to extract
+    hour: int, optional
+        Hour to extract
+
+    Returns
+    -------
+    iris.cube.Cube
+        Cube with the new frequency.
+    """
+    def compare(cell):
+        date = cell.point
+        if month is not None and month != date.month:
+            return False
+        if day is not None and day != date.day:
+            return False
+        if hour is not None and hour != date.hour:
+            return False
+        return True
+
+    return cube.extract(iris.Constraint(time=compare))
diff --git a/package/meta.yaml b/package/meta.yaml
index f5ee7471e..d69e26856 100644
--- a/package/meta.yaml
+++ b/package/meta.yaml
@@ -51,6 +51,7 @@ requirements:
     - numpy
     - prov
     - psutil
+    - pybtex
     - pydot
     - pyyaml
     - requests
@@ -82,7 +83,7 @@ test:
     - esmvalcore.cmor.check
     - esmvalcore.cmor.fix
     - esmvalcore.preprocessor
-
+    - esmvalcore.experimental
 
 about:
   home: https://www.esmvaltool.org
diff --git a/setup.py b/setup.py
index 3554240a6..03e4482cc 100755
--- a/setup.py
+++ b/setup.py
@@ -39,6 +39,7 @@ REQUIREMENTS = {
         'numpy',
         'prov[dot]',
         'psutil',
+        'pybtex',
         'pyyaml',
         'requests',
         'scitools-iris>=2.2',


Test patch:
diff --git a/tests/integration/cmor/_fixes/cmip6/test_awi_cm_1_1_mr.py b/tests/integration/cmor/_fixes/cmip6/test_awi_cm_1_1_mr.py
new file mode 100644
index 000000000..f198e2f69
--- /dev/null
+++ b/tests/integration/cmor/_fixes/cmip6/test_awi_cm_1_1_mr.py
@@ -0,0 +1,50 @@
+"""Tests for the fixes of AWI-CM-1-1-MR."""
+import iris
+import pytest
+
+from esmvalcore.cmor._fixes.cmip6.awi_cm_1_1_mr import AllVars
+from esmvalcore.cmor.fix import Fix
+
+
+@pytest.fixture
+def cubes():
+    correct_lat_coord = iris.coords.DimCoord([0.0],
+                                             var_name='lat',
+                                             standard_name='latitude',
+                                             long_name='latitude')
+    wrong_lat_coord = iris.coords.DimCoord([0.0],
+                                           var_name='latitudeCoord',
+                                           standard_name='latitude',
+                                           long_name='Latitude')
+    correct_lon_coord = iris.coords.DimCoord([0.0],
+                                             var_name='lon',
+                                             standard_name='longitude')
+    correct_cube = iris.cube.Cube(
+        [[10.0]],
+        var_name='tas',
+        dim_coords_and_dims=[(correct_lat_coord, 0), (correct_lon_coord, 1)],
+    )
+    wrong_cube = iris.cube.Cube(
+        [[10.0]],
+        var_name='ta',
+        dim_coords_and_dims=[(wrong_lat_coord, 0), (correct_lon_coord, 1)],
+    )
+    return iris.cube.CubeList([correct_cube, wrong_cube])
+
+
+def test_get_allvars_fix():
+    fix = Fix.get_fixes('CMIP6', 'AWI-CM-1-1-MR', 'Amon', 'wrong_lat_lname')
+    assert fix == [AllVars(None)]
+
+
+def test_allvars_fix_metadata(cubes):
+    fix = AllVars(None)
+    out_cubes = fix.fix_metadata(cubes)
+    assert cubes is out_cubes
+    for cube in out_cubes:
+        try:
+            lat_coord = cube.coord('latitude')
+        except iris.exceptions.CoordinateNotFoundError:
+            pass
+        else:
+            assert lat_coord.long_name == 'latitude'
diff --git a/tests/integration/cmor/_fixes/cmip6/test_ec_earth3_veg.py b/tests/integration/cmor/_fixes/cmip6/test_ec_earth3_veg.py
index 6903f10e0..8fd7372b2 100644
--- a/tests/integration/cmor/_fixes/cmip6/test_ec_earth3_veg.py
+++ b/tests/integration/cmor/_fixes/cmip6/test_ec_earth3_veg.py
@@ -1,16 +1,19 @@
-"""Tests for MIROC5."""
+"""Tests for EC-Earth3-Veg."""
 import unittest
 
+import iris
+import numpy as np
+import pytest
 from cf_units import Unit
 from iris.cube import Cube
 
-from esmvalcore.cmor._fixes.cmip6.ec_earth3_veg import Siconca
+from esmvalcore.cmor._fixes.cmip6.ec_earth3_veg import Siconca, Tas
 from esmvalcore.cmor.fix import Fix
+from esmvalcore.cmor.table import get_var_info
 
 
 class TestSiconca(unittest.TestCase):
     """Test sftof fixes."""
-
     def setUp(self):
         """Prepare tests."""
         self.cube = Cube([1.0], var_name='siconca', units='%')
@@ -18,13 +21,63 @@ class TestSiconca(unittest.TestCase):
 
     def test_get(self):
         """Test fix get."""
-        self.assertListEqual(Fix.get_fixes('CMIP6',
-                                           'EC-Earth3-Veg',
-                                           'SImon', 'siconca'),
-                             [Siconca(None)])
+        self.assertListEqual(
+            Fix.get_fixes('CMIP6', 'EC-Earth3-Veg', 'SImon', 'siconca'),
+            [Siconca(None)])
 
     def test_fix_data(self):
         """Test data fix."""
         cube = self.fix.fix_data(self.cube)
         self.assertEqual(cube.data[0], 100)
         self.assertEqual(cube.units, Unit('%'))
+
+
+@pytest.fixture
+def tas_cubes():
+    """Cubes to test fixes for ``tas``."""
+    time_coord = iris.coords.DimCoord([0.0, 1.0],
+                                      var_name='time',
+                                      standard_name='time',
+                                      units='days since 1850-01-01 00:00:00')
+    lat_coord = iris.coords.DimCoord([0.0, 1.0],
+                                     bounds=[[-0.5, 0.5], [0.5, 1.5]],
+                                     var_name='lat',
+                                     standard_name='latitude',
+                                     units='degrees')
+    lat_coord_to_round = iris.coords.DimCoord(
+        [0.0000000001, 0.9999999999],
+        bounds=[[-0.5000000001, 0.5000000001], [0.5000000001, 1.5000000001]],
+        var_name='lat',
+        standard_name='latitude',
+        units='degrees')
+    lon_coord = iris.coords.DimCoord([0.0, 1.0],
+                                     var_name='lon',
+                                     standard_name='longitude',
+                                     units='degrees')
+    tas_cube = iris.cube.Cube(
+        np.ones((2, 2, 2)),
+        var_name='tas',
+        dim_coords_and_dims=[(time_coord, 0), (lat_coord, 1), (lon_coord, 2)],
+    )
+    tas_cube_to_round = iris.cube.Cube(
+        np.ones((2, 2, 2)),
+        var_name='tas',
+        dim_coords_and_dims=[(time_coord, 0), (lat_coord_to_round, 1),
+                             (lon_coord, 2)],
+    )
+
+    return iris.cube.CubeList([tas_cube, tas_cube_to_round])
+
+
+def test_get_tas_fix():
+    """Test getting of fix."""
+    fix = Fix.get_fixes('CMIP6', 'EC-Earth3-Veg', 'Amon', 'tas')
+    assert fix == [Tas(None)]
+
+
+def test_tas_fix_metadata(tas_cubes):
+    """Test ``fix_metadata`` for ``tas``."""
+    vardef = get_var_info('CMIP6', 'Amon', 'tas')
+    fix = Tas(vardef)
+    fixed_cubes = fix.fix_metadata(tas_cubes)
+    assert fixed_cubes[0].coord('latitude') == fixed_cubes[1].coord('latitude')
diff --git a/tests/unit/experimental/test_recipe_info.py b/tests/unit/experimental/test_recipe_info.py
new file mode 100644
index 000000000..887b70c5f
--- /dev/null
+++ b/tests/unit/experimental/test_recipe_info.py
@@ -0,0 +1,48 @@
+import pytest
+
+from esmvalcore.experimental import get_recipe
+from esmvalcore.experimental.recipe_info import Contributor, Project, Reference
+
+pytest.importorskip(
+    'esmvaltool',
+    reason='The behaviour of these tests depends on what ``DIAGNOSTICS_PATH``'
+    'points to. This is defined by a forward-reference to ESMValTool, which'
+    'is not installed in the CI, but likely to be available in a developer'
+    'or user installation.')
+
+
+def test_contributor():
+    """Coverage test for Contributor."""
+    contributor = Contributor.from_tag('righi_mattia')
+
+    assert contributor.name == 'Mattia Righi'
+    assert contributor.institute == 'DLR, Germany'
+    assert contributor.orcid.startswith('https://orcid.org/')
+    assert isinstance(repr(contributor), str)
+    assert isinstance(str(contributor), str)
+
+
+def test_reference():
+    """Coverage test for Reference."""
+    reference = Reference.from_tag('acknow_project')
+
+    assert isinstance(repr(reference), str)
+    assert isinstance(str(reference), str)
+    assert isinstance(reference.render('markdown'), str)
+
+
+def test_project():
+    """Coverage test for Project."""
+    project = Project.from_tag('esmval')
+
+    assert isinstance(repr(project), str)
+    assert isinstance(str(project), str)
+
+
+def test_recipe_info():
+    """Coverage test for RecipeInfo."""
+    recipe = get_recipe('examples/recipe_python')
+
+    assert isinstance(repr(recipe), str)
+    assert isinstance(str(recipe), str)
+    assert isinstance(recipe.to_markdown(), str)
diff --git a/tests/unit/experimental/test_utils.py b/tests/unit/experimental/test_utils.py
new file mode 100644
index 000000000..33dcc0d14
--- /dev/null
+++ b/tests/unit/experimental/test_utils.py
@@ -0,0 +1,40 @@
+import pytest
+
+from esmvalcore.experimental.recipe_info import RecipeInfo
+from esmvalcore.experimental.utils import (
+    RecipeList,
+    get_all_recipes,
+    get_recipe,
+)
+
+pytest.importorskip(
+    'esmvaltool',
+    reason='The behaviour of these tests depends on what ``DIAGNOSTICS_PATH``'
+    'points to. This is defined by a forward-reference to ESMValTool, which'
+    'is not installed in the CI, but likely to be available in a developer'
+    'or user installation.')
+
+
+def test_get_recipe():
+    """Get single recipe."""
+    recipe = get_recipe('examples/recipe_python.yml')
+    assert isinstance(recipe, RecipeInfo)
+
+
+def test_get_all_recipes():
+    """Get all recipes."""
+    recipes = get_all_recipes()
+    assert isinstance(recipes, list)
+
+    recipes.find('')
+
+
+def test_recipe_list_find():
+    """Get all recipes."""
+    recipes = get_all_recipes(subdir='examples')
+
+    assert isinstance(recipes, RecipeList)
+
+    result = recipes.find('python')
+
+    assert isinstance(result, RecipeList)
diff --git a/tests/unit/preprocessor/_derive/test_shared.py b/tests/unit/preprocessor/_derive/test_shared.py
index 77e4b84f6..4c3fe686d 100644
--- a/tests/unit/preprocessor/_derive/test_shared.py
+++ b/tests/unit/preprocessor/_derive/test_shared.py
@@ -1,9 +1,60 @@
 """Unit tests for shared variable derivation functions."""
 
+import iris
 import numpy as np
 import pytest
 
-from esmvalcore.preprocessor._derive._shared import _get_pressure_level_widths
+from esmvalcore.preprocessor._derive._shared import (
+    _get_pressure_level_widths,
+    column_average,
+)
+
+
+def get_cube(data, air_pressure_coord=True, **kwargs):
+    """Get sample cube."""
+    time_coord = iris.coords.DimCoord([0.0], standard_name='time',
+                                      var_name='time',
+                                      units='days since 1950-01-01 00:00:00')
+    plev_coord = iris.coords.DimCoord([90000.0, 80000.0],
+                                      standard_name='air_pressure',
+                                      var_name='plev', units='Pa')
+    lat_coord = iris.coords.DimCoord([45.0], standard_name='latitude',
+                                     var_name='lat', units='degrees')
+    lon_coord = iris.coords.DimCoord([10.0], standard_name='longitude',
+                                     var_name='lon', units='degrees')
+    if air_pressure_coord:
+        coord_specs = [(time_coord, 0), (plev_coord, 1), (lat_coord, 2),
+                       (lon_coord, 3)]
+    else:
+        coord_specs = [(time_coord, 0), (lat_coord, 1), (lon_coord, 2)]
+    cube = iris.cube.Cube(data, dim_coords_and_dims=coord_specs, **kwargs)
+    return cube
+
+
+def test_column_average():
+    """Test calculation of column-average."""
+    cube = get_cube([[[[1.0]], [[2.0]]]], air_pressure_coord=True,
+                    var_name='ch4', units='1')
+    hus_cube = get_cube([[[[0.2]], [[0.2]]]], air_pressure_coord=True,
+                        var_name='hus', units='1')
+    zg_cube = get_cube([[[100.0]]], air_pressure_coord=False, var_name='zg',
+                       units='m')
+    ps_cube = get_cube([[[100000.0]]], air_pressure_coord=False, var_name='ps',
+                       units='Pa')
+    x_cube = column_average(cube, hus_cube, zg_cube, ps_cube)
+    assert x_cube.shape == (1, 1, 1)
+    assert x_cube.units == '1'
+    assert x_cube.coords('time')
+    assert x_cube.coords('air_pressure')
+    assert x_cube.coords('latitude')
+    assert x_cube.coords('longitude')
+    np.testing.assert_allclose(x_cube.data, [[[1.85]]])
+    np.testing.assert_allclose(x_cube.coord('time').points, [0.0])
+    np.testing.assert_allclose(x_cube.coord('air_pressure').points, 85000.0)
+    np.testing.assert_allclose(x_cube.coord('air_pressure').bounds,
+                               [[80000.0, 90000.0]])
+    np.testing.assert_allclose(x_cube.coord('latitude').points, [45.0])
+    np.testing.assert_allclose(x_cube.coord('longitude').points, [10.0])
 
 
 def test_col_is_not_monotonic():
diff --git a/tests/unit/preprocessor/_derive/test_xch4.py b/tests/unit/preprocessor/_derive/test_xch4.py
new file mode 100644
index 000000000..e9980fbca
--- /dev/null
+++ b/tests/unit/preprocessor/_derive/test_xch4.py
@@ -0,0 +1,58 @@
+"""Test derivation of ``xch4``."""
+import iris
+import numpy as np
+import pytest
+
+import esmvalcore.preprocessor._derive.xch4 as xch4
+
+from .test_shared import get_cube
+
+
+@pytest.fixture
+def cubes():
+    """Input cubes for derivation of ``xch4``."""
+    xch4_cube = get_cube([[[[1.0]], [[2.0]]]], air_pressure_coord=True,
+                         standard_name='mole_fraction_of_methane_in_air',
+                         var_name='ch4', units='1e-3')
+    hus_cube = get_cube([[[[0.2]], [[0.2]]]], air_pressure_coord=True,
+                        standard_name='specific_humidity', var_name='hus',
+                        units='%')
+    zg_cube = get_cube([[[100.0]]], air_pressure_coord=False,
+                       standard_name='geopotential_height', var_name='zg',
+
+                       units='m')
+    ps_cube = get_cube([[[100000.0]]], air_pressure_coord=False,
+                       standard_name='surface_air_pressure', var_name='ps',
+                       units='Pa')
+    return iris.cube.CubeList([xch4_cube, hus_cube, zg_cube, ps_cube])
+
+
+def test_xch4_calculate(cubes):
+    """Test function ``calculate``."""
+    derived_var = xch4.DerivedVariable()
+    out_cube = derived_var.calculate(cubes)
+    assert out_cube.shape == (1, 1, 1)
+    assert out_cube.units == '1'
+    assert out_cube.coords('time')
+    assert out_cube.coords('air_pressure')
+    assert out_cube.coords('latitude')
+    assert out_cube.coords('longitude')
+    np.testing.assert_allclose(out_cube.data, [[[1.85e-3]]])
+    np.testing.assert_allclose(out_cube.coord('time').points, [0.0])
+    np.testing.assert_allclose(out_cube.coord('air_pressure').points, 85000.0)
+    np.testing.assert_allclose(out_cube.coord('air_pressure').bounds,
+                               [[80000.0, 90000.0]])
+    np.testing.assert_allclose(out_cube.coord('latitude').points, [45.0])
+    np.testing.assert_allclose(out_cube.coord('longitude').points, [10.0])
+
+
+def test_xch4_required():
+    """Test function ``required``."""
+    derived_var = xch4.DerivedVariable()
+    output = derived_var.required(None)
+    assert output == [
+        {'short_name': 'ch4'},
+        {'short_name': 'hus'},
+        {'short_name': 'zg'},
+        {'short_name': 'ps'},
+    ]
diff --git a/tests/unit/preprocessor/_time/test_time.py b/tests/unit/preprocessor/_time/test_time.py
index 84409565a..e40054b18 100644
--- a/tests/unit/preprocessor/_time/test_time.py
+++ b/tests/unit/preprocessor/_time/test_time.py
@@ -24,8 +24,11 @@ from esmvalcore.preprocessor._time import (
     extract_season,
     extract_time,
     get_time_weights,
+    hourly_statistics,
     monthly_statistics,
     regrid_time,
+    resample_hours,
+    resample_time,
     seasonal_statistics,
     timeseries_filter,
 )
@@ -146,9 +149,8 @@ class TestTimeSlice(tests.Test):
 
 class TestClipStartEndYear(tests.Test):
     """Tests for clip_start_end_year."""
-
     def setUp(self):
-        """Prepare tests"""
+        """Prepare tests."""
         self.cube = _create_sample_cube()
 
     def test_clip_start_end_year_1_year(self):
@@ -169,9 +171,8 @@ class TestClipStartEndYear(tests.Test):
         """Test fail of clip_start_end_year."""
         with self.assertRaises(ValueError) as ctx:
             clip_start_end_year(self.cube, 2200, 2200)
-        msg = (
-            "Time slice 2200-01-01 to 2201-01-01 is outside"
-            " cube time bounds 1950-01-16 00:00:00 to 1951-12-07 00:00:00.")
+        msg = ("Time slice 2200-01-01 to 2201-01-01 is outside"
+               " cube time bounds 1950-01-16 00:00:00 to 1951-12-07 00:00:00.")
         assert ctx.exception.args == (msg, )
 
     def test_clip_start_end_year_one_time(self):
@@ -593,6 +594,69 @@ class TestMonthlyStatistics(tests.Test):
         assert_array_equal(result.data, expected)
 
 
+class TestHourlyStatistics(tests.Test):
+    """Test :func:`esmvalcore.preprocessor._time.hourly_statistics`"""
+    @staticmethod
+    def _create_cube(data, times):
+        time = iris.coords.DimCoord(times,
+                                    standard_name='time',
+                                    units=Unit('hours since 1950-01-01',
+                                               calendar='360_day'))
+        time.guess_bounds()
+        cube = iris.cube.Cube(data, dim_coords_and_dims=[(time, 0)])
+        return cube
+
+    def test_mean(self):
+        """Test average of a 1D field."""
+        data = np.arange(8)
+        times = np.arange(0, 48, 6)
+        cube = self._create_cube(data, times)
+
+        result = hourly_statistics(cube, 12, 'mean')
+        expected = np.array([0.5, 2.5, 4.5, 6.5])
+        assert_array_equal(result.data, expected)
+
+    def test_median(self):
+        """Test median of a 1D field."""
+        data = np.arange(8)
+        times = np.arange(0, 48, 6)
+        cube = self._create_cube(data, times)
+
+        result = hourly_statistics(cube, 12, 'median')
+        expected = np.array([0.5, 2.5, 4.5, 6.5])
+        assert_array_equal(result.data, expected)
+
+    def test_min(self):
+        """Test min of a 1D field."""
+        data = np.arange(8)
+        times = np.arange(0, 48, 6)
+        cube = self._create_cube(data, times)
+
+        result = hourly_statistics(cube, 12, 'min')
+        expected = np.array([0., 2., 4., 6.])
+        assert_array_equal(result.data, expected)
+
+    def test_max(self):
+        """Test max of a 1D field."""
+        data = np.arange(8)
+        times = np.arange(0, 48, 6)
+        cube = self._create_cube(data, times)
+
+        result = hourly_statistics(cube, 12, 'max')
+        expected = np.array([1., 3., 5., 7.])
+        assert_array_equal(result.data, expected)
+
+    def test_sum(self):
+        """Test sum of a 1D field."""
+        data = np.arange(8)
+        times = np.arange(0, 48, 6)
+        cube = self._create_cube(data, times)
+
+        result = hourly_statistics(cube, 12, 'sum')
+        expected = np.array([1., 5., 9., 13.])
+        assert_array_equal(result.data, expected)
+
+
 class TestDailyStatistics(tests.Test):
     """Test :func:`esmvalcore.preprocessor._time.monthly_statistics`"""
     @staticmethod
@@ -658,7 +722,6 @@ class TestDailyStatistics(tests.Test):
 
 class TestRegridTimeYearly(tests.Test):
     """Tests for regrid_time with monthly frequency."""
-
     def setUp(self):
         """Prepare tests."""
         self.cube_1 = _create_sample_cube()
@@ -1342,5 +1405,140 @@ def test_climate_statistics_complex_cube():
     np.testing.assert_allclose(new_cube.data, [[[45.0, 45.0, 45.0]]])
 
 
+class TestResampleHours(tests.Test):
+    """Test :func:`esmvalcore.preprocessor._time.resample_hours`"""
+    @staticmethod
+    def _create_cube(data, times):
+        time = iris.coords.DimCoord(times,
+                                    standard_name='time',
+                                    units=Unit('hours since 1950-01-01',
+                                               calendar='360_day'))
+        time.guess_bounds()
+        cube = iris.cube.Cube(data, dim_coords_and_dims=[(time, 0)])
+        return cube
+
+    def test_resample_1_to_6(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 48, 1)
+        times = np.arange(0, 48, 1)
+        cube = self._create_cube(data, times)
+
+        result = resample_hours(cube, 6)
+        expected = np.arange(0, 48, 6)
+        assert_array_equal(result.data, expected)
+
+    def test_resample_3_to_6(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 48, 3)
+        times = np.arange(0, 48, 3)
+        cube = self._create_cube(data, times)
+
+        result = resample_hours(cube, 6)
+        expected = np.arange(0, 48, 6)
+        assert_array_equal(result.data, expected)
+
+    def test_resample_1_to_3(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 48, 1)
+        times = np.arange(0, 48, 1)
+        cube = self._create_cube(data, times)
+
+        result = resample_hours(cube, 3)
+        expected = np.arange(0, 48, 3)
+        assert_array_equal(result.data, expected)
+
+    def test_resample_1_to_3_with_offset2(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 48, 1)
+        times = np.arange(0, 48, 1)
+        cube = self._create_cube(data, times)
+
+        result = resample_hours(cube, 3, 2)
+        expected = np.arange(2, 48, 3)
+        assert_array_equal(result.data, expected)
+
+    def test_resample_invalid(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 48, 1)
+        times = np.arange(0, 48, 1)
+        cube = self._create_cube(data, times)
+
+        with self.assertRaises(ValueError):
+            resample_hours(cube, 5)
+
+    def test_resample_invalid_offset(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 48, 1)
+        times = np.arange(0, 48, 1)
+        cube = self._create_cube(data, times)
+
+        with self.assertRaises(ValueError):
+            resample_hours(cube, interval=3, offset=6)
+
+    def test_resample_shorter_interval(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 48, 12)
+        times = np.arange(0, 48, 12)
+        cube = self._create_cube(data, times)
+
+        with self.assertRaises(ValueError):
+            resample_hours(cube, interval=3)
+
+    def test_resample_same_interval(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 48, 12)
+        times = np.arange(0, 48, 12)
+        cube = self._create_cube(data, times)
+
+        with self.assertRaises(ValueError):
+            resample_hours(cube, interval=12)
+
+
+class TestResampleTime(tests.Test):
+    """Test :func:`esmvalcore.preprocessor._time.resample_hours`"""
+    @staticmethod
+    def _create_cube(data, times):
+        time = iris.coords.DimCoord(times,
+                                    standard_name='time',
+                                    units=Unit('hours since 1950-01-01',
+                                               calendar='360_day'))
+        time.guess_bounds()
+        cube = iris.cube.Cube(data, dim_coords_and_dims=[(time, 0)])
+        return cube
+
+    def test_resample_hourly_to_daily(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 48, 1)
+        times = np.arange(0, 48, 1)
+        cube = self._create_cube(data, times)
+
+        result = resample_time(cube, hour=12)
+        expected = np.arange(12, 48, 24)
+        assert_array_equal(result.data, expected)
+
+    def test_resample_hourly_to_monthly(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 24 * 60, 3)
+        times = np.arange(0, 24 * 60, 3)
+        cube = self._create_cube(data, times)
+
+        result = resample_time(cube, hour=12, day=15)
+        expected = np.array([12 + 14 * 24, 12 + 44 * 24])
+        assert_array_equal(result.data, expected)
+
+    def test_resample_daily_to_monthly(self):
+        """Test average of a 1D field."""
+        data = np.arange(0, 60 * 24, 24)
+        times = np.arange(0, 60 * 24, 24)
+        cube = self._create_cube(data, times)
+
+        result = resample_time(cube, day=15)
+        expected = np.array([
+            14 * 24,
+            44 * 24,
+        ])
+        assert_array_equal(result.data, expected)
+
+
 if __name__ == '__main__':
     unittest.main()