Repository: ESMValGroup/ESMValCore

Issue:
Dataset problem: Issues with latitude and longitude for sos and siconc of BCCs CMIP6 models
**Describe the dataset issue**
This is the same as #537 or #748 but for the variables `sos` and `siconc` (model `BCC-ESM1` for the latter only).

- Pointer to existing copy of data on ESGF node (it would be very useful if you could provide a physical
fullpath to the file(s) that are causing the problem, e.g. on CEDA Jasmin or DKRZ):
/mnt/lustre02/work/ik1017/CMIP6/data/CMIP6/CMIP/BCC/BCC-CSM2-MR/historical/r1i1p1f1/Omon/sos/gn/v20181126/sos_Omon_BCC-CSM2-MR_historical_r1i1p1f1_gn_185001-201412.nc
/mnt/lustre02/work/ik1017/CMIP6/data/CMIP6/CMIP/BCC/BCC-CSM2-MR/historical/r1i1p1f1/SImon/siconc/gn/v20200218/siconc_SImon_BCC-CSM2-MR_historical_r1i1p1f1_gn_185001-201412.nc
/mnt/lustre02/work/ik1017/CMIP6/data/CMIP6/CMIP/BCC/BCC-ESM1/historical/r1i1p1f1/Omon/sos/gn/v20181129/sos_Omon_BCC-ESM1_historical_r1i1p1f1_gn_185001-201412.nc
/mnt/lustre02/work/ik1017/CMIP6/data/CMIP6/CMIP/BCC/BCC-ESM1/historical/r1i1p1f1/SImon/siconc/gn/v20200218/siconc_SImon_BCC-ESM1_historical_r1i1p1f1_gn_185001-201412.nc


Discussion:
schlunma: Hi @remi-kazeroni, I just opened a PR that fixes the fix that is used here (#1092). Once this is merged the fixes you introduce here are as simple as 

```py
Sos = OceanFixGrid
Siconc = OceanFixGrid
```

Would it be okay for you to wait with this PR until #1092 is merged? I will review it as soon as possible.

If this is really urgent I can have a look at it right now and adapt #1092 accordingly, but I think the other way round is easier :smile: 
remi-kazeroni: > Hi @remi-kazeroni, I just opened a PR that fixes the fix that is used here (#1092). Once this is merged the fixes you introduce here are as simple as
> 
> ```python
> Sos = OceanFixGrid
> Siconc = OceanFixGrid
> ```
> 
> Would it be okay for you to wait with this PR until #1092 is merged? I will review it as soon as possible.
> 
> If this is really urgent I can have a look at it right now and adapt #1092 accordingly, but I think the other way round is easier 😄

Hi @schlunma, thanks for offering to review this PR. I agree it makes totally sense to wait until #1092 is merged and then adapt this PR by using the `OceanFixGrid` for the variables needed here. This PR is not super urgent but the sooner, the better 👍 
schlunma: Hi @remi-kazeroni, #1092 has been merged, so you can proceed with this PR :+1:

As I said, I think the fixes can be covered with

```py
Sos = OceanFixGrid
```

and the tests similar to this:

https://github.com/ESMValGroup/ESMValCore/blob/66dd7bd2e5750c193b73056e430cd9577660e5fe/tests/integration/cmor/_fixes/cmip6/test_bcc_esm1.py#L43-L51

Patch:
diff --git a/doc/recipe/overview.rst b/doc/recipe/overview.rst
index 779887862..e49abab13 100644
--- a/doc/recipe/overview.rst
+++ b/doc/recipe/overview.rst
@@ -82,6 +82,8 @@ data specifications:
   ``RCP8.5``)
 - mip (for CMIP data, key ``mip``, value e.g. ``Amon``, ``Omon``, ``LImon``)
 - ensemble member (key ``ensemble``, value e.g. ``r1i1p1``, ``r1i1p1f1``)
+- sub-experiment id (key `sub_experiment`, value e.g. `s2000`, `s(2000:2002)`, 
+  for DCPP data only)
 - time range (e.g. key-value ``start_year: 1982``, ``end_year: 1990``. Please
   note that `yaml`_ interprets numbers with a leading ``0`` as octal numbers,
   so we recommend to avoid them. For example, use ``128`` to specify the year
@@ -97,6 +99,7 @@ For example, a datasets section could be:
       - {dataset: CanESM2, project: CMIP5, exp: historical, ensemble: r1i1p1, start_year: 2001, end_year: 2004}
       - {dataset: UKESM1-0-LL, project: CMIP6, exp: historical, ensemble: r1i1p1f2, start_year: 2001, end_year: 2004, grid: gn}
       - {dataset: EC-EARTH3, alias: custom_alias, project: CMIP6, exp: historical, ensemble: r1i1p1f1, start_year: 2001, end_year: 2004, grid: gn}
+      - {dataset: HadGEM3-GC31-MM, alias: custom_alias, project: CMIP6, exp: dcppA-hindcast, ensemble: r1i1p1f1, sub_experiment: s2000, grid: gn, start_year: 2000, end_year, 2002}
 
 It is possible to define the experiment as a list to concatenate two experiments.
 Here it is an example concatenating the `historical` experiment with `rcp85`
@@ -136,6 +139,14 @@ Please, bear in mind that this syntax can only be used in the ensemble tag.
 Also, note that the combination of multiple experiments and ensembles, like
 exp: [historical, rcp85], ensemble: [r1i1p1, "r(2:3)i1p1"] is not supported and will raise an error.
 
+The same simplified syntax can be used to add multiple sub-experiment ids:
+
+.. code-block:: yaml
+
+    datasets:
+      - {dataset: MIROC6, project: CMIP6, exp: dcppA-hindcast, ensemble: r1i1p1f1, sub_experiment: s(2000:2002), grid: gn, start_year: 2003, end_year: 2004}
+
+
 Note that this section is not required, as datasets can also be provided in the
 Diagnostics_ section.
 
diff --git a/doc/recipe/preprocessor.rst b/doc/recipe/preprocessor.rst
index bf1cc71d7..8664530ba 100644
--- a/doc/recipe/preprocessor.rst
+++ b/doc/recipe/preprocessor.rst
@@ -605,7 +605,9 @@ inter-comparison or comparison with observational datasets). Regridding is
 conceptually a very similar process to interpolation (in fact, the regridder
 engine uses interpolation and extrapolation, with various schemes). The primary
 difference is that interpolation is based on sample data points, while
-regridding is based on the horizontal grid of another cube (the reference grid).
+regridding is based on the horizontal grid of another cube (the reference
+grid). If the horizontal grids of a cube and its reference grid are sufficiently
+the same, regridding is automatically and silently skipped for performance reasons.
 
 The underlying regridding mechanism in ESMValTool uses
 :obj:`iris.cube.Cube.regrid`
diff --git a/esmvalcore/_data_finder.py b/esmvalcore/_data_finder.py
index ad0367799..8c700b221 100644
--- a/esmvalcore/_data_finder.py
+++ b/esmvalcore/_data_finder.py
@@ -92,13 +92,21 @@ def select_files(filenames, start_year, end_year):
 def _replace_tags(paths, variable):
     """Replace tags in the config-developer's file with actual values."""
     if isinstance(paths, str):
-        paths = (paths.strip('/'), )
+        paths = set((paths.strip('/'),))
     else:
-        paths = [path.strip('/') for path in paths]
+        paths = set(path.strip('/') for path in paths)
     tlist = set()
-
     for path in paths:
         tlist = tlist.union(re.findall(r'{([^}]*)}', path))
+    if 'sub_experiment' in variable:
+        new_paths = []
+        for path in paths:
+            new_paths.extend((
+                re.sub(r'(\b{ensemble}\b)', r'{sub_experiment}-\1', path),
+                re.sub(r'({ensemble})', r'{sub_experiment}-\1', path)
+            ))
+            tlist.add('sub_experiment')
+        paths = new_paths
     logger.debug(tlist)
 
     for tag in tlist:
@@ -112,7 +120,6 @@ def _replace_tags(paths, variable):
         else:
             raise KeyError("Dataset key {} must be specified for {}, check "
                            "your recipe entry".format(tag, variable))
-
         paths = _replace_tag(paths, original_tag, replacewith)
     return paths
 
@@ -127,7 +134,7 @@ def _replace_tag(paths, tag, replacewith):
     else:
         text = _apply_caps(str(replacewith), lower, upper)
         result.extend(p.replace('{' + tag + '}', text) for p in paths)
-    return result
+    return list(set(result))
 
 
 def _get_caps_options(tag):
diff --git a/esmvalcore/_recipe.py b/esmvalcore/_recipe.py
index 6db4df455..bf536c84c 100644
--- a/esmvalcore/_recipe.py
+++ b/esmvalcore/_recipe.py
@@ -532,9 +532,7 @@ def _get_ancestors(variable, config_user):
     logger.info("Using input files for variable %s of dataset %s:\n%s",
                 variable['short_name'], variable['dataset'],
                 '\n'.join(input_files))
-    if (not config_user.get('skip-nonexistent')
-            or variable['dataset'] == variable.get('reference_dataset')):
-        check.data_availability(input_files, variable, dirnames, filenames)
+    check.data_availability(input_files, variable, dirnames, filenames)
 
     # Set up provenance tracking
     for i, filename in enumerate(input_files):
@@ -673,6 +671,16 @@ def _match_products(products, variables):
     return grouped_products
 
 
+def _allow_skipping(ancestors, variable, config_user):
+    """Allow skipping of datasets."""
+    allow_skipping = all([
+        config_user.get('skip-nonexistent'),
+        not ancestors,
+        variable['dataset'] != variable.get('reference_dataset'),
+    ])
+    return allow_skipping
+
+
 def _get_preprocessor_products(variables, profile, order, ancestor_products,
                                config_user, name):
     """Get preprocessor product definitions for a set of datasets.
@@ -714,7 +722,7 @@ def _get_preprocessor_products(variables, profile, order, ancestor_products,
             try:
                 ancestors = _get_ancestors(variable, config_user)
             except RecipeError as ex:
-                if config_user.get('skip-nonexistent') and not ancestors:
+                if _allow_skipping(ancestors, variable, config_user):
                     logger.info("Skipping: %s", ex.message)
                 else:
                     missing_vars.add(ex.message)
@@ -1019,37 +1027,38 @@ class Recipe:
         return datasets
 
     @staticmethod
-    def _expand_ensemble(variables):
-        """Expand ensemble members to multiple datasets.
+    def _expand_tag(variables, input_tag):
+        """
+        Expand tags such as ensemble members or stardates to multiple datasets.
 
         Expansion only supports ensembles defined as strings, not lists.
         """
         expanded = []
         regex = re.compile(r'\(\d+:\d+\)')
 
-        def expand_ensemble(variable):
-            ens = variable.get('ensemble', "")
-            match = regex.search(ens)
+        def expand_tag(variable, input_tag):
+            tag = variable.get(input_tag, "")
+            match = regex.search(tag)
             if match:
                 start, end = match.group(0)[1:-1].split(':')
                 for i in range(int(start), int(end) + 1):
                     expand = deepcopy(variable)
-                    expand['ensemble'] = regex.sub(str(i), ens, 1)
-                    expand_ensemble(expand)
+                    expand[input_tag] = regex.sub(str(i), tag, 1)
+                    expand_tag(expand, input_tag)
             else:
                 expanded.append(variable)
 
         for variable in variables:
-            ensemble = variable.get('ensemble', "")
-            if isinstance(ensemble, (list, tuple)):
-                for elem in ensemble:
+            tag = variable.get(input_tag, "")
+            if isinstance(tag, (list, tuple)):
+                for elem in tag:
                     if regex.search(elem):
                         raise RecipeError(
-                            f"In variable {variable}: ensemble expansion "
-                            "cannot be combined with ensemble lists")
+                            f"In variable {variable}: {input_tag} expansion "
+                            f"cannot be combined with {input_tag} lists")
                 expanded.append(variable)
             else:
-                expand_ensemble(variable)
+                expand_tag(variable, input_tag)
 
         return expanded
 
@@ -1096,8 +1105,14 @@ class Recipe:
                 activity = get_activity(variable)
                 if activity:
                     variable['activity'] = activity
-            check.variable(variable, required_keys)
-        variables = self._expand_ensemble(variables)
+            if 'sub_experiment' in variable:
+                subexperiment_keys = deepcopy(required_keys)
+                subexperiment_keys.update({'sub_experiment'})
+                check.variable(variable, subexperiment_keys)
+            else:
+                check.variable(variable, required_keys)
+        variables = self._expand_tag(variables, 'ensemble')
+        variables = self._expand_tag(variables, 'sub_experiment')
         return variables
 
     def _initialize_preprocessor_output(self, diagnostic_name, raw_variables,
diff --git a/esmvalcore/cmor/_fixes/cmip6/bcc_csm2_mr.py b/esmvalcore/cmor/_fixes/cmip6/bcc_csm2_mr.py
index 79660aeb5..38f96526e 100644
--- a/esmvalcore/cmor/_fixes/cmip6/bcc_csm2_mr.py
+++ b/esmvalcore/cmor/_fixes/cmip6/bcc_csm2_mr.py
@@ -14,3 +14,6 @@ Tos = OceanFixGrid
 
 
 Siconc = OceanFixGrid
+
+
+Sos = OceanFixGrid
diff --git a/esmvalcore/cmor/_fixes/cmip6/bcc_esm1.py b/esmvalcore/cmor/_fixes/cmip6/bcc_esm1.py
index 1a0603c81..6a55b379a 100644
--- a/esmvalcore/cmor/_fixes/cmip6/bcc_esm1.py
+++ b/esmvalcore/cmor/_fixes/cmip6/bcc_esm1.py
@@ -11,3 +11,9 @@ Clw = ClFixHybridPressureCoord
 
 
 Tos = OceanFixGrid
+
+
+Sos = OceanFixGrid
+
+
+Siconc = OceanFixGrid
diff --git a/esmvalcore/cmor/_fixes/cmip6/mcm_ua_1_0.py b/esmvalcore/cmor/_fixes/cmip6/mcm_ua_1_0.py
index 08189338a..c31a5e7c5 100644
--- a/esmvalcore/cmor/_fixes/cmip6/mcm_ua_1_0.py
+++ b/esmvalcore/cmor/_fixes/cmip6/mcm_ua_1_0.py
@@ -85,6 +85,30 @@ class AllVars(Fix):
         return cubes
 
 
+class Omon(Fix):
+    """Fixes for ocean variables."""
+
+    def fix_metadata(self, cubes):
+        """Fix ocean depth coordinate.
+
+        Parameters
+        ----------
+        cubes: iris CubeList
+            List of cubes to fix
+
+        Returns
+        -------
+        iris.cube.CubeList
+
+        """
+        for cube in cubes:
+            if cube.coords(axis='Z'):
+                z_coord = cube.coord(axis='Z')
+                if z_coord.standard_name is None:
+                    fix_ocean_depth_coord(cube)
+        return cubes
+
+
 class Tas(Fix):
     """Fixes for tas."""
 
@@ -98,33 +122,30 @@ class Tas(Fix):
 
         Returns
         -------
-        iris.cube.Cube
+        iris.cube.CubeList
 
         """
         cube = self.get_cube_from_list(cubes)
         add_scalar_height_coord(cube, 2.0)
-        return [cube]
+        return cubes
 
 
-class Omon(Fix):
-    """Fixes for ocean variables."""
+class Uas(Fix):
+    """Fixes for uas."""
 
     def fix_metadata(self, cubes):
-        """Fix ocean depth coordinate.
+        """Add height (10m) coordinate.
 
         Parameters
         ----------
-        cubes: iris CubeList
-            List of cubes to fix
+        cubes : iris.cube.CubeList
+            Cubes to fix.
 
         Returns
         -------
         iris.cube.CubeList
 
         """
-        for cube in cubes:
-            if cube.coords(axis='Z'):
-                z_coord = cube.coord(axis='Z')
-                if z_coord.standard_name is None:
-                    fix_ocean_depth_coord(cube)
+        cube = self.get_cube_from_list(cubes)
+        add_scalar_height_coord(cube, 10.0)
         return cubes
diff --git a/esmvalcore/cmor/tables/custom/CMOR_tasaga.dat b/esmvalcore/cmor/tables/custom/CMOR_tasaga.dat
new file mode 100644
index 000000000..f62d34c5e
--- /dev/null
+++ b/esmvalcore/cmor/tables/custom/CMOR_tasaga.dat
@@ -0,0 +1,25 @@
+SOURCE: CMIP5 (adapted from tas)
+!============
+variable_entry:    tasaga
+!============
+modeling_realm:    atmos
+!----------------------------------
+! Variable attributes:
+!----------------------------------
+standard_name:     
+units:             K
+cell_methods:      time: mean
+cell_measures:     area: areacella
+long_name:         Global-mean Near-Surface Air Temperature Anomaly
+!----------------------------------
+! Additional variable information:
+!----------------------------------
+dimensions:        time
+out_name:          tasaga
+type:              real
+valid_min:         -20.0
+valid_max:         20.0
+ok_min_mean_abs:   -20
+ok_max_mean_abs:   20.
+!----------------------------------
+!
diff --git a/esmvalcore/preprocessor/_io.py b/esmvalcore/preprocessor/_io.py
index f508f0fa4..938e4b6f2 100644
--- a/esmvalcore/preprocessor/_io.py
+++ b/esmvalcore/preprocessor/_io.py
@@ -173,6 +173,8 @@ def _get_concatenation_error(cubes):
 
 def concatenate(cubes):
     """Concatenate all cubes after fixing metadata."""
+    if not cubes:
+        return cubes
     if len(cubes) == 1:
         return cubes[0]
 
@@ -227,7 +229,15 @@ def save(cubes, filename, optimize_access='', compress=False, alias='',
     str
         filename
 
+    Raises
+    ------
+    ValueError
+        cubes is empty.
+
     """
+    if not cubes:
+        raise ValueError(f"Cannot save empty cubes '{cubes}'")
+
     # Rename some arguments
     kwargs['target'] = filename
     kwargs['zlib'] = compress
diff --git a/esmvalcore/preprocessor/_regrid.py b/esmvalcore/preprocessor/_regrid.py
index 7ff8dde22..b35c4b3a2 100644
--- a/esmvalcore/preprocessor/_regrid.py
+++ b/esmvalcore/preprocessor/_regrid.py
@@ -467,15 +467,56 @@ def regrid(cube, target_grid, scheme, lat_offset=True, lon_offset=True):
                 [coord] = coords
                 cube.remove_coord(coord)
 
-    # Perform the horizontal regridding.
-    if _attempt_irregular_regridding(cube, scheme):
-        cube = esmpy_regrid(cube, target_grid, scheme)
-    else:
-        cube = cube.regrid(target_grid, HORIZONTAL_SCHEMES[scheme])
+    # Return non-regridded cube if horizontal grid is the same.
+    if not _horizontal_grid_is_close(cube, target_grid):
+
+        # Perform the horizontal regridding.
+        if _attempt_irregular_regridding(cube, scheme):
+            cube = esmpy_regrid(cube, target_grid, scheme)
+        else:
+            cube = cube.regrid(target_grid, HORIZONTAL_SCHEMES[scheme])
 
     return cube
 
 
+def _horizontal_grid_is_close(cube1, cube2):
+    """Check if two cubes have the same horizontal grid definition.
+
+    The result of the function is a boolean answer, if both cubes have the
+    same horizontal grid definition. The function checks both longitude and
+    latitude, based on extent and resolution.
+
+    Parameters
+    ----------
+    cube1 : cube
+        The first of the cubes to be checked.
+    cube2 : cube
+        The second of the cubes to be checked.
+
+    Returns
+    -------
+    bool
+
+    .. note::
+
+        The current implementation checks if the bounds and the
+        grid shapes are the same.
+        Exits on first difference.
+    """
+    # Go through the 2 expected horizontal coordinates longitude and latitude.
+    for coord in ['latitude', 'longitude']:
+        coord1 = cube1.coord(coord)
+        coord2 = cube2.coord(coord)
+
+        if not coord1.shape == coord2.shape:
+            return False
+
+        if not np.allclose(coord1.bounds, coord2.bounds):
+            return False
+
+    return True
+
+
 def _create_cube(src_cube, data, src_levels, levels):
     """Generate a new cube with the interpolated data.
 


Test patch:
diff --git a/tests/integration/cmor/_fixes/cmip6/test_bcc_csm2_mr.py b/tests/integration/cmor/_fixes/cmip6/test_bcc_csm2_mr.py
index 7657b663d..8b0b03916 100644
--- a/tests/integration/cmor/_fixes/cmip6/test_bcc_csm2_mr.py
+++ b/tests/integration/cmor/_fixes/cmip6/test_bcc_csm2_mr.py
@@ -1,5 +1,12 @@
 """Test fixes for BCC-CSM2-MR."""
-from esmvalcore.cmor._fixes.cmip6.bcc_csm2_mr import Cl, Cli, Clw, Siconc, Tos
+from esmvalcore.cmor._fixes.cmip6.bcc_csm2_mr import (
+    Cl,
+    Cli,
+    Clw,
+    Siconc,
+    Sos,
+    Tos,
+)
 from esmvalcore.cmor._fixes.common import (
     ClFixHybridPressureCoord,
     OceanFixGrid,
@@ -60,3 +67,14 @@ def test_get_siconc_fix():
 def test_siconc_fix():
     """Test fix for ``siconc``."""
     assert Siconc is OceanFixGrid
+
+
+def test_get_sos_fix():
+    """Test getting of fix."""
+    fix = Fix.get_fixes('CMIP6', 'BCC-CSM2-MR', 'Omon', 'sos')
+    assert fix == [Sos(None)]
+
+
+def test_sos_fix():
+    """Test fix for ``sos``."""
+    assert Sos is OceanFixGrid
diff --git a/tests/integration/cmor/_fixes/cmip6/test_bcc_esm1.py b/tests/integration/cmor/_fixes/cmip6/test_bcc_esm1.py
index d4ceec1d8..382d0268f 100644
--- a/tests/integration/cmor/_fixes/cmip6/test_bcc_esm1.py
+++ b/tests/integration/cmor/_fixes/cmip6/test_bcc_esm1.py
@@ -1,5 +1,12 @@
 """Test fixes for BCC-ESM1."""
-from esmvalcore.cmor._fixes.cmip6.bcc_esm1 import Cl, Cli, Clw, Tos
+from esmvalcore.cmor._fixes.cmip6.bcc_esm1 import (
+    Cl,
+    Cli,
+    Clw,
+    Siconc,
+    Sos,
+    Tos,
+)
 from esmvalcore.cmor._fixes.common import (
     ClFixHybridPressureCoord,
     OceanFixGrid,
@@ -40,6 +47,28 @@ def test_clw_fix():
     assert Clw is ClFixHybridPressureCoord
 
 
+def test_get_siconc_fix():
+    """Test getting of fix."""
+    fix = Fix.get_fixes('CMIP6', 'BCC-ESM1', 'SImon', 'siconc')
+    assert fix == [Siconc(None)]
+
+
+def test_siconc_fix():
+    """Test fix for ``siconc``."""
+    assert Siconc is OceanFixGrid
+
+
+def test_get_sos_fix():
+    """Test getting of fix."""
+    fix = Fix.get_fixes('CMIP6', 'BCC-ESM1', 'Omon', 'sos')
+    assert fix == [Sos(None)]
+
+
+def test_sos_fix():
+    """Test fix for ``sos``."""
+    assert Sos is OceanFixGrid
+
+
 def test_get_tos_fix():
     """Test getting of fix."""
     fix = Fix.get_fixes('CMIP6', 'BCC-ESM1', 'Omon', 'tos')
diff --git a/tests/integration/cmor/_fixes/cmip6/test_mcm_ua_1_0.py b/tests/integration/cmor/_fixes/cmip6/test_mcm_ua_1_0.py
index 04202f524..a88694681 100644
--- a/tests/integration/cmor/_fixes/cmip6/test_mcm_ua_1_0.py
+++ b/tests/integration/cmor/_fixes/cmip6/test_mcm_ua_1_0.py
@@ -4,7 +4,7 @@ import numpy as np
 import pytest
 from cf_units import Unit
 
-from esmvalcore.cmor._fixes.cmip6.mcm_ua_1_0 import AllVars, Omon, Tas
+from esmvalcore.cmor._fixes.cmip6.mcm_ua_1_0 import AllVars, Omon, Tas, Uas
 from esmvalcore.cmor.fix import Fix
 from esmvalcore.cmor.table import get_var_info
 
@@ -48,6 +48,45 @@ def cubes():
     return iris.cube.CubeList([correct_cube, wrong_cube, scalar_cube])
 
 
+@pytest.fixture
+def uas_cubes():
+    correct_lat_coord = iris.coords.DimCoord([0.0],
+                                             var_name='lat',
+                                             standard_name=' latitude  ',
+                                             long_name='  latitude')
+    wrong_lat_coord = iris.coords.DimCoord([0.0],
+                                           var_name='latitudeCoord',
+                                           standard_name='  latitude',
+                                           long_name='latitude')
+    correct_lon_coord = iris.coords.DimCoord([0.0],
+                                             var_name='lon',
+                                             standard_name='  longitude  ',
+                                             long_name='longitude  ')
+    wrong_lon_coord = iris.coords.DimCoord([0.0],
+                                           var_name='longitudeCoord',
+                                           standard_name='longitude',
+                                           long_name='  longitude')
+    correct_cube = iris.cube.Cube(
+        [[10.0]],
+        var_name='uas',
+        standard_name='eastward_wind   ',
+        long_name='   East Near-Surface Wind   ',
+        dim_coords_and_dims=[(correct_lat_coord, 0), (correct_lon_coord, 1)],
+    )
+    wrong_cube = iris.cube.Cube(
+        [[10.0]],
+        var_name='ta',
+        standard_name='   air_temperature   ',
+        long_name='Air Temperature',
+        dim_coords_and_dims=[(wrong_lat_coord, 0), (wrong_lon_coord, 1)],
+        attributes={'parent_time_units': 'days since 0000-00-00 (noleap)'},
+    )
+    scalar_cube = iris.cube.Cube(0.0, var_name='ps',
+                                 standard_name='air_pressure   ',
+                                 long_name=' Air pressure  ')
+    return iris.cube.CubeList([correct_cube, wrong_cube, scalar_cube])
+
+
 @pytest.fixture
 def cubes_bounds():
     lat_coord = iris.coords.DimCoord([0.0],
@@ -87,6 +126,11 @@ def test_get_tas_fix():
     assert fix == [Tas(None), AllVars(None)]
 
 
+def test_get_uas_fix():
+    fix = Fix.get_fixes('CMIP6', 'MCM-UA-1-0', 'Amon', 'uas')
+    assert fix == [Uas(None), AllVars(None)]
+
+
 def test_allvars_fix_metadata(cubes):
     fix = AllVars(None)
     out_cubes = fix.fix_metadata(cubes)
@@ -160,6 +204,32 @@ def test_tas_fix_metadata(cubes):
     assert coord == height_coord
 
 
+def test_uas_fix_metadata(uas_cubes):
+    for cube in uas_cubes:
+        with pytest.raises(iris.exceptions.CoordinateNotFoundError):
+            cube.coord('height')
+    height_coord = iris.coords.AuxCoord(10.0,
+                                        var_name='height',
+                                        standard_name='height',
+                                        long_name='height',
+                                        units=Unit('m'),
+                                        attributes={'positive': 'up'})
+    vardef = get_var_info('CMIP6', 'Amon', 'uas')
+    fix = Uas(vardef)
+
+    # Check fix
+    out_cubes = fix.fix_metadata(uas_cubes)
+    assert out_cubes[0].var_name == 'uas'
+    coord = out_cubes[0].coord('height')
+    assert coord == height_coord
+
+    # Check that height coordinate is not added twice
+    out_cubes_2 = fix.fix_metadata(out_cubes)
+    assert out_cubes_2[0].var_name == 'uas'
+    coord = out_cubes_2[0].coord('height')
+    assert coord == height_coord
+
+
 @pytest.fixture
 def thetao_cubes():
     time_coord = iris.coords.DimCoord(
diff --git a/tests/integration/cmor/test_table.py b/tests/integration/cmor/test_table.py
index 0580fad4d..568dd6aab 100644
--- a/tests/integration/cmor/test_table.py
+++ b/tests/integration/cmor/test_table.py
@@ -381,3 +381,12 @@ class TestCustomInfo(unittest.TestCase):
         self.assertEqual(var.long_name,
                          'Near-Surface Air Temperature Uncertainty Range')
         self.assertEqual(var.units, 'K')
+
+    def test_get_variable_tasaga(self):
+        """Get tas variable."""
+        CustomInfo()
+        var = self.variables_info.get_variable('Amon', 'tasaga')
+        self.assertEqual(var.short_name, 'tasaga')
+        self.assertEqual(var.long_name,
+                         'Global-mean Near-Surface Air Temperature Anomaly')
+        self.assertEqual(var.units, 'K')
diff --git a/tests/integration/preprocessor/_io/test_concatenate.py b/tests/integration/preprocessor/_io/test_concatenate.py
index f8ef998f9..ce0d9529b 100644
--- a/tests/integration/preprocessor/_io/test_concatenate.py
+++ b/tests/integration/preprocessor/_io/test_concatenate.py
@@ -1,7 +1,7 @@
 """Integration tests for :func:`esmvalcore.preprocessor._io.concatenate`."""
 
-import warnings
 import unittest
+import warnings
 from unittest.mock import call
 
 import numpy as np
@@ -243,6 +243,12 @@ class TestConcatenate(unittest.TestCase):
         np.testing.assert_array_equal(
             concatenated.coord('time').points, np.array([1, 2, 3, 4, 5, 6]))
 
+    def test_concatenate_empty_cubes(self):
+        """Test concatenation with empty :class:`iris.cube.CubeList`."""
+        empty_cubes = CubeList([])
+        result = _io.concatenate(empty_cubes)
+        assert result is empty_cubes
+
     def test_concatenate_noop(self):
         """Test concatenation of a single cube."""
         concatenated = _io.concatenate([self.raw_cubes[0]])
diff --git a/tests/integration/preprocessor/_io/test_save.py b/tests/integration/preprocessor/_io/test_save.py
index cc6c98364..9ccb25efc 100644
--- a/tests/integration/preprocessor/_io/test_save.py
+++ b/tests/integration/preprocessor/_io/test_save.py
@@ -8,7 +8,7 @@ import iris
 import netCDF4
 import numpy as np
 from iris.coords import DimCoord
-from iris.cube import Cube
+from iris.cube import Cube, CubeList
 
 from esmvalcore.preprocessor import save
 
@@ -78,6 +78,13 @@ class TestSave(unittest.TestCase):
         self.assertEqual(sample_filters['complevel'], 4)
         handler.close()
 
+    def test_fail_empty_cubes(self):
+        """Test save fails if empty cubes is provided."""
+        (_, filename) = self._create_sample_cube()
+        empty_cubes = CubeList([])
+        with self.assertRaises(ValueError):
+            save(empty_cubes, filename)
+
     def test_fail_without_filename(self):
         """Test save fails if filename is not provided."""
         cube, _ = self._create_sample_cube()
diff --git a/tests/unit/data_finder/test_replace_tags.py b/tests/unit/data_finder/test_replace_tags.py
index 93ba42b41..097cc7b32 100644
--- a/tests/unit/data_finder/test_replace_tags.py
+++ b/tests/unit/data_finder/test_replace_tags.py
@@ -1,22 +1,65 @@
 """Tests for _replace_tags in _data_finder.py."""
-
 from esmvalcore._data_finder import _replace_tags
 
 VARIABLE = {
+    'project': 'CMIP6',
+    'dataset': 'ACCURATE-MODEL',
+    'activity': 'act',
+    'exp': 'experiment',
+    'institute': 'HMA',
+    'ensemble': 'r1i1p1f1',
+    'mip': 'Amon',
     'short_name': 'tas',
+    'grid': 'gr',
 }
 
 
-def test_replace_tags_str():
-    assert _replace_tags('folder/subfolder/{short_name}',
-                         VARIABLE) == ['folder/subfolder/tas']
+def test_replace_tags():
+    """Tests for get_start_end_year function."""
+    path = _replace_tags(
+        '{activity}/{institute}/{dataset}/{exp}/{ensemble}/{mip}/{short_name}/'
+        '{grid}/{latestversion}', VARIABLE)
+    input_file = _replace_tags(
+        '{short_name}_{mip}_{dataset}_{exp}_{ensemble}_{grid}*.nc', VARIABLE)
+    output_file = _replace_tags(
+        '{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}', VARIABLE)
+    assert path == [
+        'act/HMA/ACCURATE-MODEL/experiment/r1i1p1f1/Amon/tas/gr/'
+        '{latestversion}'
+    ]
+    assert input_file == ['tas_Amon_ACCURATE-MODEL_experiment_r1i1p1f1_gr*.nc']
+    assert output_file == ['CMIP6_ACCURATE-MODEL_Amon_experiment_r1i1p1f1_tas']
 
 
 def test_replace_tags_list_of_str():
-    assert _replace_tags(('folder/subfolder/{short_name}',
-                          'folder2/{short_name}', 'subfolder/{short_name}'),
-                         VARIABLE) == [
-                             'folder/subfolder/tas',
-                             'folder2/tas',
-                             'subfolder/tas',
-                         ]
+    assert sorted(
+        _replace_tags(('folder/subfolder/{short_name}', 'folder2/{short_name}',
+                       'subfolder/{short_name}'), VARIABLE)) == sorted([
+                           'folder2/tas',
+                           'folder/subfolder/tas',
+                           'subfolder/tas',
+                       ])
+
+
+def test_replace_tags_with_subexperiment():
+    """Tests for get_start_end_year function."""
+    variable = {'sub_experiment': '199411', **VARIABLE}
+    path = _replace_tags(
+        '{activity}/{institute}/{dataset}/{exp}/{ensemble}/{mip}/{short_name}/'
+        '{grid}/{latestversion}', variable)
+    input_file = _replace_tags(
+        '{short_name}_{mip}_{dataset}_{exp}_{ensemble}_{grid}*.nc', variable)
+    output_file = _replace_tags(
+        '{project}_{dataset}_{mip}_{exp}_{ensemble}_{short_name}', variable)
+    assert sorted(path) == sorted([
+        'act/HMA/ACCURATE-MODEL/experiment/r1i1p1f1/Amon/tas/gr/'
+        '{latestversion}',
+        'act/HMA/ACCURATE-MODEL/experiment/199411-r1i1p1f1/Amon/tas/gr/'
+        '{latestversion}'
+    ])
+    assert input_file == [
+        'tas_Amon_ACCURATE-MODEL_experiment_199411-r1i1p1f1_gr*.nc'
+    ]
+    assert output_file == [
+        'CMIP6_ACCURATE-MODEL_Amon_experiment_199411-r1i1p1f1_tas'
+    ]
diff --git a/tests/unit/preprocessor/_regrid/test_regrid.py b/tests/unit/preprocessor/_regrid/test_regrid.py
index fb6ec9423..b7beaca44 100644
--- a/tests/unit/preprocessor/_regrid/test_regrid.py
+++ b/tests/unit/preprocessor/_regrid/test_regrid.py
@@ -1,16 +1,20 @@
-"""
-Unit tests for the :func:`esmvalcore.preprocessor.regrid.regrid` function.
-
-"""
+"""Unit tests for the :func:`esmvalcore.preprocessor.regrid.regrid`
+function."""
 
 import unittest
 from unittest import mock
 
 import iris
+import numpy as np
+import pytest
 
 import tests
 from esmvalcore.preprocessor import regrid
-from esmvalcore.preprocessor._regrid import _CACHE, HORIZONTAL_SCHEMES
+from esmvalcore.preprocessor._regrid import (
+    _CACHE,
+    HORIZONTAL_SCHEMES,
+    _horizontal_grid_is_close,
+)
 
 
 class Test(tests.Test):
@@ -64,9 +68,17 @@ class Test(tests.Test):
             'unstructured_nearest'
         ]
 
-        def _return_mock_global_stock_cube(spec,
-                                           lat_offset=True,
-                                           lon_offset=True):
+        def _mock_horizontal_grid_is_close(src, tgt):
+            return False
+
+        self.patch('esmvalcore.preprocessor._regrid._horizontal_grid_is_close',
+                   side_effect=_mock_horizontal_grid_is_close)
+
+        def _return_mock_global_stock_cube(
+            spec,
+            lat_offset=True,
+            lon_offset=True,
+        ):
             return self.tgt_grid
 
         self.mock_stock = self.patch(
@@ -117,5 +129,108 @@ class Test(tests.Test):
         _CACHE.clear()
 
 
+def _make_coord(start: float, stop: float, step: int, *, name: str):
+    """Helper function for creating a coord."""
+    coord = iris.coords.DimCoord(
+        np.linspace(start, stop, step),
+        standard_name=name,
+        units='degrees',
+    )
+    coord.guess_bounds()
+    return coord
+
+
+def _make_cube(*, lat: tuple, lon: tuple):
+    """Helper function for creating a cube."""
+    lat_coord = _make_coord(*lat, name='latitude')
+    lon_coord = _make_coord(*lon, name='longitude')
+
+    return iris.cube.Cube(
+        np.empty([len(lat_coord.points),
+                  len(lon_coord.points)]),
+        dim_coords_and_dims=[(lat_coord, 0), (lon_coord, 1)],
+    )
+
+
+# 10x10
+LAT_SPEC1 = (-85, 85, 18)
+LON_SPEC1 = (5, 355, 36)
+
+# almost 10x10, but different shape
+LAT_SPEC2 = (-85, 85, 17)
+LON_SPEC2 = (5, 355, 35)
+
+# 10x10, but different coords
+LAT_SPEC3 = (-90, 90, 18)
+LON_SPEC3 = (0, 360, 36)
+
+
+@pytest.mark.parametrize(
+    'cube2_spec, expected',
+    (
+        # equal lat/lon
+        (
+            {
+                'lat': LAT_SPEC1,
+                'lon': LON_SPEC1,
+            },
+            True,
+        ),
+        # different lon shape
+        (
+            {
+                'lat': LAT_SPEC1,
+                'lon': LON_SPEC2,
+            },
+            False,
+        ),
+        # different lat shape
+        (
+            {
+                'lat': LAT_SPEC2,
+                'lon': LON_SPEC1,
+            },
+            False,
+        ),
+        # different lon values
+        (
+            {
+                'lat': LAT_SPEC1,
+                'lon': LON_SPEC3,
+            },
+            False,
+        ),
+        # different lat values
+        (
+            {
+                'lat': LAT_SPEC3,
+                'lon': LON_SPEC1,
+            },
+            False,
+        ),
+    ),
+)
+def test_horizontal_grid_is_close(cube2_spec: dict, expected: bool):
+    """Test for `_horizontal_grid_is_close`."""
+    cube1 = _make_cube(lat=LAT_SPEC1, lon=LON_SPEC1)
+    cube2 = _make_cube(**cube2_spec)
+
+    assert _horizontal_grid_is_close(cube1, cube2) == expected
+
+
+def test_regrid_is_skipped_if_grids_are_the_same():
+    """Test that regridding is skipped if the grids are the same."""
+    cube = _make_cube(lat=LAT_SPEC1, lon=LON_SPEC1)
+    scheme = 'linear'
+
+    # regridding to the same spec returns the same cube
+    expected_same_cube = regrid(cube, target_grid='10x10', scheme=scheme)
+    assert expected_same_cube is cube
+
+    # regridding to a different spec returns a different cube
+    expected_different_cube = regrid(cube, target_grid='5x5', scheme=scheme)
+    assert expected_different_cube is not cube
+
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/unit/test_recipe.py b/tests/unit/test_recipe.py
index 1ed187592..493f366fe 100644
--- a/tests/unit/test_recipe.py
+++ b/tests/unit/test_recipe.py
@@ -1,6 +1,6 @@
 import pytest
 
-from esmvalcore._recipe import Recipe
+from esmvalcore._recipe import Recipe, _allow_skipping
 from esmvalcore._recipe_checks import RecipeError
 
 
@@ -14,7 +14,7 @@ class TestRecipe:
             },
         ]
 
-        expanded = Recipe._expand_ensemble(datasets)
+        expanded = Recipe._expand_tag(datasets, 'ensemble')
 
         ensembles = [
             'r1i2p3',
@@ -29,6 +29,31 @@ class TestRecipe:
         for i, ensemble in enumerate(ensembles):
             assert expanded[i] == {'dataset': 'XYZ', 'ensemble': ensemble}
 
+    def test_expand_subexperiment(self):
+
+        datasets = [
+            {
+                'dataset': 'XYZ',
+                'sub_experiment': 's(1998:2005)',
+            },
+        ]
+
+        expanded = Recipe._expand_tag(datasets, 'sub_experiment')
+
+        subexperiments = [
+            's1998',
+            's1999',
+            's2000',
+            's2001',
+            's2002',
+            's2003',
+            's2004',
+            's2005',
+        ]
+        for i, subexperiment in enumerate(subexperiments):
+            assert expanded[i] == {'dataset': 'XYZ',
+                                   'sub_experiment': subexperiment}
+
     def test_expand_ensemble_nolist(self):
 
         datasets = [
@@ -39,4 +64,38 @@ class TestRecipe:
         ]
 
         with pytest.raises(RecipeError):
-            Recipe._expand_ensemble(datasets)
+            Recipe._expand_tag(datasets, 'ensemble')
+
+
+VAR_A = {'dataset': 'A'}
+VAR_A_REF_A = {'dataset': 'A', 'reference_dataset': 'A'}
+VAR_A_REF_B = {'dataset': 'A', 'reference_dataset': 'B'}
+
+
+TEST_ALLOW_SKIPPING = [
+    ([], VAR_A, {}, False),
+    ([], VAR_A, {'skip-nonexistent': False}, False),
+    ([], VAR_A, {'skip-nonexistent': True}, True),
+    ([], VAR_A_REF_A, {}, False),
+    ([], VAR_A_REF_A, {'skip-nonexistent': False}, False),
+    ([], VAR_A_REF_A, {'skip-nonexistent': True}, False),
+    ([], VAR_A_REF_B, {}, False),
+    ([], VAR_A_REF_B, {'skip-nonexistent': False}, False),
+    ([], VAR_A_REF_B, {'skip-nonexistent': True}, True),
+    (['A'], VAR_A, {}, False),
+    (['A'], VAR_A, {'skip-nonexistent': False}, False),
+    (['A'], VAR_A, {'skip-nonexistent': True}, False),
+    (['A'], VAR_A_REF_A, {}, False),
+    (['A'], VAR_A_REF_A, {'skip-nonexistent': False}, False),
+    (['A'], VAR_A_REF_A, {'skip-nonexistent': True}, False),
+    (['A'], VAR_A_REF_B, {}, False),
+    (['A'], VAR_A_REF_B, {'skip-nonexistent': False}, False),
+    (['A'], VAR_A_REF_B, {'skip-nonexistent': True}, False),
+]
+
+
+@pytest.mark.parametrize('ancestors,var,cfg,out', TEST_ALLOW_SKIPPING)
+def test_allow_skipping(ancestors, var, cfg, out):
+    """Test ``_allow_skipping``."""
+    result = _allow_skipping(ancestors, var, cfg)
+    assert result is out