unitx 0.1.2__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.
- {unitx-0.1.2 → unitx-0.3.0}/PKG-INFO +1 -1
- {unitx-0.1.2 → unitx-0.3.0}/pyproject.toml +1 -1
- unitx-0.3.0/src/unitx/__init__.py +7 -0
- unitx-0.3.0/src/unitx/_data/prefix.py +65 -0
- unitx-0.3.0/src/unitx/_data/unit.py +44 -0
- unitx-0.3.0/src/unitx/dimension.py +132 -0
- unitx-0.3.0/src/unitx/exceptions.py +30 -0
- unitx-0.3.0/src/unitx/factor.py +298 -0
- unitx-0.3.0/src/unitx/monomial.py +251 -0
- unitx-0.3.0/src/unitx/quantity.py +128 -0
- unitx-0.3.0/src/unitx/singleunit.py +116 -0
- unitx-0.3.0/src/unitx/unit.py +86 -0
- unitx-0.3.0/src/unitx/unitparser.py +184 -0
- unitx-0.3.0/src/unitx/utils/__init__.py +0 -0
- unitx-0.3.0/src/unitx/utils/number.py +121 -0
- unitx-0.3.0/src/unitx/utils/special_char.py +41 -0
- unitx-0.3.0/src/unitx/utils/valuetype.py +15 -0
- unitx-0.3.0/tests/test_dimension.py +81 -0
- unitx-0.3.0/tests/test_factor.py +113 -0
- unitx-0.3.0/tests/test_monomial.py +113 -0
- unitx-0.3.0/tests/test_quantity.py +105 -0
- unitx-0.3.0/tests/test_singleunit.py +72 -0
- unitx-0.3.0/tests/test_unit.py +50 -0
- unitx-0.3.0/tests/test_unitparser.py +123 -0
- unitx-0.1.2/src/unitx/__init__.py +0 -3
- unitx-0.1.2/tests/test_import.py +0 -5
- {unitx-0.1.2 → unitx-0.3.0}/.gitignore +0 -0
- {unitx-0.1.2 → unitx-0.3.0}/LICENSE +0 -0
- {unitx-0.1.2 → unitx-0.3.0}/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: unitx
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -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}'''
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fractions import Fraction
|
|
4
|
+
from typing import Iterable, Literal
|
|
5
|
+
|
|
6
|
+
from .utils.number import common_fraction, ZERO
|
|
7
|
+
from .utils.special_char import superscript
|
|
8
|
+
|
|
9
|
+
__all__ = ['Dimension', 'DIMENSIONLESS']
|
|
10
|
+
|
|
11
|
+
_SYMBOL = ('T', 'L', 'M', 'I', 'Θ', 'N', 'J')
|
|
12
|
+
_SYMBOL_ASCII = ('T', 'L', 'M', 'I', 'Theta', 'N', 'J')
|
|
13
|
+
_LEN = len(_SYMBOL)
|
|
14
|
+
_ALLZERO = (ZERO,) * _LEN
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Dimension:
|
|
18
|
+
'''
|
|
19
|
+
Implementation of physical dimensions.
|
|
20
|
+
- `T`: time
|
|
21
|
+
- `L`: length
|
|
22
|
+
- `M`: mass
|
|
23
|
+
- `I`: current
|
|
24
|
+
- `Θ`: temperature (use ASCII alias `Theta` for code)
|
|
25
|
+
- `N`: amount
|
|
26
|
+
- `J`: luminous
|
|
27
|
+
|
|
28
|
+
Construct
|
|
29
|
+
---
|
|
30
|
+
>>> dim_velocity = Dimension(L=1, T=-1)
|
|
31
|
+
>>> dim_force = Dimension(M=1, L=1, T=-2)
|
|
32
|
+
|
|
33
|
+
Attributes
|
|
34
|
+
---
|
|
35
|
+
You can access the exponents of each dimension by symbols or full names.
|
|
36
|
+
>>> dim_force.T
|
|
37
|
+
-2
|
|
38
|
+
>>> dim_force.mass
|
|
39
|
+
1
|
|
40
|
+
|
|
41
|
+
Operations
|
|
42
|
+
---
|
|
43
|
+
You can perform multiplication, division, and exponentiation on dimensions.
|
|
44
|
+
>>> dim_force * dim_velocity
|
|
45
|
+
Dimension(T=-3, L=2, M=1)
|
|
46
|
+
>>> dim_velocity / Dimension(T=1)
|
|
47
|
+
Dimension(T=-2, L=1)
|
|
48
|
+
>>> dim_force ** 2
|
|
49
|
+
Dimension(T=-4, L=2, M=2)
|
|
50
|
+
>>> 1 / dim_force
|
|
51
|
+
Dimension(T=2, L=-1, M=-1)
|
|
52
|
+
'''
|
|
53
|
+
|
|
54
|
+
__slots__ = ('_exponents',)
|
|
55
|
+
_exponents: tuple[Fraction, ...] # Exponents for T, L, M, I, Θ, N, J
|
|
56
|
+
|
|
57
|
+
def __new__(cls, T=0, L=0, M=0, I=0, Theta=0, N=0, J=0) -> Dimension:
|
|
58
|
+
exponents = tuple(map(common_fraction, (T, L, M, I, Theta, N, J)))
|
|
59
|
+
if not any(exponents):
|
|
60
|
+
return DIMENSIONLESS
|
|
61
|
+
obj = object.__new__(cls)
|
|
62
|
+
object.__setattr__(obj, '_exponents', exponents)
|
|
63
|
+
return obj
|
|
64
|
+
|
|
65
|
+
def __setattr__(self, name: str, value) -> None:
|
|
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.')
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def _from_iter(cls, iterable: Iterable[Fraction], /) -> Dimension:
|
|
73
|
+
'''Constructor from an iterable of exponents. Internal use only.'''
|
|
74
|
+
exponents = tuple(iterable)
|
|
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.'
|
|
77
|
+
if not any(exponents):
|
|
78
|
+
return DIMENSIONLESS
|
|
79
|
+
obj = object.__new__(cls)
|
|
80
|
+
object.__setattr__(obj, '_exponents', exponents)
|
|
81
|
+
return obj
|
|
82
|
+
|
|
83
|
+
def __mul__(self, other: Dimension) -> Dimension:
|
|
84
|
+
if not isinstance(other, Dimension):
|
|
85
|
+
return NotImplemented
|
|
86
|
+
return self._from_iter(a + b for a, b in zip(self._exponents, other._exponents))
|
|
87
|
+
|
|
88
|
+
def __truediv__(self, other: Dimension) -> Dimension:
|
|
89
|
+
if not isinstance(other, Dimension):
|
|
90
|
+
return NotImplemented
|
|
91
|
+
return self._from_iter(a - b for a, b in zip(self._exponents, other._exponents))
|
|
92
|
+
|
|
93
|
+
def __pow__(self, power: int | Fraction | float) -> Dimension:
|
|
94
|
+
power = common_fraction(power, stacklevel=2)
|
|
95
|
+
if not isinstance(power, (int, Fraction)):
|
|
96
|
+
return NotImplemented
|
|
97
|
+
return self._from_iter(a * power for a in self._exponents)
|
|
98
|
+
|
|
99
|
+
def __rtruediv__(self, other: Literal[1]) -> Dimension:
|
|
100
|
+
if other == 1:
|
|
101
|
+
return self._from_iter(-a for a in self._exponents)
|
|
102
|
+
return NotImplemented
|
|
103
|
+
|
|
104
|
+
def __eq__(self, other: object) -> bool:
|
|
105
|
+
if not isinstance(other, Dimension):
|
|
106
|
+
return NotImplemented
|
|
107
|
+
return self._exponents == other._exponents
|
|
108
|
+
|
|
109
|
+
def __hash__(self) -> int: return hash(self._exponents)
|
|
110
|
+
|
|
111
|
+
def as_tuple(self) -> tuple[Fraction, ...]: return self._exponents
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def __unpack_exponents():
|
|
115
|
+
def __getter(index: int):
|
|
116
|
+
return lambda self: self._exponents[index]
|
|
117
|
+
return (property(__getter(i)) for i in range(_LEN))
|
|
118
|
+
|
|
119
|
+
T, L, M, I, Θ, N, J = __unpack_exponents()
|
|
120
|
+
Theta = Θ # Alias for Θ
|
|
121
|
+
time, length, mass, current, temperature, amount, luminous = T, L, M, I, Θ, N, J
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str:
|
|
124
|
+
para = ', '.join(f'{s}={v}' for s, v in zip(_SYMBOL_ASCII, self._exponents) if v)
|
|
125
|
+
return '{}({})'.format(self.__class__.__name__, para)
|
|
126
|
+
|
|
127
|
+
def __str__(self) -> str:
|
|
128
|
+
return ''.join(s + superscript(v) for s, v in zip(_SYMBOL, self._exponents) if v) or '1'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
DIMENSIONLESS = object.__new__(Dimension)
|
|
132
|
+
object.__setattr__(DIMENSIONLESS, '_exponents', _ALLZERO)
|
|
@@ -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.'''
|