From 5717116dc41c90771fc7e5f9ed29654e57981431 Mon Sep 17 00:00:00 2001 From: schtandard Date: Fri, 10 Nov 2023 17:17:29 +0100 Subject: [PATCH 01/18] Rewrite SymmetricalLogLocator The new locator is based on LogLocator in such a way that it should give identical results if the data is only in a logarithmic part of the axis. It is extended to the linear part in a (hopefully) reasonable manner. --- lib/matplotlib/ticker.py | 344 +++++++++++++++++++++++++-------------- 1 file changed, 225 insertions(+), 119 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 41114aafbf3e..51ccd31f44b0 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2444,157 +2444,263 @@ def nonsingular(self, vmin, vmax): class SymmetricalLogLocator(Locator): """ + Determine the tick locations for symmetric log axes. + + Place ticks on the locations : ``subs[j] * base**i`` + + Parameters + ---------- + transform : `~.scale.SymmetricalLogTransform`, optional + If set, defines *base*, *linthresh* and *linscale* of the symlog transform. + subs : None or {'auto', 'all'} or sequence of float, default: None + Gives the multiples of integer powers of the base at which + to place ticks. The default of ``None`` is equivalent to ``(1.0, )``, + i.e. it places ticks only at integer powers of the base. + Permitted string values are ``'auto'`` and ``'all'``. + Both of these 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. + numticks : None or int, default: None + The maximum number of ticks to allow on a given axis. The default + of ``None`` will try to choose intelligently as long as this + Locator has already been assigned to an axis using + `~.axis.Axis.get_tick_space`, but otherwise falls back to 9. + base, linthresh, linscale : float, optional + The *base*, *linthresh* and *linscale* of the symlog transform, as + documented for `.SymmetricalLogScale`. These parameters are only used + if *transform* is not set. + """ - def __init__(self, transform=None, subs=None, linthresh=None, base=None): - """ - Parameters - ---------- - transform : `~.scale.SymmetricalLogTransform`, optional - If set, defines the *base* and *linthresh* of the symlog transform. - base, linthresh : float, optional - The *base* and *linthresh* of the symlog transform, as documented - for `.SymmetricalLogScale`. These parameters are only used if - *transform* is not set. - subs : sequence of float, default: [1] - The multiples of integer powers of the base where ticks are placed, - i.e., ticks are placed at - ``[sub * base**i for i in ... for sub in subs]``. - - Notes - ----- - Either *transform*, or both *base* and *linthresh*, must be given. - """ + def __init__(self, transform=None, subs=None, numticks=None, + base=None, linthresh=None, linscale=None): + """Place ticks on the locations : subs[j] * base**i.""" if transform is not None: self._base = transform.base self._linthresh = transform.linthresh - elif linthresh is not None and base is not None: + self._linscale = transform.linscale + elif base is not None and linthresh is not None and linscale is not None: self._base = base self._linthresh = linthresh + self._linscale = linscale else: - raise ValueError("Either transform, or both linthresh " - "and base, must be provided.") - if subs is None: - self._subs = [1.0] - else: - self._subs = subs - self.numticks = 15 + raise ValueError("Either transform, or all of base, linthresh and " + "linscale must be provided.") + self._set_subs(subs) + if numticks is None: + if mpl.rcParams['_internal.classic_mode']: + numticks = 15 + else: + numticks = 'auto' - def set_params(self, subs=None, numticks=None): + def set_params(self, subs=None, numticks=None, + base=None, linthresh=None, linscale=None): """Set parameters within this locator.""" + if subs is not None: + self._set_subs(subs) if numticks is not None: self.numticks = numticks - if subs is not None: + if base is not None: + self._base = float(base) + if linthresh is not None: + self._linthresh = float(linthresh) + if linscale is not None: + self._linscale = float(linscale) + + def _set_subs(self, subs): + """ + Set the minor ticks for the log scaling every ``base**i*subs[j]``. + """ + if subs is None: # consistency with previous bad API + self._subs = np.array([1.0]) + elif isinstance(subs, str): + _api.check_in_list(('all', 'auto'), subs=subs) self._subs = subs + else: + try: + self._subs = np.asarray(subs, dtype=float) + except ValueError as e: + raise ValueError("subs must be None, 'all', 'auto' or " + "a sequence of floats, not " + f"{subs}.") from e + if self._subs.ndim != 1: + raise ValueError("A sequence passed to subs must be " + "1-dimensional, not " + f"{self._subs.ndim}-dimensional.") def __call__(self): """Return the locations of the ticks.""" - # Note, these are untransformed coordinates vmin, vmax = self.axis.get_view_interval() return self.tick_values(vmin, vmax) def tick_values(self, vmin, vmax): - linthresh = self._linthresh + if self.numticks == 'auto': + if self.axis is not None: + numticks = np.clip(self.axis.get_tick_space(), 2, 9) + else: + numticks = 9 + else: + numticks = self.numticks + _log.debug('vmin %s vmax %s', vmin, vmax) if vmax < vmin: vmin, vmax = vmax, vmin - # The domain is divided into three sections, only some of - # which may actually be present. - # - # <======== -t ==0== t ========> - # aaaaaaaaa bbbbb ccccccccc - # - # a) and c) will have ticks at integral log positions. The - # number of ticks needs to be reduced if there are more - # than self.numticks of them. - # - # b) has a tick at 0 and only 0 (we assume t is a small - # number, and the linear segment is just an implementation - # detail and not interesting.) - # - # We could also add ticks at t, but that seems to usually be - # uninteresting. - # - # "simple" mode is when the range falls entirely within [-t, t] - # -- it should just display (vmin, 0, vmax) - if -linthresh <= vmin < vmax <= linthresh: - # only the linear range is present - return sorted({vmin, 0, vmax}) - - # Lower log range is present - has_a = (vmin < -linthresh) - # Upper log range is present - has_c = (vmax > linthresh) - - # Check if linear range is present - has_b = (has_a and vmax > -linthresh) or (has_c and vmin < linthresh) - - base = self._base - - def get_log_range(lo, hi): - lo = np.floor(np.log(lo) / np.log(base)) - hi = np.ceil(np.log(hi) / np.log(base)) - return lo, hi - - # Calculate all the ranges, so we can determine striding - a_lo, a_hi = (0, 0) - if has_a: - a_upper_lim = min(-linthresh, vmax) - a_lo, a_hi = get_log_range(abs(a_upper_lim), abs(vmin) + 1) - - c_lo, c_hi = (0, 0) - if has_c: - c_lower_lim = max(linthresh, vmin) - c_lo, c_hi = get_log_range(c_lower_lim, vmax + 1) - - # Calculate the total number of integer exponents in a and c ranges - total_ticks = (a_hi - a_lo) + (c_hi - c_lo) - if has_b: - total_ticks += 1 - stride = max(total_ticks // (self.numticks - 1), 1) - - decades = [] - if has_a: - decades.extend(-1 * (base ** (np.arange(a_lo, a_hi, - stride)[::-1]))) - - if has_b: - decades.append(0.0) - - if has_c: - decades.extend(base ** (np.arange(c_lo, c_hi, stride))) - - subs = np.asarray(self._subs) - - if len(subs) > 1 or subs[0] != 1.0: - ticklocs = [] - for decade in decades: - if decade == 0: - ticklocs.append(decade) + haszero = vmin <= 0 <= vmax + firstdec = np.ceil(self._dec(vmin)) + lastdec = np.floor(self._dec(vmax)) + maxdec = max(abs(firstdec), abs(lastdec)) + # Number of decades completely contained in the range. + numdec = lastdec - firstdec + + # Calculate the subs immediately, as we may return early. + if isinstance(self._subs, str): + # Either 'auto' or 'all'. + if numdec > 10: + # No minor ticks. + if self._subs == 'auto': + # No major ticks either. + return np.array([]) else: - ticklocs.extend(subs * decade) + subs = np.array([1.0]) + else: + _first = 2.0 if self._subs == 'auto' else 1.0 + subs = np.arange(_first, self._base) else: - ticklocs = decades + subs = self._subs - return self.raise_if_exceeds(np.array(ticklocs)) + # Get decades between major ticks. + stride = (max(math.ceil(numdec / (numticks - 1)), 1) + if mpl.rcParams['_internal.classic_mode'] + else numdec // numticks + 1) + # Avoid axes with a single tick. + if haszero: + # Zero always gets a major tick. + if stride > maxdec: + stride = max(1, maxdec - 1) + else: + if stride >= numdec: + stride = max(1, numdec - 1) + # Determine the major ticks. + if haszero: + # Make sure 0 is ticked. + decades = np.concatenate( + (np.flip(np.arange(stride, -firstdec + 2 * stride, stride)), + np.arange(0, lastdec + 2 * stride, stride)) + ) + else: + decades = np.arange(firstdec - stride, lastdec + 2 * stride, stride) - def view_limits(self, vmin, vmax): - """Try to choose the view limits intelligently.""" - b = self._base - if vmax < vmin: - vmin, vmax = vmax, vmin + # Does subs include anything other than 1? Essentially a hack to know + # whether we're a major or a minor locator. + if len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0): + # Minor locator. + if stride == 1: + ticklocs = [] + for dec in decades: + if dec > 0: + ticklocs.append(subs * self._undec(dec)) + elif dec < 0: + ticklocs.append(np.flip(subs * self._undec(dec))) + else: + if self._linscale < 0.5: + # Don't add minor ticks around 0, it's too camped. + zeroticks = np.array([]) + else: + # We add the usual subs as well as the next lower decade. + zeropow = self._undec(1) / self._base + zeroticks = subs * zeropow + if subs[0] != 1.0: + zeroticks = np.concatenate(([zeropow], zeroticks)) + ticklocs.append(np.flip(-zeroticks)) + ticklocs.append([0.0]) + ticklocs.append(zeroticks) + ticklocs = np.concatenate(ticklocs) + else: + ticklocs = np.array([]) + else: + # Major locator. + ticklocs = np.power(self._base, decades) - if mpl.rcParams['axes.autolimit_mode'] == 'round_numbers': - vmin = _decade_less_equal(vmin, b) - vmax = _decade_greater_equal(vmax, b) - if vmin == vmax: - vmin = _decade_less(vmin, b) - vmax = _decade_greater(vmax, b) + _log.debug('ticklocs %r', ticklocs) + if (len(subs) > 1 + and stride == 1 + and ((vmin <= ticklocs) & (ticklocs <= vmax)).sum() <= 1): + # If we're a minor locator *that expects at least two ticks per + # decade* and the major locator stride is 1 and there's no more + # than one minor tick, switch to AutoLocator. + return AutoLocator().tick_values(vmin, vmax) + else: + return self.raise_if_exceeds(ticklocs) - return mtransforms.nonsingular(vmin, vmax) + def _pos(self, val): + """ + Calculate the normalized position of the value on the axis. + It is normalized such that the distance between two logarithmic decades + is 1 and the position of linthresh is linscale. + """ + sign, val = np.sign(val), np.abs(val) / self._linthresh + if val > 1: + val = self._linscale + np.log(val) / np.log(self._base) + else: + val *= self._linscale + return sign * val + + def _unpos(self, val): + """The inverse of _pos.""" + sign, val = np.sign(val), np.abs(val) + if val > self._linscale: + val = np.power(self._base, val - self._linscale) + else: + val /= self._linscale + return sign * val * self._linthresh + + def _firstdec(self): + """ + Get the first decade (i.e. first positive major tick candidate). + It shall be at least half the width of a logarithmic decade from the + origin (i.e. its _pos shall be at least 0.5). + """ + firstexp = np.ceil(np.log(self._unpos(0.5)) / np.log(self._base)) + firstpow = np.power(self._base, firstexp) + return firstexp, firstpow + def _dec(self, val): + """ + Calculate the decade number of the value. The first decade to have a + position (given by _pos) of at least 0.5 is given the number 1, the + value 0 is given the decade number 0. + """ + firstexp, firstpow = self._firstdec() + sign, val = np.sign(val), np.abs(val) + if val > firstpow: + val = np.log(val) / np.log(self._base) - firstexp + 1 + else: + # We scale linearly in order to get a monotonous mapping between + # 0 and 1, though the linear nature is arbitrary. + val /= firstpow + return sign * val + + def _undec(self, val): + """The inverse of _dec.""" + firstexp, firstpow = self._firstdec() + sign, val = np.sign(val), np.abs(val) + if val > 1: + val = np.power(self._base, val - 1 + firstexp) + else: + val *= firstpow + return sign * val + + def view_limits(self, vmin, vmax): + """Try to choose the view limits intelligently.""" + vmin, vmax = self.nonsingular(vmin, vmax) + if mpl.rcParams['axes.autolimit_mode'] == 'round_numbers': + vmin = self._undec(np.floor(self._dec(vmin))) + vmax = self._undec(np.ceil(self._dec(vmax))) + return vmin, vmax class AsinhLocator(Locator): """ From f9b3ad4494de3b71f9d78464d424ae05dcde7e88 Mon Sep 17 00:00:00 2001 From: schtandard Date: Fri, 10 Nov 2023 17:17:29 +0100 Subject: [PATCH 02/18] Remove spurious blank lines --- lib/matplotlib/ticker.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 51ccd31f44b0..bb73ff8ddc6c 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2246,7 +2246,6 @@ def _is_close_to_int(x): class LogLocator(Locator): """ - Determine the tick locations for log axes. Place ticks on the locations : ``subs[j] * base**i`` @@ -2272,7 +2271,6 @@ class LogLocator(Locator): of ``None`` will try to choose intelligently as long as this Locator has already been assigned to an axis using `~.axis.Axis.get_tick_space`, but otherwise falls back to 9. - """ @_api.delete_parameter("3.8", "numdecs") @@ -2444,7 +2442,6 @@ def nonsingular(self, vmin, vmax): class SymmetricalLogLocator(Locator): """ - Determine the tick locations for symmetric log axes. Place ticks on the locations : ``subs[j] * base**i`` @@ -2472,7 +2469,6 @@ class SymmetricalLogLocator(Locator): The *base*, *linthresh* and *linscale* of the symlog transform, as documented for `.SymmetricalLogScale`. These parameters are only used if *transform* is not set. - """ def __init__(self, transform=None, subs=None, numticks=None, From af9c0de46586c3c0f017701f2c72e8f9b4d5ed02 Mon Sep 17 00:00:00 2001 From: schtandard Date: Fri, 10 Nov 2023 17:17:29 +0100 Subject: [PATCH 03/18] Remove spurious comment --- lib/matplotlib/ticker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index bb73ff8ddc6c..2c120309507b 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2510,7 +2510,7 @@ def _set_subs(self, subs): """ Set the minor ticks for the log scaling every ``base**i*subs[j]``. """ - if subs is None: # consistency with previous bad API + if subs is None: self._subs = np.array([1.0]) elif isinstance(subs, str): _api.check_in_list(('all', 'auto'), subs=subs) From fbb1a50b8160c74504b8f962e2b1803a0c2568e1 Mon Sep 17 00:00:00 2001 From: schtandard Date: Fri, 10 Nov 2023 17:17:30 +0100 Subject: [PATCH 04/18] Fix small oversights --- lib/matplotlib/ticker.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 2c120309507b..0facd5ef6856 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2491,6 +2491,7 @@ def __init__(self, transform=None, subs=None, numticks=None, numticks = 15 else: numticks = 'auto' + self.numticks = numticks def set_params(self, subs=None, numticks=None, base=None, linthresh=None, linscale=None): @@ -2584,7 +2585,7 @@ def tick_values(self, vmin, vmax): if haszero: # Make sure 0 is ticked. decades = np.concatenate( - (np.flip(np.arange(stride, -firstdec + 2 * stride, stride)), + (np.flip(-np.arange(stride, -firstdec + 2 * stride, stride)), np.arange(0, lastdec + 2 * stride, stride)) ) else: @@ -2619,7 +2620,7 @@ def tick_values(self, vmin, vmax): ticklocs = np.array([]) else: # Major locator. - ticklocs = np.power(self._base, decades) + ticklocs = np.array([self._undec(dec) for dec in decades]) _log.debug('ticklocs %r', ticklocs) if (len(subs) > 1 From 55b45cc9c0f39055f4d77f162a21ccf597267865 Mon Sep 17 00:00:00 2001 From: schtandard Date: Fri, 10 Nov 2023 17:17:30 +0100 Subject: [PATCH 05/18] Use minor ticks for symlog scale by default --- lib/matplotlib/scale.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index d86de461efc8..4f539fcd362e 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -440,7 +440,7 @@ class SymmetricalLogScale(ScaleBase): """ name = 'symlog' - def __init__(self, axis, *, base=10, linthresh=2, subs=None, linscale=1): + def __init__(self, axis, *, base=10, linthresh=2, subs='auto', linscale=1): self._transform = SymmetricalLogTransform(base, linthresh, linscale) self.subs = subs @@ -454,7 +454,9 @@ def set_default_locators_and_formatters(self, axis): axis.set_major_formatter(LogFormatterSciNotation(self.base)) axis.set_minor_locator(SymmetricalLogLocator(self.get_transform(), self.subs)) - axis.set_minor_formatter(NullFormatter()) + axis.set_minor_formatter( + LogFormatterSciNotation(self.base, + labelOnlyBase=(self.subs != 'auto'))) def get_transform(self): """Return the `.SymmetricalLogTransform` associated with this scale.""" From ed204cdeb9736c28dc28b2b134ed25c4d66e4ae0 Mon Sep 17 00:00:00 2001 From: schtandard Date: Fri, 10 Nov 2023 17:17:30 +0100 Subject: [PATCH 06/18] Move symlog helper functions to utility class We will use it in the formatter, too. --- lib/matplotlib/ticker.py | 182 +++++++++++++++++++++------------------ 1 file changed, 100 insertions(+), 82 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 0facd5ef6856..0145f1e4ec76 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -812,6 +812,92 @@ def _set_format(self): self.format = r'$\mathdefault{%s}$' % self.format +class _SymmetricalLogUtil: + """ + Helper class for working with symmetrical log scales. + + Parameters + ---------- + transform : `~.scale.SymmetricalLogTransform`, optional + If set, defines *base*, *linthresh* and *linscale* of the symlog transform. + base, linthresh, linscale : float, optional + The *base*, *linthresh* and *linscale* of the symlog transform, as + documented for `.SymmetricalLogScale`. These parameters are only used + if *transform* is not set. + """ + + def __init__(self, transform=None, base=None, linthresh=None, linscale=None): + if transform is not None: + self.base = transform.base + self.linthresh = transform.linthresh + self.linscale = transform.linscale + elif base is not None and linthresh is not None and linscale is not None: + self.base = base + self.linthresh = linthresh + self.linscale = linscale + else: + raise ValueError("Either transform, or all of base, linthresh and " + "linscale must be provided.") + + def pos(self, val): + """ + Calculate the normalized position of the value on the axis. + It is normalized such that the distance between two logarithmic decades + is 1 and the position of linthresh is linscale. + """ + sign, val = np.sign(val), np.abs(val) / self.linthresh + if val > 1: + val = self.linscale + np.log(val) / np.log(self.base) + else: + val *= self.linscale + return sign * val + + def unpos(self, val): + """The inverse of _pos.""" + sign, val = np.sign(val), np.abs(val) + if val > self.linscale: + val = np.power(self.base, val - self.linscale) + else: + val /= self.linscale + return sign * val * self.linthresh + + def firstdec(self): + """ + Get the first decade (i.e. first positive major tick candidate). + It shall be at least half the width of a logarithmic decade from the + origin (i.e. its _pos shall be at least 0.5). + """ + firstexp = np.ceil(np.log(self.unpos(0.5)) / np.log(self.base)) + firstpow = np.power(self.base, firstexp) + return firstexp, firstpow + + def dec(self, val): + """ + Calculate the decade number of the value. The first decade to have a + position (given by _pos) of at least 0.5 is given the number 1, the + value 0 is given the decade number 0. + """ + firstexp, firstpow = self.firstdec() + sign, val = np.sign(val), np.abs(val) + if val > firstpow: + val = np.log(val) / np.log(self.base) - firstexp + 1 + else: + # We scale linearly in order to get a monotonous mapping between + # 0 and 1, though the linear nature is arbitrary. + val /= firstpow + return sign * val + + def undec(self, val): + """The inverse of _dec.""" + firstexp, firstpow = self.firstdec() + sign, val = np.sign(val), np.abs(val) + if val > 1: + val = np.power(self.base, val - 1 + firstexp) + else: + val *= firstpow + return sign * val + + class LogFormatter(Formatter): """ Base class for formatting ticks on a log or symlog scale. @@ -2474,17 +2560,7 @@ class SymmetricalLogLocator(Locator): def __init__(self, transform=None, subs=None, numticks=None, base=None, linthresh=None, linscale=None): """Place ticks on the locations : subs[j] * base**i.""" - if transform is not None: - self._base = transform.base - self._linthresh = transform.linthresh - self._linscale = transform.linscale - elif base is not None and linthresh is not None and linscale is not None: - self._base = base - self._linthresh = linthresh - self._linscale = linscale - else: - raise ValueError("Either transform, or all of base, linthresh and " - "linscale must be provided.") + self._symlogutil = _SymmetricalLogUtil(transform, base, linthresh, linscale) self._set_subs(subs) if numticks is None: if mpl.rcParams['_internal.classic_mode']: @@ -2501,11 +2577,11 @@ def set_params(self, subs=None, numticks=None, if numticks is not None: self.numticks = numticks if base is not None: - self._base = float(base) + self._symlogutil.base = float(base) if linthresh is not None: - self._linthresh = float(linthresh) + self._symlogutil.linthresh = float(linthresh) if linscale is not None: - self._linscale = float(linscale) + self._symlogutil.linscale = float(linscale) def _set_subs(self, subs): """ @@ -2547,8 +2623,8 @@ def tick_values(self, vmin, vmax): vmin, vmax = vmax, vmin haszero = vmin <= 0 <= vmax - firstdec = np.ceil(self._dec(vmin)) - lastdec = np.floor(self._dec(vmax)) + firstdec = np.ceil(self._symlogutil.dec(vmin)) + lastdec = np.floor(self._symlogutil.dec(vmax)) maxdec = max(abs(firstdec), abs(lastdec)) # Number of decades completely contained in the range. numdec = lastdec - firstdec @@ -2565,7 +2641,7 @@ def tick_values(self, vmin, vmax): subs = np.array([1.0]) else: _first = 2.0 if self._subs == 'auto' else 1.0 - subs = np.arange(_first, self._base) + subs = np.arange(_first, self._symlogutil.base) else: subs = self._subs @@ -2599,16 +2675,16 @@ def tick_values(self, vmin, vmax): ticklocs = [] for dec in decades: if dec > 0: - ticklocs.append(subs * self._undec(dec)) + ticklocs.append(subs * self._symlogutil.undec(dec)) elif dec < 0: - ticklocs.append(np.flip(subs * self._undec(dec))) + ticklocs.append(np.flip(subs * self._symlogutil.undec(dec))) else: - if self._linscale < 0.5: + if self._symlogutil.linscale < 0.5: # Don't add minor ticks around 0, it's too camped. zeroticks = np.array([]) else: # We add the usual subs as well as the next lower decade. - zeropow = self._undec(1) / self._base + zeropow = self._symlogutil.undec(1) / self._symlogutil.base zeroticks = subs * zeropow if subs[0] != 1.0: zeroticks = np.concatenate(([zeropow], zeroticks)) @@ -2620,7 +2696,7 @@ def tick_values(self, vmin, vmax): ticklocs = np.array([]) else: # Major locator. - ticklocs = np.array([self._undec(dec) for dec in decades]) + ticklocs = np.array([self._symlogutil.undec(dec) for dec in decades]) _log.debug('ticklocs %r', ticklocs) if (len(subs) > 1 @@ -2633,70 +2709,12 @@ def tick_values(self, vmin, vmax): else: return self.raise_if_exceeds(ticklocs) - def _pos(self, val): - """ - Calculate the normalized position of the value on the axis. - It is normalized such that the distance between two logarithmic decades - is 1 and the position of linthresh is linscale. - """ - sign, val = np.sign(val), np.abs(val) / self._linthresh - if val > 1: - val = self._linscale + np.log(val) / np.log(self._base) - else: - val *= self._linscale - return sign * val - - def _unpos(self, val): - """The inverse of _pos.""" - sign, val = np.sign(val), np.abs(val) - if val > self._linscale: - val = np.power(self._base, val - self._linscale) - else: - val /= self._linscale - return sign * val * self._linthresh - - def _firstdec(self): - """ - Get the first decade (i.e. first positive major tick candidate). - It shall be at least half the width of a logarithmic decade from the - origin (i.e. its _pos shall be at least 0.5). - """ - firstexp = np.ceil(np.log(self._unpos(0.5)) / np.log(self._base)) - firstpow = np.power(self._base, firstexp) - return firstexp, firstpow - - def _dec(self, val): - """ - Calculate the decade number of the value. The first decade to have a - position (given by _pos) of at least 0.5 is given the number 1, the - value 0 is given the decade number 0. - """ - firstexp, firstpow = self._firstdec() - sign, val = np.sign(val), np.abs(val) - if val > firstpow: - val = np.log(val) / np.log(self._base) - firstexp + 1 - else: - # We scale linearly in order to get a monotonous mapping between - # 0 and 1, though the linear nature is arbitrary. - val /= firstpow - return sign * val - - def _undec(self, val): - """The inverse of _dec.""" - firstexp, firstpow = self._firstdec() - sign, val = np.sign(val), np.abs(val) - if val > 1: - val = np.power(self._base, val - 1 + firstexp) - else: - val *= firstpow - return sign * val - def view_limits(self, vmin, vmax): """Try to choose the view limits intelligently.""" vmin, vmax = self.nonsingular(vmin, vmax) if mpl.rcParams['axes.autolimit_mode'] == 'round_numbers': - vmin = self._undec(np.floor(self._dec(vmin))) - vmax = self._undec(np.ceil(self._dec(vmax))) + vmin = self._symlogutil.undec(np.floor(self._symlogutil.dec(vmin))) + vmax = self._symlogutil.undec(np.ceil(self._symlogutil.dec(vmax))) return vmin, vmax class AsinhLocator(Locator): From fa94cc37e5d6eb1890d1671836f6f6d1584551fb Mon Sep 17 00:00:00 2001 From: schtandard Date: Fri, 10 Nov 2023 17:17:30 +0100 Subject: [PATCH 07/18] Preserve sign (for symlog) --- lib/matplotlib/ticker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 0145f1e4ec76..2f4c499fe5f7 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1064,6 +1064,7 @@ def __call__(self, x, pos=None): if x == 0.0: # Symlog return '0' + sign = np.sign(x) x = abs(x) b = self._base # only label the decades @@ -1079,7 +1080,7 @@ def __call__(self, x, pos=None): vmin, vmax = self.axis.get_view_interval() vmin, vmax = mtransforms.nonsingular(vmin, vmax, expander=0.05) - s = self._num_to_string(x, vmin, vmax) + s = self._num_to_string(sign * x, vmin, vmax) return self.fix_minus(s) def format_data(self, value): From 5d48e42ca8112f29ca3bd4a41e8365f02c6b97cf Mon Sep 17 00:00:00 2001 From: schtandard Date: Fri, 10 Nov 2023 17:17:31 +0100 Subject: [PATCH 08/18] Adapt LogFormatter for minor symlog ticks --- lib/matplotlib/ticker.py | 87 ++++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 2f4c499fe5f7..e5aeadea66a0 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -926,9 +926,9 @@ class LogFormatter(Formatter): avoid crowding. If ``numdec > subset`` then no minor ticks will be labeled. - linthresh : None or float, default: None - If a symmetric log scale is in use, its ``linthresh`` - parameter must be supplied here. + linthresh, linscale : None or float, default: None + If a symmetric log scale is in use, its ``linthresh`` and ``linscale`` + parameters must be supplied here. Notes ----- @@ -958,7 +958,7 @@ class LogFormatter(Formatter): def __init__(self, base=10.0, labelOnlyBase=False, minor_thresholds=None, - linthresh=None): + linthresh=None, linscale=None): self.set_base(base) self.set_label_minor(labelOnlyBase) @@ -970,6 +970,9 @@ def __init__(self, base=10.0, labelOnlyBase=False, self.minor_thresholds = minor_thresholds self._sublabels = None self._linthresh = linthresh + self._linscale = linscale + self._symlogutil = None + self._firstsublabels = None def set_base(self, base): """ @@ -991,6 +994,21 @@ def set_label_minor(self, labelOnlyBase): """ self.labelOnlyBase = labelOnlyBase + @property + def _symlog(self): + if self._symlogutil is not None: + return True + if self._linthresh is not None and self._linscale is not None: + self._symlogutil = _SymmetricalLogUtil(base=self._base, + linthresh=self._linthresh, + linscale=self._linscale) + return True + transf = self.axis.get_transform() + if hasattr(transf, 'linthresh'): + self._symlogutil = _SymmetricalLogUtil(transf) + return True + return False + def set_locs(self, locs=None): """ Use axis view limits to control which ticks are labeled. @@ -1001,19 +1019,11 @@ def set_locs(self, locs=None): self._sublabels = None return - # Handle symlog case: - linthresh = self._linthresh - if linthresh is None: - try: - linthresh = self.axis.get_transform().linthresh - except AttributeError: - pass - vmin, vmax = self.axis.get_view_interval() if vmin > vmax: vmin, vmax = vmax, vmin - if linthresh is None and vmin <= 0: + if not self._symlog and vmin <= 0: # It's probably a colorbar with # a format kwarg setting a LogFormatter in the manner # that worked with 1.5.x, but that doesn't work now. @@ -1021,16 +1031,8 @@ def set_locs(self, locs=None): return b = self._base - if linthresh is not None: # symlog - # Only compute the number of decades in the logarithmic part of the - # axis - numdec = 0 - if vmin < -linthresh: - 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) + if self._symlog: + numdec = self._symlogutil.pos(vmax) - self._symlogutil.pos(vmin) else: vmin = math.log(vmin) / math.log(b) vmax = math.log(vmax) / math.log(b) @@ -1039,6 +1041,8 @@ def set_locs(self, locs=None): if numdec > self.minor_thresholds[0]: # Label only bases self._sublabels = {1} + if self._symlog: + self._firstsublabels = {0} elif numdec > self.minor_thresholds[1]: # Add labels between bases at log-spaced coefficients; # include base powers in case the locations include @@ -1046,9 +1050,16 @@ def set_locs(self, locs=None): c = np.geomspace(1, b, int(b)//2 + 1) self._sublabels = set(np.round(c)) # For base 10, this yields (1, 2, 3, 4, 6, 10). + if self._symlog: + # For the linear part of the scale we use an analog selection. + c = np.linspace(2, b, int(b) // 2) + self._firstsublabels = set(np.round(c)) + # For base 10, this yields (0, 2, 4, 6, 8, 10). else: # Label all integer multiples of base**n. self._sublabels = set(np.arange(1, b + 1)) + if self._symlog: + self._firstsublabels = set(np.arange(0, b + 1)) def _num_to_string(self, x, vmin, vmax): if x > 10000: @@ -1073,10 +1084,17 @@ def __call__(self, x, pos=None): exponent = round(fx) if is_x_decade else np.floor(fx) coeff = round(b ** (fx - exponent)) - if self.labelOnlyBase and not is_x_decade: - return '' - if self._sublabels is not None and coeff not in self._sublabels: - return '' + _, firstpow = self._symlogutil.firstdec() if self._symlog else None, 0 + if x < firstpow: + if self.labelOnlyBase: + return '' + if self._firstsublabels is not None and coeff not in self._firstsublabels: + return '' + else: + if self.labelOnlyBase and not is_x_decade: + return '' + if self._sublabels is not None and coeff not in self._sublabels: + return '' vmin, vmax = self.axis.get_view_interval() vmin, vmax = mtransforms.nonsingular(vmin, vmax, expander=0.05) @@ -1154,10 +1172,17 @@ def __call__(self, x, pos=None): exponent = round(fx) if is_x_decade else np.floor(fx) coeff = round(b ** (fx - exponent)) - if self.labelOnlyBase and not is_x_decade: - return '' - if self._sublabels is not None and coeff not in self._sublabels: - return '' + _, firstpow = self._symlogutil.firstdec() if self._symlog else (None, 0) + if x < firstpow: + if self.labelOnlyBase: + return '' + if self._firstsublabels is not None and coeff not in self._firstsublabels: + return '' + else: + if self.labelOnlyBase and not is_x_decade: + return '' + if self._sublabels is not None and coeff not in self._sublabels: + return '' if is_x_decade: fx = round(fx) From 471e6686fa3b8490152392ecf601fc9923a34c33 Mon Sep 17 00:00:00 2001 From: schtandard Date: Fri, 10 Nov 2023 17:17:31 +0100 Subject: [PATCH 09/18] Fix docstrings --- lib/matplotlib/ticker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index e5aeadea66a0..647f0bacc8f9 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -853,7 +853,7 @@ def pos(self, val): return sign * val def unpos(self, val): - """The inverse of _pos.""" + """The inverse of pos.""" sign, val = np.sign(val), np.abs(val) if val > self.linscale: val = np.power(self.base, val - self.linscale) @@ -865,7 +865,7 @@ def firstdec(self): """ Get the first decade (i.e. first positive major tick candidate). It shall be at least half the width of a logarithmic decade from the - origin (i.e. its _pos shall be at least 0.5). + origin (i.e. its pos shall be at least 0.5). """ firstexp = np.ceil(np.log(self.unpos(0.5)) / np.log(self.base)) firstpow = np.power(self.base, firstexp) @@ -888,7 +888,7 @@ def dec(self, val): return sign * val def undec(self, val): - """The inverse of _dec.""" + """The inverse of dec.""" firstexp, firstpow = self.firstdec() sign, val = np.sign(val), np.abs(val) if val > 1: From 7ab92de3ad1b51b22e445dcd2a01e45c6b599774 Mon Sep 17 00:00:00 2001 From: schtandard Date: Sat, 11 Nov 2023 08:58:03 +0100 Subject: [PATCH 10/18] Prevent error when using dummy axis --- lib/matplotlib/ticker.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 647f0bacc8f9..496882251380 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1003,10 +1003,11 @@ def _symlog(self): linthresh=self._linthresh, linscale=self._linscale) return True - transf = self.axis.get_transform() - if hasattr(transf, 'linthresh'): - self._symlogutil = _SymmetricalLogUtil(transf) + try: + self._symlogutil = _SymmetricalLogUtil(self.axis.get_transform()) return True + except AttributeError: + pass return False def set_locs(self, locs=None): From f6b395a6f8c0f1f4def72a1af47fb7453f017b55 Mon Sep 17 00:00:00 2001 From: schtandard Date: Sat, 11 Nov 2023 08:58:29 +0100 Subject: [PATCH 11/18] Adapt tests to new behavior --- lib/matplotlib/tests/test_ticker.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 961daaa1d167..177b2fee43bd 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -601,12 +601,12 @@ def test_set_params(self): class TestSymmetricalLogLocator: def test_set_params(self): """ - Create symmetrical log locator with default subs =[1.0] numticks = 15, + Create symmetrical log locator with default subs=[1.0] numticks='auto', and change it to something else. See if change was successful. Should not exception. """ - sym = mticker.SymmetricalLogLocator(base=10, linthresh=1) + sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, linscale=1) sym.set_params(subs=[2.0], numticks=8) assert sym._subs == [2.0] assert sym.numticks == 8 @@ -614,32 +614,33 @@ def test_set_params(self): @pytest.mark.parametrize( 'vmin, vmax, expected', [ - (0, 1, [0, 1]), - (-1, 1, [-1, 0, 1]), + (0, 1, [-1, 0, 1, 10]), + (-1, 1, [-10, -1, 0, 1, 10]), ], ) def test_values(self, vmin, vmax, expected): # https://github.com/matplotlib/matplotlib/issues/25945 - sym = mticker.SymmetricalLogLocator(base=10, linthresh=1) + sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, linscale=1) ticks = sym.tick_values(vmin=vmin, vmax=vmax) assert_array_equal(ticks, expected) def test_subs(self): - sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, subs=[2.0, 4.0]) + sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, linscale=1, subs=[2.0, 4.0]) sym.create_dummy_axis() sym.axis.set_view_interval(-10, 10) - assert (sym() == [-20., -40., -2., -4., 0., 2., 4., 20., 40.]).all() + assert (sym() == [-400., -200., -40., -20., -4., -2., -0.4, -0.2, -0.1, 0., + 0.1, 0.2, 0.4, 2., 4., 20., 40., 200., 400.]).all() def test_extending(self): - sym = mticker.SymmetricalLogLocator(base=10, linthresh=1) + sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, linscale=1) sym.create_dummy_axis() sym.axis.set_view_interval(8, 9) - assert (sym() == [1.0]).all() - sym.axis.set_view_interval(8, 12) assert (sym() == [1.0, 10.0]).all() - assert sym.view_limits(10, 10) == (1, 100) - assert sym.view_limits(-10, -10) == (-100, -1) - assert sym.view_limits(0, 0) == (-0.001, 0.001) + sym.axis.set_view_interval(8, 12) + assert (sym() == [1.0, 10.0, 100.0]).all() + assert sym.view_limits(10, 10) == (1.0, 100.0) + assert sym.view_limits(-10, -10) == (-100.0, -1.0) + assert sym.view_limits(0, 0) == (-1.0, 1.0) class TestAsinhLocator: From 49151ed0313867de320f629ebc663c9d22e6d841 Mon Sep 17 00:00:00 2001 From: schtandard Date: Sun, 12 Nov 2023 17:51:44 +0100 Subject: [PATCH 12/18] Update type hints --- lib/matplotlib/scale.pyi | 2 +- lib/matplotlib/ticker.pyi | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/scale.pyi b/lib/matplotlib/scale.pyi index 7fec8e68cc5a..0d72b273e9e8 100644 --- a/lib/matplotlib/scale.pyi +++ b/lib/matplotlib/scale.pyi @@ -108,7 +108,7 @@ class SymmetricalLogScale(ScaleBase): *, base: float = ..., linthresh: float = ..., - subs: Iterable[int] | None = ..., + subs: Iterable[int] | Literal["auto", "all"] | None = ..., linscale: float = ... ) -> None: ... @property diff --git a/lib/matplotlib/ticker.pyi b/lib/matplotlib/ticker.pyi index f026b4943c94..07cef2435309 100644 --- a/lib/matplotlib/ticker.pyi +++ b/lib/matplotlib/ticker.pyi @@ -89,6 +89,20 @@ class ScalarFormatter(Formatter): def format_data_short(self, value: float | np.ma.MaskedArray) -> str: ... def format_data(self, value: float) -> str: ... +class _SymmetricalLogUtil: + def __init__( + self, + transform: Transform | None = ..., + base: float | None = ..., + linthresh: float | None = ..., + linscale: float | None = ..., + ) -> None: ... + def pos(self, val: float) -> float: ... + def unpos(self, val: float) -> float: ... + def firstdec(self) -> tuple[float, float]: ... + def dec(self, val: float) -> float: ... + def undec(self, val: float) -> float: ... + class LogFormatter(Formatter): minor_thresholds: tuple[float, float] def __init__( @@ -97,6 +111,7 @@ class LogFormatter(Formatter): labelOnlyBase: bool = ..., minor_thresholds: tuple[float, float] | None = ..., linthresh: float | None = ..., + linscale: float | None = ..., ) -> None: ... def set_base(self, base: float) -> None: ... labelOnlyBase: bool @@ -253,12 +268,18 @@ class SymmetricalLogLocator(Locator): def __init__( self, transform: Transform | None = ..., - subs: Sequence[float] | None = ..., - linthresh: float | None = ..., + subs: Sequence[float] | Literal["auto", "all"] | None = ..., base: float | None = ..., + linthresh: float | None = ..., + linscale: float | None = ..., ) -> None: ... def set_params( - self, subs: Sequence[float] | None = ..., numticks: int | None = ... + self, + subs: Sequence[float] | None = ..., + numticks: int | None = ..., + base: float | None = ..., + linthresh: float | None = ..., + linscale : float | None = ... ) -> None: ... class AsinhLocator(Locator): From 3486c5d50fda4d809e83717d975d2a7f06009daa Mon Sep 17 00:00:00 2001 From: schtandard Date: Sun, 12 Nov 2023 17:55:27 +0100 Subject: [PATCH 13/18] Conform to styleguide --- lib/matplotlib/tests/test_ticker.py | 3 ++- lib/matplotlib/ticker.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 177b2fee43bd..831e2d4012bf 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -625,7 +625,8 @@ def test_values(self, vmin, vmax, expected): assert_array_equal(ticks, expected) def test_subs(self): - sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, linscale=1, subs=[2.0, 4.0]) + sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, linscale=1, + subs=[2.0, 4.0]) sym.create_dummy_axis() sym.axis.set_view_interval(-10, 10) assert (sym() == [-400., -200., -40., -20., -4., -2., -0.4, -0.2, -0.1, 0., diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 496882251380..883a0e43f819 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2744,6 +2744,7 @@ def view_limits(self, vmin, vmax): vmax = self._symlogutil.undec(np.ceil(self._symlogutil.dec(vmax))) return vmin, vmax + class AsinhLocator(Locator): """ An axis tick locator specialized for the inverse-sinh scale From a11919e0f4521abcb5c7e4bd6cb62210a08c1543 Mon Sep 17 00:00:00 2001 From: schtandard Date: Sun, 12 Nov 2023 18:43:31 +0100 Subject: [PATCH 14/18] Don't omit the minor ticks around 0 Very cramped ticks are already avoided by choice of the first decade. --- lib/matplotlib/ticker.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 883a0e43f819..be11dfe5ff15 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2706,15 +2706,11 @@ def tick_values(self, vmin, vmax): elif dec < 0: ticklocs.append(np.flip(subs * self._symlogutil.undec(dec))) else: - if self._symlogutil.linscale < 0.5: - # Don't add minor ticks around 0, it's too camped. - zeroticks = np.array([]) - else: - # We add the usual subs as well as the next lower decade. - zeropow = self._symlogutil.undec(1) / self._symlogutil.base - zeroticks = subs * zeropow - if subs[0] != 1.0: - zeroticks = np.concatenate(([zeropow], zeroticks)) + # We add the usual subs as well as the next lower decade. + zeropow = self._symlogutil.undec(1) / self._symlogutil.base + zeroticks = subs * zeropow + if subs[0] != 1.0: + zeroticks = np.concatenate(([zeropow], zeroticks)) ticklocs.append(np.flip(-zeroticks)) ticklocs.append([0.0]) ticklocs.append(zeroticks) From d21e6f0db85168ae31e93c3ca7da469d4a12e5ae Mon Sep 17 00:00:00 2001 From: schtandard Date: Sun, 12 Nov 2023 19:28:10 +0100 Subject: [PATCH 15/18] Use dec for calculating numdec, not pos --- lib/matplotlib/ticker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index be11dfe5ff15..552f408441c6 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1033,7 +1033,7 @@ def set_locs(self, locs=None): b = self._base if self._symlog: - numdec = self._symlogutil.pos(vmax) - self._symlogutil.pos(vmin) + numdec = self._symlogutil.dec(vmax) - self._symlogutil.dec(vmin) else: vmin = math.log(vmin) / math.log(b) vmax = math.log(vmax) / math.log(b) From 97b9b82f08d988f9aee0b337f87e52d9bfc8cc12 Mon Sep 17 00:00:00 2001 From: schtandard Date: Sun, 12 Nov 2023 19:30:35 +0100 Subject: [PATCH 16/18] Avoid lonely 0 label --- lib/matplotlib/ticker.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 552f408441c6..b940affcc8ab 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1062,6 +1062,15 @@ def set_locs(self, locs=None): if self._symlog: self._firstsublabels = set(np.arange(0, b + 1)) + if self._symlog: + _, firstpow = self._symlogutil.firstdec() + if self._firstsublabels == {0} and -firstpow < vmin < vmax < firstpow: + # No minor ticks are being labeled right now and the only major tick is + # at 0. This means the axis scaling cannot be read from the labels. + numsteps = int(np.ceil(firstpow / max(-vmin, vmax))) + step = int(b / numsteps) + self._firstsublabels = set(range(0, int(b) + 1, step)) + def _num_to_string(self, x, vmin, vmax): if x > 10000: s = '%1.0e' % x From 92eec647714013d534eedf31d233f3fe0a7a2537 Mon Sep 17 00:00:00 2001 From: schtandard Date: Sun, 12 Nov 2023 21:00:41 +0100 Subject: [PATCH 17/18] Add missing argument type hint --- lib/matplotlib/ticker.pyi | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/matplotlib/ticker.pyi b/lib/matplotlib/ticker.pyi index 07cef2435309..4056a5a728d8 100644 --- a/lib/matplotlib/ticker.pyi +++ b/lib/matplotlib/ticker.pyi @@ -269,6 +269,7 @@ class SymmetricalLogLocator(Locator): self, transform: Transform | None = ..., subs: Sequence[float] | Literal["auto", "all"] | None = ..., + numticks: float | None = ..., base: float | None = ..., linthresh: float | None = ..., linscale: float | None = ..., From 9cc5db9be0f1924406d652158b909216d961c009 Mon Sep 17 00:00:00 2001 From: schtandard Date: Sun, 12 Nov 2023 21:32:10 +0100 Subject: [PATCH 18/18] Update symlog baseline images --- .../baseline_images/test_axes/symlog.pdf | Bin 6255 -> 7539 bytes .../baseline_images/test_axes/symlog2.pdf | Bin 6640 -> 10151 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/lib/matplotlib/tests/baseline_images/test_axes/symlog.pdf b/lib/matplotlib/tests/baseline_images/test_axes/symlog.pdf index d3a109773d24c8fa1bab34a79f84f0929ac2519c..ed8a23d17b6f3b621d898d7395400c3c6dd6f4ab 100644 GIT binary patch literal 7539 zcmb_h2|SeD_YY|q+nY&}(xXy&Ei?1X{$^i`%2r6k7=samF$ry2h$3YxT8Su%!b?KR zQbLwUc}bzDP?l2J{`Z-syz2Mw_xXSR<8$2m+dwbDA5&4}t-MC8VW=Vz68gM#{7SM&2wA zgy%9~Y|WfK7<3Lq<~pASpy>=3L}66VK#UH5%l<_KEK=mIZP7=_wxo2xe@8v3?Lm(e2gW-&wCe}4)%k{ zZwX;GFiW4_QEzMsL`e8!loBMKu!LcrP z!|+)hE^5(-PaRHrcuX`)*wANW)XK%?l((`QLR&mliLuU$;tB~d1SJUp+kH+_CyLgJ8|9!v_97v7hYQ?bzaQf*~i z-+Rh>M}>VF4xcKGw|l;wYl>IzeiG=hXIP2wSMOnRMJ4`8TFjl;64$P`cLr^5Jxxi! z-PJz$c|2L{T}9unOsyj)lBM*cUU+Mo+})SblhtA07HPK4BP4bB#hrLNjn-j{{NNx? z*p4LkqR7*)Wg^dcKal;_asl%#p(?KM@7H$HyK-)hbWKbQz8eVmDr;OE_+sL((B^jS z6Bp|~^o{m()ZJMgrQNa2JF4MO8N+RF+@ke~*I$Y>M$0YV8EHyb)rdYDqd^E zDv$49tDKlYnB41aC2J3D9SkYCAD!1i>8Ltga##DUK4!;(FC*2CQvsvZJz1E617qV4 zT82N}eVlJQRX7&8*_^EZcD~DvfoIthZ=B5vJFTuOfAO?>St576{8)jpt8Tq&qU^a( zq_m7}Dtb95Zztx_#!8jU2#UK(*PXqzt#0p?OIX?K%#x?JS&txn=CMMLf9@TvXxO+u z4l~o1F99<}ze~^4kgSyVWbcg2a5zUGf&0a4f-@!7SVe`}q z#@j^jFV;%NM_;Do<=Q`yw6yVE_a(c)iTT%vLH+yZm_kkl$=+H7%`eZ7|8?=BL}YWEUuQT8f^pO?>j)#h=|X_som9{UI7t7tt#es<~vPSyHM$ha+}ZL*>xigz6kxJ32yw6Eaw3 z#}lkQX5&1b#PcS*HhK9>>x{`+K1M)X;Gd;Vlu66e#_`D9<;vEpF?$>5!3|4SbtdT+w{ z+1qXhOP`)|r}{~^g}fK)Ssq+?(z#`hWXoR33iSYWQT0pZsrLp@k7Jj*Gb(PxHH0j@ zL-Hx;&HWaiQZ5(A#I3M=_OagK$&-!HM9D2!Na;@rNt?^2g^imx+T&GZ%{v>NcBeY& zU6~)V#`s~c-Z2OHXAZfxwMmRM6D9nWY?7->2T3GnYwl97JUDBw1WsgT+Utay9&T^; zP7ArBR+6rGSxnjA>!pu{)k?JmjG8Q7ciGlRsS265V{vg}YkiqMeTHiNpO}}jJ&f|u z4&uiFWY;ld_Idh#20^jnnnd3o)mD}IYwC*zc0Vk5p!bdqFpz28^7YN-0$1MR1MCx$ zuh+Inxo{LKZb&S>vA((}`Wm{W=8ub_5_Y0scSuYk`JNB)M3-By5B)+Xw+_0u)Cqi zP>Hyeakdo|yfP+ceh+0YoYG7yMT@ zkmQC{_<%RSL|Z$JDyqGl!}^;$Mebx^Buu0K89(MO9BQ~k{(`H?L#m16^~{Yr;U)lK zVJ}SIISj-HScX2f&*H{gKB5KbS3@e#MvU?wH429+WgKnul7oEm&Y0%X%FvN%A;qe~ zJjtAPFE~4P<3ybz557Kiv+7_w&+h$4Bh*u#B}eS1dOPS@AMCiY8~Mtx0B%A490;-JD8OSHAH-1DqJPp?c?=UFm} z#rd|Y@~Pivge~l5fX1pe+K##;PHy)a$`*$cPdYcx@wXYwHO$r}H41=-m!bSF1oI83 z+5OI`Obw00yvHs@l}$NMKTmOdZ(AG5TK2W-Olr?feSIeNgALz0lf5o`-F|q6uk;Ru zDbaNICRrq$;>QF0z=|6@AOQu8CE^9q7B}iZ8n=VcM8JrISTiCIm_P;L#&5!oO%@xD ziD1R=^vHbEQQQ$zl%}7c-!s+nNHT;vdwy1QO#B&JF)7@U<*J%rPAU44C*=1hKfkjr zD7xzT)R~?YRu*3@)772z9Kz)5k`{c_Oxh{E?Pl>HRrPX$U&-9%AqBJXNka5}8{pgN zZ{ifuo5go6m9$H_U$E1;tkbSRBeIP(X5`9PeMBP4MXzmfa2WJn_|{_8SP}^hZ+k4# zFHt37=hSjl-s1QnmDs2AImy=L*J@toym{(R)>j*PnZ9rEo4fUCr;rBIPqqP$AzEjy zCio9T>jk~qv~ER>0xAVwr>2s(r~Do3;5Y4R%a0RPqhFR~N!5_#R0 z9zes$dkadTd9$as=*~x>TFq9+9Q`~M`3Jn$sYi0)h^W}#fkw+k<5usGv7Ku|Z!?e5 zDSpLPt7;RK@>z4BwSLRF_$RxWr#5U=ZcC94oRV}~eKI|;fqs5ej6Swf;}=p+k1%<$ zxgf~KSnYD=aHDl2Z?+eN(c?w3YB(&6gVYElJcJ{I4dGxs9z@F+{uc36)NgI13npN# zuq5Kvhp%BWNjQgHs=Cr|B_y`$xRb66zMyfI%rI*2&$7ZubUec*Dfs^a(TF<|rPz4} zd#*Um-oN^@mPPFS7k4H8P+}FVY1g!0Ha|g`g)h^eb3t4+)WP@QNIX`|-q`%a`M%4v z=8|`_BR^fS9R45`d!aA0GxWWXu-+2aUx+sShQLRHjQzc6cs$7pYo3i2mcDW&6!pd^ z9;F;=ucH+fS1pRJ7RsqntvRT=QtV{>QgwYXI@ZhCGsVf(d5+6A2nQ9y0H`yGXpvt? zgIp120EGwW{Z=tVOH-C8Zr(q2$^|;9AI%;QU%Pg^WXyXfx>#l-)~QPwv#rszq)F+Y z)UxjF%SV-N>b;2SPpq-57+sXJuud(@xNbqd8fvx$TGKWGBeSBZZlw3GbRUz82W{r% zFO)AjykXX1k1ul-Qt^;^k51(sXV_BY(5h#COy7Co?A751Qi?8gtht_&G@`jJp}Hf+ zG_`)&rp>)V6^|-5Tyog(^-{ta6NBi}@Auu*8wo9q*3dG>RR#Bv!o8A3SNABSD%F%P z{44u`aFJ_b^VVLc`wB;uBW%QS(iJ?1wxi=xmR;|XdyaikjkR)4dX+8O^v0QSXyA1h zG3$O=;HET=Mz=M z>F%r3f=+~&)?dg;rByyqu=_MDwdL*JqrXrDa&Mif2*Q7*2r6qfPW=4@Y9AEL`sc%Q zX#J`2yAoHQFBI|RSjMii+u~n+G-OL&ALnasT&py>dp9Gbe127ibiZZj`t%}e#pVwQ za8^FiZT3UU)cf5j-M&?aw98V{#2$S8`)1bbhs&ZAY-Ls^8I-~m<-JSd9}i@^Zr>lz zER6~{a!%!`+5=lrOZj$6|FO}eK($+Q!ip2(kD-=XhcDW%J9x=Ba^L60TWq0*{-qmy zBErp21^XXtT`x}Eqv~(5d<4H1hwg2aH*C{As1$vMT2aUu>G)EA^U~zA2=d*({(QUL zZX3N(OTIt0QWbt1WIMtce){0B7~wXDZ!NNKqUq42o7WY7L zSn@)d+@}>4or{WNj~*xsshZWjk&klrfRr89gd= zbz|?rw^Lbzc_l3^%a`>BSN=jdNYY@2a$q9v_iu@s#Rj&B?z}s7DkDc+(>my%yn5rt>I zXXe+$Jk1t&&6FCCP;Tq3c!iU5%Wq*c{n6);rJ0~xm1$qDgqGddOk3$Dsht3;=lW@q zp&ldK)3lQ;>m_*U6NHfUiXqp|raYdlD;;seD3ZipIF^cu;)HIEOvm;4sd+ipZKCX~ z@3YMu>W)=#@Ks+Ui^*I%@`(iQh0M$$o%N*?EU8a^w_-X1OnV+LdeXJ0wrc497N?4{ zEi4^Ub7x0USl3uYR!`5@aS=zS?P0&b5XrR6#1KdPJ%&UJs-%dbc;BeZq|T}cOn_PP z+S03is?ob7K_~hAd;!6<=Fv1tqATD{O12@gVfX=c@Sy7yMm>xgK2_?izOZ z9`j6rP@;yAeoI5Tg}$speN(E{+U&O3TF3HPQtkA-gQx#i>Be zlR~_^WKCJx(%bSePSb3414jmK${BkLJhV*EU&L7+9+qFg$41yR2v|KLk9sA4lJv#s-5i8)^JHOeBJ%6(o)f7=Cm% z(}&~DhH(7E*Y~3uSpyu2n`vGkJ*78uYTo$Y9OTFnn`j(1lbg3v$6}GQ4g7ttA^9sH zoHdbp7Z^%8v?zSQuFWc~FD#BqXQlv!20pbLT)<6vkFi1qnrv>SWS& zS#F*TM4wh1hL<%`*+dKA?g(s%08h_03J#6!5DAO@{~*-=bsjv90ug}z2_!5;!BZd- zo`j+ju@ISpgv#eHg2E*tnaVAxWFRkS zC*feQLEgFqur3jV&3Gy@jsOusNdaIXC6{1?kHiH5DUtQLN<#nyG5}yv$eKhjh6{>7 zIHcs-53&*gdB7qNAz~w2LrOqgy&%i+2t@>2t}{ZRn|@}_{nj*%WjDj>PQ3%E)biTMqGP9Y(hX>TjARTVh_^^k&F8b=0I#iK*-KXSR&9NkQK3kUl5f7bb{Cg zaP-qjU=xB80R`wC#VwKgG~()-*N^agC&Vrgts>=*dpY861-_2!Cvp6bAzar9pyYme zKpZRgx&*#L@LVX+Ew=~psxbEx0*dc+fwH+hh#!Xe=Ly$61MUC&YlOftAuiqsXm@7+ z^ngkiH~l2|{-Fk=uL~x{&}rZX13WlJ7ygfIAQI!VO>>UZ&eIrM4(hx=zAM-f+9bNC1@idCNZ;(^b0-`FzK0mWCGYZ@|!jvne+=j z;QnUtVZojJr#3t={~3HR0l2yuZCDul(;6^=_){B^@blh?FnGmh%txRAH#(z@M4!=ko2)wvt2xIB(jaV4>#oWMh1#c!|VcbuHejFN`!!<1u O6~>~Jlnl*`Q2ztp_vgC+ literal 6255 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 J-_%y2{6EuH8O;Cy