Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit c841808

Browse filesBrowse 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 c841808
Copy full SHA for c841808

File tree

5 files changed

+212
-58
lines changed
Filter options

5 files changed

+212
-58
lines changed
+12Lines 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
+32Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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 numpy as np
16+
17+
import matplotlib.ticker as mticker
18+
19+
np.random.seed(19680801)
20+
21+
UNIT = "Hz"
22+
23+
fig, ax = plt.subplots()
24+
ax.yaxis.set_major_formatter(mticker.EngFormatter(
25+
useOffset=True,
26+
unit=UNIT
27+
))
28+
size = 100
29+
measurement = np.full(size, 1)*1e9
30+
noise = np.random.uniform(low=-2e3, high=2e3, size=(size))
31+
ax.plot(measurement + noise)
32+
plt.show()

‎lib/matplotlib/tests/test_ticker.py

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_ticker.py
+72Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/ticker.py
+93-44Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/ticker.pyi
+3-14Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.