Skip to content

ENH: EngFormatter new kwarg 'sep' #6542

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5a550f2
ENH: Add the space_sep option to EngFormatter, and docstring updates
afvincent Jun 6, 2016
722f5d6
FIX: force format_eng(-0.0) to be consistent with format_eng(0)
afvincent Jun 6, 2016
3c221ff
Fix docstrings and comments (remove leading spaces)
afvincent Jun 7, 2016
122cfa2
Simplify and move cleaning strip op. from 'format_eng' to '__call__'
afvincent Jun 7, 2016
07f022d
DOC: update the api example engineering_formatter.py
afvincent Jun 19, 2016
e371e5a
More extensive testing of EngFormatter (including 'space_sep' param)
afvincent Feb 11, 2017
3049a2d
space_sep=bool <- sep=string, and adapt tests
afvincent Feb 12, 2017
e0a2ec8
remove unnecessary trailing 'u' characters in strings
afvincent Feb 12, 2017
1621a2d
fix EngFormatter docstring: escape Unicode character codes
afvincent Feb 12, 2017
c82dcff
'fix' docstring: unindent and reorder bullet list + mention regular t…
afvincent Feb 12, 2017
f09a1b4
Update the example
afvincent Feb 12, 2017
dc9409b
fix test docstring PEP8 errors in test_ticker.py
afvincent Feb 12, 2017
0195beb
Add a 'whats_new' entry
afvincent Feb 12, 2017
3ec1dbd
docstring overhaul: fix bullet list and merge duplicated infos betwee…
afvincent Feb 13, 2017
cd2ac88
use named entities instead of raw unicode codes
afvincent Aug 17, 2017
7f1422c
stop mixing C-style and format-based formatting
afvincent Aug 17, 2017
aba7a7f
Small example tweaking to avoid label cluttering
afvincent Aug 17, 2017
b75f20d
fix an issue with {:g} and str format
afvincent Aug 17, 2017
41a69da
get rid of decimal.Decimal + fix rounding special cases like 999.9...
afvincent Aug 19, 2017
556ec20
Fix the related rounding discrepancies in test_ticker
afvincent Aug 19, 2017
a9af431
(try) fix(ing) an exception about raising an int to a negative power
afvincent Aug 19, 2017
0bde03a
fix some anntzer's comments
afvincent Aug 20, 2017
c98ba91
more complete handling of corner-case roundings + add proper tests
afvincent Aug 20, 2017
60b95ab
Fix some additional remarks made by anntzer
afvincent Aug 21, 2017
ac42b94
Deprecate passing a string as *num* argument
afvincent Aug 24, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Deprecation in EngFormatter
```````````````````````````

Passing a string as *num* argument when calling an instance of
`matplotlib.ticker.EngFormatter` is deprecated and will be removed in 2.3.
10 changes: 10 additions & 0 deletions doc/users/whats_new/EngFormatter_new_kwarg_sep.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
New keyword argument 'sep' for EngFormatter
-------------------------------------------

A new "sep" keyword argument has been added to
:class:`~matplotlib.ticker.EngFormatter` and provides a means to define
the string that will be used between the value and its unit. The default
string is " ", which preserves the former behavior. Besides, the separator is
now present between the value and its unit even in the absence of SI prefix.
There was formerly a bug that was causing strings like "3.14V" to be returned
instead of the expected "3.14 V" (with the default behavior).
30 changes: 24 additions & 6 deletions examples/api/engineering_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,31 @@
# Fixing random state for reproducibility
prng = np.random.RandomState(19680801)

fig, ax = plt.subplots()
ax.set_xscale('log')
formatter = EngFormatter(unit='Hz')
ax.xaxis.set_major_formatter(formatter)

# Create artificial data to plot.
# The x data span over several decades to demonstrate several SI prefixes.
xs = np.logspace(1, 9, 100)
ys = (0.8 + 0.4 * prng.uniform(size=100)) * np.log10(xs)**2
ax.plot(xs, ys)

# Figure width is doubled (2*6.4) to display nicely 2 subplots side by side.
fig, (ax0, ax1) = plt.subplots(nrows=2, figsize=(7, 9.6))
for ax in (ax0, ax1):
ax.set_xscale('log')

# Demo of the default settings, with a user-defined unit label.
ax0.set_title('Full unit ticklabels, w/ default precision & space separator')
formatter0 = EngFormatter(unit='Hz')
ax0.xaxis.set_major_formatter(formatter0)
ax0.plot(xs, ys)
ax0.set_xlabel('Frequency')

# Demo of the options `places` (number of digit after decimal point) and
# `sep` (separator between the number and the prefix/unit).
ax1.set_title('SI-prefix only ticklabels, 1-digit precision & '
'thin space separator')
formatter1 = EngFormatter(places=1, sep=u"\N{THIN SPACE}") # U+2009
ax1.xaxis.set_major_formatter(formatter1)
ax1.plot(xs, ys)
ax1.set_xlabel('Frequency [Hz]')

plt.tight_layout()
plt.show()
103 changes: 87 additions & 16 deletions lib/matplotlib/tests/test_ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,26 +549,97 @@ def test_basic(self, format, input, expected):


class TestEngFormatter(object):
format_data = [
('', 0.1, u'100 m'),
('', 1, u'1'),
('', 999.9, u'999.9'),
('', 1001, u'1.001 k'),
(u's', 0.1, u'100 ms'),
(u's', 1, u'1 s'),
(u's', 999.9, u'999.9 s'),
(u's', 1001, u'1.001 ks'),
# (input, expected) where ''expected'' corresponds to the outputs
# respectively returned when (places=None, places=0, places=2)
raw_format_data = [
(-1234.56789, ('-1.23457 k', '-1 k', '-1.23 k')),
(-1.23456789, ('-1.23457', '-1', '-1.23')),
(-0.123456789, ('-123.457 m', '-123 m', '-123.46 m')),
(-0.00123456789, ('-1.23457 m', '-1 m', '-1.23 m')),
(-0.0, ('0', '0', '0.00')),
(-0, ('0', '0', '0.00')),
(0, ('0', '0', '0.00')),
(1.23456789e-6, ('1.23457 \u03bc', '1 \u03bc', '1.23 \u03bc')),
(0.123456789, ('123.457 m', '123 m', '123.46 m')),
(0.1, ('100 m', '100 m', '100.00 m')),
(1, ('1', '1', '1.00')),
(1.23456789, ('1.23457', '1', '1.23')),
(999.9, ('999.9', '1 k', '999.90')), # places=0: corner-case rounding
(999.9999, ('1 k', '1 k', '1.00 k')), # corner-case roudning for all
(1000, ('1 k', '1 k', '1.00 k')),
(1001, ('1.001 k', '1 k', '1.00 k')),
(100001, ('100.001 k', '100 k', '100.00 k')),
(987654.321, ('987.654 k', '988 k', '987.65 k')),
(1.23e27, ('1230 Y', '1230 Y', '1230.00 Y')) # OoR value (> 1000 Y)
]

@pytest.mark.parametrize('unit, input, expected', format_data)
def test_formatting(self, unit, input, expected):
@pytest.mark.parametrize('input, expected', raw_format_data)
def test_params(self, input, expected):
"""
Test the formatting of EngFormatter with some inputs, against
instances with and without units. Cases focus on when no SI
prefix is present, for values in [1, 1000).
Test the formatting of EngFormatter for various values of the 'places'
argument, in several cases:
0. without a unit symbol but with a (default) space separator;
1. with both a unit symbol and a (default) space separator;
2. with both a unit symbol and some non default separators;
3. without a unit symbol but with some non default separators.
Note that cases 2. and 3. are looped over several separator strings.
"""
fmt = mticker.EngFormatter(unit)
assert fmt(input) == expected

UNIT = 's' # seconds
DIGITS = '0123456789' # %timeit showed 10-20% faster search than set

# Case 0: unit='' (default) and sep=' ' (default).
# 'expected' already corresponds to this reference case.
exp_outputs = expected
formatters = (
mticker.EngFormatter(), # places=None (default)
mticker.EngFormatter(places=0),
mticker.EngFormatter(places=2)
)
for _formatter, _exp_output in zip(formatters, exp_outputs):
assert _formatter(input) == _exp_output

# Case 1: unit=UNIT and sep=' ' (default).
# Append a unit symbol to the reference case.
# Beware of the values in [1, 1000), where there is no prefix!
exp_outputs = (_s + " " + UNIT if _s[-1] in DIGITS # case w/o prefix
else _s + UNIT for _s in expected)
formatters = (
mticker.EngFormatter(unit=UNIT), # places=None (default)
mticker.EngFormatter(unit=UNIT, places=0),
mticker.EngFormatter(unit=UNIT, places=2)
)
for _formatter, _exp_output in zip(formatters, exp_outputs):
assert _formatter(input) == _exp_output

# Test several non default separators: no separator, a narrow
# no-break space (unicode character) and an extravagant string.
for _sep in ("", "\N{NARROW NO-BREAK SPACE}", "@_@"):
# Case 2: unit=UNIT and sep=_sep.
# Replace the default space separator from the reference case
# with the tested one `_sep` and append a unit symbol to it.
exp_outputs = (_s + _sep + UNIT if _s[-1] in DIGITS # no prefix
else _s.replace(" ", _sep) + UNIT
for _s in expected)
formatters = (
mticker.EngFormatter(unit=UNIT, sep=_sep), # places=None
mticker.EngFormatter(unit=UNIT, places=0, sep=_sep),
mticker.EngFormatter(unit=UNIT, places=2, sep=_sep)
)
for _formatter, _exp_output in zip(formatters, exp_outputs):
assert _formatter(input) == _exp_output

# Case 3: unit='' (default) and sep=_sep.
# Replace the default space separator from the reference case
# with the tested one `_sep`. Reference case is already unitless.
exp_outputs = (_s.replace(" ", _sep) for _s in expected)
formatters = (
mticker.EngFormatter(sep=_sep), # places=None (default)
mticker.EngFormatter(places=0, sep=_sep),
mticker.EngFormatter(places=2, sep=_sep)
)
for _formatter, _exp_output in zip(formatters, exp_outputs):
assert _formatter(input) == _exp_output


class TestPercentFormatter(object):
Expand Down
91 changes: 60 additions & 31 deletions lib/matplotlib/ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@

import six

import decimal
import itertools
import locale
import math
Expand Down Expand Up @@ -1184,15 +1183,8 @@ class EngFormatter(Formatter):
"""
Formats axis values using engineering prefixes to represent powers
of 1000, plus a specified unit, e.g., 10 MHz instead of 1e7.

`unit` is a string containing the abbreviated name of the unit,
suitable for use with single-letter representations of powers of
1000. For example, 'Hz' or 'm'.

`places` is the precision with which to display the number,
specified in digits after the decimal point (there will be between
one and three digits before the decimal point).
"""

# The SI engineering prefixes
ENG_PREFIXES = {
-24: "y",
Expand All @@ -1214,12 +1206,42 @@ class EngFormatter(Formatter):
24: "Y"
}

def __init__(self, unit="", places=None):
def __init__(self, unit="", places=None, sep=" "):
"""
Parameters
----------
unit : str (default: "")
Unit symbol to use, suitable for use with single-letter
representations of powers of 1000. For example, 'Hz' or 'm'.

places : int (default: None)
Precision with which to display the number, specified in
digits after the decimal point (there will be between one
and three digits before the decimal point). If it is None,
the formatting falls back to the floating point format '%g',
which displays up to 6 *significant* digits, i.e. the equivalent
value for *places* varies between 0 and 5 (inclusive).

sep : str (default: " ")
Separator used between the value and the prefix/unit. For
example, one get '3.14 mV' if ``sep`` is " " (default) and
'3.14mV' if ``sep`` is "". Besides the default behavior, some
other useful options may be:

* ``sep=""`` to append directly the prefix/unit to the value;
* ``sep="\\N{THIN SPACE}"`` (``U+2009``);
* ``sep="\\N{NARROW NO-BREAK SPACE}"`` (``U+202F``);
* ``sep="\\N{NO-BREAK SPACE}"`` (``U+00A0``).
"""
self.unit = unit
self.places = places
self.sep = sep

def __call__(self, x, pos=None):
s = "%s%s" % (self.format_eng(x), self.unit)
# Remove the trailing separator when there is neither prefix nor unit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be rewritten as s = re.sub(re.escape(self.sep) + "$", "", s) (without the need for a conditional) but I'm not going to block the PR on the fix.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well regex are black magic to me, but as you kindly gave the answer plus the tests still passed locally, here it is with 033111f ;).

if len(self.sep) > 0 and s.endswith(self.sep):
s = s[:-len(self.sep)]
return self.fix_minus(s)

def format_eng(self, num):
Expand All @@ -1238,40 +1260,47 @@ def format_eng(self, num):
u'-1.00 \N{GREEK SMALL LETTER MU}'

`num` may be a numeric value or a string that can be converted
to a numeric value with the `decimal.Decimal` constructor.
to a numeric value with ``float(num)``.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we actually have a use case for supporting this? Could this behavior be deprecated? (this is not strictly needed for the PR)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String were accepted before so I let it here. But other formatter classes do not seems to support such a feature, so if one thinks it is not worth supporting it anymore, it could be deprecated indeed.

Copy link
Contributor Author

@afvincent afvincent Aug 24, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to myself: add a deprecation warning for 2.1 (=> removing in 2.3?). (Have a look at #8040 to remember how to do it...)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deprecated in f207c89

"""
dnum = decimal.Decimal(str(num))
if isinstance(num, six.string_types):
warnings.warn(
"Passing a string as *num* argument is deprecated since"
"Matplotlib 2.1, and is expected to be removed in 2.3.",
mplDeprecation)

dnum = float(num)
sign = 1
fmt = "g" if self.places is None else ".{:d}f".format(self.places)

if dnum < 0:
sign = -1
dnum = -dnum

if dnum != 0:
pow10 = decimal.Decimal(int(math.floor(dnum.log10() / 3) * 3))
pow10 = int(math.floor(math.log10(dnum) / 3) * 3)
else:
pow10 = decimal.Decimal(0)

pow10 = pow10.min(max(self.ENG_PREFIXES))
pow10 = pow10.max(min(self.ENG_PREFIXES))
pow10 = 0
# Force dnum to zero, to avoid inconsistencies like
# format_eng(-0) = "0" and format_eng(0.0) = "0"
# but format_eng(-0.0) = "-0.0"
dnum = 0.0

pow10 = np.clip(pow10, min(self.ENG_PREFIXES), max(self.ENG_PREFIXES))

mant = sign * dnum / (10.0 ** pow10)
# Taking care of the cases like 999.9..., which
# may be rounded to 1000 instead of 1 k. Beware
# of the corner case of values that are beyond
# the range of SI prefixes (i.e. > 'Y').
_fmant = float("{mant:{fmt}}".format(mant=mant, fmt=fmt))
if _fmant >= 1000 and pow10 != max(self.ENG_PREFIXES):
mant /= 1000
pow10 += 3

prefix = self.ENG_PREFIXES[int(pow10)]

mant = sign * dnum / (10 ** pow10)

if self.places is None:
format_str = "%g %s"
elif self.places == 0:
format_str = "%i %s"
elif self.places > 0:
format_str = ("%%.%if %%s" % self.places)

formatted = format_str % (mant, prefix)

formatted = formatted.strip()
if (self.unit != "") and (prefix == self.ENG_PREFIXES[0]):
formatted = formatted + " "
formatted = "{mant:{fmt}}{sep}{prefix}".format(
mant=mant, sep=self.sep, prefix=prefix, fmt=fmt)

return formatted

Expand Down