From f39d6aaa979c47e692e552365c8161730c4d01a2 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Fri, 11 Sep 2020 14:09:02 +0100 Subject: [PATCH 1/3] [FB] [PI-3478] Add resolve doc-strings (#3842) * Add resolve doc-strings * reorder some doctest statements --- lib/iris/common/__init__.py | 3 + lib/iris/common/lenient.py | 4 + lib/iris/common/metadata.py | 5 + lib/iris/common/mixin.py | 5 +- lib/iris/common/resolve.py | 575 ++++++++++++++++++++++++++++++------ 5 files changed, 504 insertions(+), 88 deletions(-) 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..a28c45dbd8 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 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..cd27e39821 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,260 @@ class Resolve: + """ + At present, :class:`~iris.common.resolve.Resolve` is used by Iris solely + during cube arithmetic 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 +333,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 +755,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 +846,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 +855,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 +958,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 +1548,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 +1557,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 +1566,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 +1575,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 +1583,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 +1592,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 +1601,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 +1610,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 +1639,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:: + + import iris + import numpy as np + from iris.common import Resolve + tgt = iris.load_cube(iris.sample_data_path("A1B_north_america.nc")) + src = iris.load_cube(iris.sample_data_path("E1_north_america.nc"))[0] + src.transpose() + zeros = np.zeros(tgt.shape, dtype=tgt.dtype) + + .. doctest:: + + >>> resolver = Resolve(tgt, src) + >>> resolver.map_rhs_to_lhs + True + >>> tgt.data.sum() + 124652160.0 + >>> zeros.shape + (240, 37, 49) + >>> zeros.sum() + 0.0 + >>> result = resolver.cube(zeros, in_place=True) + >>> result is tgt + True + >>> tgt.data.sum() + 0.0 + + """ from iris.cube import Cube expected_shape = self.shape @@ -1533,13 +1810,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 From 8ccde6d7256ac9e17fc084a4a94b4c17ab26098e Mon Sep 17 00:00:00 2001 From: Bill Little Date: Tue, 29 Sep 2020 21:29:29 +0100 Subject: [PATCH 2/3] [FB] [PI-3478] Metadata and cube maths documentation (#3869) --- docs/iris/src/conf.py | 16 +- .../src/developers_guide/gitwash/index.rst | 4 +- docs/iris/src/further_topics/index.rst | 24 + .../iris/src/further_topics/lenient_maths.rst | 281 +++++ .../src/further_topics/lenient_metadata.rst | 476 ++++++++ docs/iris/src/further_topics/metadata.rst | 1007 +++++++++++++++++ docs/iris/src/index.rst | 12 +- docs/iris/src/techpapers/index.rst | 2 +- .../src/techpapers/missing_data_handling.rst | 2 +- docs/iris/src/userguide/cube_maths.rst | 8 +- .../iris/src/userguide/real_and_lazy_data.rst | 2 +- docs/iris/src/userguide/saving_iris_cubes.rst | 12 + docs/iris/src/userguide/subsetting_a_cube.rst | 2 +- lib/iris/common/metadata.py | 15 + lib/iris/common/resolve.py | 32 +- lib/iris/coords.py | 14 +- 16 files changed, 1874 insertions(+), 35 deletions(-) create mode 100644 docs/iris/src/further_topics/index.rst create mode 100644 docs/iris/src/further_topics/lenient_maths.rst create mode 100644 docs/iris/src/further_topics/lenient_metadata.rst create mode 100644 docs/iris/src/further_topics/metadata.rst 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/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/metadata.py b/lib/iris/common/metadata.py index a28c45dbd8..9b1d3278f3 100644 --- a/lib/iris/common/metadata.py +++ b/lib/iris/common/metadata.py @@ -654,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/resolve.py b/lib/iris/common/resolve.py index cd27e39821..ad37247809 100644 --- a/lib/iris/common/resolve.py +++ b/lib/iris/common/resolve.py @@ -68,7 +68,7 @@ class Resolve: """ At present, :class:`~iris.common.resolve.Resolve` is used by Iris solely - during cube arithmetic to combine a left-hand :class:`~iris.cube.Cube` + 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. @@ -117,6 +117,7 @@ class Resolve: 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: @@ -134,6 +135,7 @@ class Resolve: 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) @@ -205,7 +207,7 @@ def __init__(self, lhs=None, rhs=None): but this may not be possible when auto-transposition or extended broadcasting is involved during the operation. - For example, + For example: .. doctest:: @@ -1683,33 +1685,33 @@ def cube(self, data, in_place=False): match** the expected resolved :attr:`~iris.common.resolve.Resolve.shape`. - For example, + For example: - .. testsetup:: + .. testsetup:: in-place import iris import numpy as np from iris.common import Resolve - tgt = iris.load_cube(iris.sample_data_path("A1B_north_america.nc")) - src = iris.load_cube(iris.sample_data_path("E1_north_america.nc"))[0] - src.transpose() - zeros = np.zeros(tgt.shape, dtype=tgt.dtype) + 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:: + .. doctest:: in-place - >>> resolver = Resolve(tgt, src) + >>> resolver = Resolve(cube1, cube2) >>> resolver.map_rhs_to_lhs True - >>> tgt.data.sum() + >>> cube1.data.sum() 124652160.0 >>> zeros.shape (240, 37, 49) >>> zeros.sum() 0.0 >>> result = resolver.cube(zeros, in_place=True) - >>> result is tgt + >>> result is cube1 True - >>> tgt.data.sum() + >>> cube1.data.sum() 0.0 """ @@ -1828,7 +1830,7 @@ def mapped(self): If no :class:`~iris.cube.Cube` operands have been provided, then ``mapped`` is ``None``. - For example, + For example: .. doctest:: @@ -1896,7 +1898,7 @@ def shape(self): If no :class:`~iris.cube.Cube` operands have been provided, then ``shape`` is ``None``. - For example, + For example: .. doctest:: 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 From 213ae3678e0e0186437e846792bded538c5adb45 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Wed, 30 Sep 2020 12:17:17 +0100 Subject: [PATCH 3/3] fix title --- .../iris/src/developers_guide/contributing_getting_involved.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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