Skip to content

Commit cf6852c

Browse files
committed
ENH: Added TransformFormatter to matplotlib.ticker
Tests included. Example code provided with some recipes from previous attempt.
1 parent 22cf7e0 commit cf6852c

File tree

5 files changed

+375
-32
lines changed

5 files changed

+375
-32
lines changed
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
Four new Formatters added to `matplotlib.ticker`
2+
-----------------------------------------------
3+
4+
Two new formatters have been added for displaying some specialized
5+
tick labels:
6+
7+
- :class:`matplotlib.ticker.PercentFormatter`
8+
- :class:`matplotlib.ticker.TransformFormatter`
9+
10+
11+
:class:`matplotlib.ticker.PercentFormatter`
12+
```````````````````````````````````````````
13+
14+
This new formatter has some nice features like being able to convert
15+
from arbitrary data scales to percents, a customizable percent symbol
16+
and either automatic or manual control over the decimal points.
17+
18+
19+
:class:`matplotlib.ticker.TransformFormatter`
20+
```````````````````````````````````````````````
21+
22+
A more generic version of :class:`matplotlib.ticker.FuncFormatter` that
23+
allows the tick values to be transformed before being passed to an
24+
underlying formatter. The transformation can yeild results of arbitrary
25+
type, so for example, using `int` as the transformation will allow
26+
:class:`matplotlib.ticker.StrMethodFormatter` to use integer format
27+
strings. If the underlying formatter is an instance of
28+
:class:`matplotlib.ticker.Formatter`, it will be configured correctly
29+
through this class.
30+

doc/users/whats_new/percent_formatter.rst

-6
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
Demo of the `matplotlib.ticker.TransformFormatter` class.
3+
4+
This code demonstrates two features:
5+
6+
1. A linear transformation of the input values. A callable class for
7+
doing the transformation is presented as a recipe here. The data
8+
type of the inputs does not change.
9+
2. A transformation of the input type. The example here allows
10+
`matplotlib.ticker.StrMethodFormatter` to handle integer formats
11+
('b', 'o', 'd', 'n', 'x', 'X'), which will normally raise an error
12+
if used directly. This transformation is associated with a
13+
`matplotlib.ticker.MaxNLocator` which has `integer` set to True to
14+
ensure that the inputs are indeed integers.
15+
16+
The same histogram is plotted in two sub-plots with a shared x-axis.
17+
Each axis shows a different temperature scale: one in degrees Celsius,
18+
one in degrees Rankine (the Fahrenheit analogue of Kelvins). This is one
19+
of the few examples of recognized scientific units that have both a
20+
scale and an offset relative to each other.
21+
"""
22+
23+
import numpy as np
24+
from matplotlib import pyplot as plt
25+
from matplotlib.axis import Ticker
26+
from matplotlib.ticker import (
27+
TransformFormatter, StrMethodFormatter, MaxNLocator
28+
)
29+
30+
class LinearTransform:
31+
"""
32+
A callable class that transforms input values to output according to
33+
a linear transformation.
34+
"""
35+
36+
def __init__(self, in_start=None, in_end=None, out_start=None, out_end=None):
37+
"""
38+
Sets up the transformation such that `in_start` gets mapped to
39+
`out_start` and `in_end` gets mapped to `out_end`. The following
40+
shortcuts apply when only some of the inputs are specified:
41+
42+
- none: no-op
43+
- in_start: translation to zero
44+
- out_start: translation from zero
45+
- in_end: scaling to one (divide input by in_end)
46+
- out_end: scaling from one (multiply input by in_end)
47+
- in_start, out_start: translation
48+
- in_end, out_end: scaling (in_start and out_start zero)
49+
- in_start, out_end: in_end=out_end, out_start=0
50+
- in_end, out_start: in_start=0, out_end=in_end
51+
52+
Based on the following rules:
53+
54+
- start missing: set start to zero
55+
- both ends are missing: set ranges to 1.0
56+
- one end is missing: set it to the other end
57+
"""
58+
self._in_offset = 0.0 if in_start is None else in_start
59+
self._out_offset = 0.0 if out_start is None else out_start
60+
61+
if in_end is None:
62+
if out_end is None:
63+
self._in_scale = 1.0
64+
else:
65+
self._in_scale = out_end - self._in_offset
66+
else:
67+
self._in_scale = in_end - self._in_offset
68+
69+
if out_end is None:
70+
if in_end is None:
71+
self._out_scale = 1.0
72+
else:
73+
self._out_scale = in_end - self._out_offset
74+
else:
75+
self._out_scale = out_end - self._out_offset
76+
77+
def __call__(self, x):
78+
"""
79+
Transforms the input value `x` according to the rule set up in
80+
`__init__`.
81+
"""
82+
return ((x - self._in_offset) * self._out_scale / self._in_scale +
83+
self._out_offset)
84+
85+
# X-data
86+
temp_C = np.arange(-5.0, 5.1, 0.25)
87+
# Y-data
88+
counts = 15.0 * np.exp(-temp_C**2 / 25)
89+
# Add some noise
90+
counts += np.random.normal(scale=4.0, size=counts.shape)
91+
if counts.min() < 0:
92+
counts += counts.min()
93+
94+
fig = plt.figure()
95+
fig.subplots_adjust(hspace=0.3)
96+
97+
ax1 = fig.add_subplot(211)
98+
ax2 = fig.add_subplot(212, sharex=ax1, sharey=ax1)
99+
100+
ax1.plot(temp_C, counts, drawstyle='steps-mid')
101+
ax2.plot(temp_C, counts, drawstyle='steps-mid')
102+
103+
ax1.xaxis.set_major_formatter(StrMethodFormatter('{x:0.2f}'))
104+
105+
# This step is necessary to allow the shared x-axes to have different
106+
# Formatter and Locator objects.
107+
ax2.xaxis.major = Ticker()
108+
# 0C -> 491.67R (definition), -273.15C (0K)->0R (-491.67F)(definition)
109+
ax2.xaxis.set_major_formatter(
110+
TransformFormatter(LinearTransform(in_start=-273.15, in_end=0,
111+
out_end=491.67),
112+
StrMethodFormatter('{x:0.2f}')))
113+
ax2.xaxis.set_major_locator(ax1.xaxis.get_major_locator())
114+
115+
# The y-axes share their locators and formatters, so only one needs to
116+
# be set
117+
ax1.yaxis.set_major_locator(MaxNLocator(integer=True))
118+
# Setting the transfrom to `int` will only alter the type, not the
119+
# actual value of the ticks
120+
ax1.yaxis.set_major_formatter(
121+
TransformFormatter(int, StrMethodFormatter('{x:02X}')))
122+
123+
fig.suptitle('Temperature vs Counts')
124+
ax1.set_xlabel('Temp (\u00B0C)')
125+
ax1.set_ylabel('Samples (Hex)')
126+
ax2.set_xlabel('Temp (\u00B0R)')
127+
ax2.set_ylabel('Samples (Hex)')
128+
129+
plt.show()
130+

lib/matplotlib/tests/test_ticker.py

+96-25
Original file line numberDiff line numberDiff line change
@@ -504,37 +504,108 @@ def test_formatstrformatter():
504504
assert '002-01' == tmp_form(2, 1)
505505

506506

507-
percentformatter_test_cases = (
508-
# Check explicitly set decimals over different intervals and values
509-
(100, 0, '%', 120, 100, '120%'),
510-
(100, 0, '%', 100, 90, '100%'),
511-
(100, 0, '%', 90, 50, '90%'),
512-
(100, 0, '%', 1.7, 40, '2%'),
513-
(100, 1, '%', 90.0, 100, '90.0%'),
514-
(100, 1, '%', 80.1, 90, '80.1%'),
515-
(100, 1, '%', 70.23, 50, '70.2%'),
516-
# 60.554 instead of 60.55: see https://bugs.python.org/issue5118
517-
(100, 1, '%', 60.554, 40, '60.6%'),
518-
# Check auto decimals over different intervals and values
519-
(100, None, '%', 95, 1, '95.00%'),
520-
(1.0, None, '%', 3, 6, '300%'),
521-
(17.0, None, '%', 1, 8.5, '6%'),
522-
(17.0, None, '%', 1, 8.4, '5.9%'),
523-
(5, None, '%', -100, 0.000001, '-2000.00000%'),
524-
# Check percent symbol
525-
(1.0, 2, None, 1.2, 100, '120.00'),
526-
(75, 3, '', 50, 100, '66.667'),
527-
(42, None, '^^Foobar$$', 21, 12, '50.0^^Foobar$$'),
528-
)
529-
530-
531507
@pytest.mark.parametrize('xmax, decimals, symbol, x, display_range, expected',
532-
percentformatter_test_cases)
508+
[
509+
# Check explicitly set decimals over different intervals and values
510+
(100, 0, '%', 120, 100, '120%'),
511+
(100, 0, '%', 100, 90, '100%'),
512+
(100, 0, '%', 90, 50, '90%'),
513+
(100, 0, '%', 1.7, 40, '2%'),
514+
(100, 1, '%', 90.0, 100, '90.0%'),
515+
(100, 1, '%', 80.1, 90, '80.1%'),
516+
(100, 1, '%', 70.23, 50, '70.2%'),
517+
# 60.554 instead of 60.55: see https://bugs.python.org/issue5118
518+
(100, 1, '%', 60.554, 40, '60.6%'),
519+
# Check auto decimals over different intervals and values
520+
(100, None, '%', 95, 1, '95.00%'),
521+
(1.0, None, '%', 3, 6, '300%'),
522+
(17.0, None, '%', 1, 8.5, '6%'),
523+
(17.0, None, '%', 1, 8.4, '5.9%'),
524+
(5, None, '%', -100, 0.000001, '-2000.00000%'),
525+
# Check percent symbol
526+
(1.0, 2, None, 1.2, 100, '120.00'),
527+
(75, 3, '', 50, 100, '66.667'),
528+
(42, None, '^^Foobar$$', 21, 12, '50.0^^Foobar$$'),
529+
])
533530
def test_percentformatter(xmax, decimals, symbol, x, display_range, expected):
534531
formatter = mticker.PercentFormatter(xmax, decimals, symbol)
535532
assert formatter.format_pct(x, display_range) == expected
536533

537534

535+
def test_TransformFormatter():
536+
"""
537+
Verifies that the linear transformations are being done correctly.
538+
"""
539+
def transform(x):
540+
return -x
541+
542+
# Make a formatter using the default underlying formatter,
543+
# which is a Formatter instance, not just a generic callable
544+
fmt = mticker.TransformFormatter(transform)
545+
546+
# Public (non-method) attributes
547+
assert fmt.transform is transform
548+
assert isinstance(fmt.formatter, mticker.ScalarFormatter)
549+
550+
# .create_dummy_axis
551+
assert fmt.axis is None
552+
fmt.create_dummy_axis()
553+
assert fmt.axis is not None
554+
assert fmt.axis is fmt.formatter.axis
555+
556+
# .set_axis
557+
prev_axis = fmt.axis
558+
fmt.set_axis(mticker._DummyAxis())
559+
assert fmt.axis is fmt.formatter.axis
560+
assert fmt.axis is not prev_axis
561+
562+
# .set_view_interval
563+
fmt.set_view_interval(100, 200)
564+
assert np.array_equal(fmt.axis.get_view_interval(), [100, 200])
565+
566+
# .set_data_interval
567+
fmt.set_data_interval(50, 60)
568+
assert np.array_equal(fmt.axis.get_data_interval(), [50, 60])
569+
570+
# .set_bounds
571+
bounds = [-7, 7]
572+
fmt.set_bounds(*bounds)
573+
assert np.array_equal(fmt.axis.get_view_interval(), bounds)
574+
assert np.array_equal(fmt.axis.get_data_interval(), bounds)
575+
576+
# .format_data, .format_data_short
577+
assert fmt.format_data(100.0) == '\u22121e2'
578+
assert fmt.format_data_short(-200.0) == '{:<12g}'.format(200)
579+
580+
# .get_offset
581+
assert fmt.get_offset() == fmt.formatter.get_offset()
582+
583+
# .set_locs
584+
locs = [1.0, 2.0, 3.0]
585+
transformed_locs = [-1.0, -2.0, -3.0]
586+
fmt.set_locs(locs)
587+
assert fmt.locs is locs
588+
assert fmt.formatter.locs == transformed_locs
589+
590+
# .fix_minus
591+
val = '-19.0'
592+
assert fmt.fix_minus(val) == '\u221219.0'
593+
assert fmt.fix_minus(val) == fmt.formatter.fix_minus(val)
594+
595+
# .__call__ needs to be tested after `set_locs` has been called at
596+
# least once.
597+
assert fmt(5.0) == '\u22125'
598+
599+
# .set_formatter
600+
prev_axis = fmt.axis
601+
fmt.set_formatter(mticker.PercentFormatter())
602+
assert isinstance(fmt.formatter, mticker.PercentFormatter)
603+
assert fmt.axis is prev_axis
604+
assert fmt.formatter.axis is fmt.axis
605+
assert fmt.locs is locs
606+
assert fmt.formatter.locs == transformed_locs
607+
608+
538609
def test_EngFormatter_formatting():
539610
"""
540611
Create two instances of EngFormatter with default parameters, with and

0 commit comments

Comments
 (0)