Skip to content

ENH: add secondary x/y axis #11859

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions doc/api/axes_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ Text and Annotations
Axes.inset_axes
Axes.indicate_inset
Axes.indicate_inset_zoom
Axes.secondary_xaxis
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not to overbikeshed the name, but perhaps consider rescaled_xaxis?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn’t have to be rescaled... I think this is a second axis, so I think the name is apt.

Axes.secondary_yaxis


Fields
Expand Down
16 changes: 16 additions & 0 deletions doc/users/next_whats_new/2018-09-08-JMK.rst
Original file line number Diff line number Diff line change
@@ -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))
164 changes: 164 additions & 0 deletions examples/subplots_axes_and_figures/secondary_axis.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also in the interest of compactness and getting to the point, you could eliminate the function definitions and use np.deg2rad and np.rad2deg.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I wanted to have these as templates to the user. Otherwise, I find it a bit mysterious.


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a bit confusing that the example uses the one and only case where forward and inverse are actually the same, although it probably makes sense to you a/x to show how a log scale behaves. Would something like 2./np.sqrt(x) also work here?


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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it is obvious, but you might add, "Note: we must ensure that both coordinates are monotonic."


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
11 changes: 9 additions & 2 deletions lib/matplotlib/_constrained_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
74 changes: 74 additions & 0 deletions lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can eliminate this sentence and go straight to the example.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you intend to do this, but forget?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooops, I somehow screwed up a transfer between machines.

OTOH, not sure about this one. Note there is a long docstring interp between here and the example, so maybe its good to have the extra line?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the entra sentence doesn't add much.

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]')


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity: does the matplotlib plot directive require 2 blank lines before functions? It looks like you are using this spacing consistently.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flake8 requires it for the actual examples, but not here I don;'t think... Fixed

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 '
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can just let SecondaryAxis.set_location emit the proper error message?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That doesn't stop secondary_xaxis('right')

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But set_location could just check self._orientation to decide what the correct keywords are?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... could, and then the user would get an error message for something they didn't directly call...

'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.
Expand Down
18 changes: 16 additions & 2 deletions lib/matplotlib/axes/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
Loading