-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
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
ENH: add secondary x/y axis #11859
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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)) |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can eliminate this sentence and go straight to the example. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you intend to do this, but forget? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]') | ||
|
||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That doesn't stop There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
There was a problem hiding this comment.
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
?There was a problem hiding this comment.
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.