diff --git a/CHANGES b/CHANGES index 1b2981430..2b0754435 100644 --- a/CHANGES +++ b/CHANGES @@ -4,7 +4,9 @@ Pint Changelog 0.24 (unreleased) ----------------- -- Nothing changed yet. +- Add `dim_order` property to BaseFormatter. +- Add `dim_sort` parameter to formatter. + (PR #1864, fixes Issue #1841) 0.23 (2023-12-08) diff --git a/docs/getting/tutorial.rst b/docs/getting/tutorial.rst index 28041339d..d0b9347bf 100644 --- a/docs/getting/tutorial.rst +++ b/docs/getting/tutorial.rst @@ -91,6 +91,30 @@ a ``Quantity()`` object. ``Quantity()`` objects also work well with NumPy arrays, which you can read about in the section on :doc:`NumPy support `. +Some units are compound, such as [energy], which is stated in terms of +[mass] * [length]**2 / [time]**2. Earlier versions of Pint would sort unit names +alphabetically by default, leading to different orderings of units (old behavior): + +``` + "{:P}".format(ureg.parse_units('pound * ft**2 * second**-2')) would yield: + 'foot²·pound/second²' + "{:P}".format(ureg.parse_units('kg * cm**2 * second**-2')) would yield: + 'centimeter²·kilogram/second²' +``` + +Now by default it sorts by dimensions as proposed by ISO 80000, with [mass] +coming before [length], which also comes before [time]. The dimension order +can be changed in the registry (`dim_order` in `defaults`): + +.. doctest:: + + >>> "{:P}".format(ureg.parse_units('pound * ft**2 * second**-2')) + 'pound·foot²/second²' + >>> "{:P}".format(ureg.parse_units('kg * cm**2 * second**-2')) + 'kilogram·centimeter²/second²' + + + Converting to different units ----------------------------- @@ -180,11 +204,11 @@ but otherwise keeping your unit definitions intact. >>> volume = 10*ureg.cc >>> mass = density*volume >>> print(mass) - 14.0 cubic_centimeter * gram / centimeter ** 3 + 14.0 gram * cubic_centimeter / centimeter ** 3 >>> print(mass.to_reduced_units()) 14.0 gram >>> print(mass) - 14.0 cubic_centimeter * gram / centimeter ** 3 + 14.0 gram * cubic_centimeter / centimeter ** 3 >>> mass.ito_reduced_units() >>> print(mass) 14.0 gram diff --git a/pint/delegates/formatter/__init__.py b/pint/delegates/formatter/__init__.py index 3954d69b7..814156afb 100644 --- a/pint/delegates/formatter/__init__.py +++ b/pint/delegates/formatter/__init__.py @@ -7,10 +7,10 @@ """ -from .base_formatter import BabelFormatter +from .base_formatter import BaseFormatter, BabelFormatter -class Formatter(BabelFormatter): +class Formatter(BabelFormatter, BaseFormatter): # TODO: this should derive from all relevant formaters to # reproduce the current behavior of Pint. pass diff --git a/pint/delegates/formatter/base_formatter.py b/pint/delegates/formatter/base_formatter.py index d15a7a6a6..f0db6c5ff 100644 --- a/pint/delegates/formatter/base_formatter.py +++ b/pint/delegates/formatter/base_formatter.py @@ -34,6 +34,18 @@ class BaseFormatter: + # This default order for sorting dimensions was described in the proposed ISO 80000 specification. + dim_order = ( + "[substance]", + "[mass]", + "[current]", + "[luminosity]", + "[length]", + "[]", + "[time]", + "[temperature]", + ) + def format_quantity( self, quantity: PlainQuantity[MagnitudeT], spec: str = "" ) -> str: diff --git a/pint/formatting.py b/pint/formatting.py index b00b771c7..57846a88d 100644 --- a/pint/formatting.py +++ b/pint/formatting.py @@ -13,7 +13,7 @@ import functools import re import warnings -from typing import Callable, Any, TYPE_CHECKING, TypeVar, Optional, Union +from typing import Callable, Any, TYPE_CHECKING, TypeVar, List, Optional, Tuple, Union from collections.abc import Iterable from numbers import Number @@ -197,6 +197,7 @@ def format_pretty(unit: UnitsContainer, registry: UnitRegistry, **options) -> st power_fmt="{}{}", parentheses_fmt="({})", exp_call=_pretty_fmt_exponent, + registry=registry, **options, ) @@ -219,15 +220,26 @@ def latex_escape(string: str) -> str: @register_unit_format("L") def format_latex(unit: UnitsContainer, registry: UnitRegistry, **options) -> str: - preprocessed = {rf"\mathrm{{{latex_escape(u)}}}": p for u, p in unit.items()} + # Lift the sorting by dimensions b/c the preprocessed units are unrecognizeable + sorted_units = dim_sort(unit.items(), registry) + preprocessed = [ + ( + rf"\mathrm{{{latex_escape(u)}}}", + p, + ) + for u, p in sorted_units + ] formatted = formatter( - preprocessed.items(), + preprocessed, as_ratio=True, single_denominator=True, product_fmt=r" \cdot ", division_fmt=r"\frac[{}][{}]", power_fmt="{}^[{}]", parentheses_fmt=r"\left({}\right)", + sort=False, + sort_dims=False, + registry=registry, **options, ) return formatted.replace("[", "{").replace("]", "}") @@ -259,6 +271,7 @@ def format_html(unit: UnitsContainer, registry: UnitRegistry, **options) -> str: division_fmt=r"{}/{}", power_fmt=r"{}{}", parentheses_fmt=r"({})", + registry=registry, **options, ) @@ -273,6 +286,7 @@ def format_default(unit: UnitsContainer, registry: UnitRegistry, **options) -> s division_fmt=" / ", power_fmt="{} ** {}", parentheses_fmt=r"({})", + registry=registry, **options, ) @@ -287,12 +301,67 @@ def format_compact(unit: UnitsContainer, registry: UnitRegistry, **options) -> s division_fmt="/", power_fmt="{}**{}", parentheses_fmt=r"({})", + registry=registry, **options, ) +def dim_sort(items: Iterable[Tuple[str, Number]], registry: UnitRegistry): + """Sort a list of units by dimensional order (from `registry.formatter.dim_order`). + + Parameters + ---------- + items : tuple + a list of tuples containing (unit names, exponent values). + registry : UnitRegistry + the registry to use for looking up the dimensions of each unit. + + Returns + ------- + list + the list of units sorted by most significant dimension first. + + Raises + ------ + KeyError + If unit cannot be found in the registry. + """ + if registry is None or len(items) <= 1: + return items + ret_dict = dict() + dim_order = registry.formatter.dim_order + for unit_name, unit_exponent in items: + cname = registry.get_name(unit_name) + if not cname: + continue + cname_dims = registry.get_dimensionality(cname) + if len(cname_dims) == 0: + cname_dims = {"[]": None} + dim_types = iter(dim_order) + while True: + try: + dim = next(dim_types) + if dim in cname_dims: + if dim not in ret_dict: + ret_dict[dim] = list() + ret_dict[dim].append( + ( + unit_name, + unit_exponent, + ) + ) + break + except StopIteration: + raise KeyError( + f"Unit {unit_name} (aka {cname}) has no recognized dimensions" + ) + + ret = sum([ret_dict[dim] for dim in dim_order if dim in ret_dict], []) + return ret + + def formatter( - items: Iterable[tuple[str, Number]], + items: Iterable[Tuple[str, Number]], as_ratio: bool = True, single_denominator: bool = False, product_fmt: str = " * ", @@ -304,6 +373,8 @@ def formatter( babel_length: str = "long", babel_plural_form: str = "one", sort: bool = True, + sort_dims: bool = True, + registry: Optional[UnitRegistry] = None, ) -> str: """Format a list of (name, exponent) pairs. @@ -334,6 +405,14 @@ def formatter( (Default value = lambda x: f"{x:n}") sort : bool, optional True to sort the formatted units alphabetically (Default value = True) + sort_dims : bool, optional + True to sort the units dimentionally (Default value = False). + When dimensions have multiple units, sort by "most significant dimension" the unit contains + When both `sort` and `sort_dims` are True, sort alphabetically within sorted dimensions + ISO 80000 and other sources guide on how dimensions shoule be ordered; the user + can set their preference in the registry. + registry : UnitRegistry, optional + The registry to use if `sort_dims` is True Returns ------- @@ -354,6 +433,8 @@ def formatter( if sort: items = sorted(items) + if sort_dims: + items = dim_sort(items, registry) for key, value in items: if locale and babel_length and babel_plural_form and key in _babel_units: _key = _babel_units[key] @@ -568,7 +649,7 @@ def vector_to_latex(vec: Iterable[Any], fmtfun: FORMATTER = ".2f".format) -> str def matrix_to_latex(matrix: ItMatrix, fmtfun: FORMATTER = ".2f".format) -> str: - ret: list[str] = [] + ret: List[str] = [] for row in matrix: ret += [" & ".join(fmtfun(f) for f in row)] diff --git a/pint/testsuite/conftest.py b/pint/testsuite/conftest.py index d51bc8c05..201e56a0e 100644 --- a/pint/testsuite/conftest.py +++ b/pint/testsuite/conftest.py @@ -70,19 +70,19 @@ def func_registry(): @pytest.fixture(scope="class") def class_registry(): - """Only use for those test that do not modify the registry.""" + """Only use for those tests that do not modify the registry.""" return pint.UnitRegistry() @pytest.fixture(scope="module") def module_registry(): - """Only use for those test that do not modify the registry.""" + """Only use for those tests that do not modify the registry.""" return pint.UnitRegistry() @pytest.fixture(scope="session") def sess_registry(): - """Only use for those test that do not modify the registry.""" + """Only use for those tests that do not modify the registry.""" return pint.UnitRegistry() diff --git a/pint/testsuite/test_issues.py b/pint/testsuite/test_issues.py index 7f388377d..fb4429b52 100644 --- a/pint/testsuite/test_issues.py +++ b/pint/testsuite/test_issues.py @@ -1155,3 +1155,80 @@ def test_issues_1505(): assert isinstance( ur.Quantity("m/s").magnitude, decimal.Decimal ) # unexpected fail (magnitude should be a decimal) + + +def test_issues_1841(): + import pint + + # sets compact display mode + ur = UnitRegistry() + ur.default_format = "~P" + + # creates quantity + q = ur.Quantity("1 kW * 1 h") + + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=True) + == "kilowatt * hour" + ) + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=False) + == "hour * kilowatt" + ) + + # this prints "1 h·kW", not "1 kW·h" unless sort_dims is True + # print(q) + + q = ur.Quantity("1 kV * A") + + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=True) + == "kilovolt * ampere" + ) + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=False) + == "ampere * kilovolt" + ) + + # this prints "1 A·kV", not "1 kV·A" unless sort_dims is True + # print(q) + + q = ur.Quantity("1 N * m") + + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=True) + == "newton * meter" + ) + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=False) + == "meter * newton" + ) + + # this prints "1 m·N", not "1 N·m" unless sort_dims is True + # print(q) + + +@pytest.mark.xfail +def test_issues_1841_xfail(): + import pint + + # sets compact display mode + ur = UnitRegistry() + ur.default_format = "~P" + + q = ur.Quantity("2*pi radian * hour") + + # Note that `radian` (and `bit` and `count`) are treated as dimensionless. + # And note that dimensionless quantities are stripped by this process, + # leading to errorneous output. Suggestions? + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=True) + == "radian * hour" + ) + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=False) + == "hour * radian" + ) + + # this prints "2*pi hour * radian", not "2*pi radian * hour" unless sort_dims is True + # print(q)