Repository: ESMValGroup/ESMValCore

Issue:
Dealing with startdates in filenames
**Is your feature request related to a problem? Please describe.**
Some experiments (DCPP, etc) contain startdates in the filenames and this is currently not supported.

**Would you be able to help out?**
Started working in branch dev_dccp_startdates, in which startdates are dealt with the same way as ensemble members by getting expanded. 
The requirement for start_year and end_year is relaxed if the startdate is provided, because in this way the number of chunks per startdate can be flexible. I am not sure if there would be a way to determine how many chunks per startdate will there with parameters from the metadata?


Discussion:
jvegasbsc: @remi-kazeroni, can you please test it in DKRZ to see if we need some extra work to support it. If you or any other volunter don't have time  to test it, we can merge it so at least we can read DCPP data in Jasmin
remi-kazeroni: > @remi-kazeroni, can you please test it in DKRZ to see if we need some extra work to support it. If you or any other volunter don't have time to test it, we can merge it so at least we can read DCPP data in Jasmin

Yes sure, I'll have a look at this PR this week
zklaus: Note that what you call startdate here is actually part of the CMIP6 DRS as `sub_experiment`. See [CMIP6 Global Attributes, DRS, Filenames, Directory Structure, and CV’s](http://goo.gl/v1drZl), particularly p. 13, DRS components and p. 14, filename template. This also makes reference to the applicable CV in [CMIP6_sub_experiment.json](https://github.com/WCRP-CMIP/CMIP6_CVs/blob/master/CMIP6_sub_experiment_id.json).

Perhaps it would be prudent to adopt the same terminology. That would also open the functionality up to other MIPs that might have sub-experiments in the future.

The first linked document also tells us what the directory structure should be (p.17, directory structure template), but of course we can't force every data center to conform.
jvegasbsc: > The start_year and end_year are not needed when a sub_experiment is provided in the recipe. Instead, the decadal time bounds are used (e.g. start_year: 2000 and end_year: 2010 for sub_experiment: s2000-r1i1p1f1). One can still consider a shorter time range (e.g. 2005-2008) but in this case filenames contain the whole decadal time range instead of the shorter range (e.g. plots/*_dcppA-hindcast_s2000-r1i1p1f1_tas_2000-2010.png and not plots/*_dcppA-hindcast_s2000-r1i1p1f1_tas_2005-2008.png). Is that expected? Perhaps this example is not relevant in practice...

Without changing the way we manage the time range, handling the start and end year will be a nightmare for the full hindcasts, as we will have to specify it manually for all years from 1979 to 2010 or so. The ability to use `s(1979:2010)` will be useless. 

We have a pending issue to deal with the time range #345 and I remember another suggestion asking for a way yo specify relative ranges (first 20 years, last 50, all available...) I think we should do this rework in a separated pull request.


zklaus: The sub-experiment should really be completely independent from the startrange and endrange. It only tells you something about the initialization of the experiment. Every run is 10 years long, but there is a new run started for every year. This means that there are many different sub-experiments covering the same years and comparing these is one of the main interests of the DCPP mip.

Consequently, we should expect it to be common to find dataset blocks like
```yaml
datasets:
  - {sub-experiment: s1960, start_year: 1965, end_year: 1970}
  - {sub-experiment: s1962, start_year: 1965, end_year: 1970}
  - {sub-experiment: s1963, start_year: 1965, end_year: 1970}
  - {sub-experiment: s1964, start_year: 1965, end_year: 1970}
```
sloosvel: >The sub-experiment should really be completely independent from the startrange and endrange.

I think it already is. This finds and loads all the files available for a certain sub-experiment value. And later it adds the overall `start_year` and `end_year`. It's true that you cannot subset it as @remi-kazeroni says, but I think people normally load all years available anyway.

>Consequently, we should expect it to be common to find dataset blocks like

datasets:
  - {sub-experiment: s1960, start_year: 1965, end_year: 1970}
  - {sub-experiment: s1962, start_year: 1965, end_year: 1970}
  - {sub-experiment: s1963, start_year: 1965, end_year: 1970}
  - {sub-experiment: s1964, start_year: 1965, end_year: 1970}
 
I am a bit lost because that's what I was trying to avoid: having 50 lines in a recipe to call a single experiment with multiple subexperiments. 
zklaus: > I am a bit lost because that's what I was trying to avoid: having 50 lines in a recipe to call a single experiment with multiple subexperiments.

Why?


sloosvel: Because everything belongs to the same dataset-experiment pair, which is what every new line in a recipe represents. It also makes the recipe easier to read. 

And the end goal will be to be able to deal with all those sub-experiments  as one in a single cube with a new dimension. So it's like a single dataset.
zklaus: I think in the context of the DCPP mip, every sub-experiment should be seen as its own experiment. You don't always (or even generally) want to look at all sub-experiments at the same time.
remi-kazeroni: 
> datasets:
> 
>     * {sub-experiment: s1960, start_year: 1965, end_year: 1970}
> 
>     * {sub-experiment: s1962, start_year: 1965, end_year: 1970}
> 
>     * {sub-experiment: s1963, start_year: 1965, end_year: 1970}
> 
>     * {sub-experiment: s1964, start_year: 1965, end_year: 1970}
> 
> 
> I am a bit lost because that's what I was trying to avoid: having 50 lines in a recipe to call a single experiment with multiple subexperiments.

But I think this can already be done in one line with this PR using: ` {sub-experiment: s(1960:1964), start_year: 1965, end_year: 1970}` (assuming 1961 is used as well). 
I tested that with the `examples/recipe_python.yml` and only the data between `start_year` and `end_year` are used and plotted. But the filenames in the `plots` dir are in the form `*dcppA-hindcast_s1964-r1i1p1f1_tas_1964-1974.png` while one would expect `*dcppA-hindcast_s1964-r1i1p1f1_tas_1965-1970.png`


zklaus: Sure, there are many ways to specify these. The point is neither should we manipulate start and end because of sub-experiment, nor sub-experiment because of start and end.
sloosvel: So which changes in this pull request do you not approve of?  
zklaus: > So which changes in this pull request do you not approve of?

In the review above I tried to mark all the places that this touches. All of those remarks really are about the same issue.
sloosvel: Good to know! I'll try to see what can be done about them.
zklaus: There is one comment from the previous review left to address.

Now, this PR seems to introduce new functionality in terms of `all_years`. That appears to be completely unrelated to DCPP, so I suggest putting it into a different PR. That way we will be able to wrap this one up more quickly and the functionality (that will be appreciated by others for other experiments!) will get better visibility.
sloosvel: >  That appears to be completely unrelated to DCPP,

The DCPP functionality that we need at the department requires it. This pull request without it does not help much our use case. 

Would it be that bad to include it as a first step, and then refine it for other experiments in another pull request?

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/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 a57dac967..bf536c84c 100644
--- a/esmvalcore/_recipe.py
+++ b/esmvalcore/_recipe.py
@@ -1027,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
 
@@ -1104,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/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


Test patch:
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/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/test_recipe.py b/tests/unit/test_recipe.py
index 885684cee..493f366fe 100644
--- a/tests/unit/test_recipe.py
+++ b/tests/unit/test_recipe.py
@@ -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,7 +64,7 @@ class TestRecipe:
         ]
 
         with pytest.raises(RecipeError):
-            Recipe._expand_ensemble(datasets)
+            Recipe._expand_tag(datasets, 'ensemble')
 
 
 VAR_A = {'dataset': 'A'}