From 27fb34d2c2986c5f15c4a37b4e8f0dbd25ff3f8d Mon Sep 17 00:00:00 2001 From: Gianmarco Iparraguirre Date: Sun, 7 Dec 2025 20:48:35 -0500 Subject: [PATCH 1/4] =?UTF-8?q?ENH:=20Add=20digits=20parameter=20to=20EngF?= =?UTF-8?q?ormatterfor=20significant=20figures.=20Added=20digits=20paramet?= =?UTF-8?q?er=20for=20significant=20figure=20formatting,=20trim=5Fzeros=20?= =?UTF-8?q?parameter=20to=20control=20trailing=20zeros,=20ensured=20mutual?= =?UTF-8?q?=20exclusion=20with=20places=20parameter,=20implemented=20bound?= =?UTF-8?q?ary=20rollover=20(e.g.,=20999.95=20=E2=86=92=201.000=20k),=20ad?= =?UTF-8?q?ded=20comprehensive=20tests=20for=20new=20functionality,=20and?= =?UTF-8?q?=20maintained=20backward=20compatibility.=20Should=20address=20?= =?UTF-8?q?#30727?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/matplotlib/tests/test_ticker.py | 78 +++++++++++++++++++++++++++++ lib/matplotlib/ticker.py | 60 +++++++++++++++++++--- 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index a9104cc1b839..60cab2c0e1fc 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -1999,3 +1999,81 @@ def test_minorticks_on_multi_fig(): assert ax.get_xgridlines() assert isinstance(ax.xaxis.get_minor_locator(), mpl.ticker.AutoMinorLocator) + +def test_engformatter_significant_digits(): + """Test EngFormatter with significant digits parameter.""" + formatter = mticker.EngFormatter(unit='Hz', digits=4) + # Test various magnitudes - checking key parts since exact format may vary + result1 = formatter.format_data(12345.6) + assert "12.35" in result1 and "kHz" in result1 + + result2 = formatter.format_data(123.456) + assert "123.5" in result2 and "Hz" in result2 + + result3 = formatter.format_data(1.23456) + assert "1.235" in result3 and "Hz" in result3 + + +def test_engformatter_places_digits_mutually_exclusive(): + """Test that places and digits cannot both be set.""" + with pytest.raises(ValueError, match="Cannot specify.*places.*digits"): + mticker.EngFormatter(places=2, digits=4) + + +def test_engformatter_default_unchanged(): + """Test that default behavior is unchanged.""" + formatter = mticker.EngFormatter(unit='Hz') + # Default should use 'g' format (up to 6 sig figs) + result = formatter.format_data(123456) + assert "kHz" in result + + +def test_engformatter_boundary_rollover(): + """Test rollover at 1000 boundaries.""" + formatter = mticker.EngFormatter(digits=4) + # Just below threshold + assert "999.4" in formatter.format_data(999.4) + + # Rounds up to 1000 -> should become 1.000 k + result = formatter.format_data(999.95) + assert "1.000" in result + assert "k" in result + + +def test_engformatter_trim_zeros(): + """Test trim_zeros parameter.""" + formatter_keep = mticker.EngFormatter(digits=5, trim_zeros="keep") + formatter_trim = mticker.EngFormatter(digits=5, trim_zeros="trim") + + # Value with trailing zeros + assert formatter_keep.format_data(12300) == "12.300 k" + assert formatter_trim.format_data(12300) == "12.3 k" + + # Value without trailing zeros + assert formatter_keep.format_data(12345) == "12.345 k" + assert formatter_trim.format_data(12345) == "12.345 k" + + +def test_engformatter_edge_cases(): + """Test edge cases with digits parameter.""" + formatter = mticker.EngFormatter(digits=4) + + # Zero - check it contains "0" + result_zero = formatter.format_data(0) + assert "0" in result_zero + + # Negative values + result = formatter.format_data(-12345) + assert "-12.35" in result + assert "k" in result + + # Very small + result = formatter.format_data(1.234e-9) + assert "1.234" in result + assert "n" in result + + +def test_engformatter_trim_zeros_validation(): + """Test that trim_zeros parameter is validated.""" + with pytest.raises(ValueError, match="trim_zeros must be"): + mticker.EngFormatter(trim_zeros="invalid") \ No newline at end of file diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index f82eeedc8918..b847b064b496 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1369,8 +1369,9 @@ class EngFormatter(ScalarFormatter): 30: "Q" } - def __init__(self, unit="", places=None, sep=" ", *, usetex=None, - useMathText=None, useOffset=False): + def __init__(self, unit="", places=None, sep=" ", *, + digits=None, trim_zeros="keep", + usetex=None, useMathText=None, useOffset=False): r""" Parameters ---------- @@ -1414,8 +1415,21 @@ def __init__(self, unit="", places=None, sep=" ", *, usetex=None, .. versionadded:: 3.10 """ + if places is not None and digits is not None: + raise ValueError( + "Cannot specify 'places' and 'digits'" + ) + + if trim_zeros not in ("keep", "trim"): + raise ValueError( + f"trim_zeros must be 'keep' or 'trim', got {trim_zeros!r}" + ) + self.unit = unit self.places = places + self.digits = digits + self.trim_zeros = trim_zeros + self.sep = sep super().__init__( useOffset=useOffset, @@ -1504,7 +1518,7 @@ def format_data(self, value): '-1.00 \N{MICRO SIGN}' """ sign = 1 - fmt = "g" if self.places is None else f".{self.places:d}f" + fmt = "g" if self.places is None and self.digits is None else None if value < 0: sign = -1 @@ -1522,13 +1536,47 @@ def format_data(self, value): pow10 = np.clip(pow10, min(self.ENG_PREFIXES), max(self.ENG_PREFIXES)) mant = sign * value / (10.0 ** pow10) + + # Determine format string based on digits or places + if self.digits is not None: + # Significant figures formatting + if mant == 0: + mant_int_digits = 1 + else: + mant_int_digits = int(math.floor(math.log10(abs(mant)))) + 1 + + # Calculate decimal places needed to achieve desired sig figs + decimal_places = max(0, self.digits - mant_int_digits) + fmt = f".{decimal_places}f" + elif self.places is not None: + # Original behavior + fmt = f".{self.places:d}f" + + # Format the mantissa + formatted_mant = format(mant, fmt) + # 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'). - if (abs(float(format(mant, fmt))) >= 1000 + if (abs(float(formatted_mant)) >= 1000 and pow10 < max(self.ENG_PREFIXES)): mant /= 1000 pow10 += 3 + + # Reformat with adjusted exponent + if self.digits is not None: + # After dividing by 1000, we have 1 digit before decimal + mant_int_digits = 1 + decimal_places = max(0, self.digits - mant_int_digits) + fmt = f".{decimal_places}f" + elif self.places is not None: + fmt = f".{self.places:d}f" + + formatted_mant = format(mant, fmt) + + # Trim trailing zeros if requested + if self.trim_zeros == "trim" and "." in formatted_mant: + formatted_mant = formatted_mant.rstrip("0").rstrip(".") unit_prefix = self.ENG_PREFIXES[int(pow10)] if self.unit or unit_prefix: @@ -1536,9 +1584,9 @@ def format_data(self, value): else: suffix = "" if self._usetex or self._useMathText: - return f"${mant:{fmt}}${suffix}" + return f"${formatted_mant}${suffix}" else: - return f"{mant:{fmt}}{suffix}" + return f"{formatted_mant}{suffix}" class PercentFormatter(Formatter): From 1bbde85b1c74444f3e1e032cf3fe9a7805fa5f40 Mon Sep 17 00:00:00 2001 From: Gianmarco Iparraguirre Date: Tue, 9 Dec 2025 10:48:36 -0500 Subject: [PATCH 2/4] Add What's New entry for EngFormatter significant digits feature --- .../engformatter_significant_digits.rst | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 doc/release/next_whats_new/engformatter_significant_digits.rst diff --git a/doc/release/next_whats_new/engformatter_significant_digits.rst b/doc/release/next_whats_new/engformatter_significant_digits.rst new file mode 100644 index 000000000000..7991079f2031 --- /dev/null +++ b/doc/release/next_whats_new/engformatter_significant_digits.rst @@ -0,0 +1,32 @@ +``EngFormatter`` significant digits support +-------------------------------------------- + +The `.ticker.EngFormatter` now supports a ``digits`` parameter to format +tick labels using a fixed number of significant figures across magnitudes, +which is a common requirement in scientific and engineering plots where +values at very different scales should convey comparable precision. + +Previously, only the ``places`` parameter was available. That option fixes +the number of decimal places, which can implicitly change the number of +significant figures as the prefix changes. For example, with ``places=1``, +the formatter would produce "10.0 Hz" (3 significant figures) but +"100.0 MHz" (4 significant figures) for similarly precise values. + +The new ``digits`` parameter ensures consistent precision regardless of +magnitude: + +.. code-block:: python + + import matplotlib.pyplot as plt + import matplotlib.ticker as mticker + + formatter = mticker.EngFormatter(unit='V', digits=4) + # All values display exactly 4 significant figures + formatter.format_data(12345) # "12.35 kV" + formatter.format_data(123.45) # "123.5 V" + formatter.format_data(0.001234) # "1.234 mV" + +In addition, a ``trim_zeros`` parameter controls whether trailing zeros are +preserved (``trim_zeros="keep"``, the default, preserving current behavior) +or removed (``trim_zeros="trim"``) for a more compact visual presentation +in tick labels. From 4403ad93d5ec566b74ff7dc7f217fa83dc26fbf8 Mon Sep 17 00:00:00 2001 From: Gianmarco Iparraguirre Date: Tue, 9 Dec 2025 10:53:54 -0500 Subject: [PATCH 3/4] Consolidate format string logic as requested by reviewer --- lib/matplotlib/ticker.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index b847b064b496..6bd33ad85bfc 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1518,7 +1518,6 @@ def format_data(self, value): '-1.00 \N{MICRO SIGN}' """ sign = 1 - fmt = "g" if self.places is None and self.digits is None else None if value < 0: sign = -1 @@ -1551,6 +1550,8 @@ def format_data(self, value): elif self.places is not None: # Original behavior fmt = f".{self.places:d}f" + else: + fmt = "g" # Format the mantissa formatted_mant = format(mant, fmt) @@ -1571,6 +1572,8 @@ def format_data(self, value): fmt = f".{decimal_places}f" elif self.places is not None: fmt = f".{self.places:d}f" + else: + fmt = "g" formatted_mant = format(mant, fmt) From fcd5e10663d1e654f40126a3c60edef9e9cb8c8c Mon Sep 17 00:00:00 2001 From: Gianmarco Iparraguirre Date: Tue, 9 Dec 2025 10:58:40 -0500 Subject: [PATCH 4/4] Add complete documentation for digits and trim_zeros parameters --- lib/matplotlib/ticker.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 6bd33ad85bfc..52e3bcd25cd8 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1398,6 +1398,33 @@ def __init__(self, unit="", places=None, sep=" ", *, * ``sep="\N{NARROW NO-BREAK SPACE}"`` (``U+202F``); * ``sep="\N{NO-BREAK SPACE}"`` (``U+00A0``). + digits : int, default: None + Number of significant digits to display in the mantissa. + This provides consistent precision across different magnitudes. + For example, with ``digits=4``, the values 12345, 123.45, and + 0.001234 will be formatted as "12.35 k", "123.5", and "1.234 m" + respectively, all displaying exactly 4 significant figures. + Mutually exclusive with *places*. + + If neither *places* nor *digits* is specified, the formatter + uses Python's '%g' format, which displays up to 6 significant + digits. + + .. versionadded:: 3.11 + + trim_zeros : {"keep", "trim"}, default: "keep" + Whether to remove trailing zeros from the mantissa after formatting. + + ``"keep"``: Preserve trailing zeros to maintain the specified + precision (e.g., "12.300 k" with ``digits=5``). + ``"trim"``: Remove trailing zeros for cleaner appearance + (e.g., "12.3 k" with ``digits=5``). + + The default ``"keep"`` preserves mathematical significance, as + trailing zeros indicate the precision of the measurement. + + .. versionadded:: 3.11 + usetex : bool, default: :rc:`text.usetex` To enable/disable the use of TeX's math mode for rendering the numbers in the formatter.