🌐 AI搜索 & 代理 主页
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions doc/release/next_whats_new/engformatter_significant_digits.rst
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 78 additions & 0 deletions lib/matplotlib/tests/test_ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1999,3 +1999,81 @@

assert ax.get_xgridlines()
assert isinstance(ax.xaxis.get_minor_locator(), mpl.ticker.AutoMinorLocator)

def test_engformatter_significant_digits():

Check warning on line 2003 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 Expected 2 blank lines, found 1 Raw Output: message:"Expected 2 blank lines, found 1" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2003 column:1} end:{line:2003 column:4}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"E302" url:"https://docs.astral.sh/ruff/rules/blank-lines-top-level"} suggestions:{range:{start:{line:2002 column:1} end:{line:2003 column:1}} text:"\n\n"}
"""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

Check warning on line 2009 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 Blank line contains whitespace Raw Output: message:"Blank line contains whitespace" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2009 column:1} end:{line:2009 column:5}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"W293" url:"https://docs.astral.sh/ruff/rules/blank-line-with-whitespace"} suggestions:{range:{start:{line:2009 column:1} end:{line:2009 column:5}}}
result2 = formatter.format_data(123.456)
assert "123.5" in result2 and "Hz" in result2

Check warning on line 2012 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 Blank line contains whitespace Raw Output: message:"Blank line contains whitespace" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2012 column:1} end:{line:2012 column:5}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"W293" url:"https://docs.astral.sh/ruff/rules/blank-line-with-whitespace"} suggestions:{range:{start:{line:2012 column:1} end:{line:2012 column:5}}}
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)

Check warning on line 2036 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 Blank line contains whitespace Raw Output: message:"Blank line contains whitespace" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2036 column:1} end:{line:2036 column:5}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"W293" url:"https://docs.astral.sh/ruff/rules/blank-line-with-whitespace"} suggestions:{range:{start:{line:2036 column:1} end:{line:2036 column:5}}}
# 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")

Check warning on line 2047 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 Blank line contains whitespace Raw Output: message:"Blank line contains whitespace" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2047 column:1} end:{line:2047 column:5}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"W293" url:"https://docs.astral.sh/ruff/rules/blank-line-with-whitespace"} suggestions:{range:{start:{line:2047 column:1} end:{line:2047 column:5}}}
# Value with trailing zeros
assert formatter_keep.format_data(12300) == "12.300 k"
assert formatter_trim.format_data(12300) == "12.3 k"

Check warning on line 2051 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 Blank line contains whitespace Raw Output: message:"Blank line contains whitespace" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2051 column:1} end:{line:2051 column:5}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"W293" url:"https://docs.astral.sh/ruff/rules/blank-line-with-whitespace"} suggestions:{range:{start:{line:2051 column:1} end:{line:2051 column:5}}}
# 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)

Check warning on line 2060 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 Blank line contains whitespace Raw Output: message:"Blank line contains whitespace" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2060 column:1} end:{line:2060 column:5}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"W293" url:"https://docs.astral.sh/ruff/rules/blank-line-with-whitespace"} suggestions:{range:{start:{line:2060 column:1} end:{line:2060 column:5}}}
# Zero - check it contains "0"
result_zero = formatter.format_data(0)
assert "0" in result_zero

Check warning on line 2064 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 Blank line contains whitespace Raw Output: message:"Blank line contains whitespace" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2064 column:1} end:{line:2064 column:5}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"W293" url:"https://docs.astral.sh/ruff/rules/blank-line-with-whitespace"} suggestions:{range:{start:{line:2064 column:1} end:{line:2064 column:5}}}
# Negative values
result = formatter.format_data(-12345)
assert "-12.35" in result
assert "k" in result

Check warning on line 2069 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 Blank line contains whitespace Raw Output: message:"Blank line contains whitespace" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2069 column:1} end:{line:2069 column:5}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"W293" url:"https://docs.astral.sh/ruff/rules/blank-line-with-whitespace"} suggestions:{range:{start:{line:2069 column:1} end:{line:2069 column:5}}}
# 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")

Check warning on line 2079 in lib/matplotlib/tests/test_ticker.py

View workflow job for this annotation

GitHub Actions / ruff

[rdjson] reported by reviewdog 🐶 No newline at end of file Raw Output: message:"No newline at end of file" location:{path:"/home/runner/work/matplotlib/matplotlib/lib/matplotlib/tests/test_ticker.py" range:{start:{line:2079 column:51} end:{line:2079 column:51}}} severity:WARNING source:{name:"ruff" url:"https://docs.astral.sh/ruff"} code:{value:"W292" url:"https://docs.astral.sh/ruff/rules/missing-newline-at-end-of-file"} suggestions:{range:{start:{line:2079 column:51} end:{line:2079 column:51}} text:"\n"}
90 changes: 84 additions & 6 deletions lib/matplotlib/ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -1397,6 +1398,33 @@ def __init__(self, unit="", places=None, sep=" ", *, usetex=None,
* ``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.
Expand All @@ -1414,8 +1442,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"):
Copy link
Member

Choose a reason for hiding this comment

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

Is there a reason to not make trim_zeros a bool?

Copy link
Author

Choose a reason for hiding this comment

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

I initially chose string values to make the intent more explicit in code (e.g., trim_zeros="trim" reads clearly), but a bool would be simpler. I'm happy to change it to 'trim_zeros=False' to keep trailing zeros (i.e., default) and 'trim_zeros=True' to trim trailing zeros. Would you prefer this bool approach?

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,
Expand Down Expand Up @@ -1504,7 +1545,6 @@ 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"

if value < 0:
sign = -1
Expand All @@ -1522,23 +1562,61 @@ 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"
else:
fmt = "g"

# 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"
else:
fmt = "g"

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:
suffix = f"{self.sep}{unit_prefix}{self.unit}"
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):
Expand Down
Loading