diff --git a/pint/default_en.txt b/pint/default_en.txt index 5fc7f8265..e059b2f8f 100644 --- a/pint/default_en.txt +++ b/pint/default_en.txt @@ -122,13 +122,15 @@ count = [] @import constants_en.txt - #### UNITS #### # Common and less common, grouped by quantity. # Conversion factors are exact (except when noted), # although floating-point conversion may introduce inaccuracies # Angle +[arc_length] = [length] +[radius] = [length] +[angle] = [arc_length] / [radius] turn = 2 * π * radian = _ = revolution = cycle = circle degree = π / 180 * radian = deg = arcdeg = arcdegree = angular_degree arcminute = degree / 60 = arcmin = arc_minute = angular_minute @@ -212,6 +214,9 @@ degree_Reaumur = 4 / 5 * kelvin; offset: 273.15 = °Re = reaumur = degRe = degre atomic_unit_of_temperature = E_h / k = a_u_temp planck_temperature = (hbar * c ** 5 / gravitational_constant / k ** 2) ** 0.5 +# Specific heat capacity +# [specific_heat_capacity] = [energy] / [temperature] / [mass] = J / kg / K + # Area [area] = [length] ** 2 are = 100 * meter ** 2 @@ -229,6 +234,7 @@ stere = meter ** 3 # Frequency [frequency] = 1 / [time] hertz = 1 / second = Hz +[angular_frequency] = [angle] / [time] revolutions_per_minute = revolution / minute = rpm revolutions_per_second = revolution / second = rps counts_per_second = count / second = cps @@ -256,7 +262,7 @@ sverdrup = 1e6 * meter ** 3 / second = sv galileo = centimeter / second ** 2 = Gal # Force -[force] = [mass] * [acceleration] +[force] = [mass] * [acceleration] = N newton = kilogram * meter / second ** 2 = N dyne = gram * centimeter / second ** 2 = dyn force_kilogram = g_0 * kilogram = kgf = kilogram_force = pond @@ -265,7 +271,7 @@ force_metric_ton = g_0 * metric_ton = tf = metric_ton_force = force_t = t_force atomic_unit_of_force = E_h / a_0 = a_u_force # Energy -[energy] = [force] * [length] +[energy] = [force] * [length] = J joule = newton * meter = J erg = dyne * centimeter watt_hour = watt * hour = Wh = watthour @@ -285,8 +291,13 @@ ton_TNT = 1e9 * calorie = tTNT tonne_of_oil_equivalent = 1e10 * international_calorie = toe atmosphere_liter = atmosphere * liter = atm_l +# Torque +[moment_arm] = [length] = m +[torque] = [force] * [moment_arm] = N m +foot_pound = foot * force_pound = ft_lb = footpound + # Power -[power] = [energy] / [time] +[power] = [energy] / [time] = [torque] * [angular_frequency] = [volumetric_flow_rate] * [pressure] = W watt = joule / second = W volt_ampere = volt * ampere = VA horsepower = 550 * foot * force_pound / second = hp = UK_horsepower = hydraulic_horsepower @@ -299,7 +310,7 @@ standard_liter_per_minute = atmosphere * liter / minute = slpm = slm conventional_watt_90 = K_J90 ** 2 * R_K90 / (K_J ** 2 * R_K) * watt = W_90 # Momentum -[momentum] = [length] * [mass] / [time] +[momentum] = [velocity] * [mass] # Density (as auxiliary for pressure) [density] = [mass] / [volume] @@ -328,9 +339,6 @@ foot_H2O = foot * water * g_0 = ftH2O = feet_H2O centimeter_H2O = centimeter * water * g_0 = cmH2O = cm_H2O sound_pressure_level = 20e-6 * pascal = SPL -# Torque -[torque] = [force] * [length] -foot_pound = foot * force_pound = ft_lb = footpound # Viscosity [viscosity] = [pressure] * [time] diff --git a/pint/delegates/formatter/full.py b/pint/delegates/formatter/full.py index e6d0eee47..3776d25c8 100644 --- a/pint/delegates/formatter/full.py +++ b/pint/delegates/formatter/full.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: from ...compat import Locale + from ...facets.kind import KindKind, QuantityKind from ...facets.measurement import Measurement from ...facets.plain import ( MagnitudeT, @@ -130,6 +131,19 @@ def format_unit( unit, uspec, sort_func=sort_func, **babel_kwds ) + def format_kind( + self, + kind: KindKind | Iterable[tuple[str, Any]], + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], + ) -> str: + uspec = uspec or self.default_format + sort_func = sort_func or self.default_sort_func + return self.get_formatter(uspec).format_kind( + kind, uspec, sort_func=sort_func, **babel_kwds + ) + def format_quantity( self, quantity: PlainQuantity[MagnitudeT], @@ -167,6 +181,19 @@ def format_quantity( locale=locale, ) + def format_quantitykind( + self, + quantitykind: QuantityKind | Iterable[tuple[str, Any]], + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], + ) -> str: + uspec = uspec or self.default_format + sort_func = sort_func or self.default_sort_func + return self.get_formatter(uspec).format_quantitykind( + quantitykind, uspec, sort_func=sort_func, **babel_kwds + ) + def format_measurement( self, measurement: Measurement, diff --git a/pint/delegates/formatter/plain.py b/pint/delegates/formatter/plain.py index d40ec1ae0..e6724cb96 100644 --- a/pint/delegates/formatter/plain.py +++ b/pint/delegates/formatter/plain.py @@ -39,6 +39,7 @@ ) if TYPE_CHECKING: + from ...facets.kind import KindKind, QuantityKind from ...facets.measurement import Measurement from ...facets.plain import MagnitudeT, PlainQuantity, PlainUnit from ...registry import UnitRegistry @@ -51,6 +52,41 @@ class BaseFormatter: def __init__(self, registry: UnitRegistry | None = None): self._registry = registry + def format_kind( + self, + kind: KindKind | Iterable[tuple[str, Any]], + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], + ) -> str: + """Format a unit (can be compound) into string + given a string formatting specification and locale related arguments. + """ + kind = kind._kinds + return self.format_unit(kind, uspec, sort_func, **babel_kwds) + + def format_quantitykind( + self, + quantitykind: QuantityKind, + qspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], + ) -> str: + """Format a quantitykind into string + given a string formatting specification and locale related arguments. + """ + quantity = quantitykind.quantity + + registry = self._registry + + mspec, uspec = split_format( + qspec, registry.formatter.default_format, registry.separate_format_defaults + ) + + mu = self.format_quantity(quantity, qspec, sort_func, **babel_kwds) + k = self.format_kind(quantitykind.kinds, uspec, sort_func, **babel_kwds) + return mu + " " + k + class DefaultFormatter(BaseFormatter): """Simple, localizable plain text formatter. diff --git a/pint/delegates/txt_defparser/plain.py b/pint/delegates/txt_defparser/plain.py index ac4230bcb..2e6f6a271 100644 --- a/pint/delegates/txt_defparser/plain.py +++ b/pint/delegates/txt_defparser/plain.py @@ -221,11 +221,11 @@ class DerivedDimensionDefinition( ): """Definition of a derived dimension:: - [dimension name] = + [dimension name] = [= ] Example:: - [density] = [mass] / [volume] + [density] = [mass] / [volume] = kilogram / meter ** 3 """ @classmethod @@ -235,23 +235,31 @@ def from_string_and_config( if not (s.startswith("[") and "=" in s): return None - name, value, *aliases = s.split("=") + name, value, *alt_refs = (p.strip() for p in s.split("=")) - if aliases: - return common.DefinitionSyntaxError( - "Derived dimensions cannot have aliases." - ) + preferred_unit = None + if alt_refs: + if "[" not in alt_refs[-1]: + preferred_unit = alt_refs[-1] + alt_refs = alt_refs[:-1] + else: + alt_refs = [] - try: - reference = config.to_dimension_container(value) - except common.DefinitionSyntaxError as exc: - return common.DefinitionSyntaxError( - f"In {name} derived dimensions must only be referenced " - f"to dimensions. {exc}" - ) + def to_dim_container(string): + try: + reference = config.to_dimension_container(string) + except common.DefinitionSyntaxError as exc: + return common.DefinitionSyntaxError( + f"In {name} derived dimensions must only be referenced " + f"to dimensions. {exc}" + ) + return reference + + reference = to_dim_container(value) + alternate_references = tuple([to_dim_container(ref) for ref in alt_refs]) try: - return cls(name.strip(), reference) + return cls(name.strip(), reference, preferred_unit, alternate_references) except Exception as exc: return common.DefinitionSyntaxError(str(exc)) diff --git a/pint/facets/__init__.py b/pint/facets/__init__.py index 12729289c..25399be0a 100644 --- a/pint/facets/__init__.py +++ b/pint/facets/__init__.py @@ -72,6 +72,7 @@ class that belongs to a registry that has NumpyRegistry as one of its bases. from .context import ContextRegistry, GenericContextRegistry from .dask import DaskRegistry, GenericDaskRegistry from .group import GenericGroupRegistry, GroupRegistry +from .kind import GenericKindRegistry, KindRegistry from .measurement import GenericMeasurementRegistry, MeasurementRegistry from .nonmultiplicative import ( GenericNonMultiplicativeRegistry, @@ -103,4 +104,6 @@ class that belongs to a registry that has NumpyRegistry as one of its bases. "QuantityT", "UnitT", "MagnitudeT", + "GenericKindRegistry", + "KindRegistry", ] diff --git a/pint/facets/kind/__init__.py b/pint/facets/kind/__init__.py new file mode 100644 index 000000000..bad2a5358 --- /dev/null +++ b/pint/facets/kind/__init__.py @@ -0,0 +1,21 @@ +""" + pint.facets.Kind + ~~~~~~~~~~~~~~~~~~~~~~~ + + Adds pint the capability to handle Kinds (quantities with uncertainties). + + :copyright: 2022 by Pint Authors, see AUTHORS for more details. + :license: BSD, see LICENSE for more details. +""" + +from __future__ import annotations + +from .objects import KindKind, KindQuantity +from .registry import GenericKindRegistry, KindRegistry + +__all__ = [ + "KindKind", + "KindQuantity", + "KindRegistry", + "GenericKindRegistry", +] diff --git a/pint/facets/kind/objects.py b/pint/facets/kind/objects.py new file mode 100644 index 000000000..795c78c0c --- /dev/null +++ b/pint/facets/kind/objects.py @@ -0,0 +1,332 @@ +""" + pint.facets.kind.objects + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :copyright: 2024 by Pint Authors, see AUTHORS for more details. + :license: BSD, see LICENSE for more details. +""" + +from __future__ import annotations + +import copy +import locale +import operator +from typing import TYPE_CHECKING + +from ...compat import NUMERIC_TYPES, _to_magnitude +from ...util import PrettyIPython, SharedRegistryObject, UnitsContainer, logger +from ..plain.definitions import UnitDefinition + +if TYPE_CHECKING: + pass + +from typing import Generic + +from ..plain import MagnitudeT, PlainQuantity, PlainUnit + + +class KindQuantity(Generic[MagnitudeT], PlainQuantity[MagnitudeT]): + def to_kind(self, kind): + return self._REGISTRY.QuantityKind(self, kind) + + def compatible_kinds(self): + return self._REGISTRY.get_compatible_kinds(self.dimensionality) + + +class KindUnit(PlainUnit): + def compatible_kinds(self): + return self._REGISTRY.get_compatible_kinds(self.dimensionality) + + +class QuantityKind(KindQuantity, SharedRegistryObject): + """Implements a class to describe a quantity and its kind. + + Parameters + ---------- + value : pint.Quantity or any numeric type + + Returns + ------- + + """ + + def __new__(cls, value, kinds, units=None): + # if is_upcast_type(type(value)): + # raise TypeError(f"PlainQuantity cannot wrap upcast type {type(value)}") + + if units is None and isinstance(value, str) and value == "": + raise ValueError( + "Expression to parse as PlainQuantity cannot be an empty string." + ) + + if units is None and isinstance(value, str): + ureg = SharedRegistryObject.__new__(cls)._REGISTRY + inst = ureg.parse_expression(value) + return cls.__new__(cls, inst) + + if units is None and isinstance(value, cls): + return copy.copy(value) + + inst = SharedRegistryObject().__new__(cls) + + if isinstance(kinds, KindKind): + kinds = kinds._kinds + elif isinstance(kinds, str): + kinds = inst._REGISTRY.parse_kinds(kinds)._kinds + elif isinstance(kinds, UnitsContainer): + kinds = kinds + else: + raise TypeError( + "kinds must be of type str, KindKind or " + "UnitsContainer; not {}.".format(type(kinds)) + ) + + if units is None: + kk = inst._REGISTRY.Kind(kinds) + if kk.preferred_unit: + units = kk.preferred_unit + elif isinstance(value, PlainQuantity): + units = value.units + else: + raise ValueError( + "units must be provided if value is not a Quantity and no preferred unit is defined for the kind." + ) + else: + if isinstance(units, (UnitsContainer, UnitDefinition)): + units = units + elif isinstance(units, str): + units = inst._REGISTRY.parse_units(units)._units + elif isinstance(units, SharedRegistryObject): + if isinstance(units, PlainQuantity) and units.magnitude != 1: + units = copy.copy(units)._units + logger.warning( + "Creating new PlainQuantity using a non unity PlainQuantity as units." + ) + else: + units = units._units + else: + raise TypeError( + "units must be of type str, PlainQuantity or " + "UnitsContainer; not {}.".format(type(units)) + ) + if isinstance(value, PlainQuantity): + magnitude = value.to(units)._magnitude + else: + magnitude = _to_magnitude( + value, inst.force_ndarray, inst.force_ndarray_like + ) + inst._magnitude = magnitude + inst._units = units + inst._kinds = kinds + + return inst + + def __repr__(self) -> str: + return f"" + + def __str__(self): + return f"{self}" + + def __format__(self, spec): + spec = spec or self._REGISTRY.default_format + return self._REGISTRY.formatter.format_quantitykind(self, spec) + + @property + def kinds(self) -> KindKind: + """PlainQuantity's kinds. Long form for `k`""" + return self._REGISTRY.Kind(self._kinds) + + @property + def k(self) -> KindKind: + """PlainQuantity's kinds. Short form for `kinds`""" + return self._REGISTRY.Kind(self._kinds) + + @property + def quantity(self): + return self._REGISTRY.Quantity(self.magnitude, self.units) + + @property + def q(self): + return self._REGISTRY.Quantity(self.magnitude, self.units) + + def _mul_div(self, other, op): + if self._check(other): + if isinstance(other, QuantityKind): + result = op(self.quantity, other.quantity) + result_units_container = op(self._kinds, other._kinds) + + for kind, relations in self._REGISTRY._kind_relations.items(): + if result_units_container in relations: + return result.to_kind(kind) + return result.to_kind(result_units_container) + elif isinstance(other, NUMERIC_TYPES): + return op(self.quantity, other).to_kind(self._kinds) + else: + return op(self.quantity, other) + + def __mul__(self, other): + return self._mul_div(other, operator.mul) + + def __div__(self, other): + return self._mul_div(other, operator.truediv) + + def __truediv__(self, other): + return self._mul_div(other, operator.truediv) + + def __eq__(self, other): + if isinstance(other, QuantityKind): + return self.q == other.q.to(self.units) and self.kinds == other.kinds + else: + return False + + def __pow__(self, other) -> KindKind: + if isinstance(other, NUMERIC_TYPES): + result_q = self.q**other + return self.__class__(result_q.m, self._kinds**other, result_q.u) + + else: + mess = f"Cannot power KindKind by {type(other)}" + raise TypeError(mess) + + +class KindKind(PrettyIPython, SharedRegistryObject): + """Implements a class to describe a kind supporting math operations""" + + def __reduce__(self): + # See notes in Quantity.__reduce__ + from pint import _unpickle_unit + + return _unpickle_unit, (KindKind, self._kinds) + + def __init__(self, kinds) -> None: + super().__init__() + if isinstance(kinds, KindKind): + self._kinds = kinds._kinds + elif isinstance(kinds, UnitsContainer) and kinds.all_kinds(): + self._kinds = kinds + elif isinstance(kinds, str): + print(kinds, 1) + self._kinds = self._REGISTRY.parse_kinds(kinds)._kinds + else: + raise TypeError( + "kinds must be of type str, UnitsContainer; not {}.".format(type(kinds)) + ) + + def __copy__(self) -> KindKind: + ret = self.__class__(self._kinds) + return ret + + def __deepcopy__(self, memo) -> KindKind: + ret = self.__class__(copy.deepcopy(self._kinds, memo)) + return ret + + def __format__(self, spec: str) -> str: + return self._REGISTRY.formatter.format_kind(self, spec) + + def __str__(self) -> str: + return str(self._kinds) + + def __bytes__(self) -> bytes: + return str(self).encode(locale.getpreferredencoding()) + + def __repr__(self) -> str: + return f"" + + @property + def dimensionless(self) -> bool: + """Return True if the KindKind is dimensionless; False otherwise.""" + return not bool(self.dimensionality) + + @property + def dimensionality(self) -> UnitsContainer: + """ + Returns + ------- + dict + Dimensionality of the KindKind, e.g. ``{length: 1, time: -1}`` + """ + try: + return self._dimensionality + except AttributeError: + dim = self._REGISTRY._get_dimensionality(self._kinds) + self._dimensionality = dim + + return self._dimensionality + + @property + def preferred_unit(self) -> UnitsContainer: + units = UnitsContainer() + for kind, exp in self._kinds.items(): + kind_definition = self._REGISTRY._dimensions[kind] + if ( + hasattr(kind_definition, "preferred_unit") + and kind_definition.preferred_unit + ): + units *= ( + self._REGISTRY.Unit(kind_definition.preferred_unit)._units ** exp + ) + else: + # kind = "[torque]" + base_dimensions = self.__class__( + UnitsContainer({kind: 1}) + ).dimensionality + units *= ( + self._REGISTRY._get_base_units_for_dimensionality(base_dimensions) + ** exp + ) + return units + + def compatible_units(self, *contexts): + return self._REGISTRY.Unit(self.preferred_unit).compatible_units(*contexts) + + def kind_relations(self): + # TODO: Find a way to do for compound kinds + if len(self._kinds) == 1: + kind_name = list(self._kinds.keys())[0] + return self._REGISTRY._kind_relations[kind_name] + + def _mul_div(self, other, op): + if self._check(other) and isinstance(other, (KindKind, QuantityKind)): + result_units_container = op(self._kinds, other._kinds) + + for kind, relations in self._REGISTRY._kind_relations.items(): + if result_units_container in relations: + return self.__class__(kind) + return self.__class__(result_units_container) + raise ValueError( + f"Cannot {op} KindKind by {type(other)}. Use KindKind or QuantityKind instead." + ) + + def __mul__(self, other): + return self._mul_div(other, operator.mul) + + __rmul__ = __mul__ + + def __truediv__(self, other): + return self._mul_div(other, operator.truediv) + + def __rtruediv__(self, other): + return self._mul_div(other, operator.truediv) + + __div__ = __truediv__ + __rdiv__ = __rtruediv__ + + def __pow__(self, other) -> KindKind: + if isinstance(other, NUMERIC_TYPES): + return self.__class__(self._kinds**other) + + else: + mess = f"Cannot power KindKind by {type(other)}" + raise TypeError(mess) + + def __hash__(self) -> int: + return self._kinds.__hash__() + + def __eq__(self, other) -> bool: + if self._check(other): + if isinstance(other, self.__class__): + return self._kinds == other._kinds + return False + + def __ne__(self, other) -> bool: + return not (self == other) diff --git a/pint/facets/kind/registry.py b/pint/facets/kind/registry.py new file mode 100644 index 000000000..16bb687a6 --- /dev/null +++ b/pint/facets/kind/registry.py @@ -0,0 +1,75 @@ +""" + pint.facets.kind.registry + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :copyright: 2024 by Pint Authors, see AUTHORS for more details. + :license: BSD, see LICENSE for more details. +""" + + +from __future__ import annotations + +from typing import Any, Generic + +from ...compat import TypeAlias +from ...util import ParserHelper, UnitsContainer, create_class_with_registry +from ..plain import GenericPlainRegistry, QuantityT, UnitT +from . import objects + + +class GenericKindRegistry( + Generic[QuantityT, UnitT], GenericPlainRegistry[QuantityT, UnitT] +): + Kind = objects.KindKind + QuantityKind = objects.QuantityKind + + def _init_dynamic_classes(self) -> None: + """Generate subclasses on the fly and attach them to self""" + super()._init_dynamic_classes() + + self.Kind = create_class_with_registry(self, self.Kind) + self.QuantityKind = create_class_with_registry(self, self.QuantityKind) + + def get_compatible_kinds(self, dimensionality: UnitsContainer) -> frozenset[str]: + return self._cache.kind_dimensional_equivalents.setdefault( + dimensionality, frozenset() + ) + + def _dimensions_to_base_units(self, dimensions: UnitsContainer) -> UnitsContainer: + """Get the base units for a dimension.""" + base_units = {self.Unit(unit).dimensonality: unit for unit in self._base_units} + return self.UnitsContainer( + {base_units[dim]: exp for dim, exp in dimensions.items()} + ) + + # mirror methods for units + def parse_kinds(self, input_string: str) -> objects.KindKind: + return self.Kind(self.parse_kinds_as_container(input_string)) + + def parse_kinds_as_container(self, input_string: str) -> UnitsContainer: + return self._parse_kinds_as_container(input_string) + + def _parse_kinds_as_container(self, input_string: str) -> UnitsContainer: + """Parse a kinds expression and returns a UnitContainer""" + + if not input_string: + return self.UnitsContainer() + + # Sanitize input_string with whitespaces. + input_string = input_string.strip() + + kinds = ParserHelper.from_string(input_string, self.non_int_type) + if kinds.scale != 1: + raise ValueError("kinds expression cannot have a scaling factor.") + + return self.UnitsContainer(kinds) + + +class KindRegistry( + GenericKindRegistry[ + objects.KindQuantity[Any], + objects.KindUnit, + ] +): + Quantity: TypeAlias = objects.KindQuantity[Any] + Unit: TypeAlias = objects.KindUnit diff --git a/pint/facets/measurement/registry.py b/pint/facets/measurement/registry.py index 905de7ab7..1bd288121 100644 --- a/pint/facets/measurement/registry.py +++ b/pint/facets/measurement/registry.py @@ -40,7 +40,8 @@ def no_uncertainties(*args, **kwargs): class MeasurementRegistry( GenericMeasurementRegistry[ - objects.MeasurementQuantity[Any], objects.MeasurementUnit + objects.MeasurementQuantity[Any], + objects.MeasurementUnit, ] ): Quantity: TypeAlias = objects.MeasurementQuantity[Any] diff --git a/pint/facets/nonmultiplicative/registry.py b/pint/facets/nonmultiplicative/registry.py index 4985ba51b..c894c8b83 100644 --- a/pint/facets/nonmultiplicative/registry.py +++ b/pint/facets/nonmultiplicative/registry.py @@ -287,7 +287,8 @@ def _convert( class NonMultiplicativeRegistry( GenericNonMultiplicativeRegistry[ - objects.NonMultiplicativeQuantity[Any], objects.NonMultiplicativeUnit + objects.NonMultiplicativeQuantity[Any], + objects.NonMultiplicativeUnit, ] ): Quantity: TypeAlias = objects.NonMultiplicativeQuantity[Any] diff --git a/pint/facets/plain/definitions.py b/pint/facets/plain/definitions.py index a43ce0dbc..c40b1f228 100644 --- a/pint/facets/plain/definitions.py +++ b/pint/facets/plain/definitions.py @@ -234,22 +234,21 @@ class DerivedDimensionDefinition(DimensionDefinition): #: reference dimensions. reference: UnitsContainer + # preferred unit + preferred_unit: str | None = None + alternate_references: ty.Tuple[UnitsContainer, ...] = () @property def is_base(self) -> bool: return False - def __post_init__(self): - if not errors.is_valid_dimension_name(self.name): - raise self.def_err(errors.MSG_INVALID_DIMENSION_NAME) - - if not all(map(errors.is_dim, self.reference.keys())): - return self.def_err( + def _validate_reference(self, reference: UnitsContainer) -> None: + if not all(map(errors.is_dim, reference.keys())): + raise self.def_err( "derived dimensions must only reference other dimensions" ) - invalid = tuple( - itertools.filterfalse(errors.is_valid_dimension_name, self.reference.keys()) + itertools.filterfalse(errors.is_valid_dimension_name, reference.keys()) ) if invalid: @@ -258,6 +257,14 @@ def __post_init__(self): + errors.MSG_INVALID_DIMENSION_NAME ) + def __post_init__(self): + if not errors.is_valid_dimension_name(self.name): + raise self.def_err(errors.MSG_INVALID_DIMENSION_NAME) + + self._validate_reference(self.reference) + for ref in self.alternate_references: + self._validate_reference(ref) + @dataclass(frozen=True) class AliasDefinition(errors.WithDefErr): diff --git a/pint/facets/plain/quantity.py b/pint/facets/plain/quantity.py index 2727a7da3..a098edd21 100644 --- a/pint/facets/plain/quantity.py +++ b/pint/facets/plain/quantity.py @@ -752,6 +752,13 @@ def _add_sub(self, other, op): self._units, other._units, self.dimensionality, other.dimensionality ) + if hasattr(self, "_kinds") and hasattr(other, "_kinds"): + if self._kinds != other._kinds: + # TODO: Should this be a KindError? + raise ValueError( + f"Cannot add/subtract quantities with different kinds: {self._kinds} and {other._kinds}" + ) + # Next we define some variables to make if-clauses more readable. self_non_mul_units = self._get_non_multiplicative_units() is_self_multiplicative = len(self_non_mul_units) == 0 diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py index 277a6f7a2..648e2b18d 100644 --- a/pint/facets/plain/registry.py +++ b/pint/facets/plain/registry.py @@ -116,6 +116,9 @@ def __init__(self) -> None: #: Maps dimensionality (UnitsContainer) to Units (str) self.dimensional_equivalents: dict[UnitsContainer, frozenset[str]] = {} + #: Maps dimensionality (UnitsContainer) to kinds (str) + self.kind_dimensional_equivalents: dict[UnitsContainer, frozenset[str]] = {} + #: Maps dimensionality (UnitsContainer) to Dimensionality (UnitsContainer) # TODO: this description is not right. self.root_units: dict[UnitsContainer, tuple[Scalar, UnitsContainer]] = {} @@ -135,6 +138,7 @@ def __eq__(self, other: Any): return False attrs = ( "dimensional_equivalents", + "kind_dimensional_equivalents", "root_units", "dimensionality", "parse_unit", @@ -292,6 +296,9 @@ def __init__( #: Might contain prefixed units. self._units: dict[str, UnitDefinition] = {} + #: Map kind name (string) to a set of kind UnitContainers that can be converted to that kind + self._kind_relations: dict[str, set[UnitsContainer]] = {} + #: List base unit names self._base_units: list[str] = [] @@ -558,6 +565,25 @@ def _add_derived_dimension(self, definition: DerivedDimensionDefinition) -> None self._add_dimension(DimensionDefinition(dim_name)) self._helper_adder(definition, self._dimensions, None) + for kind, container in self._rearrange_dimension_definition( + definition.name, definition.reference + ): + self._kind_relations.setdefault(kind, set()).add(container) + + for alt_ref in definition.alternate_references: + for kind, container in self._rearrange_dimension_definition( + definition.name, alt_ref + ): + self._kind_relations.setdefault(kind, set()).add(container) + + def _rearrange_dimension_definition(self, name, reference): + # rearrange to give a UnitContainer that is dimensionless + dimensionless = reference * UnitsContainer({name: -1}) + return [ + (kind, (dimensionless / UnitsContainer({kind: exp})) ** (-1 / exp)) + for kind, exp in dimensionless.items() + ] + def _add_prefix(self, definition: PrefixDefinition) -> None: self._helper_adder(definition, self._prefixes, None) @@ -640,6 +666,13 @@ def _build_cache(self, loaded_files=None) -> None: except Exception as exc: logger.warning(f"Could not resolve {unit_name}: {exc!r}") + + for kind_name in self._dimensions: + di = self._get_dimensionality(self.UnitsContainer({kind_name: 1})) + dimeq_set = self._cache.kind_dimensional_equivalents.setdefault( + di, set() + ) + dimeq_set.add(kind_name) return self._cache def get_name(self, name_or_alias: str, case_sensitive: bool | None = None) -> str: @@ -967,6 +1000,12 @@ def _get_compatible_units( src_dim = self._get_dimensionality(input_units) return self._cache.dimensional_equivalents.setdefault(src_dim, frozenset()) + def get_compatible_kinds(self, dimensionality: UnitsContainer) -> frozenset[str]: + # TODO: Change this to return KindT + return self._cache.kind_dimensional_equivalents.setdefault( + dimensionality, frozenset() + ) + # TODO: remove context from here def is_compatible_with( self, obj1: Any, obj2: Any, *contexts: str | Context, **ctx_kwargs @@ -1256,6 +1295,11 @@ def _parse_units_as_container( return ret + def _dimensions_to_base_units(self, dimensions: UnitsContainer) -> UnitsContainer: + """Get the base units for a dimension.""" + base_units = {self.Unit(unit).dimensonality: unit for unit in self._base_units} + return UnitsContainer({base_units[dim]: exp for dim, exp in dimensions.items()}) + def _eval_token( self, token: TokenInfo, diff --git a/pint/facets/system/registry.py b/pint/facets/system/registry.py index e5235a4cb..d0631e8ea 100644 --- a/pint/facets/system/registry.py +++ b/pint/facets/system/registry.py @@ -222,6 +222,27 @@ def _get_base_units( return base_factor, destination_units + def _get_base_units_for_dimensionality( + self, dim: UnitsContainerT, system: str | objects.System | None = None + ) -> UnitsContainerT: + if system is None: + system = self._default_system_name + sys_units = self.get_system(system, False).base_units + bu = [ + u if u not in sys_units else tuple(sys_units[u])[0] + for u in self._base_units + ] + + # maps dimensionality to base units, {'[length]': 'meter', ...} + base_units_map = { + list(self.parse_expression(unit).dimensionality.keys())[0]: unit + for unit in bu + if len(self.Unit(unit).dimensionality) == 1 + } + return self.UnitsContainer( + {base_units_map[dim]: exp for dim, exp in dim.items()} + ) + def get_compatible_units( self, input_units: UnitsContainerT, group_or_system: str | None = None ) -> frozenset[Unit]: diff --git a/pint/registry.py b/pint/registry.py index 210ea9112..6a68ac4cd 100644 --- a/pint/registry.py +++ b/pint/registry.py @@ -31,6 +31,7 @@ class Quantity( facets.DaskRegistry.Quantity, facets.NumpyRegistry.Quantity, facets.MeasurementRegistry.Quantity, + facets.KindRegistry.Quantity, facets.NonMultiplicativeRegistry.Quantity, facets.PlainRegistry.Quantity, ): @@ -43,6 +44,7 @@ class Unit( facets.DaskRegistry.Unit, facets.NumpyRegistry.Unit, facets.MeasurementRegistry.Unit, + facets.KindRegistry.Unit, facets.NonMultiplicativeRegistry.Unit, facets.PlainRegistry.Unit, ): @@ -56,6 +58,7 @@ class GenericUnitRegistry( facets.GenericDaskRegistry[facets.QuantityT, facets.UnitT], facets.GenericNumpyRegistry[facets.QuantityT, facets.UnitT], facets.GenericMeasurementRegistry[facets.QuantityT, facets.UnitT], + facets.GenericKindRegistry[facets.QuantityT, facets.UnitT], facets.GenericNonMultiplicativeRegistry[facets.QuantityT, facets.UnitT], facets.GenericPlainRegistry[facets.QuantityT, facets.UnitT], ): diff --git a/pint/testsuite/test_definitions.py b/pint/testsuite/test_definitions.py index 56a107689..7e265f0a3 100644 --- a/pint/testsuite/test_definitions.py +++ b/pint/testsuite/test_definitions.py @@ -166,9 +166,10 @@ def test_dimension_definition(self): assert x.is_base assert x.name == "[time]" - x = Definition.from_string("[speed] = [length]/[time]") + x = Definition.from_string("[speed] = [length]/[time] = m / s") assert isinstance(x, DimensionDefinition) assert x.reference == UnitsContainer({"[length]": 1, "[time]": -1}) + assert x.preferred_unit == "m / s" def test_alias_definition(self): x = Definition.from_string("@alias meter = metro = metr") diff --git a/pint/testsuite/test_kind.py b/pint/testsuite/test_kind.py new file mode 100644 index 000000000..2757c1887 --- /dev/null +++ b/pint/testsuite/test_kind.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import pytest + +from pint import UnitRegistry +from pint.util import UnitsContainer + + +class TestKind: + def test_torque_energy(self): + ureg = UnitRegistry() + Q_ = ureg.Quantity + Kind = ureg.Kind + QK_ = ureg.QuantityKind + moment_arm = Q_(1, "m").to_kind("[moment_arm]") + force = Q_(1, "lbf").to_kind("[force]") + # to_kind converts to the preferred_unit of the kind + assert force.units == ureg.N + assert force == QK_(1, "[force]", "lbf") + + # both force and moment_arm have kind defined. + # Torque is defined in default_en: + # [torque] = [force] * [moment_arm] + # the result is a quantity with kind [torque] + torque = force * moment_arm + assert torque.units == ureg.N * ureg.m + assert torque.kinds == Kind("[torque]") + + assert force == torque / moment_arm + assert moment_arm == torque / force + + # Energy is defined in default_en: + # [energy] = [force] * [length] = J + distance = Q_(1, "m").to_kind("[length]") + energy = force * distance + assert energy.kinds == Kind("[energy]") + assert energy.units == ureg.J + + assert force == energy / distance + assert distance == energy / force + + # Torque is not energy so cannot be added + with pytest.raises(ValueError): + energy + torque + + def test_acceleration(self): + ureg = UnitRegistry() + Q_ = ureg.Quantity + Kind = ureg.Kind + velocity = Q_(1, "m/s").to_kind("[velocity]") + time = Q_(1, "s").to_kind("[time]") + acceleration = velocity / time + assert acceleration.kinds == Kind("[acceleration]") + # no preferred unit defined for acceleration, so uses base units + assert acceleration.units == ureg.m / ureg.s**2 + + def test_momentum(self): + ureg = UnitRegistry() + Q_ = ureg.Quantity + Kind = ureg.Kind + mass = Q_(1, "kg").to_kind("[mass]") + velocity = Q_(1, "m/s").to_kind("[velocity]") + momentum = mass * velocity + assert momentum.kinds == Kind("[momentum]") + # no preferred unit defined for momentum, so uses base units + # ensure gram is not used as base unit + assert momentum.units == ureg.kg * ureg.m / ureg.s + + def test_compatible_kinds(self): + ureg = UnitRegistry() + Q_ = ureg.Quantity + q = Q_(1, "N m") + assert "[torque]" in q.compatible_kinds() + assert "[energy]" in q.compatible_kinds() + + @pytest.mark.xfail(reason="Not sure how to deal with order of operations") + def test_three_parameters(self): + ureg = UnitRegistry() + Q_ = ureg.Quantity + energy = Q_(1, "J").to_kind("[energy]") + mass = Q_(1, "kg").to_kind("[mass]") + temperature = Q_(1, "K").to_kind("[temperature]") + + ureg.define("[specific_heat_capacity] = [energy] / [temperature] / [mass]") + specific_heat_capacity = energy / mass / temperature + assert specific_heat_capacity.kinds == "[specific_heat_capacity]" + + # this fails, giving specific_heat_capacity.kinds == [entropy] / [mass] + specific_heat_capacity = energy / temperature / mass + assert specific_heat_capacity.kinds == "[specific_heat_capacity]" + + def test_electrical_power(self): + ureg = UnitRegistry() + Q_ = ureg.Quantity + Kind = ureg.Kind + + ureg.define("[real_power] = [power] = W") + ureg.define("[apparent_power] = [electric_potential] * [current] = VA") + ureg.define( + "[power_factor] = [real_power] / [apparent_power]" + ) # don't have a way to set unit as dimensionless + + real_power = Q_(1, "W").to_kind("[real_power]") + apparent_power = Q_(1, "VA").to_kind("[apparent_power]") + power_factor = real_power / apparent_power + assert power_factor.kinds == Kind("[power_factor]") + assert power_factor.units == ureg.Unit("W / VA") + + def test_kindkind(self): + ureg = UnitRegistry() + Kind = ureg.Kind + + torque = Kind("[torque]") + force = Kind("[force]") + moment_arm = Kind("[moment_arm]") + + assert torque == force * moment_arm + assert force == torque / moment_arm + + assert ureg.Unit("N") in force.compatible_units() + + kind_relations = [ + ("[length]", UnitsContainer({"[wavenumber]": -1.0})), + ("[length]", UnitsContainer({"[area]": 0.5})), + ( + "[length]", + UnitsContainer({"[current]": 1.0, "[magnetic_field_strength]": -1.0}), + ), + ("[length]", UnitsContainer({"[charge]": -1.0, "[electric_dipole]": 1.0})), + ( + "[length]", + UnitsContainer({"[electric_field]": -1.0, "[electric_potential]": 1.0}), + ), + ("[length]", UnitsContainer({"[energy]": 1.0, "[force]": -1.0})), + ( + "[length]", + UnitsContainer({"[esu_charge]": 1.0, "[esu_electric_potential]": -1.0}), + ), + ( + "[length]", + UnitsContainer( + {"[esu_current]": 1.0, "[esu_magnetic_field_strength]": -1.0} + ), + ), + ( + "[length]", + UnitsContainer( + {"[gaussian_charge]": 1.0, "[gaussian_electric_potential]": -1.0} + ), + ), + ( + "[length]", + UnitsContainer( + {"[gaussian_charge]": -1.0, "[gaussian_electric_dipole]": 1.0} + ), + ), + ( + "[length]", + UnitsContainer( + { + "[gaussian_electric_field]": -1.0, + "[gaussian_electric_potential]": 1.0, + } + ), + ), + ( + "[length]", + UnitsContainer( + {"[gaussian_resistance]": -1.0, "[gaussian_resistivity]": 1.0} + ), + ), + ("[length]", UnitsContainer({"[moment_arm]": 1.0})), + ("[length]", UnitsContainer({"[resistance]": -1.0, "[resistivity]": 1.0})), + ("[length]", UnitsContainer({"[time]": 1.0, "[velocity]": 1.0})), + ( + "[length]", + UnitsContainer( + { + "[esu_charge]": 0.6666666666666666, + "[mass]": -0.3333333333333333, + "[time]": 0.6666666666666666, + } + ), + ), + ( + "[length]", + UnitsContainer( + { + "[gaussian_charge]": 0.6666666666666666, + "[mass]": -0.3333333333333333, + "[time]": 0.6666666666666666, + } + ), + ), + ("[length]", UnitsContainer({"[volume]": 0.3333333333333333})), + ] + + @pytest.mark.parametrize(("kind", "relation"), kind_relations) + def test_kindkind_kind_relations(self, kind, relation): + ureg = UnitRegistry() + Kind = ureg.Kind + kind = Kind(kind) + assert relation in kind.kind_relations() + + def test_density(self): + # https://github.com/hgrecco/pint/issues/676#issuecomment-689157693 + ureg = UnitRegistry() + Q_ = ureg.Quantity + Kind = ureg.Kind + + length = Q_(10, "m").to_kind("[length]") + diameter = Q_(1, "mm").to_kind("[length]") + rho = Q_(7200, "kg/m^3").to_kind("[density]") + # mass calc from issue: + # mass = rho * (ureg.pi / 4) * diameter**2 * length + volume = (3.14159 / 4) * diameter**2 * length + mass = rho * volume + + assert mass.kinds == Kind("[mass]") + assert mass.units == ureg.kg + + def test_kindunit(self): + # https://github.com/hgrecco/pint/issues/676 + ureg = UnitRegistry() + # Kind = ureg.Kind + newton = ureg.Unit("N") + # assert Kind("[force]") in newton.compatible_kinds() + assert "[force]" in newton.compatible_kinds() + + def test_waterpump(self): + ureg = UnitRegistry() + Q_ = ureg.Quantity + Kind = ureg.Kind + shaft_speed = Q_(1200, "rpm").to_kind("[angular_frequency]") + flow_rate = Q_(8.72, "m^3 / hr").to_kind("[volumetric_flow_rate]") + pressure_delta = Q_(162, "kPa").to_kind("[pressure]") + shaft_power = Q_(1.32, "kW").to_kind("[power]") + # efficiency = Q_(30.6, "%").to_kind("[dimensionless]") + + shaft_torque = shaft_power / shaft_speed + assert shaft_torque.kinds == Kind("[torque]") + + fluid_power = flow_rate * pressure_delta + assert fluid_power.kinds == Kind("[power]") diff --git a/pint/util.py b/pint/util.py index 0c40c5187..1475f9d56 100644 --- a/pint/util.py +++ b/pint/util.py @@ -419,6 +419,24 @@ def find_connected_nodes( return visited +def is_kind(str_: str) -> bool: + """Check if a string is a valid kind. + + Parameters + ---------- + str_ + String to check. + + Returns + ------- + bool + True if the string is a valid kind. + """ + if str_.startswith("[") and str_.endswith("]"): + return True + return False + + class udict(dict[str, Scalar]): """Custom dict implementing __missing__.""" @@ -475,6 +493,9 @@ def __init__( d[key] = self._non_int_type(value) self._hash = None + def all_kinds(self) -> set[str]: + return all(is_kind(key) for key in self._d) + def copy(self: Self) -> Self: """Create a copy of this UnitsContainer.""" return self.__copy__()