unitx 0.1.2__tar.gz → 0.1.3__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.2
3
+ Version: 0.1.3
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.2"
7
+ version = "0.1.3"
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,5 @@
1
+ """Utilities for working with physical units and quantities."""
2
+
3
+ __all__ = ['Dimension', 'DIMENSIONLESS']
4
+
5
+ from .dimension import Dimension, DIMENSIONLESS
@@ -0,0 +1,135 @@
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, FloatConversionWarning
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
+
56
+ def __new__(cls, T=0, L=0, M=0, I=0, Theta=0, N=0, J=0) -> Dimension:
57
+ exponents = tuple(map(common_fraction, (T, L, M, I, Theta, N, J)))
58
+ if not any(exponents):
59
+ return DIMENSIONLESS
60
+ obj = object.__new__(cls)
61
+ object.__setattr__(obj, '_exponents', exponents)
62
+ return obj
63
+
64
+ def __setattr__(self, name: str, value) -> None:
65
+ raise AttributeError(
66
+ 'Dimension is immutable and cannot set attribute.')
67
+
68
+ @classmethod
69
+ def _from_iter(cls, iterable: Iterable[Fraction], /) -> Dimension:
70
+ '''Constructor from an iterable of exponents. Internal use only.'''
71
+ exponents = tuple(iterable)
72
+ if len(exponents) != _LEN:
73
+ raise ValueError(
74
+ f'Expected {_LEN} exponents, got {len(exponents)}.')
75
+ if not any(exponents):
76
+ return DIMENSIONLESS
77
+ obj = object.__new__(cls)
78
+ object.__setattr__(obj, '_exponents', exponents)
79
+ return obj
80
+
81
+ def __mul__(self, other: Dimension) -> Dimension:
82
+ if not isinstance(other, Dimension):
83
+ return NotImplemented
84
+ return self._from_iter(a + b for a, b in zip(self._exponents, other._exponents))
85
+
86
+ def __truediv__(self, other: Dimension) -> Dimension:
87
+ if not isinstance(other, Dimension):
88
+ return NotImplemented
89
+ return self._from_iter(a - b for a, b in zip(self._exponents, other._exponents))
90
+
91
+ 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
97
+ if not isinstance(power, (int, Fraction)):
98
+ return NotImplemented
99
+ return self._from_iter(a * power for a in self._exponents)
100
+
101
+ def __rtruediv__(self, other: Literal[1]) -> Dimension:
102
+ if other == 1:
103
+ return self._from_iter(-a for a in self._exponents)
104
+ return NotImplemented
105
+
106
+ def __eq__(self, other: object) -> bool:
107
+ if not isinstance(other, Dimension):
108
+ return NotImplemented
109
+ return self._exponents == other._exponents
110
+
111
+ def __hash__(self) -> int: return hash(self._exponents)
112
+
113
+ def as_tuple(self) -> tuple[Fraction, ...]: return self._exponents
114
+
115
+ @staticmethod
116
+ def __unpack_exponents():
117
+ def __getter(index: int):
118
+ return lambda self: self._exponents[index]
119
+ return (property(__getter(i)) for i in range(_LEN))
120
+
121
+ T, L, M, I, Θ, N, J = __unpack_exponents()
122
+ Theta = Θ # Alias for Θ
123
+ time, length, mass, current, temperature, amount, luminous = T, L, M, I, Θ, N, J
124
+
125
+ def __repr__(self) -> str:
126
+ para = ', '.join(f'{s}={v}' for s, v in zip(
127
+ _SYMBOL_ASCII, self._exponents) if v)
128
+ return '{}({})'.format(self.__class__.__name__, para)
129
+
130
+ def __str__(self) -> str:
131
+ return ''.join(s + superscript(v) for s, v in zip(_SYMBOL, self._exponents) if v) or '1'
132
+
133
+
134
+ DIMENSIONLESS = object.__new__(Dimension)
135
+ object.__setattr__(DIMENSIONLESS, '_exponents', _ALLZERO)
File without changes
@@ -0,0 +1,28 @@
1
+ from decimal import Decimal
2
+ from fractions import Fraction
3
+
4
+ ZERO = Fraction(0)
5
+ ONE = Fraction(1)
6
+ TWO = Fraction(2)
7
+ THREE = Fraction(3)
8
+ TEN = Fraction(10)
9
+
10
+
11
+ def common_fraction(value: int | float | Decimal | Fraction) -> Fraction:
12
+ '''
13
+ Convert a number to a Fraction with a small denominator, like 1, 42, -2/3...
14
+
15
+ >>> Fraction(1.47)
16
+ Fraction(6620291452234629, 4503599627370496)
17
+ >>> common_fraction(1.47)
18
+ Fraction(147, 100)
19
+ '''
20
+ if isinstance(value, Fraction):
21
+ return value
22
+ frac = Fraction(value)
23
+ return frac.limit_denominator() if isinstance(value, float) else frac
24
+
25
+
26
+ class FloatConversionWarning(UserWarning):
27
+ '''Warning issued when a float is converted to an approximate Fraction.'''
28
+
@@ -0,0 +1,39 @@
1
+ import re
2
+ from fractions import Fraction
3
+
4
+ __all__ = ['superscript']
5
+
6
+ DIGIT = '0123456789+-=()'
7
+ SUPERSCRIPT = '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾'
8
+ SUBSCRIPT = '₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎'
9
+ SUP_TRANS = str.maketrans(SUPERSCRIPT, DIGIT)
10
+ SUB_TRANS = str.maketrans(SUBSCRIPT, DIGIT)
11
+
12
+
13
+ def superscript(ratio: int | Fraction, /, *, omit1=True) -> str:
14
+ '''
15
+ Convert a ratio to a string of superscript characters.
16
+ Superscript ¹ is omitted by setting `omit1=True` (default).
17
+ >>> superscript(2)
18
+ '²'
19
+ >>> superscript(-1)
20
+ '⁻¹'
21
+ >>> superscript(Fraction(3, 4))
22
+ '³ᐟ⁴'
23
+ '''
24
+ if ratio < 0:
25
+ return '⁻' + superscript(-ratio, omit1=False)
26
+ if ratio.denominator == 1:
27
+ if omit1 and ratio.numerator == 1:
28
+ return ''
29
+ return _sup(ratio.numerator)
30
+ assert isinstance(ratio, Fraction)
31
+ return _sup(ratio.numerator) + 'ᐟ' + _sup(ratio.denominator)
32
+
33
+
34
+ def _sup(number: int, /) -> str:
35
+ return ''.join(SUPERSCRIPT[int(digit)] for digit in str(number))
36
+
37
+
38
+ def _sub(number: int, /) -> str:
39
+ return ''.join(SUBSCRIPT[int(digit)] for digit in str(number))
@@ -0,0 +1,80 @@
1
+ import pytest
2
+ from fractions import Fraction
3
+ from unitx import Dimension, DIMENSIONLESS
4
+
5
+
6
+ def test_dimension_creation() -> None:
7
+ d1 = Dimension(T=1, L=2)
8
+ d2 = Dimension(T=1, L=2)
9
+ d3 = Dimension()
10
+
11
+ assert d1 == d2
12
+ assert d1 != d3
13
+ assert d3 is DIMENSIONLESS
14
+ assert d1.as_tuple() == (1, 2, 0, 0, 0, 0, 0)
15
+ assert repr(d2) == "Dimension(T=1, L=2)"
16
+ assert str(d3) == "1"
17
+ assert str(d1) == "TL²"
18
+
19
+
20
+ def test_temperature_alias() -> None:
21
+ d = Dimension(Theta=1)
22
+
23
+ assert d.Theta == 1
24
+ assert d.Θ == 1
25
+ assert d.temperature == 1
26
+ assert str(d) == "Θ"
27
+
28
+
29
+ def test_dimension_operations() -> None:
30
+ d1 = Dimension(T=1, L=2)
31
+ d2 = Dimension(M=1)
32
+
33
+ d_mul = d1 * d2
34
+ d_div = d1 / d2
35
+ d_pow = d1 ** 2
36
+ d_inv = 1 / d1
37
+
38
+ assert d_mul == Dimension(T=1, L=2, M=1)
39
+ assert d_div == Dimension(T=1, L=2, M=-1)
40
+ assert d_pow == Dimension(T=2, L=4)
41
+ assert d_inv == Dimension(T=-1, L=-2)
42
+
43
+
44
+ def test_fractional_exponents() -> None:
45
+ d = Dimension(L=1) ** Fraction(1, 2)
46
+ assert d.L == Fraction(1, 2)
47
+ assert str(d) == "L¹ᐟ²"
48
+
49
+
50
+ def test_invalid_operations() -> None:
51
+ with pytest.raises(TypeError):
52
+ Dimension(L=1) * 2 # type: ignore
53
+
54
+ with pytest.raises(TypeError):
55
+ Dimension(L=1) / 2 # type: ignore
56
+
57
+
58
+ def test_hash() -> None:
59
+ assert hash(Dimension(L=1)) == hash(Dimension(L=1))
60
+ assert {Dimension(L=1), Dimension(L=1)} == {Dimension(L=1)}
61
+
62
+
63
+ def test_float_power_warns() -> None:
64
+ with pytest.warns(UserWarning):
65
+ Dimension(L=1) ** 0.5 # type: ignore
66
+
67
+
68
+ def test_operation_returns_dimensionless_singleton() -> None:
69
+ d = Dimension(L=1)
70
+ assert d / d is DIMENSIONLESS
71
+ assert d ** 0 is DIMENSIONLESS
72
+
73
+
74
+ def test_dimension_is_immutable() -> None:
75
+ d = Dimension(L=1)
76
+
77
+ with pytest.raises(AttributeError):
78
+ d._exponents = (1, 2, 3, 4, 5, 6, 7) # type: ignore
79
+ with pytest.raises(AttributeError):
80
+ d.L = 2 # type: ignore
@@ -1,3 +0,0 @@
1
- """Utilities for working with physical units and quantities."""
2
-
3
- __all__ = []
@@ -1,5 +0,0 @@
1
- import unitx
2
-
3
-
4
- def test_import() -> None:
5
- assert unitx.__all__ == []
File without changes
File without changes
File without changes