diff --git a/doc/users/whats_new/errorbars_3d.rst b/doc/users/whats_new/errorbars_3d.rst new file mode 100644 index 000000000000..99820890bf1e --- /dev/null +++ b/doc/users/whats_new/errorbars_3d.rst @@ -0,0 +1,7 @@ +Errorbar method for mplot3d +--------------------------- + +The errorbar function :meth:`matplotlib.axes._axes.Axes.errorbar` is ported +into the `mplot3d` framework in its entirety. Supports features such as custom +styling for error lines and cap marks, control over erorrbar spacing, upper and +lower limit marks. diff --git a/examples/mplot3d/errorbar3d.py b/examples/mplot3d/errorbar3d.py new file mode 100644 index 000000000000..7d8599d9cf2a --- /dev/null +++ b/examples/mplot3d/errorbar3d.py @@ -0,0 +1,30 @@ +""" +============ +3D errorbars +============ + +An example of using errorbars with upper and lower limits in mplot3d. +""" + +import matplotlib.pyplot as plt +import numpy as np + +fig = plt.figure() +ax = fig.gca(projection='3d') + +# setting up a parametric curve +t = np.arange(0, 2*np.pi+.1, 0.01) +x, y, z = np.sin(t), np.cos(3*t), np.sin(5*t) + +estep = 15 +i = np.arange(t.size) +zuplims = (i % estep == 0) & (i // estep % 3 == 0) +zlolims = (i % estep == 0) & (i // estep % 3 == 2) + +ax.errorbar(x, y, z, 0.2, zuplims=zuplims, zlolims=zlolims, errorevery=estep) + +ax.set_xlabel("X label") +ax.set_ylabel("Y label") +ax.set_zlabel("Z label") + +plt.show() diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index ebf03f7bf20f..8b50ff6300e2 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -12,6 +12,7 @@ from collections import defaultdict from functools import reduce +from itertools import compress import math import textwrap @@ -24,9 +25,10 @@ import matplotlib.colors as mcolors import matplotlib.docstring as docstring import matplotlib.scale as mscale +import matplotlib.container as mcontainer import matplotlib.transforms as mtransforms from matplotlib.axes import Axes, rcParams -from matplotlib.axes._base import _axis_method_wrapper +from matplotlib.axes._base import _axis_method_wrapper, _process_plot_format from matplotlib.transforms import Bbox from matplotlib.tri.triangulation import Triangulation @@ -2913,6 +2915,342 @@ def permutation_matrices(n): return polygons + def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', + barsabove=False, errorevery=1, ecolor=None, elinewidth=None, + capsize=None, capthick=None, xlolims=False, xuplims=False, + ylolims=False, yuplims=False, zlolims=False, zuplims=False, + arrow_length_ratio=.4, **kwargs): + """ + Plot lines and/or markers with errorbars around them. + + *x*/*y*/*z* define the data locations, and *xerr*/*yerr*/*zerr* define + the errorbar sizes. By default, this draws the data markers/lines as + well the errorbars. Use fmt='none' to draw errorbars only. + + Parameters + ---------- + x, y, z : float or array-like + The data positions. + + xerr, yerr, zerr : float or array-like, shape (N,) or (2, N), optional + The errorbar sizes: + + - scalar: Symmetric +/- values for all data points. + - shape(N,): Symmetric +/-values for each data point. + - shape(2, N): Separate - and + values for each bar. First row + contains the lower errors, the second row contains the upper + errors. + - *None*: No errorbar. + + Note that all error arrays should have *positive* values. + + fmt : str, default: '' + The format for the data points / data lines. See `.plot` for + details. + + Use 'none' (case insensitive) to plot errorbars without any data + markers. + + ecolor : color, default: None + The color of the errorbar lines. If None, use the color of the + line connecting the markers. + + elinewidth : float, default: None + The linewidth of the errorbar lines. If None, the linewidth of + the current style is used. + + capsize : float, default: :rc:`errorbar.capsize` + The length of the error bar caps in points. + + capthick : float, default: None + An alias to the keyword argument *markeredgewidth* (a.k.a. *mew*). + This setting is a more sensible name for the property that + controls the thickness of the error bar cap in points. For + backwards compatibility, if *mew* or *markeredgewidth* are given, + then they will over-ride *capthick*. This may change in future + releases. + + barsabove : bool, default: False + If True, will plot the errorbars above the plot + symbols. Default is below. + + xlolims, ylolims, zlolims : bool, default: False + These arguments can be used to indicate that a value gives only + lower limits. In that case a caret symbol is used to indicate + this. *lims*-arguments may be scalars, or array-likes of the same + length as the errors. To use limits with inverted axes, + `~.Axes.set_xlim` or `~.Axes.set_ylim` must be called before + :meth:`errorbar`. Note the tricky parameter names: setting e.g. + *ylolims* to True means that the y-value is a *lower* limit of the + True value, so, only an *upward*-pointing arrow will be drawn! + + xuplims, yuplims, zuplims : bool, default: False + Same as above, but for controlling the upper limits. + + errorevery : int or (int, int), default: 1 + draws error bars on a subset of the data. *errorevery* =N draws + error bars on the points (x[::N], y[::N], z[::N]). + *errorevery* =(start, N) draws error bars on the points + (x[start::N], y[start::N], z[start::N]). e.g. errorevery=(6, 3) + adds error bars to the data at (x[6], x[9], x[12], x[15], ...). + Used to avoid overlapping error bars when two series share x-axis + values. + + arrow_length_ratio : float, default: 0.4 + Passed to :meth:`quiver`, the ratio of the arrow head with respect + to the quiver. + + Returns + ------- + errlines : list + List of `~mpl_toolkits.mplot3d.art3d.Line3DCollection` instances + each containing an errorbar line. + caplines : list + List of `~mpl_toolkits.mplot3d.art3d.Line3D` instances each + containing a capline object. + limmarks : list + List of `~mpl_toolkits.mplot3d.art3d.Line3D` instances each + containing a marker with an upper or lower limit. + + Other Parameters + ---------------- + **kwargs + All other keyword arguments for styling errorbar lines are passed + `~mpl_toolkits.mplot3d.art3d.Line3DCollection`. + + Examples + -------- + .. plot:: gallery/mplot3d/errorbar3d.py + """ + had_data = self.has_data() + + plot_line = (fmt.lower() != 'none') + label = kwargs.pop("label", None) + + if fmt == '': + fmt_style_kwargs = {} + else: + fmt_style_kwargs = {k: v for k, v in + zip(('linestyle', 'marker', 'color'), + _process_plot_format(fmt)) + if v is not None} + + if fmt == 'none': + # Remove alpha=0 color that _process_plot_format returns + fmt_style_kwargs.pop('color') + + if ('color' in kwargs or 'color' in fmt_style_kwargs): + base_style = {} + if 'color' in kwargs: + base_style['color'] = kwargs.pop('color') + else: + base_style = next(self._get_lines.prop_cycler) + + base_style['label'] = '_nolegend_' + base_style.update(fmt_style_kwargs) + if 'color' not in base_style: + base_style['color'] = 'C0' + if ecolor is None: + ecolor = base_style['color'] + + # make sure all the args are iterable; use lists not arrays to + # preserve units + x = x if np.iterable(x) else [x] + y = y if np.iterable(y) else [y] + z = z if np.iterable(z) else [z] + + if not len(x) == len(y) == len(z): + raise ValueError("'x', 'y', and 'z' must have the same size") + + # make the style dict for the 'normal' plot line + if 'zorder' not in kwargs: + kwargs['zorder'] = 2 + plot_line_style = { + **base_style, + **kwargs, + 'zorder': (kwargs['zorder'] - .1 if barsabove else + kwargs['zorder'] + .1), + } + + # make the style dict for the line collections (the bars) + eb_lines_style = dict(base_style) + eb_lines_style.pop('marker', None) + eb_lines_style.pop('markerfacecolor', None) + eb_lines_style.pop('markeredgewidth', None) + eb_lines_style.pop('markeredgecolor', None) + eb_lines_style.pop('linestyle', None) + eb_lines_style['color'] = ecolor + + if elinewidth: + eb_lines_style['linewidth'] = elinewidth + elif 'linewidth' in kwargs: + eb_lines_style['linewidth'] = kwargs['linewidth'] + + for key in ('transform', 'alpha', 'zorder', 'rasterized'): + if key in kwargs: + eb_lines_style[key] = kwargs[key] + + # make the style dict for cap collections (the "hats") + eb_cap_style = dict(base_style) + # eject any marker information from format string + eb_cap_style.pop('marker', None) + eb_cap_style.pop('ls', None) + eb_cap_style['linestyle'] = 'none' + if capsize is None: + capsize = kwargs.pop('capsize', rcParams["errorbar.capsize"]) + if capsize > 0: + eb_cap_style['markersize'] = 2. * capsize + if capthick is not None: + eb_cap_style['markeredgewidth'] = capthick + eb_cap_style['color'] = ecolor + + if plot_line: + data_line = art3d.Line3D(x, y, z, **plot_line_style) + self.add_line(data_line) + + try: + offset, errorevery = errorevery + except TypeError: + offset = 0 + + if errorevery < 1 or int(errorevery) != errorevery: + raise ValueError( + 'errorevery must be positive integer or tuple of integers') + if int(offset) != offset: + raise ValueError("errorevery's starting index must be an integer") + + everymask = np.zeros(len(x), bool) + everymask[offset::errorevery] = True + + def _apply_mask(arrays, mask): + # Return, for each array in *arrays*, the elements for which *mask* + # is True, without using fancy indexing. + return [[*compress(array, mask)] for array in arrays] + + def _extract_errs(err, data, lomask, himask): + # For separate +/- error values we need to unpack err + if len(err.shape) == 2: + low_err, high_err = err + else: + low_err, high_err = err, err + + # for compatibility with the 2d errorbar function, when both upper + # and lower limits specified, we need to draw the markers / line + common_mask = (lomask == himask) & everymask + _lomask = lomask | common_mask + _himask = himask | common_mask + + lows = np.where(_lomask, data - low_err, data) + highs = np.where(_himask, data + high_err, data) + + return lows, highs + + # collect drawn items while looping over the three coordinates + errlines, caplines, limmarks = [], [], [] + + # list of endpoint coordinates, used for auto-scaling + coorderrs = [] + + # define the markers used for errorbar caps and limits below + # the dictionary key is mapped by the `i_xyz` helper dictionary + capmarker = {0: '|', 1: '|', 2: '_'} + i_xyz = {'x': 0, 'y': 1, 'z': 2} + + # loop over x-, y-, and z-direction and draw relevant elements + for zdir, data, err, lolims, uplims in zip( + ['x', 'y', 'z'], [x, y, z], [xerr, yerr, zerr], + [xlolims, ylolims, zlolims], [xuplims, yuplims, zuplims]): + + dir_vector = art3d.get_dir_vector(zdir) + i_zdir = i_xyz[zdir] + + if err is None: + continue + + if not np.iterable(err): + err = [err] * len(data) + + err = np.atleast_1d(err) + + # arrays fine here, they are booleans and hence not units + lolims = np.broadcast_to(lolims, len(data)).astype(bool) + uplims = np.broadcast_to(uplims, len(data)).astype(bool) + + nolims = ~(lolims | uplims) + + # a nested list structure that expands to (xl,xh),(yl,yh),(zl,zh), + # where x/y/z and l/h correspond to dimensions and low/high + # positions of errorbars in a dimension we're looping over + coorderr = [ + _extract_errs(err * dir_vector[i], coord, + ~lolims & everymask, ~uplims & everymask) + for i, coord in enumerate([x, y, z])] + (xl, xh), (yl, yh), (zl, zh) = coorderr + + # draws capmarkers - flat caps orthogonal to the error bars + if nolims.any() and capsize > 0: + lo_caps_xyz = _apply_mask([xl, yl, zl], nolims & everymask) + hi_caps_xyz = _apply_mask([xh, yh, zh], nolims & everymask) + + # setting '_' for z-caps and '|' for x- and y-caps; + # these markers will rotate as the viewing angle changes + cap_lo = art3d.Line3D(*lo_caps_xyz, ls='', + marker=capmarker[i_zdir], + **eb_cap_style) + cap_hi = art3d.Line3D(*hi_caps_xyz, ls='', + marker=capmarker[i_zdir], + **eb_cap_style) + self.add_line(cap_lo) + self.add_line(cap_hi) + caplines.append(cap_lo) + caplines.append(cap_hi) + + if (lolims | uplims).any(): + limits = [ + _extract_errs(err*dir_vector[i], coord, uplims, lolims) + for i, coord in enumerate([x, y, z])] + + (xlo, xup), (ylo, yup), (zlo, zup) = limits + lomask = lolims & everymask + upmask = uplims & everymask + lolims_xyz = np.array(_apply_mask([xlo, ylo, zlo], upmask)) + uplims_xyz = np.array(_apply_mask([xup, yup, zup], lomask)) + lo_xyz = np.array(_apply_mask([x, y, z], upmask)) + up_xyz = np.array(_apply_mask([x, y, z], lomask)) + x0, y0, z0 = np.concatenate([lo_xyz, up_xyz], axis=-1) + dx, dy, dz = np.concatenate([lolims_xyz - lo_xyz, + uplims_xyz - up_xyz], axis=-1) + self.quiver(x0, y0, z0, dx, dy, dz, + arrow_length_ratio=arrow_length_ratio, + **eb_lines_style) + + errline = art3d.Line3DCollection(np.array(coorderr).T, + **eb_lines_style) + self.add_collection(errline) + errlines.append(errline) + coorderrs.append(coorderr) + + coorderrs = np.array(coorderrs) + + def _digout_minmax(err_arr, coord_label): + return (np.nanmin(err_arr[:, i_xyz[coord_label], :, :]), + np.nanmax(err_arr[:, i_xyz[coord_label], :, :])) + + minx, maxx = _digout_minmax(coorderrs, 'x') + miny, maxy = _digout_minmax(coorderrs, 'y') + minz, maxz = _digout_minmax(coorderrs, 'z') + self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data) + + # Adapting errorbar containers for 3d case, assuming z-axis points "up" + errorbar_container = mcontainer.ErrorbarContainer( + (data_line, tuple(caplines), tuple(errlines)), + has_xerr=(xerr is not None or yerr is not None), + has_yerr=(zerr is not None), + label=label) + self.containers.append(errorbar_container) + + return errlines, caplines, limmarks + def get_tightbbox(self, renderer, call_axes_locator=True, bbox_extra_artists=None, *, for_layout_only=False): ret = super().get_tightbbox(renderer, diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d.png b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d.png new file mode 100644 index 000000000000..47c22afa12e8 Binary files /dev/null and b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d_errorevery.png b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d_errorevery.png new file mode 100644 index 000000000000..7d6bd85e43ef Binary files /dev/null and b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d_errorevery.png differ diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 37532335e38d..a3643db6fc10 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -975,6 +975,40 @@ def test_minor_ticks(): ax.set_zticklabels(["half"], minor=True) +@mpl3d_image_comparison(['errorbar3d_errorevery.png']) +def test_errorbar3d_errorevery(): + """Tests errorevery functionality for 3d errorbars.""" + t = np.arange(0, 2*np.pi+.1, 0.01) + x, y, z = np.sin(t), np.cos(3*t), np.sin(5*t) + + fig = plt.figure() + ax = fig.gca(projection='3d') + + estep = 15 + i = np.arange(t.size) + zuplims = (i % estep == 0) & (i // estep % 3 == 0) + zlolims = (i % estep == 0) & (i // estep % 3 == 2) + + ax.errorbar(x, y, z, 0.2, zuplims=zuplims, zlolims=zlolims, + errorevery=estep) + + +@mpl3d_image_comparison(['errorbar3d.png']) +def test_errorbar3d(): + """Tests limits, color styling, and legend for 3d errorbars.""" + fig = plt.figure() + ax = fig.gca(projection='3d') + + d = [1, 2, 3, 4, 5] + e = [.5, .5, .5, .5, .5] + ax.errorbar(x=d, y=d, z=d, xerr=e, yerr=e, zerr=e, capsize=3, + zuplims=[False, True, False, True, True], + zlolims=[True, False, False, True, False], + yuplims=True, + ecolor='purple', label='Error lines') + ax.legend() + + @image_comparison(["equal_box_aspect.png"], style="mpl20") def test_equal_box_aspect(): from itertools import product, combinations