Repository: ESMValGroup/ESMValTool
Pull request #3988: REF ozone recipe
Author: login: hb326
Labels: name: new recipe
description: Use this label if you are adding a new recipe
name: REF
description: Important for the CMIP Rapid Evaluation Framework (REF)
Created: 2025-04-10T12:51:04Z | Merged: 2025-04-11T11:01:31Z

## Description
This recipe provides five ozone diagnostics that are a valuable addition to the REF. Four of them are based on total column ozone, one of them is based on vertically resolved ozone.

- Closes #3884
- Doc: https://esmvaltool--3988.org.readthedocs.build/en/3988/recipes/recipe_ref.html


* * *

## Before you get started

- [x] [☝ Create an issue](https://docs.esmvaltool.org/en/latest/community/code_documentation.html#contributing-code-and-documentation) to discuss what you are going to do

## Checklist

It is the responsibility of the author to make sure the pull request is ready to review. The icons indicate whether the item will be subject to the [🛠 Technical][1] or [🧪 Scientific][2] review.

<!-- The next two lines turn the 🛠 and 🧪 below into hyperlinks -->
[1]: https://docs.esmvaltool.org/en/latest/community/review.html#technical-review
[2]: https://docs.esmvaltool.org/en/latest/community/review.html#scientific-review

- [x] [🛠][1] This pull request has a [descriptive title](https://docs.esmvaltool.org/en/latest/community/code_documentation.html#pull-request-title)
- [x] [🛠][1] Code is written according to the [code quality guidelines](https://docs.esmvaltool.org/en/latest/community/code_documentation.html#code-quality)
- [x] [🛠][1] [Documentation](https://docs.esmvaltool.org/en/latest/community/code_documentation.html#documentation) is available
- [x] [🛠][1] [Tests](https://docs.esmvaltool.org/en/latest/community/code_documentation.html#tests) run successfully
- [x] [🛠][1] The [list of authors](https://docs.esmvaltool.org/en/latest/community/code_documentation.html#list-of-authors) is up to date
- [x] [🛠][1] Any changed dependencies have been [added or removed correctly](https://docs.esmvaltool.org/en/latest/community/code_documentation.html#dependencies)
- [x] [🛠][1] All [checks below this pull request](https://docs.esmvaltool.org/en/latest/community/code_documentation.html#pull-request-checks) were successful

### [New or updated recipe/diagnostic](https://docs.esmvaltool.org/en/latest/community/diagnostic.html)

- [x] [🧪][2] [Recipe runs successfully](https://docs.esmvaltool.org/en/latest/community/diagnostic.html#testing-recipes)
- [x] [🧪][2] [Recipe is well documented](https://docs.esmvaltool.org/en/latest/community/diagnostic.html#recipe-and-diagnostic-documentation)
- [x] [🧪][2] [Figure(s) and data](https://docs.esmvaltool.org/en/latest/community/diagnostic.html#diagnostic-output) look as expected from literature
- [x] [🛠][1] [Provenance information](https://docs.esmvaltool.org/en/latest/community/diagnostic.html#recording-provenance) has been added


***

To help with the number of 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

<!--
If you need help with any of the items on the checklists above, please do not hesitate to ask by commenting in the issue or pull request.
-->


Linked issue #3884: Ozone diagnostics for REF
Ozone diagnostics/recipe

Tasks:
- [x] Include observational dataset
- [x] Write recipe
Linked issue comments: user: login: hb326
created_at: 2025-03-21T11:40:33Z
body: What we still need here is:

- Antarctic/Arctic sea ice area seasonal cycle
- Antarctic annual mean/Arctic September rate of sea ice area (SIA) loss per degree warming (dSIA/dGMST)

@alistairsellar kindly agreed (right?) to tackle the second diagnostics, but we are still missing a volunteer for the first diagnostics.
@ESMValGroup/esmvaltool-developmentteam: anybody has some time on their hands for coding a (relatively) simple ocean diagnostic? 
author_association: CONTRIBUTOR
user: login: axel-lauer
created_at: 2025-03-21T11:54:55Z
body: I think I can adjust my REF sea ice recipe to plot sea ice area instead of sea ice extent.
author_association: CONTRIBUTOR
user: login: hb326
created_at: 2025-03-21T12:01:50Z
body: > I think I can adjust my REF sea ice recipe to plot sea ice area instead of sea ice extent.

Perfect! Thanks @axel-lauer!!
author_association: CONTRIBUTOR
user: login: malininae
created_at: 2025-03-21T18:34:19Z
body: > What we still need here is:
> 
> * Antarctic/Arctic sea ice area seasonal cycle
> * Antarctic annual mean/Arctic September rate of sea ice area (SIA) loss per degree warming (dSIA/dGMST)
> 
> [@alistairsellar](https://github.com/alistairsellar) kindly agreed (right?) to tackle the second diagnostics, but we are still missing a volunteer for the first diagnostics. [@ESMValGroup/esmvaltool-developmentteam](https://github.com/orgs/ESMValGroup/teams/esmvaltool-developmentteam): anybody has some time on their hands for coding a (relatively) simple ocean diagnostic?

Hi all, in particular @alistairsellar, I had a diagnostic for IPCC [see this](https://github.com/ESMValGroup/ESMValTool-AR6-OriginalCode-FinalFigures/blob/main/IPCC_README_files/README_AR6_WG1_Chap3_Fig3_20_sea_ice_climatology_and_trend.md) for sea ice trend. It's a bit different, but feel free to use it as a reference if needed. 

What is exactly needed for the first one? I could maybe look into it next week. 
author_association: CONTRIBUTOR
user: login: alistairsellar
created_at: 2025-04-04T12:14:36Z
body: > What we still need here is:
> 
> * Antarctic/Arctic sea ice area seasonal cycle
> * Antarctic annual mean/Arctic September rate of sea ice area (SIA) loss per degree warming (dSIA/dGMST)
> 
> [@alistairsellar](https://github.com/alistairsellar) kindly agreed (right?) to tackle the second diagnostics, but we are still missing a volunteer for the first diagnostics. 

Yes, that's right, and have made a start. See #3962 for questions and proposals.


author_association: CONTRIBUTOR
user: login: axel-lauer
created_at: 2025-04-04T12:47:41Z
body: Sea ice area seasonal cycle is included here: https://github.com/ESMValGroup/ESMValTool/blob/main/esmvaltool/recipes/ref/recipe_ref_sea_ice_area_basic.yml
author_association: CONTRIBUTOR

Diff:
diff --git a/doc/sphinx/source/recipes/figures/ref/zonal_mean_profile_o3_CNRM-ESM2-1_historical.png b/doc/sphinx/source/recipes/figures/ref/zonal_mean_profile_o3_CNRM-ESM2-1_historical.png
new file mode 100644
index 0000000000..ea24becf5e
Binary files /dev/null and b/doc/sphinx/source/recipes/figures/ref/zonal_mean_profile_o3_CNRM-ESM2-1_historical.png differ
diff --git a/doc/sphinx/source/recipes/recipe_ref.rst b/doc/sphinx/source/recipes/recipe_ref.rst
index aa0d3b2e08..9e354ff270 100644
--- a/doc/sphinx/source/recipes/recipe_ref.rst
+++ b/doc/sphinx/source/recipes/recipe_ref.rst
@@ -28,6 +28,11 @@ Recipes are stored in recipes
 * ref/recipe_ref_sea_ice_area_basic.yml:
   Seasonal cycle of Arctic (NH) and Antarctic (SH) sea ice area, time series
   of Arctic September (NH) and Antarctic February (SH) sea ice area
+* ref/recipe_ref_ozone.yml:
+  NH/SH polar cap (60 degrees to 90 degrees) March/September total column ozone
+  time series, zonal mean total column ozone vs. time map plot, zonal mean
+  total column ozone vs. annual cycle map plot, altitude vs. zonal mean
+  ozone profile climatology map plot
 
 
 Example plots:
@@ -69,3 +74,11 @@ Example plots:
 
    Time series of Arctic (NH) September (NH) sea ice area from MPI-ESM1-2-LR
    (red line) compared with OSISAF/CCI (blue line). Created with recipe_ref_sea_ice_area_basic.yml.
+
+.. _fig_ref_6:
+.. figure::  /recipes/figures/ref/zonal_mean_profile_o3_CNRM-ESM2-1_historical.png
+   :align:   center
+   :width:   8cm
+
+   Zonal mean vertically resolved ozone climatology from CNRM-ESM2-1 compared with ESACCI-OZONE
+   for the years 1990 to 2000. Created with recipe_ref_ozone.yml.
diff --git a/esmvaltool/diag_scripts/monitor/multi_datasets.py b/esmvaltool/diag_scripts/monitor/multi_datasets.py
index 2fa0c67b32..3f984969dc 100644
--- a/esmvaltool/diag_scripts/monitor/multi_datasets.py
+++ b/esmvaltool/diag_scripts/monitor/multi_datasets.py
@@ -75,6 +75,15 @@
       :func:`esmvalcore.preprocessor.regrid_time` and
       :func:`esmvalcore.preprocessor.regrid` for this). Input data
       needs to be 2D with dimensions `time`, `latitude`/`longitude`.
+    - Hovmoeller annual cycle vs. latitude or longitude (plot type
+      ``hovmoeller_anncyc_vs_lat_or_lon``): for each variable and dataset, an
+      individual figure is plotted. If a reference dataset is defined, also
+      include this dataset and a bias plot into the figure. Note that if a
+      reference dataset is defined, all input datasets need to be given on the
+      same temporal and horizontal grid (you can use the preprocessors
+      :func:`esmvalcore.preprocessor.regrid_time` and
+      :func:`esmvalcore.preprocessor.regrid` for this). Input data
+      needs to be 2D with dimensions `month_number`, `latitude`/`longitude`.
     - Benchmarking plot annual cycles (``benchmarking_annual_cycle``):
       Same as plot type ``annual_cycle`` but including the range of metric
       results from an ensemble of models as shading.
@@ -127,7 +136,8 @@
     Plot types plotted by this diagnostic (see list above). Dictionary keys
     must be ``timeseries``, ``annual_cycle``, ``map``, ``zonal_mean_profile``,
     ``1d_profile``, ``variable_vs_lat``, ``hovmoeller_z_vs_time``,
-    ``hovmoeller_time_vs_lat_or_lon``. Dictionary values are dictionaries used
+    ``hovmoeller_time_vs_lat_or_lon``, ``hovmoeller_anncyc_vs_lat_or_lon``.
+    Dictionary values are dictionaries used
     as options for the corresponding plot. The allowed options for the
     different plot types are given below.
 plot_filename: str, optional
@@ -655,6 +665,84 @@
     the x-axis ``time_on: x-axis``. Default orientation is time on y-axis and
     lat/lon on x-axis.
 
+Configuration options for plot type ``hovmoeller_anncyc_vs_lat_or_lon``
+-----------------------------------------------------------------------
+cbar_label: str, optional (default: '{short_name} [{units}]')
+    Colorbar label. Can include facets in curly brackets which will be derived
+    from the corresponding dataset, e.g., ``{project}``, ``{short_name}``,
+    ``{exp}``.
+cbar_label_bias: str, optional (default: 'Δ{short_name} [{units}]')
+    Colorbar label for plotting biases. Can include facets in curly brackets
+    which will be derived from the corresponding dataset, e.g., ``{project}``,
+    ``{short_name}``, ``{exp}``. This option has no effect if no reference
+    dataset is given.
+cbar_kwargs: dict, optional
+    Optional keyword arguments for :func:`matplotlib.pyplot.colorbar`. By
+    default, uses ``orientation: vertical``.
+cbar_kwargs_bias: dict, optional
+    Optional keyword arguments for :func:`matplotlib.pyplot.colorbar` for
+    plotting biases. These keyword arguments update (and potentially overwrite)
+    the ``cbar_kwargs`` for the bias plot. This option has no effect if no
+    reference dataset is given.
+common_cbar: bool, optional (default: False)
+    Use a common colorbar for the top panels (i.e., plots of the dataset and
+    the corresponding reference dataset) when using a reference dataset. If
+    neither ``vmin`` and ``vmix`` nor ``levels`` is given in ``plot_kwargs``,
+    the colorbar bounds are inferred from the dataset in the top left panel,
+    which might lead to an inappropriate colorbar for the reference dataset
+    (top right panel). Thus, the use of the ``plot_kwargs`` ``vmin`` and
+    ``vmax`` or ``levels`` is highly recommend when using this ``common_cbar:
+    true``. This option has no effect if no reference dataset is given.
+fontsize: int, optional (default: None)
+    Fontsize used for ticks, labels and titles. For the latter, use the given
+    fontsize plus 2. Does not affect suptitles. If not given, use default
+    matplotlib values. For a more fine-grained definition of fontsizes, use the
+    option ``matplotlib_rc_params`` (see above).
+plot_func: str, optional (default: 'contourf')
+    Plot function used to plot the profiles. Must be a function of
+    :mod:`iris.plot` that supports plotting of 2D cubes with coordinates
+    latitude and height/air_pressure.
+plot_kwargs: dict, optional
+    Optional keyword arguments for the plot function defined by ``plot_func``.
+    Dictionary keys are elements identified by ``facet_used_for_labels`` or
+    ``default``, e.g., ``CMIP6`` if ``facet_used_for_labels: project`` or
+    ``historical`` if ``facet_used_for_labels: exp``. Dictionary values are
+    dictionaries used as keyword arguments for the plot function defined by
+    ``plot_func``. String arguments can include facets in curly brackets which
+    will be derived from the corresponding dataset, e.g., ``{project}``,
+    ``{short_name}``, ``{exp}``. Examples: ``default: {levels: 2}, CMIP6:
+    {vmin: 200, vmax: 250}``. In addition to the normalization_ options
+    supported by the plot function, the option ``norm: centered`` can be
+    specified. In this case, the keywords ``vcenter`` and ``halfrange`` should
+    be used instead of ``vmin`` or ``vmax`` (see
+    :class:`~matplotlib.colors.CenteredNorm`).
+plot_kwargs_bias: dict, optional
+    Optional keyword arguments for the plot function defined by ``plot_func``
+    for plotting biases. These keyword arguments update (and potentially
+    overwrite) the ``plot_kwargs`` for the bias plot. This option has no effect
+    if no reference dataset is given. See option ``plot_kwargs`` for more
+    details. By default, uses ``cmap: bwr`` and ``norm: centered``.
+pyplot_kwargs: dict, optional
+    Optional calls to functions of :mod:`matplotlib.pyplot`. Dictionary keys
+    are functions of :mod:`matplotlib.pyplot`. Dictionary values are used as
+    argument(s) for these functions (if values are dictionaries, these are
+    interpreted as keyword arguments; otherwise a single argument is assumed).
+    String arguments can include facets in curly brackets which will be derived
+    from the corresponding dataset, e.g., ``{project}``, ``{short_name}``,
+    ``{exp}``. Examples: ``title: 'Awesome Plot of {long_name}'``, ``xlabel:
+    '{short_name}'``, ``xlim: [0, 5]``.
+rasterize: bool, optional (default: True)
+    If ``True``, use rasterization_ for profile plots to produce smaller files.
+    This is only relevant for vector graphics (e.g., ``output_file_type:
+    pdf,svg,ps``).
+show_y_minor_ticks: bool, optional (default: True)
+    Show minor ticks for time on the Y axis.
+show_x_minor_ticks: bool, optional (default: True)
+    Show minor ticks for latitude or longitude on the X axis.
+time_on: str, optional (default: y-axis)
+    Optional switch to change the orientation of the plot so that time is on
+    the x-axis ``time_on: x-axis``. Default orientation is time on y-axis and
+    lat/lon on x-axis.
 
 Configuration options for plot type ``benchmarking_annual_cycle``
 -----------------------------------------------------------------
@@ -922,6 +1010,7 @@ def __init__(self, config):
             "variable_vs_lat",
             "hovmoeller_z_vs_time",
             "hovmoeller_time_vs_lat_or_lon",
+            "hovmoeller_anncyc_vs_lat_or_lon",
             "benchmarking_annual_cycle",
             "benchmarking_boxplot",
             "benchmarking_diurnal_cycle",
@@ -1178,6 +1267,34 @@ def __init__(self, config):
                 self.plots[plot_type].setdefault("time_format", None)
                 self.plots[plot_type].setdefault("time_on", "y-axis")
 
+            elif plot_type == "hovmoeller_anncyc_vs_lat_or_lon":
+                self.plots[plot_type].setdefault(
+                    "cbar_label", "{short_name} [{units}]"
+                )
+                self.plots[plot_type].setdefault(
+                    "cbar_label_bias", "Δ{short_name} [{units}]"
+                )
+                self.plots[plot_type].setdefault(
+                    "cbar_kwargs", {"orientation": "vertical"}
+                )
+                self.plots[plot_type].setdefault("cbar_kwargs_bias", {})
+                self.plots[plot_type].setdefault("common_cbar", False)
+                self.plots[plot_type].setdefault("fontsize", None)
+                self.plots[plot_type].setdefault("plot_func", "contourf")
+                self.plots[plot_type].setdefault("plot_kwargs", {})
+                self.plots[plot_type].setdefault("plot_kwargs_bias", {})
+                self.plots[plot_type]["plot_kwargs_bias"].setdefault(
+                    "cmap", "bwr"
+                )
+                self.plots[plot_type]["plot_kwargs_bias"].setdefault(
+                    "norm", "centered"
+                )
+                self.plots[plot_type].setdefault("pyplot_kwargs", {})
+                self.plots[plot_type].setdefault("rasterize", True)
+                self.plots[plot_type].setdefault("show_y_minor_ticks", True)
+                self.plots[plot_type].setdefault("show_x_minor_ticks", True)
+                self.plots[plot_type].setdefault("time_on", "y-axis")
+
         # Check that facet_used_for_labels is present for every dataset
         for dataset in self.input_data:
             if self.cfg["facet_used_for_labels"] not in dataset:
@@ -2357,6 +2474,204 @@ def _plot_hovmoeller_time_vs_lat_or_lon_without_ref(
         netcdf_path = get_diagnostic_filename(Path(plot_path).stem, self.cfg)
         return (plot_path, {netcdf_path: cube})
 
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_with_ref(
+        self, plot_func, dataset, ref_dataset
+    ):
+        """Plot the hovmoeller profile for single dataset with reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots with reference dataset '%s' for '%s'",
+            self._get_label(ref_dataset),
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        ref_cube = ref_dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        self._check_cube_dimensions(ref_cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create single figure with multiple axes
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            gridspec = GridSpec(
+                5, 4, figure=fig, height_ratios=[1.0, 1.0, 0.4, 1.0, 1.0]
+            )
+
+            # Options used for all subplots
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            fontsize = (
+                self.plots[plot_type]["fontsize"]
+                or mpl.rcParams["axes.labelsize"]
+            )
+
+            # Plot dataset (top left)
+            axes_data = fig.add_subplot(gridspec[0:2, 0:2])
+            plot_kwargs["axes"] = axes_data
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = list(dim_coords_dat)
+                x_label = "month"
+                y_label = non_time_label
+            else:
+                plot_kwargs["coords"] = list(reversed(dim_coords_dat))
+                x_label = non_time_label
+                y_label = "month"
+            plot_data = plot_func(cube, **plot_kwargs)
+            axes_data.set_title(self._get_label(dataset), pad=3.0)
+            axes_data.set_ylabel(y_label)
+            if self.plots[plot_type]["show_y_minor_ticks"]:
+                axes_data.get_yaxis().set_minor_locator(AutoMinorLocator())
+            if self.plots[plot_type]["show_x_minor_ticks"]:
+                axes_data.get_xaxis().set_minor_locator(AutoMinorLocator())
+            self._process_pyplot_kwargs(plot_type, dataset)
+
+            # Plot reference dataset (top right)
+            # Note: make sure to use the same vmin and vmax than the top left
+            # plot if a common colorbar is desired
+            axes_ref = fig.add_subplot(
+                gridspec[0:2, 2:4], sharex=axes_data, sharey=axes_data
+            )
+            plot_kwargs["axes"] = axes_ref
+            if self.plots[plot_type]["common_cbar"]:
+                plot_kwargs.setdefault("vmin", plot_data.get_clim()[0])
+                plot_kwargs.setdefault("vmax", plot_data.get_clim()[1])
+            plot_ref = plot_func(ref_cube, **plot_kwargs)
+            axes_ref.set_title(self._get_label(ref_dataset), pad=3.0)
+            plt.setp(axes_ref.get_yticklabels(), visible=False)
+            self._process_pyplot_kwargs(plot_type, ref_dataset)
+
+            # Add colorbar(s)
+            self._add_colorbar(
+                plot_type,
+                plot_data,
+                plot_ref,
+                axes_data,
+                axes_ref,
+                dataset,
+                ref_dataset,
+            )
+
+            # Plot bias (bottom center)
+            bias_cube = cube - ref_cube
+            axes_bias = fig.add_subplot(
+                gridspec[3:5, 1:3], sharex=axes_data, sharey=axes_data
+            )
+            plot_kwargs_bias = self._get_plot_kwargs(
+                plot_type, dataset, bias=True
+            )
+            plot_kwargs_bias["axes"] = axes_bias
+            plot_kwargs_bias["coords"] = plot_kwargs["coords"]
+            plot_bias = plot_func(bias_cube, **plot_kwargs_bias)
+            axes_bias.set_title(
+                f"{self._get_label(dataset)} - {self._get_label(ref_dataset)}",
+                pad=3.0,
+            )
+            axes_bias.set_xlabel(x_label)
+            axes_bias.set_ylabel(y_label)
+            cbar_kwargs_bias = self._get_cbar_kwargs(plot_type, bias=True)
+            cbar_bias = fig.colorbar(
+                plot_bias, ax=axes_bias, **cbar_kwargs_bias
+            )
+            cbar_bias.set_label(
+                self._get_cbar_label(plot_type, dataset, bias=True),
+                fontsize=fontsize,
+            )
+            cbar_bias.ax.tick_params(labelsize=fontsize)
+
+            # Customize plot
+            fig.suptitle(dataset["long_name"])
+            self._process_pyplot_kwargs(plot_type, dataset)
+
+            # Rasterization
+            if self.plots[plot_type]["rasterize"]:
+                self._set_rasterized([axes_data, axes_ref, axes_bias])
+
+        # File paths
+        plot_path = self.get_plot_path(plot_type, dataset)
+        netcdf_path = get_diagnostic_filename(
+            Path(plot_path).stem + "_{pos}", self.cfg
+        )
+        netcdf_paths = {
+            netcdf_path.format(pos="top_left"): cube,
+            netcdf_path.format(pos="top_right"): ref_cube,
+            netcdf_path.format(pos="bottom"): bias_cube,
+        }
+
+        return (plot_path, netcdf_paths)
+
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_without_ref(
+        self, plot_func, dataset
+    ):
+        """Plot annual cylce vs zonal or meridional Hovmoeller without reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots without reference dataset for '%s'",
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create plot with desired settings
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            axes = fig.add_subplot()
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            plot_kwargs["axes"] = axes
+
+            # Put time on desired axis
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = list(dim_coords_dat)
+                x_label = "month"
+                y_label = non_time_label
+            else:
+                plot_kwargs["coords"] = list(reversed(dim_coords_dat))
+                x_label = non_time_label
+                y_label = "month"
+            plot_hovmoeller = plot_func(cube, **plot_kwargs)
+
+            # Setup colorbar
+            fontsize = (
+                self.plots[plot_type]["fontsize"]
+                or mpl.rcParams["axes.labelsize"]
+            )
+            colorbar = fig.colorbar(
+                plot_hovmoeller, ax=axes, **self._get_cbar_kwargs(plot_type)
+            )
+            colorbar.set_label(
+                self._get_cbar_label(plot_type, dataset), fontsize=fontsize
+            )
+            colorbar.ax.tick_params(labelsize=fontsize)
+
+            # Customize plot
+            axes.set_title(self._get_label(dataset))
+            fig.suptitle(dataset["long_name"])
+            axes.set_xlabel(x_label)
+            axes.set_ylabel(y_label)
+            if self.plots[plot_type]["show_y_minor_ticks"]:
+                axes.get_yaxis().set_minor_locator(AutoMinorLocator())
+            if self.plots[plot_type]["show_x_minor_ticks"]:
+                axes.get_xaxis().set_minor_locator(AutoMinorLocator())
+            self._process_pyplot_kwargs(plot_type, dataset)
+
+            # Rasterization
+            if self.plots[plot_type]["rasterize"]:
+                self._set_rasterized([axes])
+
+        # File paths
+        plot_path = self.get_plot_path(plot_type, dataset)
+        netcdf_path = get_diagnostic_filename(Path(plot_path).stem, self.cfg)
+        return (plot_path, {netcdf_path: cube})
+
     def _plot_benchmarking_map(
         self, plot_func, dataset, percentile_dataset, metric
     ):
@@ -2625,6 +2940,10 @@ def _check_cube_dimensions(cube, plot_type):
                 ["time", "latitude"],
                 ["time", "longitude"],
             ),
+            "hovmoeller_anncyc_vs_lat_or_lon": (
+                ["month_number", "latitude"],
+                ["month_number", "longitude"],
+            ),
         }
         if plot_type not in expected_dimensions_dict:
             raise NotImplementedError(f"plot_type '{plot_type}' not supported")
@@ -3078,7 +3397,7 @@ def create_annual_cycle_plot(self, datasets):
         # Default plot appearance
         multi_dataset_facets = self._get_multi_dataset_facets(datasets)
         axes.set_title(multi_dataset_facets["long_name"])
-        axes.set_xlabel("Month")
+        axes.set_xlabel("month")
         axes.set_ylabel(
             f"{multi_dataset_facets[self.cfg['group_variables_by']]} "
             f"[{multi_dataset_facets['units']}]"
@@ -3181,7 +3500,7 @@ def create_benchmarking_annual(self, datasets):
         # Default plot appearance
         multi_dataset_facets = self._get_multi_dataset_facets(datasets)
         axes.set_title(multi_dataset_facets["long_name"])
-        axes.set_xlabel("Month")
+        axes.set_xlabel("month")
         axes.set_ylabel(
             f"{multi_dataset_facets[self.cfg['group_variables_by']]} "
             f"[{multi_dataset_facets['units']}]"
@@ -4144,6 +4463,83 @@ def create_hovmoeller_time_vs_lat_or_lon_plot(self, datasets):
                 for netcdf_path in netcdf_paths:
                     provenance_logger.log(netcdf_path, provenance_record)
 
+    def create_hovmoeller_anncyc_vs_lat_or_lon_plot(self, datasets):
+        """Create the Hovmoeller plot with annual cycle vs latitude or longitude."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        if plot_type not in self.plots:
+            return
+
+        if not datasets:
+            raise ValueError(f"No input data to plot '{plot_type}' given")
+
+        # Get reference dataset if possible
+        ref_dataset = self._get_reference_dataset(datasets)
+        if ref_dataset is None:
+            logger.info("Plotting %s without reference dataset", plot_type)
+        else:
+            logger.info(
+                "Plotting %s with reference dataset '%s'",
+                plot_type,
+                self._get_label(ref_dataset),
+            )
+
+        # Get plot function
+        plot_func = self._get_plot_func(plot_type)
+
+        # Create a single plot for each dataset (incl. reference dataset if
+        # given)
+        for dataset in datasets:
+            if dataset == ref_dataset:
+                continue
+            ancestors = [dataset["filename"]]
+            if ref_dataset is None:
+                (plot_path, netcdf_paths) = (
+                    self._plot_hovmoeller_anncyc_vs_lat_or_lon_without_ref(
+                        plot_func, dataset
+                    )
+                )
+                caption = (
+                    f"Hovmoeller plot of {dataset['long_name']} of dataset "
+                    f"{dataset['alias']}."
+                )
+            else:
+                (plot_path, netcdf_paths) = (
+                    self._plot_hovmoeller_anncyc_vs_lat_or_lon_with_ref(
+                        plot_func, dataset, ref_dataset
+                    )
+                )
+                caption = (
+                    f"Hovmoeller plot of {dataset['long_name']} of dataset "
+                    f"{dataset['alias']} including bias relative to "
+                    f"{ref_dataset['alias']}."
+                )
+                ancestors.append(ref_dataset["filename"])
+
+            # Save plot
+            plt.savefig(plot_path, **self.cfg["savefig_kwargs"])
+            logger.info("Wrote %s", plot_path)
+            plt.close()
+
+            # Save netCDFs
+            for netcdf_path, cube in netcdf_paths.items():
+                io.iris_save(cube, netcdf_path)
+
+            # Provenance tracking
+            provenance_record = {
+                "ancestors": ancestors,
+                "authors": [
+                    "schlund_manuel",
+                    "hassler_birgit",
+                ],
+                "caption": caption,
+                "plot_types": ["zonal"],
+                "long_names": [dataset["long_name"]],
+            }
+            with ProvenanceLogger(self.cfg) as provenance_logger:
+                provenance_logger.log(plot_path, provenance_record)
+                for netcdf_path in netcdf_paths:
+                    provenance_logger.log(netcdf_path, provenance_record)
+
     def compute(self):
         """Plot preprocessed data."""
         with mpl.rc_context(self.cfg["matplotlib_rc_params"]):
@@ -4164,6 +4560,7 @@ def compute(self):
                 self.create_variable_vs_lat_plot(datasets)
                 self.create_hovmoeller_z_vs_time_plot(datasets)
                 self.create_hovmoeller_time_vs_lat_or_lon_plot(datasets)
+                self.create_hovmoeller_anncyc_vs_lat_or_lon_plot(datasets)
 
 
 def main():
diff --git a/esmvaltool/recipes/ref/recipe_ref_ozone.yml b/esmvaltool/recipes/ref/recipe_ref_ozone.yml
new file mode 100644
index 0000000000..918715df90
--- /dev/null
+++ b/esmvaltool/recipes/ref/recipe_ref_ozone.yml
@@ -0,0 +1,249 @@
+# ESMValTool
+---
+documentation:
+  title: Model evaluation with focus on total column and stratospheric ozone.
+  description: >
+    Plot climatologies, zonal means, and selected monthly means for total
+    column ozone and stratospheric ozone for the REF.
+  authors:
+    - schlund_manuel
+    - hassler_birgit
+  maintainer:
+    - hassler_birgit
+
+
+preprocessors:
+
+  create_time_series_SH:
+    annual_statistics:
+      operator: mean
+    area_statistics:
+      operator: mean
+    extract_region:
+      start_latitude: -90
+      end_latitude: -60
+      start_longitude: 0
+      end_longitude: 360
+    extract_month:
+      month: 10
+    convert_units:
+      units: DU
+
+  create_time_series_NH:
+    annual_statistics:
+      operator: mean
+    area_statistics:
+      operator: mean
+    extract_region:
+      start_latitude: 60
+      end_latitude: 90
+      start_longitude: 0
+      end_longitude: 360
+    extract_month:
+      month: 3
+    convert_units:
+      units: DU
+
+  create_lat_time_map:
+    regrid:
+      scheme: linear
+      target_grid: 5x5
+    regrid_time:
+      calendar: standard
+    zonal_statistics:
+      operator: mean
+    convert_units:
+      units: DU
+
+  create_lat_month_map:
+    regrid:
+      scheme: linear
+      target_grid: 5x5
+    climate_statistics:
+      operator: mean
+      period: month
+    zonal_statistics:
+      operator: mean
+    convert_units:
+      units: DU
+
+  create_zonal_mean_profile:
+    zonal_statistics:
+      operator: mean
+    regrid:
+      scheme: linear
+      target_grid: 5x5
+    extract_levels:
+      levels: [15000, 10000, 7000, 5000, 3000, 2000, 1000, 500, 100]
+      scheme: linear_extrapolate
+      coordinate: air_pressure
+    climate_statistics:
+      operator: mean
+      period: full
+    convert_units:
+      units: ppm
+
+
+
+# Note: the following model is just an example
+datasets:
+  - {project: CMIP6, dataset: CNRM-ESM2-1, grid: gr, ensemble: r1i1p1f2, exp: historical, alias: CNRM-ESM2-1}
+
+timerange_for_toz_monthly_data: &time_period_toz_monthly
+  timerange: '1950/2014'  # can be specified, this is just an example
+timerange_for_toz_zonal_mean: &time_period_toz_zonalmean
+  timerange: '1997/2014'  # is chosen to start with the start of the obs data
+timerange_for_ozone_profile: &time_period_o3_profile
+  timerange: '1990/2000'  # can be specified, this is just an example
+
+
+diagnostics:
+# Total column ozone plots
+  polar_cap_time_series_SH:
+    variables:
+      toz:
+        mip: AERmon
+        exp: historical
+        preprocessor: create_time_series_SH
+        <<: *time_period_toz_monthly
+    additional_datasets:
+      - {project: OBS6, dataset: ESACCI-OZONE, type: sat, version: L3, tier: 2, mip: AERmon, timerange: '1997/2022', alias: ESACCI-OZONE}
+    scripts:
+      plot:
+        script: monitor/multi_datasets.py
+        plot_folder: '{plot_dir}'
+        plot_filename: '{plot_type}_{real_name}_SH_Oct'
+        facet_used_for_labels: alias
+        savefig_kwargs:
+          dpi: 150
+          bbox_inches: tight
+          orientation: landscape
+        plots:
+          timeseries:
+            annual_mean_kwargs: false
+            plot_kwargs:
+              OBS:
+                color: black
+            pyplot_kwargs:
+              title: Total Column Ozone, 60-90S, October
+
+  polar_cap_time_series_NH:
+    variables:
+      toz:
+        mip: AERmon
+        exp: historical
+        preprocessor: create_time_series_NH
+        facet_used_for_labels: alias
+        <<: *time_period_toz_monthly
+    additional_datasets:
+      - {project: OBS6, dataset: ESACCI-OZONE, type: sat, version: L3, tier: 2, mip: AERmon, timerange: '1997/2022', alias: ESACCI-OZONE}
+    scripts:
+      plot:
+        script: monitor/multi_datasets.py
+        plot_folder: '{plot_dir}'
+        plot_filename: '{plot_type}_{real_name}_NH_MAR'
+        facet_used_for_labels: alias
+        savefig_kwargs:
+          dpi: 150
+          bbox_inches: tight
+          orientation: landscape
+        plots:
+          timeseries:
+            annual_mean_kwargs: false
+            plot_kwargs:
+              OBS:
+                color: black
+            pyplot_kwargs:
+              title: Total Column Ozone, 60-90N, March
+
+  lat_time_mapplot:
+    variables:
+      toz:
+        mip: AERmon
+        exp: historical
+        preprocessor: create_lat_time_map
+        <<: *time_period_toz_zonalmean
+        additional_datasets:
+          - {project: OBS6, dataset: ESACCI-OZONE, type: sat, version: L3, tier: 2, mip: AERmon, reference_for_monitor_diags: true, alias: ESACCI-OZONE}
+    scripts:
+      plot:
+        script: monitor/multi_datasets.py
+        facet_used_for_labels: alias
+        plot_folder: '{plot_dir}'
+        plot_filename: '{plot_type}_{real_name}_LatTime_Map'
+        matplotlib_rc_params:
+          xtick.labelsize: 6
+          ytick.labelsize: 8
+          axes.labelsize: 8
+          axes.titlesize: 8
+        savefig_kwargs:
+          dpi: 150
+          bbox_inches: tight
+          orientation: landscape
+        plots:
+          hovmoeller_time_vs_lat_or_lon:
+            common_cbar: true
+            show_x_minor_ticks: false
+            time_format: '%Y'
+            time_on: x-axis
+
+  lat_month_mapplot:
+    variables:
+      toz:
+        mip: AERmon
+        exp: historical
+        preprocessor: create_lat_month_map
+        <<: *time_period_toz_zonalmean
+        additional_datasets:
+          - {project: OBS6, dataset: ESACCI-OZONE, type: sat, version: L3, tier: 2, mip: AERmon, reference_for_monitor_diags: true, alias: ESACCI-OZONE}
+    scripts:
+      plot:
+        script: monitor/multi_datasets.py
+        facet_used_for_labels: alias
+        plot_folder: '{plot_dir}'
+        plot_filename: '{plot_type}_{real_name}_LatTime_Map'
+        matplotlib_rc_params:
+          xtick.labelsize: 6
+          ytick.labelsize: 8
+          axes.labelsize: 8
+          axes.titlesize: 8
+        savefig_kwargs:
+          dpi: 150
+          bbox_inches: tight
+          orientation: landscape
+        plots:
+          hovmoeller_anncyc_vs_lat_or_lon:
+            common_cbar: true
+            show_x_minor_ticks: false
+            time_on: x-axis
+
+# Ozone profile plot
+  zonal_mean_profiles:
+    variables:
+      o3:
+        mip: Amon
+        exp: historical
+        preprocessor: create_zonal_mean_profile
+        <<: *time_period_o3_profile
+        additional_datasets:
+          - {project: OBS6, dataset: ESACCI-OZONE, type: sat, version: L3, tier: 2, reference_for_monitor_diags: true, mip: AERmon, alias: ESACCI-OZONE}
+    scripts:
+      plot:
+        script: monitor/multi_datasets.py
+        plot_folder: '{plot_dir}'
+        plot_filename: '{plot_type}_{real_name}_{dataset}_{exp}'
+        facet_used_for_labels: alias
+        savefig_kwargs:
+          dpi: 150
+          bbox_inches: tight
+          orientation: landscape
+        plots:
+          zonal_mean_profile:
+            common_cbar: true
+            show_stats: false
+            fontsize: 9
+            pyplot_kwargs:
+              suptitle: '{long_name} (1990-2000 mean)'
+            plot_kwargs:
+              default:
+                cmap: YlOrRd


Reviews:
user: login: schlunma
body: Thanks Birgit, couple of comments, then I will test it 👍 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T07:07:12Z
user: login: hb326
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T09:07:18Z
user: login: hb326
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T09:12:29Z
user: login: schlunma
body: Thanks Birgit, doc looks great and recipe works as expected! This can be merged 🚀 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T11:01:21Z

Review comments:
diff_hunk: @@ -28,6 +28,11 @@ Recipes are stored in recipes
 * ref/recipe_ref_sea_ice_area_basic.yml:
   Seasonal cycle of Arctic (NH) and Antarctic (SH) sea ice area, time series
   of Arctic September (NH) and Antarctic February (SH) sea ice area
+* ref/recipe_ref_ozone.yml:
+  NH/SH polar cap (60 degrees to 90 degrees) March/September total column 
path: doc/sphinx/source/recipes/recipe_ref.rst
user: login: schlunma
body: ```suggestion
  NH/SH polar cap (60 degrees to 90 degrees) March/September total column ozone
```
created_at: 2025-04-11T06:48:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -69,3 +74,11 @@ Example plots:
 
    Time series of Arctic (NH) September (NH) sea ice area from MPI-ESM1-2-LR
    (red line) compared with OSISAF/CCI (blue line). Created with recipe_ref_sea_ice_area_basic.yml.
+
+.. _fig_ref_6:
+.. figure::  /recipes/figures/ref/zonal_mean_profile_o3_CNRM-ESM2-1_historical.png
path: doc/sphinx/source/recipes/recipe_ref.rst
user: login: schlunma
body: I think this file is missing in this PR
created_at: 2025-04-11T06:49:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,6 +75,15 @@
       :func:`esmvalcore.preprocessor.regrid_time` and
       :func:`esmvalcore.preprocessor.regrid` for this). Input data
       needs to be 2D with dimensions `time`, `latitude`/`longitude`.
+    - Hovmoeller annual cycle vs. latitude or longitude (plot type
+      ``hovmoeller_anncyc_vs_lat_or_lon``): for each variable and dataset, an
+      individual figure is plotted. If a reference dataset is defined, also
+      include this dataset and a bias plot into the figure. Note that if a
+      reference dataset is defined, all input datasets need to be given on the
+      same temporal and horizontal grid (you can use the preprocessors
+      :func:`esmvalcore.preprocessor.regrid_time` and
+      :func:`esmvalcore.preprocessor.regrid` for this). Input data
+      needs to be 2D with dimensions `month`, `latitude`/`longitude`.
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
      needs to be 2D with dimensions `month_number`, `latitude`/`longitude`.
```
created_at: 2025-04-11T06:55:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1178,6 +1267,35 @@ def __init__(self, config):
                 self.plots[plot_type].setdefault("time_format", None)
                 self.plots[plot_type].setdefault("time_on", "y-axis")
 
+            elif plot_type == "hovmoeller_anncyc_vs_lat_or_lon":
+                self.plots[plot_type].setdefault(
+                    "cbar_label", "{short_name} [{units}]"
+                )
+                self.plots[plot_type].setdefault(
+                    "cbar_label_bias", "Δ{short_name} [{units}]"
+                )
+                self.plots[plot_type].setdefault(
+                    "cbar_kwargs", {"orientation": "vertical"}
+                )
+                self.plots[plot_type].setdefault("cbar_kwargs_bias", {})
+                self.plots[plot_type].setdefault("common_cbar", False)
+                self.plots[plot_type].setdefault("fontsize", None)
+                self.plots[plot_type].setdefault("plot_func", "contourf")
+                self.plots[plot_type].setdefault("plot_kwargs", {})
+                self.plots[plot_type].setdefault("plot_kwargs_bias", {})
+                self.plots[plot_type]["plot_kwargs_bias"].setdefault(
+                    "cmap", "bwr"
+                )
+                self.plots[plot_type]["plot_kwargs_bias"].setdefault(
+                    "norm", "centered"
+                )
+                self.plots[plot_type].setdefault("pyplot_kwargs", {})
+                self.plots[plot_type].setdefault("rasterize", True)
+                self.plots[plot_type].setdefault("show_y_minor_ticks", True)
+                self.plots[plot_type].setdefault("show_x_minor_ticks", True)
+                self.plots[plot_type].setdefault("time_format", None)
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
```
created_at: 2025-04-11T06:56:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2195,6 +2313,9 @@ def _plot_hovmoeller_time_vs_lat_or_lon_with_ref(
                 x_label = non_time_label
                 y_label = "time"
                 time_axis = axes_data.get_yaxis()
+            # plot_kwargs["coords"] = ["month_number", "latitude"]
+            # y_label = "latitude [°N]"
+            # x_label = "month"
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
```
created_at: 2025-04-11T06:56:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2357,6 +2478,217 @@ def _plot_hovmoeller_time_vs_lat_or_lon_without_ref(
         netcdf_path = get_diagnostic_filename(Path(plot_path).stem, self.cfg)
         return (plot_path, {netcdf_path: cube})
 
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_with_ref(
+        self, plot_func, dataset, ref_dataset
+    ):
+        """Plot the hovmoeller profile for single dataset with reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots with reference dataset '%s' for '%s'",
+            self._get_label(ref_dataset),
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        ref_cube = ref_dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        self._check_cube_dimensions(ref_cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create single figure with multiple axes
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            gridspec = GridSpec(
+                5, 4, figure=fig, height_ratios=[1.0, 1.0, 0.4, 1.0, 1.0]
+            )
+
+            # Options used for all subplots
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            fontsize = (
+                self.plots[plot_type]["fontsize"]
+                or mpl.rcParams["axes.labelsize"]
+            )
+
+            # Plot dataset (top left)
+            axes_data = fig.add_subplot(gridspec[0:2, 0:2])
+            plot_kwargs["axes"] = axes_data
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = ["month_number", "latitude"]
+                y_label = "latitude [°N]"
+                x_label = "month"
+                time_axis = axes_data.get_xaxis()
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
                plot_kwargs["coords"] = list(dim_coords_dat)
                x_label = "Month"
                y_label = non_time_label
```
created_at: 2025-04-11T07:00:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2357,6 +2478,217 @@ def _plot_hovmoeller_time_vs_lat_or_lon_without_ref(
         netcdf_path = get_diagnostic_filename(Path(plot_path).stem, self.cfg)
         return (plot_path, {netcdf_path: cube})
 
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_with_ref(
+        self, plot_func, dataset, ref_dataset
+    ):
+        """Plot the hovmoeller profile for single dataset with reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots with reference dataset '%s' for '%s'",
+            self._get_label(ref_dataset),
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        ref_cube = ref_dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        self._check_cube_dimensions(ref_cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create single figure with multiple axes
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            gridspec = GridSpec(
+                5, 4, figure=fig, height_ratios=[1.0, 1.0, 0.4, 1.0, 1.0]
+            )
+
+            # Options used for all subplots
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            fontsize = (
+                self.plots[plot_type]["fontsize"]
+                or mpl.rcParams["axes.labelsize"]
+            )
+
+            # Plot dataset (top left)
+            axes_data = fig.add_subplot(gridspec[0:2, 0:2])
+            plot_kwargs["axes"] = axes_data
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = ["month_number", "latitude"]
+                y_label = "latitude [°N]"
+                x_label = "month"
+                time_axis = axes_data.get_xaxis()
+            else:
+                plot_kwargs["coords"] = list(reversed(dim_coords_dat))
+                x_label = non_time_label
+                y_label = "time"
+                time_axis = axes_data.get_yaxis()
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
                y_label = "Month"
```
created_at: 2025-04-11T07:00:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2357,6 +2478,217 @@ def _plot_hovmoeller_time_vs_lat_or_lon_without_ref(
         netcdf_path = get_diagnostic_filename(Path(plot_path).stem, self.cfg)
         return (plot_path, {netcdf_path: cube})
 
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_with_ref(
+        self, plot_func, dataset, ref_dataset
+    ):
+        """Plot the hovmoeller profile for single dataset with reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots with reference dataset '%s' for '%s'",
+            self._get_label(ref_dataset),
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        ref_cube = ref_dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        self._check_cube_dimensions(ref_cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create single figure with multiple axes
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            gridspec = GridSpec(
+                5, 4, figure=fig, height_ratios=[1.0, 1.0, 0.4, 1.0, 1.0]
+            )
+
+            # Options used for all subplots
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            fontsize = (
+                self.plots[plot_type]["fontsize"]
+                or mpl.rcParams["axes.labelsize"]
+            )
+
+            # Plot dataset (top left)
+            axes_data = fig.add_subplot(gridspec[0:2, 0:2])
+            plot_kwargs["axes"] = axes_data
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = ["month_number", "latitude"]
+                y_label = "latitude [°N]"
+                x_label = "month"
+                time_axis = axes_data.get_xaxis()
+            else:
+                plot_kwargs["coords"] = list(reversed(dim_coords_dat))
+                x_label = non_time_label
+                y_label = "time"
+                time_axis = axes_data.get_yaxis()
+            plot_data = plot_func(cube, **plot_kwargs)
+            axes_data.set_title(self._get_label(dataset), pad=3.0)
+            axes_data.set_ylabel(y_label)
+            if self.plots[plot_type]["time_format"] is not None:
+                time_axis.set_major_formatter(
+                    mdates.DateFormatter(self.plots[plot_type]["time_format"])
+                )
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
```

not necessary here
created_at: 2025-04-11T07:01:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2357,6 +2478,217 @@ def _plot_hovmoeller_time_vs_lat_or_lon_without_ref(
         netcdf_path = get_diagnostic_filename(Path(plot_path).stem, self.cfg)
         return (plot_path, {netcdf_path: cube})
 
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_with_ref(
+        self, plot_func, dataset, ref_dataset
+    ):
+        """Plot the hovmoeller profile for single dataset with reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots with reference dataset '%s' for '%s'",
+            self._get_label(ref_dataset),
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        ref_cube = ref_dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        self._check_cube_dimensions(ref_cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create single figure with multiple axes
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            gridspec = GridSpec(
+                5, 4, figure=fig, height_ratios=[1.0, 1.0, 0.4, 1.0, 1.0]
+            )
+
+            # Options used for all subplots
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            fontsize = (
+                self.plots[plot_type]["fontsize"]
+                or mpl.rcParams["axes.labelsize"]
+            )
+
+            # Plot dataset (top left)
+            axes_data = fig.add_subplot(gridspec[0:2, 0:2])
+            plot_kwargs["axes"] = axes_data
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = ["month_number", "latitude"]
+                y_label = "latitude [°N]"
+                x_label = "month"
+                time_axis = axes_data.get_xaxis()
+            else:
+                plot_kwargs["coords"] = list(reversed(dim_coords_dat))
+                x_label = non_time_label
+                y_label = "time"
+                time_axis = axes_data.get_yaxis()
+            plot_data = plot_func(cube, **plot_kwargs)
+            axes_data.set_title(self._get_label(dataset), pad=3.0)
+            axes_data.set_ylabel(y_label)
+            if self.plots[plot_type]["time_format"] is not None:
+                time_axis.set_major_formatter(
+                    mdates.DateFormatter(self.plots[plot_type]["time_format"])
+                )
+            if self.plots[plot_type]["show_y_minor_ticks"]:
+                axes_data.get_yaxis().set_minor_locator(AutoMinorLocator())
+            if self.plots[plot_type]["show_x_minor_ticks"]:
+                axes_data.get_xaxis().set_minor_locator(AutoMinorLocator())
+            self._process_pyplot_kwargs(plot_type, dataset)
+
+            # Plot reference dataset (top right)
+            # Note: make sure to use the same vmin and vmax than the top left
+            # plot if a common colorbar is desired
+            axes_ref = fig.add_subplot(
+                gridspec[0:2, 2:4], sharex=axes_data, sharey=axes_data
+            )
+            plot_kwargs["axes"] = axes_ref
+            if self.plots[plot_type]["common_cbar"]:
+                plot_kwargs.setdefault("vmin", plot_data.get_clim()[0])
+                plot_kwargs.setdefault("vmax", plot_data.get_clim()[1])
+            plot_ref = plot_func(ref_cube, **plot_kwargs)
+            axes_ref.set_title(self._get_label(ref_dataset), pad=3.0)
+            plt.setp(axes_ref.get_yticklabels(), visible=False)
+            self._process_pyplot_kwargs(plot_type, ref_dataset)
+
+            # Add colorbar(s)
+            self._add_colorbar(
+                plot_type,
+                plot_data,
+                plot_ref,
+                axes_data,
+                axes_ref,
+                dataset,
+                ref_dataset,
+            )
+
+            # Plot bias (bottom center)
+            bias_cube = cube - ref_cube
+            axes_bias = fig.add_subplot(
+                gridspec[3:5, 1:3], sharex=axes_data, sharey=axes_data
+            )
+            plot_kwargs_bias = self._get_plot_kwargs(
+                plot_type, dataset, bias=True
+            )
+            plot_kwargs_bias["axes"] = axes_bias
+            plot_kwargs_bias["coords"] = plot_kwargs["coords"]
+            plot_bias = plot_func(bias_cube, **plot_kwargs_bias)
+            axes_bias.set_title(
+                f"{self._get_label(dataset)} - {self._get_label(ref_dataset)}",
+                pad=3.0,
+            )
+            axes_bias.set_xlabel(x_label)
+            axes_bias.set_ylabel(y_label)
+            cbar_kwargs_bias = self._get_cbar_kwargs(plot_type, bias=True)
+            cbar_bias = fig.colorbar(
+                plot_bias, ax=axes_bias, **cbar_kwargs_bias
+            )
+            cbar_bias.set_label(
+                self._get_cbar_label(plot_type, dataset, bias=True),
+                fontsize=fontsize,
+            )
+            cbar_bias.ax.tick_params(labelsize=fontsize)
+
+            # Customize plot
+            fig.suptitle(dataset["long_name"])
+            self._process_pyplot_kwargs(plot_type, dataset)
+
+            # Rasterization
+            if self.plots[plot_type]["rasterize"]:
+                self._set_rasterized([axes_data, axes_ref, axes_bias])
+
+        # File paths
+        plot_path = self.get_plot_path(plot_type, dataset)
+        netcdf_path = get_diagnostic_filename(
+            Path(plot_path).stem + "_{pos}", self.cfg
+        )
+        netcdf_paths = {
+            netcdf_path.format(pos="top_left"): cube,
+            netcdf_path.format(pos="top_right"): ref_cube,
+            netcdf_path.format(pos="bottom"): bias_cube,
+        }
+
+        return (plot_path, netcdf_paths)
+
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_without_ref(
+        self, plot_func, dataset
+    ):
+        """Plot annual cylce vs zonal or meridional Hovmoeller without reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots without reference dataset for '%s'",
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create plot with desired settings
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            axes = fig.add_subplot()
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            plot_kwargs["axes"] = axes
+
+            # Put time on desired axis
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = list(dim_coords_dat)
+                x_label = "time"
+                y_label = non_time_label
+                time_axis = axes.get_xaxis()
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
                x_label = "Month"
                y_label = non_time_label
```
created_at: 2025-04-11T07:02:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2357,6 +2478,217 @@ def _plot_hovmoeller_time_vs_lat_or_lon_without_ref(
         netcdf_path = get_diagnostic_filename(Path(plot_path).stem, self.cfg)
         return (plot_path, {netcdf_path: cube})
 
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_with_ref(
+        self, plot_func, dataset, ref_dataset
+    ):
+        """Plot the hovmoeller profile for single dataset with reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots with reference dataset '%s' for '%s'",
+            self._get_label(ref_dataset),
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        ref_cube = ref_dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        self._check_cube_dimensions(ref_cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create single figure with multiple axes
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            gridspec = GridSpec(
+                5, 4, figure=fig, height_ratios=[1.0, 1.0, 0.4, 1.0, 1.0]
+            )
+
+            # Options used for all subplots
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            fontsize = (
+                self.plots[plot_type]["fontsize"]
+                or mpl.rcParams["axes.labelsize"]
+            )
+
+            # Plot dataset (top left)
+            axes_data = fig.add_subplot(gridspec[0:2, 0:2])
+            plot_kwargs["axes"] = axes_data
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = ["month_number", "latitude"]
+                y_label = "latitude [°N]"
+                x_label = "month"
+                time_axis = axes_data.get_xaxis()
+            else:
+                plot_kwargs["coords"] = list(reversed(dim_coords_dat))
+                x_label = non_time_label
+                y_label = "time"
+                time_axis = axes_data.get_yaxis()
+            plot_data = plot_func(cube, **plot_kwargs)
+            axes_data.set_title(self._get_label(dataset), pad=3.0)
+            axes_data.set_ylabel(y_label)
+            if self.plots[plot_type]["time_format"] is not None:
+                time_axis.set_major_formatter(
+                    mdates.DateFormatter(self.plots[plot_type]["time_format"])
+                )
+            if self.plots[plot_type]["show_y_minor_ticks"]:
+                axes_data.get_yaxis().set_minor_locator(AutoMinorLocator())
+            if self.plots[plot_type]["show_x_minor_ticks"]:
+                axes_data.get_xaxis().set_minor_locator(AutoMinorLocator())
+            self._process_pyplot_kwargs(plot_type, dataset)
+
+            # Plot reference dataset (top right)
+            # Note: make sure to use the same vmin and vmax than the top left
+            # plot if a common colorbar is desired
+            axes_ref = fig.add_subplot(
+                gridspec[0:2, 2:4], sharex=axes_data, sharey=axes_data
+            )
+            plot_kwargs["axes"] = axes_ref
+            if self.plots[plot_type]["common_cbar"]:
+                plot_kwargs.setdefault("vmin", plot_data.get_clim()[0])
+                plot_kwargs.setdefault("vmax", plot_data.get_clim()[1])
+            plot_ref = plot_func(ref_cube, **plot_kwargs)
+            axes_ref.set_title(self._get_label(ref_dataset), pad=3.0)
+            plt.setp(axes_ref.get_yticklabels(), visible=False)
+            self._process_pyplot_kwargs(plot_type, ref_dataset)
+
+            # Add colorbar(s)
+            self._add_colorbar(
+                plot_type,
+                plot_data,
+                plot_ref,
+                axes_data,
+                axes_ref,
+                dataset,
+                ref_dataset,
+            )
+
+            # Plot bias (bottom center)
+            bias_cube = cube - ref_cube
+            axes_bias = fig.add_subplot(
+                gridspec[3:5, 1:3], sharex=axes_data, sharey=axes_data
+            )
+            plot_kwargs_bias = self._get_plot_kwargs(
+                plot_type, dataset, bias=True
+            )
+            plot_kwargs_bias["axes"] = axes_bias
+            plot_kwargs_bias["coords"] = plot_kwargs["coords"]
+            plot_bias = plot_func(bias_cube, **plot_kwargs_bias)
+            axes_bias.set_title(
+                f"{self._get_label(dataset)} - {self._get_label(ref_dataset)}",
+                pad=3.0,
+            )
+            axes_bias.set_xlabel(x_label)
+            axes_bias.set_ylabel(y_label)
+            cbar_kwargs_bias = self._get_cbar_kwargs(plot_type, bias=True)
+            cbar_bias = fig.colorbar(
+                plot_bias, ax=axes_bias, **cbar_kwargs_bias
+            )
+            cbar_bias.set_label(
+                self._get_cbar_label(plot_type, dataset, bias=True),
+                fontsize=fontsize,
+            )
+            cbar_bias.ax.tick_params(labelsize=fontsize)
+
+            # Customize plot
+            fig.suptitle(dataset["long_name"])
+            self._process_pyplot_kwargs(plot_type, dataset)
+
+            # Rasterization
+            if self.plots[plot_type]["rasterize"]:
+                self._set_rasterized([axes_data, axes_ref, axes_bias])
+
+        # File paths
+        plot_path = self.get_plot_path(plot_type, dataset)
+        netcdf_path = get_diagnostic_filename(
+            Path(plot_path).stem + "_{pos}", self.cfg
+        )
+        netcdf_paths = {
+            netcdf_path.format(pos="top_left"): cube,
+            netcdf_path.format(pos="top_right"): ref_cube,
+            netcdf_path.format(pos="bottom"): bias_cube,
+        }
+
+        return (plot_path, netcdf_paths)
+
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_without_ref(
+        self, plot_func, dataset
+    ):
+        """Plot annual cylce vs zonal or meridional Hovmoeller without reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots without reference dataset for '%s'",
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create plot with desired settings
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            axes = fig.add_subplot()
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            plot_kwargs["axes"] = axes
+
+            # Put time on desired axis
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = list(dim_coords_dat)
+                x_label = "time"
+                y_label = non_time_label
+                time_axis = axes.get_xaxis()
+            else:
+                plot_kwargs["coords"] = list(reversed(dim_coords_dat))
+                x_label = non_time_label
+                y_label = "time"
+                time_axis = axes.get_yaxis()
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
                x_label = non_time_label
                y_label = "Month"
```
created_at: 2025-04-11T07:02:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2357,6 +2478,217 @@ def _plot_hovmoeller_time_vs_lat_or_lon_without_ref(
         netcdf_path = get_diagnostic_filename(Path(plot_path).stem, self.cfg)
         return (plot_path, {netcdf_path: cube})
 
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_with_ref(
+        self, plot_func, dataset, ref_dataset
+    ):
+        """Plot the hovmoeller profile for single dataset with reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots with reference dataset '%s' for '%s'",
+            self._get_label(ref_dataset),
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        ref_cube = ref_dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        self._check_cube_dimensions(ref_cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create single figure with multiple axes
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            gridspec = GridSpec(
+                5, 4, figure=fig, height_ratios=[1.0, 1.0, 0.4, 1.0, 1.0]
+            )
+
+            # Options used for all subplots
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            fontsize = (
+                self.plots[plot_type]["fontsize"]
+                or mpl.rcParams["axes.labelsize"]
+            )
+
+            # Plot dataset (top left)
+            axes_data = fig.add_subplot(gridspec[0:2, 0:2])
+            plot_kwargs["axes"] = axes_data
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = ["month_number", "latitude"]
+                y_label = "latitude [°N]"
+                x_label = "month"
+                time_axis = axes_data.get_xaxis()
+            else:
+                plot_kwargs["coords"] = list(reversed(dim_coords_dat))
+                x_label = non_time_label
+                y_label = "time"
+                time_axis = axes_data.get_yaxis()
+            plot_data = plot_func(cube, **plot_kwargs)
+            axes_data.set_title(self._get_label(dataset), pad=3.0)
+            axes_data.set_ylabel(y_label)
+            if self.plots[plot_type]["time_format"] is not None:
+                time_axis.set_major_formatter(
+                    mdates.DateFormatter(self.plots[plot_type]["time_format"])
+                )
+            if self.plots[plot_type]["show_y_minor_ticks"]:
+                axes_data.get_yaxis().set_minor_locator(AutoMinorLocator())
+            if self.plots[plot_type]["show_x_minor_ticks"]:
+                axes_data.get_xaxis().set_minor_locator(AutoMinorLocator())
+            self._process_pyplot_kwargs(plot_type, dataset)
+
+            # Plot reference dataset (top right)
+            # Note: make sure to use the same vmin and vmax than the top left
+            # plot if a common colorbar is desired
+            axes_ref = fig.add_subplot(
+                gridspec[0:2, 2:4], sharex=axes_data, sharey=axes_data
+            )
+            plot_kwargs["axes"] = axes_ref
+            if self.plots[plot_type]["common_cbar"]:
+                plot_kwargs.setdefault("vmin", plot_data.get_clim()[0])
+                plot_kwargs.setdefault("vmax", plot_data.get_clim()[1])
+            plot_ref = plot_func(ref_cube, **plot_kwargs)
+            axes_ref.set_title(self._get_label(ref_dataset), pad=3.0)
+            plt.setp(axes_ref.get_yticklabels(), visible=False)
+            self._process_pyplot_kwargs(plot_type, ref_dataset)
+
+            # Add colorbar(s)
+            self._add_colorbar(
+                plot_type,
+                plot_data,
+                plot_ref,
+                axes_data,
+                axes_ref,
+                dataset,
+                ref_dataset,
+            )
+
+            # Plot bias (bottom center)
+            bias_cube = cube - ref_cube
+            axes_bias = fig.add_subplot(
+                gridspec[3:5, 1:3], sharex=axes_data, sharey=axes_data
+            )
+            plot_kwargs_bias = self._get_plot_kwargs(
+                plot_type, dataset, bias=True
+            )
+            plot_kwargs_bias["axes"] = axes_bias
+            plot_kwargs_bias["coords"] = plot_kwargs["coords"]
+            plot_bias = plot_func(bias_cube, **plot_kwargs_bias)
+            axes_bias.set_title(
+                f"{self._get_label(dataset)} - {self._get_label(ref_dataset)}",
+                pad=3.0,
+            )
+            axes_bias.set_xlabel(x_label)
+            axes_bias.set_ylabel(y_label)
+            cbar_kwargs_bias = self._get_cbar_kwargs(plot_type, bias=True)
+            cbar_bias = fig.colorbar(
+                plot_bias, ax=axes_bias, **cbar_kwargs_bias
+            )
+            cbar_bias.set_label(
+                self._get_cbar_label(plot_type, dataset, bias=True),
+                fontsize=fontsize,
+            )
+            cbar_bias.ax.tick_params(labelsize=fontsize)
+
+            # Customize plot
+            fig.suptitle(dataset["long_name"])
+            self._process_pyplot_kwargs(plot_type, dataset)
+
+            # Rasterization
+            if self.plots[plot_type]["rasterize"]:
+                self._set_rasterized([axes_data, axes_ref, axes_bias])
+
+        # File paths
+        plot_path = self.get_plot_path(plot_type, dataset)
+        netcdf_path = get_diagnostic_filename(
+            Path(plot_path).stem + "_{pos}", self.cfg
+        )
+        netcdf_paths = {
+            netcdf_path.format(pos="top_left"): cube,
+            netcdf_path.format(pos="top_right"): ref_cube,
+            netcdf_path.format(pos="bottom"): bias_cube,
+        }
+
+        return (plot_path, netcdf_paths)
+
+    def _plot_hovmoeller_anncyc_vs_lat_or_lon_without_ref(
+        self, plot_func, dataset
+    ):
+        """Plot annual cylce vs zonal or meridional Hovmoeller without reference."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        logger.info(
+            "Plotting Hovmoeller plots without reference dataset for '%s'",
+            self._get_label(dataset),
+        )
+
+        # Make sure that the data has the correct dimensions
+        cube = dataset["cube"]
+        dim_coords_dat = self._check_cube_dimensions(cube, plot_type)
+        if "latitude" in dim_coords_dat:
+            non_time_label = "latitude [°N]"
+        else:
+            non_time_label = "longitude [°E]"
+
+        # Create plot with desired settings
+        with mpl.rc_context(self._get_custom_mpl_rc_params(plot_type)):
+            fig = plt.figure(**self.cfg["figure_kwargs"])
+            axes = fig.add_subplot()
+            plot_kwargs = self._get_plot_kwargs(plot_type, dataset)
+            plot_kwargs["axes"] = axes
+
+            # Put time on desired axis
+            if self.plots[plot_type]["time_on"] == "x-axis":
+                plot_kwargs["coords"] = list(dim_coords_dat)
+                x_label = "time"
+                y_label = non_time_label
+                time_axis = axes.get_xaxis()
+            else:
+                plot_kwargs["coords"] = list(reversed(dim_coords_dat))
+                x_label = non_time_label
+                y_label = "time"
+                time_axis = axes.get_yaxis()
+            plot_hovmoeller = plot_func(cube, **plot_kwargs)
+
+            # Setup colorbar
+            fontsize = (
+                self.plots[plot_type]["fontsize"]
+                or mpl.rcParams["axes.labelsize"]
+            )
+            colorbar = fig.colorbar(
+                plot_hovmoeller, ax=axes, **self._get_cbar_kwargs(plot_type)
+            )
+            colorbar.set_label(
+                self._get_cbar_label(plot_type, dataset), fontsize=fontsize
+            )
+            colorbar.ax.tick_params(labelsize=fontsize)
+
+            # Customize plot
+            axes.set_title(self._get_label(dataset))
+            fig.suptitle(dataset["long_name"])
+            axes.set_xlabel(x_label)
+            axes.set_ylabel(y_label)
+            if self.plots[plot_type]["time_format"] is not None:
+                time_axis.set_major_formatter(
+                    mdates.DateFormatter(self.plots[plot_type]["time_format"])
+                )
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
```
created_at: 2025-04-11T07:02:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4144,6 +4480,85 @@ def create_hovmoeller_time_vs_lat_or_lon_plot(self, datasets):
                 for netcdf_path in netcdf_paths:
                     provenance_logger.log(netcdf_path, provenance_record)
 
+    def create_hovmoeller_anncyc_vs_lat_or_lon_plot(self, datasets):
+        """Create the Hovmoeller plot with annual cycle vs latitude or longitude."""
+        plot_type = "hovmoeller_anncyc_vs_lat_or_lon"
+        if plot_type not in self.plots:
+            return
+
+        if not datasets:
+            raise ValueError(f"No input data to plot '{plot_type}' given")
+
+        # Get reference dataset if possible
+        ref_dataset = self._get_reference_dataset(datasets)
+        if ref_dataset is None:
+            logger.info("Plotting %s without reference dataset", plot_type)
+        else:
+            logger.info(
+                "Plotting %s with reference dataset '%s'",
+                plot_type,
+                self._get_label(ref_dataset),
+            )
+
+        # Get plot function
+        plot_func = self._get_plot_func(plot_type)
+
+        # Create a single plot for each dataset (incl. reference dataset if
+        # given)
+        for dataset in datasets:
+            if dataset == ref_dataset:
+                continue
+            ancestors = [dataset["filename"]]
+            if ref_dataset is None:
+                (plot_path, netcdf_paths) = (
+                    self._plot_hovmoeller_anncyc_vs_lat_or_lon_without_ref(
+                        plot_func, dataset
+                    )
+                )
+                caption = (
+                    f"Hovmoeller plot of {dataset['long_name']} of dataset "
+                    f"{dataset['alias']}."
+                )
+            else:
+                (plot_path, netcdf_paths) = (
+                    self._plot_hovmoeller_anncyc_vs_lat_or_lon_with_ref(
+                        plot_func, dataset, ref_dataset
+                    )
+                )
+                caption = (
+                    f"Hovmoeller plot of {dataset['long_name']} of dataset "
+                    f"{dataset['alias']} including bias relative to "
+                    f"{ref_dataset['alias']}."
+                )
+                ancestors.append(ref_dataset["filename"])
+
+            # Save plot
+            plt.savefig(plot_path, **self.cfg["savefig_kwargs"])
+            logger.info("Wrote %s", plot_path)
+            plt.close()
+
+            # Save netCDFs
+            for netcdf_path, cube in netcdf_paths.items():
+                io.iris_save(cube, netcdf_path)
+
+            # Provenance tracking
+            provenance_record = {
+                "ancestors": ancestors,
+                "authors": [
+                    "schlund_manuel",
+                    "kraft_jeremy",
+                    "lindenlaub_lukas",
path: esmvaltool/diag_scripts/monitor/multi_datasets.py
user: login: schlunma
body: ```suggestion
```
created_at: 2025-04-11T07:03:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,267 @@
+# ESMValTool
+---
+documentation:
+  title: Model evaluation with focus on total column and stratospheric ozone.
+  description: >
+    Plot climatologies, zonal means, and selected monthly means for total 
+    column ozone and stratospheric ozone for the REF.
+  authors:
+    - schlund_manuel
+    - hassler_birgit
+  maintainer:
+    - hassler_birgit
+
+
+preprocessors:
+
+  create_time_series_SH:
+    annual_statistics:
+      operator: mean
+    area_statistics:
+      operator: mean
+    extract_region:
+      start_latitude: -90
+      end_latitude: -60
+      start_longitude: 0
+      end_longitude: 360
+    extract_month:
+      month: 10
+    convert_units:
+      units: DU
+
+  create_time_series_NH:
+    annual_statistics:
+      operator: mean
+    area_statistics:
+      operator: mean
+    extract_region:
+      start_latitude: 60
+      end_latitude: 90
+      start_longitude: 0
+      end_longitude: 360
+    extract_month:
+      month: 3
+    convert_units:
+      units: DU
+
+  create_lat_time_map:
+    regrid:
+      scheme: linear
+      target_grid: 5x5
+    regrid_time:
+      calendar: standard
+    zonal_statistics:
+      operator: mean
+    convert_units:
+      units: DU
+
+  create_lat_month_map:
+    regrid: 
+      scheme: linear
+      target_grid: 5x5
+    climate_statistics:
+      operator: mean
+      period: month
+    zonal_statistics:
+      operator: mean
+    convert_units:
+      units: DU
+
+  create_zonal_mean_profile:
+    zonal_statistics:
+      operator: mean
+    regrid:
+      scheme: linear
+      target_grid: 5x5
+    extract_levels:
+      levels: [15000, 10000, 7000, 5000, 3000, 2000, 1000, 500, 100]
+      scheme: linear_extrapolate
+      coordinate: air_pressure
+    climate_statistics:
+      operator: mean
+      period: full
+    convert_units:
+      units: ppm
+
+
+
+# Note: the following model is just an example
+datasets:
+  - {project: CMIP6, dataset: CNRM-ESM2-1, grid: gr, ensemble: r1i1p1f2, exp: historical, alias: CNRM-ESM2-1}
+
+timerange_for_toz_monthly_data: &time_period_toz_monthly
+  timerange: '1950/2014'  # can be specified, this is just an example
+timerange_for_toz_zonal_mean: &time_period_toz_zonalmean
+  timerange: '1997/2014'  # is chosen to start with the start of the obs data
+timerange_for_ozone_profile: &time_period_o3_profile
+  timerange: '1990/2000'  # can be specified, this is just an example
+
+
+diagnostics:
+# Total column ozone plots
+  polar_cap_time_series_SH:
+    variables:
+      toz:
+        mip: AERmon
+        exp: historical
+        preprocessor: create_time_series_SH
+        <<: *time_period_toz_monthly
+    additional_datasets:
+      - {project: OBS6, dataset: ESACCI-OZONE, type: sat, version: L3, tier: 2, mip: AERmon, timerange: '1997/2022', alias: ESACCI-OZONE}
+    scripts:
+      plot:
+        script: monitor/multi_datasets.py
+        plot_folder: '{plot_dir}'
+        plot_filename: '{plot_type}_{real_name}_SH_Oct'
+        facet_used_for_labels: alias
+        savefig_kwargs:
+          dpi: 150
+          bbox_inches: tight
+          orientation: landscape
+        plots:
+          timeseries:
+            annual_mean_kwargs: false
+            plot_kwargs:
+              OBS:
+                color: black
+              CMIP6_CNRM-ESM2-1:
+                color: cyan
+              CMIP6_CNRM-ESM2-1:
+                color: cornflowerblue
+              CMIP6_UKESM1-0-LL:
+                color: gold
+              CMIP6_UKESM1-0-LL:
+                color: darkorange
path: esmvaltool/recipes/ref/recipe_ref_ozone.yml
user: login: schlunma
body: ```suggestion
```

To make it usable for the REF with arbitrary datasets
created_at: 2025-04-11T07:05:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,267 @@
+# ESMValTool
+---
+documentation:
+  title: Model evaluation with focus on total column and stratospheric ozone.
+  description: >
+    Plot climatologies, zonal means, and selected monthly means for total 
+    column ozone and stratospheric ozone for the REF.
+  authors:
+    - schlund_manuel
+    - hassler_birgit
+  maintainer:
+    - hassler_birgit
+
+
+preprocessors:
+
+  create_time_series_SH:
+    annual_statistics:
+      operator: mean
+    area_statistics:
+      operator: mean
+    extract_region:
+      start_latitude: -90
+      end_latitude: -60
+      start_longitude: 0
+      end_longitude: 360
+    extract_month:
+      month: 10
+    convert_units:
+      units: DU
+
+  create_time_series_NH:
+    annual_statistics:
+      operator: mean
+    area_statistics:
+      operator: mean
+    extract_region:
+      start_latitude: 60
+      end_latitude: 90
+      start_longitude: 0
+      end_longitude: 360
+    extract_month:
+      month: 3
+    convert_units:
+      units: DU
+
+  create_lat_time_map:
+    regrid:
+      scheme: linear
+      target_grid: 5x5
+    regrid_time:
+      calendar: standard
+    zonal_statistics:
+      operator: mean
+    convert_units:
+      units: DU
+
+  create_lat_month_map:
+    regrid: 
+      scheme: linear
+      target_grid: 5x5
+    climate_statistics:
+      operator: mean
+      period: month
+    zonal_statistics:
+      operator: mean
+    convert_units:
+      units: DU
+
+  create_zonal_mean_profile:
+    zonal_statistics:
+      operator: mean
+    regrid:
+      scheme: linear
+      target_grid: 5x5
+    extract_levels:
+      levels: [15000, 10000, 7000, 5000, 3000, 2000, 1000, 500, 100]
+      scheme: linear_extrapolate
+      coordinate: air_pressure
+    climate_statistics:
+      operator: mean
+      period: full
+    convert_units:
+      units: ppm
+
+
+
+# Note: the following model is just an example
+datasets:
+  - {project: CMIP6, dataset: CNRM-ESM2-1, grid: gr, ensemble: r1i1p1f2, exp: historical, alias: CNRM-ESM2-1}
+
+timerange_for_toz_monthly_data: &time_period_toz_monthly
+  timerange: '1950/2014'  # can be specified, this is just an example
+timerange_for_toz_zonal_mean: &time_period_toz_zonalmean
+  timerange: '1997/2014'  # is chosen to start with the start of the obs data
+timerange_for_ozone_profile: &time_period_o3_profile
+  timerange: '1990/2000'  # can be specified, this is just an example
+
+
+diagnostics:
+# Total column ozone plots
+  polar_cap_time_series_SH:
+    variables:
+      toz:
+        mip: AERmon
+        exp: historical
+        preprocessor: create_time_series_SH
+        <<: *time_period_toz_monthly
+    additional_datasets:
+      - {project: OBS6, dataset: ESACCI-OZONE, type: sat, version: L3, tier: 2, mip: AERmon, timerange: '1997/2022', alias: ESACCI-OZONE}
+    scripts:
+      plot:
+        script: monitor/multi_datasets.py
+        plot_folder: '{plot_dir}'
+        plot_filename: '{plot_type}_{real_name}_SH_Oct'
+        facet_used_for_labels: alias
+        savefig_kwargs:
+          dpi: 150
+          bbox_inches: tight
+          orientation: landscape
+        plots:
+          timeseries:
+            annual_mean_kwargs: false
+            plot_kwargs:
+              OBS:
+                color: black
+              CMIP6_CNRM-ESM2-1:
+                color: cyan
+              CMIP6_CNRM-ESM2-1:
+                color: cornflowerblue
+              CMIP6_UKESM1-0-LL:
+                color: gold
+              CMIP6_UKESM1-0-LL:
+                color: darkorange
+            pyplot_kwargs:
+              title: Total Column Ozone, 60-90S, October
+
+  polar_cap_time_series_NH:
+    variables:
+      toz:
+        mip: AERmon
+        exp: historical
+        preprocessor: create_time_series_NH
+        facet_used_for_labels: alias
+        <<: *time_period_toz_monthly
+    additional_datasets:
+      - {project: OBS6, dataset: ESACCI-OZONE, type: sat, version: L3, tier: 2, mip: AERmon, timerange: '1997/2022', alias: ESACCI-OZONE}
+    scripts:
+      plot:
+        script: monitor/multi_datasets.py
+        plot_folder: '{plot_dir}'
+        plot_filename: '{plot_type}_{real_name}_NH_MAR'
+        facet_used_for_labels: alias
+        savefig_kwargs:
+          dpi: 150
+          bbox_inches: tight
+          orientation: landscape
+        plots:
+          timeseries:
+            annual_mean_kwargs: false
+            plot_kwargs:
+              OBS:
+                color: black
+              CMIP6_CNRM-ESM2-1:
+                color: cyan
+              CMIP6_CNRM-ESM2-1:
+                color: cornflowerblue
+              CMIP6_UKESM1-0-LL:
+                color: gold
+              CMIP6_UKESM1-0-LL:
+                color: darkorange
path: esmvaltool/recipes/ref/recipe_ref_ozone.yml
user: login: schlunma
body: ```suggestion
```
created_at: 2025-04-11T07:06:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -69,3 +74,11 @@ Example plots:
 
    Time series of Arctic (NH) September (NH) sea ice area from MPI-ESM1-2-LR
    (red line) compared with OSISAF/CCI (blue line). Created with recipe_ref_sea_ice_area_basic.yml.
+
+.. _fig_ref_6:
+.. figure::  /recipes/figures/ref/zonal_mean_profile_o3_CNRM-ESM2-1_historical.png
path: doc/sphinx/source/recipes/recipe_ref.rst
user: login: hb326
body: I have added it now. 
created_at: 2025-04-11T09:07:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,267 @@
+# ESMValTool
+---
+documentation:
+  title: Model evaluation with focus on total column and stratospheric ozone.
+  description: >
+    Plot climatologies, zonal means, and selected monthly means for total 
+    column ozone and stratospheric ozone for the REF.
+  authors:
+    - schlund_manuel
+    - hassler_birgit
+  maintainer:
+    - hassler_birgit
+
+
+preprocessors:
+
+  create_time_series_SH:
+    annual_statistics:
+      operator: mean
+    area_statistics:
+      operator: mean
+    extract_region:
+      start_latitude: -90
+      end_latitude: -60
+      start_longitude: 0
+      end_longitude: 360
+    extract_month:
+      month: 10
+    convert_units:
+      units: DU
+
+  create_time_series_NH:
+    annual_statistics:
+      operator: mean
+    area_statistics:
+      operator: mean
+    extract_region:
+      start_latitude: 60
+      end_latitude: 90
+      start_longitude: 0
+      end_longitude: 360
+    extract_month:
+      month: 3
+    convert_units:
+      units: DU
+
+  create_lat_time_map:
+    regrid:
+      scheme: linear
+      target_grid: 5x5
+    regrid_time:
+      calendar: standard
+    zonal_statistics:
+      operator: mean
+    convert_units:
+      units: DU
+
+  create_lat_month_map:
+    regrid: 
+      scheme: linear
+      target_grid: 5x5
+    climate_statistics:
+      operator: mean
+      period: month
+    zonal_statistics:
+      operator: mean
+    convert_units:
+      units: DU
+
+  create_zonal_mean_profile:
+    zonal_statistics:
+      operator: mean
+    regrid:
+      scheme: linear
+      target_grid: 5x5
+    extract_levels:
+      levels: [15000, 10000, 7000, 5000, 3000, 2000, 1000, 500, 100]
+      scheme: linear_extrapolate
+      coordinate: air_pressure
+    climate_statistics:
+      operator: mean
+      period: full
+    convert_units:
+      units: ppm
+
+
+
+# Note: the following model is just an example
+datasets:
+  - {project: CMIP6, dataset: CNRM-ESM2-1, grid: gr, ensemble: r1i1p1f2, exp: historical, alias: CNRM-ESM2-1}
+
+timerange_for_toz_monthly_data: &time_period_toz_monthly
+  timerange: '1950/2014'  # can be specified, this is just an example
+timerange_for_toz_zonal_mean: &time_period_toz_zonalmean
+  timerange: '1997/2014'  # is chosen to start with the start of the obs data
+timerange_for_ozone_profile: &time_period_o3_profile
+  timerange: '1990/2000'  # can be specified, this is just an example
+
+
+diagnostics:
+# Total column ozone plots
+  polar_cap_time_series_SH:
+    variables:
+      toz:
+        mip: AERmon
+        exp: historical
+        preprocessor: create_time_series_SH
+        <<: *time_period_toz_monthly
+    additional_datasets:
+      - {project: OBS6, dataset: ESACCI-OZONE, type: sat, version: L3, tier: 2, mip: AERmon, timerange: '1997/2022', alias: ESACCI-OZONE}
+    scripts:
+      plot:
+        script: monitor/multi_datasets.py
+        plot_folder: '{plot_dir}'
+        plot_filename: '{plot_type}_{real_name}_SH_Oct'
+        facet_used_for_labels: alias
+        savefig_kwargs:
+          dpi: 150
+          bbox_inches: tight
+          orientation: landscape
+        plots:
+          timeseries:
+            annual_mean_kwargs: false
+            plot_kwargs:
+              OBS:
+                color: black
+              CMIP6_CNRM-ESM2-1:
+                color: cyan
+              CMIP6_CNRM-ESM2-1:
+                color: cornflowerblue
+              CMIP6_UKESM1-0-LL:
+                color: gold
+              CMIP6_UKESM1-0-LL:
+                color: darkorange
path: esmvaltool/recipes/ref/recipe_ref_ozone.yml
user: login: hb326
body: Well spotted!
created_at: 2025-04-11T09:12:29Z
author_association: CONTRIBUTOR

PR comments:
user: login: hb326
created_at: 2025-04-11T09:17:02Z
body: > Thanks Birgit, couple of comments, then I will test it 👍

@schlunma: thanks for going through the changed code! I missed quite a few things that were not necessary anymore for the new plot type. I have made the suggested changes now, and I am just running the recipe again. Will let you know when you can test it as well. 
author_association: CONTRIBUTOR
user: login: hb326
created_at: 2025-04-11T09:24:45Z
body: @schlunma: all good to be tested by you when you have time.  :) 
author_association: CONTRIBUTOR
user: login: hb326
created_at: 2025-04-11T11:27:24Z
body: > Thanks Birgit, doc looks great and recipe works as expected! This can be merged 🚀

Thanks @schlunma for the quick review!! 
author_association: CONTRIBUTOR