Repository: ESMValGroup/ESMValCore

Issue:
Potential memory leak in local solar time preprocessor
The iris team warned us that Dask appears to be keeping functions it uses in memory even after it is done with them, see https://github.com/SciTools/iris/pull/5767. Wrapping large arguments, such as numpy arrays in a `functools.partial` is an example of how functions can be created that will make this into a sizeable memory leak. I found one example in our codebase where this seems to happen:
https://github.com/ESMValGroup/ESMValCore/blob/2be264db778c5be86600ff5134d28b3b1d8d1e0c/esmvalcore/preprocessor/_time.py#L1508-L1516

@schlunma, could you have a look?

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2356?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 94.11%. Comparing base [(`2be264d`)](https://app.codecov.io/gh/ESMValGroup/ESMValCore/commit/2be264db778c5be86600ff5134d28b3b1d8d1e0c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) to head [(`72859ef`)](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2356?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2356      +/-   ##
==========================================
- Coverage   94.11%   94.11%   -0.01%     
==========================================
  Files         241      241              
  Lines       13381    13379       -2     
==========================================
- Hits        12594    12592       -2     
  Misses        787      787              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2356?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup).


Patch:
diff --git a/.zenodo.json b/.zenodo.json
index df7a49977..bb00d5471 100644
--- a/.zenodo.json
+++ b/.zenodo.json
@@ -200,6 +200,10 @@
             "affiliation": "BSC, Spain",
             "name": "Martin-Martinez, Eneko",
             "orcid": "0000-0002-9213-7818"
+        },
+        {
+            "affiliation": "DLR, Germany",
+            "name": "Cammarano, Diego"
         }
     ],
     "description": "ESMValCore: A community tool for pre-processing data from Earth system models in CMIP and running analysis scripts.",
diff --git a/CITATION.cff b/CITATION.cff
index d188af5d3..b7c116e3e 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -204,6 +204,10 @@ authors:
     family-names: Martin-Martinez
     given-names: Eneko
     orcid: "https://orcid.org/0000-0002-9213-7818"
+  -
+    affiliation: "DLR, Germany"
+    family-names: Cammarano
+    given-names: Diego
 
 cff-version: 1.2.0
 date-released: 2023-12-19
diff --git a/doc/quickstart/find_data.rst b/doc/quickstart/find_data.rst
index 31ee262ef..131bf8f99 100644
--- a/doc/quickstart/find_data.rst
+++ b/doc/quickstart/find_data.rst
@@ -388,7 +388,9 @@ This grid file can either be specified as absolute or relative (to
 with the facet ``horizontal_grid`` in the recipe or the extra facets (see
 below), or retrieved automatically from the `grid_file_uri` attribute of the
 input files.
-In the latter case, the file is downloaded once and then cached.
+In the latter case, ESMValCore first searches the input directories specified
+for ICON for a grid file with that name, and if that was not successful, tries
+to download the file and cache it.
 The cached file is valid for 7 days.
 
 ESMValCore can automatically make native ICON data `UGRID
@@ -467,7 +469,7 @@ Key                 Description                      Default value if not specif
 =================== ================================ ===================================
 ``horizontal_grid`` Absolute or relative (to         If not given, use file attribute
                     ``auxiliary_data_dir`` defined   ``grid_file_uri`` to retrieve ICON
-                    in the                           grid file
+                    in the                           grid file (see details above)
                     :ref:`user configuration file`)
                     path to the ICON grid file
 ``latitude``        Standard name of the latitude    ``latitude``
diff --git a/esmvalcore/cmor/_fixes/icon/_base_fixes.py b/esmvalcore/cmor/_fixes/icon/_base_fixes.py
index 858cb4112..3255a075b 100644
--- a/esmvalcore/cmor/_fixes/icon/_base_fixes.py
+++ b/esmvalcore/cmor/_fixes/icon/_base_fixes.py
@@ -1,21 +1,25 @@
 """Fix base classes for ICON on-the-fly CMORizer."""
+from __future__ import annotations
 
 import logging
 import os
+import shutil
 import warnings
 from datetime import datetime
 from pathlib import Path
 from shutil import copyfileobj
+from tempfile import NamedTemporaryFile
 from urllib.parse import urlparse
 
 import iris
 import numpy as np
 import requests
-from filelock import FileLock
 from iris import NameConstraint
+from iris.cube import Cube, CubeList
 from iris.experimental.ugrid import Connectivity, Mesh
 
-from ..native_datasets import NativeDatasetFix
+from esmvalcore.cmor._fixes.native_datasets import NativeDatasetFix
+from esmvalcore.local import _get_rootpath, _replace_tags, _select_drs
 
 logger = logging.getLogger(__name__)
 
@@ -239,7 +243,7 @@ class IconFix(NativeDatasetFix):
             'zghalf_file',
         ]
         for facet in facets_to_consider:
-            if facet not in self.extra_facets:
+            if self.extra_facets.get(facet) is None:
                 continue
             path_to_add = self._get_path_from_facet(facet)
             logger.debug("Adding cubes from %s", path_to_add)
@@ -264,7 +268,13 @@ class IconFix(NativeDatasetFix):
         logger.debug("Loaded ICON grid file from %s", grid_path)
         return self._horizontal_grids[grid_name]
 
-    def _get_grid_from_cube_attr(self, cube):
+    @staticmethod
+    def _tmp_local_file(local_file: Path) -> Path:
+        """Return the path to a temporary local file for downloading to."""
+        with NamedTemporaryFile(prefix=f"{local_file}.") as file:
+            return Path(file.name)
+
+    def _get_grid_from_cube_attr(self, cube: Cube) -> Cube:
         """Get horizontal grid from `grid_file_uri` attribute of cube."""
         (grid_url, grid_name) = self._get_grid_url(cube)
 
@@ -272,48 +282,90 @@ class IconFix(NativeDatasetFix):
         if grid_name in self._horizontal_grids:
             return self._horizontal_grids[grid_name]
 
-        # Check if grid file has recently been downloaded and load it if
-        # possible
-        # Note: we use a lock here to prevent multiple processes from
-        # downloading the file simultaneously and to ensure that other
-        # processes wait until the download has finished
-        self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
-        lock = FileLock(self.CACHE_DIR / f"{grid_name}.lock")
-        with lock:
-            grid_path = self.CACHE_DIR / grid_name
-            if grid_path.exists():
-                mtime = grid_path.stat().st_mtime
-                now = datetime.now().timestamp()
-                age = now - mtime
-                if age < self.CACHE_VALIDITY:
-                    logger.debug("Using cached ICON grid file '%s'", grid_path)
-                    self._horizontal_grids[grid_name] = self._load_cubes(
-                        grid_path
-                    )
-                    return self._horizontal_grids[grid_name]
+        # First, check if the grid file is available in the ICON rootpath
+        grid = self._get_grid_from_rootpath(grid_name)
+
+        # Second, if that didn't work, try to download grid (or use cached
+        # version of it if possible)
+        if grid is None:
+            grid = self._get_downloaded_grid(grid_url, grid_name)
+
+        # Cache grid for later use
+        self._horizontal_grids[grid_name] = grid
+
+        return grid
+
+    def _get_grid_from_rootpath(self, grid_name: str) -> CubeList | None:
+        """Try to get grid from the ICON rootpath."""
+        rootpaths = _get_rootpath('ICON')
+        dirname_template = _select_drs('input_dir', 'ICON')
+        dirname_globs = _replace_tags(dirname_template, self.extra_facets)
+        possible_grid_paths = [
+            r / d / grid_name for r in rootpaths for d in dirname_globs
+        ]
+        for grid_path in possible_grid_paths:
+            if grid_path.is_file():
+                logger.debug("Using ICON grid file '%s'", grid_path)
+                cubes = self._load_cubes(grid_path)
+                return cubes
+        return None
+
+    def _get_downloaded_grid(self, grid_url: str, grid_name: str) -> CubeList:
+        """Get downloaded horizontal grid.
+
+        Check if grid file has recently been downloaded. If not, download grid
+        file here.
+
+        Note
+        ----
+        In order to make this function thread-safe, the downloaded grid file is
+        first saved to a temporary location, then copied to the actual location
+        later.
+
+        """
+        grid_path = self.CACHE_DIR / grid_name
+
+        # Check cache
+        valid_cache = False
+        if grid_path.exists():
+            mtime = grid_path.stat().st_mtime
+            now = datetime.now().timestamp()
+            age = now - mtime
+            if age < self.CACHE_VALIDITY:
+                logger.debug("Using cached ICON grid file '%s'", grid_path)
+                valid_cache = True
+            else:
                 logger.debug("Existing cached ICON grid file '%s' is outdated",
                              grid_path)
 
-            # Download file if necessary
+        # File is not present in cache or too old -> download it
+        if not valid_cache:
+            self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
+            tmp_path = self._tmp_local_file(grid_path)
             logger.debug(
                 "Attempting to download ICON grid file from '%s' to '%s'",
                 grid_url,
-                grid_path,
+                tmp_path,
             )
-            with requests.get(grid_url, stream=True,
-                              timeout=self.TIMEOUT) as response:
+            with requests.get(
+                    grid_url,
+                    stream=True,
+                    timeout=self.TIMEOUT,
+            ) as response:
                 response.raise_for_status()
-                with open(grid_path, 'wb') as file:
+                with tmp_path.open('wb') as file:
                     copyfileobj(response.raw, file)
+            shutil.move(tmp_path, grid_path)
             logger.info(
-                "Successfully downloaded ICON grid file from '%s' to '%s'",
+                "Successfully downloaded ICON grid file from '%s' to '%s' "
+                "and moved it to '%s'",
                 grid_url,
+                tmp_path,
                 grid_path,
             )
 
-            self._horizontal_grids[grid_name] = self._load_cubes(grid_path)
-
-        return self._horizontal_grids[grid_name]
+        cubes = self._load_cubes(grid_path)
+        return cubes
 
     def get_horizontal_grid(self, cube):
         """Get copy of ICON horizontal grid.
@@ -352,7 +404,7 @@ class IconFix(NativeDatasetFix):
             file.
 
         """
-        if 'horizontal_grid' in self.extra_facets:
+        if self.extra_facets.get('horizontal_grid') is not None:
             grid = self._get_grid_from_facet()
         else:
             grid = self._get_grid_from_cube_attr(cube)
@@ -393,7 +445,7 @@ class IconFix(NativeDatasetFix):
         """
         # If specified by the user, use `horizontal_grid` facet to determine
         # grid name; otherwise, use the `grid_file_uri` attribute of the cube
-        if 'horizontal_grid' in self.extra_facets:
+        if self.extra_facets.get('horizontal_grid') is not None:
             grid_path = self._get_path_from_facet(
                 'horizontal_grid', 'Horizontal grid file'
             )
@@ -401,7 +453,7 @@ class IconFix(NativeDatasetFix):
         else:
             (_, grid_name) = self._get_grid_url(cube)
 
-        # Re-use mesh if possible
+        # Reuse mesh if possible
         if grid_name in self._meshes:
             logger.debug("Reusing ICON mesh for grid %s", grid_name)
         else:
@@ -427,7 +479,7 @@ class IconFix(NativeDatasetFix):
         return np.int32(np.min(vertex_index.data))
 
     @staticmethod
-    def _load_cubes(path):
+    def _load_cubes(path: Path | str) -> CubeList:
         """Load cubes and ignore certain warnings."""
         with warnings.catch_warnings():
             warnings.filterwarnings(
@@ -444,7 +496,7 @@ class IconFix(NativeDatasetFix):
                 category=UserWarning,
                 module='iris',
             )
-            cubes = iris.load(str(path))
+            cubes = iris.load(path)
         return cubes
 
     @staticmethod
@@ -453,3 +505,12 @@ class IconFix(NativeDatasetFix):
         lon_coord.points = (lon_coord.core_points() + 360.0) % 360.0
         if lon_coord.has_bounds():
             lon_coord.bounds = (lon_coord.core_bounds() + 360.0) % 360.0
+
+
+class NegateData(IconFix):
+    """Base fix to negate data."""
+
+    def fix_data(self, cube):
+        """Fix data."""
+        cube.data = -cube.core_data()
+        return cube
diff --git a/esmvalcore/cmor/_fixes/icon/icon.py b/esmvalcore/cmor/_fixes/icon/icon.py
index 2e1dfd3bb..5135faade 100644
--- a/esmvalcore/cmor/_fixes/icon/icon.py
+++ b/esmvalcore/cmor/_fixes/icon/icon.py
@@ -1,6 +1,7 @@
 """On-the-fly CMORizer for ICON."""
 
 import logging
+import warnings
 from datetime import datetime, timedelta
 
 import dask.array as da
@@ -15,7 +16,7 @@ from iris.cube import CubeList
 
 from esmvalcore.iris_helpers import add_leading_dim_to_cube, date2num
 
-from ._base_fixes import IconFix
+from ._base_fixes import IconFix, NegateData
 
 logger = logging.getLogger(__name__)
 
@@ -493,7 +494,15 @@ class AllVars(IconFix):
         # this results in times that are off by 1s (e.g., 13:59:59 instead of
         # 14:00:00).
         rounded_datetimes = (year_month_day + day_float).round('s')
-        new_datetimes = np.array(rounded_datetimes.dt.to_pydatetime())
+        with warnings.catch_warnings():
+            # We already fixed the deprecated code as recommended in the
+            # warning, but it still shows up -> ignore it
+            warnings.filterwarnings(
+                'ignore',
+                message="The behavior of DatetimeProperties.to_pydatetime .*",
+                category=FutureWarning,
+            )
+            new_datetimes = np.array(rounded_datetimes.dt.to_pydatetime())
         new_dt_points = date2num(np.array(new_datetimes), new_t_units)
 
         # Modify time coordinate in place
@@ -512,3 +521,9 @@ class Clwvi(IconFix):
         )
         cube.var_name = self.vardef.short_name
         return CubeList([cube])
+
+
+Hfls = NegateData
+
+
+Hfss = NegateData
diff --git a/esmvalcore/preprocessor/_time.py b/esmvalcore/preprocessor/_time.py
index ee11e1146..1890e9739 100644
--- a/esmvalcore/preprocessor/_time.py
+++ b/esmvalcore/preprocessor/_time.py
@@ -1436,9 +1436,9 @@ def _get_time_index_and_mask(
 
 def _transform_to_lst_eager(
     data: np.ndarray,
-    *,
     time_index: np.ndarray,
     mask: np.ndarray,
+    *,
     time_dim: int,
     lon_dim: int,
     **__,
@@ -1479,9 +1479,9 @@ def _transform_to_lst_eager(
 
 def _transform_to_lst_lazy(
     data: da.core.Array,
-    *,
     time_index: np.ndarray,
     mask: np.ndarray,
+    *,
     time_dim: int,
     lon_dim: int,
     output_dtypes: DTypeLike,
@@ -1505,28 +1505,25 @@ def _transform_to_lst_lazy(
     `mask` is 2D with shape (time, lon) that will be applied to the final data.
 
     """
-    _transform_chunk_to_lst = partial(
-        _transform_to_lst_eager,
-        time_index=time_index,
-        mask=mask,
-        time_dim=-2,  # this is ensured by da.apply_gufunc
-        lon_dim=-1,  # this is ensured by da.apply_gufunc
-    )
     new_data = da.apply_gufunc(
-        _transform_chunk_to_lst,
-        '(t,y)->(t,y)',
+        _transform_to_lst_eager,
+        '(t,x),(t,x),(t,x)->(t,x)',
         data,
-        axes=[(time_dim, lon_dim), (time_dim, lon_dim)],
+        time_index,
+        mask,
+        axes=[(time_dim, lon_dim), (0, 1), (0, 1), (time_dim, lon_dim)],
         output_dtypes=output_dtypes,
+        time_dim=-2,  # this is ensured by da.apply_gufunc
+        lon_dim=-1,  # this is ensured by da.apply_gufunc
     )
     return new_data
 
 
 def _transform_arr_to_lst(
     data: np.ndarray | da.core.Array,
-    *,
     time_index: np.ndarray,
     mask: np.ndarray,
+    *,
     time_dim: int,
     lon_dim: int,
     output_dtypes: DTypeLike,
@@ -1545,8 +1542,8 @@ def _transform_arr_to_lst(
         func = _transform_to_lst_lazy  # type: ignore
     new_data = func(
         data,  # type: ignore
-        time_index=time_index,
-        mask=mask,
+        time_index,
+        mask,
         time_dim=time_dim,
         lon_dim=lon_dim,
         output_dtypes=output_dtypes,
@@ -1571,13 +1568,10 @@ def _transform_cube_to_lst(cube: Cube) -> Cube:
 
     # Transform cube data
     (time_index, mask) = _get_time_index_and_mask(time_coord, lon_coord)
-    _transform_arr = partial(
-        _transform_arr_to_lst,
-        time_index=time_index,
-        mask=mask,
-    )
-    cube.data = _transform_arr(
+    cube.data = _transform_arr_to_lst(
         cube.core_data(),
+        time_index,
+        mask,
         time_dim=time_dim,
         lon_dim=lon_dim,
         output_dtypes=cube.dtype,
@@ -1589,15 +1583,19 @@ def _transform_cube_to_lst(cube: Cube) -> Cube:
         if time_dim in dims and lon_dim in dims:
             time_dim_ = dims.index(time_dim)
             lon_dim_ = dims.index(lon_dim)
-            coord.points = _transform_arr(
+            coord.points = _transform_arr_to_lst(
                 coord.core_points(),
+                time_index,
+                mask,
                 time_dim=time_dim_,
                 lon_dim=lon_dim_,
                 output_dtypes=coord.dtype,
             )
             if coord.has_bounds():
-                coord.bounds = _transform_arr(
+                coord.bounds = _transform_arr_to_lst(
                     coord.core_bounds(),
+                    time_index,
+                    mask,
                     time_dim=time_dim_,
                     lon_dim=lon_dim_,
                     output_dtypes=coord.bounds_dtype,
@@ -1609,8 +1607,10 @@ def _transform_cube_to_lst(cube: Cube) -> Cube:
         if time_dim in dims and lon_dim in dims:
             time_dim_ = dims.index(time_dim)
             lon_dim_ = dims.index(lon_dim)
-            cell_measure.data = _transform_arr(
+            cell_measure.data = _transform_arr_to_lst(
                 cell_measure.core_data(),
+                time_index,
+                mask,
                 time_dim=time_dim_,
                 lon_dim=lon_dim_,
                 output_dtypes=cell_measure.dtype,
@@ -1622,8 +1622,10 @@ def _transform_cube_to_lst(cube: Cube) -> Cube:
         if time_dim in dims and lon_dim in dims:
             time_dim_ = dims.index(time_dim)
             lon_dim_ = dims.index(lon_dim)
-            anc_var.data = _transform_arr(
+            anc_var.data = _transform_arr_to_lst(
                 anc_var.core_data(),
+                time_index,
+                mask,
                 time_dim=time_dim_,
                 lon_dim=lon_dim_,
                 output_dtypes=anc_var.dtype,


Test patch:
diff --git a/tests/integration/cmor/_fixes/icon/test_icon.py b/tests/integration/cmor/_fixes/icon/test_icon.py
index ab823cb84..6cbb3d798 100644
--- a/tests/integration/cmor/_fixes/icon/test_icon.py
+++ b/tests/integration/cmor/_fixes/icon/test_icon.py
@@ -1,4 +1,5 @@
 """Tests for the ICON on-the-fly CMORizer."""
+from copy import deepcopy
 from datetime import datetime
 from pathlib import Path
 from unittest import mock
@@ -14,7 +15,7 @@ from iris.cube import Cube, CubeList
 import esmvalcore.cmor._fixes.icon.icon
 from esmvalcore.cmor._fixes.fix import GenericFix
 from esmvalcore.cmor._fixes.icon._base_fixes import IconFix
-from esmvalcore.cmor._fixes.icon.icon import AllVars, Clwvi
+from esmvalcore.cmor._fixes.icon.icon import AllVars, Clwvi, Hfls, Hfss
 from esmvalcore.cmor.fix import Fix
 from esmvalcore.cmor.table import CoordinateInfo, get_var_info
 from esmvalcore.config import CFG
@@ -121,6 +122,7 @@ def _get_fix(mip, short_name, fix_name, session=None):
     )
     extra_facets = get_extra_facets(dataset, ())
     extra_facets['frequency'] = 'mon'
+    extra_facets['exp'] = 'amip'
     vardef = get_var_info(project='ICON', mip=mip, short_name=short_name)
     cls = getattr(esmvalcore.cmor._fixes.icon.icon, fix_name)
     fix = cls(vardef, extra_facets=extra_facets, session=session)
@@ -147,6 +149,15 @@ def fix_metadata(cubes, mip, short_name, session=None):
     return cubes
 
 
+def fix_data(cube, mip, short_name, session=None):
+    """Fix data of cube."""
+    fix = get_fix(mip, short_name, session=session)
+    cube = fix.fix_data(cube)
+    fix = get_allvars_fix(mip, short_name, session=session)
+    cube = fix.fix_data(cube)
+    return cube
+
+
 def check_ta_metadata(cubes):
     """Check ta metadata."""
     assert len(cubes) == 1
@@ -1202,6 +1213,33 @@ def test_get_horizontal_grid_from_attr_cached_in_dict(
     mock_get_grid_from_facet.assert_not_called()
 
 
+@mock.patch.object(IconFix, '_get_grid_from_facet', autospec=True)
+def test_get_horizontal_grid_from_attr_rootpath(
+    mock_get_grid_from_facet, monkeypatch, tmp_path
+):
+    """Test fix."""
+    rootpath = deepcopy(CFG['rootpath'])
+    rootpath['ICON'] = str(tmp_path)
+    monkeypatch.setitem(CFG, 'rootpath', rootpath)
+    cube = Cube(0, attributes={'grid_file_uri': 'grid.nc'})
+    grid_cube = Cube(0, var_name='test_grid_cube')
+    (tmp_path / 'amip').mkdir(parents=True, exist_ok=True)
+    iris.save(grid_cube, tmp_path / 'amip' / 'grid.nc')
+
+    fix = get_allvars_fix('Amon', 'tas')
+    fix._horizontal_grids['grid_from_facet.nc'] = mock.sentinel.wrong_grid
+
+    grid = fix.get_horizontal_grid(cube)
+    assert len(fix._horizontal_grids) == 2
+    assert 'grid.nc' in fix._horizontal_grids
+    assert 'grid_from_facet.nc' in fix._horizontal_grids  # has not been used
+    assert fix._horizontal_grids['grid.nc'] == grid
+    assert len(grid) == 1
+    assert grid[0].var_name == 'test_grid_cube'
+    assert grid[0].shape == ()
+    mock_get_grid_from_facet.assert_not_called()
+
+
 @mock.patch.object(IconFix, '_get_grid_from_facet', autospec=True)
 @mock.patch('esmvalcore.cmor._fixes.icon._base_fixes.requests', autospec=True)
 def test_get_horizontal_grid_from_attr_cached_in_file(
@@ -1223,6 +1261,7 @@ def test_get_horizontal_grid_from_attr_cached_in_file(
     assert isinstance(grid, CubeList)
     assert len(grid) == 1
     assert grid[0].var_name == 'grid'
+    assert grid[0].shape == ()
     assert len(fix._horizontal_grids) == 1
     assert 'grid_file.nc' in fix._horizontal_grids
     assert fix._horizontal_grids['grid_file.nc'] == grid
@@ -2209,3 +2248,63 @@ def test_fix_height_alt16(bounds, simple_unstructured_cube):
         np.testing.assert_allclose(alt16.bounds, expected_bnds)
     else:
         assert alt16.bounds is None
+
+
+# Test hfls (for extra fix)
+
+
+def test_get_hfls_fix():
+    """Test getting of fix."""
+    fix = Fix.get_fixes('ICON', 'ICON', 'Amon', 'hfls')
+    assert fix == [Hfls(None), AllVars(None), GenericFix(None)]
+
+
+def test_hfls_fix(cubes_regular_grid):
+    """Test fix."""
+    cubes = CubeList([cubes_regular_grid[0].copy()])
+    cubes[0].var_name = 'hfls'
+    cubes[0].units = 'W m-2'
+
+    fixed_cubes = fix_metadata(cubes, 'Amon', 'hfls')
+
+    assert len(fixed_cubes) == 1
+    cube = fixed_cubes[0]
+    assert cube.var_name == 'hfls'
+    assert cube.standard_name == 'surface_upward_latent_heat_flux'
+    assert cube.long_name == 'Surface Upward Latent Heat Flux'
+    assert cube.units == 'W m-2'
+    assert cube.attributes['positive'] == 'up'
+
+    fixed_cube = fix_data(cube, 'Amon', 'hfls')
+
+    np.testing.assert_allclose(fixed_cube.data, [[[0.0, -1.0], [-2.0, -3.0]]])
+
+
+# Test hfss (for extra fix)
+
+
+def test_get_hfss_fix():
+    """Test getting of fix."""
+    fix = Fix.get_fixes('ICON', 'ICON', 'Amon', 'hfss')
+    assert fix == [Hfss(None), AllVars(None), GenericFix(None)]
+
+
+def test_hfss_fix(cubes_regular_grid):
+    """Test fix."""
+    cubes = CubeList([cubes_regular_grid[0].copy()])
+    cubes[0].var_name = 'hfss'
+    cubes[0].units = 'W m-2'
+
+    fixed_cubes = fix_metadata(cubes, 'Amon', 'hfss')
+
+    assert len(fixed_cubes) == 1
+    cube = fixed_cubes[0]
+    assert cube.var_name == 'hfss'
+    assert cube.standard_name == 'surface_upward_sensible_heat_flux'
+    assert cube.long_name == 'Surface Upward Sensible Heat Flux'
+    assert cube.units == 'W m-2'
+    assert cube.attributes['positive'] == 'up'
+
+    fixed_cube = fix_data(cube, 'Amon', 'hfss')
+
+    np.testing.assert_allclose(fixed_cube.data, [[[0.0, -1.0], [-2.0, -3.0]]])
diff --git a/tests/integration/cmor/_fixes/test_common.py b/tests/integration/cmor/_fixes/test_common.py
index 809c1fbfd..8eedfa084 100644
--- a/tests/integration/cmor/_fixes/test_common.py
+++ b/tests/integration/cmor/_fixes/test_common.py
@@ -141,6 +141,7 @@ def hybrid_height_coord_fix_metadata(nc_path, short_name, fix):
     assert air_pressure_coord.units == 'Pa'
 
 
+@pytest.mark.xfail(reason="github.com/SciTools/iris/issues/5806")
 def test_cl_hybrid_height_coord_fix_metadata(test_data_path):
     """Test ``fix_metadata`` for ``cl``."""
     vardef = get_var_info('CMIP6', 'Amon', 'cl')