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
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


[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. C{{'m': 1, 's': -1}}) for M{m/s}) factor: float A scaling factor from base units powers: list 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. C{{'m': 1, 's': -1}}) for M{m/s}). As a shorthand, a string may be passed which is assigned an implicit power 1. factor: A scaling factor from base units powers: The integer powers for each of the nine base units: ['m', 'kg', 's', 'A', 'K', 'mol', 'cd', 'rad', 'sr'] offset: An additive offset to the unit (used only for temperatures) url: URL describing the unit verbosename: The verbose name of the unit (e.g. Coulomb) unece_code: Official unit code (see https://www.unece.org/fileadmin/DAM/cefact/recommendations/rec20/rec20_Rev9e_2014.xls) """ 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 """ 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 as markdown 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. Used of dB conversion TODO: basically very dumb right now Returns ------- bool True if it is a power unit, i.e. W, J or anything like it """ p = self.powers if p == [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]: return True # for m^ -> dBsm if p[0] == 2 and p[1] == 1 and p[3] > -1: return True return False @property def is_dimensionless(self) -> bool: """ Check if no dimension is given Returns ------- bool True if dimensionless """ return not reduce(lambda a, b: a or b, self.powers) @property def is_angle(self) -> bool: """ Check if unit is an angle Returns ------- bool True if unit is an angle """ return self.powers[7] == 1 and reduce(lambda a, b: a + b, self.powers) == 1 def __str__(self) -> str: """ Return string text representation of unit Returns ------- str Text representation of unit """ name = self.name.strip().replace('**', u'^') return name def __repr__(self) -> str: return '<PhysicalUnit ' + self.name + '>' def _repr_markdown_(self) -> str: """ Return markdown representation for IPython notebook Returns ------- str Unit as LaTeX string """ unit = self._markdown_name s = '$%s$' % unit return s def _repr_latex_(self) -> str: """ Return LaTeX representation for IPython notebook Returns ------- str Unit as LaTeX string """ unit = self._markdown_name s = '%s' % unit return s @property def markdown(self) -> str: """ Return unit as a markdown formatted string Returns ------- str Unit as LaTeX string """ return self._repr_markdown_() @property def latex(self) -> str: """ Return unit as a LaTeX formatted string Returns ------- str Unit as LaTeX string """ 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('Cannot compare different dimensions %s and %s' % (self, 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('Cannot compare different dimensions %s and %s' % (self, 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('Cannot compare different dimensions %s and %s' % (self, 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('Cannot compare different dimensions %s and %s' % (self, 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) and self.powers == other.powers: return self.factor == other.factor raise UnitError('Cannot compare different dimensions %s and %s' % (self, other)) def __mul__(self, other): """ Multiply units with other value Parameters ---------- other: Value or unit to multiply with Returns ------- PhysicalUnit or PhysicalQuantity Multiplied unit Examples -------- >>> from PhysicalQuantities import q >>> q.m.unit * q.s.unit m*s """ from .quantity import PhysicalQuantity if self.offset != 0 or (isphysicalunit(other) and other.offset != 0): raise UnitError(f'Cannot multiply units {self} and {other} with non-zero offset') if isphysicalunit(other): 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): other = other.unit newpowers = [a + b for a, b in zip(other.powers, self.powers)] return PhysicalUnit(self.names + FractionalDict({str(other): 1}), self.factor * other.factor, newpowers, self.offset) else: return PhysicalQuantity(other, self) __rmul__ = __mul__ def __div__(self, other): """ Divide two units Parameters ---------- other: PhysicalUnit Other unit to divide Returns ------- PhysicalUnit Divided unit Examples -------- >>> from PhysicalQuantities import q >>> q.m.unit / q.s.unit m/s """ from .quantity import PhysicalQuantity if self.offset != 0 or (isphysicalunit(other) and other.offset != 0): raise UnitError(f'Cannot divide units {self} and {other} with non-zero offset') if isphysicalunit(other): 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): other = other.unit newpowers = [a - b for a, b in zip(other.powers, self.powers)] return PhysicalUnit(self.names + FractionalDict({str(other): 1}), self.factor / other.factor, newpowers) else: return PhysicalUnit(self.names + FractionalDict({str(other): -1}), self.factor/other.factor, self.powers) def __rdiv__(self, other): if self.offset != 0 or (isphysicalunit(other) and other.offset != 0): raise UnitError('Cannot divide units %s and %s with non-zero offset' % (self, other)) if isphysicalunit(other): return PhysicalUnit(other.names - self.names, other.factor / self.factor, list(map(lambda a, b: a - b, other.powers, self.powers))) else: return PhysicalUnit(FractionalDict({str(other): 1}) - self.names, other / self.factor, list(map(lambda x: -x, self.powers))) def __floordiv__(self, other): """ Divide two units Parameters ---------- other: PhysicalUnit Other unit to divide Returns ------- PhysicalUnit Divided unit Examples -------- >>> from PhysicalQuantities import q >>> q.m.unit / q.s.unit m/s """ if self.offset != 0 or (isphysicalunit(other) and other.offset != 0): raise UnitError(f'Cannot divide units {self} and {other} with non-zero offset') if isphysicalunit(other): return PhysicalUnit(self.names - other.names, self.factor // other.factor, list(map(lambda a, b: a - b, self.powers, other.powers))) else: # TODO: add test return PhysicalUnit(self.names + FractionalDict({str(other): -1}), self.factor//other.factor, self.powers) __truediv__ = __div__ __rtruediv__ = __rdiv__ def __pow__(self, exponent: PhysicalUnit | int): """ Power of a unit Parameters ---------- exponent: PhysicalUnit Power exponent 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): """Custom hash function""" return hash((self.factor, self.offset, str(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('Incompatible units') if self.offset != other.offset and self.factor != other.factor: raise UnitError(('Unit conversion (%s to %s) cannot be expressed ' + 'as a simple multiplicative factor') % (self.name, other.name)) return self.factor / other.factor def conversion_tuple_to(self, other): """Return conversion factor and offset to another unit Parameters ---------- other: PhysicalUnit Unit to compute conversion factor and offset for Returns ------- float tuple Tuple (factor, offset) Examples -------- >>> from PhysicalQuantities import q >>> q.km.unit.conversion_tuple_to(q.m.unit) (1000.0, 0.0) """ if self.powers != other.powers: raise UnitError(f'Incompatible unit for conversion from {self} to {other}') # let (s1,d1) be the conversion tuple from 'self' to base units # (ie. (x+d1)*s1 converts a value x from 'self' to base units, # and (x/s1)-d1 converts x from base to 'self' units) # and (s2,d2) be the conversion tuple from 'other' to base units # then we want to compute the conversion tuple (S,D) from # 'self' to 'other' such that (x+D)*S converts x from 'self' # units to 'other' units # the formula to convert x from 'self' to 'other' units via the # base units is (by definition of the conversion tuples): # ( ((x+d1)*s1) / s2 ) - d2 # = ( (x+d1) * s1/s2) - d2 # = ( (x+d1) * s1/s2 ) - (d2*s2/s1) * s1/s2 # = ( (x+d1) - (d1*s2/s1) ) * s1/s2 # = (x + d1 - d2*s2/s1) * s1/s2 # thus, D = d1 - d2*s2/s1 and S = s1/s2 factor = self.factor / other.factor offset = self.offset - (other.offset * other.factor / self.factor) return factor, offset @property def to_dict(self) -> dict: """Export unit as dict Returns ------- dict Dict containing unit description Notes ----- Give unit and iterate over base units """ 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 Notes ----- Give unit and iterate over base units """ 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 Returns ------- PhysicalUnit Retrieved PhysicalUnit Notes ----- Current implementation: throw exception if unit has not already been defined """ u = findunit(unit_dict['name']) if u.to_dict != unit_dict: raise UnitError(f'Unit {str(u)} does not correspond to given 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 Returns ------- PhysicalUnit New PhysicalUnit """ unit_dict = json.loads(json_unit) return PhysicalUnit.from_dict(unit_dict['PhysicalUnit'])
[docs]def addunit(unit): """ Add new PhysicalUnit entry to the unit_table Parameters ----------- unit: Physicalunit PhysicalUnit object Raises ------ KeyError If unit already exists """ 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 Parameters ----------- name: str Name of the unit factor: float scaling factor units: str Composed units of new unit offset: float Offset factor verbosename: str A more verbose name for the unit prefixed: bool This is a prefixed unit url: str A URL linking to more information about the unit Returns ------- str Name of new unit Raises ------ KeyError If unit already exists or if units string is invalid ValueError If factor or offset is not numeric """ if name in unit_table: raise KeyError(f'Unit {name} already defined') # Parse composed units string try: baseunit = eval(units, unit_table) except (SyntaxError, ValueError): raise KeyError(f'Invalid units string: {units}') # 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
[docs]@lru_cache(maxsize=None) def findunit(unitname): """ Return PhysicalUnit class if given parameter is a valid unit Parameters ---------- unitname: str or PhysicalUnit Unit to check if valid Returns ------- PhysicalUnit Unit Raises ------ UnitError If the input is invalid. Examples -------- >>> findunit('mm') <PhysicalUnit mm> """ if isinstance(unitname, str): if unitname == '': raise UnitError('Empty unit name is not valid') name = unitname.strip().replace('^', '**') if name.startswith('1/'): name = '(' + name[2:] + ')**-1' try: unit = eval(name, unit_table) except NameError: raise UnitError('Invalid or unknown unit %s' % name) for cruft in ['__builtins__', '__args__']: try: del unit_table[cruft] except KeyError: pass else: unit = unitname if not isphysicalunit(unit): raise UnitError(f'{str(unit)} is not a unit') return unit
[docs]def convertvalue(value, src_unit, target_unit): """ Convert between units, if possible Parameters ---------- value: Value in source units src_unit: PhysicalUnit Source unit target_unit: PhysicalUnit Target unit Returns ------- any Value scaled to target unit Examples -------- >>> from PhysicalQuantities import q >>> convertvalue(1, q.mm.unit, q.km.unit) 1e-06 """ (factor, offset) = src_unit.conversion_tuple_to(target_unit) if isinstance(value, list): raise UnitError('Cannot convert units for a list') return (value + offset) * factor
[docs]def isphysicalunit(x): """ Return true if valid PhysicalUnit class Parameters ---------- x: PhysicalUnit Unit """ return isinstance(x, PhysicalUnit)