Source code for iris._combine

# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.
"""Generalised mechanism for combining cubes into larger ones.

Integrates merge and concatenate with the cube-equalisation options and the promotion of
hybrid reference dimensions on loading.

This is effectively a generalised "combine cubes" operation, but it is not (yet)
publicly available.
"""

from __future__ import annotations

import contextlib
import threading
from typing import TYPE_CHECKING, Any, Dict, List

if TYPE_CHECKING:
    from iris.cube import Cube, CubeList


[docs] class CombineOptions(threading.local): """A control object for Iris loading and cube combination options. Both the iris loading functions and the "combine_cubes" utility apply a number of possible "cube combination" operations to a list of cubes, in a definite sequence, all of which tend to combine cubes into a smaller number of larger or higher-dimensional cubes. This object groups various control options for these behaviours, which apply to both the :func:`iris.util.combine_cubes` utility method and the core Iris loading functions "iris.load_xxx". The :class:`CombineOptions` class defines the allowed control options, while a global singleton object :data:`iris.COMBINE_POLICY` holds the current global default settings. The individual configurable options are : * ``equalise_cubes_kwargs`` = (dict or None) Specifies keywords for an :func:`iris.util.equalise_cubes` call, to be applied before any merge/concatenate step. If ``None``, or empty, no equalisation step is performed. * ``merge_concat_sequence`` = "m" / "c" / "cm" / "mc" Specifies whether to apply :meth:`~iris.cube.CubeList.merge`, or :meth:`~iris.cube.CubeList.concatenate` operations, or both, in either order. * ``merge_unique`` = True / False When True, any merge operation will error if its result contains multiple identical cubes. Otherwise (unique=False), that is a permitted result. .. Note:: By default, in a normal :meth:`~iris.cube.CubeList.merge` operation on a :class:`~iris.cube.CubeList`, ``unique`` defaults to ``True``. For loading operations, however, the default is ``unique=False``, as this produces the intended behaviour when loading with multiple constraints. * ``repeat_until_unchanged`` = True / False When enabled, the configured "combine" operation will be repeated until the result is stable (no more cubes are combined). * ``support_multiple_references`` = True / False When enabled, support cases where a hybrid coordinate has multiple reference fields : for example, a UM file which contains a series of fields describing a time-varying orography. Alternatively, certain fixed combinations of options can be selected by a "settings" name, one of :data:`CombineOptions.SETTINGS_NAMES` : * ``"legacy"`` Apply a plain merge step only, i.e. ``merge_concat_sequence="m"``. Other options are all "off". This produces loading behaviour identical to Iris versions < 3.11, i.e. before the varying hybrid references were supported. * ``"default"`` As "legacy" except that ``support_multiple_references=True``. This differs from "legacy" only when multiple mergeable reference fields are encountered, in which case incoming cubes are extended into the extra dimension, and a concatenate step is added. Since the handling of multiple references affects only loading operations, for the purposes of calls to :func:`~iris.util.combine_cubes`, this setting is *identical* to "legacy". .. Warning:: The ``"default"`` setting **is** the initial default mode. This "fixes" loading for cases like the time-varying orography case described. However, this setting is not strictly backwards-compatible. If this causes problems, you can force identical loading behaviour to earlier Iris versions (< v3.11) with ``COMBINE_POLICY.set("legacy")`` or equivalent. * ``"recommended"`` In addition to the "merge" step, allow a following "concatenate", i.e. ``merge_concat_sequence="mc"``. * ``"comprehensive"`` As for "recommended", uses ``merge_concat_sequence="mc"``, but now also *repeats* the merge+concatenate steps until no further change is produced, i.e. ``repeat_until_unchanged=True``. Also applies a prior 'equalise_cubes' call, of the form ``equalise_cubes(cubes, apply_all=True)``. .. Note:: The "comprehensive" policy makes a maximum effort to reduce the number of cubes to a minimum. However, it still cannot combine cubes with a mixture of matching dimension and scalar coordinates. This may be supported at some later date, but for now is not possible without specific user actions. .. testsetup:: from iris import COMBINE_POLICY loadpolicy_old_settings = COMBINE_POLICY.settings() .. testcleanup:: # restore original settings, so as not to upset other tests COMBINE_POLICY.set(loadpolicy_old_settings) Examples -------- Note: :data:`COMBINE_POLICY` is the global control object, which determines the current default options for loading or :func:`iris.util.combine_cubes` calls. For the latter case, however, control via argument and keywords is also available. .. Note:: The ``iris.COMBINE_POLICY`` can be adjusted by either: 1. calling ``iris.COMBINE_POLICY.set(<something>)``, or 2. using ``with COMBINE_POLICY.context(<something>): ...``, or 3. assigning a property ``COMBINE_POLICY.<option> = <value>``, such as ``COMBINE_POLICY.merge_concat_sequence="cm"`` What you should **not** ever do is to assign :data:`iris.COMBINE_POLICY` itself, e.g. ``iris.COMBINE_POLICY = CombineOptions("legacy")``, since in that case the original object still exists, and is still the one in control of load/combine operations. Here, the correct approach would be ``iris.COMBINE_POLICY.set("legacy")``. >>> COMBINE_POLICY.set("legacy") >>> print(COMBINE_POLICY) CombineOptions(equalise_cubes_kwargs=None, merge_concat_sequence='m', merge_unique=False, repeat_until_unchanged=False, support_multiple_references=False) >>> >>> COMBINE_POLICY.support_multiple_references = True >>> print(COMBINE_POLICY) CombineOptions(equalise_cubes_kwargs=None, merge_concat_sequence='m', merge_unique=False, repeat_until_unchanged=False, support_multiple_references=True) >>> COMBINE_POLICY.set(merge_concat_sequence="cm") >>> print(COMBINE_POLICY) CombineOptions(equalise_cubes_kwargs=None, merge_concat_sequence='cm', merge_unique=False, repeat_until_unchanged=False, support_multiple_references=True) >>> with COMBINE_POLICY.context("comprehensive"): ... print(COMBINE_POLICY) CombineOptions(equalise_cubes_kwargs={'apply_all': True}, merge_concat_sequence='mc', merge_unique=False, repeat_until_unchanged=True, support_multiple_references=True) >>> >>> print(COMBINE_POLICY) CombineOptions(equalise_cubes_kwargs=None, merge_concat_sequence='cm', merge_unique=False, repeat_until_unchanged=False, support_multiple_references=True) .. Note:: The name ``iris.LOAD_POLICY`` refers to the same thing as ``iris.COMBINE_POLICY``, and is still usable, but is no longer recommended. """ # Useful constants #: Valid option names OPTION_KEYS = [ "equalise_cubes_kwargs", # N.B. gets special treatment in options checking "merge_concat_sequence", "merge_unique", "repeat_until_unchanged", "support_multiple_references", ] # this is a list, so we can update it in an inheriting class _OPTIONS_ALLOWED_VALUES = { "merge_concat_sequence": ("", "m", "c", "mc", "cm"), "merge_unique": (True, False), "repeat_until_unchanged": (False, True), "support_multiple_references": (True, False), } #: Standard settings dictionaries SETTINGS: Dict[str, Dict[str, Any]] = { "legacy": dict( equalise_cubes_kwargs=None, merge_concat_sequence="m", merge_unique=False, repeat_until_unchanged=False, support_multiple_references=False, ), "default": dict( equalise_cubes_kwargs=None, merge_concat_sequence="m", merge_unique=False, repeat_until_unchanged=False, support_multiple_references=True, ), "recommended": dict( equalise_cubes_kwargs=None, merge_concat_sequence="mc", merge_unique=False, repeat_until_unchanged=False, support_multiple_references=True, ), "comprehensive": dict( equalise_cubes_kwargs={"apply_all": True}, merge_concat_sequence="mc", merge_unique=False, repeat_until_unchanged=True, support_multiple_references=True, ), } #: Valid settings names SETTINGS_NAMES = list(SETTINGS.keys()) def __init__(self, options: str | dict | None = None, **kwargs): """Create loading strategy control object.""" self.set("default") self.set(options, **kwargs) def __setattr__(self, key, value): if key not in self.OPTION_KEYS: raise KeyError(f"CombineOptions object has no property '{key}'.") if key != "equalise_cubes_kwargs": allowed_values = self._OPTIONS_ALLOWED_VALUES[key] if value not in allowed_values: msg = ( f"{value!r} is not a valid setting for LoadPolicy.{key} : " f"must be one of '{allowed_values}'." ) raise ValueError(msg) self.__dict__[key] = value
[docs] def set(self, options: str | dict | None = None, **kwargs): """Set new options. Parameters ---------- * options : str or dict, optional A dictionary of options values, or one of the :data:`~iris.LoadPolicy.SETTINGS_NAMES` standard settings names, e.g. "legacy" or "comprehensive". * kwargs : dict Individual option settings, from :data:`~iris.LoadPolicy.OPTION_KEYS`. Note ---- Keyword arguments are applied after the 'options' arg, and so will take precedence. """ if options is None: options_dict: dict = {} elif isinstance(options, str): if options in self.SETTINGS: options_dict = self.SETTINGS[options] else: msg = ( f"arg 'options'={options!r}, which is not a valid settings name, " f"expected one of {self.SETTINGS_NAMES}." ) raise ValueError(msg) elif isinstance(options, dict): options_dict = options # Override any options with keywords options_dict = options_dict.copy() # don't modify original options_dict.update(**kwargs) bad_keys = [key for key in options_dict if key not in self.OPTION_KEYS] if bad_keys: msg = f"Unknown options {bad_keys} : valid options are {self.OPTION_KEYS}." raise ValueError(msg) # Implement all options by changing own content. for key, value in options_dict.items(): setattr(self, key, value)
[docs] @contextlib.contextmanager def context(self, settings: str | dict | None = None, **kwargs): """Return a context manager applying given options changes during a scope. Parameters ---------- settings : str or dict, optional A settings name or options dictionary, as for :meth:`~LoadPolicy.set`. kwargs : dict Option values, as for :meth:`~LoadPolicy.set`. Examples -------- .. testsetup:: import iris from iris import COMBINE_POLICY, sample_data_path >>> # Show how a CombineOptions acts in the context of a load operation >>> path = sample_data_path("time_varying_hybrid_height", "*.pp") >>> >>> # Show that "legacy" load behaviour allows merge but not concatenate >>> with COMBINE_POLICY.context("legacy"): ... cubes = iris.load(path, "x_wind") >>> print(cubes) 0: x_wind / (m s-1) (time: 2; model_level_number: 5; latitude: 144; longitude: 192) 1: x_wind / (m s-1) (time: 12; model_level_number: 5; latitude: 144; longitude: 192) 2: x_wind / (m s-1) (model_level_number: 5; latitude: 144; longitude: 192) >>> >>> # Show how "recommended" behaviour enables concatenation also >>> with COMBINE_POLICY.context("recommended"): ... cubes = iris.load(path, "x_wind") >>> print(cubes) 0: x_wind / (m s-1) (model_level_number: 5; time: 15; latitude: 144; longitude: 192) """ # Save the current state saved_settings = self.settings() # Apply the new options and execute the context try: self.set(settings, **kwargs) yield finally: # Re-establish the former state self.set(saved_settings)
[docs] def settings(self) -> dict: """Return a settings dict containing the current options settings.""" return {key: getattr(self, key) for key in self.OPTION_KEYS}
def __repr__(self): msg = f"{self.__class__.__name__}(" msg += ", ".join(f"{key}={getattr(self, key)!r}" for key in self.OPTION_KEYS) msg += ")" return msg
def _combine_cubes(cubes: List[Cube], options: dict) -> CubeList: """Combine cubes as for load, according to "loading policy" options. This is the 'inner' implementation called by :func:`iris.util.combine_cubes`. Details of the operation and args are described there. It is also called by :func:`_combine_load_cubes`, which implements the ``support_multiple_references`` action within loading operations. Parameters ---------- cubes : list of :class:`~iris.cube.Cube` A list of cubes to combine. options : dict Dictionary of settings options, as described for :class:`iris.CombineOptions`. Returns ------- :class:`~iris.cube.CubeList` """ from iris.cube import CubeList if isinstance(cubes, CubeList): cubelist = cubes else: cubelist = CubeList(cubes) eq_args = options.get("equalise_cubes_kwargs", None) if eq_args: # Skip missing (or empty) arg, as no effect : see `equalise_cubes`. from iris.util import equalise_cubes equalise_cubes(cubelist, **eq_args) sequence = options["merge_concat_sequence"] merge_unique = options.get("merge_unique", False) while True: n_original_cubes = len(cubelist) if sequence[0] == "c": # concat if it comes first cubelist = cubelist.concatenate() if "m" in sequence: # merge if requested. # NOTE: the 'unique' arg is configurable in the combine options. # All CombineOptions settings have "unique=False", as that is needed for # "iris.load_xxx()" functions to work correctly. However, the default # for CubeList.merge() is "unique=True". cubelist = cubelist.merge(unique=merge_unique) if sequence[-1] == "c": # concat if it comes last cubelist = cubelist.concatenate() # Repeat if requested, *and* this step reduced the number of cubes if not options["repeat_until_unchanged"] or len(cubelist) >= n_original_cubes: break return cubelist def _combine_load_cubes(cubes: List[Cube]) -> CubeList: # A special version to call _combine_cubes while also implementing the # _MULTIREF_DETECTION behaviour from iris import COMBINE_POLICY options = COMBINE_POLICY.settings() if ( options["support_multiple_references"] and "c" not in options["merge_concat_sequence"] ): # Add a concatenate to implement the "multiref triggers concatenate" mechanism from iris.fileformats.rules import _MULTIREF_DETECTION if _MULTIREF_DETECTION.found_multiple_refs: options["merge_concat_sequence"] += "c" return _combine_cubes(cubes, options) #: An object to control default cube combination and loading options COMBINE_POLICY = CombineOptions()