Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Added a PercentFormatter class to matplotlib.ticker #6251

Merged
merged 1 commit into from
Apr 8, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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')