diff --git a/.flake8 b/.flake8 index c7e9114821ff..007f636b27df 100644 --- a/.flake8 +++ b/.flake8 @@ -228,6 +228,7 @@ per-file-ignores = examples/subplots_axes_and_figures/axes_zoom_effect.py: E402 examples/subplots_axes_and_figures/demo_constrained_layout.py: E402 examples/subplots_axes_and_figures/demo_tight_layout.py: E402 + examples/subplots_axes_and_figures/secondary_axis.py: E402 examples/subplots_axes_and_figures/two_scales.py: E402 examples/subplots_axes_and_figures/zoom_inset_axes.py: E402 examples/tests/backend_driver_sgskip.py: E402, E501 diff --git a/doc/api/axes_api.rst b/doc/api/axes_api.rst index 3e2f7cdd8c34..a3660fac3ba7 100644 --- a/doc/api/axes_api.rst +++ b/doc/api/axes_api.rst @@ -188,6 +188,8 @@ Text and Annotations Axes.inset_axes Axes.indicate_inset Axes.indicate_inset_zoom + Axes.secondary_xaxis + Axes.secondary_yaxis Fields diff --git a/doc/users/next_whats_new/2018-09-08-JMK.rst b/doc/users/next_whats_new/2018-09-08-JMK.rst new file mode 100644 index 000000000000..1c3bc954ae7f --- /dev/null +++ b/doc/users/next_whats_new/2018-09-08-JMK.rst @@ -0,0 +1,16 @@ +:orphan: + +Secondary x/y Axis support +-------------------------- + +A new method provides the ability to add a second axis to an existing +axes via `.Axes.secondary_xaxis` and `.Axes.secondary_yaxis`. See +:doc:`/gallery/subplots_axes_and_figures/secondary_axis` for examples. + +.. plot:: + + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=(5, 3)) + ax.plot(range(360)) + ax.secondary_xaxis('top', functions=(np.deg2rad, np.rad2deg)) diff --git a/examples/subplots_axes_and_figures/secondary_axis.py b/examples/subplots_axes_and_figures/secondary_axis.py new file mode 100644 index 000000000000..caa27a9c895f --- /dev/null +++ b/examples/subplots_axes_and_figures/secondary_axis.py @@ -0,0 +1,164 @@ +""" +============== +Secondary Axis +============== + +Sometimes we want as secondary axis on a plot, for instance to convert +radians to degrees on the same plot. We can do this by making a child +axes with only one axis visible via `.Axes.axes.secondary_xaxis` and +`.Axes.axes.secondary_yaxis`. This secondary axis can have a different scale +than the main axis by providing both a forward and an inverse conversion +function in a tuple to the ``functions`` kwarg: +""" + +import matplotlib.pyplot as plt +import numpy as np +import datetime +import matplotlib.dates as mdates +from matplotlib.transforms import Transform +from matplotlib.ticker import ( + AutoLocator, AutoMinorLocator) + +fig, ax = plt.subplots(constrained_layout=True) +x = np.arange(0, 360, 1) +y = np.sin(2 * x * np.pi / 180) +ax.plot(x, y) +ax.set_xlabel('angle [degrees]') +ax.set_ylabel('signal') +ax.set_title('Sine wave') + + +def deg2rad(x): + return x * np.pi / 180 + + +def rad2deg(x): + return x * 180 / np.pi + +secax = ax.secondary_xaxis('top', functions=(deg2rad, rad2deg)) +secax.set_xlabel('angle [rad]') +plt.show() + +########################################################################### +# Here is the case of converting from wavenumber to wavelength in a +# log-log scale. +# +# .. note :: +# +# In this case, the xscale of the parent is logarithmic, so the child is +# made logarithmic as well. + +fig, ax = plt.subplots(constrained_layout=True) +x = np.arange(0.02, 1, 0.02) +np.random.seed(19680801) +y = np.random.randn(len(x)) ** 2 +ax.loglog(x, y) +ax.set_xlabel('f [Hz]') +ax.set_ylabel('PSD') +ax.set_title('Random spectrum') + + +def forward(x): + return 1 / x + + +def inverse(x): + return 1 / x + +secax = ax.secondary_xaxis('top', functions=(forward, inverse)) +secax.set_xlabel('period [s]') +plt.show() + +########################################################################### +# Sometime we want to relate the axes in a transform that is ad-hoc from +# the data, and is derived empirically. In that case we can set the +# forward and inverse transforms functions to be linear interpolations from the +# one data set to the other. + +fig, ax = plt.subplots(constrained_layout=True) +xdata = np.arange(1, 11, 0.4) +ydata = np.random.randn(len(xdata)) +ax.plot(xdata, ydata, label='Plotted data') + +xold = np.arange(0, 11, 0.2) +# fake data set relating x co-ordinate to another data-derived co-ordinate. +# xnew must be monotonic, so we sort... +xnew = np.sort(10 * np.exp(-xold / 4) + np.random.randn(len(xold)) / 3) + +ax.plot(xold[3:], xnew[3:], label='Transform data') +ax.set_xlabel('X [m]') +ax.legend() + + +def forward(x): + return np.interp(x, xold, xnew) + + +def inverse(x): + return np.interp(x, xnew, xold) + +secax = ax.secondary_xaxis('top', functions=(forward, inverse)) +secax.xaxis.set_minor_locator(AutoMinorLocator()) +secax.set_xlabel('$X_{other}$') + +plt.show() + +########################################################################### +# A final example translates np.datetime64 to yearday on the x axis and +# from Celsius to Farenheit on the y axis: + + +dates = [datetime.datetime(2018, 1, 1) + datetime.timedelta(hours=k * 6) + for k in range(240)] +temperature = np.random.randn(len(dates)) +fig, ax = plt.subplots(constrained_layout=True) + +ax.plot(dates, temperature) +ax.set_ylabel(r'$T\ [^oC]$') +plt.xticks(rotation=70) + + +def date2yday(x): + """ + x is in matplotlib datenums, so they are floats. + """ + y = x - mdates.date2num(datetime.datetime(2018, 1, 1)) + return y + + +def yday2date(x): + """ + return a matplotlib datenum (x is days since start of year) + """ + y = x + mdates.date2num(datetime.datetime(2018, 1, 1)) + return y + +secaxx = ax.secondary_xaxis('top', functions=(date2yday, yday2date)) +secaxx.set_xlabel('yday [2018]') + + +def CtoF(x): + return x * 1.8 + 32 + + +def FtoC(x): + return (x - 32) / 1.8 + +secaxy = ax.secondary_yaxis('right', functions=(CtoF, FtoC)) +secaxy.set_ylabel(r'$T\ [^oF]$') + +plt.show() + +############################################################################# +# +# ------------ +# +# References +# """""""""" +# +# The use of the following functions and methods is shown in this example: + +import matplotlib + +matplotlib.axes.Axes.secondary_xaxis +matplotlib.axes.Axes.secondary_yaxis diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index a72b79ca7ad1..39795b544a3c 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -180,7 +180,8 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad, sup = fig._suptitle bbox = invTransFig(sup.get_window_extent(renderer=renderer)) height = bbox.y1 - bbox.y0 - sup._layoutbox.edit_height(height+h_pad) + if np.isfinite(height): + sup._layoutbox.edit_height(height+h_pad) # OK, the above lines up ax._poslayoutbox with ax._layoutbox # now we need to @@ -266,10 +267,14 @@ def _make_layout_margins(ax, renderer, h_pad, w_pad): """ fig = ax.figure invTransFig = fig.transFigure.inverted().transform_bbox - pos = ax.get_position(original=True) tightbbox = ax.get_tightbbox(renderer=renderer) bbox = invTransFig(tightbbox) + # this can go wrong: + if not (np.isfinite(bbox.width) and np.isfinite(bbox.height)): + # just abort, this is likely a bad set of co-ordinates that + # is transitory... + return # use stored h_pad if it exists h_padt = ax._poslayoutbox.h_pad if h_padt is None: @@ -287,6 +292,8 @@ def _make_layout_margins(ax, renderer, h_pad, w_pad): _log.debug('left %f', (-bbox.x0 + pos.x0 + w_pad)) _log.debug('right %f', (bbox.x1 - pos.x1 + w_pad)) _log.debug('bottom %f', (-bbox.y0 + pos.y0 + h_padt)) + _log.debug('bbox.y0 %f', bbox.y0) + _log.debug('pos.y0 %f', pos.y0) # Sometimes its possible for the solver to collapse # rather than expand axes, so they all have zero height # or width. This stops that... It *should* have been diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 57f4cc6ca991..a6238e0c6ae0 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -34,6 +34,7 @@ import matplotlib.tri as mtri from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer from matplotlib.axes._base import _AxesBase, _process_plot_format +from matplotlib.axes._secondary_axes import SecondaryAxis _log = logging.getLogger(__name__) @@ -599,6 +600,79 @@ def indicate_inset_zoom(self, inset_ax, **kwargs): return rectpatch, connects + @docstring.dedent_interpd + def secondary_xaxis(self, location, *, functions=None, **kwargs): + """ + Add a second x-axis to this axes. + + For example if we want to have a second scale for the data plotted on + the xaxis. + + %(_secax_docstring)s + + Examples + -------- + + The main axis shows frequency, and the secondary axis shows period. + + .. plot:: + + fig, ax = plt.subplots() + ax.loglog(range(1, 360, 5), range(1, 360, 5)) + ax.set_xlabel('frequency [Hz]') + + + def invert(x): + return 1 / x + + secax = ax.secondary_xaxis('top', functions=(invert, invert)) + secax.set_xlabel('Period [s]') + plt.show() + + + """ + if (location in ['top', 'bottom'] or isinstance(location, Number)): + secondary_ax = SecondaryAxis(self, 'x', location, functions, + **kwargs) + self.add_child_axes(secondary_ax) + return secondary_ax + else: + raise ValueError('secondary_xaxis location must be either ' + 'a float or "top"/"bottom"') + + def secondary_yaxis(self, location, *, functions=None, **kwargs): + """ + Add a second y-axis to this axes. + + For example if we want to have a second scale for the data plotted on + the yaxis. + + %(_secax_docstring)s + + Examples + -------- + + Add a secondary axes that converts from radians to degrees + + .. plot:: + + fig, ax = plt.subplots() + ax.plot(range(1, 360, 5), range(1, 360, 5)) + ax.set_ylabel('degrees') + secax = ax.secondary_yaxis('right', functions=(np.deg2rad, + np.rad2deg)) + secax.set_ylabel('radians') + + """ + if location in ['left', 'right'] or isinstance(location, Number): + secondary_ax = SecondaryAxis(self, 'y', location, + functions, **kwargs) + self.add_child_axes(secondary_ax) + return secondary_ax + else: + raise ValueError('secondary_yaxis location must be either ' + 'a float or "left"/"right"') + def text(self, x, y, s, fontdict=None, withdash=False, **kwargs): """ Add text to the axes. diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 2e8359b4288b..868a459538e5 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2500,8 +2500,17 @@ def _update_title_position(self, renderer): title.set_position((x, 1.0)) # need to check all our twins too... axs = self._twinned_axes.get_siblings(self) - - top = 0 # the top of all the axes twinned with this axes... + # and all the children + for ax in self.child_axes: + if ax is not None: + locator = ax.get_axes_locator() + if locator: + pos = locator(self, renderer) + ax.apply_aspect(pos) + else: + ax.apply_aspect() + axs = axs + [ax] + top = 0 for ax in axs: try: if (ax.xaxis.get_label_position() == 'top' @@ -2544,6 +2553,8 @@ def draw(self, renderer=None, inframe=False): # prevent triggering call backs during the draw process self._stale = True + + # loop over self and child axes... locator = self.get_axes_locator() if locator: pos = locator(self, renderer) @@ -4315,6 +4326,9 @@ def get_tightbbox(self, renderer, call_axes_locator=True, if bb_yaxis: bb.append(bb_yaxis) + self._update_title_position(renderer) + bb.append(self.get_window_extent(renderer)) + self._update_title_position(renderer) if self.title.get_visible(): bb.append(self.title.get_window_extent(renderer)) diff --git a/lib/matplotlib/axes/_secondary_axes.py b/lib/matplotlib/axes/_secondary_axes.py new file mode 100644 index 000000000000..3cbe8e0f1ff1 --- /dev/null +++ b/lib/matplotlib/axes/_secondary_axes.py @@ -0,0 +1,463 @@ +import collections +import numpy as np +import numbers + +import warnings + +import matplotlib.docstring as docstring +import matplotlib.ticker as mticker +import matplotlib.transforms as mtransforms +import matplotlib.scale as mscale +import matplotlib.cbook as cbook + +from matplotlib.axes._base import _AxesBase + +from matplotlib.ticker import ( + AutoLocator, + AutoMinorLocator, + FixedLocator, + FuncFormatter, + LogFormatterSciNotation, + LogLocator, + NullLocator, + NullFormatter, + ScalarFormatter +) + +from matplotlib.scale import Log10Transform + + +def _make_secondary_locator(rect, parent): + """ + Helper function to locate the secondary axes. + + A locator gets used in `Axes.set_aspect` to override the default + locations... It is a function that takes an axes object and + a renderer and tells `set_aspect` where it is to be placed. + + This locator make the transform be in axes-relative co-coordinates + because that is how we specify the "location" of the secondary axes. + + Here *rect* is a rectangle [l, b, w, h] that specifies the + location for the axes in the transform given by *trans* on the + *parent*. + """ + _rect = mtransforms.Bbox.from_bounds(*rect) + bb = mtransforms.TransformedBbox(_rect, parent.transAxes) + tr = parent.figure.transFigure.inverted() + bb = mtransforms.TransformedBbox(bb, tr) + + def secondary_locator(ax, renderer): + return bb + + return secondary_locator + + +class SecondaryAxis(_AxesBase): + """ + General class to hold a Secondary_X/Yaxis. + """ + + def __init__(self, parent, orientation, + location, functions, **kwargs): + """ + See `.secondary_xaxis` and `.secondary_yaxis` for the doc string. + While there is no need for this to be private, it should really be + called by those higher level functions. + """ + + self._functions = functions + self._parent = parent + self._orientation = orientation + self._ticks_set = False + + if self._orientation == 'x': + super().__init__(self._parent.figure, [0, 1., 1, 0.0001], **kwargs) + self._axis = self.xaxis + self._locstrings = ['top', 'bottom'] + self._otherstrings = ['left', 'right'] + elif self._orientation == 'y': + super().__init__(self._parent.figure, [0, 1., 0.0001, 1], **kwargs) + self._axis = self.yaxis + self._locstrings = ['right', 'left'] + self._otherstrings = ['top', 'bottom'] + # this gets positioned w/o constrained_layout so exclude: + self._layoutbox = None + self._poslayoutbox = None + + self.set_location(location) + self.set_functions(functions) + + # styling: + if self._orientation == 'x': + otheraxis = self.yaxis + else: + otheraxis = self.xaxis + + otheraxis.set_major_locator(mticker.NullLocator()) + otheraxis.set_ticks_position('none') + + for st in self._otherstrings: + self.spines[st].set_visible(False) + for st in self._locstrings: + self.spines[st].set_visible(True) + + if self._pos < 0.5: + # flip the location strings... + self._locstrings = self._locstrings[::-1] + self.set_alignment(self._locstrings[0]) + + def set_alignment(self, align): + """ + Set if axes spine and labels are drawn at top or bottom (or left/right) + of the axes. + + Parameters + ---------- + align :: string + either 'top' or 'bottom' for orientation='x' or + 'left' or 'right' for orientation='y' axis + + """ + if align in self._locstrings: + if align == self._locstrings[1]: + # need to change the orientation. + self._locstrings = self._locstrings[::-1] + elif align != self._locstrings[0]: + raise ValueError('"{}" is not a valid axis orientation, ' + 'not changing the orientation;' + 'choose "{}" or "{}""'.format(align, + self._locstrings[0], self._locstrings[1])) + self.spines[self._locstrings[0]].set_visible(True) + self.spines[self._locstrings[1]].set_visible(False) + self._axis.set_ticks_position(align) + self._axis.set_label_position(align) + + def set_location(self, location): + """ + Set the vertical or horizontal location of the axes in + parent-normalized co-ordinates. + + Parameters + ---------- + location : string or scalar + The position to put the secondary axis. Strings can be 'top' or + 'bottom' for orientation='x' and 'right' or 'left' for + orientation='y', scalar can be a float indicating the relative + position on the parent axes to put the new axes, 0.0 being the + bottom (or left) and 1.0 being the top (or right). + """ + + # This puts the rectangle into figure-relative coordinates. + if isinstance(location, str): + if location in ['top', 'right']: + self._pos = 1. + elif location in ['bottom', 'left']: + self._pos = 0. + else: + raise ValueError("location must be '{}', '{}', or a " + "float, not '{}'".format(location, + self._locstrings[0], self._locstrings[1])) + else: + self._pos = location + self._loc = location + + if self._orientation == 'x': + bounds = [0, self._pos, 1., 1e-10] + else: + bounds = [self._pos, 0, 1e-10, 1] + + secondary_locator = _make_secondary_locator(bounds, self._parent) + + # this locator lets the axes move in the parent axes coordinates. + # so it never needs to know where the parent is explicitly in + # figure co-ordinates. + # it gets called in `ax.apply_aspect() (of all places) + self.set_axes_locator(secondary_locator) + + def apply_aspect(self, position=None): + self._set_lims() + super().apply_aspect(position) + + def set_ticks(self, ticks, minor=False): + """ + Set the x ticks with list of *ticks* + + Parameters + ---------- + ticks : list + List of x-axis tick locations. + + minor : bool, optional + If ``False`` sets major ticks, if ``True`` sets minor ticks. + Default is ``False``. + """ + ret = self._axis.set_ticks(ticks, minor=minor) + self.stale = True + self._ticks_set = True + return ret + + def set_functions(self, functions): + """ + Set how the secondary axis converts limits from the parent axes. + + Parameters + ---------- + functions : 2-tuple of func, or `Transform` with an inverse. + Transform between the parent axis values and the secondary axis + values. + + If supplied as a 2-tuple of functions, the first function is + the forward transform function and the second is the inverse + transform. + + If a transform is supplied, then the transform must have an + inverse. + + """ + + if self._orientation == 'x': + set_scale = self.set_xscale + parent_scale = self._parent.get_xscale() + else: + set_scale = self.set_yscale + parent_scale = self._parent.get_yscale() + # we need to use a modified scale so the scale can receive the + # transform. Only types supported are linear and log10 for now. + # Probably possible to add other transforms as a todo... + if parent_scale == 'log': + defscale = 'functionlog' + else: + defscale = 'function' + + if (isinstance(functions, tuple) and len(functions) == 2 and + callable(functions[0]) and callable(functions[1])): + # make an arbitrary convert from a two-tuple of functions + # forward and inverse. + self._functions = functions + elif functions is None: + self._functions = (lambda x: x, lambda x: x) + else: + raise ValueError('functions argument of secondary axes ' + 'must be a two-tuple of callable functions ' + 'with the first function being the transform ' + 'and the second being the inverse') + set_scale(defscale, functions=self._functions) + + def draw(self, renderer=None, inframe=False): + """ + Draw the secondary axes. + + Consults the parent axes for its limits and converts them + using the converter specified by + `~.axes._secondary_axes.set_functions` (or *functions* + parameter when axes initialized.) + + """ + + self._set_lims() + # this sets the scale in case the parent has set its scale. + self._set_scale() + super().draw(renderer=renderer, inframe=inframe) + + def _set_scale(self): + """ + Check if parent has set its scale + """ + + if self._orientation == 'x': + pscale = self._parent.xaxis.get_scale() + set_scale = self.set_xscale + if self._orientation == 'y': + pscale = self._parent.yaxis.get_scale() + set_scale = self.set_yscale + if pscale == 'log': + defscale = 'functionlog' + else: + defscale = 'function' + + if self._ticks_set: + ticks = self._axis.get_ticklocs() + + set_scale(defscale, functions=self._functions) + + # OK, set_scale sets the locators, but if we've called + # axsecond.set_ticks, we want to keep those. + if self._ticks_set: + self._axis.set_major_locator(FixedLocator(ticks)) + + def _set_lims(self): + """ + Set the limits based on parent limits and the convert method + between the parent and this secondary axes + """ + if self._orientation == 'x': + lims = self._parent.get_xlim() + set_lim = self.set_xlim + trans = self.xaxis.get_transform() + if self._orientation == 'y': + lims = self._parent.get_ylim() + set_lim = self.set_ylim + trans = self.yaxis.get_transform() + order = lims[0] < lims[1] + lims = self._functions[0](np.array(lims)) + neworder = lims[0] < lims[1] + if neworder != order: + # flip because the transform will take care of the flipping.. + lims = lims[::-1] + set_lim(lims) + + def get_tightbbox(self, renderer, call_axes_locator=True): + """ + Return the tight bounding box of the axes. + The dimension of the Bbox in canvas coordinate. + + If *call_axes_locator* is *False*, it does not call the + _axes_locator attribute, which is necessary to get the correct + bounding box. ``call_axes_locator==False`` can be used if the + caller is only intereted in the relative size of the tightbbox + compared to the axes bbox. + """ + + bb = [] + + if not self.get_visible(): + return None + + self._set_lims() + locator = self.get_axes_locator() + if locator and call_axes_locator: + pos = locator(self, renderer) + self.apply_aspect(pos) + else: + self.apply_aspect() + + if self._orientation == 'x': + bb_axis = self.xaxis.get_tightbbox(renderer) + else: + bb_axis = self.yaxis.get_tightbbox(renderer) + if bb_axis: + bb.append(bb_axis) + + bb.append(self.get_window_extent(renderer)) + _bbox = mtransforms.Bbox.union( + [b for b in bb if b.width != 0 or b.height != 0]) + + return _bbox + + def set_aspect(self, *args, **kwargs): + """ + Secondary axes cannot set the aspect ratio, so calling this just + sets a warning. + """ + cbook._warn_external("Secondary axes can't set the aspect ratio") + + def set_xlabel(self, xlabel, fontdict=None, labelpad=None, **kwargs): + """ + Set the label for the x-axis. + + Parameters + ---------- + xlabel : str + The label text. + + labelpad : scalar, optional, default: None + Spacing in points between the label and the x-axis. + + Other Parameters + ---------------- + **kwargs : `.Text` properties + `.Text` properties control the appearance of the label. + + See also + -------- + text : for information on how override and the optional args work + """ + if labelpad is not None: + self.xaxis.labelpad = labelpad + return self.xaxis.set_label_text(xlabel, fontdict, **kwargs) + + def set_ylabel(self, ylabel, fontdict=None, labelpad=None, **kwargs): + """ + Set the label for the x-axis. + + Parameters + ---------- + ylabel : str + The label text. + + labelpad : scalar, optional, default: None + Spacing in points between the label and the x-axis. + + Other Parameters + ---------------- + **kwargs : `.Text` properties + `.Text` properties control the appearance of the label. + + See also + -------- + text : for information on how override and the optional args work + """ + if labelpad is not None: + self.yaxis.labelpad = labelpad + return self.yaxis.set_label_text(ylabel, fontdict, **kwargs) + + def set_color(self, color): + """ + Change the color of the secondary axes and all decorators + Parameters + ---------- + color : Matplotlib color + """ + + if self._orientation == 'x': + self.tick_params(axis='x', colors=color) + self.spines['bottom'].set_color(color) + self.spines['top'].set_color(color) + self.xaxis.label.set_color(color) + else: + self.tick_params(axis='y', colors=color) + self.spines['left'].set_color(color) + self.spines['right'].set_color(color) + self.yaxis.label.set_color(color) + + +_secax_docstring = ''' +Warnings +-------- + +This method is experimental as of 3.1, and the API may change. + +Parameters +---------- +location : string or scalar + The position to put the secondary axis. Strings can be 'top' or + 'bottom', for x-oriented axises or 'left' or 'right' for y-oriented axises + or a scalar can be a float indicating the relative position + on the axes to put the new axes (0 being the bottom (left), and 1.0 being + the top (right).) + +functions : 2-tuple of func, or Transform with an inverse + + If a 2-tuple of functions, the user specifies the transform + function and its inverse. i.e. + `functions=(lambda x: 2 / x, lambda x: 2 / x)` would be an + reciprocal transform with a factor of 2. + + The user can also directly supply a subclass of + `.transforms.Transform` so long as it has an inverse. + + See :doc:`/gallery/subplots_axes_and_figures/secondary_axis` + for examples of making these conversions. + + +Other Parameters +---------------- +**kwargs : `~matplotlib.axes.Axes` properties. + Other miscellaneous axes parameters. + +Returns +------- +ax : axes._secondary_axes.SecondaryAxis +''' +docstring.interpd.update(_secax_docstring=_secax_docstring) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 127c9c5b0fb3..e1e1ff61cd02 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1159,11 +1159,11 @@ def get_tightbbox(self, renderer): if (np.isfinite(bbox.width) and np.isfinite(bbox.height) and a.get_visible()): bb.append(bbox) - bb.extend(ticklabelBoxes) bb.extend(ticklabelBoxes2) - - bb = [b for b in bb if b.width != 0 or b.height != 0] + bb = [b for b in bb if ((b.width != 0 or b.height != 0) and + np.isfinite(b.width) and + np.isfinite(b.height))] if bb: _bbox = mtransforms.Bbox.union(bb) return _bbox diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index dbfcae354baa..915791db71c0 100644 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -309,7 +309,6 @@ def _from_ordinalf(x, tz=None): # add hours, minutes, seconds, microseconds dt += datetime.timedelta(microseconds=remainder_musec) - return dt.astimezone(tz) diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index ddd1bcbcd3d2..8ce9b68a7165 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -163,8 +163,7 @@ def __init__(self, axis, functions): functions : (callable, callable) two-tuple of the forward and inverse functions for the scale. - The forward function must have an inverse and, for best behavior, - be monotonic. + The forward function must be monotonic. Both functions must have the signature:: @@ -409,6 +408,46 @@ def limit_range_for_scale(self, vmin, vmax, minpos): minpos if vmax <= 0 else vmax) +class FuncScaleLog(LogScale): + """ + Provide an arbitrary scale with user-supplied function for the axis and + then put on a logarithmic axes. + """ + + name = 'functionlog' + + def __init__(self, axis, functions, base=10): + """ + Parameters + ---------- + + axis: the axis for the scale + + functions : (callable, callable) + two-tuple of the forward and inverse functions for the scale. + The forward function must be monotonic. + + Both functions must have the signature:: + + def forward(values: array-like) -> array-like + + base : float + logarithmic base of the scale (default = 10) + + """ + forward, inverse = functions + self.base = base + self.subs = None + transform = FuncTransform(forward, inverse) + LogTransform(base) + self._transform = transform + + def get_transform(self): + """ + The transform for arbitrary scaling + """ + return self._transform + + class SymmetricalLogTransform(Transform): input_dims = 1 output_dims = 1 @@ -660,6 +699,7 @@ def limit_range_for_scale(self, vmin, vmax, minpos): 'symlog': SymmetricalLogScale, 'logit': LogitScale, 'function': FuncScale, + 'functionlog': FuncScaleLog, } diff --git a/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png b/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png new file mode 100644 index 000000000000..2e91f63326ee Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 40c23f6c5967..1a848692907e 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -6073,3 +6073,36 @@ def test_deprecated_uppercase_colors(): with pytest.warns(MatplotlibDeprecationWarning): ax.plot([1, 2], color="B") fig.canvas.draw() + + +@image_comparison(baseline_images=['secondary_xy'], style='mpl20', + extensions=['png']) +def test_secondary_xy(): + fig, axs = plt.subplots(1, 2, figsize=(10, 5), constrained_layout=True) + + def invert(x): + with np.errstate(divide='ignore'): + return 1 / x + + for nn, ax in enumerate(axs): + ax.plot(np.arange(2, 11), np.arange(2, 11)) + if nn == 0: + secax = ax.secondary_xaxis + else: + secax = ax.secondary_yaxis + + axsec = secax(0.2, functions=(invert, invert)) + axsec = secax(0.4, functions=(lambda x: 2 * x, lambda x: x / 2)) + axsec = secax(0.6, functions=(lambda x: x**2, lambda x: x**(1/2))) + axsec = secax(0.8) + + +def test_secondary_fail(): + fig, ax = plt.subplots() + ax.plot(np.arange(2, 11), np.arange(2, 11)) + with pytest.raises(ValueError): + axsec = ax.secondary_xaxis(0.2, functions=(lambda x: 1 / x)) + with pytest.raises(ValueError): + axsec = ax.secondary_xaxis('right') + with pytest.raises(ValueError): + axsec = ax.secondary_yaxis('bottom') diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 922972cd423c..fe644d9cb967 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -801,8 +801,6 @@ def get_zscale(self): """ Return the zaxis scale string %s - .. versionadded :: 1.1.0 - This function was added, but not tested. Please report any bugs. """ % (", ".join(mscale.get_scale_names())) return self.zaxis.get_scale() @@ -829,24 +827,34 @@ def set_yscale(self, value, **kwargs): This function was added, but not tested. Please report any bugs. """ - @docstring.dedent_interpd def set_zscale(self, value, **kwargs): """ - Set the scaling of the z-axis: %(scale)s + Set the z-axis scale. - ACCEPTS: [%(scale)s] - - Different kwargs are accepted, depending on the scale: + Parameters + ---------- + value : {"linear", "log", "symlog", "logit", ...} + The axis scale type to apply. - %(scale_docs)s + **kwargs + Different keyword arguments are accepted, depending on the scale. + See the respective class keyword arguments: - .. note :: - Currently, Axes3D objects only supports linear scales. - Other scales may or may not work, and support for these - is improving with each release. + - `matplotlib.scale.LinearScale` + - `matplotlib.scale.LogScale` + - `matplotlib.scale.SymmetricalLogScale` + - `matplotlib.scale.LogitScale` - .. versionadded :: 1.1.0 - This function was added, but not tested. Please report any bugs. + Notes + ----- + Currently, Axes3D objects only supports linear scales. + Other scales may or may not work, and support for these + is improving with each release. + + By default, Matplotlib supports the above mentioned scales. + Additionally, custom scales may be registered using + `matplotlib.scale.register_scale`. These scales may then also + be used here as support is added. """ self.zaxis._set_scale(value, **kwargs) self.autoscale_view(scalex=False, scaley=False)