diff --git a/doc/users/whats_new/offset-text-choice.rst b/doc/users/whats_new/offset-text-choice.rst new file mode 100644 index 000000000000..4ffdc890c5be --- /dev/null +++ b/doc/users/whats_new/offset-text-choice.rst @@ -0,0 +1,6 @@ +Improved offset text choice +--------------------------- +The default offset-text choice was changed to only use significant digits that +are common to all ticks (e.g. 1231..1239 -> 1230, instead of 1231), except when +they straddle a relatively large multiple of a power of ten, in which case that +multiple is chosen (e.g. 1999..2001->2000). diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 438be20aa7ce..00f7f5fe8e27 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -3,7 +3,7 @@ from matplotlib.externals import six import nose.tools -from nose.tools import assert_raises +from nose.tools import assert_equal, assert_raises from numpy.testing import assert_almost_equal import numpy as np import matplotlib @@ -159,6 +159,53 @@ def test_SymmetricalLogLocator_set_params(): nose.tools.assert_equal(sym.numticks, 8) +@cleanup +def test_ScalarFormatter_offset_value(): + fig, ax = plt.subplots() + formatter = ax.get_xaxis().get_major_formatter() + + def check_offset_for(left, right, offset): + ax.set_xlim(left, right) + # Update ticks. + next(ax.get_xaxis().iter_ticks()) + assert_equal(formatter.offset, offset) + + test_data = [(123, 189, 0), + (-189, -123, 0), + (12341, 12349, 12340), + (-12349, -12341, -12340), + (99999.5, 100010.5, 100000), + (-100010.5, -99999.5, -100000), + (99990.5, 100000.5, 100000), + (-100000.5, -99990.5, -100000), + (1233999, 1234001, 1234000), + (-1234001, -1233999, -1234000), + (1, 1, 1), + (123, 123, 120), + # Test cases courtesy of @WeatherGod + (.4538, .4578, .45), + (3789.12, 3783.1, 3780), + (45124.3, 45831.75, 45000), + (0.000721, 0.0007243, 0.00072), + (12592.82, 12591.43, 12590), + (9., 12., 0), + (900., 1200., 0), + (1900., 1200., 0), + (0.99, 1.01, 1), + (9.99, 10.01, 10), + (99.99, 100.01, 100), + (5.99, 6.01, 6), + (15.99, 16.01, 16), + (-0.452, 0.492, 0), + (-0.492, 0.492, 0), + (12331.4, 12350.5, 12300), + (-12335.3, 12335.3, 0)] + + for left, right, offset in test_data: + yield check_offset_for, left, right, offset + yield check_offset_for, right, left, offset + + def _logfe_helper(formatter, base, locs, i, expected_result): vals = base**locs labels = [formatter(x, pos) for (x, pos) in zip(vals, i)] diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 872997dc8afb..b22ee19ae68c 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -159,6 +159,7 @@ from matplotlib.externals import six import decimal +import itertools import locale import math import numpy as np @@ -635,33 +636,50 @@ def set_locs(self, locs): vmin, vmax = self.axis.get_view_interval() d = abs(vmax - vmin) if self._useOffset: - self._set_offset(d) + self._compute_offset() self._set_orderOfMagnitude(d) self._set_format(vmin, vmax) - def _set_offset(self, range): - # offset of 20,001 is 20,000, for example + def _compute_offset(self): locs = self.locs - - if locs is None or not len(locs) or range == 0: + if locs is None or not len(locs): self.offset = 0 return + # Restrict to visible ticks. vmin, vmax = sorted(self.axis.get_view_interval()) locs = np.asarray(locs) locs = locs[(vmin <= locs) & (locs <= vmax)] - ave_loc = np.mean(locs) - if len(locs) and ave_loc: # dont want to take log10(0) - ave_oom = math.floor(math.log10(np.mean(np.absolute(locs)))) - range_oom = math.floor(math.log10(range)) - - if np.absolute(ave_oom - range_oom) >= 3: # four sig-figs - p10 = 10 ** range_oom - if ave_loc < 0: - self.offset = (math.ceil(np.max(locs) / p10) * p10) - else: - self.offset = (math.floor(np.min(locs) / p10) * p10) - else: - self.offset = 0 + if not len(locs): + self.offset = 0 + return + lmin, lmax = locs.min(), locs.max() + # Only use offset if there are at least two ticks and every tick has + # the same sign. + if lmin == lmax or lmin <= 0 <= lmax: + self.offset = 0 + return + # min, max comparing absolute values (we want division to round towards + # zero so we work on absolute values). + abs_min, abs_max = sorted([abs(float(lmin)), abs(float(lmax))]) + sign = math.copysign(1, lmin) + # What is the smallest power of ten such that abs_min and abs_max are + # equal up to that precision? + # Note: Internally using oom instead of 10 ** oom avoids some numerical + # accuracy issues. + oom_max = math.ceil(math.log10(abs_max)) + oom = 1 + next(oom for oom in itertools.count(oom_max, -1) + if abs_min // 10 ** oom != abs_max // 10 ** oom) + if (abs_max - abs_min) / 10 ** oom <= 1e-2: + # Handle the case of straddling a multiple of a large power of ten + # (relative to the span). + # What is the smallest power of ten such that abs_min and abs_max + # are no more than 1 apart at that precision? + oom = 1 + next(oom for oom in itertools.count(oom_max, -1) + if abs_max // 10 ** oom - abs_min // 10 ** oom > 1) + # Only use offset if it saves at least two significant digits. + self.offset = (sign * (abs_max // 10 ** oom) * 10 ** oom + if abs_max // 10 ** oom >= 10 + else 0) def _set_orderOfMagnitude(self, range): # if scientific notation is to be used, find the appropriate exponent