diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index a7c6c97249fe..be3db57e974f 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -311,7 +311,10 @@ def __init__(self, ax, cmap=None, self.locator = ticks # Handle default in _ticker() if format is None: if isinstance(self.norm, colors.LogNorm): - self.formatter = ticker.LogFormatterMathtext() + self.formatter = ticker.LogFormatterSciNotation() + elif isinstance(self.norm, colors.SymLogNorm): + self.formatter = ticker.LogFormatterSciNotation( + linthresh=self.norm.linthresh) else: self.formatter = ticker.ScalarFormatter() elif cbook.is_string_like(format): @@ -574,7 +577,14 @@ def _ticker(self): b = self.norm.boundaries locator = ticker.FixedLocator(b, nbins=10) elif isinstance(self.norm, colors.LogNorm): - locator = ticker.LogLocator() + locator = ticker.LogLocator(subs='all') + elif isinstance(self.norm, colors.SymLogNorm): + # The subs setting here should be replaced + # by logic in the locator. + locator = ticker.SymmetricalLogLocator( + subs=np.arange(1, 10), + linthresh=self.norm.linthresh, + base=10) else: if mpl.rcParams['_internal.classic_mode']: locator = ticker.MaxNLocator() diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 4ba04e3717dd..7f7a0865654c 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -249,7 +249,9 @@ def set_default_locators_and_formatters(self, axis): axis.set_major_locator(LogLocator(self.base)) axis.set_major_formatter(LogFormatterSciNotation(self.base)) axis.set_minor_locator(LogLocator(self.base, self.subs)) - axis.set_minor_formatter(LogFormatterSciNotation(self.base, self.subs)) + axis.set_minor_formatter( + LogFormatterSciNotation(self.base, + labelOnlyBase=self.subs)) def get_transform(self): """ diff --git a/lib/matplotlib/tests/test_scale.py b/lib/matplotlib/tests/test_scale.py index e864372fe346..e5936702773a 100644 --- a/lib/matplotlib/tests/test_scale.py +++ b/lib/matplotlib/tests/test_scale.py @@ -46,3 +46,8 @@ def test_log_scatter(): buf = io.BytesIO() fig.savefig(buf, format='svg') + + +if __name__ == '__main__': + import nose + nose.runmodule(argv=['-s', '--with-doctest'], exit=False) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index a600335a73b3..e60ba93276ac 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -162,8 +162,7 @@ def test_SymmetricalLogLocator_set_params(): See if change was successful. Should not exception. """ - # since we only test for the params change. I will pass empty transform - sym = mticker.SymmetricalLogLocator(None) + sym = mticker.SymmetricalLogLocator(base=10, linthresh=1) sym.set_params(subs=[2.0], numticks=8) nose.tools.assert_equal(sym._subs, [2.0]) nose.tools.assert_equal(sym.numticks, 8) @@ -240,7 +239,7 @@ def test_LogFormatter_sublabel(): ax.xaxis.set_major_locator(mticker.LogLocator(base=10, subs=[])) ax.xaxis.set_minor_locator(mticker.LogLocator(base=10, subs=np.arange(2, 10))) - ax.xaxis.set_major_formatter(mticker.LogFormatter()) + ax.xaxis.set_major_formatter(mticker.LogFormatter(labelOnlyBase=True)) ax.xaxis.set_minor_formatter(mticker.LogFormatter(labelOnlyBase=False)) # axis range above 3 decades, only bases are labeled ax.set_xlim(1, 1e4) @@ -261,9 +260,13 @@ def test_LogFormatter_sublabel(): ax.set_xlim(1, 80) _sub_labels(ax.xaxis, subs=[]) - # axis range at 0 to 1 decades, label subs 2, 3, 6 + # axis range at 0.4 to 1 decades, label subs 2, 3, 4, 6 ax.set_xlim(1, 8) - _sub_labels(ax.xaxis, subs=[2, 3, 6]) + _sub_labels(ax.xaxis, subs=[2, 3, 4, 6]) + + # axis range at 0 to 0.4 decades, label all + ax.set_xlim(0.5, 0.9) + _sub_labels(ax.xaxis, subs=np.arange(2, 10, dtype=int)) def _logfe_helper(formatter, base, locs, i, expected_result): diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 0cce991e5a7e..6bb383e8ee8c 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -798,16 +798,67 @@ def _formatSciNotation(self, s): class LogFormatter(Formatter): """ - Format values for log axis. + Base class for formatting ticks on a log or symlog scale. + + It may be instantiated directly, or subclassed. + + Parameters + ---------- + base : float, optional, default: 10. + Base of the logarithm used in all calculations. + + labelOnlyBase : bool, optional, default: False + If True, label ticks only at integer powers of base. + This is normally True for major ticks and False for + minor ticks. + + minor_thresholds : (subset, all), optional, default: (1, 0.4) + If labelOnlyBase is False, these two numbers control + the labeling of ticks that are not at integer powers of + base; normally these are the minor ticks. The controlling + parameter is the log of the axis data range. In the typical + case where base is 10 it is the number of decades spanned + by the axis, so we can call it 'numdec'. If ``numdec <= all``, + all minor ticks will be labeled. If ``all < numdec <= subset``, + then only a subset of minor ticks will be labeled, so as to + avoid crowding. If ``numdec > subset`` then no minor ticks will + be labeled. + + linthresh : None or float, optional, default: None + If a symmetric log scale is in use, its ``linthresh`` + parameter must be supplied here. + + Notes + ----- + The `set_locs` method must be called to enable the subsetting + logic controlled by the ``minor_thresholds`` parameter. + + In some cases such as the colorbar, there is no distinction between + major and minor ticks; the tick locations might be set manually, + or by a locator that puts ticks at integer powers of base and + at intermediate locations. For this situation, disable the + minor_thresholds logic by using ``minor_thresholds=(np.inf, np.inf)``. + + Examples + -------- + To label a subset of minor ticks when the view limits span up + to 2 decades, and all of the ticks when zoomed in to 0.5 decades + or less, use ``minor_thresholds=(2, 0.5)``. + + To label all minor ticks when the view limits span up to 1.5 + decades, use ``minor_thresholds=(1.5, 1.5)``. + + """ - def __init__(self, base=10.0, labelOnlyBase=True): - """ - `base` is used to locate the decade tick, which will be the only - one to be labeled if `labelOnlyBase` is ``True``. - """ - self._base = base + 0.0 + def __init__(self, base=10.0, labelOnlyBase=False, + minor_thresholds=(1, 0.4), + linthresh=None): + + self._base = float(base) self.labelOnlyBase = labelOnlyBase - self.sublabel = [1, ] + self.minor_thresholds = minor_thresholds + self._sublabels = None + self._linthresh = linthresh def base(self, base): """ @@ -823,49 +874,68 @@ def label_minor(self, labelOnlyBase): """ Switch minor tick labeling on or off. - ``labelOnlyBase=True`` to turn off minor ticks. + Parameters + ---------- + labelOnlyBase : bool + If True, label ticks only at integer powers of base. + """ self.labelOnlyBase = labelOnlyBase - def set_locs(self, locs): + def set_locs(self, locs=None): + """ + Use axis view limits to control which ticks are labeled. + + The ``locs`` parameter is ignored in the present algorithm. + + """ + if np.isinf(self.minor_thresholds[0]): + self._sublabels = None + return + b = self._base vmin, vmax = self.axis.get_view_interval() self.d = abs(vmax - vmin) - if not hasattr(self.axis, 'get_transform'): - # This might be a colorbar dummy axis, do not attempt to get - # transform - numdec = 10 - elif hasattr(self.axis.get_transform(), 'linthresh'): - t = self.axis.get_transform() - linthresh = t.linthresh + # Handle symlog case: + linthresh = self._linthresh + if linthresh is None: + try: + linthresh = self.axis.get_transform().linthresh + except AttributeError: + pass + + if linthresh is not None: # symlog # Only compute the number of decades in the logarithmic part of the # axis numdec = 0 if vmin < -linthresh: - numdec += math.log(-vmin / linthresh) / math.log(b) - - if vmax > linthresh and vmin < linthresh: - numdec += math.log(vmax / linthresh) / math.log(b) - elif vmin >= linthresh: - numdec += math.log(vmax / vmin) / math.log(b) + rhs = min(vmax, -linthresh) + numdec += math.log(vmin / rhs) / math.log(b) + if vmax > linthresh: + lhs = max(vmin, linthresh) + numdec += math.log(vmax / lhs) / math.log(b) else: vmin = math.log(vmin) / math.log(b) vmax = math.log(vmax) / math.log(b) numdec = abs(vmax - vmin) - if numdec > 1: + if numdec > self.minor_thresholds[0]: # Label only bases - self.sublabel = set((1,)) + self._sublabels = set((1,)) + elif numdec > self.minor_thresholds[1]: + # Add labels between bases at log-spaced coefficients; + # include base powers in case the locations include + # "major" and "minor" points, as in colorbar. + c = np.logspace(0, 1, b//2 + 1, base=b) + self._sublabels = set(np.round(c)) else: - # Add labels between bases at log-spaced coefficients - c = np.logspace(0, 1, (4 - int(numdec)) + 1, base=b) - self.sublabel = set(np.round(c)) + self._sublabels = set(np.linspace(1, b, b)) def __call__(self, x, pos=None): """ - Return the format for tick val `x` at position `pos`. + Return the format for tick val `x`. """ b = self._base if x == 0.0: @@ -874,22 +944,22 @@ def __call__(self, x, pos=None): x = abs(x) # only label the decades fx = math.log(x) / math.log(b) - isDecade = is_close_to_int(fx) - exponent = np.round(fx) if isDecade else np.floor(fx) + is_x_decade = is_close_to_int(fx) + exponent = np.round(fx) if is_x_decade else np.floor(fx) coeff = np.round(x / b ** exponent) - if coeff in self.sublabel: - if not isDecade and self.labelOnlyBase: - return '' - elif x > 10000: - s = '%1.0e' % x - elif x < 1: - s = '%1.0e' % x - else: - s = self.pprint_val(x, self.d) - if sign == -1: - s = '-%s' % s + if self.labelOnlyBase and not is_x_decade: + return '' + if self._sublabels is not None and coeff not in self._sublabels: + return '' + + if x > 10000: + s = '%1.0e' % x + elif x < 1: + s = '%1.0e' % x else: - s = '' + s = self.pprint_val(x, self.d) + if sign == -1: + s = '-%s' % s return self.fix_minus(s) @@ -946,8 +1016,6 @@ class LogFormatterExponent(LogFormatter): def __call__(self, x, pos=None): """ Return the format for tick value `x`. - - The position `pos` is ignored. """ vmin, vmax = self.axis.get_view_interval() vmin, vmax = mtransforms.nonsingular(vmin, vmax, expander=0.05) @@ -956,12 +1024,20 @@ def __call__(self, x, pos=None): if x == 0: return '0' sign = np.sign(x) + x = abs(x) # only label the decades - fx = math.log(abs(x)) / math.log(b) - isDecade = is_close_to_int(fx) - if not isDecade and self.labelOnlyBase: - s = '' - elif abs(fx) > 10000: + fx = math.log(x) / math.log(b) + + is_x_decade = is_close_to_int(fx) + exponent = np.round(fx) if is_x_decade else np.floor(fx) + coeff = np.round(x / b ** exponent) + + if self.labelOnlyBase and not is_x_decade: + return '' + if self._sublabels is not None and coeff not in self._sublabels: + return '' + + if abs(fx) > 10000: s = '%1.0g' % fx elif abs(fx) < 1: s = '%1.0g' % fx @@ -1003,12 +1079,13 @@ def __call__(self, x, pos=None): else: return '$%s$' % _mathdefault('0') - fx = math.log(abs(x)) / math.log(b) - is_decade = is_close_to_int(fx) - exponent = np.round(fx) if is_decade else np.floor(fx) - coeff = np.round(abs(x) / b ** exponent) - sign_string = '-' if x < 0 else '' + x = abs(x) + + fx = math.log(x) / math.log(b) + is_x_decade = is_close_to_int(fx) + exponent = np.round(fx) if is_x_decade else np.floor(fx) + coeff = np.round(x / b ** exponent) # use string formatting of the base if it is not an integer if b % 1 == 0.0: @@ -1016,33 +1093,29 @@ def __call__(self, x, pos=None): else: base = '%s' % b - if coeff in self.sublabel: - if not is_decade and self.labelOnlyBase: - return '' - elif not is_decade: - return self._non_decade_format(sign_string, base, fx, usetex) - else: - if usetex: - return (r'$%s%s^{%d}$') % (sign_string, - base, - nearest_long(fx)) - else: - return ('$%s$' % _mathdefault( - '%s%s^{%d}' % - (sign_string, base, nearest_long(fx)))) - else: + if self.labelOnlyBase and not is_x_decade: + return '' + if self._sublabels is not None and coeff not in self._sublabels: return '' + if not is_x_decade: + return self._non_decade_format(sign_string, base, fx, usetex) + else: + if usetex: + return (r'$%s%s^{%d}$') % (sign_string, + base, + nearest_long(fx)) + else: + return ('$%s$' % _mathdefault( + '%s%s^{%d}' % + (sign_string, base, nearest_long(fx)))) + class LogFormatterSciNotation(LogFormatterMathtext): """ Format values following scientific notation in a logarithmic axis """ - def __init__(self, base=10.0, labelOnlyBase=False): - super(LogFormatterSciNotation, self).__init__(base=base, - labelOnlyBase=labelOnlyBase) - def _non_decade_format(self, sign_string, base, fx, usetex): 'Return string for non-decade locations' b = float(base) @@ -1051,11 +1124,11 @@ def _non_decade_format(self, sign_string, base, fx, usetex): if is_close_to_int(coeff): coeff = nearest_long(coeff) if usetex: - return (r'$%g\times%s^{%d}$') % \ - (coeff, base, exponent) + return (r'$%s%g\times%s^{%d}$') % \ + (sign_string, coeff, base, exponent) else: - return ('$%s$' % _mathdefault(r'%g\times%s^{%d}' % - (coeff, base, exponent))) + return ('$%s$' % _mathdefault(r'%s%g\times%s^{%d}' % + (sign_string, coeff, base, exponent))) class LogitFormatter(Formatter): @@ -1781,7 +1854,22 @@ class LogLocator(Locator): def __init__(self, base=10.0, subs=(1.0,), numdecs=4, numticks=None): """ - place ticks on the location= base**i*subs[j] + Place ticks on the locations : subs[j] * base**i + + Parameters + ---------- + subs : None, string, or sequence of float, optional, default (1.0,) + Gives the multiples of integer powers of the base at which + to place ticks. The default places ticks only at + integer powers of the base. + The permitted string values are ``'auto'`` and ``'all'``, + both of which use an algorithm based on the axis view + limits to determine whether and how to put ticks between + integer powers of the base. With ``'auto'``, ticks are + placed only between integer powers; with ``'all'``, the + integer powers are included. A value of None is + equivalent to ``'auto'``. + """ if numticks is None: if rcParams['_internal.classic_mode']: @@ -1804,6 +1892,9 @@ def set_params(self, base=None, subs=None, numdecs=None, numticks=None): if numticks is not None: self.numticks = numticks + # FIXME: these base and subs functions are contrary to our + # usual and desired API. + def base(self, base): """ set the base of the log scaling (major tick every base**i, i integer) @@ -1814,8 +1905,13 @@ def subs(self, subs): """ set the minor ticks for the log scaling every base**i*subs[j] """ - if subs is None: - self._subs = None # autosub + if subs is None: # consistency with previous bad API + self._subs = 'auto' + elif cbook.is_string_like(subs): + if subs not in ('all', 'auto'): + raise ValueError("A subs string must be 'all' or 'auto'; " + "found '%s'." % subs) + self._subs = subs else: self._subs = np.asarray(subs, dtype=float) @@ -1859,13 +1955,17 @@ def tick_values(self, vmin, vmax): numdec = math.floor(vmax) - math.ceil(vmin) - if self._subs is None: # autosub for minor ticks + if cbook.is_string_like(self._subs): + _first = 2.0 if self._subs == 'auto' else 1.0 if numdec > 10 or b < 3: - return np.array([]) # no minor ticks + if self._subs == 'auto': + return np.array([]) # no minor or major ticks + else: + subs = np.array([1.0]) # major ticks elif numdec > 5 and b >= 6: - subs = np.arange(2.0, b, 2.0) + subs = np.arange(_first, b, 2.0) else: - subs = np.arange(2.0, b) + subs = np.arange(_first, b) else: subs = self._subs @@ -1933,14 +2033,22 @@ def view_limits(self, vmin, vmax): class SymmetricalLogLocator(Locator): """ - Determine the tick locations for log axes + Determine the tick locations for symmetric log axes """ - def __init__(self, transform, subs=None): + def __init__(self, transform=None, subs=None, linthresh=None, base=None): """ place ticks on the location= base**i*subs[j] """ - self._transform = transform + if transform is not None: + self._base = transform.base + self._linthresh = transform.linthresh + elif linthresh is not None and base is not None: + self._base = base + self._linthresh = linthresh + else: + raise ValueError("Either transform, or both linthresh " + "and base, must be provided.") if subs is None: self._subs = [1.0] else: @@ -1961,8 +2069,8 @@ def __call__(self): return self.tick_values(vmin, vmax) def tick_values(self, vmin, vmax): - b = self._transform.base - t = self._transform.linthresh + b = self._base + t = self._linthresh if vmax < vmin: vmin, vmax = vmax, vmin @@ -2065,7 +2173,7 @@ def get_log_range(lo, hi): def view_limits(self, vmin, vmax): 'Try to choose the view limits intelligently' - b = self._transform.base + b = self._base if vmax < vmin: vmin, vmax = vmax, vmin