Skip to content

[ENH]: Support for custom/special ticks without complex workarounds #22262

Closed

Description

@yaaun

Problem

There is no direct, user-friendly method to add custom/special ticks. Existing methods can lead to surprising results and I have found little official guidance in the documentation that would account for this.

From what I have seen in the examples gallery and on the Internet, most solutions rely on Axes.set_xticks or Axes.set_yticks. These methods can fail silently (and very frustratingly) when attempting to work with e.g. a semilog plot.

Example of what would be expected behaviour with user set ticks (using ordinary plot):

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt


# Constants
L = 6
D = 1
gamma = 1
m_0 = 1
Delta_m = 0.05
q = gamma / D
r = np.sqrt(q)
Samples = 601

# Functions that should work with numbers and ndarrays just as well
def dervLin(xs):
    return xs * 0 -m_0 / L # Trick to make this ndarray-compatible (`xs * 0 +` forces array broadcasting, but doesn't change the value)

def dervExp(xs):
    return -m_0 * r * np.exp(-r * xs)

def dervPow(xs):
    return -12 * D / gamma / (xs + np.sqrt((6 * D / gamma) / m_0))**3


xs = np.linspace(0, L, Samples)
ys1 = Delta_m / np.abs(dervLin(xs))
ys2 = Delta_m / np.abs(dervExp(xs))
ys3 = Delta_m / np.abs(dervPow(xs))


fig, ax = plt.subplots(1, 1)
ax.set_xlim(0, L)
ax.plot(xs, ys1, xs, ys2, xs, ys3)
ax.hlines([ys1[-1], ys2[-1], ys3[-1]], 0, L, linewidth=0.5, linestyle="dashed", color="grey")
ax.set_yticks([ys1[-1], ys2[-1], ys3[-1]])

outputs
issue_plot_1

Now, trying the same with Axes.semilogy instead of Axes.plot:

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt


# Constants
L = 6
D = 1
gamma = 1
m_0 = 1
Delta_m = 0.05
q = gamma / D
r = np.sqrt(q)
Samples = 601

# Functions that should work with numbers and ndarrays just as well
def dervLin(xs):
    return xs * 0 -m_0 / L # Trick to make this ndarray-compatible (`xs * 0 +` forces array broadcasting, but doesn't change the value)

def dervExp(xs):
    return -m_0 * r * np.exp(-r * xs)

def dervPow(xs):
    return -12 * D / gamma / (xs + np.sqrt((6 * D / gamma) / m_0))**3


xs = np.linspace(0, L, Samples)
ys1 = Delta_m / np.abs(dervLin(xs))
ys2 = Delta_m / np.abs(dervExp(xs))
ys3 = Delta_m / np.abs(dervPow(xs))


fig, ax = plt.subplots(1, 1)
ax.set_xlim(0, L)
ax.semilogy(xs, ys1, xs, ys2, xs, ys3)
ax.hlines([ys1[-1], ys2[-1], ys3[-1]], 0, L, linewidth=0.5, linestyle="dashed", color="grey")
ax.set_yticks([ys1[-1], ys2[-1], ys3[-1]])

silently fails to produce labelled ticks:
issue_plot_2

I have deduced that the problem lies with LogFormatterSciNotation, which doesn't produce a tick label when presented with user-provided values.

fmt = ax.yaxis.get_major_formatter()
>>> fmt
<matplotlib.ticker.LogFormatterSciNotation at 0x1d7a95a1940>
>>> fmt(0.3)
''
>>> fmt(10)
'$\\mathdefault{10^{1}}$'

My workaround includes subclassing and substituting the formatter:

class SpecialLogFmt(mpl.ticker.LogFormatterSciNotation):
    def __call__(self, x, pos):
        s = super().__call__(x, pos)
        
        if len(s) == 0:
            s = "%.2f" % x
        return s

sfmt = SpecialLogFmt()
ax.yaxis.set_major_formatter(sfmt)

Given these complications that necessitate a not completely trivial workaround, I believe it would be helpful to introduce explicit support for user-provided special ticks (in parallel to automatically-generated ticks). Other software e.g. OriginLab/OriginPro offers such a feature out of the box.

Proposed solution

At the very least, amend documentation of Axes.set_xticks/Axes.set_yticks to state that the results also depend on the Formatter of the axis, which may ignore the ticks which were added manually. Current documentation claiming that If not set, the labels show the data value. is misleading when this isn't mentioned.

Add a gallery example that shows how to use special ticks, with more emphasis on such edge cases as can occur with e.g. log plots.

If possible, create new API methods that would explicitly support manually-added special ticks, so that the user does not have to worry about making the axis formatter work with the custom ticks. This API could be connected with the hlines/vlines method to give horizontal/vertical lines ending in a special tick, which may be very helpful in some contexts.

Activity

  1. jklymak commented on Jan 18, 2022

    @jklymak
    Member

    If you need to manually label ticks because they are not part of the formatter then:

    ax.set_yticks([ys1[-1], ys2[-1], ys3[-1]], labels=[ys1[-1], ys2[-1], ys3[-1]])

    should work, plus or minus some formatting.

  2. yaaun commented on Jan 18, 2022

    @yaaun
    ContributorAuthor

    I originally stumbled upon this issue when attempting to add my own special ticks to those produced automatically. Then this issue can't be sidestepped as easily with ax.set_yticks.

    import numpy as np
    import matplotlib as mpl
    import matplotlib.pyplot as plt
    
    
    # Constants
    L = 6
    D = 1
    gamma = 1
    m_0 = 1
    Delta_m = 0.05
    q = gamma / D
    r = np.sqrt(q)
    Samples = 601
    
    # Functions that should work with numbers and ndarrays just as well
    def dervLin(xs):
        return xs * 0 -m_0 / L # Trick to make this ndarray-compatible (`xs * 0 +` forces array broadcasting, but doesn't change the value)
    
    def dervExp(xs):
        return -m_0 * r * np.exp(-r * xs)
    
    def dervPow(xs):
        return -12 * D / gamma / (xs + np.sqrt((6 * D / gamma) / m_0))**3
    
    
    xs = np.linspace(0, L, Samples)
    ys1 = Delta_m / np.abs(dervLin(xs))
    ys2 = Delta_m / np.abs(dervExp(xs))
    ys3 = Delta_m / np.abs(dervPow(xs))
    
    
    fig, ax = plt.subplots(1, 1)
    ax.set_xlim(0, L)
    ax.semilogy(xs, ys1, xs, ys2, xs, ys3)
    ax.hlines([ys1[-1], ys2[-1], ys3[-1]], 0, L, linewidth=0.5, linestyle="dashed", color="grey")
    
    
    ticks = ax.get_yticks()
    ticx = np.append(ticks, [ys1[-1], ys2[-1], ys3[-1]])
    
    tickslb = ax.get_yticklabels()
    ticxlb = np.append(tickslb, ["%.2f" % ys1[-1], "%.2f" % ys2[-1], "%.2f" % ys3[-1]])
    
    ax.set_yticks(ticx, labels=ticxlb)

    What I would want to achieve would be to add the new ticks while preserving the old, automatically set ticks and their labels (as I am appending my new ticks/labels to the end of the array). Unfortunately, this is not what happens with the simple(r) solution above.
    issue_plot_3

    The new tick labels apparently clobber the old ones:

    ax.get_yticklabels()
    Out[236]: 
    [Text(0, 0.001, ''),
     Text(0, 0.01, ''),
     Text(0, 0.1, ''),
     Text(0, 1.0, ''),
     Text(0, 10.0, ''),
     Text(0, 100.0, ''),
     Text(0, 1000.0, ''),
     Text(0, 0.30000000000000004, '0.30'),
     Text(0, 20.17143967463676, '20.17'),
     Text(0, 2.5135076278220105, '2.51')]

    I am using matplotlib 3.5.1 (pip install) with Python 3.9.2 and Spyder 5.2.1 on Windows 10.

  3. jklymak commented on Jan 18, 2022

    @jklymak
    Member

    Yes, that is correct, you cannot mix and match custom and automatic ticks like this. If you want custom ticks, you must specify all the ticks - we don't allow more than one Locator or Formatter on an axis.

    This is veering into user help, but you can easily add a secondary axis, and put whatever ticks you want on that. That is the approach I would take rather than trying to put new API on the already complicated axis ticker API. See https://matplotlib.org/stable/gallery/subplots_axes_and_figures/secondary_axis.html

  4. oscargus commented on Jan 19, 2022

    @oscargus
    Member

    Would it make sense (and be possible) that the Formatter knows if the ticks are manually provided or not?

    I guess the empty string comes from here (or the corresponding non-Mathtext lines):

    if self.labelOnlyBase and not is_x_decade:
    return ''
    if self._sublabels is not None and coeff not in self._sublabels:
    return ''

    and somehow bypassing that should make things work more as may be expected.

  5. jklymak commented on Jan 19, 2022

    @jklymak
    Member

    @oscargus the major flaw of the Locator/Formatter dance is that the Formatter doesn't know anything about the Locator or the other ticks being formatted. Fixing that is a medium term project, but it is a tricky one if back compatibility is to be maintained.

    Again, my recommended solution here is to use a secondary axis, which can also conveniently be on the right hand side, so it doesn't over plot on the scale on the left:

    secax = ax.secondary_yaxis('right')
    secax.set_yticks(ys, labels=[f'{yy:1.2f}' for yy in ys])
    secax.set_yticks([], minor=True)
    

    SecAx

    (If you want, it can be on the "left" as well, which is exactly what is being asked for here, but I really think that suffers from readability concerns)

    Whether one calls this a "complex workaround" or not, it is logically much easier than trying to track manual and custom ticks, each with different formatting etc.

  6. yaaun commented on Jan 19, 2022

    @yaaun
    ContributorAuthor

    Yes, that is correct, you cannot mix and match custom and automatic ticks like this. If you want custom ticks, you must specify all the ticks - we don't allow more than one Locator or Formatter on an axis.

    This is veering into user help, but you can easily add a secondary axis, and put whatever ticks you want on that. That is the approach I would take rather than trying to put new API on the already complicated axis ticker API. See https://matplotlib.org/stable/gallery/subplots_axes_and_figures/secondary_axis.html

    @jklymak
    Ah, so I appear to have misunderstood the API completely and fallen into a trap. Some documentation would definitely be advisable to maybe help prevent others from repeating the same mistakes. I could maybe contribute some if that is acceptable.

    Regarding the solution with the secondary axis: in my original use case, I had already used my secondary y axis for another unit scale, like this:
    Figure 2022-01-19 013630

    so that approach wouldn't work for me. In the end I used an Axes.annotate element to put some text with an arrow,

    From what I am gathering, solving this issue (i.e. adding special ticks to an axis – short of setting all ticks manually) straight on without wrestling the API would require the user to provide their own tick Locator and Formatter that would both know (from somewhere else, e.g. by some SpecialLocator.add_tick(...)) about the special, user-set ticks. Nonetheless, this requires a fair bit of coding and knowledge of the internals of axis tick making – nothing impossible, but definitely not straightforward and maybe a bit too much to expect from the user... (?)

  7. jklymak commented on Jan 19, 2022

    @jklymak
    Member

    You can have as many secondary axis-es as you want.

    Adding extra ticks is not straightforward, so while I'm sure a clever API could be developed, I'm not convinced it would be worth it given the many other ways to annotate plots.

  8. oscargus commented on Jan 19, 2022

    @oscargus
    Member

    I could maybe contribute some if that is acceptable.

    That should typically be very welcome (as far as my experience goes, not an official statement).

  9. jklymak commented on Jan 19, 2022

    @jklymak
    Member

    In general document improvements are very welcome. In this case perhaps an example of one does not already exist. Perhaps the docs for set_x/yticks should be made more explicit that they replace the locator (and maybe formatter) for the axis with a Fixedlocator and FixedFormatter and a note that they do not change the minor locator or formatter

  10. added a commit that references this issue on Jan 26, 2022
  11. anntzer commented on May 7, 2024

    @anntzer
    Contributor

    Fixed by #22270, AFAICT. Feel free to ping for reopening otherwise.

  12. added this to the v3.6.0 milestone on May 7, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions