# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE 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 warnings
import cartopy
import cartopy.crs as ccrs
from cartopy.geodesic import Geodesic
import cartopy.mpl.geoaxes
import cftime
import matplotlib.animation as animation
import matplotlib.axes
import matplotlib.collections as mpl_collections
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
from packaging.version import Version
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
from iris.warnings import IrisUnsupportedPlottingWarning
# Cynthia Brewer citation text.
BREWER_CITE = "Colours based on ColorBrewer.org"
PlotDefn = collections.namedtuple("PlotDefn", ("coords", "transpose"))
class _GeoAxesPatched(cartopy.mpl.geoaxes.GeoAxes):
# Workaround for a bug where titles collide with axis labels (cartopy#2390)
# Bug is only present in Cartopy v0.23, so this will only be invoked for
# that version.
def _draw_preprocess(self, renderer):
super()._draw_preprocess(renderer)
for artist in self.artists:
if hasattr(artist, "_draw_gridliner"):
# Note this is only necessary since Cartopy v0.23, but is not
# wasteful for earlier versions as _draw_gridliner() includes
# a check for whether a draw is necessary.
artist._draw_gridliner(renderer=renderer)
cartopy_version = Version(cartopy.__version__)
if cartopy_version.major == 0 and cartopy_version.minor == 23:
cartopy.mpl.geoaxes.GeoAxes = _GeoAxesPatched
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.
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.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):
"""Invert 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'.
Parameters
----------
v_coord :
The coord to be plotted on the y-axis.
axes : optional
"""
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):
"""Check discontiguities in the bounds of the given coordinate.
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.
Parameters
----------
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 : optional
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.
rtol : optional
"""
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)
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
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 == "standard":
r = [datetime.datetime(*date) for date in dates]
else:
try:
import nc_time_axis # noqa: F401
except ImportError:
msg = (
"Cannot plot against time in a non-standard "
'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 = [
cftime.datetime(*date, 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) > 2 and isinstance(args[2], (iris.cube.Cube, iris.coords.Coord)):
# three arguments
u_object, v_object1, v_object2 = args[:3]
u1, v1 = _uv_from_u_object_v_object(u_object, v_object1)
_, v2 = _uv_from_u_object_v_object(u_object, v_object2)
args = args[3:]
if u1.size != v1.size or u1.size != v2.size:
msg = "The x and y-axis objects are not all compatible. They should have equal sizes but got ({}: {}), ({}: {}) and ({}: {})"
raise ValueError(
msg.format(
u_object.name(),
u1.size,
v_object1.name(),
v1.size,
v_object2.name(),
v2.size,
)
)
u = u1
v = (v1, v2)
v_object = (v_object1, v_object2)
elif 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 u.size != v.size:
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(), u.size, v_object.name(), v.size)
)
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) == "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):
"""Shift subsections of u by multiples of 360 degrees.
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 _draw_two_1d_from_points(draw_method_name, arg_func, *args, **kwargs):
"""Draw two 1d frompoints (expects two y-axis variables rather than one).
This function is equivalend to _draw_two_1d_from_points but expects two
y-axis variables rather than one (such as is required for .fill_between). It
can't be used where the y-axis variables are string coordinates. The y-axis
variable provided first has precedence where the two differ on whether the
axis should be inverted or whether a map should be drawn.
"""
# 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_objects, u, vs, args = _get_plot_objects(args)
v_object1, _ = v_objects
v1, v2 = vs
# 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_object1, iris.coords.Coord)
and _can_draw_map([v_object1, 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_object1, kwargs)
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, v1, v2, *args, **kwargs)
result = draw_method(*args, **kwargs)
else:
result = draw_method(u, v1, v2, *args, **kwargs)
# Invert y-axis if necessary.
_invert_yaxis(v_object1, axes)
return result
def _replace_axes_with_cartopy_axes(cartopy_proj):
"""Replace non-cartopy subplot/axes with a cartopy alternative.
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 = ax.get_figure()
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:
position = ax.get_position(original=True)
_ = fig.add_axes(
position,
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.GeoAxes`.
Replace the current non-cartopy axes with
:class:`cartopy.mpl.geoaxes.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.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):
original_length = y.shape[1]
_, 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)
# Having extended the data, we also need to extend extra kwargs for
# matplotlib (e.g. point colours)
for key, val in kwargs.items():
try:
val_arr = np.array(val)
except TypeError:
continue
if val_arr.ndim >= 2 and val_arr.shape[1] == original_length:
# Concatenate the first column to the end of the data then
# update kwargs
val_arr = ma.concatenate([val_arr, val_arr[:, 0:1, ...]], axis=1)
kwargs[key] = val_arr
# 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):
"""Draw contour lines based on the given Cube.
Parameters
----------
coords : list of :class:`~iris.coords.Coord` objects or coordinate names, optional
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`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.contour` for details of other valid
keyword arguments.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
result = _draw_2d_from_points("contour", None, cube, *args, **kwargs)
return result
[docs]
def contourf(cube, *args, **kwargs):
"""Draw filled contours based on the given Cube.
Parameters
----------
coords : list of :class:`~iris.coords.Coord` objects or coordinate names, optional
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`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.contourf` for details of other valid
keyword arguments.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
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.
if hasattr(result, "get_antialiased"):
# Matplotlib v3.8 onwards.
antialiased = any(result.get_antialiased())
# Figure out the colours and alpha level for the contour plot
colors = result.get_facecolor()
alpha = result.alpha or colors[0][3]
# Define a zorder just *below* the polygons to ensure we minimise any boundary shift.
zorder = result.zorder - 0.1
else:
antialiased = result.antialiased
# Figure out the alpha level for the contour plot
alpha = result.alpha or result.collections[0].get_facecolor()[0][3]
colors = [c[0] for c in result.tcolors]
# Define a zorder just *below* the polygons to ensure we minimise any boundary shift.
zorder = result.collections[0].zorder - 0.1
# If the contours are anti-aliased and mostly opaque then draw lines under
# the seams.
if antialiased and alpha > 0.95:
levels = result.levels
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]:
axes = kwargs.get("axes", None)
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))
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
# 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.
Parameters
----------
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.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
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):
"""Plot orography defined at cell boundaries from the given Cube.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
# 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):
"""Plot orography defined at sample points from the given Cube.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
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):
"""Draw cell outlines based on the given Cube.
Parameters
----------
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 : mpl color, default="k"
The color of the cell outlines. If None, the matplotlibrc setting
patch.edgecolor is used by default.
linewidth : number, optional
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`,voptional
The axes to use for drawing. Defaults to the current axes if none
provided.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
result = _draw_2d_from_bounds(
"pcolormesh",
cube,
facecolors="none",
edgecolors=color,
linewidth=linewidth,
antialiased=True,
coords=coords,
axes=axes,
)
return result
[docs]
def pcolor(cube, *args, **kwargs):
"""Draw 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.
Parameters
----------
coords : list of :class:`~iris.coords.Coord` objects or coordinate names, optional
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`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
contiguity_tolerance : float, optional
The absolute tolerance used when checking for contiguity between the
bounds of the cells. Defaults to None.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.pcolor` for details of other valid
keyword arguments.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
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):
"""Draw 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.
Parameters
----------
coords : list of :class:`~iris.coords.Coord` objects or coordinate names, optional
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`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
contiguity_tolerance : float, optional
The absolute tolerance used when checking for
contiguity between the bounds of the cells. Defaults to None.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.pcolormesh` for details of other
valid keyword arguments.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
result = _draw_2d_from_bounds("pcolormesh", cube, *args, **kwargs)
return result
[docs]
def points(cube, *args, **kwargs):
"""Draw sample point positions based on the given Cube.
Parameters
----------
coords : list of :class:`~iris.coords.Coord` objects or coordinate names, optional
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`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.scatter` for details of other valid
keyword arguments.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
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):
"""Vector component 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): # numpydoc ignore=PR08
"""Draw a barb plot from two vector component cubes.
Draws a barb plot from two vector component cubes. Triangles, full-lines
and half-lines represent increments of 50, 10 and 5 respectively.
Parameters
----------
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.
coords : list of :class:`~iris.coords.Coord` or str, optional
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`, optional
Defaults to the current axes if none provided.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.barbs` for details of other valid
keyword arguments.
Notes
-----
.. 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
the :meth:`~cartopy.crs.CRS.transform_points` method of
:class:`cartopy.crs.CRS`.
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
#
# 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): # numpydoc ignore=PR08
"""Draw an arrow plot from two vector component cubes.
Parameters
----------
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.
coords : list of :class:`~iris.coords.Coord` or str, optional
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`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.quiver` for details of other valid
keyword arguments.
Notes
-----
.. 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
the :meth:`~cartopy.crs.CRS.transform_points` method of
:class:`cartopy.crs.CRS`.
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
#
# 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):
"""Draw a line plot based on the given cube(s) or coordinate(s).
Parameters
----------
axes : :class:`matplotlib.axes.Axes`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.plot` for details of additional valid
keyword arguments.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
Examples
--------
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)
"""
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):
"""Draw a scatter plot based on the given cube(s) or coordinate(s).
Parameters
----------
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.
axes : :class:`matplotlib.axes.Axes`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.scatter` for details of additional
valid keyword arguments.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
# 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)
[docs]
def fill_between(x, y1, y2, *args, **kwargs):
"""Plot y1 and y2 against x, and fills the space between them.
Parameters
----------
x : :class:`~iris.cube.Cube` or :class:`~iris.coords.Coord`
A cube or a coordinate to plot on the x-axis.
y1 : :class:`~iris.cube.Cube` or :class:`~iris.coords.Coord`
First cube or a coordinate to plot on the y-axis.
y2 : :class:`~iris.cube.Cube` or :class:`~iris.coords.Coord`
Second cube or a coordinate to plot on the y-axis.
axes : :class:`matplotlib.axes.Axes`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.fill_between` for details of additional valid
keyword arguments.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
# 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(y1, (iris.cube.Cube, iris.coords.Coord)):
raise TypeError("y1 must be a cube or a coordinate.")
if not isinstance(y1, (iris.cube.Cube, iris.coords.Coord)):
raise TypeError("y2 must be a cube or a coordinate.")
args = (x, y1, y2) + args
_plot_args = None
return _draw_two_1d_from_points("fill_between", _plot_args, *args, **kwargs)
[docs]
def hist(x, *args, **kwargs):
"""Compute and plot a histogram.
Parameters
----------
x :
A :class:`~iris.cube.Cube`, :class:`~iris.coords.Coord`,
:class:`~iris.coords.CellMeasure`, or :class:`~iris.coords.AncillaryVariable`
that will be used as the values that will be used to create the
histogram.
Note that if a coordinate is given, the points are used, ignoring the
bounds.
**kwargs : dict, optional
See :func:`matplotlib.pyplot.hist` for details of additional valid
keyword arguments.
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
if isinstance(x, iris.cube.Cube):
data = x.data
elif isinstance(x, iris.coords._DimensionalMetadata):
data = x._values
else:
raise TypeError(
"x must be a cube, coordinate, cell measure or ancillary variable."
)
return plt.hist(data, *args, **kwargs)
# Provide convenience show method from pyplot
show = plt.show
[docs]
def symbols(x, y, symbols, size, axes=None, units="inches"):
"""Draw fixed-size symbols.
See :mod:`iris.symbols` for available symbols.
Parameters
----------
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`.
axes : :class:`matplotlib.axes.Axes`, optional
The axes to use for drawing. Defaults to the current axes if none
provided.
units : ['inches', 'points'], default="inches"
The unit for the symbol size.
Notes
-----
This function does maintain laziness when called; it doesn't realise data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
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.
Parameters
----------
text : str
Citation text to be plotted.
figure : :class:`matplotlib.figure.Figure`, optional
Target figure instance. Defaults to the current figure if none provided.
axes : :class:`matplotlib.axes.Axes`, optional
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)
[docs]
def animate(cube_iterator, plot_func, fig=None, **kwargs):
"""Animate the given cube iterator.
Parameters
----------
cube_iterator : iterable of :class:`iris.cube.Cube` objects
Each animation frame corresponds to each :class:`iris.cube.Cube`
object. See :meth:`iris.cube.Cube.slices`.
plot_func : :mod:`iris.plot` or :mod:`iris.quickplot` plotting function
Plotting function used to animate. Must accept the signature
``plot_func(cube, vmin=vmin, vmax=vmax, coords=coords)``.
:func:`~iris.plot.contourf`, :func:`~iris.plot.contour`,
:func:`~iris.plot.pcolor` and :func:`~iris.plot.pcolormesh`
all conform to this signature.
fig : :class:`matplotlib.figure.Figure` instance, optional
By default, the current figure will be used or a new figure instance
created if no figure is available. See :func:`matplotlib.pyplot.gcf`.
coords : list of :class:`~iris.coords.Coord` objects or coordinate names, optional
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.
interval : int, float or long, optional
Defines the time interval in milliseconds between successive frames.
A default interval of 100ms is set.
vmin, vmax : int, float or long, optional
Color scaling values, see :class:`matplotlib.colors.Normalize` for
further details. Default values are determined by the min-max across
the data set over the entire sequence.
**kwargs : dict, optional
See :class:`matplotlib.animation.FuncAnimation` for details of other
valid keyword arguments.
Returns
-------
:class:`~matplotlib.animation.FuncAnimation` object suitable for saving and or plotting.
Examples
--------
>>> import iris
>>> from iris import plot as iplt
>>> from iris import quickplot as qplt
>>> my_cube = iris.load_cube(iris.sample_data_path("A1B_north_america.nc"))
To animate along a set of :class:`~iris.cube.Cube` slices :
>>> cube_iter = my_cube.slices(("longitude", "latitude"))
>>> ani = iplt.animate(cube_iter, qplt.contourf)
>>> iplt.show()
Notes
-----
This function does not maintain laziness when called; it realises data.
See more at :doc:`/userguide/real_and_lazy_data`.
"""
kwargs.setdefault("interval", 100)
coords = kwargs.pop("coords", None)
if fig is None:
fig = plt.gcf()
def update_animation_iris(i, cubes, vmin, vmax, coords):
# Clearing the figure is currently necessary for compatibility with
# the iris quickploting module - due to the colorbar.
plt.gcf().clf()
plot_func(cubes[i], vmin=vmin, vmax=vmax, coords=coords)
# Turn cube iterator into a list to determine plot ranges.
# NOTE: we check that we are not providing a cube as this has a deprecated
# iter special method.
if hasattr(cube_iterator, "__iter__") and not isinstance(
cube_iterator, iris.cube.Cube
):
cubes = iris.cube.CubeList(cube_iterator)
else:
msg = "iterable type object required for animation, {} given".format(
type(cube_iterator)
)
raise TypeError(msg)
supported = ["iris.plot", "iris.quickplot"]
if plot_func.__module__ not in supported:
msg = 'Given plotting module "{}" may not be supported, intended use: {}.'
msg = msg.format(plot_func.__module__, supported)
warnings.warn(msg, category=IrisUnsupportedPlottingWarning)
supported = ["contour", "contourf", "pcolor", "pcolormesh"]
if plot_func.__name__ not in supported:
msg = 'Given plotting function "{}" may not be supported, intended use: {}.'
msg = msg.format(plot_func.__name__, supported)
warnings.warn(msg, category=IrisUnsupportedPlottingWarning)
# Determine plot range.
vmin = kwargs.pop("vmin", min([cc.data.min() for cc in cubes]))
vmax = kwargs.pop("vmax", max([cc.data.max() for cc in cubes]))
update = update_animation_iris
frames = range(len(cubes))
return animation.FuncAnimation(
fig, update, frames=frames, fargs=(cubes, vmin, vmax, coords), **kwargs
)