diff --git a/doc/api/api_changes/2017-08-24-deprecation-in-engformatter.rst b/doc/api/api_changes/2017-08-24-deprecation-in-engformatter.rst new file mode 100644 index 000000000000..630bab605668 --- /dev/null +++ b/doc/api/api_changes/2017-08-24-deprecation-in-engformatter.rst @@ -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. diff --git a/doc/users/whats_new/EngFormatter_new_kwarg_sep.rst b/doc/users/whats_new/EngFormatter_new_kwarg_sep.rst new file mode 100644 index 000000000000..9e4bb2840e64 --- /dev/null +++ b/doc/users/whats_new/EngFormatter_new_kwarg_sep.rst @@ -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). diff --git a/examples/api/engineering_formatter.py b/examples/api/engineering_formatter.py index 743ee819cae9..4d9f2dfdec90 100644 --- a/examples/api/engineering_formatter.py +++ b/examples/api/engineering_formatter.py @@ -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() diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 865cc085c376..921d40cae3f7 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -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): diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 65a23537b58a..84933aeb0e35 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -173,7 +173,6 @@ import six -import decimal import itertools import locale import math @@ -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", @@ -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 + 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): @@ -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)``. """ - 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