diff --git a/docs/iris/src/conf.py b/docs/iris/src/conf.py
index 630c8f0afa..85a3d535b4 100644
--- a/docs/iris/src/conf.py
+++ b/docs/iris/src/conf.py
@@ -230,7 +230,7 @@ def autolog(message):
"menu_links_name": "Support",
"menu_links": [
(
- ' Source Code',
+ ' Source code',
"https://github.com/SciTools/iris",
),
(
@@ -242,7 +242,7 @@ def autolog(message):
"https://groups.google.com/forum/#!forum/scitools-iris-dev",
),
(
- ' StackOverflow For "How do I?"',
+ ' StackOverflow for "How do I?"',
"https://stackoverflow.com/questions/tagged/python-iris",
),
(
@@ -295,3 +295,15 @@ def autolog(message):
message="Matplotlib is currently using agg, which is a"
" non-GUI backend, so cannot show the figure.",
)
+
+
+# -- numfig options (built-in) ------------------------------------------------
+# Enable numfig.
+numfig = True
+
+numfig_format = {
+ "code-block": "Example %s",
+ "figure": "Figure %s",
+ "section": "Section %s",
+ "table": "Table %s",
+}
diff --git a/docs/iris/src/developers_guide/contributing_getting_involved.rst b/docs/iris/src/developers_guide/contributing_getting_involved.rst
index 0fd873517f..edcbbaf726 100644
--- a/docs/iris/src/developers_guide/contributing_getting_involved.rst
+++ b/docs/iris/src/developers_guide/contributing_getting_involved.rst
@@ -2,7 +2,7 @@
.. _development_where_to_start:
-Getting Involved
+Getting involved
----------------
Iris_ is an Open Source project hosted on Github and as such anyone with a
diff --git a/docs/iris/src/developers_guide/gitwash/index.rst b/docs/iris/src/developers_guide/gitwash/index.rst
index ddb73d0a84..d0e70597f1 100644
--- a/docs/iris/src/developers_guide/gitwash/index.rst
+++ b/docs/iris/src/developers_guide/gitwash/index.rst
@@ -1,7 +1,7 @@
.. _using-git:
-Working with *iris* source code
-================================================
+Working with Iris source code
+=============================
.. toctree::
:maxdepth: 2
diff --git a/docs/iris/src/further_topics/index.rst b/docs/iris/src/further_topics/index.rst
new file mode 100644
index 0000000000..b17203fe73
--- /dev/null
+++ b/docs/iris/src/further_topics/index.rst
@@ -0,0 +1,24 @@
+Introduction
+============
+
+Some specific areas of Iris may require further explanation or a deep dive
+into additional detail above and beyond that offered by the
+:ref:`User guide `.
+
+This section provides a collection of additional material on focused topics
+that may be of interest to the more advanced or curious user.
+
+.. hint::
+
+ If you wish further documentation on any specific topics or areas of Iris
+ that are missing, then please let us know by raising a `GitHub Documentation Issue`_
+ on `SciTools/Iris`_.
+
+
+* :doc:`metadata`
+* :doc:`lenient_metadata`
+* :doc:`lenient_maths`
+
+
+.. _GitHub Documentation Issue: https://github.com/SciTools/iris/issues/new?assignees=&labels=New%3A+Documentation%2C+Type%3A+Documentation&template=documentation.md&title=
+.. _SciTools/iris: https://github.com/SciTools/iris
\ No newline at end of file
diff --git a/docs/iris/src/further_topics/lenient_maths.rst b/docs/iris/src/further_topics/lenient_maths.rst
new file mode 100644
index 0000000000..6f139fd9bf
--- /dev/null
+++ b/docs/iris/src/further_topics/lenient_maths.rst
@@ -0,0 +1,281 @@
+.. _lenient maths:
+
+Lenient cube maths
+******************
+
+This section provides an overview of lenient cube maths. In particular, it explains
+what lenient maths involves, clarifies how it differs from normal or strict cube
+maths, and demonstrates how you can exercise fine control over whether your cube
+maths operations are lenient or strict.
+
+Note that, lenient cube maths is the default behaviour of Iris from version
+``3.0.0``.
+
+
+Introduction
+============
+
+Lenient maths stands somewhat on the shoulders of giants. If you've not already
+done so, you may want to recap the material discussed in the following sections,
+
+- :ref:`cube maths`,
+- :ref:`metadata`,
+- :ref:`lenient metadata`
+
+In addition to this, cube maths leans heavily on the :mod:`~iris.common.resolve`
+module, which provides the necessary infrastructure required by Iris to analyse
+and combine each :class:`~iris.cube.Cube` operand involved in a maths operation
+into the resultant :class:`~iris.cube.Cube`. It may be worth while investing
+some time to understand how the :class:`~iris.common.resolve.Resolve` class
+underpins cube maths, and consider how it may be used in general to combine
+or resolve cubes together.
+
+Given these prerequisites, recall that :ref:`lenient behaviour `
+introduced and discussed the concept of lenient metadata; a more pragmatic and
+forgiving approach to :ref:`comparing `,
+:ref:`combining ` and understanding the
+:ref:`differences ` between your metadata
+(:numref:`metadata members table`). The lenient metadata philosophy introduced
+there is extended to cube maths, with the view to also preserving as much common
+coordinate (:numref:`metadata classes table`) information, as well as common
+metadata, between the participating :class:`~iris.cube.Cube` operands as possible.
+
+Let's consolidate our understanding of lenient and strict cube maths through
+a practical worked example, which we'll explore together next.
+
+
+.. _lenient example:
+
+Lenient example
+===============
+
+.. testsetup:: lenient-example
+
+ import iris
+ from iris.common import LENIENT
+ experiment = iris.load_cube(iris.sample_data_path("hybrid_height.nc"), "air_potential_temperature")
+ control = experiment[0]
+ control.remove_aux_factory(control.aux_factory())
+ for coord in ["sigma", "forecast_reference_time", "forecast_period", "atmosphere_hybrid_height_coordinate", "surface_altitude"]:
+ control.remove_coord(coord)
+ control.attributes["Conventions"] = "CF-1.7"
+ experiment.attributes["experiment-id"] = "RT3 50"
+
+Consider the following :class:`~iris.cube.Cube` of ``air_potential_temperature``,
+which has an `atmosphere hybrid height parametric vertical coordinate`_, and
+represents the output of an low-resolution global atmospheric ``experiment``,
+
+.. doctest:: lenient-example
+
+ >>> print(experiment)
+ air_potential_temperature / (K) (model_level_number: 15; grid_latitude: 100; grid_longitude: 100)
+ Dimension coordinates:
+ model_level_number x - -
+ grid_latitude - x -
+ grid_longitude - - x
+ Auxiliary coordinates:
+ atmosphere_hybrid_height_coordinate x - -
+ sigma x - -
+ surface_altitude - x x
+ Derived coordinates:
+ altitude x x x
+ Scalar coordinates:
+ forecast_period: 0.0 hours
+ forecast_reference_time: 2009-09-09 17:10:00
+ time: 2009-09-09 17:10:00
+ Attributes:
+ Conventions: CF-1.5
+ STASH: m01s00i004
+ experiment-id: RT3 50
+ source: Data from Met Office Unified Model 7.04
+
+Consider also the following :class:`~iris.cube.Cube`, which has the same global
+spatial extent, and acts as a ``control``,
+
+.. doctest:: lenient-example
+
+ >>> print(control)
+ air_potential_temperature / (K) (grid_latitude: 100; grid_longitude: 100)
+ Dimension coordinates:
+ grid_latitude x -
+ grid_longitude - x
+ Scalar coordinates:
+ model_level_number: 1
+ time: 2009-09-09 17:10:00
+ Attributes:
+ Conventions: CF-1.7
+ STASH: m01s00i004
+ source: Data from Met Office Unified Model 7.04
+
+Now let's subtract these cubes in order to calculate a simple ``difference``,
+
+.. doctest:: lenient-example
+
+ >>> difference = experiment - control
+ >>> print(difference)
+ unknown / (K) (model_level_number: 15; grid_latitude: 100; grid_longitude: 100)
+ Dimension coordinates:
+ model_level_number x - -
+ grid_latitude - x -
+ grid_longitude - - x
+ Auxiliary coordinates:
+ atmosphere_hybrid_height_coordinate x - -
+ sigma x - -
+ surface_altitude - x x
+ Derived coordinates:
+ altitude x x x
+ Scalar coordinates:
+ forecast_period: 0.0 hours
+ forecast_reference_time: 2009-09-09 17:10:00
+ time: 2009-09-09 17:10:00
+ Attributes:
+ experiment-id: RT3 50
+ source: Data from Met Office Unified Model 7.04
+
+Note that, cube maths automatically takes care of broadcasting the
+dimensionality of the ``control`` up to that of the ``experiment``, in order to
+calculate the ``difference``. This is performed only after ensuring that both
+the **dimension coordinates** ``grid_latitude`` and ``grid_longitude`` are first
+:ref:`leniently equivalent `.
+
+As expected, the resultant ``difference`` contains the
+:class:`~iris.aux_factory.HybridHeightFactory` and all it's associated **auxiliary
+coordinates**. However, the **scalar coordinates** have been leniently combined to
+preserve as much coordinate information as possible, and the ``attributes``
+dictionaries have also been leniently combined. In addition, see what further
+:ref:`rationalisation ` is always performed by cube maths on
+the resultant metadata and coordinates.
+
+Also, note that the ``model_level_number`` **scalar coordinate** from the
+``control`` has be superseded by the similarly named **dimension coordinate**
+from the ``experiment`` in the resultant ``difference``.
+
+Now let's compare and contrast this lenient result with the strict alternative.
+But before we do so, let's first clarify how to control the behaviour of cube maths.
+
+
+Control the behaviour
+=====================
+
+As stated earlier, lenient cube maths is the default behaviour from Iris ``3.0.0``.
+However, this behaviour may be controlled via the thread-safe ``LENIENT["maths"]``
+runtime option,
+
+.. doctest:: lenient-example
+
+ >>> from iris.common import LENIENT
+ >>> print(LENIENT)
+ Lenient(maths=True)
+
+Which may be set and applied globally thereafter for Iris within the current
+thread of execution,
+
+.. doctest:: lenient-example
+
+ >>> LENIENT["maths"] = False # doctest: +SKIP
+ >>> print(LENIENT) # doctest: +SKIP
+ Lenient(maths=False)
+
+Or alternatively, temporarily alter the behaviour of cube maths only within the
+scope of the ``LENIENT`` `context manager`_,
+
+.. doctest:: lenient-example
+
+ >>> print(LENIENT)
+ Lenient(maths=True)
+ >>> with LENIENT.context(maths=False):
+ ... print(LENIENT)
+ ...
+ Lenient(maths=False)
+ >>> print(LENIENT)
+ Lenient(maths=True)
+
+
+Strict example
+==============
+
+Now that we know how to control the underlying behaviour of cube maths,
+let's return to our :ref:`lenient example `, but this
+time perform **strict** cube maths instead,
+
+.. doctest:: lenient-example
+
+ >>> with LENIENT.context(maths=False):
+ ... difference = experiment - control
+ ...
+ >>> print(difference)
+ unknown / (K) (model_level_number: 15; grid_latitude: 100; grid_longitude: 100)
+ Dimension coordinates:
+ model_level_number x - -
+ grid_latitude - x -
+ grid_longitude - - x
+ Auxiliary coordinates:
+ atmosphere_hybrid_height_coordinate x - -
+ sigma x - -
+ surface_altitude - x x
+ Derived coordinates:
+ altitude x x x
+ Scalar coordinates:
+ time: 2009-09-09 17:10:00
+ Attributes:
+ source: Data from Met Office Unified Model 7.04
+
+Although the numerical result of this strict cube maths operation is identical,
+it is not as rich in metadata as the :ref:`lenient alternative `.
+In particular, it does not contain the ``forecast_period`` and ``forecast_reference_time``
+**scalar coordinates**, or the ``experiment-id`` in the ``attributes`` dictionary.
+
+This is because strict cube maths, in general, will only return common metadata
+and common coordinates that are :ref:`strictly equivalent `.
+
+
+Finer detail
+============
+
+In general, if you want to preserve as much metadata and coordinate information as
+possible during cube maths, then opt to use the default lenient behaviour. Otherwise,
+favour the strict alternative if you require to enforce precise metadata and
+coordinate commonality.
+
+The following information may also help you decide whether lenient cube maths best
+suits your use case,
+
+- lenient behaviour uses :ref:`lenient equality ` to match the
+ metadata of coordinates, which is more tolerant to certain metadata differences,
+- lenient behaviour uses :ref:`lenient combination ` to create
+ the metadata of coordinates on the resultant :class:`~iris.cube.Cube`,
+- lenient behaviour will attempt to cover each dimension with a :class:`~iris.coords.DimCoord`
+ in the resultant :class:`~iris.cube.Cube`, even though only one :class:`~iris.cube.Cube`
+ operand may describe that dimension,
+- lenient behaviour will attempt to include **auxiliary coordinates** in the
+ resultant :class:`~iris.cube.Cube` that exist on only one :class:`~iris.cube.Cube`
+ operand,
+- lenient behaviour will attempt to include **scalar coordinates** in the
+ resultant :class:`~iris.cube.Cube` that exist on only one :class:`~iris.cube.Cube`
+ operand,
+- lenient behaviour will add a coordinate to the resultant :class:`~iris.cube.Cube`
+ with **bounds**, even if only one of the associated matching coordinates from the
+ :class:`~iris.cube.Cube` operands has **bounds**,
+- strict and lenient behaviour both require that the **points** and **bounds** of
+ matching coordinates from :class:`~iris.cube.Cube` operands must be strictly
+ equivalent. However, mismatching **bounds** of **scalar coordinates** are ignored
+ i.e., a scalar coordinate that is common to both :class:`~iris.cube.Cube` operands, with
+ equivalent **points** but different **bounds**, will be added to the resultant
+ :class:`~iris.cube.Cube` with but with **no bounds**
+
+.. _sanitise metadata:
+
+Additionally, cube maths will always perform the following rationalisation of the
+resultant :class:`~iris.cube.Cube`,
+
+- clear the ``standard_name``, ``long_name`` and ``var_name``, defaulting the
+ :meth:`~iris.common.mixin.CFVariableMixin.name` to ``unknown``,
+- clear the :attr:`~iris.cube.Cube.cell_methods`,
+- clear the :meth:`~iris.cube.Cube.cell_measures`,
+- clear the :meth:`~iris.cube.Cube.ancillary_variables`,
+- clear the ``STASH`` key from the :attr:`~iris.cube.Cube.attributes` dictionary,
+- assign the appropriate :attr:`~iris.common.mixin.CFVariableMixin.units`
+
+
+.. _atmosphere hybrid height parametric vertical coordinate: https://cfconventions.org/Data/cf-conventions/cf-conventions-1.8/cf-conventions.html#atmosphere-hybrid-height-coordinate
+.. _context manager: https://docs.python.org/3/library/contextlib.html
\ No newline at end of file
diff --git a/docs/iris/src/further_topics/lenient_metadata.rst b/docs/iris/src/further_topics/lenient_metadata.rst
new file mode 100644
index 0000000000..1b31759d9a
--- /dev/null
+++ b/docs/iris/src/further_topics/lenient_metadata.rst
@@ -0,0 +1,476 @@
+.. _lenient metadata:
+
+Lenient metadata
+****************
+
+This section discusses lenient metadata; what it is, what it means, and how you
+can perform **lenient** rather than **strict** operations with your metadata.
+
+
+Introduction
+============
+
+As discussed in :ref:`metadata`, a rich, common metadata API is available within
+Iris that supports metadata :ref:`equality `,
+:ref:`difference `, :ref:`combination `,
+and also :ref:`conversion `.
+
+The common metadata API is implemented through the ``metadata`` property
+on each of the Iris `CF Conventions`_ class containers
+(:numref:`metadata classes table`), and provides a common gateway for users to
+easily manage and manipulate their metadata in a consistent and unified way.
+
+This is primarily all thanks to the metadata classes (:numref:`metadata classes table`)
+that support the necessary state and behaviour required by the common metadata
+API. Namely, it is the ``equal`` (``__eq__``), ``difference`` and ``combine``
+methods that provide this rich metadata behaviour, all of which are explored
+more fully in :ref:`metadata`.
+
+
+Strict behaviour
+================
+
+.. testsetup:: strict-behaviour
+
+ import iris
+ cube = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
+ latitude = cube.coord("latitude")
+
+The feature that is common between the ``equal``, ``difference`` and
+``combine`` metadata class methods, is that they all perform **strict**
+metadata member comparisons **by default**.
+
+The **strict** behaviour implemented by these methods can be summarised
+as follows, where ``X`` and ``Y`` are any objects that are non-identical,
+
+.. _strict equality table:
+.. table:: - :ref:`Strict equality `
+ :widths: auto
+ :align: center
+
+ ======== ======== =========
+ Left Right ``equal``
+ ======== ======== =========
+ ``X`` ``Y`` ``False``
+ ``Y`` ``X`` ``False``
+ ``X`` ``X`` ``True``
+ ``X`` ``None`` ``False``
+ ``None`` ``X`` ``False``
+ ======== ======== =========
+
+.. _strict difference table:
+.. table:: - :ref:`Strict difference `
+ :widths: auto
+ :align: center
+
+ ======== ======== =================
+ Left Right ``difference``
+ ======== ======== =================
+ ``X`` ``Y`` (``X``, ``Y``)
+ ``Y`` ``X`` (``Y``, ``X``)
+ ``X`` ``X`` ``None``
+ ``X`` ``None`` (``X``, ``None``)
+ ``None`` ``X`` (``None``, ``X``)
+ ======== ======== =================
+
+.. _strict combine table:
+.. table:: - :ref:`Strict combination `
+ :widths: auto
+ :align: center
+
+ ======== ======== ===========
+ Left Right ``combine``
+ ======== ======== ===========
+ ``X`` ``Y`` ``None``
+ ``Y`` ``X`` ``None``
+ ``X`` ``X`` ``X``
+ ``X`` ``None`` ``None``
+ ``None`` ``X`` ``None``
+ ======== ======== ===========
+
+.. _strict example:
+
+This type of **strict** behaviour does offer obvious benefit and value. However,
+it can be unnecessarily restrictive. For example, consider the metadata of the
+following ``latitude`` coordinate,
+
+.. doctest:: strict-behaviour
+
+ >>> latitude.metadata
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Now, let's create a doctored version of this metadata with a different ``var_name``,
+
+.. doctest:: strict-behaviour
+
+ >>> metadata = latitude.metadata._replace(var_name=None)
+ >>> metadata
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name=None, units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Clearly, these metadata are different,
+
+.. doctest:: strict-behaviour
+
+ >>> metadata != latitude.metadata
+ True
+ >>> metadata.difference(latitude.metadata)
+ DimCoordMetadata(standard_name=None, long_name=None, var_name=(None, 'latitude'), units=None, attributes=None, coord_system=None, climatological=None, circular=None)
+
+And yet, they both have the same ``name``, which some may find slightly confusing
+(see :meth:`~iris.common.metadata.BaseMetadata.name` for clarification)
+
+.. doctest:: strict-behaviour
+
+ >>> metadata.name()
+ 'latitude'
+ >>> latitude.name()
+ 'latitude'
+
+Resolving this metadata inequality can only be overcome by ensuring that each
+metadata member precisely matches.
+
+If your workflow demands such metadata rigour, then the default strict behaviour
+of the common metadata API will satisfy your needs. Typically though, such
+strictness is not necessary, and as of Iris ``3.0.0`` an alternative more
+practical behaviour is available.
+
+
+.. _lenient behaviour:
+
+Lenient behaviour
+=================
+
+.. testsetup:: lenient-behaviour
+
+ import iris
+ cube = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
+ latitude = cube.coord("latitude")
+
+Lenient metadata aims to offer a practical, common sense alternative to the
+strict rigour of the default Iris metadata behaviour. It is intended to be
+complementary, and suitable for those users with a more relaxed requirement
+regarding their metadata.
+
+The lenient behaviour that is implemented as an alternative to the
+:ref:`strict equality `, :ref:`strict difference `,
+and :ref:`strict combination ` can be summarised
+as follows,
+
+.. _lenient equality table:
+.. table:: - Lenient equality
+ :widths: auto
+ :align: center
+
+ ======== ======== =========
+ Left Right ``equal``
+ ======== ======== =========
+ ``X`` ``Y`` ``False``
+ ``Y`` ``X`` ``False``
+ ``X`` ``X`` ``True``
+ ``X`` ``None`` ``True``
+ ``None`` ``X`` ``True``
+ ======== ======== =========
+
+.. _lenient difference table:
+.. table:: - Lenient difference
+ :widths: auto
+ :align: center
+
+ ======== ======== =================
+ Left Right ``difference``
+ ======== ======== =================
+ ``X`` ``Y`` (``X``, ``Y``)
+ ``Y`` ``X`` (``Y``, ``X``)
+ ``X`` ``X`` ``None``
+ ``X`` ``None`` ``None``
+ ``None`` ``X`` ``None``
+ ======== ======== =================
+
+.. _lenient combine table:
+.. table:: - Lenient combination
+ :widths: auto
+ :align: center
+
+ ======== ======== ===========
+ Left Right ``combine``
+ ======== ======== ===========
+ ``X`` ``Y`` ``None``
+ ``Y`` ``X`` ``None``
+ ``X`` ``X`` ``X``
+ ``X`` ``None`` ``X``
+ ``None`` ``X`` ``X``
+ ======== ======== ===========
+
+Lenient behaviour is enabled for the ``equal``, ``difference``, and ``combine``
+metadata class methods via the ``lenient`` keyword argument, which is ``False``
+by default. Let's first explore some examples of lenient equality, difference
+and combination, before going on to clarify which metadata members adopt
+lenient behaviour for each of the metadata classes.
+
+
+.. _lenient equality:
+
+Lenient equality
+----------------
+
+Lenient equality is enabled using the ``lenient`` keyword argument, therefore
+we are forced to use the ``equal`` method rather than the ``==`` operator
+(``__eq__``). Otherwise, the ``equal`` method and ``==`` operator are both
+functionally equivalent.
+
+For example, consider the :ref:`previous strict example `,
+where two separate ``latitude`` coordinates are compared, each with different
+``var_name`` members,
+
+.. doctest:: strict-behaviour
+
+ >>> metadata.equal(latitude.metadata, lenient=True)
+ True
+
+Unlike strict comparison, lenient comparison is a little more forgiving. In
+this case, leniently comparing **something** with **nothing** (``None``) will
+always be ``True``; it's the graceful compromise to the strict alternative.
+
+So let's take the opportunity to reinforce this a little further before moving on,
+by leniently comparing different ``attributes`` dictionaries; a constant source
+of strict contention.
+
+Firstly, populate the metadata of our ``latitude`` coordinate appropriately,
+
+.. doctest:: lenient-behaviour
+
+ >>> attributes = {"grinning face": "π", "neutral face": "π"}
+ >>> latitude.attributes = attributes
+ >>> latitude.metadata # doctest: +SKIP
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={'grinning face': 'π', 'neutral face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Then create another :class:`~iris.common.metadata.DimCoordMetadata` with a different
+``attributes`` `dict`_, namely,
+
+- the ``grinning face`` key is **missing**,
+- the ``neutral face`` key has the **same value**, and
+- the ``upside-down face`` key is **new**
+
+.. doctest:: lenient-behaviour
+
+ >>> attributes = {"neutral face": "π", "upside-down face": "π"}
+ >>> metadata = latitude.metadata._replace(attributes=attributes)
+ >>> metadata # doctest: +SKIP
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={'neutral face': 'π', 'upside-down face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Now, compare our metadata,
+
+.. doctest:: lenient-behaviour
+
+ >>> metadata.equal(latitude.metadata)
+ False
+ >>> metadata.equal(latitude.metadata, lenient=True)
+ True
+
+Again, lenient equality (:numref:`lenient equality table`) offers a more
+forgiving and practical alternative to strict behaviour.
+
+
+.. _lenient difference:
+
+Lenient difference
+------------------
+
+Similar to :ref:`lenient equality`, the lenient ``difference`` method
+(:numref:`lenient difference table`) considers there to be no difference between
+comparing **something** with **nothing** (``None``). This working assumption is
+not naively applied to all metadata members, but rather a more pragmatic approach
+is adopted, as discussed later in :ref:`lenient members`.
+
+Again, lenient behaviour for the ``difference`` metadata class method is enabled
+by the ``lenient`` keyword argument. For example, consider again the
+:ref:`previous strict example ` involving our ``latitude``
+coordinate,
+
+.. doctest:: strict-behaviour
+
+ >>> metadata.difference(latitude.metadata)
+ DimCoordMetadata(standard_name=None, long_name=None, var_name=(None, 'latitude'), units=None, attributes=None, coord_system=None, climatological=None, circular=None)
+ >>> metadata.difference(latitude.metadata, lenient=True) is None
+ True
+
+And revisiting our slightly altered ``attributes`` member comparison example,
+brings home the benefits of the lenient difference behaviour. So, given our
+``latitude`` coordinate with its populated ``attributes`` dictionary,
+
+.. doctest:: lenient-behaviour
+
+ >>> latitude.attributes # doctest: +SKIP
+ {'grinning face': 'π', 'neutral face': 'π'}
+
+We create another :class:`~iris.common.metadata.DimCoordMetadata` with a dissimilar
+``attributes`` member, namely,
+
+- the ``grinning face`` key is **missing**,
+- the ``neutral face`` key has a **different value**, and
+- the ``upside-down face`` key is **new**
+
+.. doctest:: lenient-behaviour
+
+ >>> attributes = {"neutral face": "π", "upside-down face": "π"}
+ >>> metadata = latitude.metadata._replace(attributes=attributes)
+ >>> metadata # doctest: +SKIP
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={'neutral face': 'π', 'upside-down face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Now comparing the strict and lenient behaviour for the ``difference`` method,
+highlights the change in how such dissimilar metadata is treated gracefully,
+
+.. doctest:: lenient-behaviour
+
+ >>> metadata.difference(latitude.metadata).attributes # doctest: +SKIP
+ {'upside-down face': 'π', 'neutral face': 'π'}, {'neutral face': 'π', 'grinning face': 'π'}
+ >>> metadata.difference(latitude.metadata, lenient=True).attributes # doctest: +SKIP
+ {'neutral face': 'π'}, {'neutral face': 'π'}
+
+
+.. _lenient combination:
+
+Lenient combination
+-------------------
+
+The behaviour of the lenient ``combine`` metadata class method is outlined
+in :numref:`lenient combine table`, and as with :ref:`lenient equality` and
+:ref:`lenient difference` is enabled throught the ``lenient`` keyword argument.
+
+The difference in behaviour between **lenient** and
+:ref:`strict combination ` is centered around the lenient
+handling of combining **something** with **nothing** (``None``) to return
+**something**. Whereas strict
+combination will only return a result from combining identical objects.
+
+Again, this is best demonstrated through a simple example of attempting to combine
+partially overlapping ``attributes`` member dictionaries. For example, given the
+following ``attributes`` dictionary of our favoured ``latitude`` coordinate,
+
+.. doctest:: lenient-behaviour
+
+ >>> latitude.attributes # doctest: +SKIP
+ {'grinning face': 'π', 'neutral face': 'π'}
+
+We create another :class:`~iris.common.metadata.DimCoordMetadata` with overlapping
+keys and values, namely,
+
+- the ``grinning face`` key is **missing**,
+- the ``neutral face`` key has the **same value**, and
+- the ``upside-down face`` key is **new**
+
+.. doctest:: lenient-behaviour
+
+ >>> attributes = {"neutral face": "π", "upside-down face": "π"}
+ >>> metadata = latitude.metadata._replace(attributes=attributes)
+ >>> metadata # doctest: +SKIP
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={'neutral face': 'π', 'upside-down face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Comparing the strict and lenient behaviour of ``combine`` side-by-side
+highlights the difference in behaviour, and the advantages of lenient combination
+for more inclusive, richer metadata,
+
+.. doctest:: lenient-behaviour
+
+ >>> metadata.combine(latitude.metadata).attributes
+ {'neutral face': 'π'}
+ >>> metadata.combine(latitude.metadata, lenient=True).attributes # doctest: +SKIP
+ {'neutral face': 'π', 'upside-down face': 'π', 'grinning face': 'π'}
+
+
+.. _lenient members:
+
+Lenient members
+---------------
+
+:ref:`lenient behaviour` is not applied regardlessly across all metadata members
+participating in a lenient ``equal``, ``difference`` or ``combine`` operation.
+Rather, a more pragmatic application is employed based on the `CF Conventions`_
+definition of the member, and whether being lenient would result in erroneous
+behaviour or interpretation.
+
+.. _lenient members table:
+.. table:: - Lenient member participation
+ :widths: auto
+ :align: center
+
+ ============================================================================================= ================== ============
+ Metadata Class Member Behaviour
+ ============================================================================================= ================== ============
+ All metadata classesβ ``standard_name`` ``lenient``β‘
+ All metadata classesβ ``long_name`` ``lenient``β‘
+ All metadata classesβ ``var_name`` ``lenient``β‘
+ All metadata classesβ ``units`` ``strict``
+ All metadata classesβ ``attributes`` ``lenient``
+ :class:`~iris.common.metadata.CellMeasureMetadata` ``measure`` ``strict``
+ :class:`~iris.common.metadata.CoordMetadata`, :class:`~iris.common.metadata.DimCoordMetadata` ``coord_system`` ``strict``
+ :class:`~iris.common.metadata.CoordMetadata`, :class:`~iris.common.metadata.DimCoordMetadata` ``climatological`` ``strict``
+ :class:`~iris.common.metadata.CubeMetadata` ``cell_methods`` ``strict``
+ :class:`~iris.common.metadata.DimCoordMetadata` ``circular`` ``strict`` Β§
+ ============================================================================================= ================== ============
+
+| **Key**
+| β - Applies to all metadata classes including :class:`~iris.common.metadata.AncillaryVariableMetadata`, which has no other specialised members
+| β‘ - See :ref:`special lenient name` for ``standard_name``, ``long_name``, and ``var_name``
+| Β§ - The ``circular`` is ignored for operations between :class:`~iris.common.metadata.CoordMetadata` and :class:`~iris.common.metadata.DimCoordMetadata`
+
+In summary, only ``standard_name``, ``long_name``, ``var_name`` and the ``attributes``
+members are treated leniently. All other members are considered to represent
+fundamental metadata that cannot, by their nature, be consider equivalent to
+metadata that is missing or ``None``. For example, a :class:`~iris.cube.Cube`
+with ``units`` of ``ms-1`` cannot be considered equivalent to another
+:class:`~iris.cube.Cube` with ``units`` of ``unknown``; this would be a false
+and dangerous scientific assumption to make.
+
+Similar arguments can be made for the ``measure``, ``coord_system``, ``climatological``,
+``cell_methods``, and ``circular`` members, all of which are treated with
+strict behaviour, regardlessly.
+
+
+.. _special lenient name:
+
+Special lenient name behaviour
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The ``standard_name``, ``long_name`` and ``var_name`` have a closer association
+with each other compared to all other metadata members, as they all
+underpin the functionality provided by the :meth:`~iris.common.mixin.CFVariableMixin.name`
+method. It is imperative that the :meth:`~iris.common.mixin.CFVariableMixin.name`
+derived from metadata remains constant for strict and lenient equality alike.
+
+As such, these metadata members have an additional layer of behaviour enforced
+during :ref:`lenient equality` in order to ensure that the identity or name of
+metadata does not change due to a side-effect of lenient comparison.
+
+For example, if simple :ref:`lenient equality `
+behaviour was applied to the ``standard_name``, ``long_name`` and ``var_name``,
+the following would be considered **not** equal,
+
+.. table::
+ :widths: auto
+ :align: center
+
+ ================= ============ ============
+ Member Left Right
+ ================= ============ ============
+ ``standard_name`` ``None`` ``latitude``
+ ``long_name`` ``latitude`` ``None``
+ ``var_name`` ``lat`` ``latitude``
+ ================= ============ ============
+
+Both the **Left** and **Right** metadata would have the same
+:meth:`~iris.common.mixin.CFVariableMixin.name` by definition i.e., ``latitude``.
+However, lenient equality would fail due to the difference in ``var_name``.
+
+To account for this, lenient equality is performed by two simple consecutive steps:
+
+- ensure that the result returned by the :meth:`~iris.common.mixin.CFVariableMixin.name`
+ method is the same for the metadata being compared, then
+- only perform :ref:`lenient equality ` between the
+ ``standard_name`` and ``long_name`` i.e., the ``var_name`` member is **not**
+ compared explicitly, as its value may have been accounted for through
+ :meth:`~iris.common.mixin.CFVariableMixin.name` equality
+
+
+.. _dict: https://docs.python.org/3/library/stdtypes.html#mapping-types-dict
+.. _CF Conventions: https://cfconventions.org/
diff --git a/docs/iris/src/further_topics/metadata.rst b/docs/iris/src/further_topics/metadata.rst
new file mode 100644
index 0000000000..3536c87a2b
--- /dev/null
+++ b/docs/iris/src/further_topics/metadata.rst
@@ -0,0 +1,1007 @@
+.. _metadata:
+
+Metadata
+********
+
+This section provides a detailed overview of how your metadata is managed
+within Iris. In particular, it discusses what metadata is, where it fits
+into Iris, and more importantly how you can create, access, manipulate,
+and analyse your metadata.
+
+All the finer details covered here may not be entirely relevant to your use
+case, but it's here if you ever need it. In fact, you may want to skip
+straight ahead to :ref:`richer metadata`, and take it from there.
+
+
+Introduction
+============
+
+As discussed in :ref:`iris_data_structures`, Iris draws heavily from the
+`NetCDF CF Metadata Conventions`_ as a source for its data model, thus building
+on the widely recognised and understood terminology defined within those
+`CF Conventions`_ by the scientific community.
+
+In :ref:`iris_data_structures` we introduced several fundamental classes in Iris
+that care about your ``data``, and also your ``metadata`` i.e., `data about data`_.
+These are the :class:`~iris.cube.Cube`, the :class:`~iris.coords.AuxCoord`, and the
+:class:`~iris.coords.DimCoord`, all of which should be familiar to you now. In
+addition to these, Iris models several other classes of `CF Conventions`_
+metadata. Namely,
+
+- the :class:`~iris.coords.AncillaryVariable`, see `Ancillary Data`_ and `Flags`_,
+- the :class:`~iris.coords.CellMeasure`, see `Cell Measures`_,
+- the :class:`~iris.aux_factory.AuxCoordFactory`, see `Parametric Vertical Coordinate`_
+
+Collectively, the aforementioned classes will be known here as the Iris
+`CF Conventions`_ classes.
+
+.. hint::
+
+ If there are any `CF Conventions`_ metadata missing from Iris that you
+ care about, then please let us know by raising a `GitHub Issue`_ on
+ `SciTools/iris`_
+
+
+Common metadata
+===============
+
+Each of the Iris `CF Conventions`_ classes use **metadata** to define them and
+give them meaning.
+
+The **metadata** used to define an Iris `CF Conventions`_ class is composed of
+individual **metadata members**, almost all of which reference specific
+`CF Conventions`_ terms. The individual metadata members used to define each of
+the Iris `CF Conventions`_ classes are shown in :numref:`metadata members table`.
+
+As :numref:`metadata members table` highlights, **specific** metadata is used to
+define and represent each Iris `CF Conventions`_ class. This means that metadata
+alone, can be used to easily **identify**, **compare** and **differentiate**
+between individual class instances.
+
+For example, the collective metadata used to define an
+:class:`~iris.coords.AncillaryVariable` are the ``standard_name``, ``long_name``,
+``var_name``, ``units``, and ``attributes`` members. Note that, these are the
+actual `data attribute`_ names of the metadata members on the Iris class.
+
+.. _metadata members table:
+.. table:: - Iris classes that model `CF Conventions`_ metadata
+ :widths: auto
+ :align: center
+
+ =================== ======================================= ============================== ========================================== ================================= ======================== ============================== ===================
+ Metadata members :class:`~iris.coords.AncillaryVariable` :class:`~iris.coords.AuxCoord` :class:`~iris.aux_factory.AuxCoordFactory` :class:`~iris.coords.CellMeasure` :class:`~iris.cube.Cube` :class:`~iris.coords.DimCoord` Metadata members
+ =================== ======================================= ============================== ========================================== ================================= ======================== ============================== ===================
+ ``standard_name`` β β β β β β ``standard_name``
+ ``long_name`` β β β β β β ``long_name``
+ ``var_name`` β β β β β β ``var_name``
+ ``units`` β β β β β β ``units``
+ ``attributes`` β β β β β β ``attributes``
+ ``coord_system`` β β β ``coord_system``
+ ``climatological`` β β β ``climatological``
+ ``measure`` β ``measure``
+ ``cell_methods`` β ``cell_methods``
+ ``circular`` β ``circular``
+ =================== ======================================= ============================== ========================================== ================================= ======================== ============================== ===================
+
+.. note::
+
+ The :attr:`~iris.coords.DimCoord.var_name` and :attr:`~iris.coords.DimCoord.circular`
+ metadata members are Iris specific terms, rather than recognised `CF Conventions`_
+ terms.
+
+
+Common metadata API
+===================
+
+.. testsetup::
+
+ import iris
+ cube = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
+
+As of Iris ``3.0.0``, a unified treatment of metadata has been applied
+across each Iris class (:numref:`metadata members table`) to allow users
+to easily manage and manipulate their metadata in a consistent way.
+
+This is achieved through the ``metadata`` property, which allows you to
+manipulate the associated underlying metadata members as a collective.
+For example, given the following :class:`~iris.cube.Cube`,
+
+ >>> print(cube)
+ air_temperature / (K) (time: 240; latitude: 37; longitude: 49)
+ Dimension coordinates:
+ time x - -
+ latitude - x -
+ longitude - - x
+ Auxiliary coordinates:
+ forecast_period x - -
+ Scalar coordinates:
+ forecast_reference_time: 1859-09-01 06:00:00
+ height: 1.5 m
+ Attributes:
+ Conventions: CF-1.5
+ Model scenario: A1B
+ STASH: m01s03i236
+ source: Data from Met Office Unified Model 6.05
+ Cell methods:
+ mean: time (6 hour)
+
+We can easily get all of the associated metadata of the :class:`~iris.cube.Cube`
+using the ``metadata`` property:
+
+ >>> cube.metadata
+ CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
+
+We can also inspect the ``metadata`` of the ``longitude``
+:class:`~iris.coords.DimCoord` attached to the :class:`~iris.cube.Cube` in the same way:
+
+ >>> cube.coord("longitude").metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Or use the ``metadata`` property again, but this time on the ``forecast_period``
+:class:`~iris.coords.AuxCoord` attached to the :class:`~iris.cube.Cube`:
+
+ >>> cube.coord("forecast_period").metadata
+ CoordMetadata(standard_name='forecast_period', long_name=None, var_name='forecast_period', units=Unit('hours'), attributes={}, coord_system=None, climatological=False)
+
+Note that, the ``metadata`` property is available on each of the Iris `CF Conventions`_
+class containers referenced in :numref:`metadata members table`, and thus provides
+a **common** and **consistent** approach to managing your metadata, which we'll
+now explore a little more fully.
+
+
+Metadata classes
+----------------
+
+The ``metadata`` property will return an appropriate `namedtuple`_ metadata class
+for each Iris `CF Conventions`_ class container. The metadata class returned by
+each container class is shown in :numref:`metadata classes table` below,
+
+.. _metadata classes table:
+.. table:: - Iris namedtuple metadata classes
+ :widths: auto
+ :align: center
+
+ ========================================== ========================================================
+ Container class Metadata class
+ ========================================== ========================================================
+ :class:`~iris.coords.AncillaryVariable` :class:`~iris.common.metadata.AncillaryVariableMetadata`
+ :class:`~iris.coords.AuxCoord` :class:`~iris.common.metadata.CoordMetadata`
+ :class:`~iris.aux_factory.AuxCoordFactory` :class:`~iris.common.metadata.CoordMetadata`
+ :class:`~iris.coords.CellMeasure` :class:`~iris.common.metadata.CellMeasureMetadata`
+ :class:`~iris.cube.Cube` :class:`~iris.common.metadata.CubeMetadata`
+ :class:`~iris.coords.DimCoord` :class:`~iris.common.metadata.DimCoordMetadata`
+ ========================================== ========================================================
+
+Akin to the behaviour of a `namedtuple`_, the metadata classes in
+:numref:`metadata classes table` create **tuple-like** instances i.e., they provide a
+**snapshot** of the associated metadata member **values**, which are **not
+settable**, but they **may be mutable** depending on the data-type of the member.
+For example, given the following ``metadata`` of a :class:`~iris.coords.DimCoord`,
+
+ >>> longitude = cube.coord("longitude")
+ >>> metadata = longitude.metadata
+ >>> metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+The ``metadata`` member value **is** the same as the container class member value,
+
+ >>> metadata.attributes is longitude.attributes
+ True
+ >>> metadata.circular is longitude.circular
+ True
+
+Like a `namedtuple`_, the ``metadata`` member is **not settable**,
+
+ >>> metadata.attributes = {"grinning face": "π"}
+ Traceback (most recent call last):
+ AttributeError: can't set attribute
+
+However, for a `dict`_ member, it **is mutable**,
+
+ >>> metadata.attributes
+ {}
+ >>> longitude.attributes["grinning face"] = "π"
+ >>> metadata.attributes
+ {'grinning face': 'π'}
+ >>> metadata.attributes["grinning face"] = "π"
+ >>> longitude.attributes
+ {'grinning face': 'π'}
+
+But ``metadata`` members with simple values are **not** mutable,
+
+ >>> metadata.circular
+ False
+ >>> longitude.circular = True
+ >>> metadata.circular
+ False
+
+And of course, they're also **not** settable,
+
+ >>> metadata.circular = True
+ Traceback (most recent call last):
+ AttributeError: can't set attribute
+
+Note that, the ``metadata`` property re-creates a **new** instance per invocation,
+with a **snapshot** of the container class metadata values at that point in time,
+
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={'grinning face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=True)
+
+Skip ahead to :ref:`metadata assignment ` for a fuller
+discussion on options how to **set** and **get** metadata on the instance of
+an Iris `CF Conventions`_ container class (:numref:`metadata classes table`).
+
+
+Metadata class behaviour
+------------------------
+
+As mentioned previously, the metadata classes in :numref:`metadata classes table`
+inherit the behaviour of a `namedtuple`_, and so act and feel like a `namedtuple`_,
+just as you might expect. For example, given the following ``metadata``,
+
+ >>> metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={'grinning face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+We can use the `namedtuple._make`_ method to create a **new**
+:class:`~iris.common.metadata.DimCoordMetadata` instance from an existing sequence
+or iterable. The number and order of the values used in the iterable must match that
+of the associated `namedtuple._fields`_, which is discussed later,
+
+ >>> values = (1, 2, 3, 4, 5, 6, 7, 8)
+ >>> metadata._make(values)
+ DimCoordMetadata(standard_name=1, long_name=2, var_name=3, units=4, attributes=5, coord_system=6, climatological=7, circular=8)
+
+Note that, `namedtuple._make`_ is a class method, and so it is possible to
+create a **new** instance directly from the metadata class itself,
+
+ >>> from iris.common import DimCoordMetadata
+ >>> DimCoordMetadata._make(values)
+ DimCoordMetadata(standard_name=1, long_name=2, var_name=3, units=4, attributes=5, coord_system=6, climatological=7, circular=8)
+
+It is also possible to easily convert ``metadata`` to an `OrderedDict`_
+using the `namedtuple._asdict`_ method. This can be particularly handy when a
+standard Python built-in container is required to represent your ``metadata``,
+
+ >>> metadata._asdict()
+ OrderedDict([('standard_name', 'longitude'), ('long_name', None), ('var_name', 'longitude'), ('units', Unit('degrees')), ('attributes', {'grinning face': 'π'}), ('coord_system', GeogCS(6371229.0)), ('climatological', False), ('circular', False)])
+
+Using the `namedtuple._replace`_ method allows you to create a new metadata
+class instance, but replacing specified members with **new** associated values,
+
+ >>> metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={'grinning face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+ >>> metadata._replace(standard_name=None, units=None)
+ DimCoordMetadata(standard_name=None, long_name=None, var_name='longitude', units=None, attributes={'grinning face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Another very useful method from the `namedtuple`_ toolkit is `namedtuple._fields`_.
+This method returns a tuple of strings listing the ``metadata`` members, in a
+fixed order. This allows you to easily iterate over the metadata class members,
+for what ever purpose you may require, e.g.,
+
+ >>> metadata._fields
+ ('standard_name', 'long_name', 'var_name', 'units', 'attributes', 'coord_system', 'climatological', 'circular')
+
+ >>> tuple([getattr(metadata, member) for member in metadata._fields])
+ ('longitude', None, 'longitude', Unit('degrees'), {'grinning face': 'π'}, GeogCS(6371229.0), False, False)
+
+ >>> tuple([getattr(metadata, member) for member in metadata._fields if member.endswith("name")])
+ ('longitude', None, 'longitude')
+
+Note that, `namedtuple._fields`_ is also a class method, so you don't need
+an instance to determine the members of a metadata class, e.g.,
+
+ >>> from iris.common import CubeMetadata
+ >>> CubeMetadata._fields
+ ('standard_name', 'long_name', 'var_name', 'units', 'attributes', 'cell_methods')
+
+Aside from the benefit of metadata classes inheriting behaviour and state
+from `namedtuple`_, further additional rich behaviour is also available,
+which we explore next.
+
+
+.. _richer metadata:
+
+Richer metadata behaviour
+-------------------------
+
+.. testsetup:: richer-metadata
+
+ import iris
+ import numpy as np
+ from iris.common import CoordMetadata
+ cube = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
+ longitude = cube.coord("longitude")
+
+The metadata classes from :numref:`metadata classes table` support additional
+behaviour above and beyond that of the standard Python `namedtuple`_, which
+allows you to easily **compare**, **combine**, **convert** and understand the
+**difference** between your ``metadata`` instances.
+
+
+.. _metadata equality:
+
+Metadata equality
+^^^^^^^^^^^^^^^^^
+
+The metadata classes support both **equality** (``__eq__``) and **inequality**
+(``__ne__``), but no other `rich comparison`_ operators are implemented.
+This is simply because there is no obvious ordering to any collective of metadata
+members, as defined in :numref:`metadata members table`.
+
+For example, given the following :class:`~iris.coords.DimCoord`,
+
+.. doctest:: richer-metadata
+
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+We can compare ``metadata`` using the ``==`` operator, as you may naturally
+expect,
+
+.. doctest:: richer-metadata
+
+ >>> longitude.metadata == longitude.metadata
+ True
+
+Or alternatively, using the ``equal`` method instead,
+
+.. doctest:: richer-metadata
+
+ >>> longitude.metadata.equal(longitude.metadata)
+ True
+
+Note that, the ``==`` operator (``__eq__``) and the ``equal`` method are
+both functionally equivalent. However, the ``equal`` method also provides
+a means to enable **lenient** equality, as discussed in :ref:`lenient equality`.
+
+
+.. _strict equality:
+
+Strict equality
+"""""""""""""""
+
+By default, metadata class equality will perform a **strict** comparison between
+each associated ``metadata`` member. If **any** ``metadata`` member has a
+different value, then the result of the operation will be ``False``. For example,
+
+.. doctest:: richer-metadata
+
+ >>> other = longitude.metadata._replace(standard_name=None)
+ >>> other
+ DimCoordMetadata(standard_name=None, long_name=None, var_name='longitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+ >>> longitude.metadata == other
+ False
+
+.. doctest:: richer-metadata
+
+ >>> longitude.attributes = {"grinning face": "π"}
+ >>> other = longitude.metadata._replace(attributes={"grinning face": "π"})
+ >>> other
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={'grinning face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+ >>> longitude.metadata == other
+ False
+
+One further point worth highlighting is it is possible for `NumPy`_ scalars
+and arrays to appear in the ``attributes`` `dict`_ of some Iris metadata class
+instances. Normally, this would cause issues. For example,
+
+.. doctest:: richer-metadata
+
+ >>> simply = {"one": np.int(1), "two": np.array([1.0, 2.0])}
+ >>> simply
+ {'one': 1, 'two': array([1., 2.])}
+ >>> fruity = {"one": np.int(1), "two": np.array([1.0, 2.0])}
+ >>> fruity
+ {'one': 1, 'two': array([1., 2.])}
+ >>> simply == fruity
+ Traceback (most recent call last):
+ ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
+
+However, metadata class equality is rich enough to handle this eventuality,
+
+.. doctest:: richer-metadata
+
+ >>> metadata1 = cube.metadata._replace(attributes=simply)
+ >>> metadata2 = cube.metadata._replace(attributes=fruity)
+ >>> metadata1
+ CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'one': 1, 'two': array([1., 2.])}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
+ >>> metadata2
+ CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'one': 1, 'two': array([1., 2.])}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
+
+.. doctest:: richer-metadata
+
+ >>> metadata1 == metadata2
+ True
+
+.. doctest:: richer-metadata
+
+ >>> metadata1
+ CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'one': 1, 'two': array([1., 2.])}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
+ >>> metadata2 = cube.metadata._replace(attributes={"one": np.int(1), "two": np.array([1000.0, 2000.0])})
+ >>> metadata2
+ CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'one': 1, 'two': array([1000., 2000.])}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
+ >>> metadata1 == metadata2
+ False
+
+
+.. _compare like:
+
+Comparing like with like
+""""""""""""""""""""""""
+
+So far in our journey through metadata class equality, we have only considered
+cases where the operands are instances of the **same** type. It is possible to
+compare instances of **different** metadata classes, but the result will always
+be ``False``,
+
+.. doctest:: richer-metadata
+
+ >>> cube.metadata == longitude.metadata
+ False
+
+The reason different metadata classes cannot be compared is simply because each
+metadata class contains **different** members, as shown in
+:numref:`metadata members table`. However, there is an exception to the rule...
+
+
+.. _exception rule:
+
+Exception to the rule
+~~~~~~~~~~~~~~~~~~~~~
+
+In general, **different** metadata classes cannot be compared, however support
+is provided for comparing :class:`~iris.common.metadata.CoordMetadata` and
+:class:`~iris.common.metadata.DimCoordMetadata` metadata classes. For example,
+consider the following :class:`~iris.common.metadata.DimCoordMetadata`,
+
+.. doctest:: richer-metadata
+
+ >>> latitude = cube.coord("latitude")
+ >>> latitude.metadata
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Next we create a new :class:`~iris.common.metadata.CoordMetadata` instance from
+the :class:`~iris.common.metadata.DimCoordMetadata` instance,
+
+.. doctest:: richer-metadata
+
+ >>> kwargs = latitude.metadata._asdict()
+ >>> del kwargs["circular"]
+ >>> metadata = CoordMetadata(**kwargs)
+ >>> metadata
+ CoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False)
+
+.. hint::
+
+ Alternatively, use the ``from_metadata`` class method instead, see
+ :ref:`metadata conversion`.
+
+Comparing the instances confirms that equality is indeed supported between
+:class:`~iris.common.metadata.DimCoordMetadata` and :class:`~iris.common.metadata.CoordMetadata`
+classes,
+
+.. doctest:: richer-metadata
+
+ >>> latitude.metadata == metadata
+ True
+
+The reason for this behaviour is primarily historical. The ``circular``
+member has **never** been used by the ``__eq__`` operator when comparing an
+:class:`~iris.coords.AuxCoord` and a :class:`~iris.coords.DimCoord`. Therefore
+for consistency, this behaviour is also extended to ``__eq__`` for the associated
+container metadata classes.
+
+However, note that the ``circular`` member **is used** by the ``__eq__`` operator
+when comparing one :class:`~iris.coords.DimCoord` to another. This also applies
+when comparing :class:`~iris.common.metadata.DimCoordMetadata`.
+
+This exception to the rule for :ref:`equality ` also applies
+to the :ref:`difference ` and :ref:`combine `
+methods of metadata classes.
+
+
+.. _metadata difference:
+
+Metadata difference
+^^^^^^^^^^^^^^^^^^^
+
+Being able to compare metadata is valuable, especially when we have the
+convenience of being able to do this easily with metadata classes. However,
+when the result of comparing two metadata instances is ``False``, it begs
+the question, "**what's the difference?**"
+
+Well, this is where we pull the ``difference`` method out of the metadata
+toolbox. First, let's create some ``metadata`` to compare,
+
+.. doctest:: richer-metadata
+
+ >>> longitude = cube.coord("longitude")
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={'grinning face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Now, we replace some members of the :class:`~iris.common.metadata.DimCoordMetadata` with
+different values,
+
+.. doctest:: richer-metadata
+
+ >>> from cf_units import Unit
+ >>> metadata = longitude.metadata._replace(long_name="lon", var_name="lon", units=Unit("radians"))
+ >>> metadata
+ DimCoordMetadata(standard_name='longitude', long_name='lon', var_name='lon', units=Unit('radians'), attributes={'grinning face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+First, confirm that the ``metadata`` is different,
+
+.. doctest:: richer-metadata
+
+ >>> longitude.metadata != metadata
+ True
+
+As expected, the ``metadata`` is different. Now, let's answer the question,
+"**what's the difference?**",
+
+.. doctest:: richer-metadata
+
+ >>> longitude.metadata.difference(metadata)
+ DimCoordMetadata(standard_name=None, long_name=(None, 'lon'), var_name=('longitude', 'lon'), units=(Unit('degrees'), Unit('radians')), attributes=None, coord_system=None, climatological=None, circular=None)
+
+The ``difference`` method returns a :class:`~iris.common.metadata.DimCoordMetadata` instance, when
+there is **at least** one ``metadata`` member with a different value, where,
+
+- ``None`` means that there was **no** difference for the member,
+- a `tuple`_ contains the two different associated values for the member
+
+Given our example, only the ``long_name``, ``var_name`` and ``units`` members
+have different values, as expected. Note that, the ``difference`` method **is
+not** commutative. The order of the tuple member values is the same order
+of the metadata class instances being compared, e.g., changing the
+``difference`` instance order is reflected in the result,
+
+.. doctest:: richer-metadata
+
+ >>> metadata.difference(longitude.metadata)
+ DimCoordMetadata(standard_name=None, long_name=('lon', None), var_name=('lon', 'longitude'), units=(Unit('radians'), Unit('degrees')), attributes=None, coord_system=None, climatological=None, circular=None)
+
+Also, when the ``metadata`` being compared **is identical**, then ``None``
+is simply returned,
+
+.. doctest:: richer-metadata
+
+ >>> metadata.difference(metadata) is None
+ True
+
+It's worth highlighting that for the ``attributes`` `dict`_ member, only
+those keys with **different values** or **missing keys** will be returned by the
+``difference`` method. For example, let's customise the ``attributes`` member of
+the following :class:`~iris.common.metadata.DimCoordMetadata`,
+
+.. doctest:: richer-metadata
+
+ >>> attributes = {"grinning face": "π", "neutral face": "π"}
+ >>> longitude.attributes = attributes
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={'grinning face': 'π', 'neutral face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Then create another :class:`~iris.common.metadata.DimCoordMetadata` with a different
+``attributes`` `dict`_, namely,
+
+- the ``grinning face`` key has the **same value**,
+- the ``neutral face`` key has a **different value**,
+- the ``upside-down face`` key is **new**
+
+.. doctest:: richer-metadata
+
+ >>> attributes = {"grinning face": "π", "neutral face": "π", "upside-down face": "π"}
+ >>> metadata = longitude.metadata._replace(attributes=attributes)
+ >>> metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={'grinning face': 'π', 'neutral face': 'π', 'upside-down face': 'π'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Now, let's compare the two above instances and see what ``attributes`` member differences we get,
+
+.. doctest:: richer-metadata
+
+ >>> longitude.metadata.difference(metadata) # doctest: +SKIP
+ DimCoordMetadata(standard_name=None, long_name=None, var_name=None, units=None, attributes=({'neutral face': 'π'}, {'neutral face': 'π', 'upside-down face': 'π'}), coord_system=None, climatological=None, circular=None)
+
+
+.. _diff like:
+
+Diffing like with like
+""""""""""""""""""""""
+
+As discussed in :ref:`compare like`, it only makes sense to determine the
+``difference`` between **similar** metadata class instances. However, note that
+the :ref:`exception to the rule ` still applies here i.e.,
+support is provided between :class:`~iris.common.metadata.CoordMetadata` and
+:class:`~iris.common.metadata.DimCoordMetadata` metadata classes.
+
+For example, given the following :class:`~iris.coords.AuxCoord` and
+:class:`~iris.coords.DimCoord`,
+
+.. doctest:: richer-metadata
+
+ >>> forecast_period = cube.coord("forecast_period")
+ >>> latitude = cube.coord("latitude")
+
+We can inspect their associated ``metadata``,
+
+.. doctest:: richer-metadata
+
+ >>> forecast_period.metadata
+ CoordMetadata(standard_name='forecast_period', long_name=None, var_name='forecast_period', units=Unit('hours'), attributes={}, coord_system=None, climatological=False)
+ >>> latitude.metadata
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Before comparing them to determine the values of metadata members that are different,
+
+.. doctest:: richer-metadata
+
+ >>> forecast_period.metadata.difference(latitude.metadata)
+ CoordMetadata(standard_name=('forecast_period', 'latitude'), long_name=None, var_name=('forecast_period', 'latitude'), units=(Unit('hours'), Unit('degrees')), attributes=None, coord_system=(None, GeogCS(6371229.0)), climatological=None)
+
+.. doctest:: richer-metadata
+
+ >>> latitude.metadata.difference(forecast_period.metadata)
+ DimCoordMetadata(standard_name=('latitude', 'forecast_period'), long_name=None, var_name=('latitude', 'forecast_period'), units=(Unit('degrees'), Unit('hours')), attributes=None, coord_system=(GeogCS(6371229.0), None), climatological=None, circular=(False, None))
+
+In general, however, comparing **different** metadata classes will result in a
+``TypeError`` being raised,
+
+.. doctest:: richer-metadata
+
+ >>> cube.metadata.difference(longitude.metadata)
+ Traceback (most recent call last):
+ TypeError: Cannot differ 'CubeMetadata' with .
+
+
+.. _metadata combine:
+
+Metadata combination
+^^^^^^^^^^^^^^^^^^^^
+
+.. testsetup:: metadata-combine
+
+ import iris
+ cube = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
+ longitude = cube.coord("longitude")
+
+So far we've seen how to :ref:`compare metadata `, and also how
+to determine the :ref:`difference between metadata `. Now we
+take the next step, and explore how to combine metadata together using the ``combine``
+metadata class method.
+
+For example, consider the following :class:`~iris.common.metadata.CubeMetadata`,
+
+.. doctest:: metadata-combine
+
+ >>> cube.metadata # doctest: +SKIP
+ CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
+
+We can perform the **identity function** by comparing the metadata with itself,
+
+.. doctest:: metadata-combine
+
+ >>> metadata = cube.metadata.combine(cube.metadata)
+ >>> cube.metadata == metadata
+ True
+
+As you might expect, combining identical metadata returns metadata that is
+also identical.
+
+The ``combine`` method will always return **a new** metadata class instance,
+where each metadata member is either ``None`` or populated with a **common value**.
+Let's clarify this, by combining our above :class:`~iris.common.metadata.CubeMetadata`
+with another instance that's identical apart from its ``standard_name`` member,
+which is replaced with a **different value**,
+
+.. doctest:: metadata-combine
+
+ >>> metadata = cube.metadata._replace(standard_name="air_pressure_at_sea_level")
+ >>> metadata != cube.metadata
+ True
+ >>> metadata.combine(cube.metadata) # doctest: +SKIP
+ CubeMetadata(standard_name=None, long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'STASH': STASH(model=1, section=3, item=236), 'source': 'Data from Met Office Unified Model 6.05', 'Model scenario': 'A1B', 'Conventions': 'CF-1.5'}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
+
+The ``combine`` method combines metadata by performing a **strict** comparison
+between each of the associated metadata member values,
+
+- if the values are **different**, then the combined result is ``None``
+- otherwise, the combined result is the **common value**
+
+Let's reinforce this behaviour, but this time by combining metadata where the
+``attributes`` `dict`_ member is different, where,
+
+- the ``STASH`` and ``source`` keys are **missing**,
+- the ``Model scenario`` key has the **same value**,
+- the ``Conventions`` key has a **different value**,
+- the ``grinning face`` key is **new**
+
+.. doctest:: metadata-combine
+
+ >>> attributes = {"Model scenario": "A1B", "Conventions": "CF-1.8", "grinning face": "π" }
+ >>> metadata = cube.metadata._replace(attributes=attributes)
+ >>> metadata != cube.metadata
+ True
+ >>> metadata.combine(cube.metadata).attributes
+ {'Model scenario': 'A1B'}
+
+The combined result for the ``attributes`` member only contains those
+**common keys** with **common values**.
+
+Note that, the ``combine`` method is **commutative**,
+
+.. doctest:: metadata-combine
+
+ >>> cube.metadata.combine(metadata) == metadata.combine(cube.metadata)
+ True
+
+Although, this is only the case when combining instances of the **same**
+metadata class. This is explored in a little further detail next.
+
+
+.. _combine like:
+
+Combine like with like
+""""""""""""""""""""""
+
+Akin to the :ref:`equal ` and
+:ref:`difference ` methods, only instances of **similar**
+metadata classes can be combined, otherwise a ``TypeError`` is raised,
+
+.. doctest:: metadata-combine
+
+ >>> cube.metadata.combine(longitude.metadata)
+ Traceback (most recent call last):
+ TypeError: Cannot combine 'CubeMetadata' with .
+
+Again, however, the :ref:`exception to the rule ` also applies
+here i.e., support is provided between :class:`~iris.common.metadata.CoordMetadata` and
+:class:`~iris.common.metadata.DimCoordMetadata` metadata classes.
+
+For example, we can ``combine`` the metadata of the following
+:class:`~iris.coords.AuxCoord` and :class:`~iris.coords.DimCoord`,
+
+.. doctest:: metadata-combine
+
+ >>> forecast_period = cube.coord("forecast_period")
+ >>> longitude = cube.coord("longitude")
+
+First, let's see their associated metadata,
+
+.. doctest:: metadata-combine
+
+ >>> forecast_period.metadata
+ CoordMetadata(standard_name='forecast_period', long_name=None, var_name='forecast_period', units=Unit('hours'), attributes={}, coord_system=None, climatological=False)
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Before combining their metadata together,
+
+.. doctest:: metadata-combine
+
+ >>> forecast_period.metadata.combine(longitude.metadata)
+ CoordMetadata(standard_name=None, long_name=None, var_name=None, units=None, attributes={}, coord_system=None, climatological=False)
+ >>> longitude.metadata.combine(forecast_period.metadata)
+ DimCoordMetadata(standard_name=None, long_name=None, var_name=None, units=None, attributes={}, coord_system=None, climatological=False, circular=None)
+
+However, note that commutativity in this case cannot be honoured, for obvious reasons.
+
+
+.. _metadata conversion:
+
+Metadata conversion
+^^^^^^^^^^^^^^^^^^^
+
+.. testsetup:: metadata-convert
+
+ import iris
+ from iris.common import DimCoordMetadata
+ cube = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
+ longitude = cube.coord("longitude")
+
+In general, the :ref:`equal `, :ref:`difference `,
+and :ref:`combine ` methods only support operations on instances
+of the same metadata class (see :ref:`exception to the rule `).
+
+However, metadata may be converted from one metadata class to another using
+the ``from_metadata`` class method. For example, given the following
+:class:`~iris.common.metadata.CubeMetadata`,
+
+.. doctest:: metadata-convert
+
+ >>> cube.metadata # doctest: +SKIP
+ CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
+
+We can easily convert it to a :class:`~iris.common.metadata.DimCoordMetadata` instance
+using ``from_metadata``,
+
+.. doctest:: metadata-convert
+
+ >>> DimCoordMetadata.from_metadata(cube.metadata) # doctest: +SKIP
+ DimCoordMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, coord_system=None, climatological=None, circular=None)
+
+By examining :numref:`metadata members table`, we can see that the
+:class:`~iris.cube.Cube` and :class:`~iris.coords.DimCoord` container
+classes share the following common metadata members,
+
+- ``standard_name``,
+- ``long_name``,
+- ``var_name``,
+- ``units``,
+- ``attributes``
+
+As such, all of these metadata members of the resultant
+:class:`~iris.common.metadata.DimCoordMetadata` instance are populated from the associated
+:class:`~iris.common.metadata.CubeMetadata` instance members. However, a
+:class:`~iris.common.metadata.CubeMetadata` class does not contain the following
+:class:`~iris.common.metadata.DimCoordMetadata` members,
+
+- ``coords_system``,
+- ``climatological``,
+- ``circular``
+
+Thus these particular metadata members are set to ``None`` in the resultant
+:class:`~iris.common.metadata.DimCoordMetadata` instance.
+
+Note that, the ``from_metadata`` method is also available on a metadata
+class instance,
+
+.. doctest:: metadata-convert
+
+ >>> longitude.metadata.from_metadata(cube.metadata)
+ DimCoordMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, coord_system=None, climatological=None, circular=None)
+
+
+.. _metadata assignment:
+
+Metadata assignment
+^^^^^^^^^^^^^^^^^^^
+
+.. testsetup:: metadata-assign
+
+ import iris
+ cube = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
+ longitude = cube.coord("longitude")
+ original = longitude.copy()
+ latitude = cube.coord("latitude")
+
+The ``metadata`` property available on each Iris `CF Conventions`_ container
+class (:numref:`metadata classes table`) can not only be used **to get**
+the metadata of an instance, but also **to set** the metadata on an instance.
+
+For example, given the following :class:`~iris.common.metadata.DimCoordMetadata` of the
+``longitude`` coordinate,
+
+.. doctest:: metadata-assign
+
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+We can assign to it directly using the :class:`~iris.common.metadata.DimCoordMetadata` of the ``latitude``
+coordinate,
+
+.. doctest:: metadata-assign
+
+ >>> latitude.metadata
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+ >>> longitude.metadata = latitude.metadata
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+
+Assign by iterable
+""""""""""""""""""
+
+It is also possible to assign to the ``metadata`` property of an Iris
+`CF Conventions`_ container with an iterable containing the **correct
+number** of associated member values, e.g.,
+
+.. doctest:: metadata-assign
+
+ >>> values = [getattr(latitude, member) for member in latitude.metadata._fields]
+ >>> longitude.metadata = values
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+
+Assign by namedtuple
+""""""""""""""""""""
+
+A `namedtuple`_ may also be used to assign to the ``metadata`` property of an
+Iris `CF Conventions`_ container. For example, let's first create a custom
+namedtuple class,
+
+.. doctest:: metadata-assign
+
+ >>> from collections import namedtuple
+ >>> Metadata = namedtuple("Metadata", ["standard_name", "long_name", "var_name", "units", "attributes", "coord_system", "climatological", "circular"])
+
+Now create an instance of this custom namedtuple class, and populate it,
+
+.. doctest:: metadata-assign
+
+ >>> metadata = Metadata(*values)
+ >>> metadata
+ Metadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Now we can use the custom namedtuple instance to assign directly to the metadata
+of the ``longitude`` coordinate,
+
+.. doctest:: metadata-assign
+
+ >>> longitude.metadata = metadata
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+
+Assign by mapping
+"""""""""""""""""
+
+It is also possible to assign to the ``metadata`` property using a `mapping`_,
+such as a `dict`_,
+
+.. doctest:: metadata-assign
+
+ >>> mapping = latitude.metadata._asdict()
+ >>> mapping
+ OrderedDict([('standard_name', 'latitude'), ('long_name', None), ('var_name', 'latitude'), ('units', Unit('degrees')), ('attributes', {}), ('coord_system', GeogCS(6371229.0)), ('climatological', False), ('circular', False)])
+ >>> longitude.metadata = mapping
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Support is also provided for assigning a **partial** mapping, for example,
+
+.. testcode:: metadata-assign
+ :hide:
+
+ longitude = original
+
+.. doctest:: metadata-assign
+
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+ >>> longitude.metadata = dict(var_name="lat", units="radians", circular=True)
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='lat', units=Unit('radians'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=True)
+
+Indeed, it's also possible to assign to the ``metadata`` property with a
+**different** metadata class instance,
+
+.. testcode:: metadata-assign
+ :hide:
+
+ longitude.metadata = dict(var_name="longitude", units="degrees", circular=False)
+
+.. doctest:: metadata-assign
+
+ >>> longitude.metadata
+ DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+ >>> longitude.metadata = cube.metadata
+ >>> longitude.metadata # doctest: +SKIP
+ DimCoordMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
+
+Note that, only **common** metadata members will be assigned new associated
+values. All other metadata members will be left unaltered.
+
+
+.. _data about data: https://en.wikipedia.org/wiki/Metadata
+.. _data attribute: https://docs.python.org/3/tutorial/classes.html#instance-objects
+.. _dict: https://docs.python.org/3/library/stdtypes.html#mapping-types-dict
+.. _Ancillary Data: https://cfconventions.org/Data/cf-conventions/cf-conventions-1.8/cf-conventions.html#ancillary-data
+.. _CF Conventions: https://cfconventions.org/
+.. _Cell Measures: https://cfconventions.org/Data/cf-conventions/cf-conventions-1.8/cf-conventions.html#cell-measures
+.. _Flags: https://cfconventions.org/Data/cf-conventions/cf-conventions-1.8/cf-conventions.html#flags
+.. _GitHub Issue: https://github.com/SciTools/iris/issues/new/choose
+.. _mapping: https://docs.python.org/3/glossary.html#term-mapping
+.. _namedtuple: https://docs.python.org/3/library/collections.html#collections.namedtuple
+.. _namedtuple._make: https://docs.python.org/3/library/collections.html#collections.somenamedtuple._make
+.. _namedtuple._asdict: https://docs.python.org/3/library/collections.html#collections.somenamedtuple._asdict
+.. _namedtuple._replace: https://docs.python.org/3/library/collections.html#collections.somenamedtuple._replace
+.. _namedtuple._fields: https://docs.python.org/3/library/collections.html#collections.somenamedtuple._fields
+.. _NetCDF: https://www.unidata.ucar.edu/software/netcdf/
+.. _NetCDF CF Metadata Conventions: https://cfconventions.org/
+.. _NumPy: https://github.com/numpy/numpy
+.. _OrderedDict: https://docs.python.org/3/library/collections.html#collections.OrderedDict
+.. _Parametric Vertical Coordinate: https://cfconventions.org/Data/cf-conventions/cf-conventions-1.8/cf-conventions.html#parametric-vertical-coordinate
+.. _rich comparison: https://www.python.org/dev/peps/pep-0207/
+.. _SciTools/iris: https://github.com/SciTools/iris
+.. _tuple: https://docs.python.org/3/library/stdtypes.html#tuples
diff --git a/docs/iris/src/index.rst b/docs/iris/src/index.rst
index 0b84b7bae4..1fd67f1118 100644
--- a/docs/iris/src/index.rst
+++ b/docs/iris/src/index.rst
@@ -3,8 +3,6 @@
Iris Documentation
==================
-.. todolist::
-
**A powerful, format-agnostic, community-driven Python package for analysing
and visualising Earth science data.**
@@ -124,6 +122,16 @@ For **Iris 2.4** and earlier documentation please see the
.. _developers_guide:
+.. toctree::
+ :maxdepth: 1
+ :caption: Further Topics
+
+ further_topics/index
+ further_topics/metadata
+ further_topics/lenient_metadata
+ further_topics/lenient_maths
+
+
.. toctree::
:maxdepth: 1
:caption: Developers Guide
diff --git a/docs/iris/src/techpapers/index.rst b/docs/iris/src/techpapers/index.rst
index 773c8f7059..3074569eae 100644
--- a/docs/iris/src/techpapers/index.rst
+++ b/docs/iris/src/techpapers/index.rst
@@ -1,7 +1,7 @@
.. _techpapers_index:
-Iris Technical Papers
+Iris technical papers
=====================
Extra information on specific technical issues.
diff --git a/docs/iris/src/techpapers/missing_data_handling.rst b/docs/iris/src/techpapers/missing_data_handling.rst
index cd6ef038c2..46279bc566 100644
--- a/docs/iris/src/techpapers/missing_data_handling.rst
+++ b/docs/iris/src/techpapers/missing_data_handling.rst
@@ -1,5 +1,5 @@
=============================
-Missing Data Handling in Iris
+Missing data handling in Iris
=============================
This document provides a brief overview of how Iris handles missing data values
diff --git a/docs/iris/src/userguide/cube_maths.rst b/docs/iris/src/userguide/cube_maths.rst
index 69273b45b9..eebff53e62 100644
--- a/docs/iris/src/userguide/cube_maths.rst
+++ b/docs/iris/src/userguide/cube_maths.rst
@@ -1,6 +1,8 @@
-======================
-Basic cube mathematics
-======================
+.. _cube maths:
+
+==========
+Cube maths
+==========
The section :doc:`navigating_a_cube` highlighted that
diff --git a/docs/iris/src/userguide/real_and_lazy_data.rst b/docs/iris/src/userguide/real_and_lazy_data.rst
index a58114de73..574ca4e1a0 100644
--- a/docs/iris/src/userguide/real_and_lazy_data.rst
+++ b/docs/iris/src/userguide/real_and_lazy_data.rst
@@ -10,7 +10,7 @@
==================
-Real and Lazy Data
+Real and lazy data
==================
We have seen in the :doc:`iris_cubes` section of the user guide that
diff --git a/docs/iris/src/userguide/saving_iris_cubes.rst b/docs/iris/src/userguide/saving_iris_cubes.rst
index fa67b6213d..cca8b44bd1 100644
--- a/docs/iris/src/userguide/saving_iris_cubes.rst
+++ b/docs/iris/src/userguide/saving_iris_cubes.rst
@@ -44,6 +44,8 @@ Controlling the save process
The :py:func:`iris.save` function passes all other keywords through to the saver function defined, or automatically set from the file extension. This enables saver specific functionality to be called.
+.. doctest::
+
>>> # Save a cube to PP
>>> iris.save(cubes[0], "myfile.pp")
>>> # Save a cube list to a PP file, appending to the contents of the file
@@ -54,6 +56,16 @@ The :py:func:`iris.save` function passes all other keywords through to the saver
>>> # Save a cube list to netCDF, using the NETCDF3_CLASSIC storage option
>>> iris.save(cubes, "myfile.nc", netcdf_format="NETCDF3_CLASSIC")
+.. testcleanup::
+
+ import pathlib
+ p = pathlib.Path("myfile.pp")
+ if p.exists():
+ p.unlink()
+ p = pathlib.Path("myfile.nc")
+ if p.exists():
+ p.unlink()
+
See
* :py:func:`iris.fileformats.netcdf.save`
diff --git a/docs/iris/src/userguide/subsetting_a_cube.rst b/docs/iris/src/userguide/subsetting_a_cube.rst
index 5864de531a..5d9a560be9 100644
--- a/docs/iris/src/userguide/subsetting_a_cube.rst
+++ b/docs/iris/src/userguide/subsetting_a_cube.rst
@@ -1,7 +1,7 @@
.. _subsetting_a_cube:
=================
-Subsetting a Cube
+Subsetting a cube
=================
The :doc:`loading_iris_cubes` section of the user guide showed how to load data into multidimensional Iris cubes.
diff --git a/lib/iris/common/__init__.py b/lib/iris/common/__init__.py
index c540d81bc0..d8e8ba80ef 100644
--- a/lib/iris/common/__init__.py
+++ b/lib/iris/common/__init__.py
@@ -3,7 +3,10 @@
# This file is part of Iris and is released under the LGPL license.
# See COPYING and COPYING.LESSER in the root of the repository for full
# licensing details.
+"""
+A package for provisioning common Iris infrastructure.
+"""
from .lenient import *
from .metadata import *
diff --git a/lib/iris/common/lenient.py b/lib/iris/common/lenient.py
index 802d854554..3f8d7029ef 100644
--- a/lib/iris/common/lenient.py
+++ b/lib/iris/common/lenient.py
@@ -3,6 +3,10 @@
# This file is part of Iris and is released under the LGPL license.
# See COPYING and COPYING.LESSER in the root of the repository for full
# licensing details.
+"""
+Provides the infrastructure to support lenient client/service behaviour.
+
+"""
from collections.abc import Iterable
from contextlib import contextmanager
diff --git a/lib/iris/common/metadata.py b/lib/iris/common/metadata.py
index 4efead0a39..9b1d3278f3 100644
--- a/lib/iris/common/metadata.py
+++ b/lib/iris/common/metadata.py
@@ -3,6 +3,11 @@
# This file is part of Iris and is released under the LGPL license.
# See COPYING and COPYING.LESSER in the root of the repository for full
# licensing details.
+"""
+Provides the infrastructure to support the common metadata API.
+
+"""
+
from abc import ABCMeta
from collections import namedtuple
@@ -649,6 +654,21 @@ def equal(self, other, lenient=None):
@classmethod
def from_metadata(cls, other):
+ """
+ Convert the provided metadata instance from a different type
+ to this metadata type, using only the relevant metadata members.
+
+ Non-common metadata members are set to ``None``.
+
+ Args:
+
+ * other (metadata):
+ A metadata instance of any type.
+
+ Returns:
+ New metadata instance.
+
+ """
result = None
if isinstance(other, BaseMetadata):
if other.__class__ is cls:
diff --git a/lib/iris/common/mixin.py b/lib/iris/common/mixin.py
index dc2937af0c..e40c6bf6e6 100644
--- a/lib/iris/common/mixin.py
+++ b/lib/iris/common/mixin.py
@@ -3,14 +3,17 @@
# This file is part of Iris and is released under the LGPL license.
# See COPYING and COPYING.LESSER in the root of the repository for full
# licensing details.
+"""
+Provides common metadata mixin behaviour.
+"""
from collections.abc import Mapping
from functools import wraps
import cf_units
-from iris.common import BaseMetadata
+from .metadata import BaseMetadata
import iris.std_names
diff --git a/lib/iris/common/resolve.py b/lib/iris/common/resolve.py
index 268ea723f3..ad37247809 100644
--- a/lib/iris/common/resolve.py
+++ b/lib/iris/common/resolve.py
@@ -3,6 +3,13 @@
# This file is part of Iris and is released under the LGPL license.
# See COPYING and COPYING.LESSER in the root of the repository for full
# licensing details.
+"""
+Provides the infrastructure to support the analysis, identification and
+combination of metadata common between two :class:`~iris.cube.Cube`
+operands into a single resultant :class:`~iris.cube.Cube`, which will be
+auto-transposed, and with the appropriate broadcast shape.
+
+"""
from collections import namedtuple
from collections.abc import Iterable
@@ -59,12 +66,262 @@
class Resolve:
+ """
+ At present, :class:`~iris.common.resolve.Resolve` is used by Iris solely
+ during cube maths to combine a left-hand :class:`~iris.cube.Cube`
+ operand and a right-hand :class:`~iris.cube.Cube` operand into a resultant
+ :class:`~iris.cube.Cube` with common metadata, suitably auto-transposed
+ dimensions, and an appropriate broadcast shape.
+
+ However, the capability and benefit provided by :class:`~iris.common.resolve.Resolve`
+ may be exercised as a general means to easily and consistently combine the metadata
+ of two :class:`~iris.cube.Cube` operands together into a single resultant
+ :class:`~iris.cube.Cube`. This is highlighted through the following use case
+ patterns.
+
+ Firstly, creating a ``resolver`` instance with *specific* :class:`~iris.cube.Cube`
+ operands, and then supplying ``data`` with suitable dimensionality and shape to
+ create the resultant resolved :class:`~iris.cube.Cube`, e.g.,
+
+ .. testsetup::
+
+ import iris
+ import numpy as np
+ from iris.common import Resolve
+ cube1 = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
+ cube2 = iris.load_cube(iris.sample_data_path("E1_north_america.nc"))[0]
+ cube2.transpose()
+ cube3, cube4 = cube1, cube2
+ data = np.zeros(cube1.shape)
+ data1 = data * 10
+ data2 = data * 20
+ data3 = data * 30
+
+ .. doctest::
+
+ >>> print(cube1)
+ air_temperature / (K) (time: 240; latitude: 37; longitude: 49)
+ Dimension coordinates:
+ time x - -
+ latitude - x -
+ longitude - - x
+ Auxiliary coordinates:
+ forecast_period x - -
+ Scalar coordinates:
+ forecast_reference_time: 1859-09-01 06:00:00
+ height: 1.5 m
+ Attributes:
+ Conventions: CF-1.5
+ Model scenario: A1B
+ STASH: m01s03i236
+ source: Data from Met Office Unified Model 6.05
+ Cell methods:
+ mean: time (6 hour)
+
+ >>> print(cube2)
+ air_temperature / (K) (longitude: 49; latitude: 37)
+ Dimension coordinates:
+ longitude x -
+ latitude - x
+ Scalar coordinates:
+ forecast_period: 10794 hours
+ forecast_reference_time: 1859-09-01 06:00:00
+ height: 1.5 m
+ time: 1860-06-01 00:00:00, bound=(1859-12-01 00:00:00, 1860-12-01 00:00:00)
+ Attributes:
+ Conventions: CF-1.5
+ Model scenario: E1
+ STASH: m01s03i236
+ source: Data from Met Office Unified Model 6.05
+ Cell methods:
+ mean: time (6 hour)
+
+ >>> print(data.shape)
+ (240, 37, 49)
+ >>> resolver = Resolve(cube1, cube2)
+ >>> result = resolver.cube(data)
+ >>> print(result)
+ air_temperature / (K) (time: 240; latitude: 37; longitude: 49)
+ Dimension coordinates:
+ time x - -
+ latitude - x -
+ longitude - - x
+ Auxiliary coordinates:
+ forecast_period x - -
+ Scalar coordinates:
+ forecast_reference_time: 1859-09-01 06:00:00
+ height: 1.5 m
+ Attributes:
+ Conventions: CF-1.5
+ STASH: m01s03i236
+ source: Data from Met Office Unified Model 6.05
+ Cell methods:
+ mean: time (6 hour)
+
+ Secondly, creating an *empty* ``resolver`` instance, that may be called *multiple*
+ times with *different* :class:`~iris.cube.Cube` operands and *different* ``data``,
+ e.g.,
+
+ .. doctest::
+
+ >>> resolver = Resolve()
+ >>> result1 = resolver(cube1, cube2).cube(data1)
+ >>> result2 = resolver(cube3, cube4).cube(data2)
+
+ Lastly, creating a ``resolver`` instance with *specific* :class:`~iris.cube.Cube`
+ operands, and then supply *different* ``data`` *multiple* times, e.g.,
+
+ >>> payload = (data1, data2, data3)
+ >>> resolver = Resolve(cube1, cube2)
+ >>> results = [resolver.cube(data) for data in payload]
+
+ """
+
def __init__(self, lhs=None, rhs=None):
+ """
+ Resolve the provided ``lhs`` :class:`~iris.cube.Cube` operand and
+ ``rhs`` :class:`~iris.cube.Cube` operand to determine the metadata
+ that is common between them, and the auto-transposed, broadcast shape
+ of the resultant :class:`~iris.cube.Cube`.
+
+ This includes the identification of common :class:`~iris.common.metadata.CubeMetadata`,
+ :class:`~iris.coords.DimCoord`, :class:`~iris.coords.AuxCoord`, and
+ :class:`~iris.aux_factory.AuxCoordFactory` metadata.
+
+ .. note::
+
+ Resolving common :class:`~iris.coords.AncillaryVariable` and
+ :class:`~iris.coords.CellMeasure` metadata is not supported at
+ this time. (:issue:`3839`)
+
+ .. note::
+
+ A :class:`~iris.common.resolve.Resolve` instance is **callable**,
+ allowing two new ``lhs`` and ``rhs`` :class:`~iris.cube.Cube` operands
+ to be resolved. Note that, :class:`~iris.common.resolve.Resolve` only
+ supports resolving **two** operands at a time, and no more.
+
+ .. warning::
+
+ :class:`~iris.common.resolve.Resolve` attempts to preserve commutativity,
+ but this may not be possible when auto-transposition or extended broadcasting
+ is involved during the operation.
+
+ For example:
+
+ .. doctest::
+
+ >>> cube1
+
+ >>> cube2
+
+ >>> result1 = Resolve(cube1, cube2).cube(data)
+ >>> result2 = Resolve(cube2, cube1).cube(data)
+ >>> result1 == result2
+ True
+
+ Kwargs:
+
+ * lhs:
+ The left-hand-side :class:`~iris.cube.Cube` operand.
+
+ * rhs:
+ The right-hand-side :class:`~iris.cube.Cube` operand.
+
+ """
+ #: The ``lhs`` operand to be resolved into the resultant :class:`~iris.cube.Cube`.
+ self.lhs_cube = None # set in _call__
+ #: The ``rhs`` operand to be resolved into the resultant :class:`~iris.cube.Cube`.
+ self.rhs_cube = None # set in __call__
+
+ #: The transposed/reshaped (if required) ``lhs`` :class:`~iris.cube.Cube`, which
+ #: can be broadcast with the ``rhs`` :class:`~iris.cube.Cube`.
+ self.lhs_cube_resolved = None
+ #: The transposed/reshaped (if required) ``rhs`` :class:`~iris.cube.Cube`, which
+ #: can be broadcast with the ``lhs`` :class:`~iris.cube.Cube`.
+ self.rhs_cube_resolved = None
+
+ #: Categorised dim, aux and scalar coordinate items for ``lhs`` :class:`~iris.cube.Cube`.
+ self.lhs_cube_category = None # set in _metadata_resolve
+ #: Categorised dim, aux and scalar coordinate items for ``rhs`` :class:`~iris.cube.Cube`.
+ self.rhs_cube_category = None # set in _metadata_resolve
+
+ #: Categorised dim, aux and scalar coordinate items **local** to the
+ #: ``lhs`` :class:`~iris.cube.Cube` only.
+ self.lhs_cube_category_local = None # set in _metadata_resolve
+ #: Categorised dim, aux and scalar coordinate items **local** to the
+ #: ``rhs`` :class:`~iris.cube.Cube` only.
+ self.rhs_cube_category_local = None # set in _metadata_resolve
+ #: Categorised dim, aux and scalar coordinate items **common** to both
+ #: the ``lhs`` :class:`~iris.cube.Cube` and the ``rhs`` :class:`~iris.cube.Cube`.
+ self.category_common = None # set in _metadata_resolve
+
+ #: Analysis of dim coordinates spanning the ``lhs`` :class:`~iris.cube.Cube`.
+ self.lhs_cube_dim_coverage = None # set in _metadata_coverage
+ #: Analysis of aux and scalar coordinates spanning the ``lhs`` :class:`~iris.cube.Cube`.
+ self.lhs_cube_aux_coverage = None # set in _metadata_coverage
+ #: Analysis of dim coordinates spanning the ``rhs`` :class:`~iris.cube.Cube`.
+ self.rhs_cube_dim_coverage = None # set in _metadata_coverage
+ #: Analysis of aux and scalar coordinates spanning the ``rhs`` :class:`~iris.cube.Cube`.
+ self.rhs_cube_aux_coverage = None # set in _metadata_coverage
+
+ #: Map **common** metadata from the ``rhs`` :class:`~iris.cube.Cube` to
+ #: the ``lhs`` :class:`~iris.cube.Cube` if ``lhs-rank`` >= ``rhs-rank``,
+ #: otherwise map **common** metadata from the ``lhs`` :class:`~iris.cube.Cube`
+ #: to the ``rhs`` :class:`~iris.cube.Cube`.
+ self.map_rhs_to_lhs = None # set in __call__
+
+ #: Mapping of the dimensions between **common** metadata for the :class:`~iris.cube.Cube`
+ #: operands, where the direction of the mapping is governed by
+ #: :attr:`~iris.common.resolve.Resolve.map_rhs_to_lhs`.
+ self.mapping = None # set in _metadata_mapping
+
+ #: Cache containing a list of dim, aux and scalar coordinates prepared
+ #: and ready for creating and attaching to the resultant resolved
+ #: :class:`~iris.cube.Cube`.
+ self.prepared_category = None # set in _metadata_prepare
+
+ #: Cache containing a list of aux factories prepared and ready for
+ #: creating and attaching to the resultant resolved
+ #: :class:`~iris.cube.Cube`.
+ self.prepared_factories = None # set in _metadata_prepare
+
+ # The shape of the resultant resolved cube.
+ self._broadcast_shape = None # set in _as_compatible_cubes
+
if lhs is not None or rhs is not None:
+ # Attempt to resolve the cube operands.
self(lhs, rhs)
def __call__(self, lhs, rhs):
- self._init(lhs, rhs)
+ from iris.cube import Cube
+
+ emsg = (
+ "{cls} requires {arg!r} argument to be a 'Cube', got {actual!r}."
+ )
+ clsname = self.__class__.__name__
+
+ if not isinstance(lhs, Cube):
+ raise TypeError(
+ emsg.format(cls=clsname, arg="LHS", actual=type(lhs))
+ )
+
+ if not isinstance(rhs, Cube):
+ raise TypeError(
+ emsg.format(cls=clsname, arg="RHS", actual=type(rhs))
+ )
+
+ # Initialise the operand state.
+ self.lhs_cube = lhs
+ self.rhs_cube = rhs
+
+ # Determine the initial direction to map operands.
+ # This may flip for operands with equal rank, particularly after
+ # later analysis informs the decision.
+ if self.lhs_cube.ndim >= self.rhs_cube.ndim:
+ self.map_rhs_to_lhs = True
+ else:
+ self.map_rhs_to_lhs = False
self._metadata_resolve()
self._metadata_coverage()
@@ -78,6 +335,8 @@ def __call__(self, lhs, rhs):
self._metadata_mapping()
self._metadata_prepare()
+ return self
+
def _as_compatible_cubes(self):
from iris.cube import Cube
@@ -498,86 +757,6 @@ def _pop(item, items):
self.mapping.update(free_mapping)
logger.debug(f"mapping free dimensions gives, mapping={self.mapping}")
- def _init(self, lhs, rhs):
- from iris.cube import Cube
-
- emsg = (
- "{cls} requires {arg!r} argument to be a 'Cube', got {actual!r}."
- )
- clsname = self.__class__.__name__
-
- if not isinstance(lhs, Cube):
- raise TypeError(
- emsg.format(cls=clsname, arg="LHS", actual=type(lhs))
- )
-
- if not isinstance(rhs, Cube):
- raise TypeError(
- emsg.format(cls=clsname, arg="RHS", actual=type(rhs))
- )
-
- # The LHS cube to be resolved into the resultant cube.
- self.lhs_cube = lhs
- # The RHS cube to be resolved into the resultant cube.
- self.rhs_cube = rhs
-
- # The transposed/reshaped (if required) LHS cube, which
- # can be broadcast with RHS cube.
- self.lhs_cube_resolved = None
- # The transposed/reshaped (if required) RHS cube, which
- # can be broadcast with LHS cube.
- self.rhs_cube_resolved = None
-
- # Categorised dim, aux and scalar coordinate items for LHS cube.
- self.lhs_cube_category = None
- # Categorised dim, aux and scalar coordinate items for RHS cube.
- self.rhs_cube_category = None
-
- # Categorised dim, aux and scalar coordinate items local to LHS cube only.
- self.lhs_cube_category_local = _CategoryItems(
- items_dim=[], items_aux=[], items_scalar=[]
- )
- # Categorised dim, aux and scalar coordinate items local to RHS cube only.
- self.rhs_cube_category_local = _CategoryItems(
- items_dim=[], items_aux=[], items_scalar=[]
- )
- # Categorised dim, aux and scalar coordinate items common to both
- # LHS cube and RHS cube.
- self.category_common = _CategoryItems(
- items_dim=[], items_aux=[], items_scalar=[]
- )
-
- # Analysis of dim coordinates spanning LHS cube.
- self.lhs_cube_dim_coverage = None
- # Analysis of aux and scalar coordinates spanning LHS cube.
- self.lhs_cube_aux_coverage = None
- # Analysis of dim coordinates spanning RHS cube.
- self.rhs_cube_dim_coverage = None
- # Analysis of aux and scalar coordinates spanning RHS cube.
- self.rhs_cube_aux_coverage = None
-
- # Map common metadata from RHS cube to LHS cube if LHS-rank >= RHS-rank,
- # otherwise map common metadata from LHS cube to RHS cube.
- if self.lhs_cube.ndim >= self.rhs_cube.ndim:
- self.map_rhs_to_lhs = True
- else:
- self.map_rhs_to_lhs = False
-
- # Mapping of the dimensions between common metadata for the cubes,
- # where the direction of the mapping is governed by map_rhs_to_lhs.
- self.mapping = None
-
- # Cache containing a list of dim, aux and scalar coordinates prepared
- # and ready for creating and attaching to the resultant cube.
- self.prepared_category = None
-
- # Cache containing a list of aux factories prepared and ready for
- # creating and attaching to the resultant cube.
- self.prepared_factories = None
-
- # The shape of the resultant resolved cube.
- self._broadcast_shape = None
-
def _metadata_coverage(self):
# Determine the common dim coordinate metadata coverage.
common_dim_metadata = [
@@ -669,6 +848,7 @@ def _metadata_mapping(self):
# Given the resultant broadcast shape, determine whether the
# mapping requires to be reversed.
+ # Only applies to equal src/tgt dimensionality.
broadcast_flip = (
src_cube.ndim == tgt_cube.ndim
and self._tgt_cube_resolved.shape != self.shape
@@ -677,13 +857,16 @@ def _metadata_mapping(self):
# Given the number of free dimensions, determine whether the
# mapping requires to be reversed.
+ # Only applies to equal src/tgt dimensionality.
src_free = set(src_dim_coverage.dims_free) & set(
src_aux_coverage.dims_free
)
tgt_free = set(tgt_dim_coverage.dims_free) & set(
tgt_aux_coverage.dims_free
)
- free_flip = len(tgt_free) > len(src_free)
+ free_flip = src_cube.ndim == tgt_cube.ndim and len(tgt_free) > len(
+ src_free
+ )
# Reverse the mapping direction.
if broadcast_flip or free_flip:
@@ -777,6 +960,20 @@ def _metadata_resolve(self):
self.lhs_cube_category = self._categorise_items(self.lhs_cube)
self.rhs_cube_category = self._categorise_items(self.rhs_cube)
+ # Categorised dim, aux and scalar coordinate items local to LHS cube only.
+ self.lhs_cube_category_local = _CategoryItems(
+ items_dim=[], items_aux=[], items_scalar=[]
+ )
+ # Categorised dim, aux and scalar coordinate items local to RHS cube only.
+ self.rhs_cube_category_local = _CategoryItems(
+ items_dim=[], items_aux=[], items_scalar=[]
+ )
+ # Categorised dim, aux and scalar coordinate items common to both
+ # LHS cube and RHS cube.
+ self.category_common = _CategoryItems(
+ items_dim=[], items_aux=[], items_scalar=[]
+ )
+
def _categorise(
lhs_items,
rhs_items,
@@ -1353,6 +1550,7 @@ def _prepare_points_and_bounds(
@property
def _src_cube(self):
+ assert self.map_rhs_to_lhs is not None
if self.map_rhs_to_lhs:
result = self.rhs_cube
else:
@@ -1361,6 +1559,7 @@ def _src_cube(self):
@property
def _src_cube_position(self):
+ assert self.map_rhs_to_lhs is not None
if self.map_rhs_to_lhs:
result = "RHS"
else:
@@ -1369,6 +1568,7 @@ def _src_cube_position(self):
@property
def _src_cube_resolved(self):
+ assert self.map_rhs_to_lhs is not None
if self.map_rhs_to_lhs:
result = self.rhs_cube_resolved
else:
@@ -1377,6 +1577,7 @@ def _src_cube_resolved(self):
@_src_cube_resolved.setter
def _src_cube_resolved(self, cube):
+ assert self.map_rhs_to_lhs is not None
if self.map_rhs_to_lhs:
self.rhs_cube_resolved = cube
else:
@@ -1384,6 +1585,7 @@ def _src_cube_resolved(self, cube):
@property
def _tgt_cube(self):
+ assert self.map_rhs_to_lhs is not None
if self.map_rhs_to_lhs:
result = self.lhs_cube
else:
@@ -1392,6 +1594,7 @@ def _tgt_cube(self):
@property
def _tgt_cube_position(self):
+ assert self.map_rhs_to_lhs is not None
if self.map_rhs_to_lhs:
result = "LHS"
else:
@@ -1400,6 +1603,7 @@ def _tgt_cube_position(self):
@property
def _tgt_cube_resolved(self):
+ assert self.map_rhs_to_lhs is not None
if self.map_rhs_to_lhs:
result = self.lhs_cube_resolved
else:
@@ -1408,6 +1612,7 @@ def _tgt_cube_resolved(self):
@_tgt_cube_resolved.setter
def _tgt_cube_resolved(self, cube):
+ assert self.map_rhs_to_lhs is not None
if self.map_rhs_to_lhs:
self.lhs_cube_resolved = cube
else:
@@ -1436,6 +1641,80 @@ def _tgt_cube_prepare(self, data):
cube.remove_ancillary_variable(av)
def cube(self, data, in_place=False):
+ """
+ Create the resultant :class:`~iris.cube.Cube` from the resolved ``lhs``
+ and ``rhs`` :class:`~iris.cube.Cube` operands, using the provided
+ ``data``.
+
+ Args:
+
+ * data:
+ The data payload for the resultant :class:`~iris.cube.Cube`, which
+ **must match** the expected resolved
+ :attr:`~iris.common.resolve.Resolve.shape`.
+
+ Kwargs:
+
+ * in_place:
+ If ``True``, the ``data`` is inserted into the ``tgt``
+ :class:`~iris.cube.Cube`. The existing metadata of the ``tgt``
+ :class:`~iris.cube.Cube` is replaced with the resolved metadata from
+ the ``lhs`` and ``rhs`` :class:`~iris.cube.Cube` operands. Otherwise,
+ a **new** :class:`~iris.cube.Cube` instance is returned.
+ Default is ``False``.
+
+ Returns:
+ :class:`~iris.cube.Cube`
+
+ .. note::
+
+ :class:`~iris.common.resolve.Resolve` will determine whether the
+ ``lhs`` :class:`~iris.cube.Cube` operand is mapped to the
+ ``rhs`` :class:`~iris.cube.Cube` operand, or vice versa.
+ In general, the **lower rank** operand (``src``) is mapped to the
+ **higher rank** operand (``tgt``). Therefore, the ``src``
+ :class:`~iris.cube.Cube` may be either the ``lhs`` or the ``rhs``
+ :class:`~iris.cube.Cube` operand, given the direction of the
+ mapping. See :attr:`~iris.common.resolve.Resolve.map_rhs_to_lhs`.
+
+ .. warning::
+
+ It may not be possible to perform an ``in_place`` operation,
+ due to any transposition or extended broadcasting that requires
+ to be performed i.e., the ``tgt`` :class:`~iris.cube.Cube` **must
+ match** the expected resolved
+ :attr:`~iris.common.resolve.Resolve.shape`.
+
+ For example:
+
+ .. testsetup:: in-place
+
+ import iris
+ import numpy as np
+ from iris.common import Resolve
+ cube1 = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
+ cube2 = iris.load_cube(iris.sample_data_path("E1_north_america.nc"))[0]
+ cube2.transpose()
+ zeros = np.zeros(cube1.shape, dtype=cube1.dtype)
+
+ .. doctest:: in-place
+
+ >>> resolver = Resolve(cube1, cube2)
+ >>> resolver.map_rhs_to_lhs
+ True
+ >>> cube1.data.sum()
+ 124652160.0
+ >>> zeros.shape
+ (240, 37, 49)
+ >>> zeros.sum()
+ 0.0
+ >>> result = resolver.cube(zeros, in_place=True)
+ >>> result is cube1
+ True
+ >>> cube1.data.sum()
+ 0.0
+
+ """
from iris.cube import Cube
expected_shape = self.shape
@@ -1533,13 +1812,137 @@ def cube(self, data, in_place=False):
@property
def mapped(self):
"""
- Returns the state of whether all src cube dimensions have been
- associated with relevant tgt cube dimensions.
+ Boolean state representing whether **all** ``src`` :class:`~iris.cube.Cube`
+ dimensions have been associated with relevant ``tgt``
+ :class:`~iris.cube.Cube` dimensions.
+
+ .. note::
+
+ :class:`~iris.common.resolve.Resolve` will determine whether the
+ ``lhs`` :class:`~iris.cube.Cube` operand is mapped to the
+ ``rhs`` :class:`~iris.cube.Cube` operand, or vice versa.
+ In general, the **lower rank** operand (``src``) is mapped to the
+ **higher rank** operand (``tgt``). Therefore, the ``src``
+ :class:`~iris.cube.Cube` may be either the ``lhs`` or the ``rhs``
+ :class:`~iris.cube.Cube` operand, given the direction of the
+ mapping. See :attr:`~iris.common.resolve.Resolve.map_rhs_to_lhs`.
+
+ If no :class:`~iris.cube.Cube` operands have been provided, then
+ ``mapped`` is ``None``.
+
+ For example:
+
+ .. doctest::
+
+ >>> print(cube1)
+ air_temperature / (K) (time: 240; latitude: 37; longitude: 49)
+ Dimension coordinates:
+ time x - -
+ latitude - x -
+ longitude - - x
+ Auxiliary coordinates:
+ forecast_period x - -
+ Scalar coordinates:
+ forecast_reference_time: 1859-09-01 06:00:00
+ height: 1.5 m
+ Attributes:
+ Conventions: CF-1.5
+ Model scenario: A1B
+ STASH: m01s03i236
+ source: Data from Met Office Unified Model 6.05
+ Cell methods:
+ mean: time (6 hour)
+ >>> print(cube2)
+ air_temperature / (K) (longitude: 49; latitude: 37)
+ Dimension coordinates:
+ longitude x -
+ latitude - x
+ Scalar coordinates:
+ forecast_period: 10794 hours
+ forecast_reference_time: 1859-09-01 06:00:00
+ height: 1.5 m
+ time: 1860-06-01 00:00:00, bound=(1859-12-01 00:00:00, 1860-12-01 00:00:00)
+ Attributes:
+ Conventions: CF-1.5
+ Model scenario: E1
+ STASH: m01s03i236
+ source: Data from Met Office Unified Model 6.05
+ Cell methods:
+ mean: time (6 hour)
+ >>> Resolve().mapped is None
+ True
+ >>> resolver = Resolve(cube1, cube2)
+ >>> resolver.mapped
+ True
+ >>> resolver.map_rhs_to_lhs
+ True
+ >>> resolver = Resolve(cube2, cube1)
+ >>> resolver.mapped
+ True
+ >>> resolver.map_rhs_to_lhs
+ False
"""
- return self._src_cube.ndim == len(self.mapping)
+ result = None
+ if self.mapping is not None:
+ result = self._src_cube.ndim == len(self.mapping)
+ return result
@property
def shape(self):
- """Returns the shape of the resultant resolved cube."""
- return getattr(self, "_broadcast_shape", None)
+ """
+ Proposed shape of the final resolved cube given the ``lhs``
+ :class:`~iris.cube.Cube` operand and the ``rhs`` :class:`~iris.cube.Cube`
+ operand.
+
+ If no :class:`~iris.cube.Cube` operands have been provided, then
+ ``shape`` is ``None``.
+
+ For example:
+
+ .. doctest::
+
+ >>> print(cube1)
+ air_temperature / (K) (time: 240; latitude: 37; longitude: 49)
+ Dimension coordinates:
+ time x - -
+ latitude - x -
+ longitude - - x
+ Auxiliary coordinates:
+ forecast_period x - -
+ Scalar coordinates:
+ forecast_reference_time: 1859-09-01 06:00:00
+ height: 1.5 m
+ Attributes:
+ Conventions: CF-1.5
+ Model scenario: A1B
+ STASH: m01s03i236
+ source: Data from Met Office Unified Model 6.05
+ Cell methods:
+ mean: time (6 hour)
+ >>> print(cube2)
+ air_temperature / (K) (longitude: 49; latitude: 37)
+ Dimension coordinates:
+ longitude x -
+ latitude - x
+ Scalar coordinates:
+ forecast_period: 10794 hours
+ forecast_reference_time: 1859-09-01 06:00:00
+ height: 1.5 m
+ time: 1860-06-01 00:00:00, bound=(1859-12-01 00:00:00, 1860-12-01 00:00:00)
+ Attributes:
+ Conventions: CF-1.5
+ Model scenario: E1
+ STASH: m01s03i236
+ source: Data from Met Office Unified Model 6.05
+ Cell methods:
+ mean: time (6 hour)
+ >>> Resolve().shape is None
+ True
+ >>> Resolve(cube1, cube2).shape
+ (240, 37, 49)
+ >>> Resolve(cube2, cube1).shape
+ (240, 37, 49)
+
+ """
+ return self._broadcast_shape
diff --git a/lib/iris/coords.py b/lib/iris/coords.py
index 76b226b2f6..76ca83cd96 100644
--- a/lib/iris/coords.py
+++ b/lib/iris/coords.py
@@ -2354,10 +2354,10 @@ def __init__(
Descriptive name of the coordinate.
* var_name:
The netCDF variable name for the coordinate.
- * units
+ * units:
The :class:`~cf_units.Unit` of the coordinate's values.
Can be a string, which will be converted to a Unit object.
- * bounds
+ * bounds:
An array of values describing the bounds of each cell. Given n
bounds and m cells, the shape of the bounds array should be
(m, n). For each bound, the values must be strictly monotonic along
@@ -2368,15 +2368,15 @@ def __init__(
in the same direction. Masked values are not allowed.
Note if the data is a climatology, `climatological`
should be set.
- * attributes
+ * attributes:
A dictionary containing other cf and user-defined attributes.
- * coord_system
+ * coord_system:
A :class:`~iris.coord_systems.CoordSystem` representing the
coordinate system of the coordinate,
e.g. a :class:`~iris.coord_systems.GeogCS` for a longitude Coord.
- * circular (bool)
- For units with a modulus (e.g. degrees), do the points wrap around
- the full circle?
+ * circular (bool):
+ Whether the coordinate wraps by the :attr:`~iris.coords.DimCoord.units.modulus`
+ i.e., the longitude coordinate wraps around the full great circle.
* climatological (bool):
When True: the coordinate is a NetCDF climatological time axis.
When True: saving in NetCDF will give the coordinate variable a