Thanks to visit codestin.com
Credit goes to github.com

Skip to content

ticker.EngFormatter: allow offset #28495

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Oct 30, 2024
13 changes: 13 additions & 0 deletions doc/users/next_whats_new/engformatter_offset.rst
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions doc/users/next_whats_new/update_features.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Miscellaneous Changes
---------------------

- The `matplotlib.ticker.ScalarFormatter` class has gained a new instantiating parameter ``usetex``.
33 changes: 33 additions & 0 deletions galleries/examples/ticks/engformatter_offset.py
Original file line number Diff line number Diff line change
@@ -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()
67 changes: 67 additions & 0 deletions lib/matplotlib/tests/test_ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
154 changes: 109 additions & 45 deletions lib/matplotlib/ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,11 @@
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
-----
Expand Down Expand Up @@ -444,20 +449,29 @@

"""

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 = ''
self._scientific = True
self._powerlimits = mpl.rcParams['axes.formatter.limits']
self.set_useLocale(useLocale)

def get_usetex(self):
return self._usetex

Check warning on line 468 in lib/matplotlib/ticker.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/ticker.py#L468

Added line #L468 was not covered by tests

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.
Expand Down Expand Up @@ -1324,7 +1338,7 @@
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.
Expand Down Expand Up @@ -1356,7 +1370,7 @@
}

def __init__(self, unit="", places=None, sep=" ", *, usetex=None,
useMathText=None):
useMathText=None, useOffset=False):
r"""
Parameters
----------
Expand Down Expand Up @@ -1390,76 +1404,124 @@
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 ''

Check warning on line 1470 in lib/matplotlib/ticker.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/ticker.py#L1470

Added line #L1470 was not covered by tests
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}$'

Check warning on line 1481 in lib/matplotlib/ticker.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/ticker.py#L1480-L1481

Added lines #L1480 - L1481 were not covered by tests
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)

Check warning on line 1489 in lib/matplotlib/ticker.py

View check run for this annotation

Codecov / codecov/patch

lib/matplotlib/ticker.py#L1489

Added line #L1489 was not covered by tests

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').
Expand All @@ -1468,13 +1530,15 @@
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):
Expand Down
Loading
Loading