diff --git a/pint/delegates/formatter/_compound_unit_helpers.py b/pint/delegates/formatter/_compound_unit_helpers.py index 06a8ac2d3..46934c063 100644 --- a/pint/delegates/formatter/_compound_unit_helpers.py +++ b/pint/delegates/formatter/_compound_unit_helpers.py @@ -25,7 +25,7 @@ ) from ...compat import TypeAlias, babel_parse -from ...util import UnitsContainer +from ...util import NonReducingUnitsContainer, UnitsContainer T = TypeVar("T") U = TypeVar("U") @@ -246,6 +246,7 @@ def prepare_compount_unit( locale: Locale | str | None = None, as_ratio: bool = True, registry: UnitRegistry | None = None, + empty_numerator_fmt="1", ) -> tuple[Iterable[tuple[str, T]], Iterable[tuple[str, T]]]: """Format compound unit into unit container given an spec and locale. @@ -257,6 +258,8 @@ def prepare_compount_unit( if isinstance(unit, UnitsContainer): out = unit.items() + elif hasattr(unit, "_units") and isinstance(unit._units, NonReducingUnitsContainer): + out = unit._units.non_reduced_d_items elif hasattr(unit, "_units"): out = unit._units.items() else: diff --git a/pint/delegates/formatter/_format_helpers.py b/pint/delegates/formatter/_format_helpers.py index 8a2f37a59..429c0ba5f 100644 --- a/pint/delegates/formatter/_format_helpers.py +++ b/pint/delegates/formatter/_format_helpers.py @@ -163,6 +163,7 @@ def formatter( power_fmt: str = "{} ** {}", parentheses_fmt: str = "({0})", exp_call: FORMATTER = "{:n}".format, + empty_numerator_fmt="1", ) -> str: """Format a list of (name, exponent) pairs. @@ -185,6 +186,8 @@ def formatter( the format used for parenthesis. (Default value = "({0})") exp_call : callable (Default value = lambda x: f"{x:n}") + empty_numerator_fmt : str + the format used for an empty numerator. (Default value = "1") Returns ------- @@ -220,7 +223,7 @@ def formatter( return join_u(product_fmt, pos_terms + neg_terms) # Show as Ratio: positive terms / negative terms - pos_ret = join_u(product_fmt, pos_terms) or "1" + pos_ret = join_u(product_fmt, pos_terms) or empty_numerator_fmt if not neg_terms: return pos_ret diff --git a/pint/delegates/formatter/full.py b/pint/delegates/formatter/full.py index d5de43326..5133e0691 100644 --- a/pint/delegates/formatter/full.py +++ b/pint/delegates/formatter/full.py @@ -16,7 +16,7 @@ from ..._typing import Magnitude from ...compat import Unpack, babel_parse -from ...util import iterable +from ...util import NonReducingUnitsContainer, iterable from ._compound_unit_helpers import BabelKwds, SortFunc, sort_by_unit_name from ._to_register import REGISTERED_FORMATTERS from .html import HTMLFormatter @@ -40,6 +40,11 @@ from ...registry import UnitRegistry +def _sort_func(items, registry): + # print([i for i in items]) + return items + + class FullFormatter(BaseFormatter): """A formatter that dispatch to other formatters. @@ -134,8 +139,16 @@ def format_unit( ) -> str: uspec = uspec or self.default_format sort_func = sort_func or self.default_sort_func + empty_numerator_fmt = "1" + if isinstance(unit._units, NonReducingUnitsContainer): + sort_func = _sort_func + empty_numerator_fmt = "" return self.get_formatter(uspec).format_unit( - unit, uspec, sort_func=sort_func, **babel_kwds + unit, + uspec, + sort_func=sort_func, + empty_numerator_fmt=empty_numerator_fmt, + **babel_kwds, ) def format_quantity( diff --git a/pint/delegates/formatter/plain.py b/pint/delegates/formatter/plain.py index d40ec1ae0..5cf77f3e0 100644 --- a/pint/delegates/formatter/plain.py +++ b/pint/delegates/formatter/plain.py @@ -81,6 +81,7 @@ def format_unit( unit: PlainUnit | Iterable[tuple[str, Any]], uspec: str = "", sort_func: SortFunc | None = None, + empty_numerator_fmt="1", **babel_kwds: Unpack[BabelKwds], ) -> str: """Format a unit (can be compound) into string @@ -203,6 +204,7 @@ def format_unit( unit: PlainUnit | Iterable[tuple[str, Any]], uspec: str = "", sort_func: SortFunc | None = None, + empty_numerator_fmt="1", **babel_kwds: Unpack[BabelKwds], ) -> str: numerator, denominator = prepare_compount_unit( @@ -225,6 +227,7 @@ def format_unit( division_fmt=division_fmt, power_fmt="{}**{}", parentheses_fmt=r"({})", + empty_numerator_fmt=empty_numerator_fmt, ) def format_quantity( @@ -313,6 +316,7 @@ def format_unit( unit: PlainUnit | Iterable[tuple[str, Any]], uspec: str = "", sort_func: SortFunc | None = None, + empty_numerator_fmt="1", **babel_kwds: Unpack[BabelKwds], ) -> str: numerator, denominator = prepare_compount_unit( @@ -339,6 +343,7 @@ def format_unit( power_fmt="{}{}", parentheses_fmt="({})", exp_call=pretty_fmt_exponent, + empty_numerator_fmt=empty_numerator_fmt, ) def format_quantity( diff --git a/pint/facets/plain/quantity.py b/pint/facets/plain/quantity.py index a18919273..33c85ee07 100644 --- a/pint/facets/plain/quantity.py +++ b/pint/facets/plain/quantity.py @@ -206,6 +206,8 @@ def __new__(cls, value, units=None): if units is None: units = inst.UnitsContainer() else: + if isinstance(units, list): + units = inst._REGISTRY.NonReducingUnitContainer(units) if isinstance(units, (UnitsContainer, UnitDefinition)): units = units elif isinstance(units, str): diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py index 09fd220ee..7f5a499d3 100644 --- a/pint/facets/plain/registry.py +++ b/pint/facets/plain/registry.py @@ -63,7 +63,9 @@ from ...errors import DimensionalityError, RedefinitionError, UndefinedUnitError from ...pint_eval import build_eval_tree from ...util import ( + NonReducingUnitsContainer, ParserHelper, + UnitsContainer, _is_dim, create_class_with_registry, getattr_maybe_raise, @@ -72,7 +74,6 @@ string_preprocessor, to_units_container, ) -from ...util import UnitsContainer as UnitsContainer from .definitions import ( AliasDefinition, CommentDefinition, @@ -217,6 +218,7 @@ def __init__( force_ndarray: bool = False, force_ndarray_like: bool = False, on_redefinition: str = "warn", + auto_reduce_units: bool = True, auto_reduce_dimensions: bool = False, autoconvert_to_preferred: bool = False, preprocessors: list[PreprocessorType] | None = None, @@ -261,6 +263,9 @@ def __init__( #: Action to take in case a unit is redefined. 'warn', 'raise', 'ignore' self._on_redefinition = on_redefinition + #: Determines if units should be reduced on appropriate operations. + self.auto_reduce_units = auto_reduce_units + #: Determines if dimensionality should be reduced on appropriate operations. self.auto_reduce_dimensions = auto_reduce_dimensions @@ -1258,7 +1263,6 @@ def _parse_units_as_container( if as_delta: cache[input_string] = ret - return ret def _eval_token( @@ -1402,7 +1406,20 @@ def _define_op(s: str): # TODO: Maybe in the future we need to change it to a more meaningful # non-colliding name. def UnitsContainer(self, *args: Any, **kwargs: Any) -> UnitsContainer: - return UnitsContainer(*args, non_int_type=self.non_int_type, **kwargs) + return UnitsContainer( + *args, + non_int_type=self.non_int_type, + auto_reduce_units=self.auto_reduce_units, + **kwargs, + ) + + def NonReducingUnitsContainer(self, *args: Any, **kwargs: Any) -> UnitsContainer: + return NonReducingUnitsContainer( + *args, + non_int_type=self.non_int_type, + auto_reduce_units=self.auto_reduce_units, + **kwargs, + ) __call__ = parse_expression diff --git a/pint/registry.py b/pint/registry.py index ceb9b62d1..c0c0e96ce 100644 --- a/pint/registry.py +++ b/pint/registry.py @@ -120,6 +120,7 @@ def __init__( autoconvert_offset_to_baseunit: bool = False, on_redefinition: str = "warn", system=None, + auto_reduce_units=True, auto_reduce_dimensions=False, autoconvert_to_preferred=False, preprocessors=None, @@ -136,6 +137,7 @@ def __init__( default_as_delta=default_as_delta, autoconvert_offset_to_baseunit=autoconvert_offset_to_baseunit, system=system, + auto_reduce_units=auto_reduce_units, auto_reduce_dimensions=auto_reduce_dimensions, autoconvert_to_preferred=autoconvert_to_preferred, preprocessors=preprocessors, diff --git a/pint/testsuite/test_unit.py b/pint/testsuite/test_unit.py index 2156bbafd..7ce1a84e4 100644 --- a/pint/testsuite/test_unit.py +++ b/pint/testsuite/test_unit.py @@ -832,6 +832,36 @@ def test_case_sensitivity(self): assert ureg.parse_units("j", case_sensitive=False) == UnitsContainer(joule=1) +class TestNonReducing(QuantityTestCase): + def test_init(self): + ureg = self.ureg + NRUC_ = ureg.NonReducingUnitsContainer + + strain_unit_container = NRUC_([(ureg.mm, 1), (ureg.mm, -1)]) + assert strain_unit_container.non_reduced_units == [ + ureg.UnitsContainer({"millimeter": 1}), + ureg.UnitsContainer({"millimeter": -1}), + ] + assert strain_unit_container == ureg.dimensionless + + def test_ureg_auto_reduce_units(self): + ureg = UnitRegistry(auto_reduce_units=False) + NRUC_ = ureg.NonReducingUnitsContainer + + strain_unit = ureg.Unit("mm") / ureg.Unit("mm") + strain_unit == NRUC_([(ureg.mm, 1), (ureg.mm, -1)]) + strain_unit == ureg.Unit("dimensionless") + + strain_q = ureg.Quantity(1, "mm") / ureg.Quantity(1, "mm") + assert strain_q.units == strain_unit + + def test_formatting(self): + ureg = UnitRegistry(auto_reduce_units=False) + strain_unit = ureg.Unit("mm") / ureg.Unit("mm") + assert format(strain_unit, "~D") == "mm / mm" + assert format(strain_unit, "P") == "millimeter/millimeter" + + class TestCaseInsensitiveRegistry(QuantityTestCase): kwargs = dict(case_sensitive=False) diff --git a/pint/util.py b/pint/util.py index c7a7ec10c..9e2dc5b07 100644 --- a/pint/util.py +++ b/pint/util.py @@ -442,15 +442,20 @@ class UnitsContainer(Mapping[str, Scalar]): Numerical type used for non integer values. """ - __slots__ = ("_d", "_hash", "_one", "_non_int_type") + __slots__ = ("_d", "_hash", "_one", "_non_int_type", "_auto_reduce_units") _d: udict _hash: int | None _one: Scalar _non_int_type: type + _auto_reduce_units: bool def __init__( - self, *args: Any, non_int_type: type | None = None, **kwargs: Any + self, + *args: Any, + non_int_type: type | None = None, + auto_reduce_units: bool = True, + **kwargs: Any, ) -> None: if args and isinstance(args[0], UnitsContainer): default_non_int_type = args[0]._non_int_type @@ -458,6 +463,7 @@ def __init__( default_non_int_type = float self._non_int_type = non_int_type or default_non_int_type + self._auto_reduce_units = auto_reduce_units if self._non_int_type is float: self._one = 1 @@ -620,13 +626,20 @@ def __copy__(self): out._hash = self._hash out._non_int_type = self._non_int_type out._one = self._one + out._auto_reduce_units = self._auto_reduce_units return out + def __deepcopy__(self, memo): + return self.copy() + def __mul__(self, other: Any): - if not isinstance(other, self.__class__): + if not isinstance(other, UnitsContainer): err = "Cannot multiply UnitsContainer by {}" raise TypeError(err.format(type(other))) + if not self._auto_reduce_units: + return NonReducingUnitsContainer([self, other]) + new = self.copy() for key, value in other.items(): new._d[key] += value @@ -650,10 +663,13 @@ def __pow__(self, other: Any): return new def __truediv__(self, other: Any): - if not isinstance(other, self.__class__): + if not isinstance(other, UnitsContainer): err = "Cannot divide UnitsContainer by {}" raise TypeError(err.format(type(other))) + if not self._auto_reduce_units: + return NonReducingUnitsContainer([self, UnitsContainer({}) / other]) + new = self.copy() for key, value in other.items(): new._d[key] -= self._normalize_nonfloat_value(value) @@ -664,7 +680,7 @@ def __truediv__(self, other: Any): return new def __rtruediv__(self, other: Any): - if not isinstance(other, self.__class__) and other != 1: + if not isinstance(other, UnitsContainer) and other != 1: err = "Cannot divide {} by UnitsContainer" raise TypeError(err.format(type(other))) @@ -676,6 +692,52 @@ def _normalize_nonfloat_value(self, value: Scalar) -> Scalar: return value +class NonReducingUnitsContainer(UnitsContainer): + """The NonReducingUnitsContainer stores UnitsContainers without simplifying common units. + This is useful when it is desired to show a unit in the numerator and denominator, eg mm/mm. + """ + + def __init__( + self, + units: list[UnitsContainer] | list[tuple[QuantityOrUnitLike, Scalar]], + non_int_type: type | None = None, + auto_reduce_units: bool = True, + ) -> None: + self.non_reduced_units = [] + self._non_int_type = non_int_type + self.auto_reduce_units = auto_reduce_units + + for u in units: + if isinstance(u, tuple): + u = u[0]._units ** u[1] + if hasattr(u, "_units"): + u = u._units + self.non_reduced_units.append(u) + + self.reduced_units = UnitsContainer() + for unit in self.non_reduced_units: + self.reduced_units *= unit + + self._d = self.reduced_units._d + self._hash = self.reduced_units._hash + + self.non_reduced_d_items = [ + (key, value) + for uc in self.non_reduced_units + for key, value in uc._d.items() + ] + self.i = 0 + + def __repr__(self) -> str: + tmp = "[%s]" % ", ".join( + [f"'{key}': {value}" for key, value in self.non_reduced_d_items] + ) + return f"" + + def unit_items(self) -> Iterable[tuple[str, Scalar]]: + return [items for _d in self.non_reduced_units for items in _d.items()] + + class ParserHelper(UnitsContainer): """The ParserHelper stores in place the product of variables and their respective exponent and implements the corresponding operations.