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 61833b8

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

File tree

6 files changed

+237
-59
lines changed
Filter options

6 files changed

+237
-59
lines changed
+13Lines 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
+4Lines 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``.
+33Lines 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

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

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