Source code for iris.plot

# 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.
"""
Iris-specific extensions to matplotlib, mimicking the :mod:`matplotlib.pyplot`
interface.

See also: :ref:`matplotlib <matplotlib:users-guide-index>`.

"""

import collections
import datetime

import cartopy.crs as ccrs
from cartopy.geodesic import Geodesic
import cartopy.mpl.geoaxes
import cftime
import matplotlib.axes
import matplotlib.collections as mpl_collections
import matplotlib.dates as mpl_dates
from matplotlib.offsetbox import AnchoredText
import matplotlib.pyplot as plt
import matplotlib.ticker as mpl_ticker
import matplotlib.transforms as mpl_transforms
import numpy as np
import numpy.ma as ma

import iris.analysis.cartography as cartography
import iris.coord_systems
import iris.coords
import iris.cube
from iris.exceptions import IrisError

# Importing iris.palette to register the brewer palettes.
import iris.palette
from iris.util import _meshgrid

# Cynthia Brewer citation text.
BREWER_CITE = "Colours based on ColorBrewer.org"

PlotDefn = collections.namedtuple("PlotDefn", ("coords", "transpose"))


def _get_plot_defn_custom_coords_picked(cube, coords, mode, ndims=2):
    def names(coords):
        result = []
        for coord in coords:
            if isinstance(coord, int):
                result.append("dim={}".format(coord))
            else:
                result.append(coord.name())
        return ", ".join(result)

    def as_coord(coord):
        if isinstance(coord, int):
            # Pass through valid dimension indexes.
            if coord >= ndims:
                emsg = (
                    "The data dimension ({}) is out of range for "
                    "the dimensionality of the required plot ({})"
                )
                raise IndexError(emsg.format(coord, ndims))
        else:
            coord = cube.coord(coord)
        return coord

    coords = list(map(as_coord, coords))

    # Check that we were given the right number of coordinates/dimensions.
    if len(coords) != ndims:
        raise ValueError(
            "The list of coordinates given (%s) should have the"
            " same length (%s) as the dimensionality of the"
            " required plot (%s)" % (names(coords), len(coords), ndims)
        )

    # Check which dimensions are spanned by each coordinate.
    def get_span(coord):
        if isinstance(coord, int):
            span = set([coord])
        else:
            span = set(cube.coord_dims(coord))
        return span

    spans = list(map(get_span, coords))
    for span, coord in zip(spans, coords):
        if not span:
            msg = "The coordinate {!r} doesn't span a data dimension."
            raise ValueError(msg.format(coord.name()))
        if mode == iris.coords.BOUND_MODE and len(span) not in [1, 2]:
            raise ValueError(
                "The coordinate {!r} has {} dimensions."
                "Cell-based plotting is only supported for"
                "coordinates with one or two dimensions.".format(
                    coord.name(), len(span)
                )
            )

    # Check the combination of coordinates spans enough (ndims) data
    # dimensions.
    total_span = set().union(*spans)
    if len(total_span) != ndims:
        raise ValueError(
            "The given coordinates ({}) don't span the {} data"
            " dimensions.".format(names(coords), ndims)
        )

    # If we have 2-dimensional data, and one or more 1-dimensional
    # coordinates, check if we need to transpose.
    transpose = False
    if ndims == 2 and min(map(len, spans)) == 1:
        for i, span in enumerate(spans):
            if len(span) == 1:
                if list(span)[0] == i:
                    transpose = True
                    break

    # Note the use of `reversed` to convert from the X-then-Y
    # convention of the end-user API to the V-then-U convention used by
    # the plotting routines.
    plot_coords = list(reversed(coords))
    return PlotDefn(plot_coords, transpose)


def _valid_bound_dim_coord(coord):
    result = None
    if coord and coord.ndim == 1 and coord.nbounds:
        result = coord
    return result


def _get_plot_defn(cube, mode, ndims=2):
    """
    Return data and plot-axis coords given a cube & a mode of either
    POINT_MODE or BOUND_MODE.

    """
    if cube.ndim != ndims:
        msg = "Cube must be %s-dimensional. Got %s dimensions."
        raise ValueError(msg % (ndims, cube.ndim))

    # Start by taking the DimCoords from each dimension.
    coords = [None] * ndims
    for dim_coord in cube.dim_coords:
        dim = cube.coord_dims(dim_coord)[0]
        coords[dim] = dim_coord

    # When appropriate, restrict to 1D with bounds.
    if mode == iris.coords.BOUND_MODE:
        coords = list(map(_valid_bound_dim_coord, coords))

    def guess_axis(coord):
        axis = None
        if coord is not None:
            axis = iris.util.guess_coord_axis(coord)
        return axis

    # Allow DimCoords in aux_coords to fill in for missing dim_coords.
    for dim, coord in enumerate(coords):
        if coord is None:
            aux_coords = cube.coords(dimensions=dim)
            aux_coords = [
                coord
                for coord in aux_coords
                if isinstance(coord, iris.coords.DimCoord)
            ]
            if aux_coords:
                aux_coords.sort(key=lambda coord: coord.metadata)
                coords[dim] = aux_coords[0]

    # If plotting a 2 dimensional plot, check for 2d coordinates
    if ndims == 2:
        missing_dims = [
            dim for dim, coord in enumerate(coords) if coord is None
        ]
        if missing_dims:
            # Note that this only picks up coordinates that span the dims
            two_dim_coords = cube.coords(dimensions=missing_dims)
            two_dim_coords = [
                coord for coord in two_dim_coords if coord.ndim == 2
            ]
            if len(two_dim_coords) >= 2:
                two_dim_coords.sort(key=lambda coord: coord.metadata)
                coords = two_dim_coords[:2]

    if mode == iris.coords.POINT_MODE:
        # Allow multi-dimensional aux_coords to override the dim_coords
        # along the Z axis. This results in a preference for using the
        # derived altitude over model_level_number or level_height.
        # Limit to Z axis to avoid preferring latitude over grid_latitude etc.
        axes = list(map(guess_axis, coords))
        axis = "Z"
        if axis in axes:
            for coord in cube.coords(dim_coords=False):
                if (
                    max(coord.shape) > 1
                    and iris.util.guess_coord_axis(coord) == axis
                ):
                    coords[axes.index(axis)] = coord

    # Re-order the coordinates to achieve the preferred
    # horizontal/vertical associations. If we can't associate
    # an axis to order the coordinates, fall back to using the cube dimension
    # followed by the name of the coordinate.
    def sort_key(coord):
        order = {"X": 2, "T": 1, "Y": -1, "Z": -2}
        axis = guess_axis(coord)
        return (
            order.get(axis, 0),
            coords.index(coord),
            coord and coord.name(),
        )

    sorted_coords = sorted(coords, key=sort_key)

    transpose = sorted_coords != coords
    return PlotDefn(sorted_coords, transpose)


def _can_draw_map(coords):
    std_names = [
        c and c.standard_name
        for c in coords
        if isinstance(c, iris.coords.Coord)
    ]
    valid_std_names = [
        ["latitude", "longitude"],
        ["grid_latitude", "grid_longitude"],
        ["projection_y_coordinate", "projection_x_coordinate"],
    ]
    return std_names in valid_std_names


def _broadcast_2d(u, v):
    # Matplotlib needs the U and V coordinates to have the same
    # dimensionality (either both 1D, or both 2D). So we simply
    # broadcast both to 2D to be on the safe side.
    u = np.atleast_2d(u)
    v = np.atleast_2d(v.T).T
    u, v = np.broadcast_arrays(u, v)
    return u, v


def _string_coord_axis_tick_labels(string_axes, axes=None):
    """Apply tick labels for string coordinates."""

    ax = axes if axes else plt.gca()
    for axis, ticks in string_axes.items():
        # Define a tick formatter. This will assign a label to all ticks
        # located precisely on  an integer in range(len(ticks)) and assign
        # an empty string to any other ticks.
        def ticker_func(tick_location, _):
            tick_locations = range(len(ticks))
            labels = ticks
            label_dict = dict(zip(tick_locations, labels))
            label = label_dict.get(tick_location, "")
            return label

        formatter = mpl_ticker.FuncFormatter(ticker_func)
        locator = mpl_ticker.MaxNLocator(integer=True)
        this_axis = getattr(ax, axis)
        this_axis.set_major_formatter(formatter)
        this_axis.set_major_locator(locator)


def _invert_yaxis(v_coord, axes=None):
    """
    Inverts the y-axis of the current plot based on conditions:

        * If the y-axis is already inverted we don't want to re-invert it.
        * If v_coord is None then it will not have any attributes.
        * If neither of the above are true then invert y if v_coord has
          attribute 'positive' set to 'down'.

    Args:

        * v_coord - the coord to be plotted on the y-axis

    """
    axes = axes if axes else plt.gca()
    yaxis_is_inverted = axes.yaxis_inverted()
    if not yaxis_is_inverted and isinstance(v_coord, iris.coords.Coord):
        attr_pve = v_coord.attributes.get("positive")
        if attr_pve is not None and attr_pve.lower() == "down":
            axes.invert_yaxis()


def _check_bounds_contiguity_and_mask(coord, data, atol=None, rtol=None):
    """
    Checks that any discontiguities in the bounds of the given coordinate only
    occur where the data is masked.

    Where a discontinuity occurs the grid created for plotting will not be
    correct. This does not matter if the data is masked in that location as
    this is not plotted.

    If a discontiguity occurs where the data is *not* masked, an error is
    raised.

    Args:
        coord: (iris.coord.Coord)
            Coordinate the bounds of which will be checked for contiguity
        data: (array)
            Data of the the cube we are plotting
        atol:
            Absolute tolerance when checking the contiguity. Defaults to None.
            If an absolute tolerance is not set, 1D coords are not checked (so
            as to not introduce a breaking change without a major release) but
            2D coords are always checked, by calling
            :meth:`iris.coords.Coord._discontiguity_in_bounds` with its default
            tolerance.

    """
    kwargs = {}
    data_is_masked = hasattr(data, "mask")
    if data_is_masked:
        # When checking the location of the discontiguities, we check against
        # the opposite of the mask, which is True where data exists.
        mask_invert = np.logical_not(data.mask)

    if coord.ndim == 1:
        # 1D coords are only checked if an absolute tolerance is set, to avoid
        # introducing a breaking change.
        if atol:
            contiguous, diffs = coord._discontiguity_in_bounds(atol=atol)

            if not contiguous and data_is_masked:
                not_masked_at_discontiguity = np.any(
                    np.logical_and(mask_invert[:-1], diffs)
                )
        else:
            return

    elif coord.ndim == 2:
        if atol:
            kwargs["atol"] = atol
        if rtol:
            kwargs["rtol"] = rtol
        contiguous, diffs = coord._discontiguity_in_bounds(**kwargs)

        if not contiguous and data_is_masked:
            diffs_along_x, diffs_along_y = diffs

            # Check along both dimensions that any discontiguous
            # points are correctly masked.
            not_masked_at_discontiguity_along_x = np.any(
                np.logical_and(mask_invert[:, :-1], diffs_along_x)
            )

            not_masked_at_discontiguity_along_y = np.any(
                np.logical_and(mask_invert[:-1], diffs_along_y)
            )

            not_masked_at_discontiguity = (
                not_masked_at_discontiguity_along_x
                or not_masked_at_discontiguity_along_y
            )

    # If any discontiguity occurs where the data is not masked the grid will be
    # created incorrectly, so raise an error.
    if not contiguous:
        if not data_is_masked:
            raise ValueError(
                "The bounds of the {} coordinate are not "
                "contiguous. Not able to create a suitable grid"
                "to plot. You can use "
                "iris.util.find_discontiguities() to identify "
                "discontiguities in your x and y coordinate "
                "bounds arrays.".format(coord.name())
            )
        if not_masked_at_discontiguity:
            raise ValueError(
                "The bounds of the {} coordinate are not "
                "contiguous and data is not masked where the "
                "discontiguity occurs. Not able to create a "
                "suitable grid to plot. You can use "
                "iris.util.find_discontiguities() to identify "
                "discontiguities in your x and y coordinate "
                "bounds arrays, and then mask them with "
                "iris.util.mask_cube()"
                "".format(coord.name())
            )


def _draw_2d_from_bounds(draw_method_name, cube, *args, **kwargs):
    # NB. In the interests of clarity we use "u" and "v" to refer to the
    # horizontal and vertical axes on the matplotlib plot.
    mode = iris.coords.BOUND_MODE
    # Get & remove the coords entry from kwargs.
    coords = kwargs.pop("coords", None)
    if coords is not None:
        plot_defn = _get_plot_defn_custom_coords_picked(
            cube, coords, mode, ndims=2
        )
    else:
        plot_defn = _get_plot_defn(cube, mode, ndims=2)

    contig_tol = kwargs.pop("contiguity_tolerance", None)

    for coord in plot_defn.coords:
        if hasattr(coord, "has_bounds") and coord.has_bounds():
            _check_bounds_contiguity_and_mask(
                coord, data=cube.data, atol=contig_tol
            )

    if _can_draw_map(plot_defn.coords):
        result = _map_common(
            draw_method_name,
            None,
            iris.coords.BOUND_MODE,
            cube,
            plot_defn,
            *args,
            **kwargs,
        )
    else:
        # Obtain data array.
        data = cube.data
        if plot_defn.transpose:
            data = data.T

        # Obtain U and V coordinates
        v_coord, u_coord = plot_defn.coords

        # Track numpy arrays to use for the actual plotting.
        plot_arrays = []

        # Map axis name to associated values.
        string_axes = {}

        for coord, axis_name, data_dim in zip(
            [u_coord, v_coord], ["xaxis", "yaxis"], [1, 0]
        ):
            if coord is None:
                values = np.arange(data.shape[data_dim] + 1)
            elif isinstance(coord, int):
                dim = 1 - coord if plot_defn.transpose else coord
                values = np.arange(data.shape[dim] + 1)
            else:
                if coord.points.dtype.char in "SU":
                    if coord.points.ndim != 1:
                        msg = "Coord {!r} must be one-dimensional."
                        raise ValueError(msg.format(coord))
                    if coord.bounds is not None:
                        msg = "Cannot plot bounded string coordinate."
                        raise ValueError(msg)
                    string_axes[axis_name] = coord.points
                    values = np.arange(data.shape[data_dim] + 1) - 0.5
                else:
                    values = coord.contiguous_bounds()
                    values = _fixup_dates(coord, values)
                    if values.dtype == np.dtype(object) and isinstance(
                        values[0], datetime.datetime
                    ):
                        values = mpl_dates.date2num(values)

            plot_arrays.append(values)

        u, v = plot_arrays

        # If the data is transposed, 2D coordinates will also need to be
        # transposed.
        if plot_defn.transpose is True:
            u, v = [coord.T if coord.ndim == 2 else coord for coord in [u, v]]

        if u.ndim == v.ndim == 1:
            u, v = _broadcast_2d(u, v)

        axes = kwargs.pop("axes", None)
        draw_method = getattr(axes if axes else plt, draw_method_name)
        result = draw_method(u, v, data, *args, **kwargs)

        # Apply tick labels for string coordinates.
        _string_coord_axis_tick_labels(string_axes, axes)

        # Invert y-axis if necessary.
        _invert_yaxis(v_coord, axes)

    return result


def _draw_2d_from_points(draw_method_name, arg_func, cube, *args, **kwargs):
    # NB. In the interests of clarity we use "u" and "v" to refer to the
    # horizontal and vertical axes on the matplotlib plot.
    mode = iris.coords.POINT_MODE
    # Get & remove the coords entry from kwargs.
    coords = kwargs.pop("coords", None)
    if coords is not None:
        plot_defn = _get_plot_defn_custom_coords_picked(cube, coords, mode)
    else:
        plot_defn = _get_plot_defn(cube, mode, ndims=2)

    if _can_draw_map(plot_defn.coords):
        result = _map_common(
            draw_method_name,
            arg_func,
            iris.coords.POINT_MODE,
            cube,
            plot_defn,
            *args,
            **kwargs,
        )
    else:
        # Obtain data array.
        data = cube.data
        if plot_defn.transpose:
            data = data.T
            # Also transpose the scatter marker color array,
            # as now mpl 2.x does not do this for free.
            if draw_method_name == "scatter" and "c" in kwargs:
                c = kwargs["c"]
                if hasattr(c, "T") and cube.data.shape == c.shape:
                    kwargs["c"] = c.T

        # Obtain U and V coordinates
        v_coord, u_coord = plot_defn.coords
        if u_coord is None:
            u = np.arange(data.shape[1])
        elif isinstance(u_coord, int):
            dim = 1 - u_coord if plot_defn.transpose else u_coord
            u = np.arange(data.shape[dim])
        else:
            u = u_coord.points
            u = _fixup_dates(u_coord, u)

        if v_coord is None:
            v = np.arange(data.shape[0])
        elif isinstance(v_coord, int):
            dim = 1 - v_coord if plot_defn.transpose else v_coord
            v = np.arange(data.shape[dim])
        else:
            v = v_coord.points
            v = _fixup_dates(v_coord, v)

        if plot_defn.transpose:
            u = u.T
            v = v.T

        # Track numpy arrays to use for the actual plotting.
        plot_arrays = []

        # Map axis name to associated values.
        string_axes = {}

        for values, axis_name in zip([u, v], ["xaxis", "yaxis"]):
            # Replace any string coordinates with "index" coordinates.
            if values.dtype.char in "SU":
                if values.ndim != 1:
                    raise ValueError(
                        "Multi-dimensional string coordinates "
                        "not supported."
                    )
                plot_arrays.append(np.arange(values.size))
                string_axes[axis_name] = values
            elif values.dtype == np.dtype(object) and isinstance(
                values[0], datetime.datetime
            ):
                plot_arrays.append(mpl_dates.date2num(values))
            else:
                plot_arrays.append(values)

        u, v = plot_arrays
        u, v = _broadcast_2d(u, v)

        axes = kwargs.pop("axes", None)
        draw_method = getattr(axes if axes else plt, draw_method_name)
        if arg_func is not None:
            args, kwargs = arg_func(u, v, data, *args, **kwargs)
            result = draw_method(*args, **kwargs)
        else:
            result = draw_method(u, v, data, *args, **kwargs)

        # Apply tick labels for string coordinates.
        _string_coord_axis_tick_labels(string_axes, axes)

        # Invert y-axis if necessary.
        _invert_yaxis(v_coord, axes)

    return result


def _fixup_dates(coord, values):
    if coord.units.calendar is not None and values.ndim == 1:
        # Convert coordinate values into tuples of
        # (year, month, day, hour, min, sec)
        dates = [coord.units.num2date(val).timetuple()[0:6] for val in values]
        if coord.units.calendar == "gregorian":
            r = [datetime.datetime(*date) for date in dates]
        else:
            try:
                import nc_time_axis
            except ImportError:
                msg = (
                    "Cannot plot against time in a non-gregorian "
                    'calendar, because "nc_time_axis" is not available :  '
                    "Install the package from "
                    "https://github.com/SciTools/nc-time-axis to enable "
                    "this usage."
                )
                raise IrisError(msg)

            r = [
                nc_time_axis.CalendarDateTime(
                    cftime.datetime(*date, calendar=coord.units.calendar),
                    coord.units.calendar,
                )
                for date in dates
            ]
        values = np.empty(len(r), dtype=object)
        values[:] = r
    return values


def _data_from_coord_or_cube(c):
    if isinstance(c, iris.cube.Cube):
        data = c.data
    elif isinstance(c, iris.coords.Coord):
        data = _fixup_dates(c, c.points)
    else:
        raise TypeError("Plot arguments must be cubes or coordinates.")
    return data


def _uv_from_u_object_v_object(u_object, v_object):
    ndim_msg = "Cube or coordinate must be 1-dimensional. Got {} dimensions."
    if u_object is not None and u_object.ndim > 1:
        raise ValueError(ndim_msg.format(u_object.ndim))
    if v_object.ndim > 1:
        raise ValueError(ndim_msg.format(v_object.ndim))
    v = _data_from_coord_or_cube(v_object)
    if u_object is None:
        u = np.arange(v.shape[0])
    else:
        u = _data_from_coord_or_cube(u_object)
    return u, v


def _u_object_from_v_object(v_object):
    u_object = None
    if isinstance(v_object, iris.cube.Cube):
        plot_defn = _get_plot_defn(v_object, iris.coords.POINT_MODE, ndims=1)
        (u_object,) = plot_defn.coords
    return u_object


def _get_plot_objects(args):
    if len(args) > 1 and isinstance(
        args[1], (iris.cube.Cube, iris.coords.Coord)
    ):
        # two arguments
        u_object, v_object = args[:2]
        u, v = _uv_from_u_object_v_object(u_object, v_object)
        args = args[2:]
        if len(u) != len(v):
            msg = (
                "The x and y-axis objects are not compatible. They should "
                "have equal sizes but got ({}: {}) and ({}: {})."
            )
            raise ValueError(
                msg.format(u_object.name(), len(u), v_object.name(), len(v))
            )
    else:
        # single argument
        v_object = args[0]
        u_object = _u_object_from_v_object(v_object)

        u, v = _uv_from_u_object_v_object(u_object, args[0])

        # If a single cube argument, and the associated dimension coordinate
        # is vertical-like, put the coordinate on the y axis, and the data o
        # the x.
        if (
            isinstance(v_object, iris.cube.Cube)
            and isinstance(u_object, iris.coords.Coord)
            and iris.util.guess_coord_axis(u_object) in ["Y", "Z"]
        ):
            u_object, v_object = v_object, u_object
            u, v = v, u

        args = args[1:]
    return u_object, v_object, u, v, args


def _get_geodesic_params(globe):
    # Derive the semimajor axis and flattening values for a given globe from
    # its attributes. If the values are under specified, raise a ValueError
    flattening = globe.flattening
    semimajor = globe.semimajor_axis
    try:
        if semimajor is None:
            # Has semiminor or raises error
            if flattening is None:
                # Has inverse flattening or raises error
                flattening = 1.0 / globe.inverse_flattening
            semimajor = globe.semiminor_axis / (1.0 - flattening)
        elif flattening is None:
            if globe.semiminor_axis is not None:
                flattening = (semimajor - globe.semiminor_axis) / float(
                    semimajor
                )
            else:
                # Has inverse flattening or raises error
                flattening = 1.0 / globe.inverse_flattening
    except TypeError:
        # One of the required attributes was None
        raise ValueError("The globe was underspecified.")

    return semimajor, flattening


def _shift_plot_sections(u_object, u, v):
    """
    Shifts subsections of u by multiples of 360 degrees within ranges
    defined by the points where the line should cross over the 0/360 degree
    longitude boundary.

    e.g. [ 300, 100, 200, 300, 100, 300 ] => [ 300, 460, 560, 660, 820, 660 ]

    """
    # Convert coordinates to true lat-lon
    src_crs = (
        u_object.coord_system.as_cartopy_crs()
        if u_object.coord_system is not None
        else ccrs.Geodetic()
    )
    tgt_crs = ccrs.Geodetic(globe=src_crs.globe)
    tgt_proj = ccrs.PlateCarree(globe=src_crs.globe)

    points = tgt_crs.transform_points(src_crs, u, v)
    startpoints = points[:-1, :2]
    endpoints = points[1:, :2]
    proj_x, proj_y, _ = tgt_proj.transform_points(src_crs, u, v).T

    # Calculate the inverse geodesic for each pair of points in turn, and
    # convert the start point's azimuth into a vector in the source coordinate
    # system.
    try:
        radius, flattening = _get_geodesic_params(src_crs.globe)
        geodesic = Geodesic(radius, flattening)
    except ValueError:
        geodesic = Geodesic()
    dists, azms, _ = geodesic.inverse(startpoints, endpoints).T
    azms_lon = np.sin(np.deg2rad(azms))
    azms_lat = np.cos(np.deg2rad(azms))
    azms_u, _ = src_crs.transform_vectors(
        tgt_proj, proj_x[:-1], proj_y[:-1], azms_lon, azms_lat
    )

    # Use the grid longitude values and the geodesic azimuth to determine
    # the points where the line should cross the 0/360 degree boundary, and
    # in which direction
    lwraps = np.logical_and(u[1:] > u[:-1], azms_u < 0)
    rwraps = np.logical_and(u[1:] < u[:-1], azms_u > 0)
    shifts = np.where(rwraps, 1, 0) - np.where(lwraps, 1, 0)
    shift_vals = shifts.cumsum() * u_object.units.modulus
    new_u = np.empty_like(u)
    new_u[0] = u[0]
    new_u[1:] = u[1:] + shift_vals
    return new_u


def _draw_1d_from_points(draw_method_name, arg_func, *args, **kwargs):
    # NB. In the interests of clarity we use "u" to refer to the horizontal
    # axes on the matplotlib plot and "v" for the vertical axes.

    # retrieve the objects that are plotted on the horizontal and vertical
    # axes (cubes or coordinates) and their respective values, along with the
    # argument tuple with these objects removed
    u_object, v_object, u, v, args = _get_plot_objects(args)

    # Track numpy arrays to use for the actual plotting.
    plot_arrays = []

    # Map axis name to associated values.
    string_axes = {}

    for values, axis_name in zip([u, v], ["xaxis", "yaxis"]):
        # Replace any string coordinates with "index" coordinates.
        if values.dtype.char in "SU":
            if values.ndim != 1:
                msg = "Multi-dimensional string coordinates are not supported."
                raise ValueError(msg)
            plot_arrays.append(np.arange(values.size))
            string_axes[axis_name] = values
        else:
            plot_arrays.append(values)

    u, v = plot_arrays

    # if both u_object and v_object are coordinates then check if a map
    # should be drawn
    if (
        isinstance(u_object, iris.coords.Coord)
        and isinstance(v_object, iris.coords.Coord)
        and _can_draw_map([v_object, u_object])
    ):
        # Replace non-cartopy subplot/axes with a cartopy alternative and set
        # the transform keyword.
        kwargs = _ensure_cartopy_axes_and_determine_kwargs(
            u_object, v_object, kwargs
        )
        if draw_method_name == "plot" and u_object.standard_name not in (
            "projection_x_coordinate",
            "projection_y_coordinate",
        ):
            u = _shift_plot_sections(u_object, u, v)

    axes = kwargs.pop("axes", None)
    draw_method = getattr(axes if axes else plt, draw_method_name)
    if arg_func is not None:
        args, kwargs = arg_func(u, v, *args, **kwargs)
        result = draw_method(*args, **kwargs)
    else:
        result = draw_method(u, v, *args, **kwargs)

    # Apply tick labels for string coordinates.
    _string_coord_axis_tick_labels(string_axes, axes)

    # Invert y-axis if necessary.
    _invert_yaxis(v_object, axes)

    return result


def _replace_axes_with_cartopy_axes(cartopy_proj):
    """
    Replace non-cartopy subplot/axes with a cartopy alternative
    based on the provided projection. If the current axes are already an
    instance of :class:`cartopy.mpl.geoaxes.GeoAxes` then no action is taken.

    """

    ax = plt.gca()
    if not isinstance(ax, cartopy.mpl.geoaxes.GeoAxes):
        fig = plt.gcf()
        if isinstance(ax, matplotlib.axes.SubplotBase):
            _ = fig.add_subplot(
                ax.get_subplotspec(),
                projection=cartopy_proj,
                title=ax.get_title(),
                xlabel=ax.get_xlabel(),
                ylabel=ax.get_ylabel(),
            )
        else:
            _ = fig.add_axes(
                projection=cartopy_proj,
                title=ax.get_title(),
                xlabel=ax.get_xlabel(),
                ylabel=ax.get_ylabel(),
            )

        # delete the axes which didn't have a cartopy projection
        fig.delaxes(ax)


def _ensure_cartopy_axes_and_determine_kwargs(x_coord, y_coord, kwargs):
    """
    Replace the current non-cartopy axes with :class:`cartopy.mpl.GeoAxes`
    and return the appropriate kwargs dict based on the provided coordinates
    and kwargs.

    """
    # Determine projection.
    if x_coord.coord_system != y_coord.coord_system:
        raise ValueError(
            "The X and Y coordinates must have equal coordinate" " systems."
        )
    cs = x_coord.coord_system
    if cs is not None:
        cartopy_proj = cs.as_cartopy_projection()
    else:
        cartopy_proj = ccrs.PlateCarree()

    # Ensure the current axes are a cartopy.mpl.GeoAxes instance.
    axes = kwargs.get("axes")
    if axes is None:
        if (
            isinstance(cs, iris.coord_systems.RotatedGeogCS)
            and x_coord.points.max() > 180
            and x_coord.points.max() < 360
            and x_coord.points.min() > 0
        ):
            # The RotatedGeogCS has 0 - 360 extent, different from the
            # assumptions made by Cartopy: rebase longitudes for the map axes
            # to set the datum longitude to the International Date Line.
            cs_kwargs = cs._ccrs_kwargs()
            cs_kwargs["central_rotated_longitude"] = 180.0
            adapted_cartopy_proj = ccrs.RotatedPole(**cs_kwargs)
            _replace_axes_with_cartopy_axes(adapted_cartopy_proj)
        else:
            _replace_axes_with_cartopy_axes(cartopy_proj)
    elif axes and not isinstance(axes, cartopy.mpl.geoaxes.GeoAxes):
        raise TypeError(
            "The supplied axes instance must be a cartopy " "GeoAxes instance."
        )

    # Set the "from transform" keyword.
    if "transform" in kwargs:
        raise ValueError(
            "The 'transform' keyword is not allowed as it "
            "automatically determined from the coordinate "
            "metadata."
        )
    new_kwargs = kwargs.copy()
    new_kwargs["transform"] = cartopy_proj

    return new_kwargs


def _check_geostationary_coords_and_convert(x, y, kwargs):
    # Geostationary stores projected coordinates as scanning angles (
    # radians), in line with CF definition (this behaviour is unique to
    # Geostationary). Before plotting, must be converted by multiplying by
    # satellite height.
    x, y = (i.copy() for i in (x, y))
    transform = kwargs.get("transform")
    if isinstance(transform, cartopy.crs.Geostationary):
        satellite_height = transform.proj4_params["h"]
        for i in (x, y):
            i *= satellite_height

    return x, y


def _map_common(
    draw_method_name, arg_func, mode, cube, plot_defn, *args, **kwargs
):
    """
    Draw the given cube on a map using its points or bounds.

    "Mode" parameter will switch functionality between POINT or BOUND plotting.


    """
    # Generate 2d x and 2d y grids.
    y_coord, x_coord = plot_defn.coords
    if mode == iris.coords.POINT_MODE:
        if x_coord.ndim == y_coord.ndim == 1:
            x, y = _meshgrid(x_coord.points, y_coord.points)
        elif x_coord.ndim == y_coord.ndim == 2:
            x = x_coord.points
            y = y_coord.points
        else:
            raise ValueError("Expected 1D or 2D XY coords")
    else:
        if not x_coord.ndim == y_coord.ndim == 2:
            try:
                x, y = _meshgrid(
                    x_coord.contiguous_bounds(), y_coord.contiguous_bounds()
                )
            # Exception translation.
            except iris.exceptions.CoordinateMultiDimError:
                raise ValueError(
                    "Expected two 1D coords. Could not get XY"
                    " grid from bounds. X or Y coordinate not"
                    " 1D."
                )
            except ValueError:
                raise ValueError(
                    "Could not get XY grid from bounds. "
                    "X or Y coordinate doesn't have 2 bounds "
                    "per point."
                )
        else:
            x = x_coord.contiguous_bounds()
            y = y_coord.contiguous_bounds()

    # Obtain the data array.
    data = cube.data
    if plot_defn.transpose:
        data = data.T

    # If we are global, then append the first column of data the array to the
    # last (and add 360 degrees) NOTE: if it is found that this block of code
    # is useful in anywhere other than this plotting routine, it may be better
    # placed in the CS.
    if getattr(x_coord, "circular", False):
        _, direction = iris.util.monotonic(
            x_coord.points, return_direction=True
        )
        y = np.append(y, y[:, 0:1], axis=1)
        x = np.append(x, x[:, 0:1] + 360 * direction, axis=1)
        data = ma.concatenate([data, data[:, 0:1]], axis=1)
        if "_v_data" in kwargs:
            v_data = kwargs["_v_data"]
            v_data = ma.concatenate([v_data, v_data[:, 0:1]], axis=1)
            kwargs["_v_data"] = v_data

    # Replace non-cartopy subplot/axes with a cartopy alternative and set the
    # transform keyword.
    kwargs = _ensure_cartopy_axes_and_determine_kwargs(
        x_coord, y_coord, kwargs
    )

    # Make Geostationary coordinates plot-able.
    x, y = _check_geostationary_coords_and_convert(x, y, kwargs)

    if arg_func is not None:
        new_args, kwargs = arg_func(x, y, data, *args, **kwargs)
    else:
        new_args = (x, y, data) + args

    # Draw the contour lines/filled contours.
    axes = kwargs.pop("axes", None)
    plotfn = getattr(axes if axes else plt, draw_method_name)
    return plotfn(*new_args, **kwargs)


[docs]def contour(cube, *args, **kwargs): """ Draws contour lines based on the given Cube. Kwargs: * coords: list of :class:`~iris.coords.Coord` objects or coordinate names Use the given coordinates as the axes for the plot. The order of the given coordinates indicates which axis to use for each, where the first element is the horizontal axis of the plot and the second element is the vertical axis of the plot. * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. See :func:`matplotlib.pyplot.contour` for details of other valid keyword arguments. """ result = _draw_2d_from_points("contour", None, cube, *args, **kwargs) return result
[docs]def contourf(cube, *args, **kwargs): """ Draws filled contours based on the given Cube. Kwargs: * coords: list of :class:`~iris.coords.Coord` objects or coordinate names Use the given coordinates as the axes for the plot. The order of the given coordinates indicates which axis to use for each, where the first element is the horizontal axis of the plot and the second element is the vertical axis of the plot. * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. See :func:`matplotlib.pyplot.contourf` for details of other valid keyword arguments. """ coords = kwargs.get("coords") kwargs.setdefault("antialiased", True) result = _draw_2d_from_points("contourf", None, cube, *args, **kwargs) # Matplotlib produces visible seams between anti-aliased polygons. # But if the polygons are virtually opaque then we can cover the seams # by drawing anti-aliased lines *underneath* the polygon joins. # Figure out the alpha level for the contour plot if result.alpha is None: alpha = result.collections[0].get_facecolor()[0][3] else: alpha = result.alpha # If the contours are anti-aliased and mostly opaque then draw lines under # the seams. if result.antialiased and alpha > 0.95: levels = result.levels colors = [c[0] for c in result.tcolors] if result.extend == "neither": levels = levels[1:-1] colors = colors[:-1] elif result.extend == "min": levels = levels[:-1] colors = colors[:-1] elif result.extend == "max": levels = levels[1:] colors = colors[:-1] else: colors = colors[:-1] if len(levels) > 0 and np.nanmax(cube.data) > levels[0]: # Draw the lines just *below* the polygons to ensure we minimise # any boundary shift. zorder = result.collections[0].zorder - 0.1 axes = kwargs.get("axes", None) # Workaround for cartopy#1780. We do not want contour to shrink # extent. if axes is None: _axes = plt.gca() else: _axes = axes # Subsequent calls to dataLim.update_from_data_xy should not ignore # current extent. _axes.dataLim.ignore(False) contour( cube, levels=levels, colors=colors, antialiased=True, zorder=zorder, coords=coords, axes=axes, ) # Restore the current "image" to 'result' rather than the mappable # resulting from the additional call to contour(). if axes: axes._sci(result) else: plt.sci(result) return result
[docs]def default_projection(cube): """ Return the primary map projection for the given cube. Using the returned projection, one can create a cartopy map with:: import matplotlib.pyplot as plt ax = plt.ax(projection=default_projection(cube)) """ # XXX logic seems flawed, but it is what map_setup did... cs = cube.coord_system("CoordSystem") projection = cs.as_cartopy_projection() if cs else None return projection
[docs]def default_projection_extent(cube, mode=iris.coords.POINT_MODE): """ Return the cube's extents ``(x0, x1, y0, y1)`` in its default projection. Keyword arguments: * mode: Either ``iris.coords.POINT_MODE`` or ``iris.coords.BOUND_MODE`` Triggers whether the extent should be representative of the cell points, or the limits of the cell's bounds. The default is iris.coords.POINT_MODE. """ extents = cartography._xy_range(cube, mode) xlim = extents[0] ylim = extents[1] return tuple(xlim) + tuple(ylim)
def _fill_orography(cube, coords, mode, vert_plot, horiz_plot, style_args): # Find the orography coordinate. orography = cube.coord("surface_altitude") if coords is not None: plot_defn = _get_plot_defn_custom_coords_picked( cube, coords, mode, ndims=2 ) else: plot_defn = _get_plot_defn(cube, mode, ndims=2) v_coord, u_coord = plot_defn.coords # Find which plot coordinate corresponds to the derived altitude, so that # we can replace altitude with the surface altitude. if v_coord and v_coord.standard_name == "altitude": # v is altitude, so plot u and orography with orog in the y direction. result = vert_plot(u_coord, orography, style_args) elif u_coord and u_coord.standard_name == "altitude": # u is altitude, so plot v and orography with orog in the x direction. result = horiz_plot(v_coord, orography, style_args) else: raise ValueError( "Plot does not use hybrid height. One of the " "coordinates to plot must be altitude, but %s and %s " "were given." % (u_coord.name(), v_coord.name()) ) return result
[docs]def orography_at_bounds(cube, facecolor="#888888", coords=None, axes=None): """Plots orography defined at cell boundaries from the given Cube.""" # XXX Needs contiguous orography corners to work. raise NotImplementedError( "This operation is temporarily not provided " "until coordinates can expose 2d contiguous " "bounds (corners)." ) style_args = {"edgecolor": "none", "facecolor": facecolor} def vert_plot(u_coord, orography, style_args): u = u_coord.contiguous_bounds() left = u[:-1] height = orography.points width = u[1:] - left plotfn = axes.bar if axes else plt.bar return plotfn(left, height, width, **style_args) def horiz_plot(v_coord, orography, style_args): v = v_coord.contiguous_bounds() bottom = v[:-1] width = orography.points height = v[1:] - bottom plotfn = axes.barh if axes else plt.barh return plotfn(bottom, width, height, **style_args) return _fill_orography( cube, coords, iris.coords.BOUND_MODE, vert_plot, horiz_plot, style_args )
[docs]def orography_at_points(cube, facecolor="#888888", coords=None, axes=None): """Plots orography defined at sample points from the given Cube.""" style_args = {"facecolor": facecolor} def vert_plot(u_coord, orography, style_args): x = u_coord.points y = orography.points plotfn = axes.fill_between if axes else plt.fill_between return plotfn(x, y, **style_args) def horiz_plot(v_coord, orography, style_args): y = v_coord.points x = orography.points plotfn = axes.fill_betweenx if axes else plt.fill_betweenx return plotfn(y, x, **style_args) return _fill_orography( cube, coords, iris.coords.POINT_MODE, vert_plot, horiz_plot, style_args )
[docs]def outline(cube, coords=None, color="k", linewidth=None, axes=None): """ Draws cell outlines based on the given Cube. Kwargs: * coords: list of :class:`~iris.coords.Coord` objects or coordinate names Use the given coordinates as the axes for the plot. The order of the given coordinates indicates which axis to use for each, where the first element is the horizontal axis of the plot and the second element is the vertical axis of the plot. * color: None or mpl color The color of the cell outlines. If None, the matplotlibrc setting patch.edgecolor is used by default. * linewidth: None or number The width of the lines showing the cell outlines. If None, the default width in patch.linewidth in matplotlibrc is used. * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. """ result = _draw_2d_from_bounds( "pcolormesh", cube, facecolors="none", edgecolors=color, linewidth=linewidth, antialiased=True, coords=coords, axes=axes, ) # set the _is_stroked property to get a single color grid. # See https://github.com/matplotlib/matplotlib/issues/1302 result._is_stroked = False if hasattr(result, "_wrapped_collection_fix"): result._wrapped_collection_fix._is_stroked = False return result
[docs]def pcolor(cube, *args, **kwargs): """ Draws a pseudocolor plot based on the given 2-dimensional Cube. The cube must have either two 1-dimensional coordinates or two 2-dimensional coordinates with contiguous bounds to plot the cube against. Kwargs: * coords: list of :class:`~iris.coords.Coord` objects or coordinate names Use the given coordinates as the axes for the plot. The order of the given coordinates indicates which axis to use for each, where the first element is the horizontal axis of the plot and the second element is the vertical axis of the plot. * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. * contiguity_tolerance: float The absolute tolerance used when checking for contiguity between the bounds of the cells. Defaults to None. See :func:`matplotlib.pyplot.pcolor` for details of other valid keyword arguments. """ kwargs.setdefault("antialiased", True) kwargs.setdefault("snap", False) result = _draw_2d_from_bounds("pcolor", cube, *args, **kwargs) return result
[docs]def pcolormesh(cube, *args, **kwargs): """ Draws a pseudocolor plot based on the given 2-dimensional Cube. The cube must have either two 1-dimensional coordinates or two 2-dimensional coordinates with contiguous bounds to plot against each other. Kwargs: * coords: list of :class:`~iris.coords.Coord` objects or coordinate names Use the given coordinates as the axes for the plot. The order of the given coordinates indicates which axis to use for each, where the first element is the horizontal axis of the plot and the second element is the vertical axis of the plot. * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. * contiguity_tolerance: float The absolute tolerance used when checking for contiguity between the bounds of the cells. Defaults to None. See :func:`matplotlib.pyplot.pcolormesh` for details of other valid keyword arguments. """ result = _draw_2d_from_bounds("pcolormesh", cube, *args, **kwargs) return result
[docs]def points(cube, *args, **kwargs): """ Draws sample point positions based on the given Cube. Kwargs: * coords: list of :class:`~iris.coords.Coord` objects or coordinate names Use the given coordinates as the axes for the plot. The order of the given coordinates indicates which axis to use for each, where the first element is the horizontal axis of the plot and the second element is the vertical axis of the plot. * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. See :func:`matplotlib.pyplot.scatter` for details of other valid keyword arguments. """ def _scatter_args(u, v, data, *args, **kwargs): return ((u, v) + args, kwargs) return _draw_2d_from_points( "scatter", _scatter_args, cube, *args, **kwargs )
def _vector_component_args(x_points, y_points, u_data, *args, **kwargs): """ Callback from _draw_2d_from_points for 'quiver' and 'streamlines'. Returns arguments (x, y, u, v), to be passed to the underlying matplotlib call. "u_data" will always be "u_cube.data". The matching "v_cube.data" component is stored in kwargs['_v_data']. """ v_data = kwargs.pop("_v_data") # Rescale u+v values for plot distortion. crs = kwargs.get("transform", None) if crs: if not isinstance(crs, (ccrs.PlateCarree, ccrs.RotatedPole)): msg = ( "Can only plot vectors provided in a lat-lon " 'projection, i.e. equivalent to "cartopy.crs.PlateCarree" ' 'or "cartopy.crs.RotatedPole". This ' "cube coordinate system translates as Cartopy {}." ) raise ValueError(msg.format(crs)) # Given the above check, the Y points must be latitudes. # We therefore **assume** they are in degrees : I'm not sure this # is wise, but all the rest of this plot code does that, e.g. in # _map_common. # TODO: investigate degree units assumptions, here + elsewhere. # Implement a latitude scaling, but preserve the given magnitudes. u_data, v_data = [arr.copy() for arr in (u_data, v_data)] mags = np.sqrt(u_data * u_data + v_data * v_data) v_data *= np.cos(np.deg2rad(y_points)) scales = mags / np.sqrt(u_data * u_data + v_data * v_data) u_data *= scales v_data *= scales return ((x_points, y_points, u_data, v_data), kwargs)
[docs]def barbs(u_cube, v_cube, *args, **kwargs): """ Draws a barb plot from two vector component cubes. Triangles, full-lines and half-lines represent increments of 50, 10 and 5 respectively. Args: * u_cube, v_cube : (:class:`~iris.cube.Cube`) u and v vector components. Must have same shape and units. If the cubes have geographic coordinates, the values are treated as true distance differentials, e.g. windspeeds, and *not* map coordinate vectors. The components are aligned with the North and East of the cube coordinate system. .. Note:: At present, if u_cube and v_cube have geographic coordinates, then they must be in a lat-lon coordinate system, though it may be a rotated one. To transform wind values between coordinate systems, use :func:`iris.analysis.cartography.rotate_grid_vectors`. To transform coordinate grid points, you will need to create 2-dimensional arrays of x and y values. These can be transformed with :meth:`cartopy.crs.CRS.transform_points`. Kwargs: * coords: (list of :class:`~iris.coords.Coord` or string) Coordinates or coordinate names. Use the given coordinates as the axes for the plot. The order of the given coordinates indicates which axis to use for each, where the first element is the horizontal axis of the plot and the second element is the vertical axis of the plot. * axes: the :class:`matplotlib.axes.Axes` to use for drawing. Defaults to the current axes if none provided. See :func:`matplotlib.pyplot.barbs` for details of other valid keyword arguments. """ # # TODO: check u + v cubes for compatibility. # kwargs["_v_data"] = v_cube.data return _draw_2d_from_points( "barbs", _vector_component_args, u_cube, *args, **kwargs )
[docs]def quiver(u_cube, v_cube, *args, **kwargs): """ Draws an arrow plot from two vector component cubes. Args: * u_cube, v_cube : :class:`~iris.cube.Cube` u and v vector components. Must have same shape and units. If the cubes have geographic coordinates, the values are treated as true distance differentials, e.g. windspeeds, and *not* map coordinate vectors. The components are aligned with the North and East of the cube coordinate system. .. Note:: At present, if u_cube and v_cube have geographic coordinates, then they must be in a lat-lon coordinate system, though it may be a rotated one. To transform wind values between coordinate systems, use :func:`iris.analysis.cartography.rotate_grid_vectors`. To transform coordinate grid points, you will need to create 2-dimensional arrays of x and y values. These can be transformed with :meth:`cartopy.crs.CRS.transform_points`. Kwargs: * coords: list of :class:`~iris.coords.Coord` or string Coordinates or coordinate names. Use the given coordinates as the axes for the plot. The order of the given coordinates indicates which axis to use for each, where the first element is the horizontal axis of the plot and the second element is the vertical axis of the plot. * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. See :func:`matplotlib.pyplot.quiver` for details of other valid keyword arguments. """ # # TODO: check u + v cubes for compatibility. # kwargs["_v_data"] = v_cube.data return _draw_2d_from_points( "quiver", _vector_component_args, u_cube, *args, **kwargs )
[docs]def plot(*args, **kwargs): """ Draws a line plot based on the given cube(s) or coordinate(s). The first one or two arguments may be cubes or coordinates to plot. Each of the following is valid:: # plot a 1d cube against its dimension coordinate plot(cube) # plot a 1d coordinate plot(coord) # plot a 1d cube against a given 1d coordinate, with the cube # values on the y-axis and the coordinate on the x-axis plot(coord, cube) # plot a 1d cube against a given 1d coordinate, with the cube # values on the x-axis and the coordinate on the y-axis plot(cube, coord) # plot two 1d coordinates against one-another plot(coord1, coord2) # plot two 1d cubes against one-another plot(cube1, cube2) Kwargs: * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. See :func:`matplotlib.pyplot.plot` for details of additional valid keyword arguments. """ if "coords" in kwargs: raise TypeError( '"coords" is not a valid plot keyword. Coordinates ' "and cubes may be passed as arguments for " "full control of the plot axes." ) _plot_args = None return _draw_1d_from_points("plot", _plot_args, *args, **kwargs)
[docs]def scatter(x, y, *args, **kwargs): """ Draws a scatter plot based on the given cube(s) or coordinate(s). Args: * x: :class:`~iris.cube.Cube` or :class:`~iris.coords.Coord` A cube or a coordinate to plot on the x-axis. * y: :class:`~iris.cube.Cube` or :class:`~iris.coords.Coord` A cube or a coordinate to plot on the y-axis. Kwargs: * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. See :func:`matplotlib.pyplot.scatter` for details of additional valid keyword arguments. """ # here we are more specific about argument types than generic 1d plotting if not isinstance(x, (iris.cube.Cube, iris.coords.Coord)): raise TypeError("x must be a cube or a coordinate.") if not isinstance(y, (iris.cube.Cube, iris.coords.Coord)): raise TypeError("y must be a cube or a coordinate.") args = (x, y) + args _plot_args = None return _draw_1d_from_points("scatter", _plot_args, *args, **kwargs)
# Provide convenience show method from pyplot show = plt.show
[docs]def symbols(x, y, symbols, size, axes=None, units="inches"): """ Draws fixed-size symbols. See :mod:`iris.symbols` for available symbols. Args: * x: iterable The x coordinates where the symbols will be plotted. * y: iterable The y coordinates where the symbols will be plotted. * symbols: iterable The symbols (from :mod:`iris.symbols`) to plot. * size: float The symbol size in `units`. Kwargs: * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. * units: ['inches', 'points'] The unit for the symbol size. """ if axes is None: axes = plt.gca() offsets = np.array(list(zip(x, y))) # XXX "match_original" doesn't work ... so brute-force it instead. # PatchCollection constructor ignores all non-style keywords when using # match_original # See matplotlib.collections.PatchCollection.__init__ # Specifically matplotlib/collections line 1053 # pc = PatchCollection(symbols, offsets=offsets, transOffset=ax.transData, # match_original=True) facecolors = [p.get_facecolor() for p in symbols] edgecolors = [p.get_edgecolor() for p in symbols] linewidths = [p.get_linewidth() for p in symbols] pc = mpl_collections.PatchCollection( symbols, offsets=offsets, transOffset=axes.transData, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, ) if units == "inches": scale = axes.figure.dpi elif units == "points": scale = axes.figure.dpi / 72.0 else: raise ValueError("Unrecognised units: '%s'" % units) pc.set_transform(mpl_transforms.Affine2D().scale(0.5 * size * scale)) axes.add_collection(pc) axes.autoscale_view()
[docs]def citation(text, figure=None, axes=None): """ Add a text citation to a plot. Places an anchored text citation in the bottom right hand corner of the plot. Args: * text: str Citation text to be plotted. Kwargs: * figure::class:`matplotlib.figure.Figure` Target figure instance. Defaults to the current figure if none provided. * axes: :class:`matplotlib.axes.Axes` The axes to use for drawing. Defaults to the current axes if none provided. """ if text is not None and len(text): if figure is None and not axes: figure = plt.gcf() anchor = AnchoredText(text, prop=dict(size=6), frameon=True, loc=4) anchor.patch.set_boxstyle("round, pad=0, rounding_size=0.2") axes = axes if axes else figure.gca() axes.add_artist(anchor)