Skip to content

Commit 0e35e6b

Browse files
committed
ENH: Added percent formatter and tests
1 parent 3b2ab9d commit 0e35e6b

File tree

3 files changed

+143
-18
lines changed

3 files changed

+143
-18
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Added `matplotlib.ticker.PercentFormatter`
2+
------------------------------------------
3+
4+
The new formatter has some nice features like being able to convert from
5+
arbitrary data scales to percents, a customizable percent symbol and
6+
either automatic or manual control over the decimal points.

lib/matplotlib/tests/test_ticker.py

+33
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,39 @@ def test_formatstrformatter():
366366
tmp_form = mticker.StrMethodFormatter('{x:05d}')
367367
nose.tools.assert_equal('00002', tmp_form(2))
368368

369+
370+
def _percent_format_helper(xmax, decimals, symbol, x, display_range, expected):
371+
formatter = mticker.PercentFormatter(xmax, decimals, symbol)
372+
nose.tools.assert_equal(formatter.format_pct(x, display_range), expected)
373+
374+
375+
def test_percentformatter():
376+
test_cases = (
377+
# Check explicitly set decimals over different intervals and values
378+
(100, 0, '%', 120, 100, '120%'),
379+
(100, 0, '%', 100, 90, '100%'),
380+
(100, 0, '%', 90, 50, '90%'),
381+
(100, 0, '%', 1.7, 40, '2%'),
382+
(100, 1, '%', 90.0, 100, '90.0%'),
383+
(100, 1, '%', 80.1, 90, '80.1%'),
384+
(100, 1, '%', 70.23, 50, '70.2%'),
385+
# 60.554 instead of 60.55: see https://bugs.python.org/issue5118
386+
(100, 1, '%', 60.554, 40, '60.6%'),
387+
# Check auto decimals over different intervals and values
388+
(100, None, '%', 95, 1, '95.00%'),
389+
(1.0, None, '%', 3, 6, '300%'),
390+
(17.0, None, '%', 1, 8.5, '6%'),
391+
(17.0, None, '%', 1, 8.4, '5.9%'),
392+
(5, None, '%', -100, 0.000001, '-2000.00000%'),
393+
# Check percent symbol
394+
(1.0, 2, None, 1.2, 100, '120.00'),
395+
(75, 3, '', 50, 100, '66.667'),
396+
(42, None, '^^Foobar$$', 21, 12, '50.0^^Foobar$$'),
397+
)
398+
for case in test_cases:
399+
yield (_percent_format_helper,) + case
400+
401+
369402
if __name__ == '__main__':
370403
import nose
371404
nose.runmodule(argv=['-s', '--with-doctest'], exit=False)

lib/matplotlib/ticker.py

+104-18
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@
131131
:class:`LogFormatter`
132132
formatter for log axes
133133
134+
:class:`PercentFormatter`
135+
Format labels as a percentage
134136
135137
You can derive your own formatter from the Formatter base class by
136138
simply overriding the ``__call__`` method. The formatter class has access
@@ -165,6 +167,18 @@
165167

166168
import warnings
167169

170+
171+
__all__ = ('TickHelper', 'Formatter', 'FixedFormatter',
172+
'NullFormatter', 'FuncFormatter', 'FormatStrFormatter',
173+
'StrMethodFormatter', 'ScalarFormatter', 'LogFormatter',
174+
'LogFormatterExponent', 'LogFormatterMathtext',
175+
'LogitFormatter', 'EngFormatter', 'PercentFormatter',
176+
'Locator', 'IndexLocator', 'FixedLocator', 'NullLocator',
177+
'LinearLocator', 'LogLocator', 'AutoLocator',
178+
'MultipleLocator', 'MaxNLocator', 'AutoMinorLocator',
179+
'SymmetricalLogLocator')
180+
181+
168182
if six.PY3:
169183
long = int
170184

@@ -922,8 +936,10 @@ def __call__(self, x, pos=None):
922936
return self.fix_minus(s)
923937

924938
def format_eng(self, num):
925-
""" Formats a number in engineering notation, appending a letter
926-
representing the power of 1000 of the original number. Some examples:
939+
"""
940+
Formats a number in engineering notation, appending a letter
941+
representing the power of 1000 of the original number.
942+
Some examples:
927943
928944
>>> format_eng(0) # for self.places = 0
929945
'0'
@@ -934,13 +950,9 @@ def format_eng(self, num):
934950
>>> format_eng("-1e-6") # for self.places = 2
935951
u'-1.00 \u03bc'
936952
937-
@param num: the value to represent
938-
@type num: either a numeric value or a string that can be converted to
939-
a numeric value (as per decimal.Decimal constructor)
940-
941-
@return: engineering formatted string
953+
`num` may be a numeric value or a string that can be converted
954+
to a numeric value with the `decimal.Decimal` constructor.
942955
"""
943-
944956
dnum = decimal.Decimal(str(num))
945957

946958
sign = 1
@@ -973,6 +985,90 @@ def format_eng(self, num):
973985
return formatted.strip()
974986

975987

988+
class PercentFormatter(Formatter):
989+
"""
990+
Format numbers as a percentage.
991+
992+
How the number is converted into a percentage is determined by the
993+
`xmax` parameter. `xmax` is the data value that corresponds to 100%.
994+
Percentages are computed as ``x / xmax * 100``. So if the data is
995+
already scaled to be percentages, `xmax` will be 100. Another common
996+
situation is where `xmax` is 1.0.
997+
998+
`symbol` is a string which will be appended to the label. It may be
999+
`None` or empty to indicate that no symbol should be used.
1000+
1001+
`decimals` is the number of decimal places to place after the point.
1002+
If it is set to `None` (the default), the number will be computed
1003+
automatically.
1004+
"""
1005+
def __init__(self, xmax=100, decimals=None, symbol='%'):
1006+
self.xmax = xmax + 0.0
1007+
self.decimals = decimals
1008+
self.symbol = symbol
1009+
1010+
def __call__(self, x, pos=None):
1011+
"""
1012+
Formats the tick as a percentage with the appropriate scaling.
1013+
"""
1014+
ax_min, ax_max = self.axis.get_view_interval()
1015+
display_range = abs(ax_max - ax_min)
1016+
1017+
return self.fix_minus(self.format_pct(x, display_range))
1018+
1019+
def format_pct(self, x, display_range):
1020+
"""
1021+
Formats the number as a percentage number with the correct
1022+
number of decimals and adds the percent symbol, if any.
1023+
1024+
If `self.decimals` is `None`, the number of digits after the
1025+
decimal point is set based on the `display_range` of the axis
1026+
as follows:
1027+
1028+
+---------------+----------+------------------------+
1029+
| display_range | decimals | sample |
1030+
+---------------+----------+------------------------+
1031+
| >50 | 0 | ``x = 34.5`` => 35% |
1032+
+---------------+----------+------------------------+
1033+
| >5 | 1 | ``x = 34.5`` => 34.5% |
1034+
+---------------+----------+------------------------+
1035+
| >0.5 | 2 | ``x = 34.5`` => 34.50% |
1036+
+---------------+----------+------------------------+
1037+
| ... | ... | ... |
1038+
+---------------+----------+------------------------+
1039+
1040+
This method will not be very good for tiny axis ranges or
1041+
extremely large ones. It assumes that the values on the chart
1042+
are percentages displayed on a reasonable scale.
1043+
"""
1044+
x = self.convert_to_pct(x)
1045+
if self.decimals is None:
1046+
# conversion works because display_range is a difference
1047+
scaled_range = self.convert_to_pct(display_range)
1048+
if scaled_range <= 0:
1049+
decimals = 0
1050+
else:
1051+
# Luckily Python's built-in ceil rounds to +inf, not away from
1052+
# zero. This is very important since the equation for decimals
1053+
# starts out as `scaled_range > 0.5 * 10**(2 - decimals)`
1054+
# and ends up with `decimals > 2 - log10(2 * scaled_range)`.
1055+
decimals = math.ceil(2.0 - math.log10(2.0 * scaled_range))
1056+
if decimals > 5:
1057+
decimals = 5
1058+
elif decimals < 0:
1059+
decimals = 0
1060+
else:
1061+
decimals = self.decimals
1062+
s = '{x:0.{decimals}f}'.format(x=x, decimals=int(decimals))
1063+
1064+
if self.symbol:
1065+
return s + self.symbol
1066+
return s
1067+
1068+
def convert_to_pct(self, x):
1069+
return 100.0 * (x / self.xmax)
1070+
1071+
9761072
class Locator(TickHelper):
9771073
"""
9781074
Determine the tick locations;
@@ -2055,13 +2151,3 @@ def get_locator(self, d):
20552151
locator = MultipleLocator(ticksize)
20562152

20572153
return locator
2058-
2059-
2060-
__all__ = ('TickHelper', 'Formatter', 'FixedFormatter',
2061-
'NullFormatter', 'FuncFormatter', 'FormatStrFormatter',
2062-
'StrMethodFormatter', 'ScalarFormatter', 'LogFormatter',
2063-
'LogFormatterExponent', 'LogFormatterMathtext', 'Locator',
2064-
'IndexLocator', 'FixedLocator', 'NullLocator',
2065-
'LinearLocator', 'LogLocator', 'AutoLocator',
2066-
'MultipleLocator', 'MaxNLocator', 'AutoMinorLocator',
2067-
'SymmetricalLogLocator')

0 commit comments

Comments
 (0)