Source code for PhysicalQuantities.unit

""" PhysicalUnit class definition

Original author: Georg Brandl <georg@python.org>, https://bitbucket.org/birkenfeld/ipython-physics
"""
from __future__ import annotations
import copy
import json
from functools import reduce, lru_cache
from typing import Dict
from fractions import Fraction

import numpy as np

from .fractdict import FractionalDict
# Remove top-level import causing circular dependency
# from .quantity import PhysicalQuantity


[docs]class UnitError(ValueError): pass
class PhysicalUnit: prefixed: bool = False """Physical unit. A physical unit is defined by a name (possibly composite), a scaling factor, and the exponentials of each of the SI base units that enter into it. Units can be multiplied, divided, and raised to integer powers. Attributes ---------- prefixed: bool If instance is a scaled version of a unit baseunit: PhysicalUnit Base unit if prefixed, otherwise self names: FractionalDict A dictionary mapping each name component to its associated integer power (e.g. `{'m': 1, 's': -1}`). factor: float A scaling factor from base units powers: list[int] The integer powers for each of the nine base units: ['m', 'kg', 's', 'A', 'K', 'mol', 'cd', 'rad', 'sr'] offset: float An additive offset to the unit (used only for temperatures) url: str URL describing the unit verbosename: str The verbose name of the unit (e.g. Coulomb) unece_code: str Official unit code (see https://www.unece.org/fileadmin/DAM/cefact/recommendations/rec20/rec20_Rev9e_2014.xls) """ def __init__(self, names, factor: float, powers: list[int], offset: float = 0, url: str = '', verbosename: str = '', unece_code: str = ''): """ Initialize object Parameters ---------- names: FractionalDict | str A dictionary mapping each name component to its associated integer power (e.g. `{'m': 1, 's': -1}`). As a shorthand, a string may be passed which is assigned an implicit power 1. factor: float A scaling factor from base units powers: list[int] The integer powers for each of the nine base units: ['m', 'kg', 's', 'A', 'K', 'mol', 'cd', 'rad', 'sr'] offset: float, optional An additive offset to the unit (used only for temperatures). Default is 0. url: str, optional URL describing the unit. Default is ''. verbosename: str, optional The verbose name of the unit (e.g. Coulomb). Default is ''. unece_code: str, optional Official unit code (see https://www.unece.org/fileadmin/DAM/cefact/recommendations/rec20/rec20_Rev9e_2014.xls). Default is ''. """ self.baseunit = self self.verbosename = verbosename self.url = url if isinstance(names, str): self.names = FractionalDict() self.names[names] = 1 else: self.names = FractionalDict() for _name in names: self.names[_name] = names[_name] self.factor = factor self.offset = offset if len(base_names) != len(powers): raise ValueError('Invalid number of powers given for existing base_names') self.powers = powers self.unece_code = unece_code def set_name(self, name): """Set unit name as FractionalDict Parameters ---------- name: str Unit name """ self.names = FractionalDict() self.names[name] = 1 @property def name(self) -> str: """ Return name of unit Returns ------- str Name of unit (e.g., 'm/s', 'kg*m^2/s^2'). """ num = '' denom = '' for unit in self.names.keys(): power = self.names[unit] if power < 0: denom = denom + '/' + unit if power < -1: denom = denom + '**' + str(-power) elif power > 0: num = num + '*' + unit if power > 1: num = num + '**' + str(power) if len(num) == 0: num = '1' else: num = num[1:] return num + denom @property def _markdown_name(self) -> str: """ Return name of unit as markdown string Returns ------- str Name of unit formatted as a markdown/LaTeX math string. """ num = '' denom = '' for unit in self.names.keys(): power = self.names[unit] if power < 0: if denom == '': denom = '\\text{' + unit + '}' else: denom = denom + '\\cdot \\text{' + unit + '}' if power < -1: denom = denom + '^' + str(-power) elif power > 0: if num == '': num = '\\text{' + unit + '}' else: num = num + '\\cdot \\text{' + unit + '}' if power > 1: num = num + '^{' + str(power) + '}' if num == '': num = '1' if denom != '': name = '\\frac{' + num + '}{' + denom + '}' else: name = num name = name.replace('\\text{deg}', '\\,^{\\circ}').replace(' pi', ' \\pi ') return name @property def is_power(self) -> bool: """ Test if unit is a power unit (or related, like energy or area for dBsm). Used for dB conversion logic. TODO: This detection logic is currently very basic. Returns ------- bool True if it is considered a power unit (e.g., W, J, m^2), False otherwise. """ p = self.powers # Indices: 0:m, 1:kg, 2:s, 3:A, 4:K, 5:mol, 6:cd, 7:rad, 8:sr # Check for Area (L^2), e.g., m^2 for dBsm if p[0] == 2 and sum(abs(x) for x in p) == 2: return True # Check for Energy (M L^2 T^-2) or Power (M L^2 T^-3) dimensions # Original check used p[3] > -1 (Ampere), likely a typo. # Matching comment: (L^2 M T^-n, n>=2) # Refined check: Ensure Ampere dimension (p[3]) is 0 for true power/energy units. if p[0] == 2 and p[1] == 1 and p[2] <= -2 and p[3] == 0: return True return False @property def is_dimensionless(self) -> bool: """ Check if no dimension is given Returns ------- bool True if dimensionless, False otherwise. """ # Check if all power exponents are zero return not any(self.powers) @property def is_angle(self) -> bool: """ Check if unit is an angle Returns ------- bool True if unit is an angle, False otherwise. """ # Check if radian power is 1 and all other powers sum to 1 (meaning only radian is non-zero) return self.powers[7] == 1 and sum(self.powers) == 1 def __str__(self) -> str: """ Return string text representation of unit Returns ------- str Text representation of unit (e.g., 'm/s', 'km/h^2'). """ name = self.name.strip().replace('**', '^') return name def __repr__(self) -> str: """Return unambiguous string representation of the unit.""" return f'<PhysicalUnit {self.name}>' def _repr_markdown_(self) -> str: """ Return markdown representation for IPython notebooks. Returns ------- str Unit formatted as a markdown math string (e.g., '$\\frac{\\text{m}}{\\text{s}}$'). """ unit = self._markdown_name s = '$%s$' % unit return s def _repr_latex_(self) -> str: """ Return LaTeX representation for IPython notebooks. Returns ------- str Unit formatted as a raw LaTeX math string (e.g., '\\frac{\\text{m}}{\\text{s}}'). """ unit = self._markdown_name s = '%s' % unit return s @property def markdown(self) -> str: """ Return unit as a markdown formatted string. Returns ------- str Unit formatted as a markdown math string (e.g., '$\\frac{\\text{m}}{\\text{s}}$'). """ return self._repr_markdown_() @property def latex(self) -> str: """ Return unit as a LaTeX formatted string. Returns ------- str Unit formatted as a raw LaTeX math string (e.g., '\\frac{\\text{m}}{\\text{s}}'). """ return self._repr_latex_() def __gt__(self, other) -> bool: """ Test if unit is greater than other unit Parameters ---------- other: PhysicalUnit Other unit to compare with Returns ------- bool True, if unit is greater than other unit """ if isphysicalunit(other) and self.powers == other.powers: return self.factor > other.factor raise UnitError(f'Cannot compare different dimensions {self} and {other}') def __ge__(self, other) -> bool: """ Test if unit is greater or equal than other unit Parameters ---------- other: PhysicalUnit Other unit to compare with Returns ------- bool True, if unit is greater or equal than other unit """ if isphysicalunit(other) and self.powers == other.powers: return self.factor >= other.factor raise UnitError(f'Cannot compare different dimensions {self} and {other}') def __lt__(self, other) -> bool: """ Test if unit is less than other unit Parameters ---------- other: PhysicalUnit Other unit to compare with Returns ------- bool True, if unit is less than other unit """ if isphysicalunit(other) and self.powers == other.powers: return self.factor < other.factor raise UnitError(f'Cannot compare different dimensions {self} and {other}') def __le__(self, other) -> bool: """ Test if unit is less or equal than other unit Parameters ---------- other: PhysicalUnit Other unit to compare with Returns ------- bool True, if unit is less or equal than other unit """ if isphysicalunit(other) and self.powers == other.powers: return self.factor <= other.factor raise UnitError(f'Cannot compare different dimensions {self} and {other}') def __eq__(self, other) -> bool: """ Test if unit is equal than other unit Parameters ---------- other: PhysicalUnit Other unit to compare with Returns ------- bool True, if unit is equal than other unit """ if isphysicalunit(other): # Check for compatible dimensions first if self.powers != other.powers: # Units with different dimensions cannot be equal return False # Consider using tolerance for float comparison if needed, e.g., math.isclose return self.factor == other.factor and self.offset == other.offset # If other is not a PhysicalUnit, they are not equal return False def __mul__(self, other): """ Multiply units with other value Parameters ---------- other: PhysicalUnit | PhysicalQuantity | number Value or unit to multiply with Returns ------- PhysicalUnit or PhysicalQuantity Resulting unit or quantity Examples -------- >>> from PhysicalQuantities import q >>> q.m.unit * q.s.unit <PhysicalUnit m*s> >>> q.m.unit * 5 <PhysicalQuantity 5 m> >>> 5 * q.m.unit <PhysicalQuantity 5 m> """ # Import locally from .quantity import PhysicalQuantity if self.offset != 0: raise UnitError(f'Cannot multiply unit {self} with non-zero offset') if isphysicalunit(other): if other.offset != 0: raise UnitError(f'Cannot multiply unit {other} with non-zero offset') return PhysicalUnit(self.names + other.names, self.factor * other.factor, list(map(lambda a, b: a + b, self.powers, other.powers))) elif isinstance(other, PhysicalQuantity): # Defer to PhysicalQuantity's __rmul__ for unit * quantity # This ensures the result is a PhysicalQuantity with combined unit and scaled value # Let standard dispatch handle calling PhysicalQuantity.__mul__ or __rmul__ return other * self else: # Assume 'other' is a scalar: scalar * unit try: # Check if 'other' can be treated as a number float(other) # Return PhysicalQuantity(scalar, unit) return PhysicalQuantity(other, self) except (ValueError, TypeError): raise TypeError(f"Unsupported operand type(s) for *: '{type(self).__name__}' and '{type(other).__name__}'") # __rmul__ should handle scalar * unit correctly by calling __mul__ # No change needed here if __mul__ handles scalar * unit correctly __rmul__ = __mul__ def __truediv__(self, other): """ Divide unit by another value (true division). Parameters ---------- other: PhysicalUnit | PhysicalQuantity | number Value or unit to divide by Returns ------- PhysicalUnit or PhysicalQuantity Resulting unit or quantity Examples -------- >>> from PhysicalQuantities import q >>> q.m.unit / q.s.unit <PhysicalUnit m/s> >>> q.m.unit / 5 <PhysicalQuantity 0.2 m> """ from .quantity import PhysicalQuantity # Import locally if self.offset != 0: raise UnitError(f'Cannot divide unit {self} with non-zero offset in numerator') if isphysicalunit(other): if other.offset != 0: raise UnitError(f'Cannot divide unit {other} with non-zero offset in denominator') return PhysicalUnit(self.names - other.names, self.factor / other.factor, list(map(lambda a, b: a - b, self.powers, other.powers))) elif isinstance(other, PhysicalQuantity): # Let PhysicalQuantity handle division: quantity**-1 * self return (other ** -1) * self else: # Assume 'other' is a scalar divisor try: scalar_denominator = float(other) if scalar_denominator == 0: raise ZeroDivisionError("Scalar division by zero") return PhysicalQuantity(1.0 / scalar_denominator, self) except (ValueError, TypeError): raise TypeError(f"Unsupported operand type(s) for /: '{type(self).__name__}' and '{type(other).__name__}'") def __rtruediv__(self, other): """ Called for `other / self` (true division). Handles `scalar / unit`. Parameters ---------- other: number Scalar numerator Returns ------- PhysicalUnit Resulting reciprocal unit, scaled by the scalar. Examples -------- >>> from PhysicalQuantities import q >>> 10 / q.s.unit <PhysicalUnit 10/s> """ if self.offset != 0: raise UnitError(f'Cannot divide unit {self} with non-zero offset in denominator') # Note: unit / unit is handled by __truediv__ # This method primarily handles scalar / unit try: scalar_numerator = float(other) # Calculate new factor for the reciprocal unit, scaled by the numerator # Avoid division by zero for factor if self.factor == 0: raise ZeroDivisionError("Division by unit with zero factor") new_factor = scalar_numerator / self.factor # Invert powers and names for the resulting unit new_powers = [-p for p in self.powers] new_names = FractionalDict({name: -power for name, power in self.names.items()}) # Create and return the new reciprocal PhysicalUnit return PhysicalUnit(new_names, new_factor, new_powers) except (ValueError, TypeError): raise TypeError(f"Unsupported operand type(s) for /: '{type(other).__name__}' and '{type(self).__name__}'") def __floordiv__(self, other): """ Divide unit by another value (floor division). Note: Floor division is primarily meaningful for the scalar value when dividing by a scalar. For unit/unit or unit/quantity, it behaves like true division for the unit part, but the resulting scalar factor might not be intuitive or physically standard. Parameters ---------- other: PhysicalUnit | PhysicalQuantity | number Value or unit to divide by Returns ------- PhysicalUnit or PhysicalQuantity Resulting unit or quantity Examples -------- >>> from PhysicalQuantities import q >>> q.km.unit // q.m.unit # Operates on factors, returns unit <PhysicalUnit km/m> >>> q.m.unit // 5 <PhysicalQuantity 0.0 m> """ from .quantity import PhysicalQuantity # Import locally if self.offset != 0: raise UnitError(f'Cannot divide unit {self} with non-zero offset in numerator') if isphysicalunit(other): if other.offset != 0: raise UnitError(f'Cannot divide unit {other} with non-zero offset in denominator') # Floor division applies to the factor new_factor = self.factor // other.factor return PhysicalUnit(self.names - other.names, new_factor, list(map(lambda a, b: a - b, self.powers, other.powers))) elif isinstance(other, PhysicalQuantity): # This case is potentially ambiguous. What does unit // quantity mean? # Let's define it as floor division of the scalar part resulting from (1/quantity) * unit # (1/other) * self -> calculate value and apply floor temp_quantity = (other ** -1) * self # We need the value part for floor division. Assuming PhysicalQuantity has a 'value' attribute # This requires knowing the structure of PhysicalQuantity # If PhysicalQuantity doesn't directly support floor division in this way, # this operation might need further definition or restriction. # For now, let's raise an error as the semantics are unclear. raise TypeError(f"Floor division between PhysicalUnit and PhysicalQuantity is not clearly defined.") else: # Assume 'other' is a scalar divisor try: scalar_denominator = float(other) if scalar_denominator == 0: raise ZeroDivisionError("Scalar division by zero") # Perform floor division on the resulting scalar value return PhysicalQuantity(1.0 // scalar_denominator, self) except (ValueError, TypeError): raise TypeError(f"Unsupported operand type(s) for //: '{type(self).__name__}' and '{type(other).__name__}'") # __rfloordiv__ for scalar // unit could be added if needed, # but its physical meaning is often obscure. # Keep __div__ and __rdiv__ assignments for Python 2 compatibility if needed, # but __truediv__ and __rtruediv__ are preferred in Python 3. # If strictly Python 3+, these aliases can be removed. __div__ = __truediv__ __rdiv__ = __rtruediv__ def __pow__(self, exponent: int | float): """ Power of a unit Parameters ---------- exponent: int | float Power exponent (Must be integer or inverse of integer) Returns ------- PhysicalUnit Unit to the power of exponent Examples -------- >>> from PhysicalQuantities import q >>> q.m.unit ** 2 m^2 """ if self.offset != 0: raise UnitError('Cannot exponentiate units %s and %s with non-zero offset' % (self, exponent)) if isinstance(exponent, int): y = lambda x, _p=exponent: x * _p p = list(map(y, self.powers)) f = pow(self.factor, exponent) names = FractionalDict((k, self.names[k] * Fraction(exponent, 1)) for k in self.names) return PhysicalUnit(names, f, p) elif isinstance(exponent, float): inv_exp = 1. / exponent rounded = int(np.floor(inv_exp + 0.5)) if abs(inv_exp - rounded) < 1.e-10: if all(x % rounded == 0 for x in self.powers): f = pow(self.factor, exponent) p = [int(x / rounded) for x in self.powers] if all(x % rounded == 0 for x in self.names.values()): names = FractionalDict((k, v / rounded) for k, v in self.names.items()) else: names = FractionalDict({str(f): 1} if f != 1. else {}) names.update({base_names[i]: p_i for i, p_i in enumerate(p)}) return PhysicalUnit(names, f, p) else: raise UnitError('Illegal exponent %f' % exponent) raise UnitError('Only integer and inverse integer exponents allowed') def __hash__(self): """Return a hash based on factor, offset, and powers tuple.""" # Powers list needs to be converted to a tuple to be hashable return hash((self.factor, self.offset, tuple(self.powers))) def conversion_factor_to(self, other): """Return conversion factor to another unit Parameters ---------- other: PhysicalUnit Unit to compute conversion factor for Returns ------- float Conversion factor Examples -------- >>> from PhysicalQuantities import q >>> q.km.unit.conversion_factor_to(q.m.unit) 1000.0 """ if self.powers != other.powers: raise UnitError(f'Incompatible units: cannot convert from {self} to {other}') if self.offset != other.offset and self.factor != other.factor: # This error might be too strict if only offset differs, conversion_tuple_to handles that. # Perhaps remove this check or refine it. For now, keep it but use f-string. raise UnitError(f'Unit conversion ({self.name} to {other.name}) with different offsets ' f'cannot be expressed as a simple multiplicative factor. Use conversion_tuple_to.') return self.factor / other.factor def conversion_tuple_to(self, other): """Return conversion factor and offset to another unit. The conversion is defined such that ``value_in_other = value_in_self * factor + offset``. Parameters ---------- other: PhysicalUnit Unit to compute conversion factor and offset for Returns ------- tuple[float, float] Tuple ``(factor, offset)``. Examples -------- >>> from PhysicalQuantities import q >>> q.km.unit.conversion_tuple_to(q.m.unit) (1000.0, 0.0) >>> q.degC = add_composite_unit('degC', 1.0, 'K', offset=273.15) # Define Celsius relative to Kelvin >>> q.K.unit.conversion_tuple_to(q.degC.unit) # K to degC (1.0, -273.15) >>> q.degC.unit.conversion_tuple_to(q.K.unit) # degC to K (1.0, 273.15) """ if self.powers != other.powers: raise UnitError(f'Incompatible unit for conversion from {self} to {other}') # Based on the definition: base = value * factor + offset # Let (f1, o1) be the conversion from 'self' (x) to base: base = x * f1 + o1 # Let (f2, o2) be the conversion from 'other' (y) to base: base = y * f2 + o2 # We want (F, O) such that y = x * F + O. # x * f1 + o1 = (x * F + O) * f2 + o2 # x * f1 + o1 = x * F * f2 + O * f2 + o2 # Equating coefficients: # f1 = F * f2 => F = f1 / f2 # o1 = O * f2 + o2 => O = (o1 - o2) / f2 # Note: Division by zero is possible if other.factor is 0, but this shouldn't # happen for physically meaningful units. f1, o1 = self.factor, self.offset f2, o2 = other.factor, other.offset factor = f1 / f2 offset = (o1 - o2) / f2 return factor, offset @property def to_dict(self) -> dict: """Export unit as dictionary. Returns ------- dict Dictionary containing unit description ('name', 'verbosename', 'offset', 'factor', 'base_exponents'). Notes ----- The 'base_exponents' key contains a dictionary mapping base unit names to their integer exponents. """ unit_dict = {'name': self.name, 'verbosename': self.verbosename, 'offset': self.offset, 'factor': self.factor } b = self.baseunit p = b.powers base_dict = {} for i, exponent in enumerate(p): base_dict[base_names[i]] = exponent unit_dict['base_exponents'] = base_dict return unit_dict @property def to_json(self) -> str: """Export unit as JSON string. Returns ------- str JSON string containing the unit description wrapped in a 'PhysicalUnit' key. Notes ----- Uses `to_dict` internally for serialization. """ json_unit = json.dumps({'PhysicalUnit': self.to_dict}) return json_unit @staticmethod def from_dict(unit_dict) -> PhysicalUnit: """Retrieve PhysicalUnit from dict description. Parameters ---------- unit_dict: dict PhysicalUnit stored as dict (matching the format from `to_dict`). Returns ------- PhysicalUnit Retrieved PhysicalUnit instance. Raises ------ UnitError If the unit name in the dictionary does not correspond to a known unit or if the dictionary data mismatches the found unit's definition. Notes ----- This currently requires the unit to have been previously defined (e.g., via `addunit` or `add_composite_unit`). It does not create a new unit definition from the dictionary alone. """ u = findunit(unit_dict['name']) # Check for consistency, but allow for float precision issues in factor/offset? # For now, requires exact match. if u.to_dict != unit_dict: # Provide more detailed error message if possible raise UnitError(f"Unit '{unit_dict['name']}' found, but its definition does not match the provided dict.") return u @staticmethod def from_json(json_unit) -> PhysicalUnit: """Retrieve PhysicalUnit from JSON string description. Parameters ---------- json_unit: str PhysicalUnit encoded as JSON string (matching the format from `to_json`). Returns ------- PhysicalUnit Retrieved PhysicalUnit instance. Raises ------ UnitError If the JSON is invalid or the contained dictionary data is invalid per `from_dict`. """ unit_dict = json.loads(json_unit) return PhysicalUnit.from_dict(unit_dict['PhysicalUnit'])
[docs]def addunit(unit: PhysicalUnit): """ Add a new PhysicalUnit entry to the global `unit_table`. Parameters ----------- unit: PhysicalUnit PhysicalUnit object to add. Raises ------ KeyError If a unit with the same name already exists in `unit_table`. """ if unit.name in unit_table: raise KeyError(f'Unit {unit.name} already defined') unit_table[unit.name] = unit
unit_table: Dict[str, PhysicalUnit] = {} # These are predefined base units base_names = ['m', 'kg', 's', 'A', 'K', 'mol', 'cd', 'rad', 'sr', 'Bit', 'currency'] addunit(PhysicalUnit('m', 1., [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], url='https://en.wikipedia.org/wiki/Metre', verbosename='Metre', unece_code='MTR')) addunit(PhysicalUnit('kg', 1, [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0], url='https://en.wikipedia.org/wiki/Kilogram', verbosename='Kilogram', unece_code='KGM')) addunit(PhysicalUnit('s', 1., [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0], url='https://en.wikipedia.org/wiki/Second', verbosename='Second', unece_code='SEC')) addunit(PhysicalUnit('A', 1., [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], url='https://en.wikipedia.org/wiki/Ampere', verbosename='Ampere', unece_code='AMP')) addunit(PhysicalUnit('K', 1., [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], url='https://en.wikipedia.org/wiki/Kelvin', verbosename='Kelvin', unece_code='KEL')) addunit(PhysicalUnit('mol', 1., [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0], url='https://en.wikipedia.org/wiki/Mole_(unit)', verbosename='Mol', unece_code='C34')) addunit(PhysicalUnit('cd', 1., [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], url='https://en.wikipedia.org/wiki/Candela', verbosename='Candela', unece_code='CDL')) addunit(PhysicalUnit('rad', 1., [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], url='https://en.wikipedia.org/wiki/Radian', verbosename='Radian', unece_code='C81')) addunit(PhysicalUnit('sr', 1., [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], url='https://en.wikipedia.org/wiki/Steradian', verbosename='Steradian', unece_code='D27')) addunit(PhysicalUnit('Bit', 1, [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], url='https://en.wikipedia.org/wiki/Bit', verbosename='Bit', unece_code='')) addunit(PhysicalUnit('currency', 1., [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], url='https://en.wikipedia.org/wiki/Currency', verbosename='Currency', unece_code=''))
[docs]def add_composite_unit(name, factor, units, offset=0, verbosename='', prefixed=False, url=''): """ Add new unit to the `unit_table`, defined relative to existing units. Parameters ----------- name: str Name of the new unit (e.g., 'km', 'degC'). factor: float Scaling factor relative to the base units derived from `units`. units: str String defining the base unit composition (e.g., 'm', 'K', 'm/s'). This string is evaluated using the existing `unit_table`. offset: float, optional Additive offset factor (primarily for temperature scales). Default is 0. verbosename: str, optional A more descriptive name for the unit (e.g., 'Kilometre', 'degree Celsius'). Default is ''. prefixed: bool, optional Indicates if this unit is a standard prefixed version (like 'k'ilo, 'm'illi) of the unit defined by `units`. Affects the `baseunit` attribute. Default is False. url: str, optional A URL linking to more information about the unit. Default is ''. Returns ------- str The name of the newly added unit. Raises ------ KeyError If a unit with `name` already exists or if the `units` string is invalid or cannot be evaluated. ValueError If `factor` or `offset` is not numeric. """ # Import locally to avoid circular dependency at module level from .quantity import PhysicalQuantity if name in unit_table: raise KeyError(f'Unit {name} already defined') # Parse composed units string try: potential_base = eval(units, unit_table) # Ensure we are working with a PhysicalUnit, not a PhysicalQuantity if isinstance(potential_base, PhysicalQuantity): baseunit = potential_base.unit elif isphysicalunit(potential_base): baseunit = potential_base else: # Handle cases where eval returns something unexpected (e.g., a number) raise TypeError(f"Evaluating '{units}' did not result in a PhysicalUnit or PhysicalQuantity.") except (SyntaxError, ValueError, NameError, TypeError) as e: # Catch eval errors and type errors from the check above raise KeyError(f'Invalid or unresolvable units string: {units} -> {e}') # Validate factor and offset values for value in (factor, offset): if not isinstance(value, (int, float)): raise ValueError('Factor and offset values have to be numeric') # Remove unwanted keys from unit_table for key in ['__builtins__', '__args__']: unit_table.pop(key, None) newunit = copy.deepcopy(baseunit) newunit.set_name(name) newunit.verbosename = verbosename newunit.baseunit = baseunit if prefixed else newunit newunit.prefixed = prefixed newunit.url = url newunit.factor *= factor newunit.offset += offset unit_table[name] = newunit return name
# Helper functions # Restore cache decorator
[docs]@lru_cache(maxsize=None) def findunit(unitname): """ Find and return a PhysicalUnit instance from its name string or object. Uses caching for performance. Handles simple fraction notation like '1/s'. Parameters ---------- unitname: str or PhysicalUnit The name of the unit (e.g., 'm', 'km/h', 'N*m') or a PhysicalUnit object. Returns ------- PhysicalUnit The corresponding PhysicalUnit instance. Raises ------ UnitError If the input `unitname` is an empty string, cannot be parsed, or does not correspond to a known unit in the `unit_table`. Examples -------- >>> findunit('mm') <PhysicalUnit mm> >>> findunit('1/s') <PhysicalUnit 1/s> """ # Import locally to avoid circular dependency at module level from .quantity import PhysicalQuantity # Handle PhysicalUnit input directly (it's hashable and what we want) if isphysicalunit(unitname): return unitname if isinstance(unitname, str): if unitname == '': raise UnitError('Empty unit name is not valid') name = unitname.strip().replace('^', '**') # Handle simple fractions like 1/s, but avoid double-inverting things like 1/(m*s) # A more robust parser would be better than eval. if name.startswith('1/') and '(' not in name: name = f'({name[2:]})**-1' try: evaluated_unit = eval(name, unit_table) except NameError: raise UnitError(f'Invalid or unknown unit: {name}') except Exception as e: # Catch other potential eval errors raise UnitError(f'Error parsing unit string "{name}": {e}') # Check what eval returned - it might be a PhysicalQuantity if isphysicalunit(evaluated_unit): unit = evaluated_unit elif isinstance(evaluated_unit, PhysicalQuantity): unit = evaluated_unit.unit # Extract the unit part else: raise UnitError(f'Parsed unit string "{name}" did not result in a valid PhysicalUnit or PhysicalQuantity.') # Clean up namespace pollution from eval if necessary for cruft in ['__builtins__', '__args__']: try: del unit_table[cruft] except KeyError: pass elif isphysicalunit(unitname): # This case should be caught by the initial check, but kept for safety unit = unitname else: # Raise error for other unexpected types raise TypeError(f"findunit() argument must be a str or PhysicalUnit, not {type(unitname).__name__}") # Final check (redundant if logic above is correct, but safe) if not isphysicalunit(unit): raise UnitError(f'Could not resolve "{unitname}" to a PhysicalUnit.') return unit
[docs]def convertvalue(value, src_unit, target_unit): """ Convert a numerical value between compatible units. Handles both multiplicative factors and additive offsets (e.g., temperature scales). Parameters ---------- value: number or array-like The numerical value(s) to convert. src_unit: PhysicalUnit The unit of the input `value`. target_unit: PhysicalUnit The unit to convert the `value` to. Returns ------- number or array-like The converted value(s) in `target_unit`. Raises ------ UnitError If `src_unit` and `target_unit` are incompatible (represent different physical dimensions) or if the conversion cannot be performed (e.g., involving incompatible offsets). Examples -------- >>> from PhysicalQuantities import q >>> convertvalue(1, q.mm.unit, q.km.unit) 1e-06 >>> convertvalue(0, q.degC.unit, q.K.unit) # 0 degC to K 273.15 >>> convertvalue(273.15, q.K.unit, q.degC.unit) # 273.15 K to degC 0.0 """ (factor, offset) = src_unit.conversion_tuple_to(target_unit) if isinstance(value, list): # Converting lists directly might be ambiguous (element-wise?); suggest using NumPy arrays. raise UnitError('Cannot convert units for a standard Python list; use NumPy arrays for element-wise conversion.') # Apply conversion: value_in_other = value_in_self * factor + offset # This works correctly for numbers and NumPy arrays due to broadcasting. return value * factor + offset
[docs]def isphysicalunit(x): """ Check if an object is an instance of the PhysicalUnit class. Parameters ---------- x: Any Object to check. Returns ------- bool True if `x` is a PhysicalUnit instance, False otherwise. """ return isinstance(x, PhysicalUnit)