"""Functions for loading and saving cubes."""
from __future__ import annotations
import copy
import logging
import os
import warnings
from itertools import groupby
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import fsspec
import iris
import ncdata
import xarray as xr
import yaml
from iris.cube import Cube, CubeList
from esmvalcore._task import write_ncl_settings
from esmvalcore.exceptions import ESMValCoreLoadWarning
from esmvalcore.io.local import LocalFile
from esmvalcore.io.protocol import DataElement
from esmvalcore.iris_helpers import dataset_to_iris
if TYPE_CHECKING:
from collections.abc import Sequence
from dask.delayed import Delayed
logger = logging.getLogger(__name__)
GLOBAL_FILL_VALUE = 1e20
DATASET_KEYS = {
"mip",
}
VARIABLE_KEYS = {
"reference_dataset",
"alternative_dataset",
}
def _drop_range_attributes(cube: Cube) -> Cube:
"""Drop range related attributes from cube and its components."""
drop_attrs = (
"actual_range",
"valid_max",
"valid_min",
"valid_range",
)
cube = cube.copy()
for attr in drop_attrs:
cube.attributes.pop(attr, None)
for coord in cube.dim_coords:
coord.attributes.pop(attr, None)
for aux_coord in cube.aux_coords:
aux_coord.attributes.pop(attr, None)
for cell_measure in cube.cell_measures():
cell_measure.attributes.pop(attr, None)
for ancillary_variable in cube.ancillary_variables():
ancillary_variable.attributes.pop(attr, None)
return cube
[docs]
def load(
file: str
| Path
| DataElement
| Cube
| CubeList
| xr.Dataset
| ncdata.NcData,
ignore_warnings: list[dict[str, Any]] | None = None,
backend_kwargs: dict[str, Any] | None = None,
) -> CubeList:
"""Load Iris cubes.
Parameters
----------
file:
File to be loaded. If ``file`` is already a loaded dataset, return it
as a :class:`~iris.cube.CubeList`.
File as ``Path`` object could be a Zarr store.
ignore_warnings:
Keyword arguments passed to :func:`warnings.filterwarnings` used to
ignore warnings issued by :func:`iris.load_raw`. Each list element
corresponds to one call to :func:`warnings.filterwarnings`.
backend_kwargs:
Dict to hold info needed by storage backend e.g. to access
a PRIVATE S3 bucket containing object stores (e.g. netCDF4 files);
needed by ``fsspec`` and its extensions e.g. ``s3fs``, so
most of the times this will include ``storage_options``. Note that Zarr
files are opened via ``http`` extension of ``fsspec``, so no need
for ``storage_options`` in that case (ie anon/anon). Currently only used
in Zarr file opening.
Returns
-------
iris.cube.CubeList
Loaded cubes.
Raises
------
ValueError
Cubes are empty.
TypeError
Invalid type for ``file``.
"""
if isinstance(file, DataElement):
cubes = file.to_iris()
elif isinstance(file, (str, Path)):
extension = (
file.suffix
if isinstance(file, Path)
else os.path.splitext(file)[1]
)
if "zarr" not in extension:
local_file = LocalFile(file)
local_file.ignore_warnings = ignore_warnings
cubes = local_file.to_iris()
else:
cubes = _load_zarr(
file,
ignore_warnings=ignore_warnings,
backend_kwargs=backend_kwargs,
)
elif isinstance(file, Cube):
cubes = CubeList([file])
elif isinstance(file, CubeList):
cubes = file
elif isinstance(file, (xr.Dataset, ncdata.NcData)):
cubes = dataset_to_iris(file, ignore_warnings=ignore_warnings)
else:
msg = (
f"Expected type str, pathlib.Path, iris.cube.Cube, "
f"iris.cube.CubeList, xarray.Dataset, or ncdata.NcData for file, "
f"got type {type(file)}"
)
raise TypeError(msg)
if not cubes:
msg = f"{file} does not contain any data"
raise ValueError(msg)
for cube in cubes:
if "source_file" not in cube.attributes:
warn_msg = (
f"Cube {cube.summary(shorten=True)} loaded from\n{file}\ndoes "
f"not contain attribute 'source_file' that points to original "
f"file path, please make sure to add it prior to loading "
f"(preferably during the preprocessing step 'fix_file')"
)
warnings.warn(warn_msg, ESMValCoreLoadWarning, stacklevel=2)
# Drop range related attributes as these are likely to be
# invalidated by preprocessing the data.
return CubeList(_drop_range_attributes(cube) for cube in cubes)
def _load_zarr(
file: str | Path,
ignore_warnings: list[dict[str, Any]] | None = None,
backend_kwargs: dict[str, Any] | None = None,
) -> CubeList:
# note on ``chunks`` kwarg to ``xr.open_dataset()``
# docs.xarray.dev/en/stable/generated/xarray.open_dataset.html
# this is very important because with ``chunks=None`` (default)
# data will be realized as Numpy arrays and transferred in memory;
# ``chunks={}`` loads the data with dask using the engine preferred
# chunk size, generally identical to the formats chunk size. If not
# available, a single chunk for all arrays; testing shows this is the
# "best guess" compromise for typically CMIP-like chunked data.
# see https://github.com/pydata/xarray/issues/10612 and
# https://github.com/pp-mo/ncdata/issues/139
time_coder = xr.coders.CFDatetimeCoder(use_cftime=True)
open_kwargs = {
"consolidated": False,
"decode_times": time_coder,
"engine": "zarr",
"chunks": {},
"backend_kwargs": backend_kwargs,
}
# Case 1: Zarr store is on remote object store
# file's URI will always be either http or https
if urlparse(str(file)).scheme in ["http", "https"]:
# basic test that opens the Zarr/.zmetadata file for Zarr2
# or Zarr/zarr.json for Zarr3
fs = fsspec.filesystem("http")
valid_zarr = True
try:
fs.open(str(file) + "/zarr.json", "rb") # Zarr3
except Exception: # noqa: BLE001
try:
fs.open(str(file) + "/.zmetadata", "rb") # Zarr2
except Exception: # noqa: BLE001
valid_zarr = False
# we don't want to catch any specific aiohttp/fsspec exception
# bottom line is that that file has issues, so raise
if not valid_zarr:
msg = (
f"File '{file}' can not be opened as Zarr file at the moment."
)
raise ValueError(msg)
open_kwargs["consolidated"] = True
zarr_xr = xr.open_dataset(file, **open_kwargs)
# Case 2: Zarr store is local to the file system
else:
zarr_xr = xr.open_dataset(file, **open_kwargs)
# avoid possible
# ValueError: Object has inconsistent chunks along dimension time.
# This can be fixed by calling unify_chunks().
# when trying to access the ``chunks`` store
zarr_xr = zarr_xr.unify_chunks()
return dataset_to_iris(zarr_xr, ignore_warnings=ignore_warnings)
[docs]
def save( # noqa: C901
cubes: Sequence[Cube],
filename: Path | str,
optimize_access: str = "",
compress: bool = False,
alias: str = "",
compute: bool = True,
**kwargs: Any,
) -> Delayed | None:
"""Save iris cubes to file.
Parameters
----------
cubes:
Data cubes to be saved
filename:
Name of target file
optimize_access:
Set internal NetCDF chunking to favour a reading scheme
Values can be map or timeseries, which improve performance when
reading the file one map or time series at a time.
Users can also provide a coordinate or a list of coordinates. In that
case the better performance will be avhieved by loading all the values
in that coordinate at a time
compress:
Use NetCDF internal compression.
alias:
Var name to use when saving instead of the one in the cube.
compute : bool, default=True
Default is ``True``, meaning complete the file immediately, and return
``None``.
When ``False``, create the output file but don't write any lazy array
content to its variables, such as lazy cube data or aux-coord points
and bounds. Instead return a :class:`dask.delayed.Delayed` which, when
computed, will stream all the lazy content via :meth:`dask.store`, to
complete the file. Several such data saves can be performed in
parallel, by passing a list of them into a :func:`dask.compute` call.
**kwargs:
See :func:`iris.fileformats.netcdf.saver.save` for additional
keyword arguments.
Returns
-------
:class:`dask.delayed.Delayed` or :obj:`None`
A delayed object that can be used to save the data in the cube.
Raises
------
ValueError
cubes is empty.
"""
if not cubes:
msg = f"Cannot save empty cubes '{cubes}'"
raise ValueError(msg)
if Path(filename).suffix.lower() == ".nc":
kwargs["compute"] = compute
# Rename some arguments
kwargs["target"] = filename
kwargs["zlib"] = compress
dirname = os.path.dirname(filename)
if not os.path.exists(dirname):
os.makedirs(dirname)
if os.path.exists(filename) and all(
cube.has_lazy_data() for cube in cubes
):
logger.debug(
"Not saving cubes %s to %s to avoid data loss. "
"The cube is probably unchanged.",
cubes,
filename,
)
return None
for cube in cubes:
logger.debug(
"Saving cube:\n%s\nwith %s data to %s",
cube,
"lazy" if cube.has_lazy_data() else "realized",
filename,
)
if optimize_access:
cube = cubes[0]
if optimize_access == "map":
dims = set(
cube.coord_dims("latitude") + cube.coord_dims("longitude"),
)
elif optimize_access == "timeseries":
dims = set(cube.coord_dims("time"))
else:
dims = {
dim
for coord_name in optimize_access.split(" ")
for dim in cube.coord_dims(coord_name)
}
kwargs["chunksizes"] = tuple(
length if index in dims else 1
for index, length in enumerate(cube.shape)
)
kwargs["fill_value"] = GLOBAL_FILL_VALUE
if alias:
for cube in cubes:
logger.debug(
"Changing var_name from %s to %s",
cube.var_name,
alias,
)
cube.var_name = alias
# Ignore some warnings when saving
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=(
".* is being added as CF data variable attribute, but .* "
"should only be a CF global attribute"
),
category=UserWarning,
module="iris",
)
return iris.save(cubes, **kwargs)
def _get_debug_filename(filename, step):
"""Get a filename for debugging the preprocessor."""
dirname = os.path.splitext(filename)[0]
if os.path.exists(dirname) and os.listdir(dirname):
num = int(sorted(os.listdir(dirname)).pop()[:2]) + 1
else:
num = 0
return os.path.join(dirname, f"{num:02}_{step}.nc")
def _sort_products(products):
"""Sort preprocessor output files by their order in the recipe."""
return sorted(
products,
key=lambda p: (
p.attributes.get("recipe_dataset_index", 1e6),
p.attributes.get("dataset", ""),
),
)
def write_metadata(products, write_ncl=False):
"""Write product metadata to file."""
output_files = []
for output_dir, prods in groupby(
products,
lambda p: os.path.dirname(p.filename),
):
sorted_products = _sort_products(prods)
metadata = {}
for product in sorted_products:
if isinstance(product.attributes.get("exp"), (list, tuple)):
product.attributes = dict(product.attributes)
product.attributes["exp"] = "-".join(product.attributes["exp"])
if "original_short_name" in product.attributes:
del product.attributes["original_short_name"]
metadata[product.filename] = product.attributes
output_filename = os.path.join(output_dir, "metadata.yml")
output_files.append(output_filename)
with open(output_filename, "w", encoding="utf-8") as file:
yaml.safe_dump(metadata, file)
if write_ncl:
output_files.append(_write_ncl_metadata(output_dir, metadata))
return output_files
def _write_ncl_metadata(output_dir, metadata):
"""Write NCL metadata files to output_dir."""
variables = [copy.deepcopy(v) for v in metadata.values()]
info = {"input_file_info": variables}
# Split input_file_info into dataset and variable properties
# dataset keys and keys with non-identical values will be stored
# in dataset_info, the rest in variable_info
variable_info = {}
info["variable_info"] = [variable_info]
info["dataset_info"] = []
for variable in variables:
dataset_info = {}
info["dataset_info"].append(dataset_info)
for key in variable:
dataset_specific = any(
variable[key] != var.get(key, object()) for var in variables
)
if (
dataset_specific or key in DATASET_KEYS
) and key not in VARIABLE_KEYS:
dataset_info[key] = variable[key]
else:
variable_info[key] = variable[key]
filename = os.path.join(
output_dir,
variable_info["short_name"] + "_info.ncl",
)
write_ncl_settings(info, filename)
return filename