Skip to content

Commit

Permalink
ENH: Added percent formatter and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
madphysicist committed Apr 8, 2016
1 parent 3b2ab9d commit 0e35e6b
Show file tree
Hide file tree
Showing 3 changed files with 143 additions and 18 deletions.
6 changes: 6 additions & 0 deletions doc/users/whats_new/percent_formatter.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Added `matplotlib.ticker.PercentFormatter`
------------------------------------------

The new formatter has some nice features like being able to convert from
arbitrary data scales to percents, a customizable percent symbol and
either automatic or manual control over the decimal points.
33 changes: 33 additions & 0 deletions lib/matplotlib/tests/test_ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,39 @@ def test_formatstrformatter():
tmp_form = mticker.StrMethodFormatter('{x:05d}')
nose.tools.assert_equal('00002', tmp_form(2))


def _percent_format_helper(xmax, decimals, symbol, x, display_range, expected):
formatter = mticker.PercentFormatter(xmax, decimals, symbol)
nose.tools.assert_equal(formatter.format_pct(x, display_range), expected)


def test_percentformatter():
test_cases = (
# Check explicitly set decimals over different intervals and values
(100, 0, '%', 120, 100, '120%'),
(100, 0, '%', 100, 90, '100%'),
(100, 0, '%', 90, 50, '90%'),
(100, 0, '%', 1.7, 40, '2%'),
(100, 1, '%', 90.0, 100, '90.0%'),
(100, 1, '%', 80.1, 90, '80.1%'),
(100, 1, '%', 70.23, 50, '70.2%'),
# 60.554 instead of 60.55: see https://bugs.python.org/issue5118
(100, 1, '%', 60.554, 40, '60.6%'),
# Check auto decimals over different intervals and values
(100, None, '%', 95, 1, '95.00%'),
(1.0, None, '%', 3, 6, '300%'),
(17.0, None, '%', 1, 8.5, '6%'),
(17.0, None, '%', 1, 8.4, '5.9%'),
(5, None, '%', -100, 0.000001, '-2000.00000%'),
# Check percent symbol
(1.0, 2, None, 1.2, 100, '120.00'),
(75, 3, '', 50, 100, '66.667'),
(42, None, '^^Foobar$$', 21, 12, '50.0^^Foobar$$'),
)
for case in test_cases:
yield (_percent_format_helper,) + case


if __name__ == '__main__':
import nose
nose.runmodule(argv=['-s', '--with-doctest'], exit=False)
122 changes: 104 additions & 18 deletions lib/matplotlib/ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@
:class:`LogFormatter`
formatter for log axes
:class:`PercentFormatter`
Format labels as a percentage
You can derive your own formatter from the Formatter base class by
simply overriding the ``__call__`` method. The formatter class has access
Expand Down Expand Up @@ -165,6 +167,18 @@

import warnings


__all__ = ('TickHelper', 'Formatter', 'FixedFormatter',
'NullFormatter', 'FuncFormatter', 'FormatStrFormatter',
'StrMethodFormatter', 'ScalarFormatter', 'LogFormatter',
'LogFormatterExponent', 'LogFormatterMathtext',
'LogitFormatter', 'EngFormatter', 'PercentFormatter',
'Locator', 'IndexLocator', 'FixedLocator', 'NullLocator',
'LinearLocator', 'LogLocator', 'AutoLocator',
'MultipleLocator', 'MaxNLocator', 'AutoMinorLocator',
'SymmetricalLogLocator')


if six.PY3:
long = int

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

def format_eng(self, num):
""" Formats a number in engineering notation, appending a letter
representing the power of 1000 of the original number. Some examples:
"""
Formats a number in engineering notation, appending a letter
representing the power of 1000 of the original number.
Some examples:
>>> format_eng(0) # for self.places = 0
'0'
Expand All @@ -934,13 +950,9 @@ def format_eng(self, num):
>>> format_eng("-1e-6") # for self.places = 2
u'-1.00 \u03bc'
@param num: the value to represent
@type num: either a numeric value or a string that can be converted to
a numeric value (as per decimal.Decimal constructor)
@return: engineering formatted string
`num` may be a numeric value or a string that can be converted
to a numeric value with the `decimal.Decimal` constructor.
"""

dnum = decimal.Decimal(str(num))

sign = 1
Expand Down Expand Up @@ -973,6 +985,90 @@ def format_eng(self, num):
return formatted.strip()


class PercentFormatter(Formatter):
"""
Format numbers as a percentage.
How the number is converted into a percentage is determined by the
`xmax` parameter. `xmax` is the data value that corresponds to 100%.
Percentages are computed as ``x / xmax * 100``. So if the data is
already scaled to be percentages, `xmax` will be 100. Another common
situation is where `xmax` is 1.0.
`symbol` is a string which will be appended to the label. It may be
`None` or empty to indicate that no symbol should be used.
`decimals` is the number of decimal places to place after the point.
If it is set to `None` (the default), the number will be computed
automatically.
"""
def __init__(self, xmax=100, decimals=None, symbol='%'):
self.xmax = xmax + 0.0
self.decimals = decimals
self.symbol = symbol

def __call__(self, x, pos=None):
"""
Formats the tick as a percentage with the appropriate scaling.
"""
ax_min, ax_max = self.axis.get_view_interval()
display_range = abs(ax_max - ax_min)

return self.fix_minus(self.format_pct(x, display_range))

def format_pct(self, x, display_range):
"""
Formats the number as a percentage number with the correct
number of decimals and adds the percent symbol, if any.
If `self.decimals` is `None`, the number of digits after the
decimal point is set based on the `display_range` of the axis
as follows:
+---------------+----------+------------------------+
| display_range | decimals | sample |
+---------------+----------+------------------------+
| >50 | 0 | ``x = 34.5`` => 35% |
+---------------+----------+------------------------+
| >5 | 1 | ``x = 34.5`` => 34.5% |
+---------------+----------+------------------------+
| >0.5 | 2 | ``x = 34.5`` => 34.50% |
+---------------+----------+------------------------+
| ... | ... | ... |
+---------------+----------+------------------------+
This method will not be very good for tiny axis ranges or
extremely large ones. It assumes that the values on the chart
are percentages displayed on a reasonable scale.
"""
x = self.convert_to_pct(x)
if self.decimals is None:
# conversion works because display_range is a difference
scaled_range = self.convert_to_pct(display_range)
if scaled_range <= 0:
decimals = 0
else:
# Luckily Python's built-in ceil rounds to +inf, not away from
# zero. This is very important since the equation for decimals
# starts out as `scaled_range > 0.5 * 10**(2 - decimals)`
# and ends up with `decimals > 2 - log10(2 * scaled_range)`.
decimals = math.ceil(2.0 - math.log10(2.0 * scaled_range))
if decimals > 5:
decimals = 5
elif decimals < 0:
decimals = 0
else:
decimals = self.decimals
s = '{x:0.{decimals}f}'.format(x=x, decimals=int(decimals))

if self.symbol:
return s + self.symbol
return s

def convert_to_pct(self, x):
return 100.0 * (x / self.xmax)


class Locator(TickHelper):
"""
Determine the tick locations;
Expand Down Expand Up @@ -2055,13 +2151,3 @@ def get_locator(self, d):
locator = MultipleLocator(ticksize)

return locator


__all__ = ('TickHelper', 'Formatter', 'FixedFormatter',
'NullFormatter', 'FuncFormatter', 'FormatStrFormatter',
'StrMethodFormatter', 'ScalarFormatter', 'LogFormatter',
'LogFormatterExponent', 'LogFormatterMathtext', 'Locator',
'IndexLocator', 'FixedLocator', 'NullLocator',
'LinearLocator', 'LogLocator', 'AutoLocator',
'MultipleLocator', 'MaxNLocator', 'AutoMinorLocator',
'SymmetricalLogLocator')

0 comments on commit 0e35e6b

Please sign in to comment.