Repository: SciTools/iris
Pull request #6334: Combine cubes 2
Author: login: pp-mo
Created: 2025-02-24T11:58:08Z | Merged: 2025-03-07T17:07:58Z

Rework replaces #6331, addresses #6203


Diff:
diff --git a/docs/src/userguide/loading_iris_cubes.rst b/docs/src/userguide/loading_iris_cubes.rst
index b71f033c30..cbba4da39f 100644
--- a/docs/src/userguide/loading_iris_cubes.rst
+++ b/docs/src/userguide/loading_iris_cubes.rst
@@ -15,6 +15,13 @@ Iris will attempt to return **as few cubes as possible**
 by collecting together multiple fields with a shared standard name
 into a single multidimensional cube.
 
+.. hint::
+
+    There are details at :class:`iris.CombineOptions` on how Iris works to load
+    fewer and larger cubes :  The :data:`iris.COMBINE_POLICY` object allows the user to
+    control how cubes are combined during the loading process.  See the documentation
+    of the :class:`iris.CombineOptions` class for details.
+
 The :py:func:`iris.load` function automatically recognises the format
 of the given files and attempts to produce Iris Cubes from their contents.
 
diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst
index 07915ad040..b243796082 100644
--- a/docs/src/whatsnew/latest.rst
+++ b/docs/src/whatsnew/latest.rst
@@ -30,6 +30,26 @@ This document explains the changes made to Iris for this release
 ✨ Features
 ===========
 
+#. `@pp-mo`_ renamed the :class:`iris.LoadPolicy` as :class:`iris.CombineOptions` and
+   :data:`iris.LOAD_POLICY` as :data:`iris.COMBINE_POLICY`, though the original names
+   remain functional (and refer to the same things) for now.
+   (:issue:`6203`, :pull:`6334`)
+
+#. `@pp-mo`_ added new :meth:`~iris.cube.CubeList.combine` and
+   :meth:`~iris.cube.CubeList.combine_cube` methods of a :class:`~iris.cube.CubeList`
+   as an alternative way of accessing the :func:`~iris.util.combine_cubes` mechanism.
+   (:issue:`6203`, :pull:`6334`)
+
+#. `@pp-mo`_ added a new utility function :func:`~iris.util.combine_cubes`, to give
+   general public access to the combine merge/concatenate mechanism introduced for
+   generalised loading support via :class:`iris.LoadPolicy` in the Iris 3.11 release.
+   (:issue:`6203`, :pull:`6334`)
+
+#. `@pp-mo`_ overhauled the :class:`iris.LoadPolicy` facility by adding a new
+   ``equalise_cubes_kwarg`` keyword, enabling it to call the
+   :func:`~iris.util.equalise_cubes` utility function as one of its processing stages.
+   (:issue:`6203`, :pull:`6334`)
+
 #. `@pp-mo`_ added a new utility function :func:`~iris.util.equalise_cubes`, to help
    with aligning cubes so they can merge / concatenate.
    (:issue:`6248`, :pull:`6257`)
@@ -48,11 +68,11 @@ This document explains the changes made to Iris for this release
    However, :meth:`~iris.cube.Cube.transpose` will work, as will
    :meth:`~iris.cube.Cube.copy`. Note that, ``cube.copy(data=iris.DATALESS)``
    will provide a dataless copy of a cube. (:issue:`4447`, :pull:`6253`)
-   
+
 #. `@ESadek-MO`_ added the :mod:`iris.quickplot` ``footer`` kwarg to
    render text in the bottom right of the plot figure.
    (:issue:`6247`, :pull:`6332`)
-   
+
 
 🐛 Bugs Fixed
 =============
diff --git a/lib/iris/__init__.py b/lib/iris/__init__.py
index d622bd18b0..d141dbdb5f 100644
--- a/lib/iris/__init__.py
+++ b/lib/iris/__init__.py
@@ -18,6 +18,12 @@
 :class:`Cubes <iris.cube.Cube>`, and combine those cubes into
 higher-dimensional cubes where possible.
 
+.. note::
+
+    User control of the 'combine' process is provided via a specific
+    :class:`iris.CombineOptions` object called :data:`iris.COMBINE_POLICY`.
+    See the :class:`iris.CombineOptions` class for details.
+
 The :func:`load_cube` and :func:`load_cubes` functions are similar to
 :func:`load`, but they raise an exception if the number of cubes is not
 what was expected. They are more useful in scripts, where they can
@@ -94,14 +100,13 @@ def callback(cube, field, filename):
 import threading
 from typing import Callable, Literal
 
+from iris._combine import COMBINE_POLICY as _COMBINE_POLICY
+from iris._combine import CombineOptions
 import iris._constraints
 import iris.config
 import iris.io
 from iris.io import save
-from iris.loading import LOAD_POLICY as _LOAD_POLICY
 from iris.loading import (
-    CombineOptions,
-    LoadPolicy,
     load,
     load_cube,
     load_cubes,
@@ -111,8 +116,14 @@ def callback(cube, field, filename):
 # NOTE: we make an independent local 'LOAD_POLICY' definition here, just so that we
 # can ensure an entry for it in our API documentation page.
 
-#: A control object containing the current file loading strategy options.
-LOAD_POLICY = _LOAD_POLICY
+#: An object to control default cube combination and loading options
+COMBINE_POLICY = _COMBINE_POLICY
+
+#: An alias for the :class:`~iris._combine.CombineOptions` class.
+LoadPolicy = CombineOptions
+
+#: An alias for the :data:`~iris.COMBINE_POLICY` object.
+LOAD_POLICY = _COMBINE_POLICY
 
 
 from ._deprecation import IrisDeprecation, warn_deprecated
@@ -132,6 +143,7 @@ def callback(cube, field, filename):
 # Restrict the names imported when using "from iris import *"
 __all__ = [
     "AttributeConstraint",
+    "COMBINE_POLICY",
     "CombineOptions",
     "Constraint",
     "DATALESS",
diff --git a/lib/iris/_combine.py b/lib/iris/_combine.py
index 7b01dfc87e..5afffe7309 100644
--- a/lib/iris/_combine.py
+++ b/lib/iris/_combine.py
@@ -11,124 +11,214 @@
 publicly available.
 """
 
+from __future__ import annotations
+
 import contextlib
 import threading
-from typing import Mapping
-
+from typing import TYPE_CHECKING, Any, Dict, List
 
-class CombineOptions(threading.local):
-    """A container for cube combination options.
+if TYPE_CHECKING:
+    from iris.cube import Cube, CubeList
 
-    Controls for generalised merge/concatenate options.
 
-    Also controls the detection and handling of cases where a hybrid coordinate
-    uses multiple reference fields during loading : for example, a UM file which
-    contains a series of fields describing time-varying orography.
+class CombineOptions(threading.local):
+    """A control object for Iris loading and cube combination options.
 
-    Options can be set directly, or via :meth:`~iris.LoadPolicy.set`, or changed for
-    the scope of a code block with :meth:`~iris.LoadPolicy.context`.
+    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.
 
-    .. note ::
+    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 default behaviour will "fix" loading for cases like the time-varying
-        orography case described above.  However, this is not strictly
-        backwards-compatible.  If this causes problems, you can force identical loading
-        behaviour to earlier Iris versions with ``LOAD_POLICY.set("legacy")`` or
-        equivalent.
+    The :class:`CombineOptions` class defines the allowed control options, while a
+    global singleton object :data:`iris.COMBINE_POLICY` holds the current global
+    default settings.
 
-    .. testsetup::
+    The individual configurable options are :
 
-        from iris import LOAD_POLICY
+    * ``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.
 
-    Notes
-    -----
-    The individual configurable options are :
+    * ``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.
 
-    * ``support_multiple_references`` = True / False
-        When enabled, the presence of multiple aux-factory reference cubes, which merge
-        to define a extra dimension, will add that dimension to the loaded cubes.
-        This is essential for correct support of time-dependent hybrid coordinates (i.e.
-        aux factories) when loading from fields-based data (e.g. PP or GRIB).
-        For example (notably) time-dependent orography in UM data on hybrid-heights.
+    * ``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.
 
-        In addition, when such multiple references are detected, an extra concatenate
-        step is added to the 'merge_concat_sequence' (see below), if none is already
-        configured there.
+        .. Note::
 
-    * ``merge_concat_sequence`` = "m" / "c" / "cm" / "mc"
-        Specifies whether to merge, or concatenate, or both in either order.
-        This is the "combine" operation which is applied to loaded data.
+            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).
 
-    Several common sets of options are provided in :data:`~iris.LOAD_POLICY.SETTINGS` :
+    * ``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"``
-        Produces loading behaviour identical to Iris versions < 3.11, i.e. before the
-        varying hybrid references were supported.
+        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"``
-        Enables multiple reference handling, *and* applies a merge step followed by
-        a concatenate step.
+        In addition to the "merge" step, allow a following "concatenate", i.e.
+        ``merge_concat_sequence="mc"``.
 
     * ``"comprehensive"``
-        Like "recommended", but will also *repeat* the merge+concatenate steps until no
-        further change is produced.
+        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 ::
+        .. Note::
 
-            The 'comprehensive' policy makes a maximum effort to reduce the number of
+            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.
 
-    .. Note ::
+    .. testsetup::
 
-        See also : :ref:`controlling_merge`.
+        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
-    OPTION_KEYS = (
-        "support_multiple_references",
+    #: 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 = {
-        "support_multiple_references": (False, True),
         "merge_concat_sequence": ("", "m", "c", "mc", "cm"),
+        "merge_unique": (True, False),
         "repeat_until_unchanged": (False, True),
+        "support_multiple_references": (True, False),
     }
-    SETTINGS = {
+    #: Standard settings dictionaries
+    SETTINGS: Dict[str, Dict[str, Any]] = {
         "legacy": dict(
-            support_multiple_references=False,
+            equalise_cubes_kwargs=None,
             merge_concat_sequence="m",
+            merge_unique=False,
             repeat_until_unchanged=False,
+            support_multiple_references=False,
         ),
         "default": dict(
-            support_multiple_references=True,
+            equalise_cubes_kwargs=None,
             merge_concat_sequence="m",
+            merge_unique=False,
             repeat_until_unchanged=False,
+            support_multiple_references=True,
         ),
         "recommended": dict(
-            support_multiple_references=True,
+            equalise_cubes_kwargs=None,
             merge_concat_sequence="mc",
+            merge_unique=False,
             repeat_until_unchanged=False,
+            support_multiple_references=True,
         ),
         "comprehensive": dict(
-            support_multiple_references=True,
+            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."""
@@ -137,15 +227,16 @@ def __init__(self, options: str | dict | None = None, **kwargs):
 
     def __setattr__(self, key, value):
         if key not in self.OPTION_KEYS:
-            raise KeyError(f"LoadPolicy object has no property '{key}'.")
-
-        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)
+            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
 
@@ -155,8 +246,8 @@ def set(self, options: str | dict | None = None, **kwargs):
         Parameters
         ----------
         * options : str or dict, optional
-            A dictionary of options values, or the name of one of the
-            :data:`~iris.LoadPolicy.SETTINGS` standard option sets,
+            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`.
@@ -168,45 +259,39 @@ def set(self, options: str | dict | None = None, **kwargs):
 
         """
         if options is None:
-            options = {}
-        elif isinstance(options, str) and options in self.SETTINGS:
-            options = self.SETTINGS[options]
-        elif not isinstance(options, Mapping):
-            msg = (
-                f"Invalid arg options={options!r} : "
-                f"must be a dict, or one of {tuple(self.SETTINGS.keys())}"
-            )
-            raise TypeError(msg)
+            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.update(**kwargs)
-        bad_keys = [key for key in options if key not in self.OPTION_KEYS]
+        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.items():
+        for key, value in options_dict.items():
             setattr(self, key, value)
 
-    def settings(self):
-        """Return an options dict containing the current 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
-
     @contextlib.contextmanager
-    def context(self, settings=None, **kwargs):
-        """Return a context manager applying given options.
+    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
-            Options dictionary or name, as for :meth:`~LoadPolicy.set`.
+        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`.
 
@@ -215,21 +300,21 @@ def context(self, settings=None, **kwargs):
         .. testsetup::
 
             import iris
-            from iris import LOAD_POLICY, sample_data_path
+            from iris import COMBINE_POLICY, sample_data_path
 
         >>> # Show how a CombineOptions acts in the context of a load operation
-        >>> # (N.B. the LOAD_POLICY actually *is* a CombineOptions type object)
         >>> path = sample_data_path("time_varying_hybrid_height", "*.pp")
-        >>> # "legacy" load behaviour allows merge but not concatenate
-        >>> with LOAD_POLICY.context("legacy"):
+        >>>
+        >>> # 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)
         >>>
-        >>> # "recommended" behaviour enables concatenation
-        >>> with LOAD_POLICY.context("recommended"):
+        >>> # 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)
@@ -245,69 +330,84 @@ def context(self, settings=None, **kwargs):
             # Re-establish the former state
             self.set(saved_settings)
 
+    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 _combine_cubes(cubes, options, merge_require_unique):
+    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.
 
-    Applies :meth:`~iris.cube.CubeList.merge`/:meth:`~iris.cube.CubeList.concatenate`
-    steps to the given cubes, as determined by the 'settings'.
+    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
-        Settings, as described for :class:`iris.CombineOptions`.
-    merge_require_unique : bool
-        Value for the 'unique' keyword in any merge operations.
+        Dictionary of settings options, as described for :class:`iris.CombineOptions`.
 
     Returns
     -------
     :class:`~iris.cube.CubeList`
 
-    .. Note::
-        The ``support_multiple_references`` keyword/property has no effect on the
-        :func:`_combine_cubes` operation : it only takes effect during a load operation.
-
-    Notes
-    -----
-    TODO: make this public API in future.
-    At that point, change the API to support (options=None, **kwargs) + add testing of
-    those modes (notably arg type = None / str / dict).
-
     """
     from iris.cube import CubeList
 
-    if not isinstance(cubes, CubeList):
-        cubes = CubeList(cubes)
+    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(cubes)
-        sequence = options["merge_concat_sequence"]
+        n_original_cubes = len(cubelist)
 
         if sequence[0] == "c":
             # concat if it comes first
-            cubes = cubes.concatenate()
+            cubelist = cubelist.concatenate()
         if "m" in sequence:
-            # merge if requested
-            cubes = cubes.merge(unique=merge_require_unique)
+            # 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
-            cubes = cubes.concatenate()
+            cubelist = cubelist.concatenate()
 
         # Repeat if requested, *and* this step reduced the number of cubes
-        if not options["repeat_until_unchanged"] or len(cubes) >= n_original_cubes:
+        if not options["repeat_until_unchanged"] or len(cubelist) >= n_original_cubes:
             break
 
-    return cubes
+    return cubelist
 
 
-def _combine_load_cubes(cubes, merge_require_unique=False):
+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 LOAD_POLICY
+    from iris import COMBINE_POLICY
 
-    options = LOAD_POLICY.settings()
+    options = COMBINE_POLICY.settings()
     if (
         options["support_multiple_references"]
         and "c" not in options["merge_concat_sequence"]
@@ -318,4 +418,8 @@ def _combine_load_cubes(cubes, merge_require_unique=False):
         if _MULTIREF_DETECTION.found_multiple_refs:
             options["merge_concat_sequence"] += "c"
 
-    return _combine_cubes(cubes, options, merge_require_unique=merge_require_unique)
+    return _combine_cubes(cubes, options)
+
+
+#: An object to control default cube combination and loading options
+COMBINE_POLICY = CombineOptions()
diff --git a/lib/iris/cube.py b/lib/iris/cube.py
index eae73552eb..77191c3a9a 100644
--- a/lib/iris/cube.py
+++ b/lib/iris/cube.py
@@ -637,6 +637,66 @@ def concatenate(
             check_derived_coords=check_derived_coords,
         )
 
+    def combine(self, options: str | dict | None = None, **kwargs) -> CubeList:
+        """Combine cubes, as with :func:`iris.util.combine_cubes`.
+
+        Parameters
+        ----------
+        options : str or dict, optional
+            Either a standard "combine settings" name, i.e. one of the
+            :data:`iris.CombineOptions.SETTINGS_NAMES`, or a dictionary of
+            settings options, as described for :class:`~iris.CombineOptions`.
+            Defaults to the current :meth:`~iris.CombineOptions.settings` of the
+            :data:`iris.COMBINE_POLICY`.
+
+        kwargs : dict
+            Individual option setting values, i.e. values for keys named in
+            :data:`iris.CombineOptions.OPTION_KEYS`, as described for
+            :meth:`~iris.CombineOptions.set`.
+            These take precedence over those set by the `options` arg.
+
+        Returns
+        -------
+        :class:`CubeList`
+
+        """
+        from iris.util import combine_cubes
+
+        return combine_cubes(self, options, **kwargs)
+
+    def combine_cube(self, options: str | dict | None = None, **kwargs) -> CubeList:
+        """Combine to a single cube, with :func:`iris.util.combine_cubes`.
+
+        As :meth:`combine`, but raises a ValueError if the result is not a single cube.
+
+        Parameters
+        ----------
+        options : str or dict, optional
+            Either a standard "combine settings" name, i.e. one of the
+            :data:`iris.CombineOptions.SETTINGS_NAMES`, or a dictionary of
+            settings options, as described for :class:`~iris.CombineOptions`.
+            Defaults to the current :meth:`~iris.CombineOptions.settings` of the
+            :data:`iris.COMBINE_POLICY`.
+
+        kwargs : dict
+            Individual option setting values, i.e. values for keys named in
+            :data:`iris.CombineOptions.OPTION_KEYS`, as described for
+            :meth:`~iris.CombineOptions.set`.
+            These take precedence over those set by the `options` arg.
+
+        Returns
+        -------
+        :class:`Cube`
+
+        """
+        result = self.combine(options, **kwargs)
+        n_cubes = len(result)
+        if n_cubes != 1:
+            msg = f"'combine' operation yielded {n_cubes} cubes, expected exactly 1."
+            raise ValueError(msg)
+
+        return result[0]
+
     def realise_data(self):
         """Fetch 'real' data for all cubes, in a shared calculation.
 
diff --git a/lib/iris/fileformats/rules.py b/lib/iris/fileformats/rules.py
index 2a1a74f374..d24f38d3f3 100644
--- a/lib/iris/fileformats/rules.py
+++ b/lib/iris/fileformats/rules.py
@@ -230,11 +230,11 @@ def _ensure_aligned(regrid_cache, src_cube, target_cube):
         # single, distinct dimension.
         # PP-MOD: first promote any scalar coords when needed as dims
         for target_coord in target_dimcoords:
-            from iris import LOAD_POLICY
+            from iris import COMBINE_POLICY
 
             if (
                 not target_cube.coord_dims(target_coord)
-                and LOAD_POLICY.support_multiple_references
+                and COMBINE_POLICY.support_multiple_references
             ):
                 # The chosen coord is not a dimcoord in the target (yet)
                 # Make it one with 'new_axis'
@@ -400,7 +400,7 @@ def __init__(self):
 # A single global object (per thread) to record whether multiple reference fields
 # (e.g. time-dependent orography, or surface pressure fields) have been detected during
 # the latest load operation.
-# This is used purely to implement the iris.LOAD_POLICY.multiref_triggers_concatenate
+# This is used purely to implement the iris.COMBINE_POLICY.multiref_triggers_concatenate
 # functionality.
 _MULTIREF_DETECTION = MultipleReferenceFieldDetector()
 
diff --git a/lib/iris/loading.py b/lib/iris/loading.py
index f68cb7d9e5..03a395ca8d 100644
--- a/lib/iris/loading.py
+++ b/lib/iris/loading.py
@@ -58,23 +58,17 @@ def add(self, cube):
         if sub_cube is not None:
             self.cubes.append(sub_cube)
 
-    def combined(self, unique=False):
+    def combined(self):
         """Return a new :class:`_CubeFilter` by combining the list of cubes.
 
         Combines the list of cubes with :func:`~iris._combine_load_cubes`.
 
-        Parameters
-        ----------
-        unique : bool, default=False
-            If True, raises `iris.exceptions.DuplicateDataError` if
-            duplicate cubes are detected.
-
         """
         from iris._combine import _combine_load_cubes
 
         return _CubeFilter(
             self.constraint,
-            _combine_load_cubes(self.cubes, merge_require_unique=unique),
+            _combine_load_cubes(self.cubes),
         )
 
 
@@ -110,19 +104,13 @@ def cubes(self):
             result.extend(pair.cubes)
         return result
 
-    def combined(self, unique=False):
+    def combined(self):
         """Return a new :class:`_CubeFilterCollection` by combining all the cube lists of this collection.
 
         Combines each list of cubes using :func:`~iris._combine_load_cubes`.
 
-        Parameters
-        ----------
-        unique : bool, default=False
-            If True, raises `iris.exceptions.DuplicateDataError` if
-            duplicate cubes are detected.
-
         """
-        return _CubeFilterCollection([pair.combined(unique) for pair in self.pairs])
+        return _CubeFilterCollection([pair.combined() for pair in self.pairs])
 
 
 def _load_collection(uris, constraints=None, callback=None):
@@ -203,7 +191,7 @@ def load_cube(uris, constraint=None, callback=None):
     if len(constraints) != 1:
         raise ValueError("only a single constraint is allowed")
 
-    cubes = _load_collection(uris, constraints, callback).combined(unique=False).cubes()
+    cubes = _load_collection(uris, constraints, callback).combined().cubes()
 
     try:
         # NOTE: this call currently retained to preserve the legacy exceptions
@@ -288,37 +276,3 @@ def load_raw(uris, constraints=None, callback=None):
 
     with _raw_structured_loading():
         return _load_collection(uris, constraints, callback).cubes()
-
-
-from iris._combine import CombineOptions
-
-
-class LoadPolicy(CombineOptions):
-    """A control object for Iris loading options.
-
-    Incorporates all the settings of a :class:`~iris.CombineOptions`.
-
-    Examples
-    --------
-    >>> LOAD_POLICY.set("legacy")
-    >>> print(LOAD_POLICY)
-    LoadPolicy(support_multiple_references=False, merge_concat_sequence='m', repeat_until_unchanged=False)
-    >>> LOAD_POLICY.support_multiple_references = True
-    >>> print(LOAD_POLICY)
-    LoadPolicy(support_multiple_references=True, merge_concat_sequence='m', repeat_until_unchanged=False)
-    >>> LOAD_POLICY.set(merge_concat_sequence="cm")
-    >>> print(LOAD_POLICY)
-    LoadPolicy(support_multiple_references=True, merge_concat_sequence='cm', repeat_until_unchanged=False)
-    >>> with LOAD_POLICY.context("comprehensive"):
-    ...    print(LOAD_POLICY)
-    LoadPolicy(support_multiple_references=True, merge_concat_sequence='mc', repeat_until_unchanged=True)
-    >>> print(LOAD_POLICY)
-    LoadPolicy(support_multiple_references=True, merge_concat_sequence='cm', repeat_until_unchanged=False)
-
-    """
-
-    pass
-
-
-#: A control object containing the current file loading strategy options.
-LOAD_POLICY = LoadPolicy()
diff --git a/lib/iris/tests/integration/test_trajectory.py b/lib/iris/tests/integration/test_trajectory.py
index f56970f9fa..e54e56b840 100644
--- a/lib/iris/tests/integration/test_trajectory.py
+++ b/lib/iris/tests/integration/test_trajectory.py
@@ -11,7 +11,7 @@
 import numpy as np
 
 import iris
-from iris import LOAD_POLICY
+from iris import COMBINE_POLICY
 from iris._lazy_data import as_lazy_data
 from iris.analysis.trajectory import Trajectory
 from iris.analysis.trajectory import interpolate as traj_interpolate
@@ -24,7 +24,7 @@ def setUp(self):
         # Load the COLPEX data => TZYX
         path = tests.get_data_path(["PP", "COLPEX", "theta_and_orog_subset.pp"])
         # Fix to ignore time-varying orography, for the purposes of these tests
-        with LOAD_POLICY.context(support_multiple_references=False):
+        with COMBINE_POLICY.context(support_multiple_references=False):
             cube = iris.load_cube(path, "air_potential_temperature")
         cube.coord("grid_latitude").bounds = None
         cube.coord("grid_longitude").bounds = None
diff --git a/lib/iris/tests/integration/varying_references/test_realdata_load.py b/lib/iris/tests/integration/varying_references/test_realdata_load.py
index edf2b00824..8cf8bf2c80 100644
--- a/lib/iris/tests/integration/varying_references/test_realdata_load.py
+++ b/lib/iris/tests/integration/varying_references/test_realdata_load.py
@@ -7,7 +7,7 @@
 import pytest
 
 import iris
-from iris import LOAD_POLICY, sample_data_path
+from iris import COMBINE_POLICY, sample_data_path
 
 
 @pytest.fixture(params=["default", "recommended", "legacy"])
@@ -18,7 +18,7 @@ def load_policy(request):
 def test_load_pp_timevarying_orography(load_policy):
     testdata_dirpath = sample_data_path("time_varying_hybrid_height", "*.pp")
 
-    with LOAD_POLICY.context(load_policy):
+    with COMBINE_POLICY.context(load_policy):
         cubes = iris.load(testdata_dirpath)
 
     n_cubes = len(cubes)
diff --git a/lib/iris/tests/integration/varying_references/test_roundtrip_time_varying_references.py b/lib/iris/tests/integration/varying_references/test_roundtrip_time_varying_references.py
index 0ad4b5a941..fce343719f 100644
--- a/lib/iris/tests/integration/varying_references/test_roundtrip_time_varying_references.py
+++ b/lib/iris/tests/integration/varying_references/test_roundtrip_time_varying_references.py
@@ -16,7 +16,7 @@
 import pytest
 
 import iris
-from iris import LOAD_POLICY
+from iris import COMBINE_POLICY
 from iris.aux_factory import HybridHeightFactory, HybridPressureFactory
 from iris.coord_systems import GeogCS
 from iris.coords import AuxCoord, DimCoord
@@ -240,7 +240,7 @@ def zcoord_type(request):
     return request.param
 
 
-@pytest.fixture(params=[f"{name}_policy" for name in LOAD_POLICY.SETTINGS])
+@pytest.fixture(params=[f"{name}_policy" for name in COMBINE_POLICY.SETTINGS])
 def load_policy(request):
     return request.param
 
@@ -265,7 +265,7 @@ def test_roundtrip(file_extension, time_dependence, zcoord_type, load_policy, tm
     iris.save(data, filepath)
 
     policy_name = load_policy.split("_")[0]
-    with LOAD_POLICY.context(policy_name):
+    with COMBINE_POLICY.context(policy_name):
         # NOTE: this is default, but "legacy" mode would fail
         readback = iris.load(filepath)
 
@@ -309,7 +309,7 @@ def test_split_netcdf_roundtrip(zcoord_type, load_policy, tmp_path):
         iris.save(field_cube, path)
 
     # load back with the chosen policy.
-    with LOAD_POLICY.context(policy_name):
+    with COMBINE_POLICY.context(policy_name):
         readback = iris.load(result_paths)
 
     n_cubes = len(readback)
diff --git a/lib/iris/tests/unit/combine/__init__.py b/lib/iris/tests/unit/combine/__init__.py
new file mode 100644
index 0000000000..ebaa1381a1
--- /dev/null
+++ b/lib/iris/tests/unit/combine/__init__.py
@@ -0,0 +1,5 @@
+# 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.
+"""Unit tests for the :mod:`iris._combine` module."""
diff --git a/lib/iris/tests/unit/combine/test_CombineOptions.py b/lib/iris/tests/unit/combine/test_CombineOptions.py
new file mode 100644
index 0000000000..c58a9edf24
--- /dev/null
+++ b/lib/iris/tests/unit/combine/test_CombineOptions.py
@@ -0,0 +1,226 @@
+# 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.
+"""Unit tests for the :mod:`iris._combine.CombineOptions` class."""
+
+from unittest import mock
+
+import pytest
+
+from iris import CombineOptions
+
+
+class TestInit:
+    def test_init_empty(self):
+        # Check how a bare init works
+        options = CombineOptions()
+        assert options.settings() == CombineOptions.SETTINGS["default"]
+
+    def test_init_args_kwargs(self):
+        # Check that init with args, kwargs equates to a pair of set() calls.
+        with mock.patch("iris.CombineOptions.set") as mock_set:
+            test_option = mock.sentinel.option
+            test_kwargs = {"junk": "invalid"}
+            CombineOptions(options=test_option, **test_kwargs)
+        assert mock_set.call_args_list == [
+            mock.call("default"),
+            mock.call(test_option, **test_kwargs),
+        ]
+
+
+class Test_settings:
+    """The .settings() returns a dict full of the settings."""
+
+    def test_settings(self):
+        options = CombineOptions()
+        settings = options.settings()
+        assert isinstance(settings, dict)
+        assert list(settings.keys()) == CombineOptions.OPTION_KEYS
+        for key in CombineOptions.OPTION_KEYS:
+            assert settings[key] == getattr(options, key)
+
+
+def options_checks(options, checks):
+    # Check (parts of) options against a dictionary of "expected" values.
+    settings = options.settings()
+    return all(settings[key] == value for key, value in checks.items())
+
+
+class Test_set_and_context:
+    """Check the .set(arg, **kwargs) and .context(arg, **kwargs) behaviours."""
+
+    @staticmethod
+    def do_check(
+        op_arg=None,
+        op_kwargs=None,
+        before_checks=None,
+        after_checks=None,
+        initial_options=None,
+        op_is_set=True,
+    ):
+        """Generic test routine check method.
+
+        Perform an operation(op_arg, **op_kwargs) and test (partial) options state
+        before and after.  If provided, can also start from a non-default
+        'initial_options' state.
+
+        Used to generalise between the .set() and .context() calls.
+        In the case of .context(), the 'after' is within the block, and the 'before'
+        state should always be restored again afterwards.
+        """
+        if initial_options is not None:
+            options = initial_options
+        else:
+            options = CombineOptions()
+
+        op_kwargs = op_kwargs or {}
+
+        if before_checks is not None:
+            assert options_checks(options, before_checks)
+
+        if op_is_set:
+            # do "set" check
+            options.set(op_arg, **op_kwargs)
+            assert options_checks(options, after_checks)
+        else:
+            # do "context" checks
+            with options.context(op_arg, **op_kwargs):
+                assert options_checks(options, after_checks)
+            assert options_checks(options, before_checks)
+
+    @pytest.fixture(params=["set", "context"])
+    def op_is_set(self, request):
+        """Parametrise a test over both .set() and and .context() calls."""
+        return request.param == "set"
+
+    def test_empty_set(self):
+        # More or less, just check that an empty set() call is OK.
+        options = CombineOptions()
+        orig_settings = options.settings()
+        options.set()
+        assert options.settings() == orig_settings
+
+    def test_empty_context(self):
+        # More or less, just check that an empty context() call is OK.
+        options = CombineOptions()
+        orig_settings = options.settings()
+        with options.context():
+            assert options.settings() == orig_settings
+
+    def test_arg_dict(self, op_is_set):
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        set_arg = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        expect_after = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        self.do_check(
+            op_arg=set_arg,
+            before_checks=expect_before,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_arg_string(self, op_is_set):
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        set_arg = "comprehensive"
+        expect_after = {"merge_concat_sequence": "mc", "repeat_until_unchanged": True}
+        self.do_check(
+            op_arg=set_arg,
+            before_checks=expect_before,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_kwargs(self, op_is_set):
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        set_arg = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        expect_after = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        self.do_check(
+            op_arg=set_arg,
+            before_checks=expect_before,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_arg_dict_plus_kwargs(self, op_is_set):
+        # Show that kwargs override dictionary arg
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        # NOTE: the arg changes the sequence from "m" to "c" ...
+        set_arg = dict(merge_concat_sequence="c", repeat_until_unchanged=True)
+        # .. but the keyword overrides that to "mc"
+        set_kwargs = dict(merge_concat_sequence="mc")
+        expect_after = {"merge_concat_sequence": "mc", "repeat_until_unchanged": True}
+        self.do_check(
+            before_checks=expect_before,
+            op_arg=set_arg,
+            op_kwargs=set_kwargs,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_arg_str_plus_kwargs(self, op_is_set):
+        # Show that kwargs override settings-name arg
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        # NOTE: the arg changes 'sequence' to "mc", and 'repeat' to True ...
+        set_arg = "comprehensive"
+        # .. but the keyword overrides 'repeat' to False again
+        set_kwargs = dict(repeat_until_unchanged=False)
+        expect_after = {"merge_concat_sequence": "mc", "repeat_until_unchanged": False}
+        self.do_check(
+            before_checks=expect_before,
+            op_arg=set_arg,
+            op_kwargs=set_kwargs,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_arg_bad_dict(self):
+        options = CombineOptions()
+        expected = "Unknown options.*'junk'.* : valid options are"
+        with pytest.raises(ValueError, match=expected):
+            options.set({"junk": "invalid"})
+
+    def test_arg_bad_string(self):
+        options = CombineOptions()
+        expected = (
+            r"arg 'options'='oddthing'.*not a valid setting.*expected one of.* "
+            "['legacy', 'default', 'recommended', 'comprehensive']"
+        )
+        with pytest.raises(ValueError, match=expected):
+            options.set("oddthing")
+
+    def test_bad_kwarg(self):
+        options = CombineOptions()
+        expected = "Unknown options.*'junk'.* : valid options are"
+        with pytest.raises(ValueError, match=expected):
+            options.set({"junk": "invalid"})
+
+
+class Test_AttributeAccess:
+    """Check operation of direct property access (with ".")."""
+
+    def test_getattr(self):
+        options = CombineOptions(merge_concat_sequence="m")
+        assert options.merge_concat_sequence == "m"
+
+    def test_getattr_badname(self):
+        options = CombineOptions()
+        expected = "'CombineOptions' object has no attribute 'unknown'"
+        with pytest.raises(AttributeError, match=expected):
+            options.unknown
+
+    def test_setattr(self):
+        options = CombineOptions(merge_concat_sequence="m")
+        options.merge_concat_sequence = "mc"
+        assert options.merge_concat_sequence == "mc"
+
+    def test_setattr_badname(self):
+        options = CombineOptions()
+        expected = "CombineOptions object has no property 'anyold_property'"
+        with pytest.raises(KeyError, match=expected):
+            options.anyold_property = "x"
+
+    def test_setattr_badvalue(self):
+        options = CombineOptions()
+        expected = "'mcm' is not a valid.*merge_concat_sequence : must be one of"
+        with pytest.raises(ValueError, match=expected):
+            options.merge_concat_sequence = "mcm"
diff --git a/lib/iris/tests/unit/cube/test_CubeList.py b/lib/iris/tests/unit/cube/test_CubeList.py
index 62e63e6694..ddd1ebc2bc 100644
--- a/lib/iris/tests/unit/cube/test_CubeList.py
+++ b/lib/iris/tests/unit/cube/test_CubeList.py
@@ -719,3 +719,52 @@ def test__repr_html_(mocker):
             # "CubeListRepresentation(cubelist).repr_html()" was called exactly once, with no args
             mock.call()
         ]
+
+
+class Test_combine__apis:
+    """Confirm that CubeList.combine/combine_cube just call combine_cubes."""
+
+    def mock_combine_cubes(self):
+        def mock_call(cubes, options=None, **kwargs):
+            self.call_params = [cubes, options, kwargs]
+            return cubes  # used to test effect of different cases
+
+        return mock_call
+
+    def test_combine(self):
+        cubelist = CubeList([])
+        arg = mock.sentinel.options_arg
+        kwargs = dict(
+            key_test_1=1,
+            key_test_2=2,
+        )
+        with mock.patch("iris.util.combine_cubes", self.mock_combine_cubes()):
+            result = cubelist.combine(arg, **kwargs)
+        assert self.call_params == [cubelist, arg, kwargs]
+        assert result == cubelist
+
+    @pytest.mark.parametrize("ncubes", [0, 1, 2], ids=["nocubes", "onecube", "ncubes"])
+    def test_combine_cube(self, ncubes):
+        """In this case, also check behaviour for result of <1 =1 >1 cubes."""
+        cubelist = CubeList(
+            [Cube([0], long_name=f"cube_{i_cube})") for i_cube in range(ncubes)]
+        )
+        arg = mock.sentinel.options_arg
+        kwargs = dict(
+            key_test_1=1,
+            key_test_2=2,
+        )
+        if ncubes == 1:
+            with mock.patch("iris.util.combine_cubes", self.mock_combine_cubes()):
+                result = cubelist.combine_cube(arg, **kwargs)
+            assert self.call_params == [cubelist, arg, kwargs]
+            assert result == cubelist[0]
+        else:
+            if ncubes == 0:
+                msg = "'combine' operation yielded 0 cubes, expected exactly 1"
+            else:
+                msg = f"'combine' operation yielded {ncubes} cubes, expected exactly 1"
+
+            with mock.patch("iris.util.combine_cubes", self.mock_combine_cubes()):
+                with pytest.raises(ValueError, match=msg):
+                    result = cubelist.combine_cube(arg, **kwargs)
diff --git a/lib/iris/tests/unit/test_LoadPolicy.py b/lib/iris/tests/unit/test_LoadPolicy.py
deleted file mode 100644
index 8772b089c1..0000000000
--- a/lib/iris/tests/unit/test_LoadPolicy.py
+++ /dev/null
@@ -1,144 +0,0 @@
-# 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.
-"""Unit tests for the :mod:`iris.io.loading.LoadPolicy` package."""
-
-from unittest import mock
-
-import pytest
-
-from iris import LoadPolicy
-
-
-class TestInit:
-    def test_init_empty(self):
-        # Check how a bare init works
-        options = LoadPolicy()
-        assert options.settings() == LoadPolicy.SETTINGS["default"]
-
-    def test_init_args_kwargs(self):
-        # Check that init with args, kwargs equates to a pair of set() calls.
-        with mock.patch("iris.LoadPolicy.set") as mock_set:
-            test_option = mock.sentinel.option
-            test_kwargs = {"junk": "invalid"}
-            LoadPolicy(options=test_option, **test_kwargs)
-        assert mock_set.call_args_list == [
-            mock.call("default"),
-            mock.call(test_option, **test_kwargs),
-        ]
-
-
-class Test_settings:
-    """The .settings() returns a dict full of the settings."""
-
-    def test_settings(self):
-        options = LoadPolicy()
-        settings = options.settings()
-        assert isinstance(settings, dict)
-        assert tuple(settings.keys()) == LoadPolicy.OPTION_KEYS
-        for key in LoadPolicy.OPTION_KEYS:
-            assert settings[key] == getattr(options, key)
-
-
-class Test_set:
-    """Check the .set(arg, **kwargs) behaviour."""
-
-    def test_empty(self):
-        options = LoadPolicy()
-        orig_settings = options.settings()
-        options.set()
-        assert options.settings() == orig_settings
-
-    def test_arg_dict(self):
-        options = LoadPolicy()
-        assert options.settings()["merge_concat_sequence"] == "m"
-        assert options.settings()["repeat_until_unchanged"] is False
-        options.set({"merge_concat_sequence": "c", "repeat_until_unchanged": True})
-        assert options.settings()["merge_concat_sequence"] == "c"
-        assert options.settings()["repeat_until_unchanged"] is True
-
-    def test_arg_string(self):
-        options = LoadPolicy()
-        assert options.settings()["merge_concat_sequence"] == "m"
-        assert options.settings()["repeat_until_unchanged"] is False
-        options.set("comprehensive")
-        assert options.settings()["merge_concat_sequence"] == "mc"
-        assert options.settings()["repeat_until_unchanged"] is True
-
-    def test_arg_bad_dict(self):
-        options = LoadPolicy()
-        expected = "Unknown options.*'junk'.* : valid options are"
-        with pytest.raises(ValueError, match=expected):
-            options.set({"junk": "invalid"})
-
-    def test_arg_bad_string(self):
-        options = LoadPolicy()
-        expected = "Invalid arg options='unknown' : must be a dict, or one of"
-        with pytest.raises(TypeError, match=expected):
-            options.set("unknown")
-
-    def test_arg_bad_type(self):
-        options = LoadPolicy()
-        expected = "must be a dict, or one of"
-        with pytest.raises(TypeError, match=expected):
-            options.set((1, 2, 3))
-
-    def test_kwargs(self):
-        options = LoadPolicy()
-        assert options.settings()["merge_concat_sequence"] == "m"
-        assert options.settings()["repeat_until_unchanged"] is False
-        options.set(merge_concat_sequence="c", repeat_until_unchanged=True)
-        assert options.settings()["merge_concat_sequence"] == "c"
-        assert options.settings()["repeat_until_unchanged"] is True
-
-    def test_arg_kwargs(self):
-        # Show that kwargs override arg
-        options = LoadPolicy(
-            support_multiple_references=False,
-            merge_concat_sequence="",
-            repeat_until_unchanged=False,
-        )
-        options.set(
-            dict(merge_concat_sequence="c", repeat_until_unchanged=True),
-            merge_concat_sequence="mc",
-        )
-        assert options.merge_concat_sequence == "mc"
-        assert options.repeat_until_unchanged is True
-
-    def test_bad_kwarg(self):
-        options = LoadPolicy()
-        expected = "Unknown options.*'junk'.* : valid options are"
-        with pytest.raises(ValueError, match=expected):
-            options.set({"junk": "invalid"})
-
-
-class Test_AttributeAccess:
-    """Check operation of direct property access (with ".")."""
-
-    def test_getattr(self):
-        options = LoadPolicy(merge_concat_sequence="m")
-        assert options.merge_concat_sequence == "m"
-
-    def test_getattr_badname(self):
-        options = LoadPolicy()
-        expected = "'LoadPolicy' object has no attribute 'unknown'"
-        with pytest.raises(AttributeError, match=expected):
-            options.unknown
-
-    def test_setattr(self):
-        options = LoadPolicy(merge_concat_sequence="m")
-        options.merge_concat_sequence = "mc"
-        assert options.merge_concat_sequence == "mc"
-
-    def test_setattr_badname(self):
-        options = LoadPolicy()
-        expected = "LoadPolicy object has no property 'anyold_property'"
-        with pytest.raises(KeyError, match=expected):
-            options.anyold_property = "x"
-
-    def test_setattr_badvalue(self):
-        options = LoadPolicy()
-        expected = "'mcm' is not a valid.*merge_concat_sequence : must be one of"
-        with pytest.raises(ValueError, match=expected):
-            options.merge_concat_sequence = "mcm"
diff --git a/lib/iris/tests/unit/test_combine_cubes.py b/lib/iris/tests/unit/test_combine_cubes.py
deleted file mode 100644
index a60831ed4c..0000000000
--- a/lib/iris/tests/unit/test_combine_cubes.py
+++ /dev/null
@@ -1,90 +0,0 @@
-# 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.
-"""Unit tests for the :func:`iris.io.loading.combine_cubes` function.
-
-Note: These tests are fairly extensive to cover functional uses within the loading
-operations.
-TODO: when function is public API, extend testing to the extended API options,
-i.e. different types + defaulting of the 'options' arg, and **kwargs support.
-"""
-
-import pytest
-
-from iris import LoadPolicy
-from iris._combine import _combine_cubes
-from iris.tests.unit.fileformats.test_load_functions import cu
-
-
-@pytest.fixture(params=list(LoadPolicy.SETTINGS.keys()))
-def options(request):
-    # N.B. "request" is a standard PyTest fixture
-    return request.param  # Return the name of the attribute to test.
-
-
-# Interface to convert settings-name / kwargs into an options dict,
-# TODO: remove this wrapper when the API of "combine_cubes" is opened up.
-def combine_cubes(cubes, settings_name="default", **kwargs):
-    options = LoadPolicy.SETTINGS[settings_name]
-    options.update(kwargs)
-    return _combine_cubes(cubes, options, merge_require_unique=False)
-
-
-class Test:
-    def test_mergeable(self, options):
-        c1, c2 = cu(t=1), cu(t=2)
-        c12 = cu(t=(1, 2))
-        input_cubes = [c1, c2]
-        result = combine_cubes(input_cubes, options)
-        expected = [c12]  # same in all cases
-        assert result == expected
-
-    def test_catable(self, options):
-        c1, c2 = cu(t=(1, 2)), cu(t=(3, 4))
-        c12 = cu(t=(1, 2, 3, 4))
-        input_cubes = [c1, c2]
-        result = combine_cubes(input_cubes, options)
-        expected = {
-            "legacy": [c1, c2],  # standard options can't do this ..
-            "default": [c1, c2],
-            "recommended": [c12],  # .. but it works if you enable concatenate
-            "comprehensive": [c12],
-        }[options]
-        assert result == expected
-
-    def test_cat_enables_merge(self, options):
-        c1, c2 = cu(t=(1, 2), z=1), cu(t=(3, 4, 5), z=1)
-        c3, c4 = cu(t=(1, 2, 3), z=2), cu(t=(4, 5), z=2)
-        c1234 = cu(t=(1, 2, 3, 4, 5), z=(1, 2))
-        c12 = cu(t=(1, 2, 3, 4, 5), z=1)
-        c34 = cu(t=(1, 2, 3, 4, 5), z=2)
-        input_cubes = [c1, c2, c3, c4]
-        result = combine_cubes(input_cubes, options)
-        expected = {
-            "legacy": input_cubes,
-            "default": input_cubes,
-            "recommended": [c12, c34],  # standard "mc" sequence can't do this one..
-            "comprehensive": [c1234],  # .. but works if you repeat
-        }[options]
-        assert result == expected
-
-    def test_cat_enables_merge__custom(self):
-        c1, c2 = cu(t=(1, 2), z=1), cu(t=(3, 4, 5), z=1)
-        c3, c4 = cu(t=(1, 2, 3), z=2), cu(t=(4, 5), z=2)
-        c1234 = cu(t=(1, 2, 3, 4, 5), z=(1, 2))
-        input_cubes = [c1, c2, c3, c4]
-        result = combine_cubes(input_cubes, merge_concat_sequence="cm")
-        assert result == [c1234]
-
-    def test_nocombine_overlapping(self, options):
-        c1, c2 = cu(t=(1, 3)), cu(t=(2, 4))
-        input_cubes = [c1, c2]
-        result = combine_cubes(input_cubes, options)
-        assert result == input_cubes  # same in all cases : can't do this
-
-    def test_nocombine_dim_scalar(self, options):
-        c1, c2 = cu(t=(1,)), cu(t=2)
-        input_cubes = [c1, c2]
-        result = combine_cubes(input_cubes, options)
-        assert result == input_cubes  # can't do this at present
diff --git a/lib/iris/tests/unit/util/test_combine_cubes.py b/lib/iris/tests/unit/util/test_combine_cubes.py
new file mode 100644
index 0000000000..c55ec6f363
--- /dev/null
+++ b/lib/iris/tests/unit/util/test_combine_cubes.py
@@ -0,0 +1,142 @@
+# 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.
+"""Unit tests for the :func:`iris.util.combine_cubes` function."""
+
+import pytest
+
+from iris import LoadPolicy
+from iris.exceptions import DuplicateDataError
+from iris.tests.unit.fileformats.test_load_functions import cu
+from iris.util import combine_cubes
+
+
+@pytest.fixture(params=list(LoadPolicy.SETTINGS.keys()))
+def settings(request):
+    # N.B. "request" is a standard PyTest fixture
+    return request.param  # Return the name of the attribute to test.
+
+
+class Test_settings:
+    """These tests cover functional uses of the different "settings" choices, with specific
+    sets of cubes sensitive to those choices.
+    """
+
+    def test_mergeable(self, settings):
+        c1, c2 = cu(t=1), cu(t=2)
+        c12 = cu(t=(1, 2))
+        input_cubes = [c1, c2]
+        result = combine_cubes(input_cubes, settings)
+        expected = [c12]  # same in all cases
+        assert result == expected
+
+    def test_catable(self, settings):
+        c1, c2 = cu(t=(1, 2)), cu(t=(3, 4))
+        c12 = cu(t=(1, 2, 3, 4))
+        input_cubes = [c1, c2]
+        result = combine_cubes(input_cubes, settings)
+        expected = {
+            "legacy": [c1, c2],  # standard settings can't do this ..
+            "default": [c1, c2],
+            "recommended": [c12],  # .. but it works if you enable concatenate
+            "comprehensive": [c12],
+        }[settings]
+        assert result == expected
+
+    def test_cat_enables_merge(self, settings):
+        c1, c2 = cu(t=(1, 2), z=1), cu(t=(3, 4, 5), z=1)
+        c3, c4 = cu(t=(1, 2, 3), z=2), cu(t=(4, 5), z=2)
+        c1234 = cu(t=(1, 2, 3, 4, 5), z=(1, 2))
+        c12 = cu(t=(1, 2, 3, 4, 5), z=1)
+        c34 = cu(t=(1, 2, 3, 4, 5), z=2)
+        input_cubes = [c1, c2, c3, c4]
+        result = combine_cubes(input_cubes, settings)
+        expected = {
+            "legacy": input_cubes,
+            "default": input_cubes,
+            "recommended": [c12, c34],  # standard "mc" sequence can't do this one..
+            "comprehensive": [c1234],  # .. but works if you repeat
+        }[settings]
+        assert result == expected
+
+    def test_nocombine_overlapping(self, settings):
+        c1, c2 = cu(t=(1, 3)), cu(t=(2, 4))
+        input_cubes = [c1, c2]
+        result = combine_cubes(input_cubes, settings)
+        assert result == input_cubes  # same in all cases : can't do this
+
+    def test_nocombine_dim_scalar(self, settings):
+        c1, c2 = cu(t=(1,)), cu(t=2)
+        input_cubes = [c1, c2]
+        result = combine_cubes(input_cubes, settings)
+        assert result == input_cubes  # can't do this at present
+
+
+def test_cat_enables_merge__custom():
+    """A standalone testcase.
+
+    This shows specifically how a 'cm' sequence can improve on the default 'm'.
+    """
+    c1, c2 = cu(t=(1, 2), z=1), cu(t=(3, 4, 5), z=1)
+    c3, c4 = cu(t=(1, 2, 3), z=2), cu(t=(4, 5), z=2)
+    c1234 = cu(t=(1, 2, 3, 4, 5), z=(1, 2))
+    input_cubes = [c1, c2, c3, c4]
+    result = combine_cubes(input_cubes, merge_concat_sequence="cm")
+    assert result == [c1234]
+
+
+class Test_options:
+    """Test all the individual combine options keywords."""
+
+    def test_equalise_cubes_kwargs(self):
+        # two cubes will merge ..
+        cubes = [cu(t=1), cu(t=2)]
+        # .. but prevent by adding an attribute on one
+        cubes[0].attributes["x"] = 3
+        # won't combine..
+        result = combine_cubes(cubes)
+        assert len(result) == 2
+        # ..but will if you enable attribute equalisation
+        result = combine_cubes(
+            cubes, equalise_cubes_kwargs={"equalise_attributes": True}
+        )
+        assert len(result) == 1
+
+    def test_merge_concat_sequence(self):
+        # cubes require concat, merge won't work
+        cubes = [cu(t=[1, 2]), cu(t=[3, 4])]
+        result = combine_cubes(cubes)
+        assert len(result) == 2
+        # .. but will if you put concat in the sequence
+        result = combine_cubes(cubes, merge_concat_sequence="c")
+        assert len(result) == 1
+
+    def test_merge_unique(self):
+        # two identical cubes
+        cubes = [cu("myname"), cu("myname")]
+        # the combine call (with merge) is OK if we *don't* insist on unique cubes
+        combine_cubes(cubes)
+        # .. but it errors if we *do* insist on uniqueness
+        msg = "Duplicate 'myname' cube"
+        with pytest.raises(DuplicateDataError, match=msg):
+            combine_cubes(cubes, merge_unique=True)
+
+    def test_repeat_until_unchanged(self):
+        # construct a case that will only merge once it was previously concatenated
+        cubes = [
+            cu(t=[1, 2, 3], z=1),
+            cu(t=[4, 5], z=1),
+            cu(t=[1, 2], z=2),
+            cu(t=[3, 4, 5], z=2),
+        ]
+        result = combine_cubes(cubes, merge_concat_sequence="mc")
+        assert len(result) == 2
+        result = combine_cubes(
+            cubes, merge_concat_sequence="mc", repeat_until_unchanged=True
+        )
+        assert len(result) == 1
+
+    # NOTE: "test_support_multiple_references" -- not tested here
+    # this may be too hard
+    # it is adequately tested in tests/integration/varying_references
diff --git a/lib/iris/util.py b/lib/iris/util.py
index fef83b4c94..e0d2fa5183 100644
--- a/lib/iris/util.py
+++ b/lib/iris/util.py
@@ -15,7 +15,7 @@
 import os.path
 import sys
 import tempfile
-from typing import Literal
+from typing import TYPE_CHECKING, List, Literal
 from warnings import warn
 
 import cf_units
@@ -31,6 +31,9 @@
 import iris.exceptions
 import iris.warnings
 
+if TYPE_CHECKING:
+    from iris.cube import Cube, CubeList
+
 
 def broadcast_to_shape(array, shape, dim_map, chunks=None):
     """Broadcast an array to a given shape.
@@ -2398,3 +2401,141 @@ def _print_xml(doc):
     """
     result = doc.toprettyxml(indent="  ")
     return result.replace("&#10;", "\n")
+
+
+def _combine_options_asdict(options: str | dict | None) -> dict:
+    """Convert any valid combine options into an options dictionary."""
+    from iris import COMBINE_POLICY
+
+    if options is None:
+        opts_dict = COMBINE_POLICY.settings()
+    elif isinstance(options, dict):
+        opts_dict = options
+    elif isinstance(options, str):
+        if options in COMBINE_POLICY.SETTINGS:
+            opts_dict = COMBINE_POLICY.SETTINGS[options]
+        else:
+            msg = (
+                "Unrecognised settings name : expected one of "
+                f"{tuple(COMBINE_POLICY.SETTINGS)}."
+            )
+            raise ValueError(msg)
+    else:
+        msg = (  # type: ignore[unreachable]
+            f"arg 'options' has type {type(options)!r}, "
+            "expected one of (str | dict | None)"
+        )
+        raise ValueError(msg)  # type: ignore[unreachable]
+
+    return opts_dict
+
+
+def combine_cubes(
+    cubes: List[Cube],
+    options: str | dict | None = None,
+    **kwargs,
+) -> CubeList:
+    """Combine cubes, according to "combine options".
+
+    Applies a combination of :meth:`~iris.util.equalise_cubes`,
+    :meth:`~iris.cube.CubeList.merge` and/or :meth:`~iris.cube.CubeList.concatenate`
+    steps to the given cubes, as determined by the given settings (from `options` and
+    `kwargs`).
+
+    Parameters
+    ----------
+    cubes : list of :class:`~iris.cube.Cube`
+        A list of cubes to combine.
+
+    options : str or dict, optional
+        Either a standard "combine settings" name, i.e. one of the
+        :data:`iris.CombineOptions.SETTINGS_NAMES`, or a dictionary of
+        settings options, as described for :class:`~iris.CombineOptions`.
+        Defaults to the current :meth:`~iris.CombineOptions.settings` of the
+        :data:`iris.COMBINE_POLICY`.
+
+    kwargs : dict
+        Individual option setting values, i.e. values for keys named in
+        :data:`iris.CombineOptions.OPTION_KEYS`, as described for
+        :meth:`~iris.CombineOptions.set`.  These take precedence over those set by the
+        `options` arg.
+
+    Returns
+    -------
+    :class:`~iris.cube.CubeList`
+
+    Notes
+    -----
+        A ``support_multiple_references`` option will be accepted as valid, but will
+        have *no* effect on :func:`combine_cubes` because this option only acts during
+        load operations.
+
+
+    Examples
+    --------
+    .. testsetup::
+
+        import numpy as np
+        from iris.cube import Cube, CubeList
+        from iris.coords import DimCoord
+        from iris.util import combine_cubes
+
+        def testcube(timepts):
+            cube = Cube(np.array(timepts))
+            cube.add_dim_coord(
+                DimCoord(timepts, standard_name="time", units="days since 1990-01-01"),
+                0
+            )
+            return cube
+
+        cubes = CubeList([testcube([1., 2]), testcube([13., 14, 15])])
+        combinecubes_old_policysettings = iris.COMBINE_POLICY.settings()
+
+    .. testcleanup::
+
+        # restore old state to avoid upsetting other tests
+        iris.COMBINE_POLICY.set(combinecubes_old_policysettings)
+
+    >>> # Take a pair of sample cubes which can concatenate together
+    >>> print(cubes)
+    0: unknown / (unknown)                 (time: 2)
+    1: unknown / (unknown)                 (time: 3)
+    >>> print([cube.coord("time").points for cube in cubes])
+    [array([1., 2.]), array([13., 14., 15.])]
+
+    >>> # Show these do NOT combine with the "default" action, which only merges ..
+    >>> print(combine_cubes(cubes))
+    0: unknown / (unknown)                 (time: 2)
+    1: unknown / (unknown)                 (time: 3)
+    >>> # ... however, they **do** combine if you enable concatenation
+    >>> print(combine_cubes(cubes, merge_concat_sequence="mc"))
+    0: unknown / (unknown)                 (time: 5)
+    >>> # ... which may be controlled by various means
+    >>> iris.COMBINE_POLICY.set("recommended")
+    >>> print(combine_cubes(cubes))
+    0: unknown / (unknown)                 (time: 5)
+
+    >>> # Also, show how a differing attribute will block cube combination
+    >>> cubes[0].attributes["x"] = 3
+    >>> print(combine_cubes(cubes))
+    0: unknown / (unknown)                 (time: 2)
+    1: unknown / (unknown)                 (time: 3)
+    >>> # ... which can then be fixed by enabling attribute equalisation
+    >>> with iris.COMBINE_POLICY.context(equalise_cubes_kwargs={"apply_all":True}):
+    ...     print(combine_cubes(cubes))
+    ...
+    0: unknown / (unknown)                 (time: 5)
+
+    >>> # .. BUT NOTE : this modifies the original input cubes
+    >>> print(cubes[0].attributes.get("x"))
+    None
+
+    """
+    from iris._combine import _combine_cubes
+
+    opts_dict = _combine_options_asdict(options)
+    if kwargs is not None:
+        opts_dict = opts_dict.copy()  # avoid changing original
+        opts_dict.update(kwargs)
+
+    return _combine_cubes(cubes, opts_dict)


Reviews:
user: login: stephenworsley
body: Looking good. I'm pretty happy with the decision to simplify the API by making LOAD_POLICY a copy of COMBINE_POLICY, though I think it's worth adding another test to ensure that modifying one does indeed modify the other (and vice versa). I just have a couple suggestions for the documentation for the moment, I plan to go look through the testing next. So far, the code itself looks like a definite improvement.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-28T16:09:03Z
user: login: pp-mo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-28T17:18:48Z
user: login: pp-mo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-28T17:19:45Z
user: login: stephenworsley
body: Looking good, Just a couple small notes from the tests.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T16:56:24Z
user: login: pp-mo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T01:24:33Z
user: login: pp-mo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T01:27:47Z
user: login: stephenworsley
body: Just a couple TODOs to remove.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T16:14:17Z
user: login: stephenworsley
body: Looks good, I think this is mergable now.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T17:07:13Z

Review comments:
diff_hunk: @@ -11,124 +11,197 @@
 publicly available.
 """
 
+from __future__ import annotations
+
 import contextlib
 import threading
-from typing import Mapping
-
+from typing import TYPE_CHECKING, Any, Dict, List
 
-class CombineOptions(threading.local):
-    """A container for cube combination options.
+if TYPE_CHECKING:
+    from iris.cube import Cube, CubeList
 
-    Controls for generalised merge/concatenate options.
 
-    Also controls the detection and handling of cases where a hybrid coordinate
-    uses multiple reference fields during loading : for example, a UM file which
-    contains a series of fields describing time-varying orography.
+class CombineOptions(threading.local):
+    """A control object for Iris loading and cube combination options.
 
-    Options can be set directly, or via :meth:`~iris.LoadPolicy.set`, or changed for
-    the scope of a code block with :meth:`~iris.LoadPolicy.context`.
+    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.
 
-    .. note ::
+    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 default behaviour will "fix" loading for cases like the time-varying
-        orography case described above.  However, this is not strictly
-        backwards-compatible.  If this causes problems, you can force identical loading
-        behaviour to earlier Iris versions with ``LOAD_POLICY.set("legacy")`` or
-        equivalent.
+    The :class:`CombineOptions` class defines the allowed control options, while a
+    global singleton object :data:`iris.COMBINE_POLICY` holds the current global
+    default settings.
 
-    .. testsetup::
+    The individual configurable options are :
 
-        from iris import LOAD_POLICY
+    * ``equalise_cubes_kwargs`` = (dict)
+        Specifies keywords for an :func:`iris.util.equalise_cubes` call, to be applied
+        before any merge/concatenate step.
path: lib/iris/_combine.py
user: login: stephenworsley
body: You should probably mention that if this is not set (or is set to an empty dictionary) this will not call equalise_cubes.
created_at: 2025-02-28T15:13:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -278,36 +354,47 @@ def _combine_cubes(cubes, options, merge_require_unique):
     """
     from iris.cube import CubeList
 
-    if not isinstance(cubes, CubeList):
-        cubes = CubeList(cubes)
+    if isinstance(cubes, CubeList):
+        cubelist = cubes
+    else:
+        cubelist = CubeList(cubes)
+
+    eq_args = options.get("equalise_cubes_kwargs", None)
+    if eq_args:
path: lib/iris/_combine.py
user: login: stephenworsley
body: I take it that if `eq_args` is an empty dictionary, calling `equalise_cubes` would do nothing so it makes sense to skip calling it. This isn't entirely obvious from this side of the code though, so it may be worth adding a small comment here stating that calling `equalise_cubes` without these kwargs would do nothing.
created_at: 2025-02-28T15:21:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,7 +16,8 @@
 The :func:`load` function provides a simple way to explore data from
 the interactive Python prompt. It will convert the source data into
 :class:`Cubes <iris.cube.Cube>`, and combine those cubes into
-higher-dimensional cubes where possible.
+higher-dimensional cubes where possible
+(for which, please see :class:`iris.CombineOptions`).
path: lib/iris/__init__.py
user: login: stephenworsley
body: Might be worth briefly mentioning that this is controlled by COMBINE_POLICY. Maybe change to something like "(this combination is controlled by :obj:`iris.COMBINE_POLICY`, for which, please see :class:`iris.CombineOptions`)".
created_at: 2025-02-28T15:53:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -637,6 +637,20 @@ def concatenate(
             check_derived_coords=check_derived_coords,
         )
 
+    def combine(self, options: str | dict | None = None, **kwargs) -> CubeList:
path: lib/iris/cube.py
user: login: stephenworsley
body: I think there ought to be a docstring for this.
created_at: 2025-02-28T15:56:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -637,6 +637,20 @@ def concatenate(
             check_derived_coords=check_derived_coords,
         )
 
+    def combine(self, options: str | dict | None = None, **kwargs) -> CubeList:
+        from iris.util import combine_cubes
+
+        return combine_cubes(self, options, **kwargs)
+
+    def combine_cube(self, options: str | dict | None = None, **kwargs) -> CubeList:
path: lib/iris/cube.py
user: login: stephenworsley
body: As above, this could do with a docstring.
created_at: 2025-02-28T15:57:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -11,124 +11,197 @@
 publicly available.
 """
 
+from __future__ import annotations
+
 import contextlib
 import threading
-from typing import Mapping
-
+from typing import TYPE_CHECKING, Any, Dict, List
 
-class CombineOptions(threading.local):
-    """A container for cube combination options.
+if TYPE_CHECKING:
+    from iris.cube import Cube, CubeList
 
-    Controls for generalised merge/concatenate options.
 
-    Also controls the detection and handling of cases where a hybrid coordinate
-    uses multiple reference fields during loading : for example, a UM file which
-    contains a series of fields describing time-varying orography.
+class CombineOptions(threading.local):
+    """A control object for Iris loading and cube combination options.
 
-    Options can be set directly, or via :meth:`~iris.LoadPolicy.set`, or changed for
-    the scope of a code block with :meth:`~iris.LoadPolicy.context`.
+    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.
 
-    .. note ::
+    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 default behaviour will "fix" loading for cases like the time-varying
-        orography case described above.  However, this is not strictly
-        backwards-compatible.  If this causes problems, you can force identical loading
-        behaviour to earlier Iris versions with ``LOAD_POLICY.set("legacy")`` or
-        equivalent.
+    The :class:`CombineOptions` class defines the allowed control options, while a
+    global singleton object :data:`iris.COMBINE_POLICY` holds the current global
+    default settings.
 
-    .. testsetup::
+    The individual configurable options are :
 
-        from iris import LOAD_POLICY
+    * ``equalise_cubes_kwargs`` = (dict)
+        Specifies keywords for an :func:`iris.util.equalise_cubes` call, to be applied
+        before any merge/concatenate step.
path: lib/iris/_combine.py
user: login: pp-mo
body: OK I'll try to make this a bit clearer
created_at: 2025-02-28T17:18:48Z
author_association: MEMBER
diff_hunk: @@ -637,6 +637,20 @@ def concatenate(
             check_derived_coords=check_derived_coords,
         )
 
+    def combine(self, options: str | dict | None = None, **kwargs) -> CubeList:
path: lib/iris/cube.py
user: login: pp-mo
body: 😮 how did I miss these ?!?
created_at: 2025-02-28T17:19:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,215 @@
+# 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.
+"""Unit tests for the :mod:`iris._combine.CombineOptions` class."""
+
+from unittest import mock
+
+import pytest
+
+from iris import CombineOptions
+
+
+class TestInit:
+    def test_init_empty(self):
+        # Check how a bare init works
+        options = CombineOptions()
+        assert options.settings() == CombineOptions.SETTINGS["default"]
+
+    def test_init_args_kwargs(self):
+        # Check that init with args, kwargs equates to a pair of set() calls.
+        with mock.patch("iris.CombineOptions.set") as mock_set:
+            test_option = mock.sentinel.option
+            test_kwargs = {"junk": "invalid"}
+            CombineOptions(options=test_option, **test_kwargs)
+        assert mock_set.call_args_list == [
+            mock.call("default"),
+            mock.call(test_option, **test_kwargs),
+        ]
+
+
+class Test_settings:
+    """The .settings() returns a dict full of the settings."""
+
+    def test_settings(self):
+        options = CombineOptions()
+        settings = options.settings()
+        assert isinstance(settings, dict)
+        assert list(settings.keys()) == CombineOptions.OPTION_KEYS
+        for key in CombineOptions.OPTION_KEYS:
+            assert settings[key] == getattr(options, key)
+
+
+def options_checks(options, checks):
+    # Check (parts of) options against a dictionary of "expected" values.
+    settings = options.settings()
+    return all(settings[key] == value for key, value in checks.items())
+
+
+class Test_set_and_context:
+    """Check the .set(arg, **kwargs) and .context(arg, **kwargs) behaviours."""
+
+    @staticmethod
+    def do_check(
+        op_arg=None,
+        op_kwargs=None,
+        before_checks=None,
+        after_checks=None,
+        initial_options=None,
+        op_is_set=True,
+    ):
+        """Generic test routine check method.
+
+        Perform an operation(op_arg, **op_kwargs) and test (partial) options state
+        before and after.  If provided, can also start from a non-default
+        'initial_options' state.
+
+        Used to generalise between the .set() and .context() calls.
+        In the case of .context(), the 'after' is within the block, and the 'before'
+        state should always be restored again afterwards.
+        """
+        if initial_options is not None:
+            options = initial_options
+        else:
+            options = CombineOptions()
+
+        op_kwargs = op_kwargs or {}
+
+        if before_checks is not None:
+            assert options_checks(options, before_checks)
+
+        if op_is_set:
+            # do "set" check
+            options.set(op_arg, **op_kwargs)
+            assert options_checks(options, after_checks)
+        else:
+            # do "context" checks
+            with options.context(op_arg, **op_kwargs):
+                assert options_checks(options, after_checks)
+            assert options_checks(options, before_checks)
+
+    @pytest.fixture(params=["set", "context"])
+    def op_is_set(self, request):
+        """Parametrise a test over both .set() and and .context() calls."""
+        return request.param == "set"
+
+    def test_empty_set(self):
+        # More or less, just check that an empty set() call is OK.
+        options = CombineOptions()
+        orig_settings = options.settings()
+        options.set()
+        assert options.settings() == orig_settings
+
+    def test_empty_context(self):
+        # More or less, just check that an empty context() call is OK.
+        options = CombineOptions()
+        orig_settings = options.settings()
+        with options.context():
+            assert options.settings() == orig_settings
+
+    def test_arg_dict(self, op_is_set):
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        set_arg = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        expect_after = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        self.do_check(
+            op_arg=set_arg,
+            before_checks=expect_before,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_arg_string(self, op_is_set):
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        set_arg = "comprehensive"
+        expect_after = {"merge_concat_sequence": "mc", "repeat_until_unchanged": True}
+        self.do_check(
+            op_arg=set_arg,
+            before_checks=expect_before,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_kwargs(self, op_is_set):
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        set_arg = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        expect_after = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        self.do_check(
+            op_arg=set_arg,
+            before_checks=expect_before,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_arg_kwargs(self, op_is_set):
path: lib/iris/tests/unit/combine/test_CombineOptions.py
user: login: stephenworsley
body: I think it's worth adding a similar test for when the arg is a string.
created_at: 2025-03-03T16:31:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# 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.
+"""Unit tests for the :func:`iris.io.loading.combine_cubes` function.
+
+Note: These tests are fairly extensive to cover functional uses within the loading
+operations.
+TODO: when function is public API, extend testing to the extended API options,
path: lib/iris/tests/unit/util/test_combine_cubes.py
user: login: stephenworsley
body: I believe this TODO is completed and can be removed now.
created_at: 2025-03-03T16:51:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# 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.
+"""Unit tests for the :func:`iris.io.loading.combine_cubes` function.
+
+Note: These tests are fairly extensive to cover functional uses within the loading
+operations.
+TODO: when function is public API, extend testing to the extended API options,
path: lib/iris/tests/unit/util/test_combine_cubes.py
user: login: pp-mo
body: OK thanks!
[811168b](https://github.com/SciTools/iris/pull/6334/commits/811168bf9674023e330edf1f2e153b282a91cae0)
created_at: 2025-03-07T01:24:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,215 @@
+# 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.
+"""Unit tests for the :mod:`iris._combine.CombineOptions` class."""
+
+from unittest import mock
+
+import pytest
+
+from iris import CombineOptions
+
+
+class TestInit:
+    def test_init_empty(self):
+        # Check how a bare init works
+        options = CombineOptions()
+        assert options.settings() == CombineOptions.SETTINGS["default"]
+
+    def test_init_args_kwargs(self):
+        # Check that init with args, kwargs equates to a pair of set() calls.
+        with mock.patch("iris.CombineOptions.set") as mock_set:
+            test_option = mock.sentinel.option
+            test_kwargs = {"junk": "invalid"}
+            CombineOptions(options=test_option, **test_kwargs)
+        assert mock_set.call_args_list == [
+            mock.call("default"),
+            mock.call(test_option, **test_kwargs),
+        ]
+
+
+class Test_settings:
+    """The .settings() returns a dict full of the settings."""
+
+    def test_settings(self):
+        options = CombineOptions()
+        settings = options.settings()
+        assert isinstance(settings, dict)
+        assert list(settings.keys()) == CombineOptions.OPTION_KEYS
+        for key in CombineOptions.OPTION_KEYS:
+            assert settings[key] == getattr(options, key)
+
+
+def options_checks(options, checks):
+    # Check (parts of) options against a dictionary of "expected" values.
+    settings = options.settings()
+    return all(settings[key] == value for key, value in checks.items())
+
+
+class Test_set_and_context:
+    """Check the .set(arg, **kwargs) and .context(arg, **kwargs) behaviours."""
+
+    @staticmethod
+    def do_check(
+        op_arg=None,
+        op_kwargs=None,
+        before_checks=None,
+        after_checks=None,
+        initial_options=None,
+        op_is_set=True,
+    ):
+        """Generic test routine check method.
+
+        Perform an operation(op_arg, **op_kwargs) and test (partial) options state
+        before and after.  If provided, can also start from a non-default
+        'initial_options' state.
+
+        Used to generalise between the .set() and .context() calls.
+        In the case of .context(), the 'after' is within the block, and the 'before'
+        state should always be restored again afterwards.
+        """
+        if initial_options is not None:
+            options = initial_options
+        else:
+            options = CombineOptions()
+
+        op_kwargs = op_kwargs or {}
+
+        if before_checks is not None:
+            assert options_checks(options, before_checks)
+
+        if op_is_set:
+            # do "set" check
+            options.set(op_arg, **op_kwargs)
+            assert options_checks(options, after_checks)
+        else:
+            # do "context" checks
+            with options.context(op_arg, **op_kwargs):
+                assert options_checks(options, after_checks)
+            assert options_checks(options, before_checks)
+
+    @pytest.fixture(params=["set", "context"])
+    def op_is_set(self, request):
+        """Parametrise a test over both .set() and and .context() calls."""
+        return request.param == "set"
+
+    def test_empty_set(self):
+        # More or less, just check that an empty set() call is OK.
+        options = CombineOptions()
+        orig_settings = options.settings()
+        options.set()
+        assert options.settings() == orig_settings
+
+    def test_empty_context(self):
+        # More or less, just check that an empty context() call is OK.
+        options = CombineOptions()
+        orig_settings = options.settings()
+        with options.context():
+            assert options.settings() == orig_settings
+
+    def test_arg_dict(self, op_is_set):
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        set_arg = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        expect_after = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        self.do_check(
+            op_arg=set_arg,
+            before_checks=expect_before,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_arg_string(self, op_is_set):
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        set_arg = "comprehensive"
+        expect_after = {"merge_concat_sequence": "mc", "repeat_until_unchanged": True}
+        self.do_check(
+            op_arg=set_arg,
+            before_checks=expect_before,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_kwargs(self, op_is_set):
+        expect_before = {"merge_concat_sequence": "m", "repeat_until_unchanged": False}
+        set_arg = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        expect_after = {"merge_concat_sequence": "c", "repeat_until_unchanged": True}
+        self.do_check(
+            op_arg=set_arg,
+            before_checks=expect_before,
+            after_checks=expect_after,
+            op_is_set=op_is_set,
+        )
+
+    def test_arg_kwargs(self, op_is_set):
path: lib/iris/tests/unit/combine/test_CombineOptions.py
user: login: pp-mo
body: Yes I see, nice spot !
[0c11048](https://github.com/SciTools/iris/pull/6334/commits/0c1104890cc80bd3efc7b446f1492f1bda6e3103)

Notes:
(1) having already clarified the purpose of `Test_settings`, in response to your other review comment about the obsolete "TODO" comment, I have now 'removed' `test_cat_enables_merge__custom` from the class since it actually _doesn't_ test against settings.

(2) On reworking the `test_arg_kwargs` code for this suggestion, I find it did not actually need the "initial_options" usage of the `do_check` method : And in fact, this kwarg is no longer not used _anywhere_, as all the tests are in fact compatible with starting from a "default" state.  
I have left this kwarg in place for now, but if you think it should be tidied away, I will do so.
created_at: 2025-03-07T01:27:46Z
author_association: MEMBER
diff_hunk: @@ -2398,3 +2401,142 @@ def _print_xml(doc):
     """
     result = doc.toprettyxml(indent="  ")
     return result.replace("&#10;", "\n")
+
+
+def _combine_options_asdict(options: str | dict | None) -> dict:
+    """Convert any valid combine options into an options dictionary."""
+    from iris import COMBINE_POLICY
+
+    if options is None:
+        opts_dict = COMBINE_POLICY.settings()
+    elif isinstance(options, dict):
+        opts_dict = options
+    elif isinstance(options, str):
+        if options in COMBINE_POLICY.SETTINGS:
+            opts_dict = COMBINE_POLICY.SETTINGS[options]
+        else:
+            msg = (
+                "Unrecognised settings name : expected one of "
+                f"{tuple(COMBINE_POLICY.SETTINGS)}."
+            )
+            raise ValueError(msg)
+    else:
+        msg = (  # type: ignore[unreachable]
+            f"arg 'options' has type {type(options)!r}, "
+            "expected one of (str | dict | None)"
+        )
+        raise ValueError(msg)  # type: ignore[unreachable]
+
+    return opts_dict
+
+
+def combine_cubes(
+    cubes: List[Cube],
+    options: str | dict | None = None,
+    **kwargs,
+) -> CubeList:
+    """Combine cubes, according to "combine options".
+
+    Applies a combination of :meth:`~iris.util.equalise_cubes`,
+    :meth:`~iris.cube.CubeList.merge` and/or :meth:`~iris.cube.CubeList.concatenate`
+    steps to the given cubes, as determined by the given settings (from `options` and
+    `kwargs`).
+
+    Parameters
+    ----------
+    cubes : list of :class:`~iris.cube.Cube`
+        A list of cubes to combine.
+
+    options : str or dict, optional
+        Either a standard "combine settings" name, i.e. one of the
+        :data:`iris.CombineOptions.SETTINGS_NAMES`, or a dictionary of
+        settings options, as described for :class:`~iris.CombineOptions`.
+        Defaults to the current :meth:`~iris.CombineOptions.settings` of the
+        :data:`iris.COMBINE_POLICY`.
+
+    kwargs : dict
+        Individual option setting values, i.e. values for keys named in
+        :data:`iris.CombineOptions.OPTION_KEYS`, as described for
+        :meth:`~iris.CombineOptions.set`.  These take precedence over those set by the
+        `options` arg.
+
+    Returns
+    -------
+    :class:`~iris.cube.CubeList`
+
+    Notes
+    -----
+        A ``support_multiple_references`` option will be accepted as valid, but will
+        have *no* effect on :func:`combine_cubes` because this option only acts during
+        load operations.
+
+
+    Examples
+    --------
+    .. testsetup::
+
+        import numpy as np
+        from iris.cube import Cube, CubeList
+        from iris.coords import DimCoord
+        from iris.util import combine_cubes
+
+        def testcube(timepts):
+            cube = Cube(np.array(timepts))
+            cube.add_dim_coord(
+                DimCoord(timepts, standard_name="time", units="days since 1990-01-01"),
+                0
+            )
+            return cube
+
+        cubes = CubeList([testcube([1., 2]), testcube([13., 14, 15])])
+        combinecubes_old_policysettings = iris.COMBINE_POLICY.settings()
+
+    .. testcleanup::
+
+        # restore old state to avoid upsetting other tests
+        iris.COMBINE_POLICY.set(combinecubes_old_policysettings)
+
+    >>> # Take a pair of sample cubes which can concatenate together
+    >>> print(cubes)
+    0: unknown / (unknown)                 (time: 2)
+    1: unknown / (unknown)                 (time: 3)
+    >>> print([cube.coord("time").points for cube in cubes])
+    [array([1., 2.]), array([13., 14., 15.])]
+
+    >>> # Show these do NOT combine with the "default" action, which only merges ..
+    >>> print(combine_cubes(cubes))
+    0: unknown / (unknown)                 (time: 2)
+    1: unknown / (unknown)                 (time: 3)
+    >>> # ... however, they **do** combine if you enable concatenation
+    >>> print(combine_cubes(cubes, merge_concat_sequence="mc"))
+    0: unknown / (unknown)                 (time: 5)
+    >>> # ... which may be controlled by various means
+    >>> iris.COMBINE_POLICY.set("recommended")
+    >>> print(combine_cubes(cubes))
+    0: unknown / (unknown)                 (time: 5)
+
+    >>> # Also, show how a differing attribute will block cube combination
+    >>> cubes[0].attributes["x"] = 3
+    >>> print(combine_cubes(cubes))
+    0: unknown / (unknown)                 (time: 2)
+    1: unknown / (unknown)                 (time: 3)
+    >>> # ... which can then be fixed by enabling attribute equalisation
+    >>> with iris.COMBINE_POLICY.context(equalise_cubes_kwargs={"apply_all":True}):
+    ...     print(combine_cubes(cubes))
+    ...
+    0: unknown / (unknown)                 (time: 5)
+
+    >>> # .. BUT NOTE : this modifies the original input cubes
+    >>> print(cubes[0].attributes.get("x"))
+    None
+
+    """
+    # TODO: somehow, provide a real + useful working code example
path: lib/iris/util.py
user: login: stephenworsley
body: I believe this can be removed now.
created_at: 2025-03-07T16:13:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -278,36 +371,48 @@ def _combine_cubes(cubes, options, merge_require_unique):
     """
     from iris.cube import CubeList
 
-    if not isinstance(cubes, CubeList):
-        cubes = CubeList(cubes)
+    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(cubes)
-        sequence = options["merge_concat_sequence"]
+        n_original_cubes = len(cubelist)
 
         if sequence[0] == "c":
             # concat if it comes first
-            cubes = cubes.concatenate()
+            cubelist = cubelist.concatenate()
         if "m" in sequence:
             # merge if requested
-            cubes = cubes.merge(unique=merge_require_unique)
+            # NOTE: this needs "unique=False" to make "iris.load()" work correctly.
+            # TODO: make configurable via options.
path: lib/iris/_combine.py
user: login: stephenworsley
body: This can be removed, as with line 367 above.
created_at: 2025-03-07T16:14:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-02-24T12:06:14Z
body: ## [Codecov](https://app.codecov.io/gh/SciTools/iris/pull/6334?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools) Report
Attention: Patch coverage is `85.26316%` with `14 lines` in your changes missing coverage. Please review.
> Project coverage is 89.78%. Comparing base [(`8745465`)](https://app.codecov.io/gh/SciTools/iris/commit/87454650a4647d46cb196b3f2b7f64e4a3a65cbb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools) to head [(`44bb2c6`)](https://app.codecov.io/gh/SciTools/iris/commit/44bb2c6e82c082f73cad181338f49aecdd83c7bf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools).
> Report is 81 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/SciTools/iris/pull/6334?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools) | Patch % | Lines |
|---|---|---|
| [lib/iris/util.py](https://app.codecov.io/gh/SciTools/iris/pull/6334?src=pr&el=tree&filepath=lib%2Firis%2Futil.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools#diff-bGliL2lyaXMvdXRpbC5weQ==) | 59.09% | [5 Missing and 4 partials :warning: ](https://app.codecov.io/gh/SciTools/iris/pull/6334?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools) |
| [lib/iris/\_combine.py](https://app.codecov.io/gh/SciTools/iris/pull/6334?src=pr&el=tree&filepath=lib%2Firis%2F_combine.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools#diff-bGliL2lyaXMvX2NvbWJpbmUucHk=) | 90.56% | [4 Missing and 1 partial :warning: ](https://app.codecov.io/gh/SciTools/iris/pull/6334?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #6334      +/-   ##
==========================================
- Coverage   89.80%   89.78%   -0.02%     
==========================================
  Files          90       90              
  Lines       23589    23632      +43     
  Branches     4402     4411       +9     
==========================================
+ Hits        21183    21217      +34     
- Misses       1662     1667       +5     
- Partials      744      748       +4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/SciTools/iris/pull/6334?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: pp-mo
created_at: 2025-02-25T12:37:43Z
body: ## Status update 2025-02-25
* functional code and APIs are basically complete and correct
* basic API documentation is now complete + correct
* doctests and examples need review
* all existing tests are passing
* most additional tests are now present
* some extra testing may be needed

The currently-failing doctests in the docsring of the `combine_cubes()` utility routine are just a placeholder.
(We do need a working example there...)

### My current details to-do list 
(maybe not exhaustive) ...
  * add unit tests for
     * [x] `LoadPolicy.context()`
     * [x]  `equalise_cubes_kwargs`control
         * [X] ~what happens if this contains invalid settings -- does that need further handling ?~
            (update: no I don't think this needs handling)
     * [x]  `merge_unique` control
     * [x] CubeList combine methods
  * review options + settings explanations for 
     * [x] `CombineOptions
        * [x] especially "settings" are more directly appropriate for loading -- needs saying, **at least**
     * [x] `LoadPolicy`
        * [x]  needs (at least) strong hint to refer to settings -- documented under  CombineOptions. ?use example?
  * add a proper example/doctests for
     * [x] `CombineOptions`
     * [x] `LOAD_POLICY` 
     * [x] `combine_cubes`
  * [ ] consider using enums for switchable API options (at least the `merge_concat_sequence` values)
  * [x] cross-reference user  documentation between (?all-of ?) ...
    * load functions
    * iris main module
    * LoadPolicy
    * user-guide loading chapter
author_association: MEMBER
user: login: pp-mo
created_at: 2025-02-26T09:53:42Z
body: I'm going to mark this "ready for review", since it is (I think) functionally complete.
But I must admit the list of to-dos is a bit daunting.

~For a functional query, I'm _still_ a bit unsure of the relationship complexity between CombineOptions and LoadPolicy,~
~not least because the default for the `options` of a `combine_cubes` call is defined by `LOAD_POLICY`.~
~And you can see that in the subsidiary [_combine_options_asdict](https://github.com/SciTools/iris/blob/88be3be20dd6f83d1eca3be2f4c9ac134e897e21/lib/iris/util.py#L2351) routine which I needed to pull out for sharing,which 'ought' to live in iris._combine, but actually relies on iris.loading .~
~Hmmm~
(update : resolved -- see below}

Hopefully I can get a big chunk of the TODOs today.
author_association: MEMBER
user: login: pp-mo
created_at: 2025-02-26T17:15:50Z
body: ## Note: 
Following considerable offline  discussion with @trexfeathers @stephenworsley , 
We decided it was better to re-merge the definitions of `CombineOptions` and `LoadPolicy`
So I've now done that : https://github.com/SciTools/iris/pull/6334/commits/f7ecf8433e45f8159dab03a0bbe8e15647a2c472

In short, `LOAD_POLICY` and `LoadPolicy` are now just _**aliases**_ for `COMBINE_POLICY` (object) and `CombineOptions` (class).
The notable reasons were :
  * since all APIs (internal included) took settings dictionaries  and not settings objects, there was no need ever to create a CombineOptions object
     * ... and clearly it was over-complex to make a **_separate_** global control object for `combine_cubes`, so I was going to be using `LOAD_POLICY` to control that too
  * in order to make either or both of `CombineOptions` and `LoadPolicy`docs legible, I was finding I had to replicate documentation writeup between the two
  * the "settings" classes of a CombineObject wanted to be the same as those of a LoadPolicy, but these were determined by the needs of the loading modes -- and notably tje distinction between "default" and "legacy" which is meaningless for `combine_cubes`)
  
So in the end ... it makes more sense to have a single definition, parts of which are irrelevant to one use, than to separate the two for the sake of a small difference

author_association: MEMBER
user: login: pp-mo
created_at: 2025-02-27T15:27:55Z
body: ## Update 2025-02-26
I finally completed my checklist for adding tests :champagne: 

I've also added a whatsnew, and some _really minimal_ redirection links from the iris main module API and the userguide loading chapter.
HOWEVER I have also realised that we are missing (probably need) a mention of combine in the UG chapter on merge+concatenate.  I don't know quite how to contemplate that at this point.

For now, I'm just awaiting comments
author_association: MEMBER
user: login: pp-mo
created_at: 2025-02-28T11:52:51Z
body: Rebased to update with main -- needed to fix whatsnew conflict
author_association: MEMBER
user: login: pp-mo
created_at: 2025-03-03T00:04:14Z
body: > making LOAD_POLICY a copy of COMBINE_POLICY, though I think it's worth adding another test to ensure that modifying one does indeed modify the other

I don't think there's a real point to answer for this one, as they aren't copies but different names for the _actual same object_.
Both references are set to the same thing [here](https://github.com/SciTools/iris/blob/cc74cdaa3608144f2e9aecd4bbd8dd965b7e4c09/lib/iris/__init__.py#L115-L121)
author_association: MEMBER
user: login: pp-mo
created_at: 2025-03-03T00:42:17Z
body: Update : thanks @stephenworsley 
I _think_ I addressed all comments to-date
(which is not to say there is not more to come)

please re-review !
author_association: MEMBER
user: login: stephenworsley
created_at: 2025-03-03T09:41:58Z
body: > I don't think there's a real point to answer for this one, as they aren't copies but different names for the _actual same object_.
> Both references are set to the same thing [here](https://github.com/SciTools/iris/blob/cc74cdaa3608144f2e9aecd4bbd8dd965b7e4c09/lib/iris/__init__.py#L115-L121)

ah yes, that's what I meant, I was just imprecise (or incorrect) in my language. I ask because I have seen some confusion in the past over how to expect configuration objects to behave when being modified or set (e.g. code of the form `iris.COMBINE_POLICY = ...` rather than `iris.COMBINE_POLICY[key] = ...`, which I imagine is not what we would expect to support). I think I recall that for some non-standard use cases, it mattered how these objects were imported so I wondered if there was any call for tests for what happens to the same object imported with different names. If there was any non-trivial interaction we wanted to ensure was supported, that might be worth considering a test for, but it may be the case as you say that everything is either trivial or ought to be unsupported.


author_association: CONTRIBUTOR
user: login: pp-mo
created_at: 2025-03-07T01:23:46Z
body: > > I don't think there's a real point to answer for this one, as they aren't copies but different names for the _actual same object_.
> > Both references are set to the same thing [here](https://github.com/SciTools/iris/blob/cc74cdaa3608144f2e9aecd4bbd8dd965b7e4c09/lib/iris/__init__.py#L115-L121)
> 
> ah yes, that's what I meant, I was just imprecise (or incorrect) in my language. I ask because I have seen some confusion in the past over how to expect configuration objects to behave when being modified or set (e.g. code of the form `iris.COMBINE_POLICY = ...` rather than `iris.COMBINE_POLICY[key] = ...`, which I imagine is not what we would expect to support). I think I recall that for some non-standard use cases, it mattered how these objects were imported so I wondered if there was any call for tests for what happens to the same object imported with different names. If there was any non-trivial interaction we wanted to ensure was supported, that might be worth considering a test for, but it may be the case as you say that everything is either trivial or ought to be unsupported.

OK I think I do see what you mean now.
I will add a warning about this to the docs where the control usage is explained.
See 857a4f56e
author_association: MEMBER
user: login: pp-mo
created_at: 2025-03-07T01:57:34Z
body: ## Update 2024-03-6 
Thanks @stephenworsley responded to comments so-far + up to date, I hope.
Please re-check !

Note that while other PRs are merging at present, unfortunately every merge tends to create conflict in the whastnew/latest.rst.
author_association: MEMBER
user: login: pp-mo
created_at: 2025-03-07T16:43:48Z
body: OK I had to rebase yet again, but hopefully that addresses the stale TODOs you pointed out.
author_association: MEMBER
user: login: pp-mo
created_at: 2025-03-07T18:27:20Z
body: Many thanks @stephenworsley !
author_association: MEMBER