diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 0fc381725d..d85dab7d8f 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -2233,6 +2233,11 @@ def summary(self, shorten=False, name_padding=35): versus length and optionally relevant coordinate information. """ + try: + ugrid_mesh = self.ugrid + except AttributeError: + ugrid_mesh = None + # Create a set to contain the axis names for each data dimension. dim_names = [set() for dim in range(len(self.shape))] @@ -2241,10 +2246,16 @@ def summary(self, shorten=False, name_padding=35): for dim in range(len(self.shape)): dim_coords = self.coords(contains_dimension=dim, dim_coords=True) if dim_coords: - dim_names[dim].add(dim_coords[0].name()) + dim_name = dim_coords[0].name() else: - dim_names[dim].add("-- ") + dim_name = "-- " + if ugrid_mesh: + # Identify the unstructured dimension with an `*`. + if dim == ugrid_mesh.cube_dim: + dim_name = "*" + dim_name + + dim_names[dim].add(dim_name) # Convert axes sets to lists and sort. dim_names = [sorted(names, key=sorted_axes) for names in dim_names] @@ -2328,17 +2339,17 @@ def summary(self, shorten=False, name_padding=35): ) # - # Generate textual summary of cube vector coordinates. + # Generate textual summary of cube vector coordinates, cell measures, ancillary variables and ugrid_mesh. # def vector_summary( - vector_coords, + vector_items, + dim_function, cube_header, max_line_offset, - cell_measures=None, - ancillary_variables=None, + add_extra_lines=False, ): """ - Generates a list of suitably aligned strings containing coord + Generates a list of suitably aligned strings containing item names and dimensions indicated by one or more 'x' symbols. .. note:: @@ -2347,12 +2358,7 @@ def vector_summary( returned with the list of strings. """ - if cell_measures is None: - cell_measures = [] - if ancillary_variables is None: - ancillary_variables = [] vector_summary = [] - vectors = [] # Identify offsets for each dimension text marker. alignment = np.array( @@ -2363,11 +2369,9 @@ def vector_summary( ] ) - # Generate basic textual summary for each vector coordinate + # Generate basic textual summary for each vector item # - WITHOUT dimension markers. - for dim_meta in ( - vector_coords + cell_measures + ancillary_variables - ): + for dim_meta in vector_items: vector_summary.append( "%*s%s" % (indent, " ", iris.util.clip_string(dim_meta.name())) @@ -2375,7 +2379,7 @@ def vector_summary( min_alignment = min(alignment) # Determine whether the cube header requires realignment - # due to one or more longer vector coordinate summaries. + # due to one or more longer vector item summaries. if max_line_offset >= min_alignment: delta = max_line_offset - min_alignment + 5 cube_header = "%-*s (%s)" % ( @@ -2385,59 +2389,40 @@ def vector_summary( ) alignment += delta - if vector_coords: - # Generate full textual summary for each vector coordinate - # - WITH dimension markers. - for index, coord in enumerate(vector_coords): - dims = self.coord_dims(coord) - - for dim in range(len(self.shape)): - width = alignment[dim] - len(vector_summary[index]) - char = "x" if dim in dims else "-" - line = "{pad:{width}}{char}".format( - pad=" ", width=width, char=char - ) - vector_summary[index] += line - vectors = vectors + vector_coords - if cell_measures: - # Generate full textual summary for each vector cell - # measure - WITH dimension markers. - for index, cell_measure in enumerate(cell_measures): - dims = self.cell_measure_dims(cell_measure) - - for dim in range(len(self.shape)): - width = alignment[dim] - len(vector_summary[index]) - char = "x" if dim in dims else "-" - line = "{pad:{width}}{char}".format( - pad=" ", width=width, char=char - ) - vector_summary[index] += line - vectors = vectors + cell_measures - if ancillary_variables: - # Generate full textual summary for each vector ancillary - # variable - WITH dimension markers. - for index, av in enumerate(ancillary_variables): - dims = self.ancillary_variable_dims(av) - - for dim in range(len(self.shape)): - width = alignment[dim] - len(vector_summary[index]) - char = "x" if dim in dims else "-" - line = "{pad:{width}}{char}".format( - pad=" ", width=width, char=char - ) - vector_summary[index] += line - vectors = vectors + ancillary_variables - # Interleave any extra lines that are needed to distinguish - # the coordinates. - vector_summary = self._summary_extra( - vectors, vector_summary, extra_indent - ) + # Generate full textual summary for each vector item + # - WITH dimension markers. + for index, coord in enumerate(vector_items): + dims = dim_function(coord) + + for dim in range(len(self.shape)): + width = alignment[dim] - len(vector_summary[index]) + char = "x" if dim in dims else "-" + line = "{pad:{width}}{char}".format( + pad=" ", width=width, char=char + ) + vector_summary[index] += line + + if add_extra_lines: + # Interleave any extra lines that are needed to distinguish + # the coordinates. + # TODO: This should also be done for cell measures and + # ancillary variables. + vector_summary = self._summary_extra( + vector_items, vector_summary, extra_indent + ) return vector_summary, cube_header # Calculate the maximum line offset. max_line_offset = 0 - for coord in all_coords: + dimension_metadata_to_check = ( + list(all_coords) + + vector_cell_measures + + vector_ancillary_variables + ) + if ugrid_mesh: + dimension_metadata_to_check += [ugrid_mesh] + for coord in dimension_metadata_to_check: max_line_offset = max( max_line_offset, len( @@ -2452,7 +2437,11 @@ def vector_summary( if vector_dim_coords: dim_coord_summary, cube_header = vector_summary( - vector_dim_coords, cube_header, max_line_offset + vector_dim_coords, + self.coord_dims, + cube_header, + max_line_offset, + add_extra_lines=True, ) summary += "\n Dimension coordinates:\n" + "\n".join( dim_coord_summary @@ -2460,7 +2449,11 @@ def vector_summary( if vector_aux_coords: aux_coord_summary, cube_header = vector_summary( - vector_aux_coords, cube_header, max_line_offset + vector_aux_coords, + self.coord_dims, + cube_header, + max_line_offset, + add_extra_lines=True, ) summary += "\n Auxiliary coordinates:\n" + "\n".join( aux_coord_summary @@ -2468,7 +2461,11 @@ def vector_summary( if vector_derived_coords: derived_coord_summary, cube_header = vector_summary( - vector_derived_coords, cube_header, max_line_offset + vector_derived_coords, + self.coord_dims, + cube_header, + max_line_offset, + add_extra_lines=True, ) summary += "\n Derived coordinates:\n" + "\n".join( derived_coord_summary @@ -2479,10 +2476,10 @@ def vector_summary( # if vector_cell_measures: cell_measure_summary, cube_header = vector_summary( - [], + vector_cell_measures, + self.cell_measure_dims, cube_header, max_line_offset, - cell_measures=vector_cell_measures, ) summary += "\n Cell measures:\n" summary += "\n".join(cell_measure_summary) @@ -2492,14 +2489,35 @@ def vector_summary( # if vector_ancillary_variables: ancillary_variable_summary, cube_header = vector_summary( - [], + vector_ancillary_variables, + self.ancillary_variable_dims, cube_header, max_line_offset, - ancillary_variables=vector_ancillary_variables, ) summary += "\n Ancillary variables:\n" summary += "\n".join(ancillary_variable_summary) + # + # Generate summary of ugrid mesh object. + # + if ugrid_mesh: + ugrid_mesh_summary, cube_header = vector_summary( + [ugrid_mesh], + lambda mesh: [mesh.cube_dim], + cube_header, + max_line_offset, + ) + summary += "\n ugrid information:\n" + summary += "\n".join(ugrid_mesh_summary) + summary += "\n{pad:{width}}topology_dimension: {val}".format( + pad=" ", width=indent, val=ugrid_mesh.topology_dimension, + ) + summary += "\n{pad:{width}}node_coordinates: {val}".format( + pad=" ", + width=indent, + val=" ".join(ugrid_mesh.node_coordinates), + ) + # # Generate textual summary of cube scalar coordinates. # diff --git a/lib/iris/experimental/representation.py b/lib/iris/experimental/representation.py index c33c162d4c..f6b3bf3b74 100644 --- a/lib/iris/experimental/representation.py +++ b/lib/iris/experimental/representation.py @@ -91,6 +91,7 @@ def __init__(self, cube): "Derived coordinates:": None, "Cell measures:": None, "Ancillary variables:": None, + "ugrid information:": None, "Scalar coordinates:": None, "Scalar cell measures:": None, "Attributes:": None, @@ -102,6 +103,7 @@ def __init__(self, cube): "Derived coordinates:", "Cell measures:", "Ancillary variables:", + "ugrid information:", ] self.two_cell_headers = ["Scalar coordinates:", "Attributes:"] @@ -123,6 +125,11 @@ def _get_dim_names(self): Note: borrows from `cube.summary`. """ + try: + ugrid_mesh = self.cube.ugrid + except AttributeError: + ugrid_mesh = None + # Create a set to contain the axis names for each data dimension. dim_names = list(range(len(self.cube.shape))) @@ -133,9 +140,16 @@ def _get_dim_names(self): contains_dimension=dim, dim_coords=True ) if dim_coords: - dim_names[dim] = dim_coords[0].name() + dim_name = dim_coords[0].name() else: - dim_names[dim] = "--" + dim_name = "--" + + if ugrid_mesh: + # Identify the unstructured dimension with an `*`. + if dim == ugrid_mesh.cube_dim: + dim_name = "*" + dim_name + + dim_names[dim] = dim_name return dim_names def _dim_names(self): @@ -285,7 +299,7 @@ def _make_content(self): for line in v: # Add every other row in the sub-heading. if k in self.dim_desc_coords: - body = re.findall(r"[\w-]+", line) + body = re.findall(r"[\w\.-]+", line) title = body.pop(0) colspan = 0 elif k in self.two_cell_headers: diff --git a/lib/iris/fileformats/ugrid_cf_reader.py b/lib/iris/fileformats/ugrid_cf_reader.py index f0d7ef62af..ae5abc2d50 100644 --- a/lib/iris/fileformats/ugrid_cf_reader.py +++ b/lib/iris/fileformats/ugrid_cf_reader.py @@ -48,7 +48,16 @@ class CubeUgrid( - namedtuple("CubeUgrid", ["cube_dim", "grid", "mesh_location"]) + namedtuple( + "CubeUgrid", + [ + "cube_dim", + "grid", + "mesh_location", + "topology_dimension", + "node_coordinates", + ], + ) ): """ Object recording the unstructured grid dimension of a cube. @@ -64,13 +73,25 @@ class CubeUgrid( Which element of the mesh the cube is mapped to. Can be 'face', 'edge' or 'node'. A 'volume' is not supported. + * topology_dimension (int): + The highest dimensionality of the geometric elements in the mesh. + + * node_coordinates (list): + A list of the names of the spatial coordinates, used to geolocate the nodes. + """ def __str__(self): result = "Cube unstructured-grid dimension:" result += "\n cube dimension = {}".format(self.cube_dim) result += '\n mesh_location = "{}"'.format(self.mesh_location) - result += '\n mesh "{}" :\n'.format(self.grid.mesh_name) + result += '\n mesh "{}" :'.format(self.grid.mesh_name) + result += '\n topology_dimension "{}" :'.format( + self.topology_dimension + ) + result += '\n node_coordinates "{}" :\n'.format( + " ".join(self.node_coordinates) + ) try: mesh_str = str(self.grid.info) except TypeError: @@ -79,6 +100,9 @@ def __str__(self): result += "\n" return result + def name(self): + return ".".join([self.grid.mesh_name, self.mesh_location]) + class UGridCFReader: """ @@ -188,8 +212,27 @@ def complete_ugrid_cube(self, cube): raise ValueError(msg.format(meshes_info)) if meshes_info: i_dim, (mesh, mesh_location) = meshes_info[0] + mesh_var = self.dataset.variables[mesh.mesh_name] + + topology_dimension = mesh_var.getncattr("topology_dimension") + node_coordinates = [] + for node_var_name in mesh_var.getncattr("node_coordinates").split( + " " + ): + node_var = self.dataset.variables[node_var_name] + name = ( + getattr(node_var, "standard_name", None) + or getattr(node_var, "long_name", None) + or node_var_name + ) + node_coordinates.append(name) + cube.ugrid = CubeUgrid( - cube_dim=i_dim, grid=mesh, mesh_location=mesh_location + cube_dim=i_dim, + grid=mesh, + mesh_location=mesh_location, + topology_dimension=topology_dimension, + node_coordinates=sorted(node_coordinates), ) else: # Add an empty 'cube.ugrid' to all cubes otherwise. diff --git a/lib/iris/tests/integration/ugrid_cf_reader/__init__.py b/lib/iris/tests/integration/ugrid/__init__.py similarity index 100% rename from lib/iris/tests/integration/ugrid_cf_reader/__init__.py rename to lib/iris/tests/integration/ugrid/__init__.py diff --git a/lib/iris/tests/integration/ugrid_cf_reader/test_ugrid_load.py b/lib/iris/tests/integration/ugrid/test_ugrid_load.py similarity index 94% rename from lib/iris/tests/integration/ugrid_cf_reader/test_ugrid_load.py rename to lib/iris/tests/integration/ugrid/test_ugrid_load.py index bc5d5c3b9b..ae0063df9e 100644 --- a/lib/iris/tests/integration/ugrid_cf_reader/test_ugrid_load.py +++ b/lib/iris/tests/integration/ugrid/test_ugrid_load.py @@ -56,6 +56,8 @@ def test_basic_load(self): self.assertIsInstance(cubegrid, CubeUgrid) self.assertEqual(cubegrid.cube_dim, 2) self.assertEqual(cubegrid.mesh_location, "node") + self.assertEqual(cubegrid.topology_dimension, 2) + self.assertEqual(cubegrid.node_coordinates, ["latitude", "longitude"]) # Check cube.ugrid.grid : a gridded Grid type. ugrid = cubegrid.grid diff --git a/lib/iris/tests/integration/ugrid/test_ugrid_summary.py b/lib/iris/tests/integration/ugrid/test_ugrid_summary.py new file mode 100644 index 0000000000..963b3efe7b --- /dev/null +++ b/lib/iris/tests/integration/ugrid/test_ugrid_summary.py @@ -0,0 +1,61 @@ +# Copyright Iris contributors +# +# 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. +""" +Integration tests for the print strings of a UGRID-based cube. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from iris.cube import CubeList +from iris import Constraint +from iris.fileformats.netcdf import load_cubes + + +@tests.skip_data +class TestUgrid(tests.IrisTest): + def setUp(self): + file_path = tests.get_data_path( + ("NetCDF", "unstructured_grid", "theta_nodal_xios.nc") + ) + + # cube = iris.load_cube(file_path, "theta") + # Note: cannot use iris.load, as merge does not yet preserve + # the cube 'ugrid' properties. + + # Here's a thing that at least works. + loaded_cubes = CubeList(load_cubes(file_path, temp_xios_fix=True)) + (self.cube,) = loaded_cubes.extract(Constraint("theta")) + + def test_str__short(self): + text = self.cube.summary(shorten=True) + expect = "Potential Temperature / (K) (time: 1; levels: 6; *-- : 866)" + self.assertEqual(text, expect) + + def test_str__long(self): + self.cube.attributes.clear() # Just remove some uninteresting content. + text = str(self.cube) + expect = """\ +Potential Temperature / (K) (time: 1; levels: 6; *-- : 866) + Dimension coordinates: + time x - - + levels - x - + Auxiliary coordinates: + time x - - + ugrid information: + Mesh0.node - - x + topology_dimension: 2 + node_coordinates: latitude longitude + Cell methods: + point: time\ +""" + self.assertEqual(text, expect) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py b/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py index 6215c039be..3b25677e5e 100644 --- a/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py +++ b/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py @@ -126,8 +126,11 @@ def setUp(self): self.representer._get_bits(self.representer._get_lines()) def test_population(self): - for v in self.representer.str_headings.values(): - self.assertIsNotNone(v) + for k, v in self.representer.str_headings.items(): + if k == "ugrid information:": + self.assertIsNone(v) + else: + self.assertIsNotNone(v) def test_headings__dimcoords(self): contents = self.representer.str_headings["Dimension coordinates:"]