"""Module containing :func:`esmvalcore.preprocessor.concatenate`."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, NamedTuple, Self
import cftime
import iris.exceptions
import numpy as np
from iris.cube import CubeList
from esmvalcore.cmor.check import CheckLevels
from esmvalcore.io.esgf.facets import FACETS
from esmvalcore.iris_helpers import merge_cube_attributes
from esmvalcore.preprocessor._shared import _rechunk_aux_factory_dependencies
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from iris.coords import Coord, DimCoord
from iris.cube import Cube
logger = logging.getLogger(__name__)
def _delete_attributes(iris_object: Cube | Coord, atts: Iterable[str]) -> None:
"""Delete attributes from Iris cube or coordinate."""
for att in atts:
if att in iris_object.attributes:
del iris_object.attributes[att]
def _concatenate_cubes(
cubes: Iterable[Cube],
check_level: CheckLevels,
) -> CubeList:
"""Concatenate cubes according to the check_level."""
kwargs = {
"check_aux_coords": True,
"check_cell_measures": True,
"check_ancils": True,
"check_derived_coords": True,
}
if check_level > CheckLevels.DEFAULT:
kwargs = dict.fromkeys(kwargs, False)
logger.debug(
"Concatenation will be performed without checking "
"auxiliary coordinates, cell measures, ancillaries "
"and derived coordinates present in the cubes.",
)
return CubeList(cubes).concatenate(**kwargs)
class _TimesHelper:
def __init__(self, time: DimCoord) -> None:
self.times = time.core_points()
self.units = str(time.units)
def __getattr__(self, name: str) -> Any: # noqa: ANN401
return getattr(self.times, name)
def __len__(self) -> int:
return len(self.times)
def __getitem__(self, key: Any) -> Any: # noqa: ANN401
return self.times[key]
def _remove_time_overlaps(cubes: CubeList) -> CubeList:
"""Handle time overlaps.
Parameters
----------
cubes : iris.cube.CubeList
A list of cubes belonging to a single timeseries,
ordered by starting point with possible overlaps.
Returns
-------
iris.cube.CubeList
A list of cubes belonging to a single timeseries,
ordered by starting point with no overlaps.
"""
if len(cubes) < 2:
return cubes
class _TrackedCube(NamedTuple):
cube: Cube
times: iris.coords.DimCoord
start: float
end: float
@classmethod
def from_cube(cls, cube: Cube) -> Self:
"""Construct tracked cube."""
times = cube.coord("time")
start, end = times.core_points()[[0, -1]]
return cls(cube, times, start, end)
new_cubes = CubeList()
current_cube = _TrackedCube.from_cube(cubes[0])
for new_cube in map(_TrackedCube.from_cube, cubes[1:]):
if new_cube.start > current_cube.end:
# no overlap, use current cube and start again from new cube
logger.debug("Using %s", current_cube.cube)
new_cubes.append(current_cube.cube)
current_cube = new_cube
continue
# overlap
if current_cube.end > new_cube.end:
# current cube ends after new one, just forget new cube
logger.debug(
"Discarding %s because the time range "
"is already covered by %s",
new_cube.cube,
current_cube.cube,
)
continue
if new_cube.start == current_cube.start:
# new cube completely covers current one
# forget current cube
current_cube = new_cube
logger.debug(
"Discarding %s because the time range is covered by %s",
current_cube.cube,
new_cube.cube,
)
continue
# new cube ends after current one,
# use all of new cube, and shorten current cube to
# eliminate overlap with new cube
cut_index = (
cftime.time2index(
new_cube.start,
_TimesHelper(current_cube.times),
current_cube.times.units.calendar,
select="before",
)
+ 1
)
logger.debug(
"Using %s shortened to %s due to overlap",
current_cube.cube,
current_cube.times.cell(cut_index).point,
)
new_cubes.append(current_cube.cube[:cut_index])
current_cube = new_cube
logger.debug("Using %s", current_cube.cube)
new_cubes.append(current_cube.cube)
return new_cubes
def _fix_calendars(cubes: Sequence[Cube]) -> None:
"""Check and homogenise calendars, if possible."""
calendars = [cube.coord("time").units.calendar for cube in cubes]
unique_calendars = np.unique(calendars)
calendar_ocurrences = np.array(
[calendars.count(calendar) for calendar in unique_calendars],
)
calendar_index = int(
np.argwhere(calendar_ocurrences == calendar_ocurrences.max()).ravel()[
0
],
)
for cube in cubes:
time_coord = cube.coord("time")
old_calendar = time_coord.units.calendar
if old_calendar != unique_calendars[calendar_index]:
new_unit = time_coord.units.change_calendar(
unique_calendars[calendar_index],
)
time_coord.units = new_unit
def _raise_concatenation_exception(cubes: Sequence[Cube]) -> None:
"""Raise an error for concatenation."""
# Concatenation not successful -> retrieve exact error message
try:
CubeList(cubes).concatenate_cube()
except iris.exceptions.ConcatenateError as exc:
msg = str(exc)
logger.error("Can not concatenate cubes into a single one: %s", msg)
logger.error("Resulting cubes:")
for cube in cubes:
logger.error(cube)
time = cube.coord("time")
logger.error("From %s to %s", time.cell(0), time.cell(-1))
msg = f"Can not concatenate cubes: {msg}"
raise ValueError(msg) from exc
def _sort_cubes_by_time(cubes: Iterable[Cube]) -> list[Cube]:
"""Sort CubeList by time coordinate."""
try:
cubes = sorted(cubes, key=lambda c: c.coord("time").cell(0).point)
except iris.exceptions.CoordinateNotFoundError as exc:
msg = f"One or more cubes {cubes} are missing time coordinate: {exc!s}"
raise ValueError(msg) from exc
except TypeError as error:
msg = f"Cubes cannot be sorted due to differing time units: {error!s}"
raise TypeError(msg) from error
return cubes
def _concatenate_cubes_by_experiment(cubes: Sequence[Cube]) -> Sequence[Cube]:
"""Concatenate cubes by experiment.
This ensures overlapping (branching) experiments are handled correctly.
"""
# get the possible facet names in CMIP3, 5, 6 for exp
# currently these are 'experiment', 'experiment_id'
exp_facet_names = {
project["exp"] for project in FACETS.values() if "exp" in project
}
def get_exp(cube: Cube) -> str:
for key in exp_facet_names:
if key in cube.attributes:
return cube.attributes[key]
return ""
experiments = {get_exp(cube) for cube in cubes}
if len(experiments) > 1:
# first do experiment-wise concatenation, then time-based
cubes = [
concatenate([cube for cube in cubes if get_exp(cube) == exp])
for exp in experiments
]
return cubes
[docs]
def concatenate(
cubes: Sequence[Cube],
check_level: CheckLevels = CheckLevels.DEFAULT,
) -> Cube:
"""Concatenate all cubes after fixing metadata.
Parameters
----------
cubes: iterable of iris.cube.Cube
Data cubes to be concatenated
check_level: CheckLevels
Level of strictness of the checks in the concatenation.
Returns
-------
cube: iris.cube.Cube
Resulting concatenated cube.
Raises
------
ValueError
Concatenation was not possible.
"""
if not cubes:
return cubes
if len(cubes) == 1:
return cubes[0]
for cube in cubes:
# Remove attributes that cause issues with merging and concatenation
_delete_attributes(
cube,
("creation_date", "tracking_id", "history", "comment"),
)
for coord in cube.coords():
# CMOR sometimes adds a history to the coordinates.
_delete_attributes(coord, ("history",))
cubes = _concatenate_cubes_by_experiment(cubes)
merge_cube_attributes(cubes)
cubes = _sort_cubes_by_time(cubes)
_fix_calendars(cubes)
cubes = _remove_time_overlaps(cubes)
cubes = [_rechunk_aux_factory_dependencies(cube) for cube in cubes]
result = _concatenate_cubes(cubes, check_level=check_level)
if len(result) == 1:
result = result[0]
else:
_raise_concatenation_exception(result)
return result