unitx 0.1.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unitx
3
- Version: 0.1.3
3
+ Version: 0.3.0
4
4
  Summary: A modern, intuitive Python library for units, quantities, and dimensional analysis.
5
5
  Project-URL: Homepage, https://github.com/DaitTHU/unitx
6
6
  Project-URL: Issues, https://github.com/DaitTHU/unitx/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "unitx"
7
- version = "0.1.3"
7
+ version = "0.3.0"
8
8
  description = "A modern, intuitive Python library for units, quantities, and dimensional analysis."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,7 @@
1
+ """Utilities for working with physical units and quantities."""
2
+
3
+ __all__ = ['Dimension', 'DIMENSIONLESS', 'Unit', 'Quantity']
4
+
5
+ from .dimension import Dimension, DIMENSIONLESS
6
+ from .unit import Unit
7
+ from .quantity import Quantity
@@ -0,0 +1,65 @@
1
+ from ..factor import Factor, ONE, TEN
2
+
3
+
4
+ class PrefixData:
5
+ '''
6
+ Attributes:
7
+ factor (Factor): 1000 for kilo-
8
+ names (tuple[str, ...]): kilo
9
+ '''
10
+
11
+ __slots__ = ('factor', 'names')
12
+
13
+ def __init__(self, facotr: Factor, *names: str) -> None:
14
+ self.factor = facotr
15
+ self.names = names
16
+
17
+ @property
18
+ def name(self) -> str: return self.names[0]
19
+
20
+
21
+ __PREFIX_LIB: dict[str | tuple[str, ...], PrefixData] = {
22
+ # whole unit
23
+ 'Q': PrefixData(TEN ** 30, 'quetta'),
24
+ 'R': PrefixData(TEN ** 27, 'ronna'),
25
+ 'Y': PrefixData(TEN ** 24, 'yotta'),
26
+ 'Z': PrefixData(TEN ** 21, 'zetta'),
27
+ 'E': PrefixData(TEN ** 18, 'exa'),
28
+ 'P': PrefixData(TEN ** 15, 'peta'),
29
+ 'T': PrefixData(TEN ** 12, 'tera'),
30
+ 'G': PrefixData(TEN ** 9, 'giga'),
31
+ 'M': PrefixData(TEN ** 6, 'mega'),
32
+ ('k', 'K'): PrefixData(TEN ** 3, 'kilo'),
33
+ 'h': PrefixData(TEN ** 2, 'hecto'),
34
+ 'da': PrefixData(TEN ** 1, 'deca'),
35
+ '': PrefixData(ONE, ''),
36
+ # sub-unit
37
+ 'd': PrefixData(TEN ** -1, 'deci'),
38
+ 'c': PrefixData(TEN ** -2, 'centi'),
39
+ 'm': PrefixData(TEN ** -3, 'milli'),
40
+ ('µ', 'μ', 'u'): PrefixData(TEN ** -6, 'micro'), # chr(0xB5), chr(0x03BC)
41
+ 'n': PrefixData(TEN ** -9, 'nano'),
42
+ 'p': PrefixData(TEN ** -12, 'pico'),
43
+ 'f': PrefixData(TEN ** -15, 'femto'),
44
+ 'a': PrefixData(TEN ** -18, 'atto'),
45
+ 'z': PrefixData(TEN ** -21, 'zepto'),
46
+ 'y': PrefixData(TEN ** -24, 'yocto'),
47
+ 'r': PrefixData(TEN ** -27, 'ronto'),
48
+ 'q': PrefixData(TEN ** -30, 'quecto'),
49
+ }
50
+
51
+ PREFIX: dict[str, PrefixData] = {
52
+ prefix[0] if isinstance(prefix, tuple) else prefix: data
53
+ for prefix, data in __PREFIX_LIB.items()
54
+ }
55
+ '''prefix {symbol: data}'''
56
+
57
+ PREFIX_NAME: dict[str, str] = {
58
+ name: prefix for prefix, data in PREFIX.items() for name in data.names if name
59
+ }
60
+ '''prefix {name: symbol}'''
61
+
62
+ PREFIX_ALIAS: dict[str, str] = {
63
+ alias: prefix[0] for prefix in __PREFIX_LIB if isinstance(prefix, tuple) for alias in prefix[1:]
64
+ }
65
+ '''prefix {alias: symbol}'''
@@ -0,0 +1,44 @@
1
+ from ..dimension import Dimension
2
+ from ..factor import Factor, ONE, TEN
3
+
4
+
5
+ class UnitData:
6
+ '''
7
+ Attributes:
8
+ factor (Factor): 1/1000 for gram
9
+ names (tuple[str, ...]): liter or litre
10
+ dimension (Dimension): L³ for liter
11
+ prefixable (bool): whether the unit can be prefixed
12
+ '''
13
+
14
+ __slots__ = ('factor', 'names', 'dimension', 'prefixable')
15
+
16
+ def __init__(self, factor: Factor, *names: str, dimension: Dimension, prefixable=True) -> None:
17
+ self.factor = factor
18
+ self.names = names
19
+ self.dimension = dimension
20
+ self.prefixable = prefixable
21
+
22
+ @property
23
+ def name(self) -> str: return self.names[0]
24
+
25
+
26
+ UNIT: dict[str, UnitData] = {
27
+ '': UnitData(ONE, '', dimension=Dimension(), prefixable=False),
28
+ 's': UnitData(ONE, 'second', dimension=Dimension(T=1)),
29
+ 'm': UnitData(ONE, 'meter', 'metre', dimension=Dimension(L=1)),
30
+ 'g': UnitData(TEN ** -3, 'gram', dimension=Dimension(M=1)),
31
+ 'A': UnitData(ONE, 'ampere', dimension=Dimension(I=1)),
32
+ 'K': UnitData(ONE, 'kelvin', dimension=Dimension(Theta=1)),
33
+ 'mol': UnitData(ONE, 'mole', dimension=Dimension(N=1)),
34
+ 'cd': UnitData(ONE, 'candela', dimension=Dimension(J=1)),
35
+ }
36
+ '''unit {symbol: data}'''
37
+
38
+ UNIT_NAME: dict[str, str] = {
39
+ name: unit for unit, data in UNIT.items() for name in data.names if name
40
+ }
41
+ '''unit {name: symbol}'''
42
+
43
+ UNIT_ALIAS: dict[str, str] = {}
44
+ '''unit {alias: symbol}'''
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from fractions import Fraction
4
4
  from typing import Iterable, Literal
5
5
 
6
- from .utils.number import common_fraction, ZERO, FloatConversionWarning
6
+ from .utils.number import common_fraction, ZERO
7
7
  from .utils.special_char import superscript
8
8
 
9
9
  __all__ = ['Dimension', 'DIMENSIONLESS']
@@ -52,6 +52,7 @@ class Dimension:
52
52
  '''
53
53
 
54
54
  __slots__ = ('_exponents',)
55
+ _exponents: tuple[Fraction, ...] # Exponents for T, L, M, I, Θ, N, J
55
56
 
56
57
  def __new__(cls, T=0, L=0, M=0, I=0, Theta=0, N=0, J=0) -> Dimension:
57
58
  exponents = tuple(map(common_fraction, (T, L, M, I, Theta, N, J)))
@@ -62,16 +63,17 @@ class Dimension:
62
63
  return obj
63
64
 
64
65
  def __setattr__(self, name: str, value) -> None:
65
- raise AttributeError(
66
- 'Dimension is immutable and cannot set attribute.')
66
+ raise AttributeError('Dimension is immutable and cannot set attribute.')
67
+
68
+ def __delattr__(self, name: str) -> None:
69
+ raise AttributeError('Dimension is immutable and cannot delete attribute.')
67
70
 
68
71
  @classmethod
69
72
  def _from_iter(cls, iterable: Iterable[Fraction], /) -> Dimension:
70
73
  '''Constructor from an iterable of exponents. Internal use only.'''
71
74
  exponents = tuple(iterable)
72
- if len(exponents) != _LEN:
73
- raise ValueError(
74
- f'Expected {_LEN} exponents, got {len(exponents)}.')
75
+ assert len(exponents) == _LEN, f'Expected {_LEN} exponents, got {len(exponents)}.'
76
+ assert all(isinstance(v, Fraction) for v in exponents), 'All exponents must be of type Fraction.'
75
77
  if not any(exponents):
76
78
  return DIMENSIONLESS
77
79
  obj = object.__new__(cls)
@@ -89,11 +91,7 @@ class Dimension:
89
91
  return self._from_iter(a - b for a, b in zip(self._exponents, other._exponents))
90
92
 
91
93
  def __pow__(self, power: int | Fraction | float) -> Dimension:
92
- if isinstance(power, float):
93
- import warnings
94
- power_fraction = common_fraction(power)
95
- warnings.warn(f'Float power {power} converted to {power_fraction}, use Fraction to specify an exact exponent.', FloatConversionWarning, stacklevel=2)
96
- power = power_fraction
94
+ power = common_fraction(power, stacklevel=2)
97
95
  if not isinstance(power, (int, Fraction)):
98
96
  return NotImplemented
99
97
  return self._from_iter(a * power for a in self._exponents)
@@ -123,8 +121,7 @@ class Dimension:
123
121
  time, length, mass, current, temperature, amount, luminous = T, L, M, I, Θ, N, J
124
122
 
125
123
  def __repr__(self) -> str:
126
- para = ', '.join(f'{s}={v}' for s, v in zip(
127
- _SYMBOL_ASCII, self._exponents) if v)
124
+ para = ', '.join(f'{s}={v}' for s, v in zip(_SYMBOL_ASCII, self._exponents) if v)
128
125
  return '{}({})'.format(self.__class__.__name__, para)
129
126
 
130
127
  def __str__(self) -> str:
@@ -0,0 +1,30 @@
1
+ class UnitxError(Exception):
2
+ '''Base class for all exceptions raised by unitx.'''
3
+
4
+
5
+ class UnitSyntaxError(UnitxError, ValueError):
6
+ '''Exception raised when a unit expression has invalid syntax.'''
7
+
8
+
9
+ class UnitSymbolError(UnitxError, ValueError):
10
+ '''Exception raised when a unit symbol is invalid.'''
11
+
12
+
13
+ class DimensionError(UnitxError, ValueError):
14
+ '''Exception raised when quantities with incompatible dimensions are combined.'''
15
+
16
+
17
+ class UnitxWarning(UserWarning):
18
+ '''Base class for all warnings raised by unitx.'''
19
+
20
+
21
+ class InexactFloatWarning(UnitxWarning):
22
+ '''Warning issued when a float is converted to an approximate Rational.'''
23
+
24
+
25
+ class SymbolExistsWarning(UnitxWarning):
26
+ '''Warning issued when a created symbol already exists.'''
27
+
28
+
29
+ class IncompleteFactorWarning(UnitxWarning):
30
+ '''Warning issued when the factorization of a number is not completed due to time constraints.'''
@@ -0,0 +1,298 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import operator
5
+ from decimal import Decimal
6
+ from fractions import Fraction
7
+ from typing import Callable, Literal, Self
8
+
9
+ from .monomial import Monomial
10
+ from .utils.number import common_fraction, factorint, factorfrac, isprime
11
+
12
+
13
+ class SymbolicNumber:
14
+ '''
15
+ A symbolic representation of a mathematical constant.
16
+
17
+ The constant is identified by a specific symbol and value. Its value is not
18
+ expressible as a product of prime numbers raised to rational exponents.
19
+ '''
20
+ __slots__ = ('_symbol', '_value')
21
+
22
+ def __new__(cls, symbol: str, value: float) -> SymbolicNumber:
23
+ if not isinstance(symbol, str):
24
+ raise TypeError(f'Symbol must be str, got {type(symbol)!r}.')
25
+ if not isinstance(value, float):
26
+ raise TypeError(f'Value must be float, got {type(value)!r}.')
27
+ if not math.isfinite(value) or value <= 0:
28
+ raise ValueError('SymbolicNumber value must be positive.')
29
+ if symbol == '':
30
+ raise ValueError('SymbolicNumber symbol cannot be empty.')
31
+ obj = super().__new__(cls)
32
+ obj._symbol, obj._value = symbol, value
33
+ return obj
34
+
35
+ @property
36
+ def symbol(self) -> str: return self._symbol
37
+ @property
38
+ def value(self) -> float: return self._value
39
+
40
+ def __repr__(self) -> str:
41
+ return f'{type(self).__name__}({self._symbol!r}, {self._value!r})'
42
+
43
+ def __str__(self) -> str: return self._symbol
44
+
45
+ def __float__(self) -> float: return self._value
46
+
47
+ def __hash__(self) -> int: return hash((self._symbol, self._value))
48
+
49
+ def __eq__(self, other: object) -> bool:
50
+ if isinstance(other, SymbolicNumber):
51
+ return self._symbol == other._symbol and self._value == other._value
52
+ if isinstance(other, (int, float)):
53
+ return self._value == other
54
+ return NotImplemented
55
+
56
+ @staticmethod
57
+ def _comparison_op(op) -> Callable[[SymbolicNumber, object], bool]:
58
+ def comparison(self: SymbolicNumber, other: object) -> bool:
59
+ if isinstance(other, SymbolicNumber):
60
+ return op(self._value, other._value)
61
+ if isinstance(other, (int, float)):
62
+ return op(self._value, other)
63
+ return NotImplemented
64
+ return comparison
65
+
66
+ __lt__ = _comparison_op(operator.lt)
67
+ __le__ = _comparison_op(operator.le)
68
+ __gt__ = _comparison_op(operator.gt)
69
+ __ge__ = _comparison_op(operator.ge)
70
+
71
+ @staticmethod
72
+ def _arithmetic_op(op) -> Callable[[SymbolicNumber, object], Factor]:
73
+ def arithmetic(self: SymbolicNumber, other: object) -> Factor:
74
+ if isinstance(other, (int, float, Fraction, SymbolicNumber)):
75
+ return op(Factor(self), Factor(other))
76
+ return NotImplemented
77
+ return arithmetic
78
+
79
+ __mul__ = __rmul__ = _arithmetic_op(operator.mul)
80
+ __truediv__ = _arithmetic_op(operator.truediv)
81
+ __rtruediv__ = _arithmetic_op(lambda self, other: other / self)
82
+
83
+ def __pow__(self, other: int | float | Fraction) -> Factor:
84
+ if not isinstance(other, (int, float, Fraction)):
85
+ return NotImplemented
86
+ return Factor._from_monomial(Monomial({self: common_fraction(other, stacklevel=2)}))
87
+
88
+
89
+ class Factor:
90
+ '''
91
+ An exact representation of scaling factors for unit conversions.
92
+
93
+ This class uses `Monomial` to represent factor number absolutely precisely.
94
+ Instead of using floats which suffer from precision drift during repeated
95
+ multiplications and roots, `Factor` decomposes numbers into a mapping of
96
+ prime bases to their rational exponents.
97
+
98
+ Algebraic Mapping:
99
+ - Base Elements: Prime integers (e.g., 2, 3, 5) or `SymbolicNumber`s (e.g., π).
100
+ - Exponents: `Fraction` representing powers.
101
+
102
+ Examples
103
+ --------
104
+ >>> Factor(12)
105
+ Factor.from_dict({2: 2, 3: 1})
106
+ >>> Factor(2.54) # 127 / 50
107
+ Factor.from_dict({127: 1, 2: -1, 5: -2})
108
+ >>> str(Factor(3) / 10)
109
+ '3/10'
110
+ >>> float(Factor(2.54))
111
+ 2.54
112
+ '''
113
+ __slots__ = ('_monomial', '_value')
114
+ _monomial: Monomial[int | SymbolicNumber]
115
+ _value: float
116
+
117
+ def __init__(self, value: int | float | Fraction | Decimal | SymbolicNumber, /) -> None:
118
+ try:
119
+ if value <= 0:
120
+ raise ValueError('Factor must be positive.')
121
+ except TypeError:
122
+ raise TypeError('Factor must be a number.')
123
+ if isinstance(value, int):
124
+ self._monomial = Monomial(factorint(value))
125
+ self._value = float(value)
126
+ return
127
+ elif isinstance(value, SymbolicNumber):
128
+ self._monomial = Monomial({value: 1})
129
+ self._value = float(value)
130
+ return
131
+ elif isinstance(value, (float, Decimal)):
132
+ value = common_fraction(value, stacklevel=2)
133
+ if isinstance(value, Fraction):
134
+ self._monomial = Monomial(factorfrac(value)) # type: ignore
135
+ self._value = float(value)
136
+ return
137
+ raise TypeError(f'Expected int, float, Fraction, Decimal or SymbolicNumber, got {type(value)!r}.')
138
+
139
+ @classmethod
140
+ def _from_monomial(cls, monomial: Monomial[int | SymbolicNumber], /) -> Self:
141
+ '''Direct constructor from Monomial without copy. Internal use only.'''
142
+ obj = object.__new__(cls)
143
+ obj._monomial = monomial
144
+ return obj
145
+
146
+ @classmethod
147
+ def from_dict(cls, elements: dict[int | SymbolicNumber, int | Fraction], /) -> Self:
148
+ '''Construct a Factor from a dictionary of base elements to their exponents.'''
149
+ monomial = Monomial(elements)
150
+ if not all(isinstance(base, (int, SymbolicNumber)) for base in monomial.bases()):
151
+ raise TypeError('All bases must be int or SymbolicNumber.')
152
+ if not all(isinstance(exp, Fraction) for exp in monomial.exponents()):
153
+ raise TypeError('All exponents must be Fraction.')
154
+ if not all(exp > 0 for base, exp in monomial.components() if isinstance(base, int)):
155
+ raise ValueError('All integer bases must have positive exponents.')
156
+ if not all(isprime(base) for base in monomial.bases() if isinstance(base, int)):
157
+ raise ValueError('All integer bases must be prime numbers.')
158
+ return cls._from_monomial(monomial)
159
+
160
+ def decompose(self, *, extract_integer=True, rationalization_denominator=False) -> tuple[Fraction, Factor]:
161
+ '''
162
+ Decompose the factor into a rational part and an irrational part.
163
+
164
+ Parameters
165
+ ----------
166
+ extract_integer : bool, optional
167
+ If True, extract integer part from the fraction exponents of integer bases.
168
+ This is the difference between 2^(3/2) and 2 * 2^(1/2).
169
+ rationalization_denominator : bool, optional
170
+ If True, exponents of integer bases in the irrational part are all
171
+ positive to rationalize the denominator. This is the difference
172
+ between 2^(-3/2) and 2^(1/2) / 4.
173
+
174
+ Returns
175
+ -------
176
+ tuple[Fraction, Factor]
177
+ A tuple containing the rational part and the irrational part.
178
+ '''
179
+ rational = Fraction(1)
180
+ irrational = {}
181
+ for base, exponent in self._monomial.components():
182
+ if isinstance(base, SymbolicNumber):
183
+ irrational[base] = exponent
184
+ continue
185
+ if exponent.denominator == 1:
186
+ rational *= Fraction(base) ** exponent.numerator
187
+ continue
188
+ if extract_integer or (rationalization_denominator and exponent > 0):
189
+ integer_exponent = math.floor(exponent) \
190
+ if rationalization_denominator else int(exponent)
191
+ rational *= Fraction(base) ** integer_exponent
192
+ irrational[base] = exponent - integer_exponent
193
+ else:
194
+ irrational[base] = exponent
195
+ return rational, Factor._from_monomial(Monomial._from_dict(irrational))
196
+
197
+ @property
198
+ def value(self) -> float:
199
+ if hasattr(self, '_value_cache'):
200
+ return self._value
201
+ rational, irrational = self.decompose()
202
+ if not irrational._monomial:
203
+ return float(rational)
204
+ float_factors = [float(base) ** float(exponent) for base, exponent in irrational._monomial.components()]
205
+ float_factors.append(float(rational))
206
+ float_factors.sort()
207
+ # balance multiplication to minimize floating-point error
208
+ self._value = 1.0
209
+ lo, hi = 0, len(float_factors) - 1
210
+ while lo <= hi:
211
+ if self._value > 1.0:
212
+ self._value *= float_factors[hi]
213
+ hi -= 1
214
+ else:
215
+ self._value *= float_factors[lo]
216
+ lo += 1
217
+ return self._value
218
+
219
+ def __float__(self) -> float: return self.value
220
+
221
+ def format(self, mul: Literal['⋅', '*', ' * ', ' '] = '⋅',
222
+ exp: Literal['sup', '^', '**'] = 'sup', frac: bool = True,
223
+ order: Literal['insertion', 'ascending', 'descending'] = 'insertion',
224
+ compact: bool = True) -> str:
225
+ '''
226
+ Format the factor as a string.
227
+ - `mul`, `exp`, `frac`, and `order` are passed to the `Monomial.format()` method.
228
+ - `compact=True` indicates that the factor is formatted in a compact form,
229
+ with the rational part extracted as a single fraction rather than as
230
+ a product of factors with integer exponents.
231
+ >>> Factor(12).format()
232
+ '12'
233
+ >>> Factor(12).format(compact=False)
234
+ '2²⋅3'
235
+ '''
236
+ if not compact:
237
+ return self._monomial.format(mul=mul, exp=exp, frac=frac, order=order)
238
+ rational, irrational = self.decompose(rationalization_denominator=True)
239
+ display_elements = {}
240
+ if rational.numerator > 1:
241
+ display_elements[rational.numerator] = Fraction(1)
242
+ if rational.denominator > 1:
243
+ display_elements[rational.denominator] = Fraction(-1)
244
+ display_elements.update(irrational._monomial.components())
245
+ return Monomial._from_dict(display_elements).format(mul=mul, exp=exp, frac=frac, order=order)
246
+
247
+ def __str__(self) -> str: return self.format()
248
+
249
+ def __repr__(self) -> str:
250
+ kv = ', '.join(f'{base!r}: {exponent!r}' for base, exponent in self._monomial.components())
251
+ return '%s.from_dict({%s})' % (type(self).__name__, kv)
252
+
253
+ def __eq__(self, other: object) -> bool:
254
+ if isinstance(other, Factor):
255
+ return self._monomial == other._monomial
256
+ if isinstance(other, (int, float, Fraction)):
257
+ return self.value == float(other)
258
+ return NotImplemented
259
+
260
+ def __mul__(self, other: int | float | Fraction | SymbolicNumber | Factor) -> Factor:
261
+ if isinstance(other, (int, float, Fraction, SymbolicNumber)):
262
+ other = Factor(other)
263
+ if not isinstance(other, Factor):
264
+ return NotImplemented
265
+ return Factor._from_monomial(self._monomial * other._monomial)
266
+
267
+ __rmul__ = __mul__
268
+
269
+ def __truediv__(self, other: int | float | Fraction | SymbolicNumber | Factor) -> Factor:
270
+ if other == 0:
271
+ raise ZeroDivisionError('Division by zero.')
272
+ if isinstance(other, (int, float, Fraction, SymbolicNumber)):
273
+ other = Factor(other)
274
+ if not isinstance(other, Factor):
275
+ return NotImplemented
276
+ return Factor._from_monomial(self._monomial / other._monomial)
277
+
278
+ def __rtruediv__(self, other: int | float | Fraction | SymbolicNumber) -> Factor:
279
+ if isinstance(other, (int, float, Fraction, SymbolicNumber)):
280
+ return Factor(other) / self
281
+ return NotImplemented
282
+
283
+ def __pow__(self, other: int | float | Fraction) -> Factor:
284
+ if other == 0:
285
+ return Factor(1)
286
+ other = common_fraction(other, stacklevel=2)
287
+ return Factor._from_monomial(self._monomial ** other)
288
+
289
+ def __call__(self, magnitude):
290
+ '''Mapping `factor(x) = factor * x`'''
291
+ return self.value * magnitude
292
+
293
+
294
+ ONE = Factor(1)
295
+ TWO = Factor(2)
296
+ TEN = Factor(10)
297
+ PI = SymbolicNumber('π', 3.141592653589793)
298
+ '''The ratio of the circumference of a circle to its diameter.'''