diff --git a/lib/iris/mesh/components.py b/lib/iris/mesh/components.py index ef7b7c3575..04d7b82400 100644 --- a/lib/iris/mesh/components.py +++ b/lib/iris/mesh/components.py @@ -13,6 +13,7 @@ from collections import namedtuple from collections.abc import Container from contextlib import contextmanager +from datetime import datetime from typing import Iterable, Literal import warnings @@ -1138,6 +1139,13 @@ def all_connectivities(self): """All the :class:`~iris.mesh.Connectivity` instances of the :class:`MeshXY`.""" return self._connectivity_manager.all_members + @property + def timestamp(self): + """The most recent time and date that the mesh coordinates and or connecitivities + were edited. + """ + return max(self._coord_manager.timestamp, self._connectivity_manager.timestamp) + @property def all_coords(self): """All the :class:`~iris.coords.AuxCoord` coordinates of the :class:`MeshXY`.""" @@ -1993,7 +2001,7 @@ class _Mesh1DCoordinateManager: def __init__(self, node_x, node_y, edge_x=None, edge_y=None): # initialise all the coordinates self.ALL = self.REQUIRED + self.OPTIONAL - self._members = {member: None for member in self.ALL} + self._members_dict = {member: None for member in self.ALL} # required coordinates self.node_x = node_x @@ -2001,6 +2009,9 @@ def __init__(self, node_x, node_y, edge_x=None, edge_y=None): # optional coordinates self.edge_x = edge_x self.edge_y = edge_y + # makes a note of when the mesh coordinates were last edited, for use in + # ensuring MeshCoords are up to date + self.timestamp = datetime.now() def __eq__(self, other): # TBD: this is a minimalist implementation and requires to be revisited @@ -2012,6 +2023,7 @@ def __getstate__(self): def __iter__(self): for item in self._members.items(): yield item + # ELIASISCOOL def __ne__(self, other): result = self.__eq__(other) @@ -2031,6 +2043,7 @@ def __str__(self): return f"{self.__class__.__name__}({', '.join(args)})" def _remove(self, **kwargs): + self.timestamp = datetime.now() result = {} members = self.filters(**kwargs) @@ -2045,6 +2058,7 @@ def _remove(self, **kwargs): return result def _setter(self, element, axis, coord, shape): + self.timestamp = datetime.now() axis = axis.lower() member = f"{element}_{axis}" @@ -2093,10 +2107,22 @@ def _edge_shape(self): def _node_shape(self): return self._shape(element="node") + @property + def _members(self): + self.timestamp = datetime.now() + return self._members_dict + + @_members.setter + def _members(self, value): + self.timestamp = datetime.now() + self._members_dict = value + @property def all_members(self): return Mesh1DCoords(**self._members) + # ELIASISCOOL + @property def edge_coords(self): return MeshEdgeCoords(edge_x=self.edge_x, edge_y=self.edge_y) @@ -2105,6 +2131,8 @@ def edge_coords(self): def edge_x(self): return self._members["edge_x"] + # ELIASISCOOL + @edge_x.setter def edge_x(self, coord): self._setter(element="edge", axis="x", coord=coord, shape=self._edge_shape) @@ -2113,6 +2141,8 @@ def edge_x(self, coord): def edge_y(self): return self._members["edge_y"] + # ELIASISCOOL + @edge_y.setter def edge_y(self, coord): self._setter(element="edge", axis="y", coord=coord, shape=self._edge_shape) @@ -2125,6 +2155,8 @@ def node_coords(self): def node_x(self): return self._members["node_x"] + # ELIASISCOOL + @node_x.setter def node_x(self, coord): self._setter(element="node", axis="x", coord=coord, shape=self._node_shape) @@ -2138,6 +2170,7 @@ def node_y(self, coord): self._setter(element="node", axis="y", coord=coord, shape=self._node_shape) def _add(self, coords): + self.timestamp = datetime.now() member_x, member_y = coords._fields # deal with the special case where both members are changing @@ -2375,8 +2408,11 @@ def __init__(self, *connectivities): raise ValueError(message) self.ALL = self.REQUIRED + self.OPTIONAL - self._members = {member: None for member in self.ALL} + self._members_dict = {member: None for member in self.ALL} self.add(*connectivities) + # makes a note of when the mesh connectivities were last edited, for use in + # ensuring MeshCoords are up to date + self.timestamp = datetime.now() def __eq__(self, other): # TBD: this is a minimalist implementation and requires to be revisited @@ -2417,12 +2453,23 @@ def __str__(self): def all_members(self): return NotImplemented + @property + def _members(self): + self.timestamp = datetime.now() + return self._members_dict + + @_members.setter + def _members(self, value): + self.timestamp = datetime.now() + self._members_dict = value + def add(self, *connectivities): # Since Connectivity classes include their cf_role, no setters will be # provided, just a means to add one or more connectivities to the # manager. # No warning is raised for duplicate cf_roles - user is trusted to # validate their outputs. + self.timestamp = datetime.now() add_dict = {} for connectivity in connectivities: if not isinstance(connectivity, Connectivity): @@ -2564,6 +2611,7 @@ def remove( contains_edge=None, contains_face=None, ): + self.timestamp = datetime.now() removal_dict = self.filters( item=item, standard_name=standard_name, @@ -2649,9 +2697,6 @@ def face_node(self): return self._members["face_node_connectivity"] -Location = Literal["edge", "node", "face"] - - class MeshCoord(AuxCoord): """Geographic coordinate values of data on an unstructured mesh. @@ -2690,16 +2735,17 @@ class MeshCoord(AuxCoord): def __init__( self, - mesh: MeshXY, - location: Location, - axis: Literal["x", "y"], + mesh, + location, + axis, ): + self._read_only_points_and_bounds = True # Setup the metadata. - self._metadata_manager = metadata_manager_factory(MeshCoordMetadata) + self._metadata_manager_temp = metadata_manager_factory(MeshCoordMetadata) # Validate and record the class-specific constructor args. if not isinstance(mesh, MeshXY): - msg = ( # type: ignore[unreachable] + msg = ( "'mesh' must be an " f"{MeshXY.__module__}.{MeshXY.__name__}, " f"got {mesh}." @@ -2716,7 +2762,7 @@ def __init__( ) raise ValueError(msg) # Held in metadata, readable as self.location, but cannot set it. - self._metadata_manager.location = location + self._metadata_manager_temp.location = location if axis not in MeshXY.AXES: # The valid axes are defined by the MeshXY class. @@ -2726,75 +2772,15 @@ def __init__( ) raise ValueError(msg) # Held in metadata, readable as self.axis, but cannot set it. - self._metadata_manager.axis = axis - - points, bounds = self._construct_access_arrays() - if points is None: - # TODO: we intend to support this in future, but it will require - # extra work to refactor the parent classes. - msg = "Cannot yet create a MeshCoord without points." - raise ValueError(msg) - - # Get the 'coord identity' metadata from the relevant node-coordinate. - node_coord = self.mesh.coord(location="node", axis=self.axis) - node_metadict = node_coord.metadata._asdict() - # Use node metadata, unless location is face/edge. - use_metadict = node_metadict.copy() - if location != "node": - # Location is either "edge" or "face" - get the relevant coord. - location_coord = self.mesh.coord(location=location, axis=axis) - - # Take the MeshCoord metadata from the 'location' coord. - use_metadict = location_coord.metadata._asdict() - unit_unknown = Unit(None) - - # N.B. at present, coords in a MeshXY are stored+accessed by 'axis', which - # means they must have a standard_name. So ... - # (a) the 'location' (face/edge) coord *always* has a usable phenomenon - # identity. - # (b) we still want to check that location+node coords have the same - # phenomenon (i.e. physical meaning identity + units), **but** ... - # (c) we will accept/ignore some differences : not just "var_name", but - # also "long_name" *and* "attributes". So it is *only* "standard_name" - # and "units" that cause an error if they differ. - for key in ("standard_name", "units"): - bounds_value = use_metadict[key] - nodes_value = node_metadict[key] - if key == "units" and ( - bounds_value == unit_unknown or nodes_value == unit_unknown - ): - # Allow "any" unit to match no-units (for now) - continue - if bounds_value != nodes_value: - - def fix_repr(val): - # Tidy values appearance by converting Unit to string, and - # wrapping strings in '', but leaving other types as a - # plain str() representation. - if isinstance(val, Unit): - val = str(val) - if isinstance(val, str): - val = repr(val) - return val - - nodes_value, bounds_value = [ - fix_repr(val) for val in (nodes_value, bounds_value) - ] - msg = ( - f"Node coordinate {node_coord!r} disagrees with the " - f"{location} coordinate {location_coord!r}, " - f'in having a "{key}" value of {nodes_value} ' - f"instead of {bounds_value}." - ) - raise ValueError(msg) - + self._metadata_manager_temp.axis = axis + points, bounds = self._load_points_and_bounds() + use_metadict = self._load_metadata() # Don't use 'coord_system' as a constructor arg, since for # MeshCoords it is deduced from the mesh. # (Otherwise a non-None coord_system breaks the 'copy' operation) use_metadict.pop("coord_system") - - # Call parent constructor to handle the common constructor args. - super().__init__(points, bounds=bounds, **use_metadict) + with self._writable_points_and_bounds(): + super().__init__(points, bounds=bounds, **use_metadict) # Define accessors for MeshCoord-specific properties mesh/location/axis. # These are all read-only. @@ -2804,13 +2790,71 @@ def mesh(self): return self._mesh @property - def location(self) -> Location: + def location(self): return self._metadata_manager.location @property def axis(self): return self._metadata_manager.axis + @contextmanager + def _writable_points_and_bounds(self): + """Context manager to allow bounds and points to be set during __init__. + `points` currently doesn't encounter any issues without this manager, but + is included here for future proofing. + """ + try: + self._read_only_points_and_bounds = False + yield + finally: + self._read_only_points_and_bounds = True + + @property + def points(self): + """The coordinate points values as a NumPy array.""" + if self.timestamp < self.mesh.timestamp or self.timestamp is None: + points, bounds = self._load_points_and_bounds() + super(MeshCoord, self.__class__).points.fset(self, points) + super(MeshCoord, self.__class__).bounds.fset(self, bounds) + return super().points + + @points.setter + def points(self, value): + if self._read_only_points_and_bounds: + if len(value) != 0 or not (value is None): + msg = "Cannot set 'points' on a MeshCoord." + raise ValueError(msg) + + @property + def bounds(self): + if self.timestamp < self.mesh.timestamp or self.timestamp is None: + points, bounds = self._load_points_and_bounds() + super(MeshCoord, self.__class__).points.fset(self, points) + super(MeshCoord, self.__class__).bounds.fset(self, bounds) + return super().bounds + + @bounds.setter + def bounds(self, value): + if self._read_only_points_and_bounds: + if len(value) != 0: # or not(value is None) and self.bounds: + msg = "Cannot set 'bounds' on a MeshCoord." + raise ValueError(msg) + else: + super(MeshCoord, self.__class__).bounds.fset(self, value) + + @property + def _metadata_manager(self): + # An explanatory comment. + use_metadict = self._load_metadata() + self._metadata_manager_temp.standard_name = use_metadict["standard_name"] + self._metadata_manager_temp.long_name = use_metadict["long_name"] + self._metadata_manager_temp.var_name = use_metadict["var_name"] + self._metadata_manager_temp.units = use_metadict["units"] + self._metadata_manager_temp.attributes = use_metadict["attributes"] + self._metadata_manager_temp.coord_system = use_metadict["coord_system"] + self._metadata_manager_temp.climatological = use_metadict["climatological"] + return self._metadata_manager_temp + # Provide overrides to mimic the Coord-specific properties that are not # supported by MeshCoord, i.e. "coord_system" and "climatological". # These mimic the Coord properties, but always return fixed 'null' values. @@ -3020,6 +3064,73 @@ def summary(self, *args, **kwargs): result = "\n".join(lines) return result + def _load_points_and_bounds(self): + points, bounds = self._construct_access_arrays() + if points is None: + # TODO: we intend to support this in future, but it will require + # extra work to refactor the parent classes. + msg = "Cannot yet create a MeshCoord without points." + raise ValueError(msg) + self.timestamp = self.mesh.timestamp + return points, bounds + + def _load_metadata(self): + axis = self._metadata_manager_temp.axis + location = self._metadata_manager_temp.location + # Get the 'coord identity' metadata from the relevant node-coordinate. + node_coord = self.mesh.coord(location="node", axis=axis) + node_metadict = node_coord.metadata._asdict() + # Use node metadata, unless location is face/edge. + use_metadict = node_metadict.copy() + if location != "node": + # Location is either "edge" or "face" - get the relevant coord. + location_coord = self.mesh.coord(location=location, axis=axis) + + # Take the MeshCoord metadata from the 'location' coord. + use_metadict = location_coord.metadata._asdict() + unit_unknown = Unit(None) + + # N.B. at present, coords in a MeshXY are stored+accessed by 'axis', which + # means they must have a standard_name. So ... + # (a) the 'location' (face/edge) coord *always* has a usable phenomenon + # identity. + # (b) we still want to check that location+node coords have the same + # phenomenon (i.e. physical meaning identity + units), **but** ... + # (c) we will accept/ignore some differences : not just "var_name", but + # also "long_name" *and* "attributes". So it is *only* "standard_name" + # and "units" that cause an error if they differ. + for key in ("standard_name", "units"): + bounds_value = use_metadict[key] + nodes_value = node_metadict[key] + if key == "units" and ( + bounds_value == unit_unknown or nodes_value == unit_unknown + ): + # Allow "any" unit to match no-units (for now) + continue + if bounds_value != nodes_value: + + def fix_repr(val): + # Tidy values appearance by converting Unit to string, and + # wrapping strings in '', but leaving other types as a + # plain str() representation. + if isinstance(val, Unit): + val = str(val) + if isinstance(val, str): + val = repr(val) + return val + + nodes_value, bounds_value = [ + fix_repr(val) for val in (nodes_value, bounds_value) + ] + msg = ( + f"Node coordinate {node_coord!r} disagrees with the " + f"{location} coordinate {location_coord!r}, " + f'in having a "{key}" value of {nodes_value} ' + f"instead of {bounds_value}." + ) + raise ValueError(msg) + return use_metadict + def _construct_access_arrays(self): """Build lazy points and bounds arrays. diff --git a/lib/iris/tests/unit/cube/test_Cube.py b/lib/iris/tests/unit/cube/test_Cube.py index 8c36240fb6..2adc4c03bf 100644 --- a/lib/iris/tests/unit/cube/test_Cube.py +++ b/lib/iris/tests/unit/cube/test_Cube.py @@ -2074,6 +2074,10 @@ def test_mesh(self): result = self.cube.mesh self.assertIs(result, self.mesh) + def test_mesh_timestamp(self): + result = self.cube.mesh.timestamp + self.assertIsNotNone(result) + def test_no_mesh(self): # Replace standard setUp cube with a no-mesh version. _add_test_meshcube(self, nomesh=True)