diff --git a/lib/iris/experimental/representation.py b/lib/iris/experimental/representation.py index b9a6de6e65..48e11e1fb0 100644 --- a/lib/iris/experimental/representation.py +++ b/lib/iris/experimental/representation.py @@ -88,6 +88,7 @@ def __init__(self, cube): self.str_headings = { "Dimension coordinates:": None, "Auxiliary coordinates:": None, + "Mesh coordinates:": None, "Derived coordinates:": None, "Cell measures:": None, "Ancillary variables:": None, @@ -99,6 +100,7 @@ def __init__(self, cube): self.dim_desc_coords = [ "Dimension coordinates:", "Auxiliary coordinates:", + "Mesh coordinates:", "Derived coordinates:", "Cell measures:", "Ancillary variables:", diff --git a/lib/iris/fileformats/netcdf.py b/lib/iris/fileformats/netcdf.py index 02d852b76d..867d49291b 100644 --- a/lib/iris/fileformats/netcdf.py +++ b/lib/iris/fileformats/netcdf.py @@ -1418,80 +1418,84 @@ def _add_mesh(self, cube_or_mesh): cf_mesh_name = self._create_mesh(mesh) self._name_coord_map.append(cf_mesh_name, mesh) - cf_mesh_var = self._dataset.variables[cf_mesh_name] - - # Get the mesh-element dim names. - mesh_dims = self._mesh_dims[mesh] + cf_mesh_var = self._dataset.variables[cf_mesh_name] + + # Get the mesh-element dim names. + mesh_dims = self._mesh_dims[mesh] + + # Add all the element coordinate variables. + for location in MESH_LOCATIONS: + coords_meshobj_attr = f"{location}_coords" + coords_file_attr = f"{location}_coordinates" + mesh_coords = getattr(mesh, coords_meshobj_attr, None) + if mesh_coords: + coord_names = [] + for coord in mesh_coords: + if coord is None: + continue # an awkward thing that mesh.coords does + coord_name = self._create_generic_cf_array_var( + cube_or_mesh, + [], + coord, + element_dims=(mesh_dims[location],), + ) + coord_names.append(coord_name) + # Record the coordinates (if any) on the mesh variable. + if coord_names: + coord_names = " ".join(coord_names) + _setncattr( + cf_mesh_var, coords_file_attr, coord_names + ) - # Add all the element coordinate variables. - for location in MESH_LOCATIONS: - coords_meshobj_attr = f"{location}_coords" - coords_file_attr = f"{location}_coordinates" - mesh_coords = getattr(mesh, coords_meshobj_attr, None) - if mesh_coords: - coord_names = [] - for coord in mesh_coords: - if coord is None: - continue # an awkward thing that mesh.coords does - coord_name = self._create_generic_cf_array_var( - cube_or_mesh, - [], - coord, - element_dims=(mesh_dims[location],), - ) - coord_names.append(coord_name) - # Record the coordinates (if any) on the mesh variable. - if coord_names: - coord_names = " ".join(coord_names) - _setncattr(cf_mesh_var, coords_file_attr, coord_names) - - # Add all the connectivity variables. - # pre-fetch the set + ignore "None"s, which are empty slots. - conns = [ - conn for conn in mesh.all_connectivities if conn is not None - ] - for conn in conns: - # Get the connectivity role, = "{loc1}_{loc2}_connectivity". - cf_conn_attr_name = conn.cf_role - loc_from, loc_to, _ = cf_conn_attr_name.split("_") - # Construct a trailing dimension name. - last_dim = f"{cf_mesh_name}_{loc_from}_N_{loc_to}s" - # Create if it does not already exist. - if last_dim not in self._dataset.dimensions: - length = conn.shape[1 - conn.src_dim] - self._dataset.createDimension(last_dim, length) - - # Create variable. - # NOTE: for connectivities *with missing points*, this will use a - # fixed standard fill-value of -1. In that case, we create the - # variable with a '_FillValue' property, which can only be done - # when it is first created. - loc_dim_name = mesh_dims[loc_from] - conn_dims = (loc_dim_name, last_dim) - if conn.src_dim == 1: - # Has the 'other' dimension order, =reversed - conn_dims = conn_dims[::-1] - cf_conn_name = self._create_generic_cf_array_var( - cube_or_mesh, - [], - conn, - element_dims=conn_dims, - fill_value=-1, - ) - # Add essential attributes to the Connectivity variable. - cf_conn_var = self._dataset.variables[cf_conn_name] - _setncattr(cf_conn_var, "cf_role", cf_conn_attr_name) - _setncattr(cf_conn_var, "start_index", conn.start_index) - - # Record the connectivity on the parent mesh var. - _setncattr(cf_mesh_var, cf_conn_attr_name, cf_conn_name) - # If the connectivity had the 'alternate' dimension order, add the - # relevant dimension property - if conn.src_dim == 1: - loc_dim_attr = f"{loc_from}_dimension" - # Should only get here once. - assert loc_dim_attr not in cf_mesh_var.ncattrs() - _setncattr(cf_mesh_var, loc_dim_attr, loc_dim_name) + # Add all the connectivity variables. + # pre-fetch the set + ignore "None"s, which are empty slots. + conns = [ + conn + for conn in mesh.all_connectivities + if conn is not None + ] + for conn in conns: + # Get the connectivity role, = "{loc1}_{loc2}_connectivity". + cf_conn_attr_name = conn.cf_role + loc_from, loc_to, _ = cf_conn_attr_name.split("_") + # Construct a trailing dimension name. + last_dim = f"{cf_mesh_name}_{loc_from}_N_{loc_to}s" + # Create if it does not already exist. + if last_dim not in self._dataset.dimensions: + length = conn.shape[1 - conn.src_dim] + self._dataset.createDimension(last_dim, length) + + # Create variable. + # NOTE: for connectivities *with missing points*, this will use a + # fixed standard fill-value of -1. In that case, we create the + # variable with a '_FillValue' property, which can only be done + # when it is first created. + loc_dim_name = mesh_dims[loc_from] + conn_dims = (loc_dim_name, last_dim) + if conn.src_dim == 1: + # Has the 'other' dimension order, =reversed + conn_dims = conn_dims[::-1] + cf_conn_name = self._create_generic_cf_array_var( + cube_or_mesh, + [], + conn, + element_dims=conn_dims, + fill_value=-1, + ) + # Add essential attributes to the Connectivity variable. + cf_conn_var = self._dataset.variables[cf_conn_name] + _setncattr(cf_conn_var, "cf_role", cf_conn_attr_name) + _setncattr(cf_conn_var, "start_index", conn.start_index) + + # Record the connectivity on the parent mesh var. + _setncattr(cf_mesh_var, cf_conn_attr_name, cf_conn_name) + # If the connectivity had the 'alternate' dimension order, add the + # relevant dimension property + if conn.src_dim == 1: + loc_dim_attr = f"{loc_from}_dimension" + # Should only get here once. + assert loc_dim_attr not in cf_mesh_var.ncattrs() + _setncattr(cf_mesh_var, loc_dim_attr, loc_dim_name) return cf_mesh_name diff --git a/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py b/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py index b05e19e1ee..eab3e7942d 100644 --- a/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py +++ b/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py @@ -11,9 +11,13 @@ from html import escape +import numpy as np + from iris.coords import AncillaryVariable, CellMeasure, CellMethod +from iris.cube import Cube from iris.experimental.representation import CubeRepresentation import iris.tests.stock as stock +from iris.tests.stock.mesh import sample_mesh @tests.skip_data @@ -125,7 +129,12 @@ def setUp(self): self.representer._get_bits(self.representer._get_lines()) def test_population(self): - for v in self.representer.str_headings.values(): + nonmesh_values = [ + value + for key, value in self.representer.str_headings.items() + if "Mesh" not in key + ] + for v in nonmesh_values: self.assertIsNotNone(v) def test_headings__dimcoords(self): @@ -343,6 +352,17 @@ def setUp(self): self.representer._get_bits(self.representer._get_lines()) self.result = self.representer._make_content() + # Also provide an ultra-simple mesh cube, with only meshcoords. + mesh = sample_mesh() + meshco_x, meshco_y = mesh.to_MeshCoords("face") + mesh_cube = Cube(np.zeros(meshco_x.shape)) + mesh_cube.add_aux_coord(meshco_x, (0,)) + mesh_cube.add_aux_coord(meshco_y, (0,)) + self.mesh_cube = mesh_cube + self.mesh_representer = CubeRepresentation(self.mesh_cube) + self.mesh_representer._get_bits(self.mesh_representer._get_lines()) + self.mesh_result = self.mesh_representer._make_content() + def test_included(self): included = "Dimension coordinates" self.assertIn(included, self.result) @@ -357,6 +377,23 @@ def test_not_included(self): for heading in not_included: self.assertNotIn(heading, self.result) + def test_mesh_included(self): + # self.mesh_cube contains a `Mesh coordinates` section. + included = "Mesh coordinates" + self.assertIn(included, self.mesh_result) + mesh_coord_names = [ + c.name() for c in self.mesh_cube.coords(mesh_coords=True) + ] + for coord_name in mesh_coord_names: + self.assertIn(coord_name, self.result) + + def test_mesh_not_included(self): + # self.mesh_cube _only_ contains a `Mesh coordinates` section. + not_included = list(self.representer.str_headings.keys()) + not_included.pop(not_included.index("Mesh coordinates:")) + for heading in not_included: + self.assertNotIn(heading, self.result) + @tests.skip_data class Test_repr_html(tests.IrisTest): diff --git a/lib/iris/tests/unit/fileformats/netcdf/test_Saver__ugrid.py b/lib/iris/tests/unit/fileformats/netcdf/test_Saver__ugrid.py index 650e030ac3..3c8838dbea 100644 --- a/lib/iris/tests/unit/fileformats/netcdf/test_Saver__ugrid.py +++ b/lib/iris/tests/unit/fileformats/netcdf/test_Saver__ugrid.py @@ -1240,6 +1240,30 @@ def test_multiple_identical_meshes(self): # Check there are two independent meshes self._check_two_different_meshes(vars) + def test_multiple_same_mesh(self): + mesh = make_mesh() + + # Save and snapshot the result + tempfile_path = self.check_save_mesh([mesh, mesh]) + dims, vars = scan_dataset(tempfile_path) + + # In this case there should be only *one* mesh. + mesh_names = vars_meshnames(vars) + self.assertEqual(1, len(mesh_names)) + + # Check it has the correct number of coords + conns (no duplicates) + # Should have 2 each X and Y coords (face+node): _no_ edge coords. + coord_vars_x = vars_w_props(vars, standard_name="longitude") + coord_vars_y = vars_w_props(vars, standard_name="latitude") + self.assertEqual(2, len(coord_vars_x)) + self.assertEqual(2, len(coord_vars_y)) + + # Check the connectivities are all present: _only_ 1 var of each type. + for conn in mesh.all_connectivities: + if conn is not None: + conn_vars = vars_w_props(vars, cf_role=conn.cf_role) + self.assertEqual(1, len(conn_vars)) + def test_multiple_different_meshes(self): # Create 2 meshes with different faces, but same edges. # N.B. they should *not* then share an edge dimension !