Skip to content

Commit df9e346

Browse files
authored
Merge pull request #11859 from jklymak/enh-secondary-axes
ENH: add secondary x/y axis
2 parents 6b68d86 + 85c1b99 commit df9e346

File tree

14 files changed

+845
-24
lines changed

14 files changed

+845
-24
lines changed

.flake8

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ per-file-ignores =
224224
examples/subplots_axes_and_figures/axes_zoom_effect.py: E402
225225
examples/subplots_axes_and_figures/demo_constrained_layout.py: E402
226226
examples/subplots_axes_and_figures/demo_tight_layout.py: E402
227+
examples/subplots_axes_and_figures/secondary_axis.py: E402
227228
examples/subplots_axes_and_figures/two_scales.py: E402
228229
examples/subplots_axes_and_figures/zoom_inset_axes.py: E402
229230
examples/tests/backend_driver_sgskip.py: E402, E501

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
:orphan:
2+
3+
Secondary x/y Axis support
4+
--------------------------
5+
6+
A new method provides the ability to add a second axis to an existing
7+
axes via `.Axes.secondary_xaxis` and `.Axes.secondary_yaxis`. See
8+
:doc:`/gallery/subplots_axes_and_figures/secondary_axis` for examples.
9+
10+
.. plot::
11+
12+
import matplotlib.pyplot as plt
13+
14+
fig, ax = plt.subplots(figsize=(5, 3))
15+
ax.plot(range(360))
16+
ax.secondary_xaxis('top', functions=(np.deg2rad, np.rad2deg))
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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`. This secondary axis can have a different scale
10+
than the main axis by providing both a forward and an inverse conversion
11+
function in a tuple to the ``functions`` kwarg:
12+
"""
13+
14+
import matplotlib.pyplot as plt
15+
import numpy as np
16+
import datetime
17+
import matplotlib.dates as mdates
18+
from matplotlib.transforms import Transform
19+
from matplotlib.ticker import (
20+
AutoLocator, AutoMinorLocator)
21+
22+
fig, ax = plt.subplots(constrained_layout=True)
23+
x = np.arange(0, 360, 1)
24+
y = np.sin(2 * x * np.pi / 180)
25+
ax.plot(x, y)
26+
ax.set_xlabel('angle [degrees]')
27+
ax.set_ylabel('signal')
28+
ax.set_title('Sine wave')
29+
30+
31+
def deg2rad(x):
32+
return x * np.pi / 180
33+
34+
35+
def rad2deg(x):
36+
return x * 180 / np.pi
37+
38+
secax = ax.secondary_xaxis('top', functions=(deg2rad, rad2deg))
39+
secax.set_xlabel('angle [rad]')
40+
plt.show()
41+
42+
###########################################################################
43+
# Here is the case of converting from wavenumber to wavelength in a
44+
# log-log scale.
45+
#
46+
# .. note ::
47+
#
48+
# In this case, the xscale of the parent is logarithmic, so the child is
49+
# made logarithmic as well.
50+
51+
fig, ax = plt.subplots(constrained_layout=True)
52+
x = np.arange(0.02, 1, 0.02)
53+
np.random.seed(19680801)
54+
y = np.random.randn(len(x)) ** 2
55+
ax.loglog(x, y)
56+
ax.set_xlabel('f [Hz]')
57+
ax.set_ylabel('PSD')
58+
ax.set_title('Random spectrum')
59+
60+
61+
def forward(x):
62+
return 1 / x
63+
64+
65+
def inverse(x):
66+
return 1 / x
67+
68+
secax = ax.secondary_xaxis('top', functions=(forward, inverse))
69+
secax.set_xlabel('period [s]')
70+
plt.show()
71+
72+
###########################################################################
73+
# Sometime we want to relate the axes in a transform that is ad-hoc from
74+
# the data, and is derived empirically. In that case we can set the
75+
# forward and inverse transforms functions to be linear interpolations from the
76+
# one data set to the other.
77+
78+
fig, ax = plt.subplots(constrained_layout=True)
79+
xdata = np.arange(1, 11, 0.4)
80+
ydata = np.random.randn(len(xdata))
81+
ax.plot(xdata, ydata, label='Plotted data')
82+
83+
xold = np.arange(0, 11, 0.2)
84+
# fake data set relating x co-ordinate to another data-derived co-ordinate.
85+
# xnew must be monotonic, so we sort...
86+
xnew = np.sort(10 * np.exp(-xold / 4) + np.random.randn(len(xold)) / 3)
87+
88+
ax.plot(xold[3:], xnew[3:], label='Transform data')
89+
ax.set_xlabel('X [m]')
90+
ax.legend()
91+
92+
93+
def forward(x):
94+
return np.interp(x, xold, xnew)
95+
96+
97+
def inverse(x):
98+
return np.interp(x, xnew, xold)
99+
100+
secax = ax.secondary_xaxis('top', functions=(forward, inverse))
101+
secax.xaxis.set_minor_locator(AutoMinorLocator())
102+
secax.set_xlabel('$X_{other}$')
103+
104+
plt.show()
105+
106+
###########################################################################
107+
# A final example translates np.datetime64 to yearday on the x axis and
108+
# from Celsius to Farenheit on the y axis:
109+
110+
111+
dates = [datetime.datetime(2018, 1, 1) + datetime.timedelta(hours=k * 6)
112+
for k in range(240)]
113+
temperature = np.random.randn(len(dates))
114+
fig, ax = plt.subplots(constrained_layout=True)
115+
116+
ax.plot(dates, temperature)
117+
ax.set_ylabel(r'$T\ [^oC]$')
118+
plt.xticks(rotation=70)
119+
120+
121+
def date2yday(x):
122+
"""
123+
x is in matplotlib datenums, so they are floats.
124+
"""
125+
y = x - mdates.date2num(datetime.datetime(2018, 1, 1))
126+
return y
127+
128+
129+
def yday2date(x):
130+
"""
131+
return a matplotlib datenum (x is days since start of year)
132+
"""
133+
y = x + mdates.date2num(datetime.datetime(2018, 1, 1))
134+
return y
135+
136+
secaxx = ax.secondary_xaxis('top', functions=(date2yday, yday2date))
137+
secaxx.set_xlabel('yday [2018]')
138+
139+
140+
def CtoF(x):
141+
return x * 1.8 + 32
142+
143+
144+
def FtoC(x):
145+
return (x - 32) / 1.8
146+
147+
secaxy = ax.secondary_yaxis('right', functions=(CtoF, FtoC))
148+
secaxy.set_ylabel(r'$T\ [^oF]$')
149+
150+
plt.show()
151+
152+
#############################################################################
153+
#
154+
# ------------
155+
#
156+
# References
157+
# """"""""""
158+
#
159+
# The use of the following functions and methods is shown in this example:
160+
161+
import matplotlib
162+
163+
matplotlib.axes.Axes.secondary_xaxis
164+
matplotlib.axes.Axes.secondary_yaxis

lib/matplotlib/_constrained_layout.py

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

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

lib/matplotlib/axes/_axes.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import matplotlib.tri as mtri
3535
from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer
3636
from matplotlib.axes._base import _AxesBase, _process_plot_format
37+
from matplotlib.axes._secondary_axes import SecondaryAxis
3738

3839
_log = logging.getLogger(__name__)
3940

@@ -599,6 +600,79 @@ def indicate_inset_zoom(self, inset_ax, **kwargs):
599600

600601
return rectpatch, connects
601602

603+
@docstring.dedent_interpd
604+
def secondary_xaxis(self, location, *, functions=None, **kwargs):
605+
"""
606+
Add a second x-axis to this axes.
607+
608+
For example if we want to have a second scale for the data plotted on
609+
the xaxis.
610+
611+
%(_secax_docstring)s
612+
613+
Examples
614+
--------
615+
616+
The main axis shows frequency, and the secondary axis shows period.
617+
618+
.. plot::
619+
620+
fig, ax = plt.subplots()
621+
ax.loglog(range(1, 360, 5), range(1, 360, 5))
622+
ax.set_xlabel('frequency [Hz]')
623+
624+
625+
def invert(x):
626+
return 1 / x
627+
628+
secax = ax.secondary_xaxis('top', functions=(invert, invert))
629+
secax.set_xlabel('Period [s]')
630+
plt.show()
631+
632+
633+
"""
634+
if (location in ['top', 'bottom'] or isinstance(location, Number)):
635+
secondary_ax = SecondaryAxis(self, 'x', location, functions,
636+
**kwargs)
637+
self.add_child_axes(secondary_ax)
638+
return secondary_ax
639+
else:
640+
raise ValueError('secondary_xaxis location must be either '
641+
'a float or "top"/"bottom"')
642+
643+
def secondary_yaxis(self, location, *, functions=None, **kwargs):
644+
"""
645+
Add a second y-axis to this axes.
646+
647+
For example if we want to have a second scale for the data plotted on
648+
the yaxis.
649+
650+
%(_secax_docstring)s
651+
652+
Examples
653+
--------
654+
655+
Add a secondary axes that converts from radians to degrees
656+
657+
.. plot::
658+
659+
fig, ax = plt.subplots()
660+
ax.plot(range(1, 360, 5), range(1, 360, 5))
661+
ax.set_ylabel('degrees')
662+
secax = ax.secondary_yaxis('right', functions=(np.deg2rad,
663+
np.rad2deg))
664+
secax.set_ylabel('radians')
665+
666+
"""
667+
if location in ['left', 'right'] or isinstance(location, Number):
668+
secondary_ax = SecondaryAxis(self, 'y', location,
669+
functions, **kwargs)
670+
self.add_child_axes(secondary_ax)
671+
return secondary_ax
672+
else:
673+
raise ValueError('secondary_yaxis location must be either '
674+
'a float or "left"/"right"')
675+
602676
def text(self, x, y, s, fontdict=None, withdash=False, **kwargs):
603677
"""
604678
Add text to the axes.

lib/matplotlib/axes/_base.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2500,8 +2500,17 @@ def _update_title_position(self, renderer):
25002500
title.set_position((x, 1.0))
25012501
# need to check all our twins too...
25022502
axs = self._twinned_axes.get_siblings(self)
2503-
2504-
top = 0 # the top of all the axes twinned with this axes...
2503+
# and all the children
2504+
for ax in self.child_axes:
2505+
if ax is not None:
2506+
locator = ax.get_axes_locator()
2507+
if locator:
2508+
pos = locator(self, renderer)
2509+
ax.apply_aspect(pos)
2510+
else:
2511+
ax.apply_aspect()
2512+
axs = axs + [ax]
2513+
top = 0
25052514
for ax in axs:
25062515
try:
25072516
if (ax.xaxis.get_label_position() == 'top'
@@ -2544,6 +2553,8 @@ def draw(self, renderer=None, inframe=False):
25442553

25452554
# prevent triggering call backs during the draw process
25462555
self._stale = True
2556+
2557+
# loop over self and child axes...
25472558
locator = self.get_axes_locator()
25482559
if locator:
25492560
pos = locator(self, renderer)
@@ -4315,6 +4326,9 @@ def get_tightbbox(self, renderer, call_axes_locator=True,
43154326
if bb_yaxis:
43164327
bb.append(bb_yaxis)
43174328

4329+
self._update_title_position(renderer)
4330+
bb.append(self.get_window_extent(renderer))
4331+
43184332
self._update_title_position(renderer)
43194333
if self.title.get_visible():
43204334
bb.append(self.title.get_window_extent(renderer))

0 commit comments

Comments
 (0)