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 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 O6~>~Jlnl*`Q2ztp_vgC+ literal 6255 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 J-_%y2{6EuH8O;Cy pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy