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

Skip to content

Commit 6a24602

Browse files
committed
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.
1 parent ad8c8f0 commit 6a24602

File tree

5 files changed

+211
-58
lines changed

5 files changed

+211
-58
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
ticker.EngFormatter now computes offset by default
2+
--------------------------------------------------
3+
4+
:class:`matplotlib.ticker.EngFormatter` has gained the ability to show an offset text near the
5+
axis. With shared logic with :class:`matplotlib.ticker.ScalarFormatter`, it is capable of
6+
deciding whether the data qualifies having an offset and show it with an
7+
appropriate SI quantity prefix, and with the supplied ``unit``.
8+
9+
To enable this new behavior, simply pass ``useOffset=True`` when you instantiate
10+
:class:`matplotlib.ticker.EngFormatter`. Example is available here_.
11+
12+
.. _here: ../../gallery/ticks/engformatter_offset.html
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
===================================================
3+
SI prefixed offsets and natural order of magnitudes
4+
===================================================
5+
6+
:class:`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 matplotlib.ticker as mticker
16+
import numpy as np
17+
18+
np.random.seed(19680801)
19+
20+
UNIT = "Hz"
21+
22+
fig, ax = plt.subplots()
23+
ax.yaxis.set_major_formatter(mticker.EngFormatter(
24+
useOffset=True,
25+
unit=UNIT
26+
))
27+
size = 100
28+
measurement = np.full(size, 1)*1e9
29+
noise = np.random.uniform(low=-2e3, high=2e3, size=(size))
30+
ax.plot(measurement + noise)
31+
plt.show()

lib/matplotlib/tests/test_ticker.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,6 +1594,78 @@ def test_engformatter_usetex_useMathText():
15941594
assert x_tick_label_text == ['$0$', '$500$', '$1$ k']
15951595

15961596

1597+
@pytest.mark.parametrize(
1598+
'oom_center, oom_noise, oom_center_desired, oom_noise_desired', [
1599+
(11, 1, 9, 0),
1600+
(13, 7, 12, 6),
1601+
(1, -2, 0, -3),
1602+
(3, -2, 3, -3),
1603+
(5, -3, 3, -3),
1604+
(2, -3, 0, -3),
1605+
# The following sets of parameters demonstrates that when oom_center-1
1606+
# and oom_noise-2 equal a standard 3*N oom, we get that
1607+
# oom_noise_desired < oom_noise
1608+
(10, 2, 9, 3),
1609+
(1, -7, 0, -6),
1610+
(2, -4, 0, -3),
1611+
(1, -4, 0, -3),
1612+
# Tests where oom_center <= oom_noise
1613+
(4, 4, 0, 3),
1614+
(1, 4, 0, 3),
1615+
(1, 3, 0, 3),
1616+
(1, 2, 0, 0),
1617+
(1, 1, 0, 0),
1618+
]
1619+
)
1620+
def test_engformatter_offset_oom(
1621+
oom_center,
1622+
oom_noise,
1623+
oom_center_desired,
1624+
oom_noise_desired
1625+
):
1626+
UNIT = "eV"
1627+
# Doesn't really matter here, but should be of order of magnitude ~= 1
1628+
r = range(-5, 7)
1629+
fig, ax = plt.subplots()
1630+
# Use some random ugly number
1631+
data_offset = 2.7149*10**oom_center
1632+
ydata = data_offset + np.array(r, dtype=float)*10**oom_noise
1633+
ax.plot(ydata)
1634+
formatter = mticker.EngFormatter(useOffset=True, unit=UNIT)
1635+
# So that offset strings will always have the same size
1636+
formatter.ENG_PREFIXES[0] = "_"
1637+
ax.yaxis.set_major_formatter(formatter)
1638+
fig.canvas.draw()
1639+
offsetGot = formatter.get_offset()
1640+
ticksGot = [labl.get_text() for labl in ax.get_yticklabels()]
1641+
# Predicting whether offset should be 0 or not is essentially testing
1642+
# ScalarFormatter._compute_offset . This function is pretty complex and it
1643+
# would be nice to test it, but this is out of scope for this test which
1644+
# only makes sure that offset text and the ticks gets the correct unit
1645+
# prefixes and the ticks.
1646+
if formatter.offset:
1647+
prefix_noise_got = offsetGot[2]
1648+
prefix_noise_desired = formatter.ENG_PREFIXES[oom_noise_desired]
1649+
prefix_center_got = offsetGot[-1-len(UNIT)]
1650+
prefix_center_desired = formatter.ENG_PREFIXES[oom_center_desired]
1651+
assert prefix_noise_desired == prefix_noise_got
1652+
assert prefix_center_desired == prefix_center_got
1653+
# Make sure the ticks didn't get the UNIT
1654+
for tick in ticksGot:
1655+
assert UNIT not in tick
1656+
else:
1657+
assert oom_center_desired == 0
1658+
assert offsetGot == ""
1659+
# Make sure the ticks contain now the prefixes
1660+
for tick in ticksGot:
1661+
# 0 is zero on all orders of magnitudes, no
1662+
if tick[0] == "0":
1663+
prefixIdx = 0
1664+
else:
1665+
prefixIdx = oom_noise_desired
1666+
assert tick.endswith(formatter.ENG_PREFIXES[prefixIdx] + UNIT)
1667+
1668+
15971669
class TestPercentFormatter:
15981670
percent_data = [
15991671
# Check explicitly set decimals over different intervals and values

lib/matplotlib/ticker.py

Lines changed: 93 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,7 +1340,7 @@ def format_data_short(self, value):
13401340
return f"1-{1 - value:e}"
13411341

13421342

1343-
class EngFormatter(Formatter):
1343+
class EngFormatter(ScalarFormatter):
13441344
"""
13451345
Format axis values using engineering prefixes to represent powers
13461346
of 1000, plus a specified unit, e.g., 10 MHz instead of 1e7.
@@ -1372,7 +1372,7 @@ class EngFormatter(Formatter):
13721372
}
13731373

13741374
def __init__(self, unit="", places=None, sep=" ", *, usetex=None,
1375-
useMathText=None):
1375+
useMathText=None, useOffset=False):
13761376
r"""
13771377
Parameters
13781378
----------
@@ -1406,76 +1406,123 @@ def __init__(self, unit="", places=None, sep=" ", *, usetex=None,
14061406
useMathText : bool, default: :rc:`axes.formatter.use_mathtext`
14071407
To enable/disable the use mathtext for rendering the numbers in
14081408
the formatter.
1409+
useOffset : bool or float, default: False
1410+
Whether to use offset notation with :math:`10^{3*N}` based prefixes.
1411+
This features allows showing an offset with standard SI order of
1412+
magnitude prefix near the axis. Offset is computed similarly to
1413+
how `ScalarFormatter` computes it internally, but here you are
1414+
guaranteed to get an offset which will make the tick labels exceed
1415+
3 digits. See also `.set_useOffset`.
1416+
1417+
.. versionadded:: 3.10
14091418
"""
14101419
self.unit = unit
14111420
self.places = places
14121421
self.sep = sep
1413-
self.set_usetex(usetex)
1414-
self.set_useMathText(useMathText)
1415-
1416-
def get_usetex(self):
1417-
return self._usetex
1418-
1419-
def set_usetex(self, val):
1420-
if val is None:
1421-
self._usetex = mpl.rcParams['text.usetex']
1422-
else:
1423-
self._usetex = val
1424-
1425-
usetex = property(fget=get_usetex, fset=set_usetex)
1426-
1427-
def get_useMathText(self):
1428-
return self._useMathText
1422+
super().__init__(
1423+
useOffset=useOffset,
1424+
useMathText=useMathText,
1425+
useLocale=False,
1426+
usetex=usetex,
1427+
)
14291428

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

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

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

14451488
def format_eng(self, num):
1489+
"""Alias to EngFormatter.format_data"""
1490+
return self.format_data(num)
1491+
1492+
def format_data(self, value):
14461493
"""
14471494
Format a number in engineering notation, appending a letter
14481495
representing the power of 1000 of the original number.
14491496
Some examples:
14501497
1451-
>>> format_eng(0) # for self.places = 0
1498+
>>> format_data(0) # for self.places = 0
14521499
'0'
14531500
1454-
>>> format_eng(1000000) # for self.places = 1
1501+
>>> format_data(1000000) # for self.places = 1
14551502
'1.0 M'
14561503
1457-
>>> format_eng(-1e-6) # for self.places = 2
1504+
>>> format_data(-1e-6) # for self.places = 2
14581505
'-1.00 \N{MICRO SIGN}'
14591506
"""
14601507
sign = 1
14611508
fmt = "g" if self.places is None else f".{self.places:d}f"
14621509

1463-
if num < 0:
1510+
if value < 0:
14641511
sign = -1
1465-
num = -num
1512+
value = -value
14661513

1467-
if num != 0:
1468-
pow10 = int(math.floor(math.log10(num) / 3) * 3)
1514+
if value != 0:
1515+
pow10 = int(math.floor(math.log10(value) / 3) * 3)
14691516
else:
14701517
pow10 = 0
1471-
# Force num to zero, to avoid inconsistencies like
1518+
# Force value to zero, to avoid inconsistencies like
14721519
# format_eng(-0) = "0" and format_eng(0.0) = "0"
14731520
# but format_eng(-0.0) = "-0.0"
1474-
num = 0.0
1521+
value = 0.0
14751522

14761523
pow10 = np.clip(pow10, min(self.ENG_PREFIXES), max(self.ENG_PREFIXES))
14771524

1478-
mant = sign * num / (10.0 ** pow10)
1525+
mant = sign * value / (10.0 ** pow10)
14791526
# Taking care of the cases like 999.9..., which may be rounded to 1000
14801527
# instead of 1 k. Beware of the corner case of values that are beyond
14811528
# the range of SI prefixes (i.e. > 'Y').
@@ -1484,13 +1531,15 @@ def format_eng(self, num):
14841531
mant /= 1000
14851532
pow10 += 3
14861533

1487-
prefix = self.ENG_PREFIXES[int(pow10)]
1534+
unitPrefix = self.ENG_PREFIXES[int(pow10)]
1535+
if self.unit or unitPrefix:
1536+
suffix = f"{self.sep}{unitPrefix}{self.unit}"
1537+
else:
1538+
suffix = ""
14881539
if self._usetex or self._useMathText:
1489-
formatted = f"${mant:{fmt}}${self.sep}{prefix}"
1540+
return rf"${mant:{fmt}}${suffix}"
14901541
else:
1491-
formatted = f"{mant:{fmt}}{self.sep}{prefix}"
1492-
1493-
return formatted
1542+
return rf"{mant:{fmt}}{suffix}"
14941543

14951544

14961545
class PercentFormatter(Formatter):

lib/matplotlib/ticker.pyi

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ class LogitFormatter(Formatter):
130130
def set_minor_number(self, minor_number: int) -> None: ...
131131
def format_data_short(self, value: float) -> str: ...
132132

133-
class EngFormatter(Formatter):
133+
class EngFormatter(ScalarFormatter):
134134
ENG_PREFIXES: dict[int, str]
135135
unit: str
136136
places: int | None
@@ -142,20 +142,9 @@ class EngFormatter(Formatter):
142142
sep: str = ...,
143143
*,
144144
usetex: bool | None = ...,
145-
useMathText: bool | None = ...
145+
useMathText: bool | None = ...,
146+
useOffset: bool | float | None = ...,
146147
) -> None: ...
147-
def get_usetex(self) -> bool: ...
148-
def set_usetex(self, val: bool | None) -> None: ...
149-
@property
150-
def usetex(self) -> bool: ...
151-
@usetex.setter
152-
def usetex(self, val: bool | None) -> None: ...
153-
def get_useMathText(self) -> bool: ...
154-
def set_useMathText(self, val: bool | None) -> None: ...
155-
@property
156-
def useMathText(self) -> bool: ...
157-
@useMathText.setter
158-
def useMathText(self, val: bool | None) -> None: ...
159148
def format_eng(self, num: float) -> str: ...
160149

161150
class PercentFormatter(Formatter):

0 commit comments

Comments
 (0)