Skip to content
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

WIP ENH secondary axes: #11589

Closed
wants to merge 22 commits into from
Closed

Conversation

jklymak
Copy link
Member

@jklymak jklymak commented Jul 6, 2018

PR Summary

MOVED TO #11859 (sorry for the inconvenience)

New methods ax.secondary_xaxis and ax.secondary_yaxis; here the work is all in _secondary_axes.py, and a new method in _axes.py.

Updated 15 Aug.

Feedback needed:

Any other use cases for a secondary axes? If so, let me know so I can test...

Notes

  • the secondary axes below are recalculated at draw time based on their parents, so they are responsive to the xlim changing
  • there is no way to set the limits of the secondary axes independent of the parent axes. i.e. you can't specify xlim in the secondary axes units.
  • this functionality has no knowledge of actual units as meant by Matplotlib unit support. I'd need to think hard about how that would work, and is kind of waiting for the units MEP.

Closes #10976

Example placement:

import numpy as np
import matplotlib.pyplot as plt


convert = [np.pi / 180]

fig, axs = plt.subplots(2, 2, constrained_layout=True)

ax = axs[0, 0]
ax.plot(np.arange(360)+20)
ax.set_xlabel('Raw')
ax.set_title('The Title')

axsecond = ax.secondary_xaxis('top', conversion=convert)
axsecond.set_xlabel('Converted: top')

ax = axs[0, 1]
ax.plot(np.arange(360)+20)
ax.set_xlabel('Raw')
ax.set_title('The Title')
axsecond = ax.secondary_xaxis('bottom', conversion=convert)
axsecond.set_color('0.5')
axsecond.set_axis_orientation('top')
axsecond.set_xlabel('Converted: bottom')

ax = axs[1, 0]
ax.plot(np.arange(360)+20)
ax.set_xlabel('Raw')
ax.set_title('The Title')
axsecond = ax.secondary_xaxis(0.6, conversion=convert)
axsecond.set_color('0.5')
axsecond.set_xlabel('Converted: y=0.6')

ax = axs[1, 1]
ax.plot(np.arange(360)+20)
ax.set_xlabel('Raw')
ax.set_title('The Title')
axsecond = ax.secondary_xaxis(1.1, conversion=convert)
axsecond.set_xlabel('Converted: y=1.1')

yields:

figure_1

Example inverted axes

Note that conversion='power' and conversion='linear' are also possible...

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

secax = ax.secondary_xaxis('top', conversion='inverted', otherargs=1)
secax.set_xlabel('period [s]')
secax.set_xscale('log')
plt.show()

figure_1

Arbitrary transforms...

... these need to have a definable inverse. Here the idea is that we have some data that maps one-to-one into the xaxis of the parent plot, and we'd like that mapping to be the xaxis of the secondary axis:

fig, ax = plt.subplots(constrained_layout=True)
ax.plot(np.arange(1, 11), np.arange(1, 11))


class LocalArbitraryInterp(Transform):
    """
    Return interpolated from data.  Note that both arrays
    have to be ascending for this to work in this example.  (Could
    have more error checking to do more generally)
    """

    input_dims = 1
    output_dims = 1
    is_separable = True
    has_inverse = True

    def __init__(self, xold, xnew):
        Transform.__init__(self)
        self._xold = xold
        self._xnew = xnew

    def transform_non_affine(self, values):
        q = np.interp(values, self._xold, self._xnew, )
        return q

    def inverted(self):
        """ we are just our own inverse """
        return LocalArbitraryInterp(self._xnew, self._xold)

# this is anarbitrary mapping defined by data.  Only issue is that it
# should be one-to-one and the vectors need to be ascending for the inverse
# mapping to work.
xold = np.arange(0, 11, 0.2)
xnew = np.sort(10 * np.exp( -xold / 4))

ax.plot(xold[3:], xnew[3:])
ax.set_xlabel('X [m]')

secax = ax.secondary_xaxis('top', conversion=LocalArbitraryInterp(xold, xnew))
secax.xaxis.set_minor_locator(AutoMinorLocator())
secax.set_xlabel('Exponential axes')

figure_3

PR Checklist

  • Has Pytest style unit tests
  • Code is PEP 8 compliant
  • New features are documented, with examples if plot related
  • Documentation is sphinx and numpydoc compliant
  • Added an entry to doc/users/next_whats_new/ if major new feature (follow instructions in README.rst there)

@jklymak jklymak added this to the v3.1 milestone Jul 6, 2018
@tacaswell tacaswell modified the milestones: v3.1, v3.0 Jul 7, 2018
@jklymak
Copy link
Member Author

jklymak commented Jul 8, 2018

Accepting comments on this. Of course, not for 3.0, so no rush. Tests, examples, etc still to be done.

@jklymak
Copy link
Member Author

jklymak commented Jul 10, 2018

@ImportanceOfBeingErnest you are very familiar w/ the axes_grid API. Any comments here?


def set_color(self, color):
"""
Change the color of the secondary axes and all decorators

Choose a reason for hiding this comment

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

I would add here that this is a convenience wrapper and that you may of course use the usual methods to set the color of the label, tickmarks, ticklabels and spines individually as well, in case this is desired.

loc : string or scalar
FIX
The position to put the secondary axis. Strings can be 'top' or
'bottom', scalar can be a float indicating the relative position

Choose a reason for hiding this comment

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

"left" or "right"


return rectpatch, connects

def secondary_xaxis(self, loc, conversion, **kwargs):

Choose a reason for hiding this comment

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

Would it make sense to let conversion and loc take a default? Maybe just loc="right" and conversion=1?

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh, hmm. I'd be OK w/ that....


Returns
-------
ax : `~matplotlib.axes.Axes` (??)

Choose a reason for hiding this comment

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

Why is that axes private? If you include it as a public object you could directly refer to it as the return type.

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 wasn't meant to be private. I think the (??) was just because I wasn't sure that the ref was right... (sorry, this isn't a fully polished PR yet, just wanted to get feedback on the overall API)...

Choose a reason for hiding this comment

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

I'm just wondering if having this as private object only would make it harder to customize, if needed.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not following what makes it private? Its not meant to be private...

Choose a reason for hiding this comment

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

What is returned is a matplotlib.axes._secondary_axes.Secondary_Yaxis. So one would expect to see something like

Returns
-------
ax : `~matplotlib.axes._secondary_axes.Secondary_Yaxis`

right?

But that may not be possible due to _secondary_axes being "private" and not part of the documentation.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah I see what you mean. I find this sort of thing very confusing. Somehow Axes is defined in a private sub module, and I think that’s what should be done here too. But I’m not clear on how the API bubbles up to be public.

@ImportanceOfBeingErnest
Copy link
Member

I like this. It allows to get a second scale for a plot, without creating a totally new twin axes. Up to now people needed to use such twin axes in cases where they only wanted to have a different formatter on the right side or different colors etc.

I'm currently not sure how would this behave in cases with set aspect. I.e. what if the parent has a specific aspect set? And more importantly, what if the user calls set_aspect on this Secondary_Axis object?
Some tests for those edge cases would be good.

In general I think the documentation should point towards the differences between twinx and secondary_xaxis.

@jklymak
Copy link
Member Author

jklymak commented Jul 10, 2018

I'm currently not sure how would this behave in cases with set aspect. I.e. what if the parent has a specific aspect set? And more importantly, what if the user calls set_aspect on this Secondary_Axis object?

Ooh, good point. I'm not sure either. I suspect issues ;-).

I'll make it so the user will not have a set_aspect method available on the secondary axes.

@jklymak
Copy link
Member Author

jklymak commented Jul 11, 2018

I'm currently not sure how would this behave in cases with set aspect. I.e. what if the parent has a specific aspect set? And more importantly, what if the user calls set_aspect on this Secondary_Axis object?

OK, set_aspect on parent works. set_aspect on secondary axis has been disabled (with a warning)

One could argue that the secondary axes might be able to set an aspect using its "units", but I think that'd be very hard to make work in general (i.e. w/ non-linear conversion between the axes), and I don't think its unreasonable to ask the user to specify the aspect ratio on the parent axes. The secondary axes is just a decoration, not meant to control the other axes.

Similarly set_xlim doesn't work for the secondary axes...

@leejjoon
Copy link
Contributor

I only had a quick look at the code, but it seems to me that it only changes the limit of an secondary axis. Then, I guess the location of ticks will be correct only if the convert function is linear. If this is the case, allowing an arbitrary function may not be a good idea.

@ImportanceOfBeingErnest
Copy link
Member

That's a good point. So I guess the documentation needs to be very very clear about the fact that only because you use some arbitrary function, it does not mean that the axes itself is following this function.

Instead one still needs to set some custom locator and formatter, or use a custom Scale on that axis.

Or else, it would of course be cool to have this function manage this as well.

In any case, in a next stage there will then need to be some example on how to produce a plot with e.g. frequency and wavelength correctly.

@jklymak
Copy link
Member Author

jklymak commented Jul 18, 2018

I only had a quick look at the code, but it seems to me that it only changes the limit of an secondary axis. Then, I guess the location of ticks will be correct only if the convert function is linear. If this is the case, allowing an arbitrary function may not be a good idea.

Ooops, yes, thats correct. Shouldn't advertise something I didn't really make work. I imagine its possible to encompass non-linear transformations in the locator, but would have to look at it.

Also, I supposes I"ve made no effort to link the xscale, so if I make the parent a logarithm, the secondary axes should be as well. That should be doable.

@jklymak
Copy link
Member Author

jklymak commented Jul 18, 2018

OK, that took a while, and needs a bit of an API discussion.

What we need to do to make ticks be properly spaced is call sec.set_xscale('arbitrary', transform=transform) where we need to define the transform, and the "arbitrary" scale gets registered. So that all works in prototype (not pushed yet).

For an API, I have the following options:

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.transforms import Transform

fig, ax = plt.subplots()

ax.plot(np.arange(2,11), np.arange(2,11))

class LocalInverted(Transform):
    """
    Return a/x
    """

    input_dims = 1
    output_dims = 1
    is_separable = True
    has_inverse = True

    def __init__(self, out_of_bounds='mask'):
        Transform.__init__(self)
        self._fac = 1.0

    def transform_non_affine(self, values):
        with np.errstate(divide="ignore", invalid="ignore"):
            q = self._fac / values
        return q

    def inverted(self):
        """ we are just our own inverse """
        return LocalInverted(1 / self._fac)

axsecond = ax.secondary_xaxis(0.2, conversion='power', otherargs=(1.5, 1.))
axsecond = ax.secondary_xaxis(0.4, conversion='inverted', otherargs=2)
axsecond = ax.secondary_xaxis(0.6, conversion=[3.5, 1.])
axsecond = ax.secondary_xaxis(0.8, conversion=2.5)
axsecond = ax.secondary_xaxis(1.0, conversion=LocalInverted())

plt.show()

The ArbitraryScale class that gets used by the non-linear transformations just uses the AutoLocator and ScalarFormater. Of course the user could specify these themselves....

figure_1

@jklymak jklymak force-pushed the enh-secondary-axes branch 2 times, most recently from b7c5a92 to ac33b8a Compare August 3, 2018 17:30
@jklymak
Copy link
Member Author

jklymak commented Aug 7, 2018

Closing for now so I can sync between machines w/o triggering CI every time. This is close to working, but the transform structure needs some thought...

@Knusper
Copy link

Knusper commented Jun 4, 2019

(I hope I comment in the right place, if not please direct me to the right place)

First, I have to say that I was quite excited when I saw this feature arriving in 3.1, because I always felt this was a bit hacky to implement using previous versions of matplotlib. For getting a secondary axis quick it is already nice.
Nevertheless, I encountered two problems with the current implementation:

(a) Why do I need to supply a forward and backward transform? I assume there is a technical reason for it, but from a user perspective this makes not much sense to me. There are cases where I can define easily a forward transform, but not so easily a backward transform. In theses cases I have to define a grid and resort to linear interpolation for the forward and backward transformation, as shown in the "arbitrary transform" example. The question than arises, if the call to the secondary_axis as such could be made in a way, that if a user supplies only one transform, the backward transform is handled internally, e.g. via linear interpolation?

(b) I was not able to get minor_ticks working, e.g. in the deg2rad example from the documentation I tried

secax = ax.secondary_xaxis('top', functions=(deg2rad, rad2deg))
secax.set_xlabel('angle [rad]')
secax.minorticks_on()

but that didn't work.

@jklymak
Copy link
Member Author

jklymak commented Jun 4, 2019

@Knusper, can you open a new issue that outlines your concerns with minor ticks? Hopefully that is something straight forward.

For forward/inverse transformations, you need to be able to go from plot space to data space in order to do the tick labelling, so the inverse is crucial. We appreciate the problem with coming up with an inverse, but hopefully you can appreciate the issue with us automatically making one for you - there is no guarantee that a transform is one-to-one, and hence the inverse may not be unique. So the user should supply the inverse to make the one-to-one mapping clear.

Out of curiousity, what are you trying to map that doesn't have an inverse?

@Knusper
Copy link

Knusper commented Jun 4, 2019

For example transformations from vacuum wavelengths to air wavelengths. The inverse transformation will always be an approximation, since you require the refractive index as a function of vacuum wavelength. If you want to look at the mess you can check here: http://www.astro.uu.se/valdwiki/Air-to-vacuum%20conversion - maybe thats a very specific case...

@jklymak
Copy link
Member Author

jklymak commented Jun 4, 2019

Gotcha, well, you definitely need the inverse, and for now we are probably going to keep it user-supplied. But if there is a PR that implemented doing it on our side, I'm sure it'd be considered.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

Successfully merging this pull request may close these issues.

ENH: secondary axis for a x or y scale.
5 participants