diff --git a/lib/iris/_representation/__init__.py b/lib/iris/_representation/__init__.py new file mode 100644 index 0000000000..f6c7fdf9b4 --- /dev/null +++ b/lib/iris/_representation/__init__.py @@ -0,0 +1,9 @@ +# 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. +""" +Code to make printouts and other representations (e.g. html) of Iris objects. + +""" diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py new file mode 100644 index 0000000000..869d18407f --- /dev/null +++ b/lib/iris/_representation/cube_printout.py @@ -0,0 +1,376 @@ +# 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. +""" +Provides text printouts of Iris cubes. + +""" + +import beautifultable as bt + + +class CubePrinter: + """ + An object created from a + :class:`iris._representation.cube_summary.CubeSummary`, which provides + text printout of a :class:`iris.cube.Cube`. + + This is the type of object now returned by :meth:`iris.cube.Cube.summary` + (when 'oneline=False') : Hence it needs to be printable, so it has a + :meth:`__str__` method which calls its :meth:`to_string`. + + The cube :meth:`iris.cube.Cube.__str__` and + :meth:`iris.cube.Cube.__repr__` methods, and + :meth:`iris.cube.Cube.summary` with 'oneline=True', also use this to + produce cube summary strings. + + It's "table" property is a :class:`beautifultable.BeautifulTable`, which + provides a representation of cube content as a flexible table object. + However, but this cannot currently produce output identical to the + :meth:`to_string` method, which uses additional techniques. + + In principle, this class does not have any internal knowledge of + :class:`iris.cube.Cube`, but only of + :class:`iris._representation.cube_summary.CubeSummary`. + + """ + + def __init__(self, cube_summary, max_width=None): + # Extract what we need from the cube summary, to produce printouts. + + if max_width is None: + max_width = 120 # Our magic best guess + self.max_width = max_width + + # Create a table to produce the printouts. + self.table = self._make_table(cube_summary, max_width) + # NOTE: although beautifultable is useful and provides a flexible output + # form, its formatting features are not yet adequate to produce our + # desired "standard cube summary" appearance. + # (It really needs column-spanning, at least). + # So +make_table the table is useful, it does not encode the whole of the + # state / object info to produce + # So the 'normal' cube summary is produced by "CubePrinter.to_string()", + # which must also use information *not* stored in the table. + # in :meth:`to_string`. + + def _make_table( + self, + cube_summary, + max_width, + n_indent_section=4, + n_indent_item=4, + n_indent_extra=4, + ): + """Make a beautifultable representing the cube-summary.""" + # NOTE: although beautifultable is useful and provides a flexible output + # form, its formatting features are not yet adequate to produce our + # desired "standard cube summary" appearance : For that, we still need + # column spanning (at least). + # So a 'normal' cube summary is produced by "CubePrinter.to_string()", + # which must also use information *not* stored in the table. + sect_indent = " " * n_indent_section + item_indent = sect_indent + " " * n_indent_item + item_to_extra_indent = " " * n_indent_extra + extra_indent = item_indent + item_to_extra_indent + summ = cube_summary + + fullheader = summ.header + nameunits_string = fullheader.nameunit + dimheader = fullheader.dimension_header + cube_is_scalar = dimheader.scalar + + cube_shape = dimheader.shape # may be empty + dim_names = dimheader.dim_names # may be empty + n_dims = len(dim_names) + assert len(cube_shape) == n_dims + + tb = bt.BeautifulTable(maxwidth=max_width) + + # First setup the columns + # - x1 @0 column-1 content : main title; headings; elements-names + # - x1 @1 "value" content (for scalar items) + # - x2n @2 (name, length) for each of n dimensions + column_texts = [nameunits_string, ""] + + if cube_is_scalar: + # We will put this in the column-2 position (replacing the dim-map) + column_texts.append("(scalar cube)") + else: + for dim_name, length in zip(dim_names, cube_shape): + column_texts.append(f"{dim_name}:") + column_texts.append(f"{length:d}") + + n_cols = len(column_texts) + tb.columns.header = column_texts[:] # Making copy, in case (!) + + # Add rows from all the vector sections + for sect in summ.vector_sections.values(): + if sect.contents: + sect_name = sect.title + column_texts = [sect_indent + sect_name, ""] + column_texts += [""] * (n_cols - 2) + tb.rows.append(column_texts) + for vec_summary in sect.contents: + element_name = vec_summary.name + dim_chars = vec_summary.dim_chars + extra_string = vec_summary.extra + column_texts = [item_indent + element_name, ""] + for dim_char in dim_chars: + column_texts += ["", dim_char] + column_texts += [""] * (n_cols - len(column_texts)) + tb.rows.append(column_texts) + if extra_string: + column_texts = [""] * len(column_texts) + column_texts[0] = extra_indent + extra_string + tb.rows.append(column_texts) + + # Record where the 'scalar' part starts. + self.i_first_scalar_row = len(tb.rows) + + # Similar for scalar sections + for sect in summ.scalar_sections.values(): + if sect.contents: + # Add a row for the "section title" text. + sect_name = sect.title + column_texts = [sect_indent + sect_name, ""] + column_texts += [""] * (n_cols - 2) + tb.rows.append(column_texts) + title = sect_name.lower() + + def add_scalar(name, value=""): + column_texts = [item_indent + name, value] + column_texts += [""] * (n_cols - 2) + tb.rows.append(column_texts) + + # Add a row for each item + # NOTE: different section types handle differently + if "scalar coordinate" in title: + for item in sect.contents: + add_scalar(item.name, item.content) + if item.extra: + add_scalar(item_to_extra_indent + item.extra) + elif "attribute" in title: + for title, value in zip(sect.names, sect.values): + add_scalar(title, value) + elif "scalar cell measure" in title or "cell method" in title: + # These are just strings: nothing in the 'value' column. + for name in sect.contents: + add_scalar(name) + # elif "mesh" in title: + # for line in sect.contents() + # add_scalar(line, "") + else: + msg = f"Unknown section type : {type(sect)}" + raise ValueError(msg) + + # Setup our "standard" style options, which is important because the + # column alignment is very helpful to readability. + CubePrinter._set_table_style(tb) + # .. but adopt a 'normal' overall style showing the boxes. + tb.set_style(bt.STYLE_DEFAULT) + return tb + + @staticmethod + def _set_table_style(tb, no_values_column=False): + # Fix all the column paddings and alignments. + # tb.maxwidth = 9999 # don't curtail or wrap *anything* (initially) + tb.columns.alignment[0] = bt.ALIGN_LEFT + if no_values_column: + # Columns are: 1*(section/entry) + 2*(dim, dim-length) + dim_cols = range(1, len(tb.columns) - 1, 2) + else: + # Columns are: 1*(section/entry) + 1*(value) + 2*(dim, dim-length) + tb.columns.alignment[1] = bt.ALIGN_LEFT + dim_cols = range(2, len(tb.columns) - 1, 2) + for i_col in dim_cols: + tb.columns.alignment[i_col] = bt.ALIGN_RIGHT + tb.columns.alignment[i_col] = bt.ALIGN_LEFT + tb.columns.padding_left[i_col] = 2 + tb.columns.padding_right[i_col] = 0 + tb.columns.padding_left[i_col + 1] = 0 + + # Default style uses no decoration at all. + tb.set_style(bt.STYLE_NONE) + + def _oneline_string(self): + """Produce a one-line summary string.""" + # Make a copy of the table, with no spacing and doctored columns. + tb = bt.BeautifulTable() # start from a new table + + # Add column headers, with extra text modifications. + cols = self.table.columns.header[:] + # Add parentheses around the dim column texts, unless already present + # - e.g. "(scalar cube)". + if len(cols) > 2 and not cols[2].startswith("("): + # Add parentheses around the dim columns + cols[2] = "(" + cols[2] + cols[-1] = cols[-1] + ")" + + # Add <> context. + cols[0] = "" + # Add semicolons as column spacers + for i_col in range(3, len(cols) - 1, 2): + cols[i_col] += ";" + # NOTE: it would be "nice" use `table.columns.separator` to do + # this, but bt doesn't currently support that : Setting it + # affects the header-underscore/separator line instead. + tb.columns.header = cols + + # Add a single row matching the header (or nothing will print). + # -- as used inside bt.BeautifulTable._get_string(). + tb.rows.append(tb.columns.header) + + # Setup all our normal column styling options + CubePrinter._set_table_style(tb) + + # Adjust all column paddings for minimal spacing. + tb.columns.padding_left = 0 + tb.columns.padding_right = 1 + + # Print with no width restriction + tb.maxwidth = 9999 + + # Return only the top (header) line. + result = next(tb._get_string()) + return result + + def _multiline_string_OLD(self, max_width): + """Produce a one-line summary string.""" + # pre-render with no width limitation whatsoever. + tb = self.table + tb.maxwidth = 9999 + str(tb) + + # Force wraps in the 'value column' (== column #1) + widths = tb.columns.width[:] + widths[1] = 0 + widths[1] = max_width - sum(widths) + tb.columns.width = widths + tb.columns.width_exceed_policy = bt.WEP_WRAP + # Also must re-establish the style. + # Hmmm, none of this is that obvious, is it ?? + tb.set_style(bt.STYLE_NONE) + + # Finally, use _get_string to reprint *without* recalulting widths. + summary_lines = list(tb._get_string(recalculate_width=False)) + result = "\n".join(summary_lines) + + return result + + def _multiline_summary(self, max_width): + """ + Produce a one-line summary string. + + Note: 'max_width' controls wrapping of the values column. but the + However, the sections-titles/item-names column and dim map are produced + *without* any width restriction. The max_width + + """ + # First print the vector sections. + + # Make a copy, but omitting column 1 (the scalar "values" column) + cols = list(self.table.columns.header) + del cols[1] + tb = bt.BeautifulTable() + + # Add parentheses around the dim column texts, unless already present + # - e.g. "(scalar cube)". + if len(cols) > 1 and not cols[1].startswith("("): + # Add parentheses around the dim columns + cols[1] = "(" + cols[1] + cols[-1] = cols[-1] + ")" + + tb.columns.header = cols + + # Copy the rows, also removing column#1 throughout + # - which puts the dim-map columns in the column#1 place. + for i_row in range(len(self.table.rows)): + row = list(self.table.rows[i_row]) + del row[1] + tb.rows.append(row) + + # Establish our standard style settings (alignment etc). + self._set_table_style(tb, no_values_column=True) + + # Use no width limitation. + tb.maxwidth = 9999 + # First "pre-render", to calculate widths with all of column 0, to + # account for any long scalar names + str(tb) + # Capture the column widths for later + # WARNING: "table.columns.width" is not a simple list, but this works.. + column_widths = list(tb.columns.width[:]) + + # Get just the 'vector rows', as a list of strings + tb = tb.rows[: self.i_first_scalar_row] + if len(tb.rows) > 0: + # 'Normal' case with vector rows : _get_string--> list of lines + summary_lines = list(tb._get_string(recalculate_width=False)) + else: + # When there *are* no vector rows.. + # add a 'dummy' row and get just the first line (= the header) + # ( N.B. as done in bt code table._get_string ). + tb.rows.append(tb.columns.header) + summary_lines = [next(tb._get_string(recalculate_width=False))] + + # Now add the "scalar rows". + # For this part, we have only 2 columns + we force wrapping of the + # second column at a specific width. + + tb = self.table.rows[self.i_first_scalar_row :] + if tb.rows: + # Reset style : *needed* for derived table -- really not obvious ?!? + CubePrinter._set_table_style(tb) + + # Force any wrapping needed in the 'value column' (== column #1) + # WARNING: the 'table.columns.width' parameter behaves strangely : + # - you cannot always simply assign an iterable ? + tb.columns.width[0] = column_widths[0] + tb.columns.width[1] = max_width - column_widths[0] + for i in range(2, len(column_widths)): + column_widths[i] = 0 + tb.columns.width_exceed_policy = bt.WEP_WRAP + + # Get rows for the scalar part + scalar_lines = tb._get_string(recalculate_width=False) + # discard first line (header) + next(scalar_lines) + # add the rest to the summary lines + summary_lines += list(scalar_lines) + + result = "\n".join(summary_lines) + return result + + def to_string(self, oneline=False, max_width=None): + """ + Produce a printable summary. + + Args: + * oneline (bool): + If set, produce a one-line summary (without any extra spacings). + Default is False = produce full (multiline) summary. + * max_width (int): + If set, override the default maximum output width. + Default is None = use the default established at object creation. + + Returns: + result (string) + + """ + if max_width is None: + max_width = self.max_width + + if oneline: + result = self._oneline_string() + else: + result = self._multiline_summary(max_width) + + return result + + def __str__(self): + """Printout of self is the full multiline string.""" + return self.to_string() diff --git a/lib/iris/_representation/cube_summary.py b/lib/iris/_representation/cube_summary.py new file mode 100644 index 0000000000..becae176db --- /dev/null +++ b/lib/iris/_representation/cube_summary.py @@ -0,0 +1,294 @@ +# 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. +""" +Provides objects describing cube summaries. +""" + +import iris.util + + +def sorted_axes(axes): + """ + Returns the axis names sorted alphabetically, with the exception that + 't', 'z', 'y', and, 'x' are sorted to the end. + """ + return sorted( + axes, + key=lambda name: ({"x": 4, "y": 3, "z": 2, "t": 1}.get(name, 0), name), + ) + + +class DimensionHeader: + def __init__(self, cube): + if cube.shape == (): + self.scalar = True + self.dim_names = [] + self.shape = [] + self.contents = ["scalar cube"] + else: + self.scalar = False + self.dim_names = [] + for dim in range(len(cube.shape)): + dim_coords = cube.coords( + contains_dimension=dim, dim_coords=True + ) + if dim_coords: + self.dim_names.append(dim_coords[0].name()) + else: + self.dim_names.append("-- ") + self.shape = list(cube.shape) + self.contents = [ + name + ": %d" % dim_len + for name, dim_len in zip(self.dim_names, self.shape) + ] + + +class FullHeader: + def __init__(self, cube, name_padding=35): + self.name = cube.name() + self.unit = cube.units + self.nameunit = "{name} / ({units})".format( + name=self.name, units=self.unit + ) + self.name_padding = name_padding + self.dimension_header = DimensionHeader(cube) + + +class CoordSummary: + def _summary_coord_extra(self, cube, coord): + # Returns the text needed to ensure this coordinate can be + # distinguished from all others with the same name. + extra = "" + similar_coords = cube.coords(coord.name()) + if len(similar_coords) > 1: + # Find all the attribute keys + keys = set() + for similar_coord in similar_coords: + keys.update(similar_coord.attributes.keys()) + # Look for any attributes that vary + vary = set() + attributes = {} + for key in keys: + for similar_coord in similar_coords: + if key not in similar_coord.attributes: + vary.add(key) + break + value = similar_coord.attributes[key] + if attributes.setdefault(key, value) != value: + vary.add(key) + break + keys = sorted(vary & set(coord.attributes.keys())) + bits = [ + "{}={!r}".format(key, coord.attributes[key]) for key in keys + ] + if bits: + extra = ", ".join(bits) + return extra + + +class VectorSummary(CoordSummary): + def __init__(self, cube, vector, iscoord): + vector_indent = 10 + extra_indent = 13 + self.name = iris.util.clip_string( + vector.name(), clip_length=70 - vector_indent + ) + dims = vector.cube_dims(cube) + self.dim_chars = [ + "x" if dim in dims else "-" for dim in range(len(cube.shape)) + ] + if iscoord: + extra = self._summary_coord_extra(cube, vector) + self.extra = iris.util.clip_string( + extra, clip_length=70 - extra_indent + ) + else: + self.extra = "" + + +class ScalarSummary(CoordSummary): + def __init__(self, cube, coord): + extra_indent = 13 + self.name = coord.name() + if ( + coord.units in ["1", "no_unit", "unknown"] + or coord.units.is_time_reference() + ): + self.unit = "" + else: + self.unit = " {!s}".format(coord.units) + coord_cell = coord.cell(0) + if isinstance(coord_cell.point, str): + self.string_type = True + self.lines = [ + iris.util.clip_string(str(item)) + for item in coord_cell.point.split("\n") + ] + self.point = None + self.bound = None + self.content = "\n".join(self.lines) + else: + self.string_type = False + self.lines = None + self.point = "{!s}".format(coord_cell.point) + coord_cell_cbound = coord_cell.bound + if coord_cell_cbound is not None: + self.bound = "({})".format( + ", ".join(str(val) for val in coord_cell_cbound) + ) + self.content = "{}{}, bound={}{}".format( + self.point, self.unit, self.bound, self.unit + ) + else: + self.bound = None + self.content = "{}{}".format(self.point, self.unit) + extra = self._summary_coord_extra(cube, coord) + self.extra = iris.util.clip_string( + extra, clip_length=70 - extra_indent + ) + + +class Section: + def is_empty(self): + return self.contents == [] + + +class VectorSection(Section): + def __init__(self, title, cube, vectors, iscoord): + self.title = title + self.contents = [ + VectorSummary(cube, vector, iscoord) for vector in vectors + ] + + +class ScalarSection(Section): + def __init__(self, title, cube, scalars): + self.title = title + self.contents = [ScalarSummary(cube, scalar) for scalar in scalars] + + +class ScalarCMSection(Section): + def __init__(self, title, cell_measures): + self.title = title + self.contents = [cm.name() for cm in cell_measures] + + +class AttributeSection(Section): + def __init__(self, title, attributes): + self.title = title + self.names = [] + self.values = [] + self.contents = [] + for name, value in sorted(attributes.items()): + value = iris.util.clip_string(str(value)) + self.names.append(name) + self.values.append(value) + content = "{}: {}".format(name, value) + self.contents.append(content) + + +class CellMethodSection(Section): + def __init__(self, title, cell_methods): + self.title = title + self.contents = [str(cm) for cm in cell_methods] + + +class CubeSummary: + """ + This class provides a structure for output representations of an Iris cube. + It is used to produce the printout of :meth:`iris.cube.Cube.__str__`. + + """ + + def __init__(self, cube, shorten=False, name_padding=35): + self.section_indent = 5 + self.item_indent = 10 + self.extra_indent = 13 + self.shorten = shorten + self.header = FullHeader(cube, name_padding) + + # Cache the derived coords so we can rely on consistent + # object IDs. + derived_coords = cube.derived_coords + # Determine the cube coordinates that are scalar (single-valued) + # AND non-dimensioned. + dim_coords = cube.dim_coords + aux_coords = cube.aux_coords + all_coords = dim_coords + aux_coords + derived_coords + scalar_coords = [ + coord + for coord in all_coords + if not cube.coord_dims(coord) and coord.shape == (1,) + ] + # Determine the cube coordinates that are not scalar BUT + # dimensioned. + scalar_coord_ids = set(map(id, scalar_coords)) + vector_dim_coords = [ + coord for coord in dim_coords if id(coord) not in scalar_coord_ids + ] + vector_aux_coords = [ + coord for coord in aux_coords if id(coord) not in scalar_coord_ids + ] + vector_derived_coords = [ + coord + for coord in derived_coords + if id(coord) not in scalar_coord_ids + ] + + # cell measures + vector_cell_measures = [ + cm for cm in cube.cell_measures() if cm.shape != (1,) + ] + + # Ancillary Variables + vector_ancillary_variables = [av for av in cube.ancillary_variables()] + + # Sort scalar coordinates by name. + scalar_coords.sort(key=lambda coord: coord.name()) + # Sort vector coordinates by data dimension and name. + vector_dim_coords.sort( + key=lambda coord: (cube.coord_dims(coord), coord.name()) + ) + vector_aux_coords.sort( + key=lambda coord: (cube.coord_dims(coord), coord.name()) + ) + vector_derived_coords.sort( + key=lambda coord: (cube.coord_dims(coord), coord.name()) + ) + scalar_cell_measures = [ + cm for cm in cube.cell_measures() if cm.shape == (1,) + ] + + self.vector_sections = {} + + def add_vector_section(title, contents, iscoord=True): + self.vector_sections[title] = VectorSection( + title, cube, contents, iscoord + ) + + add_vector_section("Dimension coordinates:", vector_dim_coords) + add_vector_section("Auxiliary coordinates:", vector_aux_coords) + add_vector_section("Derived coordinates:", vector_derived_coords) + add_vector_section("Cell Measures:", vector_cell_measures, False) + add_vector_section( + "Ancillary Variables:", vector_ancillary_variables, False + ) + + self.scalar_sections = {} + + def add_scalar_section(section_class, title, *args): + self.scalar_sections[title] = section_class(title, *args) + + add_scalar_section( + ScalarSection, "Scalar Coordinates:", cube, scalar_coords + ) + add_scalar_section( + ScalarCMSection, "Scalar cell measures:", scalar_cell_measures + ) + add_scalar_section(AttributeSection, "Attributes:", cube.attributes) + add_scalar_section( + CellMethodSection, "Cell methods:", cube.cell_methods + ) diff --git a/lib/iris/cube.py b/lib/iris/cube.py index c3f77c9288..6be63f58d6 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -49,7 +49,8 @@ import iris.coords import iris.exceptions import iris.util - +from iris._representation.cube_summary import CubeSummary +from iris._representation.cube_printout import CubePrinter __all__ = ["Cube", "CubeList"] @@ -2213,6 +2214,14 @@ def _summary_extra(self, coords, summary, indent): return new_summary def summary(self, shorten=False, name_padding=35): + result = CubePrinter(CubeSummary(self)) + if shorten: + # CubePrinter init doesn't have a "oneline" control, so we return + # a different type in this case. + result = result.to_string(oneline=True) + return result + + def summary_OLDSTYLE(self, shorten=False, name_padding=35): """ Unicode string summary of the Cube with name, a list of dim coord names versus length and optionally relevant coordinate information. @@ -2591,15 +2600,13 @@ def vector_summary( return summary def __str__(self): - return self.summary() + return self.summary().to_string() def __unicode__(self): - return self.summary() + return self.summary().to_string() def __repr__(self): - return "" % self.summary( - shorten=True, name_padding=1 - ) + return self.summary().to_string(oneline=True) def _repr_html_(self): from iris.experimental.representation import CubeRepresentation diff --git a/lib/iris/tests/unit/representation/__init__.py b/lib/iris/tests/unit/representation/__init__.py new file mode 100644 index 0000000000..e943ad149b --- /dev/null +++ b/lib/iris/tests/unit/representation/__init__.py @@ -0,0 +1,6 @@ +# 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. +"""Unit tests for the :mod:`iris._representation` module.""" diff --git a/lib/iris/tests/unit/representation/test_cube_printout.py b/lib/iris/tests/unit/representation/test_cube_printout.py new file mode 100644 index 0000000000..102dac8791 --- /dev/null +++ b/lib/iris/tests/unit/representation/test_cube_printout.py @@ -0,0 +1,172 @@ +# 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. +"""Unit tests for the :mod:`iris._representation.cube_summary` module.""" + +import iris.tests as tests + +import numpy as np +import iris +from iris.coords import AuxCoord, DimCoord +from iris._representation import cube_summary as icr +import iris.tests.stock as istk +from iris.util import new_axis + +from iris._representation.cube_printout import CubePrinter + + +def test_cube(n_extra_dims=0): + cube = istk.realistic_4d() # this one has a derived coord + + # Optionally : add multiple extra dimensions to test the width controls + if n_extra_dims > 0: + + new_cube = cube.copy() + # Add n extra scalar *1 coords + for i_dim in range(n_extra_dims): + dim_name = "long_name_dim_{}".format(i_dim + cube.ndim) + dimco = DimCoord([0], long_name=dim_name) + new_cube.add_aux_coord(dimco) + # Promote to dim coord + new_cube = new_axis(new_cube, dim_name) + + # Put them all at the back + dim_order = list(range(new_cube.ndim)) + dim_order = dim_order[n_extra_dims:] + dim_order[:n_extra_dims] + new_cube.transpose(dim_order) # dontcha hate this inplace way ?? + + # Replace the original test cube + cube = new_cube + + # Add extra things to test all aspects + rotlats_1d, rotlons_1d = ( + cube.coord("grid_latitude").points, + cube.coord("grid_longitude").points, + ) + rotlons_2d, rotlats_2d = np.meshgrid(rotlons_1d, rotlats_1d) + + cs = cube.coord_system() + trulons, trulats = iris.analysis.cartography.unrotate_pole( + rotlons_2d, + rotlats_2d, + cs.grid_north_pole_longitude, + cs.grid_north_pole_latitude, + ) + co_lat, co_lon = cube.coord(axis="y"), cube.coord(axis="x") + latlon_dims = cube.coord_dims(co_lat) + cube.coord_dims(co_lon) + cube.add_aux_coord( + AuxCoord(trulons, standard_name="longitude", units="degrees"), + latlon_dims, + ) + cube.add_aux_coord( + AuxCoord(trulats, standard_name="latitude", units="degrees"), + latlon_dims, + ) + + cube.attributes[ + "history" + ] = "Exceedingly and annoying long message with many sentences. And more and more. And more and more." + + cube.add_cell_method(iris.coords.CellMethod("mean", ["time"])) + cube.add_cell_method( + iris.coords.CellMethod( + "max", ["latitude"], intervals="3 hour", comments="remark" + ) + ) + latlons_shape = [cube.shape[i_dim] for i_dim in latlon_dims] + cube.add_cell_measure( + iris.coords.CellMeasure( + np.zeros(latlons_shape), long_name="cell-timings", units="s" + ), + latlon_dims, + ) + cube.add_cell_measure( + iris.coords.CellMeasure( + [4.3], long_name="whole_cell_factor", units="m^2" + ), + (), + ) # a SCALAR cell-measure + + time_dim = cube.coord_dims(cube.coord(axis="t")) + cube.add_ancillary_variable( + iris.coords.AncillaryVariable( + np.zeros(cube.shape[0]), long_name="time_scalings", units="ppm" + ), + time_dim, + ) + cube.add_ancillary_variable( + iris.coords.AncillaryVariable( + [3.2], long_name="whole_cube_area_factor", units="m^2" + ), + (), + ) # a SCALAR ancillary + + # Add some duplicate-named coords (not possible for dim-coords) + vector_duplicate_name = "level_height" + co_orig = cube.coord(vector_duplicate_name) + dim_orig = cube.coord_dims(co_orig) + co_new = co_orig.copy() + co_new.attributes.update(dict(a=1, b=2)) + cube.add_aux_coord(co_new, dim_orig) + + vector_different_name = "sigma" + co_orig = cube.coord(vector_different_name) + co_orig.attributes["setting"] = "a" + dim_orig = cube.coord_dims(co_orig) + co_new = co_orig.copy() + co_new.attributes["setting"] = "B" + cube.add_aux_coord(co_new, dim_orig) + + # Also need to test this with a SCALAR coord + scalar_duplicate_name = "forecast_period" + co_orig = cube.coord(scalar_duplicate_name) + co_new = co_orig.copy() + co_new.points = co_new.points + 2.3 + co_new.attributes["different"] = "True" + cube.add_aux_coord(co_new) + + # Add a scalar coord with a *really* long name, to challenge the column width formatting + long_name = "long_long_long_long_long_long_long_long_long_long_long_name" + cube.add_aux_coord(DimCoord([0], long_name=long_name)) + return cube + + +class TestCubePrintout(tests.IrisTest): + def _exercise_methods(self, cube): + summ = icr.CubeSummary(cube) + printer = CubePrinter(summ, max_width=110) + has_scalar_ancils = any( + len(anc.cube_dims(cube)) == 0 for anc in cube.ancillary_variables() + ) + unprintable = has_scalar_ancils and cube.ndim == 0 + print("EXISTING full :") + if unprintable: + print(" ( would fail, due to scalar-cube with scalar-ancils )") + else: + print(cube) + print("---full--- :") + print(printer.to_string(max_width=120)) + print("") + print("EXISTING oneline :") + print(repr(cube)) + print("---oneline--- :") + print(printer.to_string(oneline=True)) + print("") + print("original table form:") + tb = printer.table + tb.maxwidth = 140 + print(tb) + print("") + print("") + + def test_basic(self): + cube = test_cube( + n_extra_dims=4 + ) # NB does not yet work with factories. + self._exercise_methods(cube) + + def test_scalar_cube(self): + cube = test_cube()[0, 0, 0, 0] + self._exercise_methods(cube) diff --git a/lib/iris/tests/unit/representation/test_cube_summary.py b/lib/iris/tests/unit/representation/test_cube_summary.py new file mode 100644 index 0000000000..1e3f9afbc8 --- /dev/null +++ b/lib/iris/tests/unit/representation/test_cube_summary.py @@ -0,0 +1,150 @@ +# 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. +"""Unit tests for the :mod:`iris._representation.cube_summary` module.""" + +import numpy as np +import iris.tests as tests +from iris.cube import Cube +from iris.coords import ( + DimCoord, + AuxCoord, + CellMeasure, + AncillaryVariable, + CellMethod, +) + +from iris._representation import cube_summary + + +def example_cube(): + cube = Cube( + np.arange(6).reshape([3, 2]), + standard_name="air_temperature", + long_name="screen_air_temp", + var_name="airtemp", + units="K", + ) + lat = DimCoord([0, 1, 2], standard_name="latitude", units="degrees") + cube.add_dim_coord(lat, 0) + return cube + + +class Test_CubeSummary(tests.IrisTest): + def setUp(self): + self.cube = example_cube() + + def test_header(self): + rep = cube_summary.CubeSummary(self.cube) + header_left = rep.header.nameunit + header_right = rep.header.dimension_header.contents + + self.assertEqual(header_left, "air_temperature / (K)") + self.assertEqual(header_right, ["latitude: 3", "-- : 2"]) + + def test_vector_coord(self): + rep = cube_summary.CubeSummary(self.cube) + dim_section = rep.vector_sections["Dimension coordinates:"] + + self.assertEqual(len(dim_section.contents), 1) + + dim_summary = dim_section.contents[0] + + name = dim_summary.name + dim_chars = dim_summary.dim_chars + extra = dim_summary.extra + + self.assertEqual(name, "latitude") + self.assertEqual(dim_chars, ["x", "-"]) + self.assertEqual(extra, "") + + def test_scalar_coord(self): + cube = self.cube + scalar_coord_no_bounds = AuxCoord([10], long_name="bar", units="K") + scalar_coord_with_bounds = AuxCoord( + [10], long_name="foo", units="K", bounds=[(5, 15)] + ) + scalar_coord_text = AuxCoord( + ["a\nb\nc"], long_name="foo", attributes={"key": "value"} + ) + cube.add_aux_coord(scalar_coord_no_bounds) + cube.add_aux_coord(scalar_coord_with_bounds) + cube.add_aux_coord(scalar_coord_text) + rep = cube_summary.CubeSummary(cube) + + scalar_section = rep.scalar_sections["Scalar Coordinates:"] + + self.assertEqual(len(scalar_section.contents), 3) + + no_bounds_summary = scalar_section.contents[0] + bounds_summary = scalar_section.contents[1] + text_summary = scalar_section.contents[2] + + self.assertEqual(no_bounds_summary.name, "bar") + self.assertEqual(no_bounds_summary.content, "10 K") + self.assertEqual(no_bounds_summary.extra, "") + + self.assertEqual(bounds_summary.name, "foo") + self.assertEqual(bounds_summary.content, "10 K, bound=(5, 15) K") + self.assertEqual(bounds_summary.extra, "") + + self.assertEqual(text_summary.name, "foo") + self.assertEqual(text_summary.content, "a\nb\nc") + self.assertEqual(text_summary.extra, "key='value'") + + def test_cell_measure(self): + cube = self.cube + cell_measure = CellMeasure([1, 2, 3], long_name="foo") + cube.add_cell_measure(cell_measure, 0) + rep = cube_summary.CubeSummary(cube) + + cm_section = rep.vector_sections["Cell Measures:"] + self.assertEqual(len(cm_section.contents), 1) + + cm_summary = cm_section.contents[0] + self.assertEqual(cm_summary.name, "foo") + self.assertEqual(cm_summary.dim_chars, ["x", "-"]) + + def test_ancillary_variable(self): + cube = self.cube + cell_measure = AncillaryVariable([1, 2, 3], long_name="foo") + cube.add_ancillary_variable(cell_measure, 0) + rep = cube_summary.CubeSummary(cube) + + av_section = rep.vector_sections["Ancillary Variables:"] + self.assertEqual(len(av_section.contents), 1) + + av_summary = av_section.contents[0] + self.assertEqual(av_summary.name, "foo") + self.assertEqual(av_summary.dim_chars, ["x", "-"]) + + def test_attributes(self): + cube = self.cube + cube.attributes = {"a": 1, "b": "two"} + rep = cube_summary.CubeSummary(cube) + + attribute_section = rep.scalar_sections["Attributes:"] + attribute_contents = attribute_section.contents + expected_contents = ["a: 1", "b: two"] + + self.assertEqual(attribute_contents, expected_contents) + + def test_cell_methods(self): + cube = self.cube + x = AuxCoord(1, long_name="x") + y = AuxCoord(1, long_name="y") + cell_method_xy = CellMethod("mean", [x, y]) + cell_method_x = CellMethod("mean", x) + cube.add_cell_method(cell_method_xy) + cube.add_cell_method(cell_method_x) + + rep = cube_summary.CubeSummary(cube) + cell_method_section = rep.scalar_sections["Cell methods:"] + expected_contents = ["mean: x, y", "mean: x"] + self.assertEqual(cell_method_section.contents, expected_contents) + + +if __name__ == "__main__": + tests.main() diff --git a/requirements/ci/py36.yml b/requirements/ci/py36.yml index 4d9d25d7c6..f89ef10382 100644 --- a/requirements/ci/py36.yml +++ b/requirements/ci/py36.yml @@ -20,6 +20,9 @@ dependencies: - numpy>=1.14 - python-xxhash - scipy + - pip + - pip: + - beautifultable>=1 # Optional dependencies. - esmpy>=7.0 diff --git a/requirements/ci/py37.yml b/requirements/ci/py37.yml index bdb097796a..f6f9347b88 100644 --- a/requirements/ci/py37.yml +++ b/requirements/ci/py37.yml @@ -20,6 +20,9 @@ dependencies: - numpy>=1.14 - python-xxhash - scipy + - pip + - pip: + - beautifultable>=1 # Optional dependencies. - esmpy>=7.0 diff --git a/requirements/core.txt b/requirements/core.txt index 9e0c4fb1bb..14bb81c863 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -9,3 +9,4 @@ netcdf4 numpy>=1.14 scipy xxhash +beautifultable>=1