diff --git a/.zenodo.json b/.zenodo.json index 92a3980eb8..fd321496dd 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -456,6 +456,11 @@ { "affiliation": "Met Office, UK", "name": "Ellis, Hannah" + }, + { + "affiliation": "SMHI, Sweden", + "name": "Lenhardt, Julien", + "orcid": "0000-0002-9949-3989" } ], "description": "ESMValTool: A community diagnostic and performance metrics tool for routine evaluation of Earth system models in CMIP.", diff --git a/doc/sphinx/source/input.rst b/doc/sphinx/source/input.rst index b8bf116272..a960bb3e08 100644 --- a/doc/sphinx/source/input.rst +++ b/doc/sphinx/source/input.rst @@ -360,6 +360,8 @@ A list of the datasets for which a CMORizers is available is provided in the fol +----------------------------------------+------------------------------------------------------------------------------------------------------+------+-----------------+ | GLODAP | dissic, ph, talk (Oyr) | 2 | Python | +----------------------------------------+------------------------------------------------------------------------------------------------------+------+-----------------+ +| GLORYS12V1 | uo, vo, thetao, so, zos, tob (Omon), siconc, sithick, siu, siv (SImon), thkcello (Ofx) | 2 | Python | ++----------------------------------------+------------------------------------------------------------------------------------------------------+------+-----------------+ | GLWD | wetlandFrac (Emon) | 2 | Python | +----------------------------------------+------------------------------------------------------------------------------------------------------+------+-----------------+ | GPCC | pr (Amon) | 2 | Python | diff --git a/esmvaltool/cmorizers/data/cmor_config/GLORYS12V1.yml b/esmvaltool/cmorizers/data/cmor_config/GLORYS12V1.yml new file mode 100644 index 0000000000..a7fbd5ad0b --- /dev/null +++ b/esmvaltool/cmorizers/data/cmor_config/GLORYS12V1.yml @@ -0,0 +1,44 @@ +--- +# Common global attributes for Cmorizer output +attributes: + dataset_id: GLORYS12V1 + version: "v202311" + tier: 2 + type: reanaly + project_id: OBS6 + source: 'https://data.marine.copernicus.eu/product/GLOBAL_MULTIYEAR_PHY_001_030' + reference: 'glorys12v1' + comment: | + Data is downloaded using the Copernicus Marine Service toolbox API. + start_date: 1993 + end_date: 2026 + +# Variables to cmorize +variables: + uo: + mip: Omon + vo: + mip: Omon + thetao: + mip: Omon + so: + mip: Omon + zos: + mip: Omon + mlotst: + mip: Omon + tob: + mip: Omon + raw_name: bottomT + siconc: + mip: SImon + sithick: + mip: SImon + siu: + mip: SImon + raw_name: usi + siv: + mip: SImon + raw_name: vsi + thkcello: + mip: Ofx diff --git a/esmvaltool/cmorizers/data/datasets.yml b/esmvaltool/cmorizers/data/datasets.yml index d02711ea1b..53d78780c2 100644 --- a/esmvaltool/cmorizers/data/datasets.yml +++ b/esmvaltool/cmorizers/data/datasets.yml @@ -789,6 +789,22 @@ datasets: last_access: 2020-03-03 info: "Use automatic download feature to get the data" + GLORYS12V1: + tier: 2 + source: https://data.marine.copernicus.eu/product/GLOBAL_MULTIYEAR_PHY_001_030/description + last_access: 2026-06-01 + info: | + Use automatic download feature to get the data. + The Copernicus Marine Service API is used to download the dataset: + GLORYS12V1 = cmems_mod_glo_phy_my_0.083deg_P1M-m + + The dataset starts in 1993 onwards and is frequently updated. + The downloaded data files have the following filename: + mercatorglorys12v1_gl12_mean_{date}.nc + + The CMS credentials need to be setup before the download by running: + copernicusmarine login + GLWD: tier: 2 source: https://figshare.com/articles/dataset/Global_Lakes_and_Wetlands_Database_GLWD_version_2_0/28519994 diff --git a/esmvaltool/cmorizers/data/downloaders/cms.py b/esmvaltool/cmorizers/data/downloaders/cms.py new file mode 100644 index 0000000000..e57648dbe8 --- /dev/null +++ b/esmvaltool/cmorizers/data/downloaders/cms.py @@ -0,0 +1,84 @@ +"""Downloader for the Copernicus Marine Service data store.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import copernicusmarine + +from .downloader import BaseDownloader + +if TYPE_CHECKING: + from pathlib import Path + + from esmvaltool.cmorizers.data.typing import DatasetInfo + +logger = logging.getLogger(__name__) + + +class CMSDownloader(BaseDownloader): + """Downloader class for the COpernicus Marine Service. + + Parameters + ---------- + product_name: + Name of the product in the Copernicus Marine Service data store. + product_filename: + Product filename in the Copernicus Marine Service data store. + original_data_dir: + Directory where original data will be stored. + dataset: + Name of the dataset + dataset_info: + Dataset information from the datasets.yml file + overwrite: + Overwrite already downloaded files + """ + + def __init__( + self, + original_data_dir: Path, + product_name: str, + dataset: str, + dataset_info: DatasetInfo, + *, + overwrite: bool, + no_directories: bool, + product_filename: str | None = None, + ) -> None: + super().__init__( + original_data_dir=original_data_dir, + dataset=dataset, + dataset_info=dataset_info, + overwrite=overwrite, + ) + self._product_name = product_name + self._product_filename = product_filename + self._no_directories = no_directories + if product_filename is None: + self._product_filename = "" + + def download(self): + """Download a whole dataset from the Copernicus Marine Service.""" + _ = copernicusmarine.get( + dataset_id=self._product_name, + output_directory=self.local_folder, + no_directories=self._no_directories, + filter=f"{self._product_filename}*.nc", + ) + + def download_year(self, year): + """Download a specific year from the Copernicus Marine Service. + + Parameters + ---------- + year : int + Year to download + """ + _ = copernicusmarine.get( + dataset_id=self._product_name, + output_directory=self.local_folder, + no_directories=self._no_directories, + filter=f"{self._product_filename}_{year}*.nc", + ) diff --git a/esmvaltool/cmorizers/data/downloaders/datasets/glorys12v1.py b/esmvaltool/cmorizers/data/downloaders/datasets/glorys12v1.py new file mode 100644 index 0000000000..72829bb45d --- /dev/null +++ b/esmvaltool/cmorizers/data/downloaders/datasets/glorys12v1.py @@ -0,0 +1,63 @@ +"""Downloader for the GLORYS12V1 dataset from the Copernicus Marine Service.""" + +import datetime +import logging + +from dateutil import relativedelta + +from esmvaltool.cmorizers.data.downloaders.cms import CMSDownloader + +logger = logging.getLogger(__name__) + + +def download_dataset( + original_data_dir, + dataset, + dataset_info, + start_date, + end_date, + overwrite, +): + """Download dataset. + + Parameters + ---------- + original_data_dir : Path + Directory where original data will be stored. + dataset : str + Name of the dataset + dataset_info : dict + Dataset information from the datasets.yml file + start_date : datetime + Start of the interval to download + end_date : datetime + End of the interval to download + overwrite : bool + Overwrite already downloaded files + """ + # Download variables + downloader = CMSDownloader( + original_data_dir=original_data_dir, + product_name="cmems_mod_glo_phy_my_0.083deg_P1M-m", + dataset=dataset, + dataset_info=dataset_info, + overwrite=overwrite, + no_directories=True, + product_filename="mercatorglorys12v1_gl12_mean", + ) + if start_date is None: + start_date = datetime.datetime(1993, 1, 1, tzinfo=datetime.UTC) + if end_date is None: + end_date = datetime.datetime(2026, 4, 30, tzinfo=datetime.UTC) + + # Download the whole dataset + if (start_date == datetime.datetime(1993, 1, 1, tzinfo=datetime.UTC)) and ( + end_date == datetime.datetime(2026, 4, 30, tzinfo=datetime.UTC) + ): + downloader.download() + # Loop over years to download + else: + loop_date = start_date + while loop_date <= end_date: + downloader.download_year(year=loop_date.year) + loop_date += relativedelta.relativedelta(years=1) diff --git a/esmvaltool/cmorizers/data/formatters/datasets/glorys12v1.py b/esmvaltool/cmorizers/data/formatters/datasets/glorys12v1.py new file mode 100644 index 0000000000..c359da682b --- /dev/null +++ b/esmvaltool/cmorizers/data/formatters/datasets/glorys12v1.py @@ -0,0 +1,161 @@ +"""ESMValTool CMORizer for GLORYS12V1 data. + +Tier + Tier 2: other freely-available dataset. + +Source + https://data.marine.copernicus.eu/product/GLOBAL_MULTIYEAR_PHY_001_030 + +Last access + 20260601 +""" + +import datetime +import logging + +import iris +import numpy as np + +from esmvaltool.cmorizers.data.utilities import ( + save_variable, +) + +logger = logging.getLogger(__name__) + + +def _fix_latlon_coordinates(cube): + """Fix latitude/longitude coordinates.""" + for c in ["latitude", "longitude"]: + cube.coord(c).attributes = None + cube.coord(c).var_name = c[:3] + + +def _fix_depth_coordinate(cube): + """Fix depth coordinate.""" + cube.coord("depth").attributes = {"positive": "down"} + cube.coord("depth").var_name = "lev" + cube.coord("depth").long_name = "Ocean depth coordinate" + + +def _setup_cell_thickness(cubelist, cfg, out_dir): + """Set up the cell thickness cube of the thkcello variable.""" + logger.info("Creating the cell thickness variable Ofx.thkcello...") + definition = cfg["cmor_table"].get_variable("Ofx", "thkcello") + # Extract a 3D variable to get the depth coordinate + cube_base = cubelist.extract(iris.NameConstraint(var_name="so"))[0] + # Extract a (depth, lat, lon) cube + cube_depth = cube_base[0] + cube_depth.remove_coord("time") + # Creat cell thickness values from the depth coordinate + depth_points = cube_depth.coord("depth").points + thickness_points = np.concatenate( + [[depth_points[0]], np.diff(depth_points)], + ) + # Extract a mask from the data following the bathymetry of the original data + mask = np.isnan(cube_depth.data) + # Duplicate the thickness coordinate values along the lat, lon axes + cube_depth.data = np.tile( + thickness_points[..., np.newaxis, np.newaxis], + (1, cube_depth.shape[1], cube_depth.shape[2]), + ) + # Mask the cell thickness following the bathymetry + cube_depth.data[mask] = np.nan + # Set up names and attributes + cube_depth.var_name = definition.short_name + cube_depth.standard_name = definition.standard_name + cube_depth.long_name = definition.long_name + cube_depth.units = definition.units + cube_depth.attributes["frequency"] = definition.frequency + cube_depth.attributes["modeling_realm"] = definition.modeling_realm + _fix_latlon_coordinates(cube_depth) + _fix_depth_coordinate(cube_depth) + drop_attrs = ["valid_min", "valid_max", "unit_long"] + for d in drop_attrs: + if d in cube_depth.attributes: + cube_depth.attributes.pop(d) + attributes = cfg["attributes"].copy() + attributes["mip"] = "Ofx" + # Save cube + save_variable( + cube_depth, + cube_depth.var_name, + out_dir, + attributes, + ) + + +def _extract_variable( + short_name, + var, + cubelist, + start_date, + end_date, + cfg, + out_dir, +): + """Set up the cube for the variable.""" + logger.info("Cmorising variable %s.%s...", var["mip"], short_name) + variable = var.get("raw_name", short_name) + cube_var = cubelist.extract(iris.NameConstraint(var_name=variable)) + # Equalise attributes + iris.util.equalise_attributes(cube_var) + for cube in cube_var: + cube.attributes.pop("valid_min", None) + cube.attributes.pop("valid_max", None) + # Concatenate cube + cube_var = cube_var.concatenate_cube() + # Extract time range if necesary + time_constraint = iris.Constraint( + time=lambda t: (t >= start_date) and (t <= end_date), + ) + cube_var = cube_var.extract(time_constraint) + # Simple fixes + definition = cfg["cmor_table"].get_variable( + var["mip"], + short_name, + ) + cube_var.convert_units(definition.units) + cube_var.attributes.pop("unit_long") + cube_var.var_name = short_name + _fix_latlon_coordinates(cube_var) + if cube_var.coords("depth"): + logger.info("Fixing depth coordinate") + _fix_depth_coordinate(cube_var) + attributes = cfg["attributes"].copy() + attributes["mip"] = var["mip"] + # Save cube + save_variable( + cube_var, + cube_var.var_name, + out_dir, + attributes, + unlimited_dimensions=["time"], + ) + + +def cmorization(in_dir, out_dir, cfg, cfg_user, start_date, end_date): + """Cmorization func call.""" + if start_date is None: + start_date = datetime.datetime(1993, 1, 1, tzinfo=datetime.UTC) + if end_date is None: + end_date = datetime.datetime(2026, 4, 30, tzinfo=datetime.UTC) + + # Load input in CubeList object + cubelist = iris.load(in_dir / "*.nc") + + for short_name, var in cfg["variables"].items(): + # If variable is Ofx.thkcello + if short_name == "thkcello": + _setup_cell_thickness(cubelist=cubelist, cfg=cfg, out_dir=out_dir) + # Other variables from Omon or SImon + else: + # Extract variable + _extract_variable( + short_name=short_name, + var=var, + cubelist=cubelist, + start_date=start_date, + end_date=end_date, + cfg=cfg, + out_dir=out_dir, + ) diff --git a/esmvaltool/recipes/examples/recipe_check_obs.yml b/esmvaltool/recipes/examples/recipe_check_obs.yml index f3d30a33c4..1ca7bc72ca 100644 --- a/esmvaltool/recipes/examples/recipe_check_obs.yml +++ b/esmvaltool/recipes/examples/recipe_check_obs.yml @@ -595,6 +595,37 @@ diagnostics: type: clim, version: v2.2016b, start_year: 2000, end_year: 2000} scripts: null + GLORYS12V1: + description: GLORYS12V1 check + variables: + uo: + mip: Omon + vo: + mip: Omon + thetao: + mip: Omon + so: + mip: Omon + zos: + mip: Omon + mlotst: + mip: Omon + tob: + mip: Omon + siconc: + mip: SImon + sithick: + mip: SImon + siu: + mip: SImon + siv: + mip: SImon + thkcello: + mip: Ofx + additional_datasets: + - {dataset: GLORYS12V1, project: OBS6, tier: 2, type: reanaly, + version: v202311, start_year: 1993, end_year: 2025} + scripts: null GPCC: description: GPCC check diff --git a/esmvaltool/references/glorys12v1.bibtex b/esmvaltool/references/glorys12v1.bibtex new file mode 100644 index 0000000000..6bc2a72414 --- /dev/null +++ b/esmvaltool/references/glorys12v1.bibtex @@ -0,0 +1,22 @@ +@ARTICLE{10.3389/feart.2021.698876, + +AUTHOR={Jean-Michel, Lellouche and Eric, Greiner and Romain, Bourdallé-Badie and Gilles, Garric and Angélique, Melet and Marie, Drévillon and Clément, Bricaud and Mathieu, Hamon and Olivier, Le Galloudec and Charly, Regnier and Tony, Candela and Charles-Emmanuel, Testut and Florent, Gasparin and Giovanni, Ruggiero and Mounir, Benkiran and Yann, Drillet and Pierre-Yves, Le Traon }, + +TITLE={The Copernicus Global 1/12° Oceanic and Sea Ice GLORYS12 Reanalysis}, + +JOURNAL={Frontiers in Earth Science}, + +VOLUME={Volume 9 - 2021}, + +YEAR={2021}, + +URL={https://www.frontiersin.org/journals/earth-science/articles/10.3389/feart.2021.698876}, + +DOI={10.3389/feart.2021.698876}, + +ISSN={2296-6463}, + +ABSTRACT={GLORYS12 is a global eddy-resolving physical ocean and sea ice reanalysis at 1/12° horizontal resolution covering the 1993-present altimetry period, designed and implemented in the framework of the Copernicus Marine Environment Monitoring Service (CMEMS). The model component is the NEMO platform driven at the surface by atmospheric conditions from the ECMWF ERA-Interim reanalysis. Ocean observations are assimilated by means of a reduced-order Kalman filter. Along track altimeter sea level anomaly, satellite sea surface temperature and sea ice concentration, as well as in situ temperature and salinity vertical profiles are jointly assimilated. A 3D-VAR scheme provides an additional correction for the slowly-evolving large-scale biases in temperature and salinity. +The performance of the reanalysis shows a clear dependency on the time-dependent in situ observation system, which is intrinsic to most reanalyses. The general assessment of GLORYS12 highlights a level of performance at the state-of-the-art and the capacity of the system to capture the main expected climatic interannual variability signals for ocean and sea ice, the general circulation and the inter-basins exchanges. In terms of trends, GLORYS12 shows a higher than observed warming trend together with a slightly lower than observed global mean sea level rise. +Comparisons made with an experiment carried out on the same platform without assimilation show the benefit of data assimilation in controlling water masses properties and sea ice cover and their low frequency variability. Moreover, GLORYS12 represents particularly well the small-scale variability of surface dynamics and compares well with independent (non-assimilated) data. Comparisons made with a twin experiment carried out at ¼° resolution allows characterizing and quantifying the strengthened contribution of the 1/12° resolution onto the downscaled dynamics. +GLORYS12 provides a reliable physical ocean state for climate variability and supports applications such as seasonal forecasts. In addition, this reanalysis has strong assets to serve regional applications and provide relevant physical conditions for applications such as marine biogeochemistry. In the near future, GLORYS12 will be maintained to be as close as possible to real time and could therefore provide relevant and continuous reference past ocean states for many operational applications.}} diff --git a/pyproject.toml b/pyproject.toml index 08a7d02980..7c2180fd69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,9 @@ all = [ "cfgrib", "cftime", "cmocean", + "copernicusmarine", + "dask!=2024.8.0", # https://github.com/dask/dask/issues/11296 + "distributed", "ecmwf-api-client", "eofs", # "ESMPy", # not on PyPI @@ -315,7 +318,9 @@ test-py314 = { features = ["py314", "esmvalcore", "all", "test"], solve-group = "cdsapi" = "*" "cfgrib" = "*" "cftime" = "*" +"click" = ">=8.2.0,!=8.3.0,!=8.3.1" "cmocean" = "*" +"copernicusmarine" = "*" "cython" = "*" "ecmwf-api-client" = "*" "eofs" = "*"