diff --git a/doc/users/next_whats_new/engformatter_offset.rst b/doc/users/next_whats_new/engformatter_offset.rst new file mode 100644 index 000000000000..c805e45444c5 --- /dev/null +++ b/doc/users/next_whats_new/engformatter_offset.rst @@ -0,0 +1,13 @@ +``matplotlib.ticker.EngFormatter`` can computes offsets now +----------------------------------------------------------- + +`matplotlib.ticker.EngFormatter` has gained the ability to show an offset text near the +axis. Using logic shared with `matplotlib.ticker.ScalarFormatter`, it is capable of +deciding whether the data qualifies having an offset and show it with an appropriate SI +quantity prefix, and with the supplied ``unit``. + +To enable this new behavior, simply pass ``useOffset=True`` when you +instantiate `matplotlib.ticker.EngFormatter`. See example +:doc:`/gallery/ticks/engformatter_offset`. + +.. plot:: gallery/ticks/engformatter_offset.py diff --git a/doc/users/next_whats_new/update_features.rst b/doc/users/next_whats_new/update_features.rst new file mode 100644 index 000000000000..a655a06b9e23 --- /dev/null +++ b/doc/users/next_whats_new/update_features.rst @@ -0,0 +1,4 @@ +Miscellaneous Changes +--------------------- + +- The `matplotlib.ticker.ScalarFormatter` class has gained a new instantiating parameter ``usetex``. diff --git a/galleries/examples/ticks/engformatter_offset.py b/galleries/examples/ticks/engformatter_offset.py new file mode 100644 index 000000000000..7da2d45a7942 --- /dev/null +++ b/galleries/examples/ticks/engformatter_offset.py @@ -0,0 +1,33 @@ +""" +=================================================== +SI prefixed offsets and natural order of magnitudes +=================================================== + +`matplotlib.ticker.EngFormatter` is capable of computing a natural +offset for your axis data, and presenting it with a standard SI prefix +automatically calculated. + +Below is an examples of such a plot: + +""" + +import matplotlib.pyplot as plt +import numpy as np + +import matplotlib.ticker as mticker + +# Fixing random state for reproducibility +np.random.seed(19680801) + +UNIT = "Hz" + +fig, ax = plt.subplots() +ax.yaxis.set_major_formatter(mticker.EngFormatter( + useOffset=True, + unit=UNIT +)) +size = 100 +measurement = np.full(size, 1e9) +noise = np.random.uniform(low=-2e3, high=2e3, size=size) +ax.plot(measurement + noise) +plt.show() diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 222a0d7e11b0..77c0e917df8a 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -1591,6 +1591,73 @@ def test_engformatter_usetex_useMathText(): assert x_tick_label_text == ['$0$', '$500$', '$1$ k'] +@pytest.mark.parametrize( + 'data_offset, noise, oom_center_desired, oom_noise_desired', [ + (271_490_000_000.0, 10, 9, 0), + (27_149_000_000_000.0, 10_000_000, 12, 6), + (27.149, 0.01, 0, -3), + (2_714.9, 0.01, 3, -3), + (271_490.0, 0.001, 3, -3), + (271.49, 0.001, 0, -3), + # The following sets of parameters demonstrates that when + # oom(data_offset)-1 and oom(noise)-2 equal a standard 3*N oom, we get + # that oom_noise_desired < oom(noise) + (27_149_000_000.0, 100, 9, +3), + (27.149, 1e-07, 0, -6), + (271.49, 0.0001, 0, -3), + (27.149, 0.0001, 0, -3), + # Tests where oom(data_offset) <= oom(noise), those are probably + # covered by the part where formatter.offset != 0 + (27_149.0, 10_000, 0, 3), + (27.149, 10_000, 0, 3), + (27.149, 1_000, 0, 3), + (27.149, 100, 0, 0), + (27.149, 10, 0, 0), + ] +) +def test_engformatter_offset_oom( + data_offset, + noise, + oom_center_desired, + oom_noise_desired +): + UNIT = "eV" + fig, ax = plt.subplots() + ydata = data_offset + np.arange(-5, 7, dtype=float)*noise + ax.plot(ydata) + formatter = mticker.EngFormatter(useOffset=True, unit=UNIT) + # So that offset strings will always have the same size + formatter.ENG_PREFIXES[0] = "_" + ax.yaxis.set_major_formatter(formatter) + fig.canvas.draw() + offset_got = formatter.get_offset() + ticks_got = [labl.get_text() for labl in ax.get_yticklabels()] + # Predicting whether offset should be 0 or not is essentially testing + # ScalarFormatter._compute_offset . This function is pretty complex and it + # would be nice to test it, but this is out of scope for this test which + # only makes sure that offset text and the ticks gets the correct unit + # prefixes and the ticks. + if formatter.offset: + prefix_noise_got = offset_got[2] + prefix_noise_desired = formatter.ENG_PREFIXES[oom_noise_desired] + prefix_center_got = offset_got[-1-len(UNIT)] + prefix_center_desired = formatter.ENG_PREFIXES[oom_center_desired] + assert prefix_noise_desired == prefix_noise_got + assert prefix_center_desired == prefix_center_got + # Make sure the ticks didn't get the UNIT + for tick in ticks_got: + assert UNIT not in tick + else: + assert oom_center_desired == 0 + assert offset_got == "" + # Make sure the ticks contain now the prefixes + for tick in ticks_got: + # 0 is zero on all orders of magnitudes, no matter what is + # oom_noise_desired + prefix_idx = 0 if tick[0] == "0" else oom_noise_desired + assert tick.endswith(formatter.ENG_PREFIXES[prefix_idx] + UNIT) + + class TestPercentFormatter: percent_data = [ # Check explicitly set decimals over different intervals and values diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 0053031ece3e..d98b48fafd0f 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -407,6 +407,11 @@ class ScalarFormatter(Formatter): useLocale : bool, default: :rc:`axes.formatter.use_locale`. Whether to use locale settings for decimal sign and positive sign. See `.set_useLocale`. + usetex : bool, default: :rc:`text.usetex` + To enable/disable the use of TeX's math mode for rendering the + numbers in the formatter. + + .. versionadded:: 3.10 Notes ----- @@ -444,13 +449,14 @@ class ScalarFormatter(Formatter): """ - def __init__(self, useOffset=None, useMathText=None, useLocale=None): + def __init__(self, useOffset=None, useMathText=None, useLocale=None, *, + usetex=None): if useOffset is None: useOffset = mpl.rcParams['axes.formatter.useoffset'] self._offset_threshold = \ mpl.rcParams['axes.formatter.offset_threshold'] self.set_useOffset(useOffset) - self._usetex = mpl.rcParams['text.usetex'] + self.set_usetex(usetex) self.set_useMathText(useMathText) self.orderOfMagnitude = 0 self.format = '' @@ -458,6 +464,14 @@ def __init__(self, useOffset=None, useMathText=None, useLocale=None): self._powerlimits = mpl.rcParams['axes.formatter.limits'] self.set_useLocale(useLocale) + def get_usetex(self): + return self._usetex + + def set_usetex(self, val): + self._usetex = mpl._val_or_rc(val, 'text.usetex') + + usetex = property(fget=get_usetex, fset=set_usetex) + def get_useOffset(self): """ Return whether automatic mode for offset notation is active. @@ -1324,7 +1338,7 @@ def format_data_short(self, value): return f"1-{1 - value:e}" -class EngFormatter(Formatter): +class EngFormatter(ScalarFormatter): """ Format axis values using engineering prefixes to represent powers of 1000, plus a specified unit, e.g., 10 MHz instead of 1e7. @@ -1356,7 +1370,7 @@ class EngFormatter(Formatter): } def __init__(self, unit="", places=None, sep=" ", *, usetex=None, - useMathText=None): + useMathText=None, useOffset=False): r""" Parameters ---------- @@ -1390,76 +1404,124 @@ def __init__(self, unit="", places=None, sep=" ", *, usetex=None, useMathText : bool, default: :rc:`axes.formatter.use_mathtext` To enable/disable the use mathtext for rendering the numbers in the formatter. + useOffset : bool or float, default: False + Whether to use offset notation with :math:`10^{3*N}` based prefixes. + This features allows showing an offset with standard SI order of + magnitude prefix near the axis. Offset is computed similarly to + how `ScalarFormatter` computes it internally, but here you are + guaranteed to get an offset which will make the tick labels exceed + 3 digits. See also `.set_useOffset`. + + .. versionadded:: 3.10 """ self.unit = unit self.places = places self.sep = sep - self.set_usetex(usetex) - self.set_useMathText(useMathText) - - def get_usetex(self): - return self._usetex - - def set_usetex(self, val): - if val is None: - self._usetex = mpl.rcParams['text.usetex'] - else: - self._usetex = val - - usetex = property(fget=get_usetex, fset=set_usetex) + super().__init__( + useOffset=useOffset, + useMathText=useMathText, + useLocale=False, + usetex=usetex, + ) - def get_useMathText(self): - return self._useMathText + def __call__(self, x, pos=None): + """ + Return the format for tick value *x* at position *pos*. - def set_useMathText(self, val): - if val is None: - self._useMathText = mpl.rcParams['axes.formatter.use_mathtext'] + If there is no currently offset in the data, it returns the best + engineering formatting that fits the given argument, independently. + """ + if len(self.locs) == 0 or self.offset == 0: + return self.fix_minus(self.format_data(x)) else: - self._useMathText = val + xp = (x - self.offset) / (10. ** self.orderOfMagnitude) + if abs(xp) < 1e-8: + xp = 0 + return self._format_maybe_minus_and_locale(self.format, xp) - useMathText = property(fget=get_useMathText, fset=set_useMathText) + def set_locs(self, locs): + # docstring inherited + self.locs = locs + if len(self.locs) > 0: + vmin, vmax = sorted(self.axis.get_view_interval()) + if self._useOffset: + self._compute_offset() + if self.offset != 0: + # We don't want to use the offset computed by + # self._compute_offset because it rounds the offset unaware + # of our engineering prefixes preference, and this can + # cause ticks with 4+ digits to appear. These ticks are + # slightly less readable, so if offset is justified + # (decided by self._compute_offset) we set it to better + # value: + self.offset = round((vmin + vmax)/2, 3) + # Use log1000 to use engineers' oom standards + self.orderOfMagnitude = math.floor(math.log(vmax - vmin, 1000))*3 + self._set_format() - def __call__(self, x, pos=None): - s = f"{self.format_eng(x)}{self.unit}" - # Remove the trailing separator when there is neither prefix nor unit - if self.sep and s.endswith(self.sep): - s = s[:-len(self.sep)] - return self.fix_minus(s) + # Simplify a bit ScalarFormatter.get_offset: We always want to use + # self.format_data. Also we want to return a non-empty string only if there + # is an offset, no matter what is self.orderOfMagnitude. If there _is_ an + # offset, self.orderOfMagnitude is consulted. This behavior is verified + # in `test_ticker.py`. + def get_offset(self): + # docstring inherited + if len(self.locs) == 0: + return '' + if self.offset: + offsetStr = '' + if self.offset: + offsetStr = self.format_data(self.offset) + if self.offset > 0: + offsetStr = '+' + offsetStr + sciNotStr = self.format_data(10 ** self.orderOfMagnitude) + if self._useMathText or self._usetex: + if sciNotStr != '': + sciNotStr = r'\times%s' % sciNotStr + s = f'${sciNotStr}{offsetStr}$' + else: + s = sciNotStr + offsetStr + return self.fix_minus(s) + return '' def format_eng(self, num): + """Alias to EngFormatter.format_data""" + return self.format_data(num) + + def format_data(self, value): """ Format a number in engineering notation, appending a letter representing the power of 1000 of the original number. Some examples: - >>> format_eng(0) # for self.places = 0 + >>> format_data(0) # for self.places = 0 '0' - >>> format_eng(1000000) # for self.places = 1 + >>> format_data(1000000) # for self.places = 1 '1.0 M' - >>> format_eng(-1e-6) # for self.places = 2 + >>> format_data(-1e-6) # for self.places = 2 '-1.00 \N{MICRO SIGN}' """ sign = 1 fmt = "g" if self.places is None else f".{self.places:d}f" - if num < 0: + if value < 0: sign = -1 - num = -num + value = -value - if num != 0: - pow10 = int(math.floor(math.log10(num) / 3) * 3) + if value != 0: + pow10 = int(math.floor(math.log10(value) / 3) * 3) else: pow10 = 0 - # Force num to zero, to avoid inconsistencies like + # Force value to zero, to avoid inconsistencies like # format_eng(-0) = "0" and format_eng(0.0) = "0" # but format_eng(-0.0) = "-0.0" - num = 0.0 + value = 0.0 pow10 = np.clip(pow10, min(self.ENG_PREFIXES), max(self.ENG_PREFIXES)) - mant = sign * num / (10.0 ** pow10) + mant = sign * value / (10.0 ** pow10) # Taking care of the cases like 999.9..., which may be rounded to 1000 # instead of 1 k. Beware of the corner case of values that are beyond # the range of SI prefixes (i.e. > 'Y'). @@ -1468,13 +1530,15 @@ def format_eng(self, num): mant /= 1000 pow10 += 3 - prefix = self.ENG_PREFIXES[int(pow10)] + unit_prefix = self.ENG_PREFIXES[int(pow10)] + if self.unit or unit_prefix: + suffix = f"{self.sep}{unit_prefix}{self.unit}" + else: + suffix = "" if self._usetex or self._useMathText: - formatted = f"${mant:{fmt}}${self.sep}{prefix}" + return f"${mant:{fmt}}${suffix}" else: - formatted = f"{mant:{fmt}}{self.sep}{prefix}" - - return formatted + return f"{mant:{fmt}}{suffix}" class PercentFormatter(Formatter): diff --git a/lib/matplotlib/ticker.pyi b/lib/matplotlib/ticker.pyi index fd8e41848671..f990bf53ca42 100644 --- a/lib/matplotlib/ticker.pyi +++ b/lib/matplotlib/ticker.pyi @@ -64,8 +64,16 @@ class ScalarFormatter(Formatter): useOffset: bool | float | None = ..., useMathText: bool | None = ..., useLocale: bool | None = ..., + *, + usetex: bool | None = ..., ) -> None: ... offset: float + def get_usetex(self) -> bool: ... + def set_usetex(self, val: bool) -> None: ... + @property + def usetex(self) -> bool: ... + @usetex.setter + def usetex(self, val: bool) -> None: ... def get_useOffset(self) -> bool: ... def set_useOffset(self, val: bool | float) -> None: ... @property @@ -125,7 +133,7 @@ class LogitFormatter(Formatter): def set_minor_number(self, minor_number: int) -> None: ... def format_data_short(self, value: float) -> str: ... -class EngFormatter(Formatter): +class EngFormatter(ScalarFormatter): ENG_PREFIXES: dict[int, str] unit: str places: int | None @@ -137,20 +145,9 @@ class EngFormatter(Formatter): sep: str = ..., *, usetex: bool | None = ..., - useMathText: bool | None = ... + useMathText: bool | None = ..., + useOffset: bool | float | None = ..., ) -> None: ... - def get_usetex(self) -> bool: ... - def set_usetex(self, val: bool | None) -> None: ... - @property - def usetex(self) -> bool: ... - @usetex.setter - def usetex(self, val: bool | None) -> None: ... - def get_useMathText(self) -> bool: ... - def set_useMathText(self, val: bool | None) -> None: ... - @property - def useMathText(self) -> bool: ... - @useMathText.setter - def useMathText(self, val: bool | None) -> None: ... def format_eng(self, num: float) -> str: ... class PercentFormatter(Formatter):