Skip to content

Commit a776200

Browse files
committed
ENH: add secondary x/y axis
1 parent f68063e commit a776200

File tree

11 files changed

+995
-17
lines changed

11 files changed

+995
-17
lines changed

doc/api/axes_api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ Text and Annotations
188188
Axes.inset_axes
189189
Axes.indicate_inset
190190
Axes.indicate_inset_zoom
191+
Axes.secondary_xaxis
192+
Axes.secondary_yaxis
191193

192194

193195
Fields
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""
2+
==============
3+
Secondary Axis
4+
==============
5+
6+
Sometimes we want as secondary axis on a plot, for instance to convert
7+
radians to degrees on the same plot. We can do this by making a child
8+
axes with only one axis visible via `.Axes.axes.secondary_xaxis` and
9+
`.Axes.axes.secondary_yaxis`.
10+
11+
"""
12+
13+
import matplotlib.pyplot as plt
14+
import numpy as np
15+
from matplotlib.transforms import Transform
16+
from matplotlib.ticker import (
17+
AutoLocator, AutoMinorLocator)
18+
19+
fig, ax = plt.subplots(constrained_layout=True)
20+
x = np.arange(0, 360, 1)
21+
y = np.sin(2 * x * np.pi / 180)
22+
ax.plot(x, y)
23+
ax.set_xlabel('angle [degrees]')
24+
ax.set_ylabel('signal')
25+
ax.set_title('Sine wave')
26+
27+
secax = ax.secondary_xaxis('top', conversion=[np.pi / 180])
28+
secax.set_xlabel('angle [rad]')
29+
plt.show()
30+
31+
###########################################################################
32+
# The conversion can be a linear slope and an offset as a 2-tuple. It can
33+
# also be more complicated. The strings "inverted", "power", and "linear"
34+
# are accepted as valid arguments for the ``conversion`` kwarg, and scaling
35+
# is set by the ``otherargs`` kwarg.
36+
#
37+
# .. note ::
38+
#
39+
# In this case, the xscale of the parent is logarithmic, so the child is
40+
# made logarithmic as well.
41+
42+
fig, ax = plt.subplots(constrained_layout=True)
43+
x = np.arange(0.02, 1, 0.02)
44+
np.random.seed(19680801)
45+
y = np.random.randn(len(x)) ** 2
46+
ax.loglog(x, y)
47+
ax.set_xlabel('f [Hz]')
48+
ax.set_ylabel('PSD')
49+
ax.set_title('Random spectrum')
50+
51+
secax = ax.secondary_xaxis('top', conversion='inverted', otherargs=1)
52+
secax.set_xlabel('period [s]')
53+
secax.set_xscale('log')
54+
plt.show()
55+
56+
###########################################################################
57+
# Considerably more complicated, the user can define their own transform
58+
# to pass to ``conversion``. Here the conversion is arbitrary linearly
59+
# interpolated between two (sorted) arrays.
60+
61+
fig, ax = plt.subplots(constrained_layout=True)
62+
ax.plot(np.arange(1, 11), np.arange(1, 11))
63+
64+
65+
class LocalArbitraryInterp(Transform):
66+
"""
67+
Return interpolated from data. Note that both arrays
68+
have to be ascending for this to work in this example. (Could
69+
have more error checking to do more generally)
70+
"""
71+
72+
input_dims = 1
73+
output_dims = 1
74+
is_separable = True
75+
has_inverse = True
76+
77+
def __init__(self, xold, xnew):
78+
Transform.__init__(self)
79+
self._xold = xold
80+
self._xnew = xnew
81+
82+
def transform_non_affine(self, values):
83+
q = np.interp(values, self._xold, self._xnew, )
84+
return q
85+
86+
def inverted(self):
87+
""" we are just our own inverse """
88+
return LocalArbitraryInterp(self._xnew, self._xold)
89+
90+
# this is anarbitrary mapping defined by data. Only issue is that it
91+
# should be one-to-one and the vectors need to be ascending for the inverse
92+
# mapping to work.
93+
xold = np.arange(0, 11, 0.2)
94+
xnew = np.sort(10 * np.exp(-xold / 4))
95+
96+
ax.plot(xold[3:], xnew[3:])
97+
ax.set_xlabel('X [m]')
98+
99+
secax = ax.secondary_xaxis('top', conversion=LocalArbitraryInterp(xold, xnew))
100+
secax.xaxis.set_minor_locator(AutoMinorLocator())
101+
secax.set_xlabel('Exponential axes')
102+
103+
plt.show()
104+
105+
#############################################################################
106+
#
107+
# ------------
108+
#
109+
# References
110+
# """"""""""
111+
#
112+
# The use of the following functions and methods is shown in this example:
113+
114+
import matplotlib
115+
116+
matplotlib.axes.Axes.secondary_xaxis
117+
matplotlib.axes.Axes.secondary_yaxis

lib/matplotlib/_constrained_layout.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad,
179179
sup = fig._suptitle
180180
bbox = invTransFig(sup.get_window_extent(renderer=renderer))
181181
height = bbox.y1 - bbox.y0
182-
sup._layoutbox.edit_height(height+h_pad)
182+
if np.isfinite(height):
183+
sup._layoutbox.edit_height(height+h_pad)
183184

184185
# OK, the above lines up ax._poslayoutbox with ax._layoutbox
185186
# now we need to
@@ -265,10 +266,14 @@ def _make_layout_margins(ax, renderer, h_pad, w_pad):
265266
"""
266267
fig = ax.figure
267268
invTransFig = fig.transFigure.inverted().transform_bbox
268-
269269
pos = ax.get_position(original=True)
270270
tightbbox = ax.get_tightbbox(renderer=renderer)
271271
bbox = invTransFig(tightbbox)
272+
# this can go wrong:
273+
if not np.isfinite(bbox.y0 + bbox.x0 + bbox.y1 + bbox.x1):
274+
# just abort, this is likely a bad set of co-ordinates that
275+
# is transitory...
276+
return
272277
# use stored h_pad if it exists
273278
h_padt = ax._poslayoutbox.h_pad
274279
if h_padt is None:
@@ -286,6 +291,8 @@ def _make_layout_margins(ax, renderer, h_pad, w_pad):
286291
_log.debug('left %f', (-bbox.x0 + pos.x0 + w_pad))
287292
_log.debug('right %f', (bbox.x1 - pos.x1 + w_pad))
288293
_log.debug('bottom %f', (-bbox.y0 + pos.y0 + h_padt))
294+
_log.debug('bbox.y0 %f', bbox.y0)
295+
_log.debug('pos.y0 %f', pos.y0)
289296
# Sometimes its possible for the solver to collapse
290297
# rather than expand axes, so they all have zero height
291298
# or width. This stops that... It *should* have been

lib/matplotlib/axes/_axes.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import matplotlib.tri as mtri
3636
from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer
3737
from matplotlib.axes._base import _AxesBase, _process_plot_format
38+
from matplotlib.axes._secondary_axes import Secondary_Axis
3839

3940
_log = logging.getLogger(__name__)
4041

@@ -636,6 +637,159 @@ def indicate_inset_zoom(self, inset_ax, **kwargs):
636637

637638
return rectpatch, connects
638639

640+
def secondary_xaxis(self, location, *, conversion=None,
641+
otherargs=None, **kwargs):
642+
"""
643+
Add a second x-axis to this axes.
644+
645+
For example if we want to have a second scale for the data plotted on
646+
the xaxis.
647+
648+
Warnings
649+
--------
650+
651+
This method is experimental as of 3.1, and the API may change.
652+
653+
Parameters
654+
----------
655+
location : string or scalar
656+
The position to put the secondary axis. Strings can be 'top' or
657+
'bottom', scalar can be a float indicating the relative position
658+
on the axes to put the new axes (0 being the bottom, and 1.0 being
659+
the top.)
660+
661+
conversion : scalar, two-tuple of scalars, string, or Transform
662+
If a scalar or a two-tuple of scalar, the secondary axis is
663+
converted via a linear conversion with slope given by the first
664+
and offset given by the second. i.e. ``conversion = [2, 1]``
665+
element for a parent axis between 0 and 1 gives a secondary axis
666+
between 1 and 3.
667+
668+
If a string, if can be one of "linear", "power", and "inverted".
669+
If "linear", the value of ``otherargs`` should be a float or
670+
two-tuple as above. If "inverted" the values in the secondary axis
671+
are inverted and multiplied by the value supplied by ``oterargs``.
672+
If "power", then the original values are transformed by
673+
``newx = otherargs[1] * oldx ** otherargs[0]``.
674+
675+
Finally, the user can supply a subclass of `.transforms.Transform`
676+
to arbitrarily transform between the parent axes and the
677+
secondary axes.
678+
See :doc:`/gallery/subplots_axes_and_figures/secondary_axis`
679+
for an example of making such a transform.
680+
681+
682+
Other Parameters
683+
----------------
684+
**kwargs : `~matplotlib.axes.Axes` properties.
685+
Other miscellaneous axes parameters.
686+
687+
Returns
688+
-------
689+
ax : axes._secondary_axes.Secondary_Axis
690+
691+
Examples
692+
--------
693+
694+
Add a secondary axes that shows both wavelength for the main
695+
axes that shows wavenumber.
696+
697+
.. plot::
698+
699+
fig, ax = plt.subplots()
700+
ax.loglog(range(1, 360, 5), range(1, 360, 5))
701+
ax.set_xlabel('wavenumber [cpkm]')
702+
secax = ax.secondary_xaxis('top', conversion='inverted',
703+
otherargs=1.)
704+
secax.set_xlabel('wavelength [km]')
705+
706+
707+
"""
708+
if (location in ['top', 'bottom'] or isinstance(location, Number)):
709+
secondary_ax = Secondary_Axis(self, 'x', location,
710+
conversion, otherargs=otherargs,
711+
**kwargs)
712+
self.add_child_axes(secondary_ax)
713+
return secondary_ax
714+
else:
715+
raise ValueError('secondary_xaxis location must be either '
716+
'"top" or "bottom"')
717+
718+
def secondary_yaxis(self, location, *, conversion=None,
719+
otherargs=None, **kwargs):
720+
"""
721+
Add a second y-axis to this axes.
722+
723+
For example if we want to have a second scale for the data plotted on
724+
the xaxis.
725+
726+
Warnings
727+
--------
728+
729+
This method is experimental as of 3.1, and the API may change.
730+
731+
Parameters
732+
----------
733+
location : string or scalar
734+
The position to put the secondary axis. Strings can be 'left' or
735+
'right', scalar can be a float indicating the relative position
736+
on the axes to put the new axes (0 being the left, and 1.0 being
737+
the right.)
738+
739+
conversion : scalar, two-tuple of scalars, string, or Transform
740+
If a scalar or a two-tuple of scalar, the secondary axis is
741+
converted via a linear conversion with slope given by the first
742+
and offset given by the second. i.e. ``conversion = [2, 1]``
743+
element for a parent axis between 0 and 1 gives a secondary axis
744+
between 1 and 3.
745+
746+
If a string, if can be one of "linear", "power", and "inverted".
747+
If "linear", the value of ``otherargs`` should be a float or
748+
two-tuple as above. If "inverted" the values in the secondary axis
749+
are inverted and multiplied by the value supplied by ``oterargs``.
750+
If "power", then the original values are transformed by
751+
``newy = otherargs[1] * oldy ** otherargs[0]``.
752+
753+
Finally, the user can supply a subclass of `.transforms.Transform`
754+
to arbitrarily transform between the parent axes and the
755+
secondary axes.
756+
See :doc:`/gallery/subplots_axes_and_figures/secondary_axis`
757+
for an example of making such a transform.
758+
759+
760+
Other Parameters
761+
----------------
762+
**kwargs : `~matplotlib.axes.Axes` properties.
763+
Other miscellaneous axes parameters.
764+
765+
Returns
766+
-------
767+
ax : axes._secondary_axes.Secondary_Axis
768+
769+
Examples
770+
--------
771+
772+
Add a secondary axes that converts from radians to degrees
773+
774+
.. plot::
775+
776+
fig, ax = plt.subplots()
777+
ax.plot(range(1, 360, 5), range(1, 360, 5))
778+
ax.set_ylabel('degrees')
779+
secax = ax.secondary_yaxis('right', conversion=[np.pi / 180])
780+
secax.set_ylabel('radians')
781+
782+
"""
783+
if location in ['left', 'right'] or isinstance(location, Number):
784+
secondary_ax = Secondary_Axis(self, 'y', location,
785+
conversion, otherargs=otherargs,
786+
**kwargs)
787+
self.add_child_axes(secondary_ax)
788+
return secondary_ax
789+
else:
790+
raise ValueError('secondary_yaxis location must be either '
791+
'"left" or "right"')
792+
639793
def text(self, x, y, s, fontdict=None, withdash=False, **kwargs):
640794
"""
641795
Add text to the axes.

lib/matplotlib/axes/_base.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2511,8 +2511,17 @@ def _update_title_position(self, renderer):
25112511
title.set_position((x, 1.0))
25122512
# need to check all our twins too...
25132513
axs = self._twinned_axes.get_siblings(self)
2514-
2515-
top = 0 # the top of all the axes twinned with this axes...
2514+
# and all the children
2515+
for ax in self.child_axes:
2516+
if ax is not None:
2517+
locator = ax.get_axes_locator()
2518+
if locator:
2519+
pos = locator(self, renderer)
2520+
ax.apply_aspect(pos)
2521+
else:
2522+
ax.apply_aspect()
2523+
axs = axs + [ax]
2524+
top = 0
25162525
for ax in axs:
25172526
try:
25182527
if (ax.xaxis.get_label_position() == 'top'
@@ -2556,12 +2565,15 @@ def draw(self, renderer=None, inframe=False):
25562565

25572566
# prevent triggering call backs during the draw process
25582567
self._stale = True
2559-
locator = self.get_axes_locator()
2560-
if locator:
2561-
pos = locator(self, renderer)
2562-
self.apply_aspect(pos)
2563-
else:
2564-
self.apply_aspect()
2568+
2569+
# loop over self and child axes...
2570+
for ax in [self]:
2571+
locator = ax.get_axes_locator()
2572+
if locator:
2573+
pos = locator(self, renderer)
2574+
ax.apply_aspect(pos)
2575+
else:
2576+
ax.apply_aspect()
25652577

25662578
artists = self.get_children()
25672579
artists.remove(self.patch)
@@ -4219,6 +4231,9 @@ def get_tightbbox(self, renderer, call_axes_locator=True,
42194231
if bb_yaxis:
42204232
bb.append(bb_yaxis)
42214233

4234+
self._update_title_position(renderer)
4235+
bb.append(self.get_window_extent(renderer))
4236+
42224237
self._update_title_position(renderer)
42234238
if self.title.get_visible():
42244239
bb.append(self.title.get_window_extent(renderer))
@@ -4234,9 +4249,11 @@ def get_tightbbox(self, renderer, call_axes_locator=True,
42344249
bbox_artists = self.get_default_bbox_extra_artists()
42354250

42364251
for a in bbox_artists:
4237-
bbox = a.get_tightbbox(renderer)
4238-
if bbox is not None and (bbox.width != 0 or bbox.height != 0):
4239-
bb.append(bbox)
4252+
bbox = a.get_tightbbox(renderer, )
4253+
if (bbox is not None and
4254+
(bbox.width != 0 or bbox.height != 0) and
4255+
np.isfinite(bbox.x0 + bbox.x1 + bbox.y0 + bbox.y1)):
4256+
bb.append(bbox)
42404257

42414258
_bbox = mtransforms.Bbox.union(
42424259
[b for b in bb if b.width != 0 or b.height != 0])

0 commit comments

Comments
 (0)