diff --git a/iris_ugrid/tests/integration/test_ugrid_load.py b/iris_ugrid/tests/integration/test_ugrid_load.py index 943839a..783d1c4 100644 --- a/iris_ugrid/tests/integration/test_ugrid_load.py +++ b/iris_ugrid/tests/integration/test_ugrid_load.py @@ -16,7 +16,9 @@ from gridded.pyugrid.ugrid import UGrid from iris import Constraint -from iris.cube import CubeList +from iris.cube import CubeList, Cube + +from iris_ugrid.ucube import UCube from iris_ugrid.ugrid_cf_reader import CubeUgrid, load_cubes @@ -38,9 +40,9 @@ def test_basic_load(self): self.assertEqual(len(loaded_cubes), 2) (cube_0,) = loaded_cubes.extract(Constraint("theta")) - (cube_1,) = loaded_cubes.extract(Constraint("radius")) # Check the primary cube. + self.assertIsInstance(cube_0, UCube) self.assertEqual(cube_0.var_name, "theta") self.assertEqual(cube_0.long_name, "Potential Temperature") self.assertEqual(cube_0.shape, (1, 6, 866)) @@ -63,6 +65,16 @@ def test_basic_load(self): self.assertIsInstance(ugrid, UGrid) self.assertEqual(ugrid.mesh_name, "Mesh0") + def test_nonugrid_load(self): + # Check that ugrid-load can still return "ordinary" cubes. + file_path = tests.get_data_path( + ("NetCDF", "rotated", "xy", "rotPole_landAreaFraction.nc") + ) + cubes = CubeList(load_cubes(file_path)) + cube = cubes[0] + self.assertIsInstance(cube, Cube) + self.assertFalse(isinstance(cube, UCube)) + if __name__ == "__main__": tests.main() diff --git a/iris_ugrid/tests/unit/ucube/test_UCube.py b/iris_ugrid/tests/unit/ucube/test_UCube.py new file mode 100644 index 0000000..f083369 --- /dev/null +++ b/iris_ugrid/tests/unit/ucube/test_UCube.py @@ -0,0 +1,86 @@ +# Copyright Iris-ugrid 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. +""" +Test basic :class:`iris_ugrid.ucube.Ucube` object. +""" +import iris.tests as tests + +import re + +from iris import Constraint +from iris.cube import CubeList + +from iris_ugrid.ugrid_cf_reader import load_cubes + + +class Test_cube_representations(tests.IrisTest): + def setUp(self): + file_path = tests.get_data_path( + ("NetCDF", "unstructured_grid", "theta_nodal_xios.nc") + ) + loaded_cubes = CubeList(load_cubes(file_path)) + (cube,) = loaded_cubes.extract(Constraint("theta")) + # Prune the attributes, just because there are a lot. + keep_attrs = ["timeStamp", "Conventions"] + cube.attributes = { + key: value + for key, value in cube.attributes.items() + if key in keep_attrs + } + self.ucube = cube + + def test_summary_short(self): + # Check the short-form of a UCube summary. + # This the same as what will appear in a CubeList string repr. + result = self.ucube.summary(shorten=True) + expected = ( + "Potential Temperature / (K) " + "(time: 1; levels: 6; *-- : 866)" + ) + self.assertEqual(result, expected) + + def test_summary_long(self): + result = str(self.ucube) + expected = """\ +Potential Temperature / (K) (time: 1; levels: 6; *-- : 866) + Dimension coordinates: + time x - - + levels - x - + Auxiliary coordinates: + time x - - + Unstructured mesh: + Mesh0.node - - x + topology_dimension "2" : + node_coordinates "latitude longitude" : + + Attributes: + Conventions: UGRID + timeStamp: 2016-Oct-24 15:16:48 BST + Cell methods: + point: time\ +""" + self.assertEqual(result, expected) + + def test__repr_html_(self): + result = self.ucube._repr_html_() + # Check for some key pieces of html, which indicate that it includes + # a summary of the unstructured dimension, and mesh details. + str_dim = '*--' + self.assertIn(str_dim, result) + str_section = ( + 'Unstructured mesh' + ) + self.assertIn(str_section, result) + re_mesh = ( + r'' + r"\s*Mesh0\s*" + r'\s*node' + ) + self.assertIsNotNone(re.search(re_mesh, result)) + + +if __name__ == "__main__": + tests.main() diff --git a/iris_ugrid/ucube.py b/iris_ugrid/ucube.py new file mode 100644 index 0000000..189ae9d --- /dev/null +++ b/iris_ugrid/ucube.py @@ -0,0 +1,97 @@ +# 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. +""" +Defines the UCube : a cube which has an unstructured mesh dimension. + +""" +from iris.cube import Cube + + +class UCube(Cube): + # Derived 'unstructured' Cube subtype, with a '.ugrid' property. + def __init__(self, *args, ugrid=None, **kwargs): + super().__init__(*args, **kwargs) + self.ugrid = ugrid + + def _summary_dim_name(self, dim): + """ + Add an identifying "*" prefix to the mesh dimension. + + This specialises the labelling of dims in cube summaries. + + """ + name = super()._summary_dim_name(dim) + if self.ugrid is not None and dim == self.ugrid.cube_dim: + name = "*" + name + return name + + def _summary_vector_sections_info(self): + """ + Build the "vector summary sections" list. This has the standard form, + plus one extra section to contain the mesh. + + This extends cube summaries with a row showing the mesh as for a + coordinate, showing which cube dims it maps to. + + """ + specs = super()._summary_vector_sections_info() + if self.ugrid: + Spec = Cube._VectorSectionSpec + specs.append( + Spec( + title="Unstructured mesh", + elements=[self.ugrid], + add_extra_lines=True, + ) + ) + return specs + + def summary(self, shorten=False, *args, **kwargs): + """ + Provide cube summaries, extended to include mesh information. + + """ + summary = super().summary(shorten=shorten, *args, **kwargs) + if self.ugrid and not shorten: + # Get a mesh description : as it prints itself. + detail_lines = str(self.ugrid).split("\n") + # Use only certain parts: which happens to be the last N lines. + (i_wanted_line,) = [ + i + for i, line in enumerate(detail_lines) + if "topology_dimension" in line + ] + # Cut out end portion, strip lines and discard blank ones. + detail_lines = detail_lines[i_wanted_line:] + detail_lines = [line.strip() for line in detail_lines] + detail_lines = [line for line in detail_lines if line] + + # Find the section that shows the grid info. + summary_lines = summary.split("\n") + ugrid_section_title = "Unstructured mesh" + (i_ugrid_line,) = [ + i + for i, line in enumerate(summary_lines) + if line.strip().startswith(ugrid_section_title) + ] + + # Get the indent of the line below (the grid variable dims). + next_line = summary_lines[i_ugrid_line + 1] + indent_number = [ + ind for ind, char in enumerate(next_line) if char != " " + ][0] + + # Indent the mesh details 4 spaces more than that. + indent_string = " " * (indent_number + 4) + detail_lines = [indent_string + line for line in detail_lines] + + # Splice in the detail lines after that, indenting to match. + i_next_section = i_ugrid_line + 2 + summary_lines[i_next_section:i_next_section] = detail_lines + + summary = "\n".join(summary_lines) + + return summary diff --git a/iris_ugrid/ugrid_cf_reader.py b/iris_ugrid/ugrid_cf_reader.py index 61a12ee..ac3ded6 100644 --- a/iris_ugrid/ugrid_cf_reader.py +++ b/iris_ugrid/ugrid_cf_reader.py @@ -21,6 +21,7 @@ from iris.fileformats.cf import CFReader import iris.fileformats.netcdf +from iris_ugrid.ucube import UCube _UGRID_ELEMENT_TYPE_NAMES = ("node", "edge", "face", "volume") @@ -100,6 +101,18 @@ def __str__(self): def name(self): return ".".join([self.grid.mesh_name, self.mesh_location]) + def cube_dims(self, cube): + # This is needed for cube summary generation, because this object is + # included as a "cube element" in the list structure returned by + # :meth:`UCube._summary_vector_sections_info`. + # All the other elements are _DimensionalMetadata objects. + # Hopefully this will be the only aspect of those which we must mimic. + if self.cube_dim is None: + result = () + else: + result = (self.cube_dim,) + return result + class UGridCFReader(CFReader): """ @@ -179,19 +192,27 @@ def __init__(self, filename, *args, **kwargs): def cube_completion_adjust(self, cube): """ - Cube post-processing method to add details of the mesh to any newly - created cubes which have a mesh dimension. + Cube post-processing method convert newly created cubes which have a + a mesh dimension into :class:`UCubes`s. Called by a 'cube post-modify hook' in :func:`iris.fileformats.netcdf.load_cubes`. - Adds the ".ugrid" property to cubes created by the CF reader, which - links the cube mesh dimension to a specific mesh and element-type (aka - "mesh_location"). + Constructs a :class:`CubeUgrid` referencing the appropriate file mesh, + and makes a new `UCube` of which this is the '.ugrid' property. + + Args: + + * cube (:class:`iris.cube.Cube`): + The cube to be post-processed + + Returns: + :class:`iris_ugrid.ucube.UCube` or ``None`` """ # Identify the unstructured-grid dimension of the cube (if any), and # attach a suitable CubeUgrid object + new_result_cube = None data_var = self.dataset.variables[cube.var_name] meshes_info = [ (i_dim, self.meshdims_map.get(dim_name)) @@ -218,16 +239,35 @@ def cube_completion_adjust(self, cube): ) node_coordinates.append(name) - cube.ugrid = CubeUgrid( + cube_ugrid = CubeUgrid( 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. - cube.ugrid = None + # Return a new UCube, based on the provided Cube. Relying on the + # caller (e.g. :func:iris.fileformats.netcdf.load_cubes) to + # appropriately handle any replacement of the original Cube. + # Absolutely **everything** is the same, except for the extra ugrid + # property. + new_result_cube = UCube( + data=cube.core_data(), + standard_name=cube.standard_name, + long_name=cube.long_name, + var_name=cube.var_name, + units=cube.units, + attributes=cube.attributes, + cell_methods=cube.cell_methods, + dim_coords_and_dims=cube._dim_coords_and_dims, + aux_coords_and_dims=cube._aux_coords_and_dims, + aux_factories=cube.aux_factories, + cell_measures_and_dims=cube._cell_measures_and_dims, + ancillary_variables_and_dims=cube._ancillary_variables_and_dims, + ugrid=cube_ugrid, + ) + + return new_result_cube def load_cubes(filenames, callback=None):