Skip to content

Commit d921e18

Browse files
committed
ENH: add secondary x/y axis
1 parent b9b02f1 commit d921e18

File tree

11 files changed

+977
-17
lines changed

11 files changed

+977
-17
lines changed

doc/api/axes_api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ Text and Annotations
184184
Axes.inset_axes
185185
Axes.indicate_inset
186186
Axes.indicate_inset_zoom
187+
Axes.secondary_xaxis
188+
Axes.secondary_yaxis
187189

188190

189191
Fields
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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()

lib/matplotlib/_constrained_layout.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,8 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad,
181181
sup = fig._suptitle
182182
bbox = invTransFig(sup.get_window_extent(renderer=renderer))
183183
height = bbox.y1 - bbox.y0
184-
sup._layoutbox.edit_height(height+h_pad)
184+
if np.isfinite(height):
185+
sup._layoutbox.edit_height(height+h_pad)
185186

186187
# OK, the above lines up ax._poslayoutbox with ax._layoutbox
187188
# now we need to
@@ -267,10 +268,14 @@ def _make_layout_margins(ax, renderer, h_pad, w_pad):
267268
"""
268269
fig = ax.figure
269270
invTransFig = fig.transFigure.inverted().transform_bbox
270-
271271
pos = ax.get_position(original=True)
272272
tightbbox = ax.get_tightbbox(renderer=renderer)
273273
bbox = invTransFig(tightbbox)
274+
# this can go wrong:
275+
if not np.isfinite(bbox.y0 + bbox.x0 + bbox.y1 + bbox.x1):
276+
# just abort, this is likely a bad set of co-ordinates that
277+
# is transitory...
278+
return
274279
# use stored h_pad if it exists
275280
h_padt = ax._poslayoutbox.h_pad
276281
if h_padt is None:
@@ -288,6 +293,8 @@ def _make_layout_margins(ax, renderer, h_pad, w_pad):
288293
_log.debug('left %f', (-bbox.x0 + pos.x0 + w_pad))
289294
_log.debug('right %f', (bbox.x1 - pos.x1 + w_pad))
290295
_log.debug('bottom %f', (-bbox.y0 + pos.y0 + h_padt))
296+
_log.debug('bbox.y0 %f', bbox.y0)
297+
_log.debug('pos.y0 %f', pos.y0)
291298
# Sometimes its possible for the solver to collapse
292299
# rather than expand axes, so they all have zero height
293300
# 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
@@ -38,6 +38,7 @@
3838
safe_first_element)
3939
from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer
4040
from matplotlib.axes._base import _AxesBase, _process_plot_format
41+
from matplotlib.axes._secondary_axes import Secondary_Axis
4142

4243
_log = logging.getLogger(__name__)
4344

@@ -639,6 +640,159 @@ def indicate_inset_zoom(self, inset_ax, **kwargs):
639640

640641
return rectpatch, connects
641642

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

lib/matplotlib/axes/_base.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2511,7 +2511,16 @@ def _update_title_position(self, renderer):
25112511
y = 1.0
25122512
# need to check all our twins too...
25132513
axs = self._twinned_axes.get_siblings(self)
2514-
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]
25152524
for ax in axs:
25162525
try:
25172526
if (ax.xaxis.get_label_position() == 'top'
@@ -2543,12 +2552,15 @@ def draw(self, renderer=None, inframe=False):
25432552

25442553
# prevent triggering call backs during the draw process
25452554
self._stale = True
2546-
locator = self.get_axes_locator()
2547-
if locator:
2548-
pos = locator(self, renderer)
2549-
self.apply_aspect(pos)
2550-
else:
2551-
self.apply_aspect()
2555+
2556+
# loop over self and child axes...
2557+
for ax in [self]:
2558+
locator = ax.get_axes_locator()
2559+
if locator:
2560+
pos = locator(self, renderer)
2561+
ax.apply_aspect(pos)
2562+
else:
2563+
ax.apply_aspect()
25522564

25532565
artists = self.get_children()
25542566
artists.remove(self.patch)
@@ -4199,7 +4211,6 @@ def get_tightbbox(self, renderer, call_axes_locator=True,
41994211
bb_xaxis = self.xaxis.get_tightbbox(renderer)
42004212
if bb_xaxis:
42014213
bb.append(bb_xaxis)
4202-
42034214
self._update_title_position(renderer)
42044215
bb.append(self.get_window_extent(renderer))
42054216

@@ -4219,9 +4230,11 @@ def get_tightbbox(self, renderer, call_axes_locator=True,
42194230
bbox_artists = self.get_default_bbox_extra_artists()
42204231

42214232
for a in bbox_artists:
4222-
bbox = a.get_tightbbox(renderer)
4223-
if bbox is not None and (bbox.width != 0 or bbox.height != 0):
4224-
bb.append(bbox)
4233+
bbox = a.get_tightbbox(renderer, )
4234+
if (bbox is not None and
4235+
(bbox.width != 0 or bbox.height != 0) and
4236+
np.isfinite(bbox.x0 + bbox.x1 + bbox.y0 + bbox.y1)):
4237+
bb.append(bbox)
42254238

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

0 commit comments

Comments
 (0)