Skip to content

Make MeshCoords immutable and sync updates with the attached mesh #6405

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/iris/cube.py
Original file line number Diff line number Diff line change
Expand Up @@ -2785,7 +2785,7 @@ def shape(self) -> tuple[int, ...]:

@property
def dtype(self):
"""The data type of the values in the data array of this :class:`~iris.cube.Cube`."""
"""The dtype of the values in the data array of this :class:`~iris.cube.Cube`."""
return self._data_manager.dtype

@property
Expand Down
252 changes: 179 additions & 73 deletions lib/iris/mesh/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -840,6 +841,7 @@ def from_coords(cls, *coords):
# Validate points and bounds shape match.
def check_shape(array_name):
attr_name = f"core_{array_name}"
coords
arrays = [getattr(coord, attr_name)() for coord in coords]
if any(a is None for a in arrays):
message = f"{array_name} missing from coords[{arrays.index(None)}] ."
Expand Down Expand Up @@ -1137,6 +1139,11 @@ 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 time and date that the mesh coordinates and or connecitivities were last 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`."""
Expand Down Expand Up @@ -1992,14 +1999,17 @@ 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
self.node_y = node_y
# 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
Expand Down Expand Up @@ -2030,6 +2040,7 @@ def __str__(self):
return f"{self.__class__.__name__}({', '.join(args)})"

def _remove(self, **kwargs):
self.timestamp = datetime.now()
result = {}
members = self.filters(**kwargs)

Expand All @@ -2044,6 +2055,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}"

Expand Down Expand Up @@ -2092,6 +2104,16 @@ 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)
Expand Down Expand Up @@ -2137,6 +2159,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
Expand Down Expand Up @@ -2374,8 +2397,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
Expand Down Expand Up @@ -2416,12 +2442,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):
Expand Down Expand Up @@ -2561,6 +2598,7 @@ def remove(
contains_edge=None,
contains_face=None,
):
self.timestamp = datetime.now()
removal_dict = self.filters(
item=item,
standard_name=standard_name,
Expand Down Expand Up @@ -2687,12 +2725,13 @@ 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):
Expand All @@ -2711,7 +2750,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.
Expand All @@ -2721,75 +2760,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.
Expand All @@ -2799,13 +2778,73 @@ 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.
Expand Down Expand Up @@ -3015,6 +3054,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.

Expand Down
Loading