From da6cd85d919edc83a7aa23f42ffaed4b18f1fe8b Mon Sep 17 00:00:00 2001 From: schtandard Date: Sun, 15 Mar 2026 18:04:13 +0100 Subject: [PATCH 1/7] Do not discard sign of tick value --- 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 e27d71974471..56e057ad160c 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1015,6 +1015,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 @@ -1030,7 +1031,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 10301f0319e3e9148bca2f373a344cbdb90cb13f Mon Sep 17 00:00:00 2001 From: schtandard Date: Mon, 16 Mar 2026 12:00:08 +0100 Subject: [PATCH 2/7] Improve some log ticker details - Use the same logic to calculate numticks in LogFormatter.set_locs() as later in LogLocator.tick_values(). - Do not hard-code n_request == 9 in minor tick check. - Correct a type in an explanatary comment (< instead of <=). - Actually calculate the largest possible stride, as outlined in the comments. - The check for lonely ticks at the end of LogLocator.tick_values() assumed that major ticks are not also minor ticks. Fix that logic. --- lib/matplotlib/ticker.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 56e057ad160c..dd20c7b8acce 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -987,11 +987,8 @@ def set_locs(self, locs=None): else: lmin = math.log(vmin, b) lmax = math.log(vmax, b) - # The nextafter call handles the case where vmin is exactly at a - # decade (e.g. there's one major tick between 1 and 5). - numticks = (math.floor(lmax) - - math.floor(math.nextafter(lmin, -math.inf))) - numdec = abs(lmax - lmin) + numdec = lmax - lmin + numticks = math.floor(lmax) - math.ceil(lmin) + 1 if numticks > self.minor_thresholds[0]: # Label only bases @@ -2437,7 +2434,7 @@ def tick_values(self, vmin, vmax): n_avail = emax - emin + 1 # Total number of decade ticks available. if isinstance(self._subs, str): - if n_avail >= 10 or b < 3: + if n_avail > n_request or b < 3: if self._subs == 'auto': return np.array([]) # no minor or major ticks else: @@ -2459,7 +2456,7 @@ def tick_values(self, vmin, vmax): # be drawn (e.g., with 9 decades ticks, no stride yields 7 # ticks). For a given value of the stride *s*, there are either # floor(n_avail/s) or ceil(n_avail/s) ticks depending on the - # offset. Pick the smallest stride such that floor(n_avail/s) < + # offset. Pick the smallest stride such that floor(n_avail/s) <= # n_request, i.e. n_avail/s < n_request+1, then re-set n_request # to ceil(...) if acceptable, else to floor(...) (which must then # equal the original n_request, i.e. n_request is kept unchanged). @@ -2490,7 +2487,7 @@ def tick_values(self, vmin, vmax): # n_avail/(n_request+1) < stride <= n_avail/n_request # One of these cases must have an integer solution (given the # choice of n_request above). - stride = (n_avail - 1) // (n_request - 1) + stride = math.ceil(n_avail / (n_request - 1)) - 1 if stride < n_avail / n_request: # fallback to second case stride = n_avail // n_request # *Determine the offset*: For a given stride *and offset* @@ -2521,17 +2518,18 @@ def tick_values(self, vmin, vmax): else: ticklocs = b ** np.array(decades) - if (len(subs) > 1 - and stride == 1 - and (len(decades) - 2 # major - + ((vmin <= ticklocs) & (ticklocs <= vmax)).sum()) # minor - <= 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 major or minor tick, switch to AutoLocator. - return AutoLocator().tick_values(vmin, vmax) - else: - return self.raise_if_exceeds(ticklocs) + if is_minor and stride == 1: + numticks = ((vmin <= ticklocs) & (ticklocs <= vmax)).sum() + if subs[0] != 1.0: + # Major ticks are excluded from minor ticks. + numticks += n_avail + if numticks <= 1: + # If we're a minor locator (expecting at least two ticks per + # decade) and the major locator stride is 1 and there's no more + # than one major or minor tick, switch to AutoLocator. + return AutoLocator().tick_values(vmin, vmax) + + return self.raise_if_exceeds(ticklocs) def view_limits(self, vmin, vmax): """Try to choose the view limits intelligently.""" From 647e335472b7fa5177e7f0d7637e06c5c8010ac1 Mon Sep 17 00:00:00 2001 From: schtandard Date: Mon, 16 Mar 2026 12:07:48 +0100 Subject: [PATCH 3/7] Improve symlog ticker The new SymmetricalLogLocator emulates the behavior of LogLocator in the logarithmic region and extends its behavior to the linear part in a (hopefully) reasonable manner. The change is hidden behind a new rcParam (and corresponding class parameters) for now. --- lib/matplotlib/mpl-data/matplotlibrc | 3 + lib/matplotlib/rcsetup.py | 8 + lib/matplotlib/scale.py | 6 +- lib/matplotlib/scale.pyi | 2 +- lib/matplotlib/ticker.py | 509 ++++++++++++++++++++++----- lib/matplotlib/ticker.pyi | 29 +- lib/matplotlib/typing.py | 1 + 7 files changed, 463 insertions(+), 95 deletions(-) diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 17705fe60347..da06d400d272 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -395,6 +395,9 @@ # - above patches but below lines ('line') # - above all (False) +#axes.locator.legacy_symlog_ticker: True # Use legacy tick placement algorithm + # for symlog axes. May cause bad tick + # placement in some cases. #axes.formatter.limits: -5, 6 # use scientific notation if log10 # of the axis range is smaller than the # first or larger than the second diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 5cd42750d27f..fcb015efa5e5 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1146,6 +1146,7 @@ def _convert_validator_spec(key, conv): "axes.labelpad": validate_float, # space between label and axis "axes.labelweight": validate_fontweight, # fontsize of x & y labels "axes.labelcolor": validate_color, # color of axis label + "axes.locator.legacy_symlog_ticker": validate_bool, # use scientific notation if log10 of the axis range is smaller than the # first or larger than the second "axes.formatter.limits": validate_intlist, @@ -1972,6 +1973,13 @@ class _Param: "- above patches but below lines ('line') " "- above all (False)" ), + _Param( + "axes.locator.legacy_symlog_ticker", + default=True, + validator=validate_bool, + description="When True, ticks in symlog axes are placed using legacy rules. " + "This is known to cause badly labeled axes in some cases." + ), _Param( "axes.formatter.limits", default=[-5, 6], diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index f6ccc42442d6..eadc6bdab3b3 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -539,7 +539,7 @@ class SymmetricalLogScale(ScaleBase): name = 'symlog' @_make_axis_parameter_optional - def __init__(self, axis=None, *, base=10, linthresh=2, subs=None, linscale=1): + def __init__(self, axis=None, *, base=10, linthresh=2, subs='auto', linscale=1): self._transform = SymmetricalLogTransform(base, linthresh, linscale) self.subs = subs @@ -553,7 +553,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.""" diff --git a/lib/matplotlib/scale.pyi b/lib/matplotlib/scale.pyi index ba9f269b8c78..91af8bc73398 100644 --- a/lib/matplotlib/scale.pyi +++ b/lib/matplotlib/scale.pyi @@ -112,7 +112,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.py b/lib/matplotlib/ticker.py index dd20c7b8acce..74d057d8bad4 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -845,6 +845,100 @@ 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 _log_b(self, x): + # Use specialized logs if possible, as they can be more accurate; e.g. + # log(.001) / log(10) = -2.999... (whether math.log or np.log) due to + # floating point error. + return (np.log10(x) if self.base == 10 else + np.log2(x) if self.base == 2 else + np.log(x) / np.log(self.base)) + + 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 + self._log_b(val) + 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(self._log_b(self.unpos(0.5))) + 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 = self._log_b(val) - 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, firstexp + val - 1) + else: + val *= firstpow + return sign * val + + class LogFormatter(Formatter): """ Base class for formatting ticks on a log or symlog scale. @@ -876,9 +970,13 @@ class LogFormatter(Formatter): usual ``base=10``, all minor ticks are shown only if the axis limit range spans less than 0.4 decades. - 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. + + legacy_symlog_ticker : bool, default: :rc:`axes.locator.legacy_symlog_ticker` + Whether to use the legacy tick placement algorithm for symlog axes, + which is known to cause bad tick placement in some cases. Notes ----- @@ -905,7 +1003,8 @@ class LogFormatter(Formatter): def __init__(self, base=10.0, labelOnlyBase=False, minor_thresholds=None, - linthresh=None): + linthresh=None, linscale=None, *, + legacy_symlog_ticker=None): self.set_base(base) self.set_label_minor(labelOnlyBase) @@ -917,6 +1016,11 @@ 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 + self._legacy_symlog_ticker = mpl._val_or_rc( + legacy_symlog_ticker, 'axes.locator.legacy_symlog_ticker') def set_base(self, base): """ @@ -938,6 +1042,22 @@ 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 + try: + self._symlogutil = _SymmetricalLogUtil(self.axis.get_transform()) + return True + except AttributeError: + pass + return False + def set_locs(self, locs=None): """ Use axis view limits to control which ticks are labeled. @@ -948,19 +1068,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. @@ -969,21 +1081,27 @@ def set_locs(self, locs=None): b = self._base - if linthresh is not None: # symlog - # Only count ticks and decades in the logarithmic part of the axis. - numdec = numticks = 0 - if vmin < -linthresh: - rhs = min(vmax, -linthresh) - numticks += ( - math.floor(math.log(abs(rhs), b)) - - math.floor(math.nextafter(math.log(abs(vmin), b), -math.inf))) - numdec += math.log(vmin / rhs, b) - if vmax > linthresh: - lhs = max(vmin, linthresh) - numticks += ( - math.floor(math.log(vmax, b)) - - math.floor(math.nextafter(math.log(lhs, b), -math.inf))) - numdec += math.log(vmax / lhs, b) + if self._symlog: + if self._legacy_symlog_ticker: + # Only count ticks and decades in the logarithmic part of the axis. + numdec = numticks = 0 + if vmin < -self._symlogutil.linthresh: + rhs = min(vmax, -self._symlogutil.linthresh) + numticks += ( + math.floor(math.log(abs(rhs), b)) + - math.floor(math.nextafter(math.log(abs(vmin), b), -math.inf))) + numdec += math.log(vmin / rhs, b) + if vmax > self._symlogutil.linthresh: + lhs = max(vmin, self._symlogutil.linthresh) + numticks += ( + math.floor(math.log(vmax, b)) + - math.floor(math.nextafter(math.log(lhs, b), -math.inf))) + numdec += math.log(vmax / lhs, b) + else: + minrdec = self._symlogutil.dec(vmin) + maxrdec = self._symlogutil.dec(vmax) + numdec = maxrdec - minrdec + numticks = np.floor(maxrdec) - np.ceil(minrdec) + 1 else: lmin = math.log(vmin, b) lmax = math.log(vmax, b) @@ -993,6 +1111,8 @@ def set_locs(self, locs=None): if numticks > 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 @@ -1000,9 +1120,25 @@ 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(0, b, int(b)//2 + 1) + 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)) + + 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): return self._pprint_val(x, vmax - vmin) if 1 <= x <= 10000 else f"{x:1.0e}" @@ -1021,10 +1157,21 @@ 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 '' + if self._symlog and not self._legacy_symlog_ticker: + _, firstpow = self._symlogutil.firstdec() + below_firstpow = x < firstpow + else: + below_firstpow = False + if below_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) @@ -1101,10 +1248,21 @@ 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 '' + if self._symlog and not self._legacy_symlog_ticker: + _, firstpow = self._symlogutil.firstdec() + below_firstpow = x < firstpow + else: + below_firstpow = False + if below_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) @@ -2571,55 +2729,244 @@ class SymmetricalLogLocator(Locator): Place ticks spaced linearly near zero and spaced logarithmically beyond a threshold. """ - def __init__(self, transform=None, subs=None, linthresh=None, base=None): + def __init__(self, transform=None, subs=None, numticks=None, + linthresh=None, base=None, linscale=None, *, + legacy_symlog_ticker=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]``. + If set, defines *base*, *lintresh* 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. + subs : None, 'auto', 'all' or sequence of float, default: None + 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. + + legacy_symlog_ticker : bool, default: :rc:`axes.locator.legacy_symlog_ticker` + Whether to use the legacy tick placement algorithm for symlog axes, + which is known to cause bad tick placement in some cases. Notes ----- - Either *transform*, or both *base* and *linthresh*, must be given. + Either *transform*, or all of *base*, *linthresh* and *linscale* must be given. """ - if transform is not None: - self._base = transform.base - self._linthresh = transform.linthresh - elif linthresh is not None and base is not None: - self._base = base - self._linthresh = linthresh - else: - raise ValueError("Either transform, or both linthresh " - "and base, must be provided.") - if subs is None: - self._subs = [1.0] - else: - self._subs = subs - self.numticks = 15 + self._symlogutil = _SymmetricalLogUtil(transform, base, linthresh, linscale) + self._set_subs(subs) + if numticks is None: + if mpl.rcParams['_internal.classic_mode']: + numticks = 15 + else: + numticks = 'auto' + self.numticks = numticks + self._legacy_symlog_ticker = mpl._val_or_rc( + legacy_symlog_ticker, 'axes.locator.legacy_symlog_ticker') - 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 numticks is not None: self.numticks = numticks if subs is not None: + self._set_subs(subs) + if base is not None: + self._symlogutil.base = float(base) + if linthresh is not None: + self._symlogutil.linthresh = float(linthresh) + if linscale is not None: + self._symlogutil.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: + 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._legacy_symlog_ticker: + return self._tick_values_legacy(vmin, vmax) + + n_request = ( + self.numticks if self.numticks != 'auto' else + np.clip(self.axis.get_tick_space(), 2, 9) if self.axis is not None else + 9) + + if vmax < vmin: + vmin, vmax = vmax, vmin + + haszero = vmin <= 0 <= vmax + minrdec = self._symlogutil.dec(vmin) + maxrdec = self._symlogutil.dec(vmax) + mindec = math.ceil(minrdec) + maxdec = math.floor(maxrdec) + # Number of decade ticks available. + n_avail = maxdec - mindec + 1 + + # Calculate the subs immediately, as we may be able to return early. + if isinstance(self._subs, str): + # Either 'auto' or 'all'. + if n_avail > n_request: + # No minor ticks. + if self._subs == 'auto': + # No major ticks either. + return np.array([]) + else: + subs = np.array([1.0]) + else: + _first = 2.0 if self._subs == 'auto' else 1.0 + subs = np.arange(_first, self._symlogutil.base) + else: + subs = self._subs + + # Get decades between major ticks. + # We follow the same logic as LogLocator (see there for more + # extensive comments), except when 0 is in the axis range. + if mpl.rcParams['_internal.classic_mode']: + stride = max(math.ceil((n_avail - 1) / (n_request - 1)), 1) + decades = np.arange(mindec - stride, maxdec + stride + 1, stride) + else: + # Calculate the minimum possible stride. + stride = n_avail // (n_request + 1) + 1 + # If n_request is impossible, update it to the + # largest possible value. + nr = math.ceil(n_avail / stride) + if nr <= n_request: + n_request = nr + else: + assert nr == n_request + 1 + if n_request == 0: + # No ticks requested or available. + decades = [mindec - 1, maxdec + 1] + if haszero: + stride = np.max(np.abs(decades)) + decades = [-stride, 0, stride] + else: + stride = decades[1] - decades[0] + elif n_request == 1: + # A single tick. + if haszero: + mid = 0 + else: + mid = round((minrdec + maxrdec) / 2) + stride = max(mid - (mindec - 1), (maxdec + 1) - mid) + decades = [mid - stride, mid, mid + stride] + else: + # Calculate the largest possible stride + # resulting in n_request ticks while ticking 0. + stride = math.ceil(n_avail / (n_request - 1)) - 1 + if stride < n_avail / n_request: + stride = n_avail // n_request + # Determine the offset. + offset = (-mindec) % stride + olo = max(n_avail - stride * n_request, 0) + ohi = min(n_avail - stride * (n_request - 1), stride) + if not olo <= offset < ohi: + if haszero: + # We force the offset and instead check if we + # need to reduce stride to get n_request ticks. + # We need the largest stride that will cause an + # additional tick to appear on either side. + # First, calculate the current number of ticks + # on each side. We already know mindec < 0 < maxdec. + posnum = maxdec // stride + negnum = -mindec // stride + # Now calculate the necessary new stride. + posnewstride = maxdec // (posnum + 1) + negnewstride = -mindec // (negnum + 1) + newstride = max(posnewstride, negnewstride) + if n_request == maxdec // newstride - mindec // newstride + 1: + # The new value works out. + stride = newstride + offset = (-mindec) % newstride + else: + offset = olo + decades = np.arange(mindec + offset - stride, + maxdec + stride + 1, + stride) + + # Guess whether we're a minor locator, based on whether subs include + # anything other than 1. + is_minor = len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0) + if is_minor: + # Minor locator. + if stride == 1: + ticklocs = [] + for dec in decades: + if dec > 0: + ticklocs.append(subs * self._symlogutil.undec(dec)) + elif dec < 0: + ticklocs.append(np.flip(subs * self._symlogutil.undec(dec))) + 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: + # Add the otherwise missing minor tick. + zeroticks = np.concatenate(([zeropow], zeroticks)) + ticklocs.append(np.flip(-zeroticks)) + if subs[0] == 1.0: + # Only add a 0 tick if major ticks are not excluded. + ticklocs.append([0.0]) + ticklocs.append(zeroticks) + ticklocs = np.concatenate(ticklocs) + else: + ticklocs = np.array([]) + else: + # Major locator. + ticklocs = np.array([self._symlogutil.undec(dec) for dec in decades]) + + if is_minor and stride == 1: + numticks = ((vmin <= ticklocs) & (ticklocs <= vmax)).sum() + if subs[0] != 1.0: + # Major ticks are excluded from minor ticks. + numticks += n_avail + if numticks <= 1: + # If we're a minor locator (expecting at least two ticks per + # decade) and the major locator stride is 1 and there's no more + # than one major or minor tick, switch to AutoLocator. + return AutoLocator().tick_values(vmin, vmax) + + return self.raise_if_exceeds(ticklocs) + + def _tick_values_legacy(self, vmin, vmax): + if self.numticks == 'auto': + numticks = 15 + else: + numticks = self.numticks + base = self._symlogutil.base + linthresh = self._symlogutil.linthresh if vmax < vmin: vmin, vmax = vmax, vmin @@ -2632,7 +2979,7 @@ def tick_values(self, vmin, vmax): # # 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. + # than 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 @@ -2655,8 +3002,6 @@ def tick_values(self, vmin, vmax): # 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)) @@ -2677,7 +3022,7 @@ def get_log_range(lo, hi): total_ticks = (a_hi - a_lo) + (c_hi - c_lo) if has_b: total_ticks += 1 - stride = max(total_ticks // (self.numticks - 1), 1) + stride = max(total_ticks // (numticks - 1), 1) decades = [] if has_a: @@ -2690,34 +3035,18 @@ def get_log_range(lo, hi): 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) - else: - ticklocs.extend(subs * decade) - else: - ticklocs = decades + # The legacy locator did not use minor ticks, so we don't support them. + ticklocs = decades return self.raise_if_exceeds(np.array(ticklocs)) def view_limits(self, vmin, vmax): """Try to choose the view limits intelligently.""" - b = self._base - if vmax < vmin: - vmin, vmax = vmax, vmin - + vmin, vmax = self.nonsingular(vmin, vmax) 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) - - return mtransforms._nonsingular(vmin, 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): diff --git a/lib/matplotlib/ticker.pyi b/lib/matplotlib/ticker.pyi index bed288658909..ee56c5b6d1d9 100644 --- a/lib/matplotlib/ticker.pyi +++ b/lib/matplotlib/ticker.pyi @@ -97,6 +97,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__( @@ -105,6 +119,9 @@ class LogFormatter(Formatter): labelOnlyBase: bool = ..., minor_thresholds: tuple[float, float] | None = ..., linthresh: float | None = ..., + linscale: float | None = ..., + *, + legacy_symlog_ticker: bool | None = ..., ) -> None: ... def set_base(self, base: float) -> None: ... labelOnlyBase: bool @@ -249,12 +266,20 @@ class SymmetricalLogLocator(Locator): def __init__( self, transform: Transform | None = ..., - subs: Sequence[float] | None = ..., + subs: Sequence[float] | Literal["auto", "all"] | None = ..., linthresh: float | None = ..., base: float | None = ..., + linscale: float | None = ..., + *, + legacy_symlog_ticker: bool | None = ..., ) -> None: ... def set_params( - self, subs: Sequence[float] | None = ..., numticks: int | None = ... + self, + subs: Sequence[float] | Literal["auto", "all"] | None = ..., + numticks: int | None = ..., + base: float | None = ..., + linthresh: float | None = ..., + linscale: float | None = ..., ) -> None: ... class AsinhLocator(Locator): diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index d2e12c6e08d9..913c5998e27e 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -187,6 +187,7 @@ "axes.axisbelow", "axes.edgecolor", "axes.facecolor", + "axes.locator.legacy_symlog_ticker", "axes.formatter.limits", "axes.formatter.min_exponent", "axes.formatter.offset_threshold", From c111c4d8ea8bd43a857aa2813eb6b3397696186a Mon Sep 17 00:00:00 2001 From: schtandard Date: Mon, 16 Mar 2026 14:02:25 +0100 Subject: [PATCH 4/7] Update symlog tests --- .../test_axes/symlog2_nolegacy.pdf | Bin 0 -> 10151 bytes .../test_axes/symlog_nolegacy.pdf | Bin 0 -> 7539 bytes lib/matplotlib/tests/test_axes.py | 36 ++++++++-- lib/matplotlib/tests/test_ticker.py | 67 ++++++++++++++++-- 4 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 lib/matplotlib/tests/baseline_images/test_axes/symlog2_nolegacy.pdf create mode 100644 lib/matplotlib/tests/baseline_images/test_axes/symlog_nolegacy.pdf diff --git a/lib/matplotlib/tests/baseline_images/test_axes/symlog2_nolegacy.pdf b/lib/matplotlib/tests/baseline_images/test_axes/symlog2_nolegacy.pdf new file mode 100644 index 0000000000000000000000000000000000000000..36fb9b1a144f7f83ea1102169a142da599cb5496 GIT binary patch literal 10151 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 O6~>~Jlnl*`Q2ztp_vgC+ literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 88bc9932de07..44726899d66c 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -1341,11 +1341,9 @@ def test_fill_between_interpolate_nan(): interpolate=True, alpha=0.5) -# test_symlog and test_symlog2 used to have baseline images in all three -# formats, but the png and svg baselines got invalidated by the removal of -# minor tick overstriking. -@image_comparison(['symlog.pdf']) +@image_comparison(['symlog_nolegacy.pdf']) def test_symlog(): + mpl.rcParams['axes.locator.legacy_symlog_ticker'] = False x = np.array([0, 1, 2, 4, 6, 9, 12, 24]) y = np.array([1000000, 500000, 100000, 100, 5, 0, 0, 0]) @@ -1356,8 +1354,36 @@ def test_symlog(): ax.set_ylim(-1, 10000000) -@image_comparison(['symlog2.pdf'], remove_text=True) +@image_comparison(['symlog2_nolegacy.pdf'], remove_text=True) def test_symlog2(): + mpl.rcParams['axes.locator.legacy_symlog_ticker'] = False + # Numbers from -50 to 50, with 0.1 as step + x = np.arange(-50, 50, 0.001) + + fig, axs = plt.subplots(5, 1) + for ax, linthresh in zip(axs, [20., 2., 1., 0.1, 0.01]): + ax.plot(x, x) + ax.set_xscale('symlog', linthresh=linthresh) + ax.grid(True) + axs[-1].set_ylim(-0.1, 0.1) + + +@image_comparison(['symlog.pdf']) +def test_legacy_symlog(): + mpl.rcParams['axes.locator.legacy_symlog_ticker'] = True + x = np.array([0, 1, 2, 4, 6, 9, 12, 24]) + y = np.array([1000000, 500000, 100000, 100, 5, 0, 0, 0]) + + fig, ax = plt.subplots() + ax.plot(x, y) + ax.set_yscale('symlog') + ax.set_xscale('linear') + ax.set_ylim(-1, 10000000) + + +@image_comparison(['symlog2.pdf'], remove_text=True) +def test_legacy_symlog2(): + mpl.rcParams['axes.locator.legacy_symlog_ticker'] = True # Numbers from -50 to 50, with 0.1 as step x = np.arange(-50, 50, 0.001) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index a9104cc1b839..404b5e54865f 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -608,12 +608,62 @@ 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, + legacy_symlog_ticker=False) + sym.set_params(subs=[2.0], numticks=8) + assert sym._subs == [2.0] + assert sym.numticks == 8 + + @pytest.mark.parametrize( + 'vmin, vmax, expected', + [ + (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, linscale=1, + legacy_symlog_ticker=False) + ticks = sym.tick_values(vmin=vmin, vmax=vmax) + assert_array_equal(ticks, expected) + + def test_subs(self): + sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, linscale=1, + subs=[2.0, 4.0], legacy_symlog_ticker=False) + sym.create_dummy_axis() + sym.axis.set_view_interval(-10, 10) + assert_array_equal(sym(), [-400, -200, -40, -20, -4, -2, -0.4, -0.2, -0.1, + 0.1, 0.2, 0.4, 2, 4, 20, 40, 200, 400]) + + def test_extending(self): + sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, linscale=1, + legacy_symlog_ticker=False) + sym.create_dummy_axis() + sym.axis.set_view_interval(8, 9) + assert_array_equal(sym(), [1, 10]) + sym.axis.set_view_interval(8, 12) + assert_array_equal(sym(), [1, 10, 100]) + assert sym.view_limits(10, 10) == (1, 100) + assert sym.view_limits(-10, -10) == (-100, -1) + assert sym.view_limits(0, 0) == (-1, 1) + + +class TestLegacySymmetricalLogLocator: + def test_set_params(self): + """ + 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, linscale=1, + legacy_symlog_ticker=True) sym.set_params(subs=[2.0], numticks=8) assert sym._subs == [2.0] assert sym.numticks == 8 @@ -627,18 +677,21 @@ def test_set_params(self): ) 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, + legacy_symlog_ticker=True) 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], legacy_symlog_ticker=True) sym.create_dummy_axis() sym.axis.set_view_interval(-10, 10) - assert_array_equal(sym(), [-20, -40, -2, -4, 0, 2, 4, 20, 40]) + assert_array_equal(sym(), [-10, -1, 0, 1, 10]) def test_extending(self): - sym = mticker.SymmetricalLogLocator(base=10, linthresh=1) + sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, linscale=1, + legacy_symlog_ticker=True) sym.create_dummy_axis() sym.axis.set_view_interval(8, 9) assert (sym() == [1.0]).all() @@ -646,7 +699,7 @@ def test_extending(self): 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) + assert sym.view_limits(0, 0) == (-1, 1) class TestAsinhLocator: From e2780e10762e07d2d54d9430c8c6a5c382272094 Mon Sep 17 00:00:00 2001 From: schtandard Date: Mon, 16 Mar 2026 14:42:59 +0100 Subject: [PATCH 5/7] Rename new rcParam --- lib/matplotlib/mpl-data/matplotlibrc | 6 +++--- lib/matplotlib/rcsetup.py | 16 ++++++++-------- lib/matplotlib/tests/test_axes.py | 8 ++++---- lib/matplotlib/ticker.py | 8 ++++---- lib/matplotlib/typing.py | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index da06d400d272..7e01209a1275 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -395,9 +395,6 @@ # - above patches but below lines ('line') # - above all (False) -#axes.locator.legacy_symlog_ticker: True # Use legacy tick placement algorithm - # for symlog axes. May cause bad tick - # placement in some cases. #axes.formatter.limits: -5, 6 # use scientific notation if log10 # of the axis range is smaller than the # first or larger than the second @@ -417,6 +414,9 @@ # will be used when it can remove # at least this number of significant # digits from tick labels. +#axes.formatter.legacy_symlog_ticker: True # Use legacy tick placement algorithm + # for symlog axes. May cause bad tick + # placement in some cases. #axes.spines.left: True # display axis spines #axes.spines.bottom: True diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index fcb015efa5e5..c3ad4709706e 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1146,7 +1146,6 @@ def _convert_validator_spec(key, conv): "axes.labelpad": validate_float, # space between label and axis "axes.labelweight": validate_fontweight, # fontsize of x & y labels "axes.labelcolor": validate_color, # color of axis label - "axes.locator.legacy_symlog_ticker": validate_bool, # use scientific notation if log10 of the axis range is smaller than the # first or larger than the second "axes.formatter.limits": validate_intlist, @@ -1157,6 +1156,7 @@ def _convert_validator_spec(key, conv): "axes.formatter.min_exponent": validate_int, "axes.formatter.useoffset": validate_bool, "axes.formatter.offset_threshold": validate_int, + "axes.formatter.legacy_symlog_ticker": validate_bool, "axes.unicode_minus": validate_bool, # This entry can be either a cycler object or a string repr of a # cycler-object, which gets eval()'ed to create the object. @@ -1973,13 +1973,6 @@ class _Param: "- above patches but below lines ('line') " "- above all (False)" ), - _Param( - "axes.locator.legacy_symlog_ticker", - default=True, - validator=validate_bool, - description="When True, ticks in symlog axes are placed using legacy rules. " - "This is known to cause badly labeled axes in some cases." - ), _Param( "axes.formatter.limits", default=[-5, 6], @@ -2022,6 +2015,13 @@ class _Param: "remove at least this number of significant digits from tick " "labels." ), + _Param( + "axes.formatter.legacy_symlog_ticker", + default=True, + validator=validate_bool, + description="When True, ticks in symlog axes are placed using legacy rules. " + "This is known to cause badly labeled axes in some cases." + ), _Param( "axes.spines.left", default=True, diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 44726899d66c..64f5f26250a6 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -1343,7 +1343,7 @@ def test_fill_between_interpolate_nan(): @image_comparison(['symlog_nolegacy.pdf']) def test_symlog(): - mpl.rcParams['axes.locator.legacy_symlog_ticker'] = False + mpl.rcParams['axes.formatter.legacy_symlog_ticker'] = False x = np.array([0, 1, 2, 4, 6, 9, 12, 24]) y = np.array([1000000, 500000, 100000, 100, 5, 0, 0, 0]) @@ -1356,7 +1356,7 @@ def test_symlog(): @image_comparison(['symlog2_nolegacy.pdf'], remove_text=True) def test_symlog2(): - mpl.rcParams['axes.locator.legacy_symlog_ticker'] = False + mpl.rcParams['axes.formatter.legacy_symlog_ticker'] = False # Numbers from -50 to 50, with 0.1 as step x = np.arange(-50, 50, 0.001) @@ -1370,7 +1370,7 @@ def test_symlog2(): @image_comparison(['symlog.pdf']) def test_legacy_symlog(): - mpl.rcParams['axes.locator.legacy_symlog_ticker'] = True + mpl.rcParams['axes.formatter.legacy_symlog_ticker'] = True x = np.array([0, 1, 2, 4, 6, 9, 12, 24]) y = np.array([1000000, 500000, 100000, 100, 5, 0, 0, 0]) @@ -1383,7 +1383,7 @@ def test_legacy_symlog(): @image_comparison(['symlog2.pdf'], remove_text=True) def test_legacy_symlog2(): - mpl.rcParams['axes.locator.legacy_symlog_ticker'] = True + mpl.rcParams['axes.formatter.legacy_symlog_ticker'] = True # Numbers from -50 to 50, with 0.1 as step x = np.arange(-50, 50, 0.001) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 74d057d8bad4..822b1ee48fcb 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -974,7 +974,7 @@ class LogFormatter(Formatter): If a symmetric log scale is in use, its ``linthresh`` and ``linscale`` parameters must be supplied here. - legacy_symlog_ticker : bool, default: :rc:`axes.locator.legacy_symlog_ticker` + legacy_symlog_ticker : bool, default: :rc:`axes.formatter.legacy_symlog_ticker` Whether to use the legacy tick placement algorithm for symlog axes, which is known to cause bad tick placement in some cases. @@ -1020,7 +1020,7 @@ def __init__(self, base=10.0, labelOnlyBase=False, self._symlogutil = None self._firstsublabels = None self._legacy_symlog_ticker = mpl._val_or_rc( - legacy_symlog_ticker, 'axes.locator.legacy_symlog_ticker') + legacy_symlog_ticker, 'axes.formatter.legacy_symlog_ticker') def set_base(self, base): """ @@ -2755,7 +2755,7 @@ def __init__(self, transform=None, subs=None, numticks=None, already been assigned to an axis using `~.axis.Axis.get_tick_space`, but otherwise falls back to 9. - legacy_symlog_ticker : bool, default: :rc:`axes.locator.legacy_symlog_ticker` + legacy_symlog_ticker : bool, default: :rc:`axes.formatter.legacy_symlog_ticker` Whether to use the legacy tick placement algorithm for symlog axes, which is known to cause bad tick placement in some cases. @@ -2772,7 +2772,7 @@ def __init__(self, transform=None, subs=None, numticks=None, numticks = 'auto' self.numticks = numticks self._legacy_symlog_ticker = mpl._val_or_rc( - legacy_symlog_ticker, 'axes.locator.legacy_symlog_ticker') + legacy_symlog_ticker, 'axes.formatter.legacy_symlog_ticker') def set_params(self, subs=None, numticks=None, base=None, linthresh=None, linscale=None): diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index 913c5998e27e..e6e175f0729d 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -187,13 +187,13 @@ "axes.axisbelow", "axes.edgecolor", "axes.facecolor", - "axes.locator.legacy_symlog_ticker", "axes.formatter.limits", "axes.formatter.min_exponent", "axes.formatter.offset_threshold", "axes.formatter.use_locale", "axes.formatter.use_mathtext", "axes.formatter.useoffset", + "axes.formatter.legacy_symlog_ticker", "axes.grid", "axes.grid.axis", "axes.grid.which", From 11a3079cda2ee6a5811d245c8309f0de82f010c9 Mon Sep 17 00:00:00 2001 From: schtandard Date: Mon, 16 Mar 2026 15:04:27 +0100 Subject: [PATCH 6/7] Streamline subtick determination - For SymLogLocator, do not use a hard-coded limit preventing minor ticks. Instead, always use the same behavior. - For LogLocator, revert to the old behavior with the hard-coded limit of 10 available major ticks. --- lib/matplotlib/ticker.py | 46 +++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 822b1ee48fcb..d9e033d2e866 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2592,7 +2592,7 @@ def tick_values(self, vmin, vmax): n_avail = emax - emin + 1 # Total number of decade ticks available. if isinstance(self._subs, str): - if n_avail > n_request or b < 3: + if n_avail >= 10 or b < 3: if self._subs == 'auto': return np.array([]) # no minor or major ticks else: @@ -2834,22 +2834,6 @@ def tick_values(self, vmin, vmax): # Number of decade ticks available. n_avail = maxdec - mindec + 1 - # Calculate the subs immediately, as we may be able to return early. - if isinstance(self._subs, str): - # Either 'auto' or 'all'. - if n_avail > n_request: - # No minor ticks. - if self._subs == 'auto': - # No major ticks either. - return np.array([]) - else: - subs = np.array([1.0]) - else: - _first = 2.0 if self._subs == 'auto' else 1.0 - subs = np.arange(_first, self._symlogutil.base) - else: - subs = self._subs - # Get decades between major ticks. # We follow the same logic as LogLocator (see there for more # extensive comments), except when 0 is in the axis range. @@ -2916,6 +2900,13 @@ def tick_values(self, vmin, vmax): maxdec + stride + 1, stride) + if isinstance(self._subs, str): + # Either 'auto' or 'all'. + _first = 2.0 if self._subs == 'auto' else 1.0 + subs = np.arange(_first, self._symlogutil.base) + else: + subs = self._subs + # Guess whether we're a minor locator, based on whether subs include # anything other than 1. is_minor = len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0) @@ -3035,6 +3026,27 @@ def get_log_range(lo, hi): if has_c: decades.extend(base ** (np.arange(c_lo, c_hi, stride))) + # The legacy symlog ticker did not use minor ticks by default, + # but they could be obtained explicitly, so we still want to + # support them. However, string values were not supported then + # but are the default now, so we need to test for it. + if isinstance(self._subs, str): + # Either 'auto' or 'all'. + _first = 2.0 if self._subs == 'auto' else 1.0 + subs = np.arange(_first, self._symlogutil.base) + else: + 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) + else: + ticklocs.extend(subs * decade) + else: + ticklocs = decades + # The legacy locator did not use minor ticks, so we don't support them. ticklocs = decades From edf99f28174794e8e240f51b562eb65659b38801 Mon Sep 17 00:00:00 2001 From: schtandard Date: Mon, 16 Mar 2026 15:55:13 +0100 Subject: [PATCH 7/7] Fix missing stub parameter --- lib/matplotlib/ticker.pyi | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/matplotlib/ticker.pyi b/lib/matplotlib/ticker.pyi index ee56c5b6d1d9..bb3d4db2112b 100644 --- a/lib/matplotlib/ticker.pyi +++ b/lib/matplotlib/ticker.pyi @@ -267,6 +267,7 @@ class SymmetricalLogLocator(Locator): self, transform: Transform | None = ..., subs: Sequence[float] | Literal["auto", "all"] | None = ..., + numticks: int | None = ..., linthresh: float | None = ..., base: float | None = ..., linscale: float | None = ...,