Skip to content

FIX: clean up re-limiting hysteresis #20168

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 1 commit into from
Jun 1, 2021

Conversation

jklymak
Copy link
Member

@jklymak jklymak commented May 6, 2021

PR Summary

Closes #7742

This was pretty old code, I'm not sure what it was supposed to do, but it was only being triggered if the units changed on the axis

For date axes we need to set the default to something not 0-1 if the old epoch is being used, otherwise we end up w invalid dates so for the empty axes case we call the old set_default_limits.

PR Checklist

  • Has pytest style unit tests (and pytest passes).
  • Is Flake 8 compliant (run flake8 on changed files to check).
  • New features are documented, with examples if plot related.
  • Documentation is sphinx and numpydoc compliant (the docs should build without error).
  • Conforms to Matplotlib style conventions (install flake8-docstrings and run flake8 --docstring-convention=all).
  • New features have an entry in doc/users/next_whats_new/ (follow instructions in README.rst there).
  • API changes documented in doc/api/next_api_changes/ (follow instructions in README.rst there).

@jklymak jklymak added this to the v3.5.0 milestone May 6, 2021
@jklymak
Copy link
Member Author

jklymak commented May 6, 2021

... ok this has issues if we use the old epoch, but is fine w/ the new epoch.

@jklymak jklymak marked this pull request as draft May 6, 2021 01:32
@jklymak jklymak force-pushed the fix-hline-plot-hysteresis branch from 0c0c300 to b0decc5 Compare May 6, 2021 01:47
@jklymak jklymak marked this pull request as ready for review May 6, 2021 02:09
@jklymak
Copy link
Member Author

jklymak commented May 6, 2021

OK, I think this is fine...

@QuLogic
Copy link
Member

QuLogic commented May 7, 2021

For date axes we need to set the default to something not 0-1 if the old epoch is being used, otherwise we end up w invalid dates so for the empty axes case we call the old set_default_limits.

So to confirm, you mean if axis_date is called when the Axes is empty? What if it's called after something is plotted? Or can that not normally be done by a user?

@jklymak
Copy link
Member Author

jklymak commented May 7, 2021

import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot()

ax.plot([50, 70], [1, 2])
ax.xaxis.axis_date()
assert ax.get_xlim() == (49, 71)

plt.show()

Works as expected. New commit adds this as a test.

@jklymak
Copy link
Member Author

jklymak commented May 27, 2021

This is a one-line fix to a pretty old bug....

@QuLogic
Copy link
Member

QuLogic commented May 29, 2021

I found an explanation in the base class:

def set_default_intervals(self):
"""
Set the default limits for the axis data and view interval if they
have not been not mutated yet.
"""
# this is mainly in support of custom object plotting. For
# example, if someone passes in a datetime object, we do not
# know automagically how to set the default min/max of the
# data and view limits. The unit conversion AxisInfo
# interface provides a hook for custom types to register
# default limits through the AxisInfo.default_limits
# attribute, and the derived code below will check for that
# and use it if it's available (else just use 0..1)

Does this still do the right thing for units that implement default_limits?

@anntzer
Copy link
Contributor

anntzer commented May 29, 2021

I think that the "correct" solution is likely a bit more complex and likely involves something related to #17106 (comment).

@jklymak
Copy link
Member Author

jklymak commented May 29, 2021

Well, a failing example
Would be helpful, particularly as this passes all our tests.

@anntzer
Copy link
Contributor

anntzer commented May 29, 2021

Not a particularly meaningful example, but the first I could come up with:

from matplotlib.units import AxisInfo, ConversionInterface, registry
from matplotlib import pyplot as plt
import numpy as np


class Temperature:  # Just a made up unit system.
    def __init__(self, value):
        self.value = value


class TemperatureConverter:
    axisinfo = staticmethod(lambda *args: AxisInfo(
        default_limits=(0, 100), label="temperature"))
    default_units = staticmethod(lambda *args: None)
    convert = staticmethod(
        lambda obj, unit, axis:
        obj if ConversionInterface.is_numlike(obj)
        else np.array([o.value for o in obj]) if isinstance(obj, np.ndarray)
        else obj.value)


registry[Temperature] = TemperatureConverter()
fig, axs = plt.subplots(2)
# Just checking that the interface works.
axs[0].plot([0, 1], [Temperature(0), Temperature(1)])
# An empty axis set to use Temperature units should default to (0, 100).
axs[1].yaxis.update_units(Temperature(0))
plt.show()

Before this PR, "empty" temperature axis default to limits of (0, 100); after this PR they don't anymore.

To be clear, I think it may be OK to get rid of that per-unit-custom-autolimits feature (well, again, I don't actually use the units system...), but then it should be done everywhere, whereas this PR special-cases dates to be the only units to respect default_units.

@jklymak
Copy link
Member Author

jklymak commented May 29, 2021

Thanks. Well dates need the special case because they are undefined at datenun=0 for the old epoch because we rely on datetime. For the new epoch that is not the case, but we still need to allow the old epoch.

I guess a more general solution of limits=[None, None] would be good. Not sure how that tracks though the rest of the code base.

@anntzer
Copy link
Contributor

anntzer commented May 29, 2021

Just to be clear, my point (and I guess @QuLogic's too) is that the current unit system allows any unit to define whatever default_limits they want. With this PR, default_limits gets ignored for anything but dates. If we really want to go that route (which again, may be the right thing to do), we should just deprecate default_limits and add an internal special-casing for dates -- leaving default_limits present but ignored just seems wrong.

@jklymak
Copy link
Member Author

jklymak commented May 29, 2021

No I understand and agree we can't just break this. The logic in this part of the code is pretty convoluted, and I'd be against making it more so just to fix this hysteresis (ie it's not hard to remember to add your hlines after you add your data). But if there is a straightforward fix that simplifies things we should aim for that.

@jklymak jklymak marked this pull request as draft May 29, 2021 15:15
@jklymak jklymak force-pushed the fix-hline-plot-hysteresis branch from 5d006ac to 673718b Compare May 29, 2021 17:21
if self.converter is not None:
info = self.converter.axisinfo(self.units, self)
if info.default_limits is not None:
valmin, valmax = info.default_limits
xmin = self.converter.convert(valmin, self.units, self)
xmax = self.converter.convert(valmax, self.units, self)
if not dataMutated:
Copy link
Member Author

Choose a reason for hiding this comment

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

Just because we changed the view limits doesn't mean we should change the data limits, which is what this line does...

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 this is likely the correct fix, thanks for figuring that out! :-)

@jklymak jklymak force-pushed the fix-hline-plot-hysteresis branch from 6f3bae3 to 3164ce1 Compare May 29, 2021 20:11
@jklymak
Copy link
Member Author

jklymak commented May 29, 2021

... OK new commit rips out different 15 year old code.

viewMutated = self.axes.viewLim.mutatedx()
if not dataMutated or not viewMutated:
# only change view if dataLim has not changed:
if not self.axes.dataLim.mutatedx():
Copy link
Contributor

@anntzer anntzer May 29, 2021

Choose a reason for hiding this comment

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

Should this rather be if not self.axes.dataLim.mutatedx() and not self.axes.viewLim.mutatedx():? (i.e. if someone explicitly called set_xlim() before, we should also just respect that).
Likewise below.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, if I do this, then

        munits.registry[Quantity] = quantity_converter
        fig, ax1 = plt.subplots()
        ax1.xaxis.update_units(Quantity([10], "miles"))
        fig.draw_no_output()
       assert ax1.get_xlim() == (0, 100)

fails. The xlim is set somehow to (-0.06, 0.06), and the viewLim has been mutated. However, I have no idea where that happens or why.

Copy link
Member Author

Choose a reason for hiding this comment

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

The view limits are deliberately mutated by _request_autoscale_view in _unit_change_handler().

The issue here seems to be that we would like to have nonsingular limits due to what scale we choose, but we have a parallel nonsingular limit due to units.

@anntzer you were probably the last to work on this. I don't know how to make it all work together cleanly - it seems like the same concept was developed independently in units and scales, and they are clashing.

Copy link
Contributor

@anntzer anntzer May 29, 2021

Choose a reason for hiding this comment

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

I looked a bit into this, and I guess that the correct patch may be

diff --git i/lib/matplotlib/axes/_base.py w/lib/matplotlib/axes/_base.py
index afba7c8321..05317ac140 100644
--- i/lib/matplotlib/axes/_base.py
+++ w/lib/matplotlib/axes/_base.py
@@ -2857,8 +2857,8 @@ class _AxesBase(martist.Artist):
         if self.get_yscale() == 'log':
             y_stickies = y_stickies[y_stickies > 0]
 
-        def handle_single_axis(scale, autoscaleon, shared_axes, interval,
-                               minpos, axis, margin, stickies, set_bound):
+        def handle_single_axis(scale, autoscaleon, shared_axes, name,
+                               axis, margin, stickies, set_bound):
 
             if not (scale and autoscaleon):
                 return  # nothing to do...
@@ -2870,12 +2870,15 @@ class _AxesBase(martist.Artist):
             x_values = []
             minimum_minpos = np.inf
             for ax in shared:
-                x_values.extend(getattr(ax.dataLim, interval))
+                x_values.extend(getattr(ax.dataLim, f"interval{name}"))
                 minimum_minpos = min(minimum_minpos,
-                                     getattr(ax.dataLim, minpos))
+                                     getattr(ax.dataLim, f"minpos{name}"))
             x_values = np.extract(np.isfinite(x_values), x_values)
             if x_values.size >= 1:
                 x0, x1 = (x_values.min(), x_values.max())
+            elif getattr(self._viewLim, f"mutated{name}")():
+                # No data but explicit viewLims already set: exit.
+                return
             else:
                 x0, x1 = (-np.inf, np.inf)
             # If x0 and x1 are non finite, use the locator to figure out
@@ -2919,11 +2922,11 @@ class _AxesBase(martist.Artist):
             # End of definition of internal function 'handle_single_axis'.
 
         handle_single_axis(
-            scalex, self._autoscaleXon, self._shared_x_axes, 'intervalx',
-            'minposx', self.xaxis, self._xmargin, x_stickies, self.set_xbound)
+            scalex, self._autoscaleXon, self._shared_x_axes, 'x',
+            self.xaxis, self._xmargin, x_stickies, self.set_xbound)
         handle_single_axis(
-            scaley, self._autoscaleYon, self._shared_y_axes, 'intervaly',
-            'minposy', self.yaxis, self._ymargin, y_stickies, self.set_ybound)
+            scaley, self._autoscaleYon, self._shared_y_axes, 'y',
+            self.yaxis, self._ymargin, y_stickies, self.set_ybound)
 
     def _get_axis_list(self):
         return self.xaxis, self.yaxis
diff --git i/lib/matplotlib/axis.py w/lib/matplotlib/axis.py
index 443dee43f7..6f2ed97858 100644
--- i/lib/matplotlib/axis.py
+++ w/lib/matplotlib/axis.py
@@ -1456,8 +1456,7 @@ class Axis(martist.Artist):
         default = self.converter.default_units(data, self)
         if default is not None and self.units is None:
             self.set_units(default)
-
-        if neednew:
+        elif neednew:
             self._update_axisinfo()
         self.stale = True
         return True
@@ -2268,7 +2267,7 @@ class XAxis(Axis):
         # docstring inherited
         xmin, xmax = 0., 1.
         # only change view if dataLim has not changed:
-        if not self.axes.dataLim.mutatedx():
+        if not self.axes.dataLim.mutatedx() and not self.axes.viewLim.mutatedx():
             if self.converter is not None:
                 info = self.converter.axisinfo(self.units, self)
                 if info.default_limits is not None:

(and likewise for y)

The problem is first that update_units called both set_units and _update_axisinfo, but set_units itself already calls _update_axisinfo, which thus ended up being called twice, and the second time mutatedx would be True (see (1)). So first get rid of the duplicate call to _update_axisinfo.

The second problem, then, is that _update_axisinfo triggers unstale_viewLim (the first time), which leads to autoscaling being called, which would set the locator-defined (see (2)) default limits instead (causing (1)) (previously, this was not a problem because the second call to _update_axisinfo would undo that). So fix autoscale_view to respect preset viewLims if there's no data and the viewLims have already been mutated (i.e., by the default_limits).

(2): Currently, both units and locators can define default limits (units do so via default_limits; locators do so via nonsingular()); it may make more sense to unify the two approaches and e.g. only support locator.nonsingular() (saying that units who want to define default limits must do so via a custom locator). But that may be for later...

Copy link
Member Author

Choose a reason for hiding this comment

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

Right, so as this stands, we lose the user setting the xlim before changing the units on the axes. I actually think this is acceptable, and I don't see a way around it. What does

ax1.set_xlim(0, 10)
ax1.plot(Quantity(np.arange(3), "miles"))

even mean?

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, sorry, cross post...

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, the above seems to work with all the tests I've made and

ax1.set_xlim(0, 10)
ax1.plot(Quantity(np.arange(3), "miles"))

@jklymak jklymak force-pushed the fix-hline-plot-hysteresis branch 2 times, most recently from af857f7 to c3a1319 Compare May 29, 2021 22:52
@jklymak jklymak marked this pull request as ready for review May 29, 2021 23:50
Copy link
Contributor

@anntzer anntzer left a comment

Choose a reason for hiding this comment

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

just a comment that needs fixing

@jklymak jklymak force-pushed the fix-hline-plot-hysteresis branch 3 times, most recently from f5662e8 to 5de931c Compare May 30, 2021 15:12
@jklymak jklymak changed the title FIX: remove default-limit behaviour FIX: clean up re-limiting hysteresis May 30, 2021
@QuLogic
Copy link
Member

QuLogic commented Jun 1, 2021

Just to be clear, my point (and I guess @QuLogic's too) is that the current unit system allows any unit to define whatever default_limits they want.

Well, actually, my point was that it was more complicated than a one-line change, and I certainly did not understand it enough to approve yet.

@anntzer
Copy link
Contributor

anntzer commented Jun 1, 2021

The OSX failure seems spurious.

@anntzer anntzer merged commit ceb4023 into matplotlib:master Jun 1, 2021
@jklymak jklymak deleted the fix-hline-plot-hysteresis branch June 1, 2021 16:37
@jklymak
Copy link
Member Author

jklymak commented Jun 1, 2021

Thanks both!

@tacaswell
Copy link
Member

👍 I'm glad this got sorted without removing set_default_interval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
PR: bugfix Pull requests that fix identified bugs topic: units and array ducktypes
Projects
None yet
Development

Successfully merging this pull request may close these issues.

plot_date() after axhline() doesn't rescale axes
4 participants