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..1802732598 --- /dev/null +++ b/lib/iris/_representation/cube_printout.py @@ -0,0 +1,311 @@ +# 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. + +""" +from copy import deepcopy + + +class Row: + def __init__(self, cols, aligns, i_col_unlimited=None): + assert len(cols) == len(aligns) + self.cols = cols + self.aligns = aligns + self.i_col_unlimited = i_col_unlimited + # This col + those after do not add to width + # - a crude alternative to proper column spanning + + +class Table: + def __init__(self, rows=None, col_widths=None): + if rows is None: + rows = [] + self.rows = [deepcopy(row) for row in rows] + self.col_widths = col_widths + + def copy(self): + return Table(self.rows, col_widths=self.col_widths) + + @property + def n_columns(self): + if self.rows: + result = len(self.rows[0].cols) + else: + result = None + return result + + def add_row(self, cols, aligns, i_col_unlimited=None): + n_cols = len(cols) + assert len(aligns) == n_cols + if self.n_columns is not None: + # For now, all rows must have same number of columns + assert n_cols == self.n_columns + row = Row(cols, aligns, i_col_unlimited) + self.rows.append(row) + + def set_min_column_widths(self): + """Set all column widths to minimum required for current content.""" + if self.rows: + widths = [0] * self.n_columns + for row in self.rows: + cols, lim = row.cols, row.i_col_unlimited + if lim is not None: + cols = cols[:lim] # Ignore "unlimited" columns + for i_col, col in enumerate(cols): + widths[i_col] = max(widths[i_col], len(col)) + + self.col_widths = widths + + def formatted_as_strings(self): + """Return lines formatted to the set column widths.""" + if self.col_widths is None: + # If not set, calculate minimum widths. + self.set_min_column_widths() + result_lines = [] + for row in self.rows: + col_texts = [] + for col, align, width in zip( + row.cols, row.aligns, self.col_widths + ): + if align == "left": + col_text = col.ljust(width) + elif align == "right": + col_text = col.rjust(width) + else: + msg = ( + f'Unknown alignment "{align}" ' + 'not in ("left", "right")' + ) + raise ValueError(msg) + col_texts.append(col_text) + result_lines.append(" ".join(col_texts)) + return result_lines + + +class CubePrinter: + """ + An object created from a + :class:`iris._representation.cube_summary.CubeSummary`, which provides + text printout of a :class:`iris.cube.Cube`. + + 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. + + 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): + # Create our internal table from the summary, to produce the printouts. + self.table = self._ingest_summary(cube_summary) + + def _ingest_summary( + self, + cube_summary, + n_indent_section=4, + n_indent_item=4, + n_indent_extra=4, + ): + """Make a table of strings representing the cube-summary.""" + 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 + + # First setup the columns + # - x1 @0 column-1 content : main title; headings; elements-names + # - x1 @1 "value" content (for scalar items) + # - OR x2n @1.. (name, length) for each of n dimensions + column_header_texts = [nameunits_string] # Note extra spacer here + + if cube_is_scalar: + # We will put this in the column-1 position (replacing the dim-map) + column_header_texts.append("(scalar cube)") + else: + for dim_name, length in zip(dim_names, cube_shape): + column_header_texts.append(f"{dim_name}:") + column_header_texts.append(f"{length:d}") + + n_cols = len(column_header_texts) + + # Create a table : a (n_rows) list of (n_cols) strings + + table = Table() + + # Code for adding a row, with control options. + scalar_column_aligns = ["left"] * n_cols + vector_column_aligns = deepcopy(scalar_column_aligns) + if cube_is_scalar: + vector_column_aligns[1] = "left" + else: + vector_column_aligns[1:] = n_dims * ["right", "left"] + + def add_row(col_texts, scalar=False): + aligns = scalar_column_aligns if scalar else vector_column_aligns + i_col_unlimited = 1 if scalar else None + n_missing = n_cols - len(col_texts) + col_texts += [" "] * n_missing + table.add_row(col_texts, aligns, i_col_unlimited=i_col_unlimited) + + # Start with the header line + add_row(column_header_texts) + + # 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] + add_row(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] + add_row(column_texts) + if extra_string: + column_texts = [extra_indent + extra_string] + add_row(column_texts) + + # 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 + add_row([sect_indent + sect_name]) + + def add_scalar_row(name, value=""): + column_texts = [item_indent + name, value] + add_row(column_texts, scalar=True) + + # Add a row for each item + # NOTE: different section types handle differently + title = sect_name.lower() + if "scalar coordinate" in title: + for item in sect.contents: + add_scalar_row(item.name, item.content) + if item.extra: + add_scalar_row(item_to_extra_indent + item.extra) + elif "attribute" in title: + for title, value in zip(sect.names, sect.values): + add_scalar_row(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_row(name) + # elif "mesh" in title: + # for line in sect.contents() + # add_scalar_row(line) + else: + msg = f"Unknown section type : {type(sect)}" + raise ValueError(msg) + + return table + + @staticmethod + def _decorated_table(table, name_padding=None): + """Return a modified table with added characters in the header.""" + + # Copy the input table + extract the header + its columns. + table = table.copy() + header = table.rows[0] + cols = header.cols + + if name_padding: + # Extend header column#0 to a given minimum width. + cols[0] = cols[0].ljust(name_padding) + + # 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] + ")" + + # Add semicolons as dim column spacers + for i_col in range(2, len(cols) - 1, 2): + cols[i_col] += ";" + + # Modify the new table to be returned, invalidate any stored widths. + header.cols = cols + table.rows[0] = header + + # Recalc widths + table.set_min_column_widths() + + # Add extra spacing between columns 0 and 1 + # (Ok because col#0 is left-aligned) + table.col_widths[0] += 1 + + return table + + def _oneline_string(self): + """Produce a one-line summary string.""" + # Copy existing content -- just the header line. + table = Table(rows=[self.table.rows[0]]) + # Note: by excluding other columns, we get a minimum-width result. + + # Add standard decorations. + table = self._decorated_table(table, name_padding=0) + + # Format (with no extra spacing) --> one-line result + (oneline_result,) = table.formatted_as_strings() + return oneline_result + + def _multiline_summary(self, max_width): + """Produce a multi-line summary string.""" + # Get a derived table with standard 'decorations' added. + table = self._decorated_table(self.table, name_padding=35) + result_lines = table.formatted_as_strings() + result = "\n".join(result_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..ec52a16cf8 --- /dev/null +++ b/lib/iris/_representation/cube_summary.py @@ -0,0 +1,300 @@ +# 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 numpy as np + +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] + # Like "if attributes.setdefault(key, value) != value:" + # ..but setdefault fails if values are numpy arrays. + if key not in attributes: + attributes[key] = value + elif not np.all(attributes[key] == 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/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..568dce8fb1 --- /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()