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

Skip to content

Commit 61833b8

Browse files
authored
ticker.EngFormatter: allow offset (#28495)
* ticker.ScalarFormatter: allow changing usetex like in EngFormatter * ticker.EngFormatter: base upon ScalarFormatter Allows us to use many order of magnitude and offset related routines from ScalarFormatter, and removes a bit usetex related duplicated code. Solves #28463. * Fix small documentation issues from QuLogic's review * Small comment fixups from review * test_ticker.py: small cleanups after review * ticker.py: small cleanups after review * ticker.ScalarFormatter: Fix type hints & document new attributes * engformatter_offset.rst: fix title * engformatter related small fixes * test_engformatter_offset_oom: parametrize center & noise directly
1 parent 218a42b commit 61833b8

File tree

6 files changed

+237
-59
lines changed

6 files changed

+237
-59
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
``matplotlib.ticker.EngFormatter`` can computes offsets now
2+
-----------------------------------------------------------
3+
4+
`matplotlib.ticker.EngFormatter` has gained the ability to show an offset text near the
5+
axis. Using logic shared with `matplotlib.ticker.ScalarFormatter`, it is capable of
6+
deciding whether the data qualifies having an offset and show it with an appropriate SI
7+
quantity prefix, and with the supplied ``unit``.
8+
9+
To enable this new behavior, simply pass ``useOffset=True`` when you
10+
instantiate `matplotlib.ticker.EngFormatter`. See example
11+
:doc:`/gallery/ticks/engformatter_offset`.
12+
13+
.. plot:: gallery/ticks/engformatter_offset.py
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Miscellaneous Changes
2+
---------------------
3+
4+
- The `matplotlib.ticker.ScalarFormatter` class has gained a new instantiating parameter ``usetex``.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
===================================================
3+
SI prefixed offsets and natural order of magnitudes
4+
===================================================
5+
6+
`matplotlib.ticker.EngFormatter` is capable of computing a natural
7+
offset for your axis data, and presenting it with a standard SI prefix
8+
automatically calculated.
9+
10+
Below is an examples of such a plot:
11+
12+
"""
13+
14+
import matplotlib.pyplot as plt
15+
import numpy as np
16+
17+
import matplotlib.ticker as mticker
18+
19+
# Fixing random state for reproducibility
20+
np.random.seed(19680801)
21+
22+
UNIT = "Hz"
23+
24+
fig, ax = plt.subplots()
25+
ax.yaxis.set_major_formatter(mticker.EngFormatter(
26+
useOffset=True,
27+
unit=UNIT
28+
))
29+
size = 100
30+
measurement = np.full(size, 1e9)
31+
noise = np.random.uniform(low=-2e3, high=2e3, size=size)
32+
ax.plot(measurement + noise)
33+
plt.show()

lib/matplotlib/tests/test_ticker.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,6 +1591,73 @@ def test_engformatter_usetex_useMathText():
15911591
assert x_tick_label_text == ['$0$', '$500$', '$1$ k']
15921592

15931593

1594+
@pytest.mark.parametrize(
1595+
'data_offset, noise, oom_center_desired, oom_noise_desired', [
1596+
(271_490_000_000.0, 10, 9, 0),
1597+
(27_149_000_000_000.0, 10_000_000, 12, 6),
1598+
(27.149, 0.01, 0, -3),
1599+
(2_714.9, 0.01, 3, -3),
1600+
(271_490.0, 0.001, 3, -3),
1601+
(271.49, 0.001, 0, -3),
1602+
# The following sets of parameters demonstrates that when
1603+
# oom(data_offset)-1 and oom(noise)-2 equal a standard 3*N oom, we get
1604+
# that oom_noise_desired < oom(noise)
1605+
(27_149_000_000.0, 100, 9, +3),
1606+
(27.149, 1e-07, 0, -6),
1607+
(271.49, 0.0001, 0, -3),
1608+
(27.149, 0.0001, 0, -3),
1609+
# Tests where oom(data_offset) <= oom(noise), those are probably
1610+
# covered by the part where formatter.offset != 0
1611+
(27_149.0, 10_000, 0, 3),
1612+
(27.149, 10_000, 0, 3),
1613+
(27.149, 1_000, 0, 3),
1614+
(27.149, 100, 0, 0),
1615+
(27.149, 10, 0, 0),
1616+
]
1617+
)
1618+
def test_engformatter_offset_oom(
1619+
data_offset,
1620+
noise,
1621+
oom_center_desired,
1622+
oom_noise_desired
1623+
):
1624+
UNIT = "eV"
1625+
fig, ax = plt.subplots()
1626+
ydata = data_offset + np.arange(-5, 7, dtype=float)*noise
1627+
ax.plot(ydata)
1628+
formatter = mticker.EngFormatter(useOffset=True, unit=UNIT)
1629+
# So that offset strings will always have the same size
1630+
formatter.ENG_PREFIXES[0] = "_"
1631+
ax.yaxis.set_major_formatter(formatter)
1632+
fig.canvas.draw()
1633+
offset_got = formatter.get_offset()
1634+
ticks_got = [labl.get_text() for labl in ax.get_yticklabels()]
1635+
# Predicting whether offset should be 0 or not is essentially testing
1636+
# ScalarFormatter._compute_offset . This function is pretty complex and it
1637+
# would be nice to test it, but this is out of scope for this test which
1638+
# only makes sure that offset text and the ticks gets the correct unit
1639+
# prefixes and the ticks.
1640+
if formatter.offset:
1641+
prefix_noise_got = offset_got[2]
1642+
prefix_noise_desired = formatter.ENG_PREFIXES[oom_noise_desired]
1643+
prefix_center_got = offset_got[-1-len(UNIT)]
1644+
prefix_center_desired = formatter.ENG_PREFIXES[oom_center_desired]
1645+
assert prefix_noise_desired == prefix_noise_got
1646+
assert prefix_center_desired == prefix_center_got
1647+
# Make sure the ticks didn't get the UNIT
1648+
for tick in ticks_got:
1649+
assert UNIT not in tick
1650+
else:
1651+
assert oom_center_desired == 0
1652+
assert offset_got == ""
1653+
# Make sure the ticks contain now the prefixes
1654+
for tick in ticks_got:
1655+
# 0 is zero on all orders of magnitudes, no matter what is
1656+
# oom_noise_desired
1657+
prefix_idx = 0 if tick[0] == "0" else oom_noise_desired
1658+
assert tick.endswith(formatter.ENG_PREFIXES[prefix_idx] + UNIT)
1659+
1660+
15941661
class TestPercentFormatter:
15951662
percent_data = [
15961663
# Check explicitly set decimals over different intervals and values

lib/matplotlib/ticker.py

Lines changed: 109 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,11 @@ class ScalarFormatter(Formatter):
407407
useLocale : bool, default: :rc:`axes.formatter.use_locale`.
408408
Whether to use locale settings for decimal sign and positive sign.
409409
See `.set_useLocale`.
410+
usetex : bool, default: :rc:`text.usetex`
411+
To enable/disable the use of TeX's math mode for rendering the
412+
numbers in the formatter.
413+
414+
.. versionadded:: 3.10
410415
411416
Notes
412417
-----
@@ -444,20 +449,29 @@ class ScalarFormatter(Formatter):
444449
445450
"""
446451

447-
def __init__(self, useOffset=None, useMathText=None, useLocale=None):
452+
def __init__(self, useOffset=None, useMathText=None, useLocale=None, *,
453+
usetex=None):
448454
if useOffset is None:
449455
useOffset = mpl.rcParams['axes.formatter.useoffset']
450456
self._offset_threshold = \
451457
mpl.rcParams['axes.formatter.offset_threshold']
452458
self.set_useOffset(useOffset)
453-
self._usetex = mpl.rcParams['text.usetex']
459+
self.set_usetex(usetex)
454460
self.set_useMathText(useMathText)
455461
self.orderOfMagnitude = 0
456462
self.format = ''
457463
self._scientific = True
458464
self._powerlimits = mpl.rcParams['axes.formatter.limits']
459465
self.set_useLocale(useLocale)
460466

467+
def get_usetex(self):
468+
return self._usetex
469+
470+
def set_usetex(self, val):
471+
self._usetex = mpl._val_or_rc(val, 'text.usetex')
472+
473+
usetex = property(fget=get_usetex, fset=set_usetex)
474+
461475
def get_useOffset(self):
462476
"""
463477
Return whether automatic mode for offset notation is active.
@@ -1324,7 +1338,7 @@ def format_data_short(self, value):
13241338
return f"1-{1 - value:e}"
13251339

13261340

1327-
class EngFormatter(Formatter):
1341+
class EngFormatter(ScalarFormatter):
13281342
"""
13291343
Format axis values using engineering prefixes to represent powers
13301344
of 1000, plus a specified unit, e.g., 10 MHz instead of 1e7.
@@ -1356,7 +1370,7 @@ class EngFormatter(Formatter):
13561370
}
13571371

13581372
def __init__(self, unit="", places=None, sep=" ", *, usetex=None,
1359-
useMathText=None):
1373+
useMathText=None, useOffset=False):
13601374
r"""
13611375
Parameters
13621376
----------
@@ -1390,76 +1404,124 @@ def __init__(self, unit="", places=None, sep=" ", *, usetex=None,
13901404
useMathText : bool, default: :rc:`axes.formatter.use_mathtext`
13911405
To enable/disable the use mathtext for rendering the numbers in
13921406
the formatter.
1407+
useOffset : bool or float, default: False
1408+
Whether to use offset notation with :math:`10^{3*N}` based prefixes.
1409+
This features allows showing an offset with standard SI order of
1410+
magnitude prefix near the axis. Offset is computed similarly to
1411+
how `ScalarFormatter` computes it internally, but here you are
1412+
guaranteed to get an offset which will make the tick labels exceed
1413+
3 digits. See also `.set_useOffset`.
1414+
1415+
.. versionadded:: 3.10
13931416
"""
13941417
self.unit = unit
13951418
self.places = places
13961419
self.sep = sep
1397-
self.set_usetex(usetex)
1398-
self.set_useMathText(useMathText)
1399-
1400-
def get_usetex(self):
1401-
return self._usetex
1402-
1403-
def set_usetex(self, val):
1404-
if val is None:
1405-
self._usetex = mpl.rcParams['text.usetex']
1406-
else:
1407-
self._usetex = val
1408-
1409-
usetex = property(fget=get_usetex, fset=set_usetex)
1420+
super().__init__(
1421+
useOffset=useOffset,
1422+
useMathText=useMathText,
1423+
useLocale=False,
1424+
usetex=usetex,
1425+
)
14101426

1411-
def get_useMathText(self):
1412-
return self._useMathText
1427+
def __call__(self, x, pos=None):
1428+
"""
1429+
Return the format for tick value *x* at position *pos*.
14131430
1414-
def set_useMathText(self, val):
1415-
if val is None:
1416-
self._useMathText = mpl.rcParams['axes.formatter.use_mathtext']
1431+
If there is no currently offset in the data, it returns the best
1432+
engineering formatting that fits the given argument, independently.
1433+
"""
1434+
if len(self.locs) == 0 or self.offset == 0:
1435+
return self.fix_minus(self.format_data(x))
14171436
else:
1418-
self._useMathText = val
1437+
xp = (x - self.offset) / (10. ** self.orderOfMagnitude)
1438+
if abs(xp) < 1e-8:
1439+
xp = 0
1440+
return self._format_maybe_minus_and_locale(self.format, xp)
14191441

1420-
useMathText = property(fget=get_useMathText, fset=set_useMathText)
1442+
def set_locs(self, locs):
1443+
# docstring inherited
1444+
self.locs = locs
1445+
if len(self.locs) > 0:
1446+
vmin, vmax = sorted(self.axis.get_view_interval())
1447+
if self._useOffset:
1448+
self._compute_offset()
1449+
if self.offset != 0:
1450+
# We don't want to use the offset computed by
1451+
# self._compute_offset because it rounds the offset unaware
1452+
# of our engineering prefixes preference, and this can
1453+
# cause ticks with 4+ digits to appear. These ticks are
1454+
# slightly less readable, so if offset is justified
1455+
# (decided by self._compute_offset) we set it to better
1456+
# value:
1457+
self.offset = round((vmin + vmax)/2, 3)
1458+
# Use log1000 to use engineers' oom standards
1459+
self.orderOfMagnitude = math.floor(math.log(vmax - vmin, 1000))*3
1460+
self._set_format()
14211461

1422-
def __call__(self, x, pos=None):
1423-
s = f"{self.format_eng(x)}{self.unit}"
1424-
# Remove the trailing separator when there is neither prefix nor unit
1425-
if self.sep and s.endswith(self.sep):
1426-
s = s[:-len(self.sep)]
1427-
return self.fix_minus(s)
1462+
# Simplify a bit ScalarFormatter.get_offset: We always want to use
1463+
# self.format_data. Also we want to return a non-empty string only if there
1464+
# is an offset, no matter what is self.orderOfMagnitude. If there _is_ an
1465+
# offset, self.orderOfMagnitude is consulted. This behavior is verified
1466+
# in `test_ticker.py`.
1467+
def get_offset(self):
1468+
# docstring inherited
1469+
if len(self.locs) == 0:
1470+
return ''
1471+
if self.offset:
1472+
offsetStr = ''
1473+
if self.offset:
1474+
offsetStr = self.format_data(self.offset)
1475+
if self.offset > 0:
1476+
offsetStr = '+' + offsetStr
1477+
sciNotStr = self.format_data(10 ** self.orderOfMagnitude)
1478+
if self._useMathText or self._usetex:
1479+
if sciNotStr != '':
1480+
sciNotStr = r'\times%s' % sciNotStr
1481+
s = f'${sciNotStr}{offsetStr}$'
1482+
else:
1483+
s = sciNotStr + offsetStr
1484+
return self.fix_minus(s)
1485+
return ''
14281486

14291487
def format_eng(self, num):
1488+
"""Alias to EngFormatter.format_data"""
1489+
return self.format_data(num)
1490+
1491+
def format_data(self, value):
14301492
"""
14311493
Format a number in engineering notation, appending a letter
14321494
representing the power of 1000 of the original number.
14331495
Some examples:
14341496
1435-
>>> format_eng(0) # for self.places = 0
1497+
>>> format_data(0) # for self.places = 0
14361498
'0'
14371499
1438-
>>> format_eng(1000000) # for self.places = 1
1500+
>>> format_data(1000000) # for self.places = 1
14391501
'1.0 M'
14401502
1441-
>>> format_eng(-1e-6) # for self.places = 2
1503+
>>> format_data(-1e-6) # for self.places = 2
14421504
'-1.00 \N{MICRO SIGN}'
14431505
"""
14441506
sign = 1
14451507
fmt = "g" if self.places is None else f".{self.places:d}f"
14461508

1447-
if num < 0:
1509+
if value < 0:
14481510
sign = -1
1449-
num = -num
1511+
value = -value
14501512

1451-
if num != 0:
1452-
pow10 = int(math.floor(math.log10(num) / 3) * 3)
1513+
if value != 0:
1514+
pow10 = int(math.floor(math.log10(value) / 3) * 3)
14531515
else:
14541516
pow10 = 0
1455-
# Force num to zero, to avoid inconsistencies like
1517+
# Force value to zero, to avoid inconsistencies like
14561518
# format_eng(-0) = "0" and format_eng(0.0) = "0"
14571519
# but format_eng(-0.0) = "-0.0"
1458-
num = 0.0
1520+
value = 0.0
14591521

14601522
pow10 = np.clip(pow10, min(self.ENG_PREFIXES), max(self.ENG_PREFIXES))
14611523

1462-
mant = sign * num / (10.0 ** pow10)
1524+
mant = sign * value / (10.0 ** pow10)
14631525
# Taking care of the cases like 999.9..., which may be rounded to 1000
14641526
# instead of 1 k. Beware of the corner case of values that are beyond
14651527
# the range of SI prefixes (i.e. > 'Y').
@@ -1468,13 +1530,15 @@ def format_eng(self, num):
14681530
mant /= 1000
14691531
pow10 += 3
14701532

1471-
prefix = self.ENG_PREFIXES[int(pow10)]
1533+
unit_prefix = self.ENG_PREFIXES[int(pow10)]
1534+
if self.unit or unit_prefix:
1535+
suffix = f"{self.sep}{unit_prefix}{self.unit}"
1536+
else:
1537+
suffix = ""
14721538
if self._usetex or self._useMathText:
1473-
formatted = f"${mant:{fmt}}${self.sep}{prefix}"
1539+
return f"${mant:{fmt}}${suffix}"
14741540
else:
1475-
formatted = f"{mant:{fmt}}{self.sep}{prefix}"
1476-
1477-
return formatted
1541+
return f"{mant:{fmt}}{suffix}"
14781542

14791543

14801544
class PercentFormatter(Formatter):

0 commit comments

Comments
 (0)