From 65c7b28e883d24eb2471cf34a215553b8895a3c5 Mon Sep 17 00:00:00 2001 From: Manuel Schlund <32543114+schlunma@users.noreply.github.com> Date: Fri, 6 Dec 2024 12:41:49 +0100 Subject: [PATCH] Add support for native ERA5 data in GRIB format (#2178) Co-authored-by: Valeriu Predoi Co-authored-by: Bouwe Andela Co-authored-by: Bettina Gier --- doc/quickstart/configure.rst | 2 +- doc/quickstart/find_data.rst | 109 ++- esmvalcore/_provenance.py | 8 +- esmvalcore/_recipe/recipe.py | 30 + esmvalcore/cmor/_fixes/fix.py | 2 + esmvalcore/cmor/_fixes/native6/era5.py | 186 ++++- esmvalcore/config-developer.yml | 2 + .../configurations/defaults/config-user.yml | 4 +- .../config/extra_facets/native6-era5.yml | 196 ++++++ esmvalcore/preprocessor/_io.py | 9 +- pyproject.toml | 2 +- .../cmor/_fixes/native6/test_era5.py | 666 ++++++++++++++---- tests/integration/cmor/test_fix.py | 30 +- tests/integration/conftest.py | 38 +- .../integration/preprocessor/_io/test_load.py | 20 + tests/integration/recipe/test_recipe.py | 113 +++ tests/sample_data/iris-sample-data/LICENSE | 10 + .../iris-sample-data/polar_stereo.grib2 | Bin 0 -> 25934 bytes tests/unit/provenance/test_trackedfile.py | 66 +- 19 files changed, 1296 insertions(+), 197 deletions(-) create mode 100644 esmvalcore/config/extra_facets/native6-era5.yml create mode 100644 tests/sample_data/iris-sample-data/LICENSE create mode 100644 tests/sample_data/iris-sample-data/polar_stereo.grib2 diff --git a/doc/quickstart/configure.rst b/doc/quickstart/configure.rst index baf4dd2998..78ce5dcea2 100644 --- a/doc/quickstart/configure.rst +++ b/doc/quickstart/configure.rst @@ -974,7 +974,7 @@ infrastructure. The following example illustrates the concept. .. _extra-facets-example-1: .. code-block:: yaml - :caption: Extra facet example file `native6-era5.yml` + :caption: Extra facet example file `native6-era5-example.yml` ERA5: Amon: diff --git a/doc/quickstart/find_data.rst b/doc/quickstart/find_data.rst index b7708fd95f..d93f114f21 100644 --- a/doc/quickstart/find_data.rst +++ b/doc/quickstart/find_data.rst @@ -107,18 +107,27 @@ The following native reanalysis/observational datasets are supported under the To use these datasets, put the files containing the data in the directory that you have :ref:`configured ` for the ``rootpath`` of the ``native6`` project, in a subdirectory called -``Tier{tier}/{dataset}/{version}/{frequency}/{short_name}``. +``Tier{tier}/{dataset}/{version}/{frequency}/{short_name}`` (assuming you are +using the ``default`` DRS for ``native6``). Replace the items in curly braces by the values used in the variable/dataset definition in the :ref:`recipe `. -Below is a list of native reanalysis/observational datasets currently -supported. -.. _read_native_era5: +.. _read_native_era5_nc: -ERA5 -^^^^ +ERA5 (in netCDF format downloaded from the CDS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +ERA5 data can be downloaded from the Copernicus Climate Data Store (CDS) using +the convenient tool `era5cli `__. +For example for monthly data, place the files in the +``/Tier3/ERA5/version/mon/pr`` subdirectory of your ``rootpath`` that you have +configured for the ``native6`` project (assuming you are using the ``default`` +DRS for ``native6``). -- Supported variables: ``cl``, ``clt``, ``evspsbl``, ``evspsblpot``, ``mrro``, ``pr``, ``prsn``, ``ps``, ``psl``, ``ptype``, ``rls``, ``rlds``, ``rsds``, ``rsdt``, ``rss``, ``uas``, ``vas``, ``tas``, ``tasmax``, ``tasmin``, ``tdps``, ``ts``, ``tsn`` (``E1hr``/``Amon``), ``orog`` (``fx``) +- Supported variables: ``cl``, ``clt``, ``evspsbl``, ``evspsblpot``, ``mrro``, + ``pr``, ``prsn``, ``ps``, ``psl``, ``ptype``, ``rls``, ``rlds``, ``rsds``, + ``rsdt``, ``rss``, ``uas``, ``vas``, ``tas``, ``tasmax``, ``tasmin``, + ``tdps``, ``ts``, ``tsn`` (``E1hr``/``Amon``), ``orog`` (``fx``). - Tier: 3 .. note:: According to the description of Evapotranspiration and potential Evapotranspiration on the Copernicus page @@ -131,6 +140,85 @@ ERA5 of both liquid and solid phases to vapor (from underlying surface and vegetation)." Therefore, the ERA5 (and ERA5-Land) CMORizer switches the signs of ``evspsbl`` and ``evspsblpot`` to be compatible with the CMOR standard used e.g. by the CMIP models. +.. _read_native_era5_grib: + +ERA5 (in GRIB format available on DKRZ's Levante or downloaded from the CDS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +ERA5 data in monthly, daily, and hourly resolution is `available on Levante +`__ +in its native GRIB format. + +.. note:: + ERA5 data in its native GRIB format can also be downloaded from the + `Copernicus Climate Data Store (CDS) + `__. + For example, hourly data on pressure levels is available `here + `__. + Reading self-downloaded ERA5 data in GRIB format is experimental and likely + requires additional setup from the user like setting up the proper directory + structure for the input files and/or creating a custom :ref:`DRS + `. + +To read these data with ESMValCore, use the :ref:`rootpath +` ``/pool/data/ERA5`` with :ref:`DRS +` ``DKRZ-ERA5-GRIB`` in your configuration, for example: + +.. code-block:: yaml + + rootpath: + ... + native6: + /pool/data/ERA5: DKRZ-ERA5-GRIB + ... + +The `naming conventions +`__ +for input directories and files for native ERA5 data in GRIB format on Levante +are + +* input directories: ``{family}/{level}/{type}/{tres}/{grib_id}`` +* input files: ``{family}{level}{typeid}_{tres}_*_{grib_id}.grb`` + +All of these facets have reasonable defaults preconfigured in the corresponding +:ref:`extra facets` file, which is available here: +:download:`native6-era5.yml +`. +If necessary, these facets can be overwritten in the recipe. + +Thus, example dataset entries could look like this: + +.. code-block:: yaml + + datasets: + - {project: native6, dataset: ERA5, timerange: '2000/2001', + short_name: tas, mip: Amon} + - {project: native6, dataset: ERA5, timerange: '2000/2001', + short_name: cl, mip: Amon, tres: 1H, frequency: 1hr} + - {project: native6, dataset: ERA5, timerange: '2000/2001', + short_name: ta, mip: Amon, type: fc, typeid: '12'} + +The native ERA5 output in GRIB format is stored on a `reduced Gaussian grid +`__. +By default, these data are regridded to a regular 0.25°x0.25° grid as +`recommended by the ECMWF +`__ +using bilinear interpolation. + +To disable this, you can use the facet ``automatic_regrid: false`` in the +recipe: + +.. code-block:: yaml + + datasets: + - {project: native6, dataset: ERA5, timerange: '2000/2001', + short_name: tas, mip: Amon, automatic_regrid: false} + +- Supported variables: ``albsn``, ``cl``, ``cli``, ``clt``, ``clw``, ``hur``, + ``hus``, ``o3``, ``prw``, ``ps``, ``psl``, ``rainmxrat27``, ``sftlf``, + ``snd``, ``snowmxrat27``, ``ta``, ``tas``, ``tdps``, ``toz``, ``ts``, ``ua``, + ``uas``, ``va``, ``vas``, ``wap``, ``zg``. + .. _read_native_mswep: MSWEP @@ -140,7 +228,10 @@ MSWEP - Supported frequencies: ``mon``, ``day``, ``3hr``. - Tier: 3 -For example for monthly data, place the files in the ``/Tier3/MSWEP/version/mon/pr`` subdirectory of your ``native6`` project location. +For example for monthly data, place the files in the +``/Tier3/MSWEP/version/mon/pr`` subdirectory of your ``rootpath`` that you have +configured for the ``native6`` project (assuming you are using the ``default`` +DRS for ``native6``). .. note:: For monthly data (``V220``), the data must be postfixed with the date, i.e. rename ``global_monthly_050deg.nc`` to ``global_monthly_050deg_197901-201710.nc`` @@ -642,6 +733,8 @@ first discuss the ``drs`` parameter: as we've seen in the previous section, the DRS as a standard is used for both file naming conventions and for directory structures. +.. _config_option_drs: + Explaining ``drs: CMIP5:`` or ``drs: CMIP6:`` --------------------------------------------- Whereas ESMValCore will by default use the CMOR standard for file naming (please diff --git a/esmvalcore/_provenance.py b/esmvalcore/_provenance.py index 25ad81f5ba..89b5822c27 100644 --- a/esmvalcore/_provenance.py +++ b/esmvalcore/_provenance.py @@ -4,6 +4,7 @@ import logging import os from functools import total_ordering +from pathlib import Path from netCDF4 import Dataset from PIL import Image @@ -209,9 +210,10 @@ def _initialize_entity(self): """Initialize the entity representing the file.""" if self.attributes is None: self.attributes = {} - with Dataset(self.filename, "r") as dataset: - for attr in dataset.ncattrs(): - self.attributes[attr] = dataset.getncattr(attr) + if "nc" in Path(self.filename).suffix: + with Dataset(self.filename, "r") as dataset: + for attr in dataset.ncattrs(): + self.attributes[attr] = dataset.getncattr(attr) attributes = { "attribute:" + str(k).replace(" ", "_"): str(v) diff --git a/esmvalcore/_recipe/recipe.py b/esmvalcore/_recipe/recipe.py index 8d4809ffa0..9c5aa74553 100644 --- a/esmvalcore/_recipe/recipe.py +++ b/esmvalcore/_recipe/recipe.py @@ -37,6 +37,7 @@ PreprocessorFile, ) from esmvalcore.preprocessor._area import _update_shapefile_path +from esmvalcore.preprocessor._io import GRIB_FORMATS from esmvalcore.preprocessor._multimodel import _get_stat_identifier from esmvalcore.preprocessor._regrid import ( _spec_to_latlonvals, @@ -230,6 +231,34 @@ def _get_default_settings(dataset): return settings +def _add_dataset_specific_settings(dataset: Dataset, settings: dict) -> None: + """Add dataset-specific settings.""" + project = dataset.facets["project"] + dataset_name = dataset.facets["dataset"] + file_suffixes = [Path(file.name).suffix for file in dataset.files] + + # Automatic regridding for native ERA5 data in GRIB format if regridding + # step is not already present (can be disabled with facet + # automatic_regrid=False) + if all( + [ + project == "native6", + dataset_name == "ERA5", + any(grib_format in file_suffixes for grib_format in GRIB_FORMATS), + "regrid" not in settings, + dataset.facets.get("automatic_regrid", True), + ] + ): + # Settings recommended by ECMWF + # (https://confluence.ecmwf.int/display/CKB/ERA5%3A+What+is+the+spatial+reference#heading-Interpolation) + settings["regrid"] = {"target_grid": "0.25x0.25", "scheme": "linear"} + logger.debug( + "Automatically regrid native6 ERA5 data in GRIB format with the " + "settings %s", + settings["regrid"], + ) + + def _exclude_dataset(settings, facets, step): """Exclude dataset from specific preprocessor step if requested.""" exclude = { @@ -546,6 +575,7 @@ def _get_preprocessor_products( _apply_preprocessor_profile(settings, profile) _update_multi_dataset_settings(dataset.facets, settings) _update_preproc_functions(settings, dataset, datasets, missing_vars) + _add_dataset_specific_settings(dataset, settings) check.preprocessor_supplementaries(dataset, settings) input_datasets = _get_input_datasets(dataset) missing = _check_input_files(input_datasets) diff --git a/esmvalcore/cmor/_fixes/fix.py b/esmvalcore/cmor/_fixes/fix.py index 4d3e297e3a..9a229b2dc4 100644 --- a/esmvalcore/cmor/_fixes/fix.py +++ b/esmvalcore/cmor/_fixes/fix.py @@ -845,6 +845,8 @@ def _fix_time_bounds(self, cube: Cube, cube_coord: Coord) -> None: """Fix time bounds.""" times = {"time", "time1", "time2", "time3"} key = times.intersection(self.vardef.coordinates) + if not key: # cube has time, but CMOR variable does not + return cmor = self.vardef.coordinates[" ".join(key)] if cmor.must_have_bounds == "yes" and not cube_coord.has_bounds(): cube_coord.bounds = get_time_bounds(cube_coord, self.frequency) diff --git a/esmvalcore/cmor/_fixes/native6/era5.py b/esmvalcore/cmor/_fixes/native6/era5.py index 85b570c57d..2214238557 100644 --- a/esmvalcore/cmor/_fixes/native6/era5.py +++ b/esmvalcore/cmor/_fixes/native6/era5.py @@ -5,12 +5,16 @@ import iris import numpy as np +from iris.util import reverse -from esmvalcore.iris_helpers import date2num, safe_convert_units - -from ...table import CMOR_TABLES -from ..fix import Fix -from ..shared import add_scalar_height_coord +from esmvalcore.cmor._fixes.fix import Fix +from esmvalcore.cmor._fixes.shared import add_scalar_height_coord +from esmvalcore.cmor.table import CMOR_TABLES +from esmvalcore.iris_helpers import ( + date2num, + has_unstructured_grid, + safe_convert_units, +) logger = logging.getLogger(__name__) @@ -24,7 +28,11 @@ def get_frequency(cube): time.convert_units("days since 1850-1-1 00:00:00.0") if len(time.points) == 1: - if cube.long_name != "Geopotential": + acceptable_long_names = ( + "Geopotential", + "Percentage of the Grid Cell Occupied by Land (Including Lakes)", + ) + if cube.long_name not in acceptable_long_names: raise ValueError( "Unable to infer frequency of cube " f"with length 1 time dimension: {cube}" @@ -32,9 +40,11 @@ def get_frequency(cube): return "fx" interval = time.points[1] - time.points[0] + if interval - 1 / 24 < 1e-4: return "hourly" - + if interval - 1.0 < 1e-4: + return "daily" return "monthly" @@ -52,6 +62,11 @@ def fix_accumulated_units(cube): cube.units = cube.units * "d-1" elif get_frequency(cube) == "hourly": cube.units = cube.units * "h-1" + elif get_frequency(cube) == "daily": + raise NotImplementedError( + f"Fixing of accumulated units of cube " + f"{cube.summary(shorten=True)} is not implemented for daily data" + ) return cube @@ -76,6 +91,27 @@ def divide_by_gravity(cube): return cube +class Albsn(Fix): + """Fixes for albsn.""" + + def fix_metadata(self, cubes): + """Fix metadata.""" + for cube in cubes: + # Invalid input cube units (ignored on load) were '0-1' + cube.units = "1" + return cubes + + +class Cli(Fix): + """Fixes for cli.""" + + def fix_metadata(self, cubes): + """Fix metadata.""" + for cube in cubes: + cube.units = "kg kg-1" + return cubes + + class Clt(Fix): """Fixes for clt.""" @@ -89,6 +125,16 @@ def fix_metadata(self, cubes): return cubes +class Clw(Fix): + """Fixes for clw.""" + + def fix_metadata(self, cubes): + """Fix metadata.""" + for cube in cubes: + cube.units = "kg kg-1" + return cubes + + class Cl(Fix): """Fixes for cl.""" @@ -136,6 +182,16 @@ def fix_metadata(self, cubes): return cubes +class Hus(Fix): + """Fixes for hus.""" + + def fix_metadata(self, cubes): + """Fix metadata.""" + for cube in cubes: + cube.units = "kg kg-1" + return cubes + + class Mrro(Fix): """Fixes for mrro.""" @@ -149,6 +205,20 @@ def fix_metadata(self, cubes): return cubes +class O3(Fix): + """Fixes for o3.""" + + def fix_metadata(self, cubes): + """Convert mass mixing ratios to mole fractions.""" + for cube in cubes: + # Original units are kg kg-1. Convert these to molar mixing ratios, + # which is almost identical to mole fraction for small amounts of + # substances (which we have here) + cube.data = cube.core_data() * 28.9644 / 47.9982 + cube.units = "mol mol-1" + return cubes + + class Orog(Fix): """Fixes for orography.""" @@ -194,6 +264,26 @@ def fix_metadata(self, cubes): return cubes +class Prw(Fix): + """Fixes for prw.""" + + def fix_metadata(self, cubes): + """Fix metadata.""" + for cube in cubes: + cube.units = "kg m-2" + return cubes + + +class Ps(Fix): + """Fixes for ps.""" + + def fix_metadata(self, cubes): + """Fix metadata.""" + for cube in cubes: + cube.units = "Pa" + return cubes + + class Ptype(Fix): """Fixes for ptype.""" @@ -205,6 +295,16 @@ def fix_metadata(self, cubes): return cubes +class Rainmxrat27(Fix): + """Fixes for rainmxrat27.""" + + def fix_metadata(self, cubes): + """Fix metadata.""" + for cube in cubes: + cube.units = "kg kg-1" + return cubes + + class Rlds(Fix): """Fixes for Rlds.""" @@ -321,6 +421,27 @@ def fix_metadata(self, cubes): return cubes +class Sftlf(Fix): + """Fixes for sftlf.""" + + def fix_metadata(self, cubes): + """Fix metadata.""" + for cube in cubes: + # Invalid input cube units (ignored on load) were '0-1' + cube.units = "1" + return cubes + + +class Snowmxrat27(Fix): + """Fixes for snowmxrat27.""" + + def fix_metadata(self, cubes): + """Fix metadata.""" + for cube in cubes: + cube.units = "kg kg-1" + return cubes + + class Tasmax(Fix): """Fixes for tasmax.""" @@ -341,6 +462,22 @@ def fix_metadata(self, cubes): return cubes +class Toz(Fix): + """Fixes for toz.""" + + def fix_metadata(self, cubes): + """Convert 'kg m-2' to 'm'.""" + for cube in cubes: + # Original units are kg m-2. Convert these to m here. + # 1 DU = 0.4462 mmol m-2 = 21.415 mg m-2 = 2.1415e-5 kg m-2 + # (assuming O3 molar mass of 48 g mol-1) + # Since 1 mm of pure O3 layer is defined as 100 DU + # --> 1m ~ 2.1415 kg m-2 + cube.data = cube.core_data() / 2.1415 + cube.units = "m" + return cubes + + class Zg(Fix): """Fixes for Geopotential.""" @@ -356,21 +493,13 @@ class AllVars(Fix): def _fix_coordinates(self, cube): """Fix coordinates.""" - # Fix coordinate increasing direction - slices = [] - for coord in cube.coords(): - if coord.var_name in ("latitude", "pressure_level"): - slices.append(slice(None, None, -1)) - else: - slices.append(slice(None)) - cube = cube[tuple(slices)] - # Add scalar height coordinates if "height2m" in self.vardef.dimensions: add_scalar_height_coord(cube, 2.0) if "height10m" in self.vardef.dimensions: add_scalar_height_coord(cube, 10.0) + # Fix coord metadata for coord_def in self.vardef.coordinates.values(): axis = coord_def.axis # ERA5 uses regular pressure level coordinate. In case the cmor @@ -383,7 +512,7 @@ def _fix_coordinates(self, cube): coord = cube.coord(axis=axis) if axis == "T": coord.convert_units("days since 1850-1-1 00:00:00.0") - if axis == "Z": + if axis in ("X", "Y", "Z"): coord.convert_units(coord_def.units) coord.standard_name = coord_def.standard_name coord.var_name = coord_def.out_name @@ -394,10 +523,25 @@ def _fix_coordinates(self, cube): and len(coord.core_points()) > 1 and coord_def.must_have_bounds == "yes" ): - coord.guess_bounds() + # Do not guess bounds for lat and lon on unstructured grids + if not ( + coord.name() in ("latitude", "longitude") + and has_unstructured_grid(cube) + ): + coord.guess_bounds() self._fix_monthly_time_coord(cube) + # Fix coordinate increasing direction + if cube.coords("latitude") and not has_unstructured_grid(cube): + lat = cube.coord("latitude") + if lat.points[0] > lat.points[-1]: + cube = reverse(cube, "latitude") + if cube.coords("air_pressure"): + plev = cube.coord("air_pressure") + if plev.points[0] < plev.points[-1]: + cube = reverse(cube, "air_pressure") + return cube @staticmethod @@ -426,16 +570,18 @@ def fix_metadata(self, cubes): if self.vardef.standard_name: cube.standard_name = self.vardef.standard_name cube.long_name = self.vardef.long_name - cube = self._fix_coordinates(cube) cube = safe_convert_units(cube, self.vardef.units) - cube.data = cube.core_data().astype("float32") year = datetime.datetime.now().year cube.attributes["comment"] = ( "Contains modified Copernicus Climate Change " f"Service Information {year}" ) + if "GRIB_PARAM" in cube.attributes: + cube.attributes["GRIB_PARAM"] = str( + cube.attributes["GRIB_PARAM"] + ) fixed_cubes.append(cube) diff --git a/esmvalcore/config-developer.yml b/esmvalcore/config-developer.yml index c81324142a..faa009ec8f 100644 --- a/esmvalcore/config-developer.yml +++ b/esmvalcore/config-developer.yml @@ -99,8 +99,10 @@ native6: cmor_strict: false input_dir: default: 'Tier{tier}/{dataset}/{version}/{frequency}/{short_name}' + DKRZ-ERA5-GRIB: '{family}/{level}/{type}/{tres}/{grib_id}' input_file: default: '*.nc' + DKRZ-ERA5-GRIB: '{family}{level}{typeid}_{tres}_*_{grib_id}.grb' output_file: '{project}_{dataset}_{type}_{version}_{mip}_{short_name}' cmor_type: 'CMIP6' cmor_default_table_prefix: 'CMIP6_' diff --git a/esmvalcore/config/configurations/defaults/config-user.yml b/esmvalcore/config/configurations/defaults/config-user.yml index 39cffb67fb..a666875542 100644 --- a/esmvalcore/config/configurations/defaults/config-user.yml +++ b/esmvalcore/config/configurations/defaults/config-user.yml @@ -196,7 +196,9 @@ drs: # /work/bd0854/DATA/ESMValTool2/OBS: default # /work/bd0854/DATA/ESMValTool2/download: ESGF # ana4mips: /work/bd0854/DATA/ESMValTool2/OBS -# native6: /work/bd0854/DATA/ESMValTool2/RAWOBS +# native6: +# /work/bd0854/DATA/ESMValTool2/RAWOBS: default +# /pool/data/ERA5: DKRZ-ERA5-GRIB # RAWOBS: /work/bd0854/DATA/ESMValTool2/RAWOBS #drs: # ana4mips: default diff --git a/esmvalcore/config/extra_facets/native6-era5.yml b/esmvalcore/config/extra_facets/native6-era5.yml new file mode 100644 index 0000000000..4ab1915da9 --- /dev/null +++ b/esmvalcore/config/extra_facets/native6-era5.yml @@ -0,0 +1,196 @@ +# Extra facets for native6 ERA5 data in GRIB format +# +# See +# https://docs.dkrz.de/doc/dataservices/finding_and_accessing_data/era_data/index.html#file-and-directory-names +# for details on these facets. + +# Notes: +# - All facets can also be specified in the recipes. The values given here are +# only defaults. + +# A complete list of supported keys is given in the documentation (see +# ESMValCore/doc/quickstart/find_data.rst). +--- + +ERA5: + + # Settings for all variables of all MIPs + '*': + '*': + automatic_regrid: true + family: E5 + type: an + typeid: '00' + version: v1 + + # Variable-specific settings + albsn: + level: sf + grib_id: '032' + cl: + level: pl + grib_id: '248' + cli: + level: pl + grib_id: '247' + clt: + level: sf + grib_id: '164' + clw: + level: pl + grib_id: '246' + hur: + level: pl + grib_id: '157' + hus: + level: pl + grib_id: '133' + o3: + level: pl + grib_id: '203' + prw: + level: sf + grib_id: '137' + ps: + level: sf + grib_id: '134' + psl: + level: sf + grib_id: '151' + rainmxrat27: + level: pl + grib_id: '075' + sftlf: + level: sf + grib_id: '172' + siconc: + level: sf + grib_id: '031' + siconca: + level: sf + grib_id: '031' + snd: + level: sf + grib_id: '141' + snowmxrat27: + level: pl + grib_id: '076' + ta: + level: pl + grib_id: '130' + tas: + level: sf + grib_id: '167' + tdps: + level: sf + grib_id: '168' + tos: + level: sf + grib_id: '034' + toz: + level: sf + grib_id: '206' + ts: + level: sf + grib_id: '235' + ua: + level: pl + grib_id: '131' + uas: + level: sf + grib_id: '165' + va: + level: pl + grib_id: '132' + vas: + level: sf + grib_id: '166' + wap: + level: pl + grib_id: '135' + zg: + level: pl + grib_id: '129' + + # MIP-specific settings + AERday: + '*': + tres: 1D + AERhr: + '*': + tres: 1H + AERmon: + '*': + tres: 1M + AERmonZ: + '*': + tres: 1M + Amon: + '*': + tres: 1M + CFday: + '*': + tres: 1D + CFmon: + '*': + tres: 1M + day: + '*': + tres: 1D + E1hr: + '*': + tres: 1H + E1hrClimMon: + '*': + tres: 1H + Eday: + '*': + tres: 1D + EdayZ: + '*': + tres: 1D + Efx: + '*': + tres: IV + Emon: + '*': + tres: 1M + EmonZ: + '*': + tres: 1M + fx: + '*': + tres: IV + IfxAnt: + '*': + tres: IV + IfxGre: + '*': + tres: IV + ImonAnt: + '*': + tres: 1M + ImonGre: + '*': + tres: 1M + LImon: + '*': + tres: 1M + Lmon: + '*': + tres: 1M + Oday: + '*': + tres: 1D + Ofx: + '*': + tres: IV + Omon: + '*': + tres: 1M + SIday: + '*': + tres: 1D + SImon: + '*': + tres: 1M diff --git a/esmvalcore/preprocessor/_io.py b/esmvalcore/preprocessor/_io.py index eccac411f2..0851e1d37e 100644 --- a/esmvalcore/preprocessor/_io.py +++ b/esmvalcore/preprocessor/_io.py @@ -39,6 +39,7 @@ "reference_dataset", "alternative_dataset", } +GRIB_FORMATS = (".grib2", ".grib", ".grb2", ".grb", ".gb2", ".gb") iris.FUTURE.save_split_attrs = True @@ -142,7 +143,13 @@ def load( # warnings.filterwarnings # (see https://github.com/SciTools/cf-units/issues/240) with suppress_errors(): - raw_cubes = iris.load_raw(file, callback=_load_callback) + # GRIB files need to be loaded with iris.load, otherwise we will + # get separate (lat, lon) slices for each time step, pressure + # level, etc. + if file.suffix in GRIB_FORMATS: + raw_cubes = iris.load(file, callback=_load_callback) + else: + raw_cubes = iris.load_raw(file, callback=_load_callback) logger.debug("Done with loading %s", file) if not raw_cubes: diff --git a/pyproject.toml b/pyproject.toml index 61de91ae25..4ce0f4c303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,7 +168,7 @@ enable_error_code = [ # Configure linters [tool.codespell] -skip = "*.ipynb,esmvalcore/config/extra_facets/ipslcm-mappings.yml" +skip = "*.ipynb,esmvalcore/config/extra_facets/ipslcm-mappings.yml,tests/sample_data/iris-sample-data/LICENSE" ignore-words-list = "vas,hist,oce" [tool.ruff] diff --git a/tests/integration/cmor/_fixes/native6/test_era5.py b/tests/integration/cmor/_fixes/native6/test_era5.py index 60460138a9..b65acabbff 100644 --- a/tests/integration/cmor/_fixes/native6/test_era5.py +++ b/tests/integration/cmor/_fixes/native6/test_era5.py @@ -2,16 +2,19 @@ import datetime -import iris +import dask.array as da import numpy as np import pytest from cf_units import Unit +from iris.coords import AuxCoord, DimCoord +from iris.cube import Cube, CubeList from esmvalcore.cmor._fixes.fix import Fix, GenericFix from esmvalcore.cmor._fixes.native6.era5 import ( AllVars, Evspsbl, Zg, + fix_accumulated_units, get_frequency, ) from esmvalcore.cmor.fix import fix_metadata @@ -40,12 +43,12 @@ def test_get_zg_fix(): def test_get_frequency_hourly(): """Test cubes with hourly frequency.""" - time = iris.coords.DimCoord( + time = DimCoord( [0, 1, 2], standard_name="time", units=Unit("hours since 1900-01-01"), ) - cube = iris.cube.Cube( + cube = Cube( [1, 6, 3], var_name="random_var", dim_coords_and_dims=[(time, 0)], @@ -55,14 +58,31 @@ def test_get_frequency_hourly(): assert get_frequency(cube) == "hourly" +def test_get_frequency_daily(): + """Test cubes with daily frequency.""" + time = DimCoord( + [0, 1, 2], + standard_name="time", + units=Unit("days since 1900-01-01"), + ) + cube = Cube( + [1, 6, 3], + var_name="random_var", + dim_coords_and_dims=[(time, 0)], + ) + assert get_frequency(cube) == "daily" + cube.coord("time").convert_units("hours since 1850-1-1 00:00:00.0") + assert get_frequency(cube) == "daily" + + def test_get_frequency_monthly(): """Test cubes with monthly frequency.""" - time = iris.coords.DimCoord( + time = DimCoord( [0, 31, 59], standard_name="time", units=Unit("hours since 1900-01-01"), ) - cube = iris.cube.Cube( + cube = Cube( [1, 6, 3], var_name="random_var", dim_coords_and_dims=[(time, 0)], @@ -74,27 +94,50 @@ def test_get_frequency_monthly(): def test_get_frequency_fx(): """Test cubes with time invariant frequency.""" - cube = iris.cube.Cube(1.0, long_name="Cube without time coordinate") + cube = Cube(1.0, long_name="Cube without time coordinate") assert get_frequency(cube) == "fx" - time = iris.coords.DimCoord( + + time = DimCoord( 0, standard_name="time", units=Unit("hours since 1900-01-01"), ) - cube = iris.cube.Cube( + cube = Cube( [1], var_name="cube_with_length_1_time_coord", long_name="Geopotential", dim_coords_and_dims=[(time, 0)], ) assert get_frequency(cube) == "fx" + + cube.long_name = ( + "Percentage of the Grid Cell Occupied by Land (Including Lakes)" + ) + assert get_frequency(cube) == "fx" + cube.long_name = "Not geopotential" with pytest.raises(ValueError): get_frequency(cube) +def test_fix_accumulated_units_fail(): + """Test `fix_accumulated_units`.""" + time = DimCoord( + [0, 1, 2], + standard_name="time", + units=Unit("days since 1900-01-01"), + ) + cube = Cube( + [1, 6, 3], + var_name="random_var", + dim_coords_and_dims=[(time, 0)], + ) + with pytest.raises(NotImplementedError): + fix_accumulated_units(cube) + + def _era5_latitude(): - return iris.coords.DimCoord( + return DimCoord( np.array([90.0, 0.0, -90.0]), standard_name="latitude", long_name="latitude", @@ -104,7 +147,7 @@ def _era5_latitude(): def _era5_longitude(): - return iris.coords.DimCoord( + return DimCoord( np.array([0, 180, 359.75]), standard_name="longitude", long_name="longitude", @@ -117,11 +160,15 @@ def _era5_longitude(): def _era5_time(frequency): if frequency == "invariant": timestamps = [788928] # hours since 1900 at 1 january 1990 + elif frequency == "daily": + timestamps = [788940, 788964, 788988] elif frequency == "hourly": timestamps = [788928, 788929, 788930] elif frequency == "monthly": timestamps = [788928, 789672, 790344] - return iris.coords.DimCoord( + else: + raise NotImplementedError(f"Invalid frequency {frequency}") + return DimCoord( np.array(timestamps, dtype="int32"), standard_name="time", long_name="time", @@ -137,7 +184,7 @@ def _era5_plev(): 1000, ] ) - return iris.coords.DimCoord( + return DimCoord( values, long_name="pressure", units=Unit("millibars"), @@ -153,7 +200,7 @@ def _era5_data(frequency): def _cmor_latitude(): - return iris.coords.DimCoord( + return DimCoord( np.array([-90.0, 0.0, 90.0]), standard_name="latitude", long_name="Latitude", @@ -164,7 +211,7 @@ def _cmor_latitude(): def _cmor_longitude(): - return iris.coords.DimCoord( + return DimCoord( np.array([0, 180, 359.75]), standard_name="longitude", long_name="Longitude", @@ -184,14 +231,22 @@ def _cmor_time(mip, bounds=None, shifted=False): timestamps -= 1 / 48 if bounds is not None: bounds = [[t - 1 / 48, t + 1 / 48] for t in timestamps] - elif mip == "Amon": + elif mip == "Eday": + timestamps = np.array([51134.5, 51135.5, 51136.5]) + if bounds is not None: + bounds = np.array( + [[51134.0, 51135.0], [51135.0, 51136.0], [51136.0, 51137.0]] + ) + elif "mon" in mip: timestamps = np.array([51149.5, 51179.0, 51208.5]) if bounds is not None: bounds = np.array( [[51134.0, 51165.0], [51165.0, 51193.0], [51193.0, 51224.0]] ) + else: + raise NotImplementedError() - return iris.coords.DimCoord( + return DimCoord( np.array(timestamps, dtype=float), standard_name="time", long_name="time", @@ -202,7 +257,7 @@ def _cmor_time(mip, bounds=None, shifted=False): def _cmor_aux_height(value): - return iris.coords.AuxCoord( + return AuxCoord( value, long_name="height", standard_name="height", @@ -219,7 +274,7 @@ def _cmor_plev(): 100.0, ] ) - return iris.coords.DimCoord( + return DimCoord( values, long_name="pressure", standard_name="air_pressure", @@ -235,10 +290,97 @@ def _cmor_data(mip): return np.arange(27).reshape(3, 3, 3)[:, ::-1, :] +def era5_2d(frequency): + if frequency == "monthly": + time = DimCoord( + [-31, 0, 31], standard_name="time", units="days since 1850-01-01" + ) + else: + time = _era5_time(frequency) + cube = Cube( + _era5_data(frequency), + long_name=None, + var_name=None, + units="unknown", + dim_coords_and_dims=[ + (time, 0), + (_era5_latitude(), 1), + (_era5_longitude(), 2), + ], + ) + return CubeList([cube]) + + +def era5_3d(frequency): + cube = Cube( + np.ones((3, 2, 3, 3)), + long_name=None, + var_name=None, + units="unknown", + dim_coords_and_dims=[ + (_era5_time(frequency), 0), + (_era5_plev(), 1), + (_era5_latitude(), 2), + (_era5_longitude(), 3), + ], + ) + return CubeList([cube]) + + +def cmor_2d(mip, short_name): + cmor_table = CMOR_TABLES["native6"] + vardef = cmor_table.get_variable(mip, short_name) + if "mon" in mip: + time = DimCoord( + [-15.5, 15.5, 45.0], + bounds=[[-31.0, 0.0], [0.0, 31.0], [31.0, 59.0]], + standard_name="time", + long_name="time", + var_name="time", + units="days since 1850-01-01", + ) + else: + time = _cmor_time(mip, bounds=True) + cube = Cube( + _cmor_data(mip).astype("float32"), + long_name=vardef.long_name, + var_name=vardef.short_name, + standard_name=vardef.standard_name, + units=Unit(vardef.units), + dim_coords_and_dims=[ + (time, 0), + (_cmor_latitude(), 1), + (_cmor_longitude(), 2), + ], + attributes={"comment": COMMENT}, + ) + return CubeList([cube]) + + +def cmor_3d(mip, short_name): + cmor_table = CMOR_TABLES["native6"] + vardef = cmor_table.get_variable(mip, short_name) + cube = Cube( + np.ones((3, 2, 3, 3)), + long_name=vardef.long_name, + var_name=vardef.short_name, + standard_name=vardef.standard_name, + units=Unit(vardef.units), + dim_coords_and_dims=[ + (_cmor_time(mip, bounds=True), 0), + (_cmor_plev(), 1), + (_cmor_latitude(), 2), + (_cmor_longitude(), 3), + ], + attributes={"comment": COMMENT}, + ) + return CubeList([cube]) + + def cl_era5_monthly(): time = _era5_time("monthly") data = np.ones((3, 2, 3, 3)) - cube = iris.cube.Cube( + cube = Cube( data, long_name="Percentage Cloud Cover", var_name="cl", @@ -250,7 +392,7 @@ def cl_era5_monthly(): (_era5_longitude(), 3), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def cl_cmor_amon(): @@ -259,7 +401,7 @@ def cl_cmor_amon(): time = _cmor_time("Amon", bounds=True) data = np.ones((3, 2, 3, 3)) data = data * 100.0 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -273,12 +415,12 @@ def cl_cmor_amon(): ], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def clt_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="cloud cover fraction", var_name="cloud_cover", @@ -289,7 +431,7 @@ def clt_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def clt_cmor_e1hr(): @@ -297,7 +439,7 @@ def clt_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "clt") time = _cmor_time("E1hr", bounds=True) data = _cmor_data("E1hr") * 100 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -310,12 +452,12 @@ def clt_cmor_e1hr(): ], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def evspsbl_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly") * -1.0, long_name="total evapotranspiration", var_name="e", @@ -326,7 +468,7 @@ def evspsbl_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def evspsbl_cmor_e1hr(): @@ -334,7 +476,7 @@ def evspsbl_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "evspsbl") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") * 1000 / 3600.0 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -347,12 +489,12 @@ def evspsbl_cmor_e1hr(): ], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def evspsblpot_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly") * -1.0, long_name="potential evapotranspiration", var_name="epot", @@ -363,7 +505,7 @@ def evspsblpot_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def evspsblpot_cmor_e1hr(): @@ -371,7 +513,7 @@ def evspsblpot_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "evspsblpot") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") * 1000 / 3600.0 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -384,12 +526,12 @@ def evspsblpot_cmor_e1hr(): ], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def mrro_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="runoff", var_name="runoff", @@ -400,7 +542,7 @@ def mrro_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def mrro_cmor_e1hr(): @@ -408,7 +550,7 @@ def mrro_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "mrro") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") * 1000 / 3600.0 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -421,12 +563,20 @@ def mrro_cmor_e1hr(): ], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) + + +def o3_era5_monthly(): + cube = era5_3d("monthly")[0] + cube = cube[:, ::-1, ::-1, :] # test if correct order of plev and lat stay + cube.data = cube.data.astype("float32") + cube.data *= 47.9982 / 28.9644 + return CubeList([cube]) def orog_era5_hourly(): time = _era5_time("invariant") - cube = iris.cube.Cube( + cube = Cube( _era5_data("invariant"), long_name="geopotential height", var_name="zg", @@ -437,14 +587,14 @@ def orog_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def orog_cmor_fx(): cmor_table = CMOR_TABLES["native6"] vardef = cmor_table.get_variable("fx", "orog") data = _cmor_data("fx") / 9.80665 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -453,12 +603,12 @@ def orog_cmor_fx(): dim_coords_and_dims=[(_cmor_latitude(), 0), (_cmor_longitude(), 1)], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def pr_era5_monthly(): time = _era5_time("monthly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("monthly"), long_name="total_precipitation", var_name="tp", @@ -469,7 +619,7 @@ def pr_era5_monthly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def pr_cmor_amon(): @@ -477,7 +627,7 @@ def pr_cmor_amon(): vardef = cmor_table.get_variable("Amon", "pr") time = _cmor_time("Amon", bounds=True) data = _cmor_data("Amon") * 1000.0 / 3600.0 / 24.0 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -490,12 +640,12 @@ def pr_cmor_amon(): ], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def pr_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="total_precipitation", var_name="tp", @@ -506,7 +656,7 @@ def pr_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def pr_cmor_e1hr(): @@ -514,7 +664,7 @@ def pr_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "pr") time = _cmor_time("E1hr", bounds=True, shifted=True) data = _cmor_data("E1hr") * 1000.0 / 3600.0 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -527,12 +677,12 @@ def pr_cmor_e1hr(): ], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def prsn_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="snow", var_name="snow", @@ -543,7 +693,7 @@ def prsn_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def prsn_cmor_e1hr(): @@ -551,7 +701,7 @@ def prsn_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "prsn") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") * 1000 / 3600.0 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -564,12 +714,12 @@ def prsn_cmor_e1hr(): ], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def ptype_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="snow", var_name="snow", @@ -580,7 +730,7 @@ def ptype_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def ptype_cmor_e1hr(): @@ -588,7 +738,7 @@ def ptype_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "ptype") time = _cmor_time("E1hr", shifted=False, bounds=True) data = _cmor_data("E1hr") - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -602,12 +752,12 @@ def ptype_cmor_e1hr(): ) cube.coord("latitude").long_name = "latitude" cube.coord("longitude").long_name = "longitude" - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rlds_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="surface thermal radiation downwards", var_name="ssrd", @@ -618,7 +768,7 @@ def rlds_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rlds_cmor_e1hr(): @@ -626,7 +776,87 @@ def rlds_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "rlds") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") / 3600 - cube = iris.cube.Cube( + cube = Cube( + data.astype("float32"), + long_name=vardef.long_name, + var_name=vardef.short_name, + standard_name=vardef.standard_name, + units=Unit(vardef.units), + dim_coords_and_dims=[ + (time, 0), + (_cmor_latitude(), 1), + (_cmor_longitude(), 2), + ], + attributes={"comment": COMMENT, "positive": "down"}, + ) + return CubeList([cube]) + + +def rlns_era5_hourly(): + freq = "hourly" + cube = Cube( + _era5_data(freq), + long_name=None, + var_name=None, + units="J m**-2", + dim_coords_and_dims=[ + (_era5_time(freq), 0), + (_era5_latitude(), 1), + (_era5_longitude(), 2), + ], + ) + return CubeList([cube]) + + +def rlns_cmor_e1hr(): + mip = "E1hr" + short_name = "rlns" + cmor_table = CMOR_TABLES["native6"] + vardef = cmor_table.get_variable(mip, short_name) + time = _cmor_time(mip, shifted=True, bounds=True) + data = _cmor_data(mip) / 3600 + cube = Cube( + data.astype("float32"), + long_name=vardef.long_name, + var_name=vardef.short_name, + standard_name=vardef.standard_name, + units=Unit(vardef.units), + dim_coords_and_dims=[ + (time, 0), + (_cmor_latitude(), 1), + (_cmor_longitude(), 2), + ], + attributes={"comment": COMMENT, "positive": "down"}, + ) + cube.coord("latitude").long_name = "latitude" # from custom table + cube.coord("longitude").long_name = "longitude" # from custom table + return CubeList([cube]) + + +def rlus_era5_hourly(): + freq = "hourly" + cube = Cube( + _era5_data(freq), + long_name=None, + var_name=None, + units="J m**-2", + dim_coords_and_dims=[ + (_era5_time(freq), 0), + (_era5_latitude(), 1), + (_era5_longitude(), 2), + ], + ) + return CubeList([cube]) + + +def rlus_cmor_e1hr(): + mip = "E1hr" + short_name = "rlus" + cmor_table = CMOR_TABLES["native6"] + vardef = cmor_table.get_variable(mip, short_name) + time = _cmor_time(mip, shifted=True, bounds=True) + data = _cmor_data(mip) / 3600 + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -637,17 +867,14 @@ def rlds_cmor_e1hr(): (_cmor_latitude(), 1), (_cmor_longitude(), 2), ], - attributes={ - "comment": COMMENT, - "positive": "down", - }, + attributes={"comment": COMMENT, "positive": "up"}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rls_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="runoff", var_name="runoff", @@ -658,7 +885,7 @@ def rls_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rls_cmor_e1hr(): @@ -666,7 +893,7 @@ def rls_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "rls") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -677,17 +904,14 @@ def rls_cmor_e1hr(): (_cmor_latitude(), 1), (_cmor_longitude(), 2), ], - attributes={ - "comment": COMMENT, - "positive": "down", - }, + attributes={"comment": COMMENT, "positive": "down"}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rsds_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="solar_radiation_downwards", var_name="rlwd", @@ -698,7 +922,7 @@ def rsds_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rsds_cmor_e1hr(): @@ -706,7 +930,87 @@ def rsds_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "rsds") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") / 3600 - cube = iris.cube.Cube( + cube = Cube( + data.astype("float32"), + long_name=vardef.long_name, + var_name=vardef.short_name, + standard_name=vardef.standard_name, + units=Unit(vardef.units), + dim_coords_and_dims=[ + (time, 0), + (_cmor_latitude(), 1), + (_cmor_longitude(), 2), + ], + attributes={"comment": COMMENT, "positive": "down"}, + ) + return CubeList([cube]) + + +def rsns_era5_hourly(): + freq = "hourly" + cube = Cube( + _era5_data(freq), + long_name=None, + var_name=None, + units="J m**-2", + dim_coords_and_dims=[ + (_era5_time(freq), 0), + (_era5_latitude(), 1), + (_era5_longitude(), 2), + ], + ) + return CubeList([cube]) + + +def rsns_cmor_e1hr(): + mip = "E1hr" + short_name = "rsns" + cmor_table = CMOR_TABLES["native6"] + vardef = cmor_table.get_variable(mip, short_name) + time = _cmor_time(mip, shifted=True, bounds=True) + data = _cmor_data(mip) / 3600 + cube = Cube( + data.astype("float32"), + long_name=vardef.long_name, + var_name=vardef.short_name, + standard_name=vardef.standard_name, + units=Unit(vardef.units), + dim_coords_and_dims=[ + (time, 0), + (_cmor_latitude(), 1), + (_cmor_longitude(), 2), + ], + attributes={"comment": COMMENT, "positive": "down"}, + ) + cube.coord("latitude").long_name = "latitude" # from custom table + cube.coord("longitude").long_name = "longitude" # from custom table + return CubeList([cube]) + + +def rsus_era5_hourly(): + freq = "hourly" + cube = Cube( + _era5_data(freq), + long_name=None, + var_name=None, + units="J m**-2", + dim_coords_and_dims=[ + (_era5_time(freq), 0), + (_era5_latitude(), 1), + (_era5_longitude(), 2), + ], + ) + return CubeList([cube]) + + +def rsus_cmor_e1hr(): + mip = "E1hr" + short_name = "rsus" + cmor_table = CMOR_TABLES["native6"] + vardef = cmor_table.get_variable(mip, short_name) + time = _cmor_time(mip, shifted=True, bounds=True) + data = _cmor_data(mip) / 3600 + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -717,17 +1021,14 @@ def rsds_cmor_e1hr(): (_cmor_latitude(), 1), (_cmor_longitude(), 2), ], - attributes={ - "comment": COMMENT, - "positive": "down", - }, + attributes={"comment": COMMENT, "positive": "up"}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rsdt_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="thermal_radiation_downwards", var_name="strd", @@ -738,7 +1039,7 @@ def rsdt_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rsdt_cmor_e1hr(): @@ -746,7 +1047,7 @@ def rsdt_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "rsdt") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") / 3600 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -757,17 +1058,14 @@ def rsdt_cmor_e1hr(): (_cmor_latitude(), 1), (_cmor_longitude(), 2), ], - attributes={ - "comment": COMMENT, - "positive": "down", - }, + attributes={"comment": COMMENT, "positive": "down"}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rss_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="net_solar_radiation", var_name="ssr", @@ -778,7 +1076,7 @@ def rss_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def rss_cmor_e1hr(): @@ -786,7 +1084,7 @@ def rss_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "rss") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") / 3600 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -797,17 +1095,43 @@ def rss_cmor_e1hr(): (_cmor_latitude(), 1), (_cmor_longitude(), 2), ], - attributes={ - "comment": COMMENT, - "positive": "down", - }, + attributes={"comment": COMMENT, "positive": "down"}, + ) + return CubeList([cube]) + + +def sftlf_era5(): + cube = Cube( + np.ones((3, 3)), + long_name=None, + var_name=None, + units="unknown", + dim_coords_and_dims=[ + (_era5_latitude(), 0), + (_era5_longitude(), 1), + ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) + + +def sftlf_cmor_fx(): + cmor_table = CMOR_TABLES["native6"] + vardef = cmor_table.get_variable("fx", "sftlf") + cube = Cube( + np.ones((3, 3)).astype("float32") * 100.0, + long_name=vardef.long_name, + var_name=vardef.short_name, + standard_name=vardef.standard_name, + units=Unit(vardef.units), + dim_coords_and_dims=[(_cmor_latitude(), 0), (_cmor_longitude(), 1)], + attributes={"comment": COMMENT}, + ) + return CubeList([cube]) def tas_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="2m_temperature", var_name="t2m", @@ -818,7 +1142,7 @@ def tas_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def tas_cmor_e1hr(): @@ -826,7 +1150,7 @@ def tas_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "tas") time = _cmor_time("E1hr") data = _cmor_data("E1hr") - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -840,12 +1164,12 @@ def tas_cmor_e1hr(): attributes={"comment": COMMENT}, ) cube.add_aux_coord(_cmor_aux_height(2.0)) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def tas_era5_monthly(): time = _era5_time("monthly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("monthly"), long_name="2m_temperature", var_name="t2m", @@ -856,7 +1180,7 @@ def tas_era5_monthly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def tas_cmor_amon(): @@ -864,7 +1188,7 @@ def tas_cmor_amon(): vardef = cmor_table.get_variable("Amon", "tas") time = _cmor_time("Amon", bounds=True) data = _cmor_data("Amon") - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -878,13 +1202,20 @@ def tas_cmor_amon(): attributes={"comment": COMMENT}, ) cube.add_aux_coord(_cmor_aux_height(2.0)) - return iris.cube.CubeList([cube]) + return CubeList([cube]) + + +def toz_era5_monthly(): + cube = era5_2d("monthly")[0] + cube.data = cube.data.astype("float32") + cube.data *= 2.1415 + return CubeList([cube]) def zg_era5_monthly(): time = _era5_time("monthly") data = np.ones((3, 2, 3, 3)) - cube = iris.cube.Cube( + cube = Cube( data, long_name="geopotential height", var_name="zg", @@ -896,7 +1227,7 @@ def zg_era5_monthly(): (_era5_longitude(), 3), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def zg_cmor_amon(): @@ -905,7 +1236,7 @@ def zg_cmor_amon(): time = _cmor_time("Amon", bounds=True) data = np.ones((3, 2, 3, 3)) data = data / 9.80665 - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -919,12 +1250,12 @@ def zg_cmor_amon(): ], attributes={"comment": COMMENT}, ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def tasmax_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="maximum 2m temperature", var_name="mx2t", @@ -935,7 +1266,7 @@ def tasmax_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def tasmax_cmor_e1hr(): @@ -943,7 +1274,7 @@ def tasmax_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "tasmax") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -957,12 +1288,12 @@ def tasmax_cmor_e1hr(): attributes={"comment": COMMENT}, ) cube.add_aux_coord(_cmor_aux_height(2.0)) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def tasmin_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="minimum 2m temperature", var_name="mn2t", @@ -973,7 +1304,7 @@ def tasmin_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def tasmin_cmor_e1hr(): @@ -981,7 +1312,7 @@ def tasmin_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "tasmin") time = _cmor_time("E1hr", shifted=True, bounds=True) data = _cmor_data("E1hr") - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -995,12 +1326,12 @@ def tasmin_cmor_e1hr(): attributes={"comment": COMMENT}, ) cube.add_aux_coord(_cmor_aux_height(2.0)) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def uas_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="10m_u_component_of_wind", var_name="u10", @@ -1011,7 +1342,7 @@ def uas_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def uas_cmor_e1hr(): @@ -1019,7 +1350,7 @@ def uas_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "uas") time = _cmor_time("E1hr") data = _cmor_data("E1hr") - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -1033,12 +1364,12 @@ def uas_cmor_e1hr(): attributes={"comment": COMMENT}, ) cube.add_aux_coord(_cmor_aux_height(10.0)) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def vas_era5_hourly(): time = _era5_time("hourly") - cube = iris.cube.Cube( + cube = Cube( _era5_data("hourly"), long_name="10m_v_component_of_wind", var_name="v10", @@ -1049,7 +1380,7 @@ def vas_era5_hourly(): (_era5_longitude(), 2), ], ) - return iris.cube.CubeList([cube]) + return CubeList([cube]) def vas_cmor_e1hr(): @@ -1057,7 +1388,7 @@ def vas_cmor_e1hr(): vardef = cmor_table.get_variable("E1hr", "vas") time = _cmor_time("E1hr") data = _cmor_data("E1hr") - cube = iris.cube.Cube( + cube = Cube( data.astype("float32"), long_name=vardef.long_name, var_name=vardef.short_name, @@ -1071,14 +1402,17 @@ def vas_cmor_e1hr(): attributes={"comment": COMMENT}, ) cube.add_aux_coord(_cmor_aux_height(10.0)) - return iris.cube.CubeList([cube]) + return CubeList([cube]) VARIABLES = [ pytest.param(a, b, c, d, id=c + "_" + d) for (a, b, c, d) in [ + (era5_2d("daily"), cmor_2d("Eday", "albsn"), "albsn", "Eday"), (cl_era5_monthly(), cl_cmor_amon(), "cl", "Amon"), + (era5_3d("monthly"), cmor_3d("Amon", "cli"), "cli", "Amon"), (clt_era5_hourly(), clt_cmor_e1hr(), "clt", "E1hr"), + (era5_3d("monthly"), cmor_3d("Amon", "clw"), "clw", "Amon"), (evspsbl_era5_hourly(), evspsbl_cmor_e1hr(), "evspsbl", "E1hr"), ( evspsblpot_era5_hourly(), @@ -1086,21 +1420,43 @@ def vas_cmor_e1hr(): "evspsblpot", "E1hr", ), + (era5_3d("monthly"), cmor_3d("Amon", "hus"), "hus", "Amon"), (mrro_era5_hourly(), mrro_cmor_e1hr(), "mrro", "E1hr"), + (o3_era5_monthly(), cmor_3d("Amon", "o3"), "o3", "Amon"), (orog_era5_hourly(), orog_cmor_fx(), "orog", "fx"), (pr_era5_monthly(), pr_cmor_amon(), "pr", "Amon"), (pr_era5_hourly(), pr_cmor_e1hr(), "pr", "E1hr"), (prsn_era5_hourly(), prsn_cmor_e1hr(), "prsn", "E1hr"), + (era5_2d("monthly"), cmor_2d("Amon", "prw"), "prw", "Amon"), + (era5_2d("monthly"), cmor_2d("Amon", "ps"), "ps", "Amon"), (ptype_era5_hourly(), ptype_cmor_e1hr(), "ptype", "E1hr"), + ( + era5_3d("monthly"), + cmor_3d("Emon", "rainmxrat27"), + "rainmxrat27", + "Emon", + ), (rlds_era5_hourly(), rlds_cmor_e1hr(), "rlds", "E1hr"), + (rlns_era5_hourly(), rlns_cmor_e1hr(), "rlns", "E1hr"), + (rlus_era5_hourly(), rlus_cmor_e1hr(), "rlus", "E1hr"), (rls_era5_hourly(), rls_cmor_e1hr(), "rls", "E1hr"), (rsds_era5_hourly(), rsds_cmor_e1hr(), "rsds", "E1hr"), + (rsns_era5_hourly(), rsns_cmor_e1hr(), "rsns", "E1hr"), + (rsus_era5_hourly(), rsus_cmor_e1hr(), "rsus", "E1hr"), (rsdt_era5_hourly(), rsdt_cmor_e1hr(), "rsdt", "E1hr"), (rss_era5_hourly(), rss_cmor_e1hr(), "rss", "E1hr"), + (sftlf_era5(), sftlf_cmor_fx(), "sftlf", "fx"), + ( + era5_3d("monthly"), + cmor_3d("Emon", "snowmxrat27"), + "snowmxrat27", + "Emon", + ), (tas_era5_hourly(), tas_cmor_e1hr(), "tas", "E1hr"), (tas_era5_monthly(), tas_cmor_amon(), "tas", "Amon"), (tasmax_era5_hourly(), tasmax_cmor_e1hr(), "tasmax", "E1hr"), (tasmin_era5_hourly(), tasmin_cmor_e1hr(), "tasmin", "E1hr"), + (toz_era5_monthly(), cmor_2d("AERmon", "toz"), "toz", "AERmon"), (uas_era5_hourly(), uas_cmor_e1hr(), "uas", "E1hr"), (vas_era5_hourly(), vas_cmor_e1hr(), "vas", "E1hr"), (zg_era5_monthly(), zg_cmor_amon(), "zg", "Amon"), @@ -1139,3 +1495,61 @@ def test_cmorization(era5_cubes, cmor_cubes, var, mip): for coord in fixed_cube.coords(): print(coord) assert fixed_cube == cmor_cube + + +@pytest.fixture +def unstructured_grid_cubes(): + """Sample cubes with unstructured grid.""" + time = DimCoord( + [0.0, 31.0], standard_name="time", units="days since 1950-01-01" + ) + lat = AuxCoord( + [1.0, 1.0, -1.0, -1.0], standard_name="latitude", units="degrees_north" + ) + lon = AuxCoord( + [179.0, 180.0, 180.0, 179.0], + standard_name="longitude", + units="degrees_east", + ) + cube = Cube( + da.from_array([[0.0, 1.0, 2.0, 3.0], [0.0, 0.0, 0.0, 0.0]]), + standard_name="air_temperature", + units="K", + dim_coords_and_dims=[(time, 0)], + aux_coords_and_dims=[(lat, 1), (lon, 1)], + attributes={"GRIB_PARAM": (1, 1)}, + ) + return CubeList([cube]) + + +def test_unstructured_grid(unstructured_grid_cubes): + """Test processing unstructured data.""" + fixed_cubes = fix_metadata( + unstructured_grid_cubes, + "tas", + "native6", + "era5", + "Amon", + ) + + assert len(fixed_cubes) == 1 + fixed_cube = fixed_cubes[0] + + assert fixed_cube.shape == (2, 4) + + assert fixed_cube.coords("time", dim_coords=True) + assert fixed_cube.coord_dims("time") == (0,) + + assert fixed_cube.coords("latitude", dim_coords=False) + assert fixed_cube.coord_dims("latitude") == (1,) + lat = fixed_cube.coord("latitude") + np.testing.assert_allclose(lat.points, [1, 1, -1, -1]) + assert lat.bounds is None + + assert fixed_cube.coords("longitude", dim_coords=False) + assert fixed_cube.coord_dims("longitude") == (1,) + lon = fixed_cube.coord("longitude") + np.testing.assert_allclose(lon.points, [179, 180, 180, 179]) + assert lon.bounds is None + + assert fixed_cube.attributes["GRIB_PARAM"] == "(1, 1)" diff --git a/tests/integration/cmor/test_fix.py b/tests/integration/cmor/test_fix.py index 4af47a44d7..43b9419f64 100644 --- a/tests/integration/cmor/test_fix.py +++ b/tests/integration/cmor/test_fix.py @@ -418,9 +418,6 @@ def test_fix_metadata_amon_ta_wrong_lat_units(self): with pytest.raises(CMORCheckError): cmor_check_metadata(fixed_cube, project, mip, short_name) - print(self.mock_debug.mock_calls) - print(self.mock_warning.mock_calls) - assert self.mock_debug.call_count == 3 assert self.mock_warning.call_count == 9 @@ -867,3 +864,30 @@ def test_fix_data_amon_tas(self): assert self.mock_debug.call_count == 0 assert self.mock_warning.call_count == 0 + + def test_fix_metadata_no_time_in_table(self): + """Test ``fix_data``.""" + short_name = "sftlf" + project = "CMIP6" + dataset = "__MODEL_WITH_NO_EXPLICIT_FIX__" + mip = "fx" + cube = self.cubes_2d_latlon[0][0] + cube.units = "%" + cube.data = da.full(cube.shape, 1.0, dtype=cube.dtype) + + fixed_cubes = fix_metadata( + [cube], + short_name, + project, + dataset, + mip, + ) + + assert len(fixed_cubes) == 1 + fixed_cube = fixed_cubes[0] + assert fixed_cube.has_lazy_data() + + cmor_check_metadata(fixed_cube, project, mip, short_name) + + assert self.mock_debug.call_count == 3 + assert self.mock_warning.call_count == 6 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e32e3ca3fa..f6251a8bb0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -100,21 +100,35 @@ def _get_files(root_path, facets, tracking_id): return files, globs -@pytest.fixture -def patched_datafinder(tmp_path, monkeypatch): - def tracking_ids(i=0): - while True: - yield i - i += 1 +def _tracking_ids(i=0): + while True: + yield i + i += 1 - tracking_id = tracking_ids() + +def _get_find_files_func(path: Path, suffix: str = ".nc"): + tracking_id = _tracking_ids() def find_files(*, debug: bool = False, **facets): - files, file_globs = _get_files(tmp_path, facets, tracking_id) + files, file_globs = _get_files(path, facets, tracking_id) + files = [f.with_suffix(suffix) for f in files] + file_globs = [g.with_suffix(suffix) for g in file_globs] if debug: return files, file_globs return files + return find_files + + +@pytest.fixture +def patched_datafinder(tmp_path, monkeypatch): + find_files = _get_find_files_func(tmp_path) + monkeypatch.setattr(esmvalcore.local, "find_files", find_files) + + +@pytest.fixture +def patched_datafinder_grib(tmp_path, monkeypatch): + find_files = _get_find_files_func(tmp_path, suffix=".grib") monkeypatch.setattr(esmvalcore.local, "find_files", find_files) @@ -129,13 +143,7 @@ def patched_failing_datafinder(tmp_path, monkeypatch): Otherwise, return files just like `patched_datafinder`. """ - - def tracking_ids(i=0): - while True: - yield i - i += 1 - - tracking_id = tracking_ids() + tracking_id = _tracking_ids() def find_files(*, debug: bool = False, **facets): files, file_globs = _get_files(tmp_path, facets, tracking_id) diff --git a/tests/integration/preprocessor/_io/test_load.py b/tests/integration/preprocessor/_io/test_load.py index 4c76ba2651..e776b9caa2 100644 --- a/tests/integration/preprocessor/_io/test_load.py +++ b/tests/integration/preprocessor/_io/test_load.py @@ -4,12 +4,14 @@ import tempfile import unittest import warnings +from pathlib import Path import iris import numpy as np from iris.coords import DimCoord from iris.cube import Cube, CubeList +import esmvalcore from esmvalcore.preprocessor._io import load @@ -52,6 +54,24 @@ def test_load(self): (cube.coord("latitude").points == np.array([1, 2])).all() ) + def test_load_grib(self): + """Test loading a grib file.""" + grib_path = Path( + Path(esmvalcore.__file__).parents[1], + "tests", + "sample_data", + "iris-sample-data", + "polar_stereo.grib2", + ) + cubes = load(grib_path) + + assert len(cubes) == 1 + cube = cubes[0] + assert cube.standard_name == "air_temperature" + assert cube.units == "K" + assert cube.shape == (200, 247) + assert "source_file" in cube.attributes + def test_callback_remove_attributes(self): """Test callback remove unwanted attributes.""" attributes = ("history", "creation_date", "tracking_id", "comment") diff --git a/tests/integration/recipe/test_recipe.py b/tests/integration/recipe/test_recipe.py index 90b4985a6e..3077901c33 100644 --- a/tests/integration/recipe/test_recipe.py +++ b/tests/integration/recipe/test_recipe.py @@ -3392,3 +3392,116 @@ def test_invalid_interpolate(tmp_path, patched_datafinder, session): get_recipe(tmp_path, content, session) assert str(exc.value) == INITIALIZATION_ERROR_MSG assert exc.value.failed_tasks[0].message == msg + + +def test_automatic_regrid_era5_nc(tmp_path, patched_datafinder, session): + content = dedent(""" + diagnostics: + diagnostic_name: + variables: + tas: + mip: Amon + timerange: '20000101/20001231' + additional_datasets: + - {project: native6, dataset: ERA5, tier: 3} + scripts: null + """) + recipe = get_recipe(tmp_path, content, session) + + assert len(recipe.tasks) == 1 + task = recipe.tasks.pop() + + assert len(task.products) == 1 + product = task.products.pop() + + assert "regrid" not in product.settings + + +def test_automatic_regrid_era5_grib( + tmp_path, patched_datafinder_grib, session +): + content = dedent(""" + diagnostics: + diagnostic_name: + variables: + tas: + mip: Amon + timerange: '20000101/20001231' + additional_datasets: + - {project: native6, dataset: ERA5, tier: 3} + scripts: null + """) + recipe = get_recipe(tmp_path, content, session) + + assert len(recipe.tasks) == 1 + task = recipe.tasks.pop() + + assert len(task.products) == 1 + product = task.products.pop() + + assert "regrid" in product.settings + assert product.settings["regrid"] == { + "target_grid": "0.25x0.25", + "scheme": "linear", + } + + +def test_automatic_no_regrid_era5_grib( + tmp_path, patched_datafinder_grib, session +): + content = dedent(""" + diagnostics: + diagnostic_name: + variables: + tas: + mip: Amon + timerange: '20000101/20001231' + additional_datasets: + - {project: native6, dataset: ERA5, tier: 3, automatic_regrid: false} + scripts: null + """) + recipe = get_recipe(tmp_path, content, session) + + assert len(recipe.tasks) == 1 + task = recipe.tasks.pop() + + assert len(task.products) == 1 + product = task.products.pop() + + assert "regrid" not in product.settings + + +def test_automatic_already_regrid_era5_grib( + tmp_path, patched_datafinder_grib, session +): + content = dedent(""" + preprocessors: + test_automatic_regrid_era5: + regrid: + target_grid: 1x1 + scheme: nearest + + diagnostics: + diagnostic_name: + variables: + tas: + preprocessor: test_automatic_regrid_era5 + mip: Amon + timerange: '20000101/20001231' + additional_datasets: + - {project: native6, dataset: ERA5, tier: 3} + scripts: null + """) + recipe = get_recipe(tmp_path, content, session) + + assert len(recipe.tasks) == 1 + task = recipe.tasks.pop() + + assert len(task.products) == 1 + product = task.products.pop() + + assert "regrid" in product.settings + assert product.settings["regrid"] == { + "target_grid": "1x1", + "scheme": "nearest", + } diff --git a/tests/sample_data/iris-sample-data/LICENSE b/tests/sample_data/iris-sample-data/LICENSE new file mode 100644 index 0000000000..6ab33c6548 --- /dev/null +++ b/tests/sample_data/iris-sample-data/LICENSE @@ -0,0 +1,10 @@ +Data in this directory is taken from iris-sample-data (https://github.com/SciTools/iris-sample-data). + +It is licensed under the following UK's Open Government Licence (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/): + + +(c) British Crown copyright, 2018. + +You may use and re-use the information featured in this repository (not including logos) free of charge in any format or medium, under the terms of the Open Government Licence. We encourage users to establish hypertext links to this website. + +Any email enquiries regarding the use and re-use of this information resource should be sent to: psi@nationalarchives.gsi.gov.uk. diff --git a/tests/sample_data/iris-sample-data/polar_stereo.grib2 b/tests/sample_data/iris-sample-data/polar_stereo.grib2 new file mode 100644 index 0000000000000000000000000000000000000000..ab02a2d13fe920e78498d11f07291890729c344b GIT binary patch literal 25934 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 bV<`A7G(w&j?v4)$LIh3>fA9a