units-lib 0.2.0__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.
- {units_lib-0.2.0 → units_lib-0.3.0}/PKG-INFO +1 -1
- units_lib-0.3.0/tests/hypothesis/test_scale.py +248 -0
- units_lib-0.3.0/tests/hypothesis/utils/test_factors.py +75 -0
- units_lib-0.3.0/tests/unit/test_scale.py +452 -0
- units_lib-0.3.0/tests/unit/utils/test_factors.py +88 -0
- units_lib-0.3.0/units_lib/scale.py +101 -0
- units_lib-0.3.0/units_lib/utils/factors.py +62 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/PKG-INFO +1 -1
- {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/SOURCES.txt +7 -1
- {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/scm_file_list.json +6 -0
- units_lib-0.3.0/units_lib.egg-info/scm_version.json +8 -0
- units_lib-0.2.0/units_lib.egg-info/scm_version.json +0 -8
- {units_lib-0.2.0 → units_lib-0.3.0}/.github/workflows/ci.yml +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/.github/workflows/pypi-release.yml +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/.gitignore +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/LICENSE +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/README.md +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/pyproject.toml +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/ruff.toml +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/setup.cfg +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/tests/__init__.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/tests/hypothesis/__init__.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/tests/hypothesis/test_dimension.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/tests/unit/__init__.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/tests/unit/conftest.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/tests/unit/test_dimension.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/tests/unit/test_kind.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/units_lib/__init__.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/units_lib/dimensions.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/units_lib/kind.py +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/dependency_links.txt +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/top_level.txt +0 -0
- {units_lib-0.2.0 → units_lib-0.3.0}/uv.lock +0 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from fractions import Fraction as F
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from hypothesis import given
|
|
6
|
+
from hypothesis import strategies as st
|
|
7
|
+
|
|
8
|
+
from units_lib.scale import Scale
|
|
9
|
+
|
|
10
|
+
ONE = Scale()
|
|
11
|
+
PI = Scale.constant("pi")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
rationals = st.builds(F, st.integers(1, 10**6), st.integers(1, 10**4))
|
|
15
|
+
|
|
16
|
+
exponents = st.fractions(min_value=-3, max_value=3, max_denominator=5)
|
|
17
|
+
int_exponents = st.integers(-3, 3)
|
|
18
|
+
nonzero_exponents = exponents.filter(lambda e: e != 0)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@st.composite
|
|
22
|
+
def scales(draw) -> Scale:
|
|
23
|
+
"""Scales mixing rational powers of primes with powers of pi."""
|
|
24
|
+
s = Scale.from_number(draw(rationals)) ** draw(exponents)
|
|
25
|
+
s *= PI ** draw(exponents)
|
|
26
|
+
return s
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_prime(n: int) -> bool:
|
|
30
|
+
return n > 1 and all(n % d for d in range(2, math.isqrt(n) + 1))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_square(n: int) -> bool:
|
|
34
|
+
return math.isqrt(n) ** 2 == n
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def approx(x: float) -> object:
|
|
38
|
+
return pytest.approx(x, rel=1e-9)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@given(rationals)
|
|
42
|
+
def test_from_number_roundtrips(q):
|
|
43
|
+
assert Scale.from_number(q).to_fraction() == q
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@given(rationals)
|
|
47
|
+
def test_from_number_uses_only_primes_and_nonzero_exponents(q):
|
|
48
|
+
for base, exp in Scale.from_number(q).factors:
|
|
49
|
+
assert isinstance(base, int) and is_prime(base)
|
|
50
|
+
assert exp != 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@given(rationals, rationals)
|
|
54
|
+
def test_from_number_is_injective(a, b):
|
|
55
|
+
assert (Scale.from_number(a) == Scale.from_number(b)) == (a == b)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@given(rationals, rationals)
|
|
59
|
+
def test_from_number_is_multiplicative(a, b):
|
|
60
|
+
assert Scale.from_number(a) * Scale.from_number(b) == Scale.from_number(a * b)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@given(rationals)
|
|
64
|
+
def test_from_number_float_matches_fraction(q):
|
|
65
|
+
assert float(Scale.from_number(q)) == approx(float(q))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@given(st.integers(max_value=0))
|
|
69
|
+
def test_from_number_rejects_non_positive_ints(n):
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
Scale.from_number(n)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@given(rationals)
|
|
75
|
+
def test_from_number_rejects_negated_rationals(q):
|
|
76
|
+
with pytest.raises(ValueError):
|
|
77
|
+
Scale.from_number(-q)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@given(scales(), scales())
|
|
81
|
+
def test_results_never_contain_zero_exponents(a, b):
|
|
82
|
+
for s in (a * b, a / b, a**2, a ** F(-1, 3)):
|
|
83
|
+
assert all(exp != 0 for _, exp in s.factors)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@given(scales(), scales())
|
|
87
|
+
def test_mul_commutative(a, b):
|
|
88
|
+
assert a * b == b * a
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@given(scales(), scales(), scales())
|
|
92
|
+
def test_mul_associative(a, b, c):
|
|
93
|
+
assert (a * b) * c == a * (b * c)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@given(scales())
|
|
97
|
+
def test_mul_identity(a):
|
|
98
|
+
assert a * ONE == a == ONE * a
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@given(scales())
|
|
102
|
+
def test_inverse(a):
|
|
103
|
+
assert a * a**-1 == ONE
|
|
104
|
+
assert a / a == ONE
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@given(scales(), scales())
|
|
108
|
+
def test_div_is_mul_by_inverse(a, b):
|
|
109
|
+
assert a / b == a * b**-1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@given(scales(), scales())
|
|
113
|
+
def test_div_undoes_mul(a, b):
|
|
114
|
+
assert (a * b) / b == a
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@given(scales(), scales())
|
|
118
|
+
def test_operations_do_not_mutate_operands(a, b):
|
|
119
|
+
before = (a.factors, b.factors)
|
|
120
|
+
_ = a * b, a / b, a**2
|
|
121
|
+
assert (a.factors, b.factors) == before
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@given(scales(), rationals)
|
|
125
|
+
def test_mul_by_number_equals_mul_by_scale(a, q):
|
|
126
|
+
assert a * q == a * Scale.from_number(q)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@given(scales(), rationals)
|
|
130
|
+
def test_div_by_number_equals_div_by_scale(a, q):
|
|
131
|
+
assert a / q == a / Scale.from_number(q)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@given(scales(), st.integers(max_value=0))
|
|
135
|
+
def test_mul_by_non_positive_int_raise(a, n):
|
|
136
|
+
with pytest.raises(ValueError):
|
|
137
|
+
_ = a * n
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@given(scales(), st.integers(max_value=0))
|
|
141
|
+
def test_div_by_non_positive_int_raise(a, n):
|
|
142
|
+
with pytest.raises(ValueError):
|
|
143
|
+
_ = a / n
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@given(scales())
|
|
147
|
+
def test_pow_one(a):
|
|
148
|
+
assert a**0 == ONE
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@given(scales())
|
|
152
|
+
def test_pow_zero(a):
|
|
153
|
+
assert a**1 == a
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@given(scales(), exponents, exponents)
|
|
157
|
+
def test_pow_adds_exponents(a, p, q):
|
|
158
|
+
assert a ** (p + q) == a**p * a**q
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@given(scales(), exponents, exponents)
|
|
162
|
+
def test_pow_of_pow_multiplies_exponents(a, p, q):
|
|
163
|
+
assert (a**p) ** q == a ** (p * q)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@given(scales(), scales(), exponents)
|
|
167
|
+
def test_pow_distributes_over_mul(a, b, p):
|
|
168
|
+
assert (a * b) ** p == a**p * b**p
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@given(rationals, rationals)
|
|
172
|
+
def test_to_fraction_of_product_and_quotient(a, b):
|
|
173
|
+
sa = Scale.from_number(a)
|
|
174
|
+
sb = Scale.from_number(b)
|
|
175
|
+
|
|
176
|
+
assert (sa * sb).to_fraction() == a * b
|
|
177
|
+
assert (sa / sb).to_fraction() == a / b
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@given(rationals, int_exponents)
|
|
181
|
+
def test_to_fraction_of_integer_power(q, n):
|
|
182
|
+
assert (Scale.from_number(q) ** n).to_fraction() == q**n
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@given(rationals)
|
|
186
|
+
def test_square_root_is_rational_iff_perfect_square(q):
|
|
187
|
+
root = Scale.from_number(q) ** F(1, 2)
|
|
188
|
+
expected_rational = is_square(q.numerator) and is_square(q.denominator)
|
|
189
|
+
|
|
190
|
+
result = root.to_fraction()
|
|
191
|
+
|
|
192
|
+
assert (result is not None) == expected_rational
|
|
193
|
+
if result is not None:
|
|
194
|
+
assert result * result == q
|
|
195
|
+
|
|
196
|
+
# Squaring always recovers the original, exactly
|
|
197
|
+
assert root**2 == Scale.from_number(q)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@given(rationals, nonzero_exponents)
|
|
201
|
+
def test_any_pi_power_makes_value_irrational(q, e):
|
|
202
|
+
assert (Scale.from_number(q) * PI**e).to_fraction() is None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@given(rationals, exponents)
|
|
206
|
+
def test_pi_powers_cancel(q, e):
|
|
207
|
+
s = Scale.from_number(q) * PI**e / PI**e
|
|
208
|
+
assert s.to_fraction() == q
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@given(scales())
|
|
212
|
+
def test_float_is_positive_finite_float(a):
|
|
213
|
+
f = float(a)
|
|
214
|
+
|
|
215
|
+
assert type(f) is float
|
|
216
|
+
assert 0 < f < math.inf
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@given(scales(), scales())
|
|
220
|
+
def test_float_of_mul_and_div(a, b):
|
|
221
|
+
assert float(a * b) == approx(float(a) * float(b))
|
|
222
|
+
assert float(a / b) == approx(float(a) / float(b))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@given(scales(), exponents)
|
|
226
|
+
def test_float_of_pow(a, p):
|
|
227
|
+
assert float(a**p) == approx(float(a) ** float(p))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@given(scales())
|
|
231
|
+
def test_float_of_inverse(a):
|
|
232
|
+
assert float(a**-1) == approx(1 / float(a))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@given(exponents)
|
|
236
|
+
def test_float_of_pi_power(e):
|
|
237
|
+
assert float(PI**e) == approx(math.pi ** float(e))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@given(scales(), scales())
|
|
241
|
+
def test_equal_scales_hash_equal(a, b):
|
|
242
|
+
assert (a * b) == (b * a)
|
|
243
|
+
assert hash(a * b) == hash(b * a)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@given(scales(), scales())
|
|
247
|
+
def test_set_membership_matches_equality(a, b):
|
|
248
|
+
assert (len({a, b}) == 1) == (a == b)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from collections import Counter
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from hypothesis import given
|
|
6
|
+
from hypothesis import strategies as st
|
|
7
|
+
|
|
8
|
+
from units_lib.utils.factors import factorise
|
|
9
|
+
|
|
10
|
+
# fmt: off
|
|
11
|
+
SMALL_PRIMES = [2, 3, 5, 7, 11, 13, 53, 97, 101, 103, 107, 109, 113, 997, 1009, 10007, 104729]
|
|
12
|
+
# fmt: on
|
|
13
|
+
|
|
14
|
+
positive_ints = st.integers(min_value=1, max_value=10**9)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_prime(n: int) -> bool:
|
|
18
|
+
return n > 1 and all(n % d for d in range(2, math.isqrt(n) + 1))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def as_counter(n: int) -> Counter:
|
|
22
|
+
return Counter(dict(factorise(n)))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@given(positive_ints)
|
|
26
|
+
def test_product_of_factors_reconstructs_value(n):
|
|
27
|
+
assert math.prod(p**e for p, e in factorise(n)) == n
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@given(positive_ints)
|
|
31
|
+
def test_factors_are_prime_ascending_and_unique_with_positive_powers(n):
|
|
32
|
+
result = list(factorise(n))
|
|
33
|
+
|
|
34
|
+
primes = [p for p, _ in result]
|
|
35
|
+
exponents = [e for _, e in result]
|
|
36
|
+
|
|
37
|
+
assert all(is_prime(p) for p in primes)
|
|
38
|
+
assert all(e >= 1 for e in exponents)
|
|
39
|
+
assert primes == sorted(set(primes))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@given(st.lists(st.sampled_from(SMALL_PRIMES), max_size=8))
|
|
43
|
+
def test_recovers_constructed_factorisation(primes):
|
|
44
|
+
"""Build a number from known primes, and check we get exactly those back."""
|
|
45
|
+
expected = Counter(primes)
|
|
46
|
+
n = math.prod(primes)
|
|
47
|
+
|
|
48
|
+
assert as_counter(n) == expected
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@given(st.sampled_from(SMALL_PRIMES), st.integers(min_value=1, max_value=3))
|
|
52
|
+
def test_prime_powers(p, k):
|
|
53
|
+
assert list(factorise(p**k)) == [(p, k)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@given(st.integers(1, 10**4), st.integers(1, 10**4))
|
|
57
|
+
def test_factorisation_is_multiplicative(a, b):
|
|
58
|
+
assert as_counter(a * b) == as_counter(a) + as_counter(b)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@given(st.integers(1, 10**5), st.integers(1, 4))
|
|
62
|
+
def test_powers_scale_exponents(n, k):
|
|
63
|
+
assert as_counter(n**k) == Counter({p: e * k for p, e in factorise(n)})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@given(st.sampled_from([p for p in SMALL_PRIMES if p > 97]))
|
|
67
|
+
def test_primes_beyond_precomputed_list_are_found(p):
|
|
68
|
+
assert list(factorise(p)) == [(p, 1)]
|
|
69
|
+
assert list(factorise(p * p)) == [(p, 2)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@given(st.integers(max_value=0))
|
|
73
|
+
def test_non_positive_raises(n):
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
list(factorise(n))
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import math
|
|
3
|
+
from fractions import Fraction as F
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from units_lib.scale import _CONSTANTS, Scale
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def factors(s: Scale) -> dict:
|
|
11
|
+
return dict(s.factors)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestScale:
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def pi(self):
|
|
17
|
+
return Scale.constant("pi")
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def one(self):
|
|
21
|
+
return Scale.from_number(1)
|
|
22
|
+
|
|
23
|
+
def test_one_is_identity(self, one):
|
|
24
|
+
# ACT
|
|
25
|
+
scale = Scale()
|
|
26
|
+
|
|
27
|
+
# ASSERT
|
|
28
|
+
assert scale == one
|
|
29
|
+
assert scale.factors == frozenset()
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize(
|
|
32
|
+
"value, expected",
|
|
33
|
+
[
|
|
34
|
+
(2, {2: F(1)}),
|
|
35
|
+
(12, {2: F(2), 3: F(1)}),
|
|
36
|
+
(7, {7: F(1)}),
|
|
37
|
+
(F(1, 2), {2: F(-1)}),
|
|
38
|
+
(F(3, 4), {2: F(-2), 3: F(1)}),
|
|
39
|
+
(F(9, 4), {2: F(-2), 3: F(2)}),
|
|
40
|
+
(F(5, 7), {5: F(1), 7: F(-1)}),
|
|
41
|
+
(F(7, 101), {7: F(1), 101: F(-1)}),
|
|
42
|
+
],
|
|
43
|
+
)
|
|
44
|
+
def test_factors(self, value, expected):
|
|
45
|
+
# ACT
|
|
46
|
+
scale = Scale.from_number(value)
|
|
47
|
+
|
|
48
|
+
# ASSERT
|
|
49
|
+
assert factors(scale) == expected
|
|
50
|
+
|
|
51
|
+
def test_equal_values_give_equal_scales(self):
|
|
52
|
+
# ARRANGE
|
|
53
|
+
first = F(6, 4)
|
|
54
|
+
second = F(3, 2)
|
|
55
|
+
|
|
56
|
+
# ACT
|
|
57
|
+
first_scale = Scale.from_number(first)
|
|
58
|
+
second_scale = Scale.from_number(second)
|
|
59
|
+
|
|
60
|
+
# ASSERT
|
|
61
|
+
assert first_scale == second_scale
|
|
62
|
+
|
|
63
|
+
@pytest.mark.parametrize("value", [0, -1, F(-1, 2)])
|
|
64
|
+
def test_from_number_non_positive_raises(self, value):
|
|
65
|
+
with pytest.raises(ValueError):
|
|
66
|
+
Scale.from_number(value)
|
|
67
|
+
|
|
68
|
+
def test_no_zero_exponents_stored(self):
|
|
69
|
+
# ACT
|
|
70
|
+
scale = Scale.from_number(F(6, 6))
|
|
71
|
+
|
|
72
|
+
# ASSERT
|
|
73
|
+
assert all(exp != 0 for _, exp in scale.factors)
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize(
|
|
76
|
+
"value",
|
|
77
|
+
[1, 2, 10, 97, 101, 360, 10201, F(22, 7), F(1, 1000)],
|
|
78
|
+
)
|
|
79
|
+
def test_roundtrip_through_to_fraction(self, value):
|
|
80
|
+
# ACT
|
|
81
|
+
scale = Scale.from_number(value)
|
|
82
|
+
result = scale.to_fraction()
|
|
83
|
+
|
|
84
|
+
# ASSERT
|
|
85
|
+
assert result == F(value)
|
|
86
|
+
|
|
87
|
+
def test_constant_pi_factors(self):
|
|
88
|
+
# ACT
|
|
89
|
+
pi = Scale.constant("pi")
|
|
90
|
+
|
|
91
|
+
# ASSERT
|
|
92
|
+
assert factors(pi) == {"pi": F(1)}
|
|
93
|
+
|
|
94
|
+
def test_constant_matches_manual_construction(self):
|
|
95
|
+
# ARRANGE
|
|
96
|
+
manual = Scale(frozenset({("pi", F(1))}))
|
|
97
|
+
|
|
98
|
+
# ACT
|
|
99
|
+
constant = Scale.constant("pi")
|
|
100
|
+
|
|
101
|
+
# ASSERT
|
|
102
|
+
assert constant == manual
|
|
103
|
+
|
|
104
|
+
@pytest.mark.parametrize("name", ["tau", "", "PI", "2"])
|
|
105
|
+
def test_constant_unknown_name_raises(self, name):
|
|
106
|
+
with pytest.raises(ValueError, match="not known"):
|
|
107
|
+
Scale.constant(name)
|
|
108
|
+
|
|
109
|
+
@pytest.mark.parametrize("name", _CONSTANTS.keys())
|
|
110
|
+
def test_every_known_constant_is_constructible_and_floatable(self, name):
|
|
111
|
+
# ARRANGE
|
|
112
|
+
value = _CONSTANTS[name]
|
|
113
|
+
|
|
114
|
+
# ACT
|
|
115
|
+
result = float(Scale.constant(name))
|
|
116
|
+
|
|
117
|
+
# ASSERT
|
|
118
|
+
assert result == pytest.approx(value)
|
|
119
|
+
|
|
120
|
+
def test_simple_multiplication(self):
|
|
121
|
+
# ARRANGE
|
|
122
|
+
two = Scale.from_number(2)
|
|
123
|
+
three = Scale.from_number(3)
|
|
124
|
+
six = Scale.from_number(6)
|
|
125
|
+
|
|
126
|
+
# ACT
|
|
127
|
+
result = two * three
|
|
128
|
+
|
|
129
|
+
# ASSERT
|
|
130
|
+
assert result == six
|
|
131
|
+
|
|
132
|
+
def test_multiplication_adds_exponents(self):
|
|
133
|
+
# ARRANGE
|
|
134
|
+
two = Scale.from_number(2)
|
|
135
|
+
four = Scale.from_number(4)
|
|
136
|
+
|
|
137
|
+
# ACT
|
|
138
|
+
result = two * four
|
|
139
|
+
|
|
140
|
+
# ASSERT
|
|
141
|
+
assert factors(result) == {2: F(3)}
|
|
142
|
+
|
|
143
|
+
def test_multiplicitive_identity(self, one):
|
|
144
|
+
# ARRANGE
|
|
145
|
+
a = Scale.from_number(F(3, 5))
|
|
146
|
+
|
|
147
|
+
# ACT
|
|
148
|
+
first = a * one
|
|
149
|
+
second = one * a
|
|
150
|
+
|
|
151
|
+
# ASSERT
|
|
152
|
+
assert first == a
|
|
153
|
+
assert second == a
|
|
154
|
+
|
|
155
|
+
def test_multiplication_is_commutative(self):
|
|
156
|
+
# ARRANGE
|
|
157
|
+
a = Scale.from_number(F(3, 5))
|
|
158
|
+
b = Scale.from_number(10)
|
|
159
|
+
|
|
160
|
+
# ACT
|
|
161
|
+
same = a * b == b * a
|
|
162
|
+
|
|
163
|
+
# ASSERT
|
|
164
|
+
assert same
|
|
165
|
+
|
|
166
|
+
def test_multiplication_isassociative(self):
|
|
167
|
+
# ARRANGE
|
|
168
|
+
a, b, c = (Scale.from_number(n) for n in (2, 3, 5))
|
|
169
|
+
|
|
170
|
+
# ACT
|
|
171
|
+
same = (a * b) * c == a * (b * c)
|
|
172
|
+
|
|
173
|
+
# ASSERT
|
|
174
|
+
assert same
|
|
175
|
+
|
|
176
|
+
def test_multiplicitive_inverse_cancels_to_identity(self, one):
|
|
177
|
+
# ARRANGE
|
|
178
|
+
a = Scale.from_number(F(7, 12))
|
|
179
|
+
|
|
180
|
+
# ACT
|
|
181
|
+
result = a * a**-1
|
|
182
|
+
|
|
183
|
+
# ASSERT
|
|
184
|
+
assert result == one
|
|
185
|
+
|
|
186
|
+
def test_cancelled_factors_are_removed(self):
|
|
187
|
+
assert Scale.from_number(6) * Scale.from_number(F(1, 3)) == Scale.from_number(2)
|
|
188
|
+
|
|
189
|
+
def test_multiplication_with_constant(self, pi):
|
|
190
|
+
# ARRANGE
|
|
191
|
+
two = Scale.from_number(2)
|
|
192
|
+
|
|
193
|
+
# ACT
|
|
194
|
+
result = pi * two
|
|
195
|
+
|
|
196
|
+
# ASSERT
|
|
197
|
+
assert factors(result) == {"pi": F(1), 2: F(1)}
|
|
198
|
+
|
|
199
|
+
def test_matches_rational_multiplication(self):
|
|
200
|
+
# ARRANGE
|
|
201
|
+
a = F(15, 4)
|
|
202
|
+
b = F(8, 25)
|
|
203
|
+
|
|
204
|
+
expected = a * b
|
|
205
|
+
|
|
206
|
+
# ACT
|
|
207
|
+
result = Scale.from_number(a) * Scale.from_number(b)
|
|
208
|
+
|
|
209
|
+
# ASSERT
|
|
210
|
+
assert result.to_fraction() == expected
|
|
211
|
+
|
|
212
|
+
def test_multiplication_int_operand(self):
|
|
213
|
+
assert Scale.from_number(3) * 4 == Scale.from_number(12)
|
|
214
|
+
|
|
215
|
+
def test_multiplication_fraction_operand(self):
|
|
216
|
+
assert Scale.from_number(3) * F(2, 9) == Scale.from_number(F(2, 3))
|
|
217
|
+
|
|
218
|
+
def test_multiplication_int_operand_matches_scale_operand(self):
|
|
219
|
+
# ARRANGE
|
|
220
|
+
a = Scale.from_number(F(5, 7))
|
|
221
|
+
|
|
222
|
+
# ACT & ASSERT
|
|
223
|
+
assert a * 6 == a * Scale.from_number(6)
|
|
224
|
+
|
|
225
|
+
def test_int_operand_with_constant(self, pi):
|
|
226
|
+
assert factors(pi * 2) == {"pi": F(1), 2: F(1)}
|
|
227
|
+
|
|
228
|
+
def test_chained_with_numbers(self, one):
|
|
229
|
+
assert (one * 2 * F(3, 4) * 8).to_fraction() == 12
|
|
230
|
+
|
|
231
|
+
@pytest.mark.parametrize("value", [0, -3, F(-1, 2)])
|
|
232
|
+
def test_multiplication_non_positive_number_raises(self, one, value):
|
|
233
|
+
with pytest.raises(ValueError):
|
|
234
|
+
_ = one * value
|
|
235
|
+
|
|
236
|
+
@pytest.mark.parametrize("value", [1.5, "2", None])
|
|
237
|
+
def test_multiplication_unsupported_operand_returns_not_implemented(
|
|
238
|
+
self, one, value
|
|
239
|
+
):
|
|
240
|
+
assert Scale.__mul__(one, value) is NotImplemented
|
|
241
|
+
|
|
242
|
+
with pytest.raises(TypeError):
|
|
243
|
+
_ = one * value
|
|
244
|
+
|
|
245
|
+
def test_multiplication_reflected_int_operand(self):
|
|
246
|
+
assert 4 * Scale.from_number(3) == Scale.from_number(12)
|
|
247
|
+
|
|
248
|
+
def test_multiplication_reflected_fraction_operand(self):
|
|
249
|
+
assert F(1, 2) * Scale.from_number(6) == Scale.from_number(3)
|
|
250
|
+
|
|
251
|
+
def test_truediv(self):
|
|
252
|
+
# ARRANGE
|
|
253
|
+
a = Scale.from_number(6)
|
|
254
|
+
b = Scale.from_number(3)
|
|
255
|
+
|
|
256
|
+
# ACT & ASSERT
|
|
257
|
+
assert a / b == Scale.from_number(2)
|
|
258
|
+
|
|
259
|
+
def test_self_division_is_identity(self, one):
|
|
260
|
+
# ARRANGE
|
|
261
|
+
a = Scale.from_number(F(5, 9))
|
|
262
|
+
|
|
263
|
+
# ACT & ASSERT
|
|
264
|
+
assert a / a == one
|
|
265
|
+
|
|
266
|
+
def test_division_by_pi(self, pi):
|
|
267
|
+
assert factors(Scale.from_number(2) / pi) == {2: F(1), "pi": F(-1)}
|
|
268
|
+
|
|
269
|
+
def test_division_int_operand(self):
|
|
270
|
+
assert Scale.from_number(12) / 4 == Scale.from_number(3)
|
|
271
|
+
|
|
272
|
+
def test_division_fraction_operand(self):
|
|
273
|
+
assert Scale.from_number(3) / F(3, 4) == Scale.from_number(4)
|
|
274
|
+
|
|
275
|
+
def test_division_int_operand_matches_scale_operand(self):
|
|
276
|
+
# ARRANGE
|
|
277
|
+
a = Scale.from_number(F(5, 7))
|
|
278
|
+
|
|
279
|
+
# ACT & ASSERT
|
|
280
|
+
assert a / 6 == a / Scale.from_number(6)
|
|
281
|
+
|
|
282
|
+
def test_constant_divided_by_itself(self, pi, one):
|
|
283
|
+
assert pi / pi == one
|
|
284
|
+
|
|
285
|
+
@pytest.mark.parametrize("value", [0, -3, F(-1, 2)])
|
|
286
|
+
def test_division_non_positive_number_raises(self, one, value):
|
|
287
|
+
# ASSERT
|
|
288
|
+
with pytest.raises(ValueError):
|
|
289
|
+
_ = one / value
|
|
290
|
+
|
|
291
|
+
@pytest.mark.parametrize("value", [1.5, "2", None])
|
|
292
|
+
def test_division_unsupported_operand_returns_not_implemented(self, one, value):
|
|
293
|
+
assert Scale.__truediv__(one, value) is NotImplemented
|
|
294
|
+
|
|
295
|
+
with pytest.raises(TypeError):
|
|
296
|
+
_ = one / value
|
|
297
|
+
|
|
298
|
+
def test_division_reflected_int_operand(self):
|
|
299
|
+
assert 12 / Scale.from_number(4) == Scale.from_number(3)
|
|
300
|
+
|
|
301
|
+
def test_division_reflected_fraction_operand(self):
|
|
302
|
+
assert F(1, 2) / Scale.from_number(4) == Scale.from_number(F(1, 8))
|
|
303
|
+
|
|
304
|
+
def test_power_zero_gives_identity(self, one):
|
|
305
|
+
assert Scale.from_number(12) ** 0 == one
|
|
306
|
+
|
|
307
|
+
def test_power_one_is_noop(self):
|
|
308
|
+
# ARRANGE
|
|
309
|
+
a = Scale.from_number(12)
|
|
310
|
+
|
|
311
|
+
# ACT & ASSERT
|
|
312
|
+
assert a**1 == a
|
|
313
|
+
|
|
314
|
+
def test_integer_power(self):
|
|
315
|
+
assert Scale.from_number(2) ** 3 == Scale.from_number(8)
|
|
316
|
+
|
|
317
|
+
def test_negative_power(self):
|
|
318
|
+
assert Scale.from_number(2) ** -2 == Scale.from_number(F(1, 4))
|
|
319
|
+
|
|
320
|
+
def test_fractional_power_stays_exact(self):
|
|
321
|
+
# ACT
|
|
322
|
+
s = Scale.from_number(4) ** F(1, 2)
|
|
323
|
+
|
|
324
|
+
# ASSERT
|
|
325
|
+
assert s == Scale.from_number(2)
|
|
326
|
+
|
|
327
|
+
def test_irrational_result_has_no_fraction(self):
|
|
328
|
+
# ASSERT
|
|
329
|
+
assert (Scale.from_number(2) ** F(1, 2)).to_fraction() is None
|
|
330
|
+
|
|
331
|
+
def test_power_of_power(self):
|
|
332
|
+
# ARRANGE
|
|
333
|
+
a = Scale.from_number(F(3, 2))
|
|
334
|
+
|
|
335
|
+
# ACT & ASSERT
|
|
336
|
+
assert (a**2) ** 3 == a**6
|
|
337
|
+
|
|
338
|
+
def test_power_distributes_over_mul(self):
|
|
339
|
+
# ARRANGE
|
|
340
|
+
a = Scale.from_number(6)
|
|
341
|
+
b = Scale.from_number(F(5, 3))
|
|
342
|
+
|
|
343
|
+
# ACT & ASSERT
|
|
344
|
+
assert (a * b) ** 2 == a**2 * b**2
|
|
345
|
+
|
|
346
|
+
def test_pi_power(self, pi):
|
|
347
|
+
assert factors(pi**3) == {"pi": F(3)}
|
|
348
|
+
|
|
349
|
+
def test_from_number_to_fraction_round_trip_integer(self):
|
|
350
|
+
assert Scale.from_number(1).to_fraction() == 1
|
|
351
|
+
|
|
352
|
+
def test_from_number_to_fraction_round_trip_fraction(self):
|
|
353
|
+
assert Scale.from_number(F(3, 4)).to_fraction() == F(3, 4)
|
|
354
|
+
|
|
355
|
+
def test_pi_is_not_rational(self, pi):
|
|
356
|
+
assert pi.to_fraction() is None
|
|
357
|
+
|
|
358
|
+
def test_fractional_exponent_is_not_rational(self):
|
|
359
|
+
assert (Scale.from_number(3) ** F(1, 3)).to_fraction() is None
|
|
360
|
+
|
|
361
|
+
def test_constants_cancelling_become_rational(self, pi):
|
|
362
|
+
assert (pi * Scale.from_number(5) / pi).to_fraction() == 5
|
|
363
|
+
|
|
364
|
+
def test_fractional_exponents_combining_to_integer(self):
|
|
365
|
+
# ARRANGE
|
|
366
|
+
root2 = Scale.from_number(2) ** F(1, 2)
|
|
367
|
+
|
|
368
|
+
# ACT & ASSERT
|
|
369
|
+
assert (root2 * root2).to_fraction() == 2
|
|
370
|
+
|
|
371
|
+
@pytest.mark.parametrize("value", [2, F(1, 2), 1])
|
|
372
|
+
def test_returns_a_float(self, value):
|
|
373
|
+
# ACT
|
|
374
|
+
result = float(Scale.from_number(value))
|
|
375
|
+
|
|
376
|
+
# ASSERT
|
|
377
|
+
assert type(result) is float
|
|
378
|
+
|
|
379
|
+
@pytest.mark.parametrize(
|
|
380
|
+
"value", [1, 2, 12, F(1, 2), F(22, 7), F(1, 1000), 101 * 103]
|
|
381
|
+
)
|
|
382
|
+
def test_rational_values(self, value):
|
|
383
|
+
assert float(Scale.from_number(value)) == pytest.approx(float(value))
|
|
384
|
+
|
|
385
|
+
def test_float_pi(self, pi):
|
|
386
|
+
assert float(pi) == pytest.approx(math.pi)
|
|
387
|
+
|
|
388
|
+
@pytest.mark.parametrize("power", [-2, -1, 2, 3, F(1, 2), F(-1, 2)])
|
|
389
|
+
def test_pi_powers(self, pi, power):
|
|
390
|
+
assert float(pi**power) == pytest.approx(math.pi ** float(power))
|
|
391
|
+
|
|
392
|
+
@pytest.mark.parametrize("power", [-3, -1, 2, F(1, 3), F(-3, 2)])
|
|
393
|
+
def test_rational_powers(self, power):
|
|
394
|
+
# ACT
|
|
395
|
+
s = Scale.from_number(F(5, 3)) ** power
|
|
396
|
+
|
|
397
|
+
# ASSERT
|
|
398
|
+
assert float(s) == pytest.approx((5 / 3) ** float(power))
|
|
399
|
+
|
|
400
|
+
def test_large_value(self):
|
|
401
|
+
assert float(Scale.from_number(10**30)) == pytest.approx(1e30)
|
|
402
|
+
|
|
403
|
+
def test_tiny_value(self):
|
|
404
|
+
assert float(Scale.from_number(F(1, 10**30))) == pytest.approx(1e-30)
|
|
405
|
+
|
|
406
|
+
def test_sqrt(self):
|
|
407
|
+
assert float(Scale.from_number(2) ** F(1, 2)) == pytest.approx(math.sqrt(2))
|
|
408
|
+
|
|
409
|
+
def test_mixed(self, pi):
|
|
410
|
+
# ARRANGE
|
|
411
|
+
s = Scale.from_number(F(3, 2)) * pi**2
|
|
412
|
+
|
|
413
|
+
# ASSERT
|
|
414
|
+
assert float(s) == pytest.approx(1.5 * math.pi**2)
|
|
415
|
+
|
|
416
|
+
def test_float_of_product_matches_product_of_floats(self, pi):
|
|
417
|
+
# ARRANGE
|
|
418
|
+
a = Scale.from_number(F(7, 3))
|
|
419
|
+
b = pi
|
|
420
|
+
|
|
421
|
+
# ACT & ASSERT
|
|
422
|
+
assert float(a * b) == pytest.approx(float(a) * float(b))
|
|
423
|
+
|
|
424
|
+
def test_hashable_and_equal_hash(self):
|
|
425
|
+
# ARRANGE
|
|
426
|
+
a = Scale.from_number(F(3, 2))
|
|
427
|
+
b = Scale.from_number(F(6, 4))
|
|
428
|
+
|
|
429
|
+
# ASSERT
|
|
430
|
+
assert hash(a) == hash(b)
|
|
431
|
+
assert len({a, b}) == 1
|
|
432
|
+
|
|
433
|
+
def test_different_values_not_equal(self):
|
|
434
|
+
assert Scale.from_number(2) != Scale.from_number(3)
|
|
435
|
+
|
|
436
|
+
def test_frozen(self, one):
|
|
437
|
+
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
438
|
+
one.factors = frozenset() # type: ignore[misc]
|
|
439
|
+
|
|
440
|
+
def test_operations_do_not_mutate_operands(self):
|
|
441
|
+
# ARRANGE
|
|
442
|
+
a = Scale.from_number(6)
|
|
443
|
+
b = Scale.from_number(10)
|
|
444
|
+
|
|
445
|
+
before = (a.factors, b.factors)
|
|
446
|
+
|
|
447
|
+
# ACT
|
|
448
|
+
_ = a * b
|
|
449
|
+
_ = a**2
|
|
450
|
+
|
|
451
|
+
# ASSERT
|
|
452
|
+
assert (a.factors, b.factors) == before
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from collections.abc import Generator
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from units_lib.utils.factors import factorise
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_prime(n: int) -> bool:
|
|
10
|
+
return n > 1 and all(n % d for d in range(2, math.isqrt(n) + 1))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def as_dict(generator: Generator[tuple[int, int], None, None]) -> dict[int, int]:
|
|
14
|
+
return dict(generator)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize(
|
|
18
|
+
"value, expected",
|
|
19
|
+
[
|
|
20
|
+
(1, {}),
|
|
21
|
+
(2, {2: 1}),
|
|
22
|
+
(3, {3: 1}),
|
|
23
|
+
(4, {2: 2}),
|
|
24
|
+
(6, {2: 1, 3: 1}),
|
|
25
|
+
(7, {7: 1}),
|
|
26
|
+
(12, {2: 2, 3: 1}),
|
|
27
|
+
(97, {97: 1}),
|
|
28
|
+
(97 * 97, {97: 2}),
|
|
29
|
+
(101, {101: 1}),
|
|
30
|
+
(101 * 101, {101: 2}),
|
|
31
|
+
(101 * 103, {101: 1, 103: 1}),
|
|
32
|
+
(2**10, {2: 10}),
|
|
33
|
+
(2**3 * 3**2 * 5 * 101, {2: 3, 3: 2, 5: 1, 101: 1}),
|
|
34
|
+
(1_000_003, {1_000_003: 1}),
|
|
35
|
+
(999_983 * 2, {2: 1, 999_983: 1}),
|
|
36
|
+
(97 * 101, {97: 1, 101: 1}),
|
|
37
|
+
],
|
|
38
|
+
)
|
|
39
|
+
def test_known_factorisations(value, expected):
|
|
40
|
+
# ACT
|
|
41
|
+
factorisation = factorise(value)
|
|
42
|
+
|
|
43
|
+
# ASSERT
|
|
44
|
+
assert as_dict(factorisation) == expected
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize(
|
|
48
|
+
"value",
|
|
49
|
+
list(range(1, 200)) + [10201, 123456, 999_999, 1_030_301],
|
|
50
|
+
)
|
|
51
|
+
def test_product_reconstructs_value(value):
|
|
52
|
+
# ACT
|
|
53
|
+
result = factorise(value)
|
|
54
|
+
|
|
55
|
+
# ASSERT
|
|
56
|
+
reconstructed = math.prod(p**e for p, e in result)
|
|
57
|
+
assert reconstructed == value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize("value", list(range(1, 500)) + [10201, 1_030_301])
|
|
61
|
+
def test_all_factors_prime_with_positive_powers(value):
|
|
62
|
+
# ACT
|
|
63
|
+
factorisation = factorise(value)
|
|
64
|
+
|
|
65
|
+
# ASSERT
|
|
66
|
+
for prime, power in factorisation:
|
|
67
|
+
assert is_prime(prime)
|
|
68
|
+
assert power >= 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize("value", [2 * 3 * 5 * 7 * 101, 12, 360, 10201])
|
|
72
|
+
def test_primes_unique_and_ascending(value):
|
|
73
|
+
# ACT
|
|
74
|
+
primes = [p for p, _ in factorise(value)]
|
|
75
|
+
|
|
76
|
+
# ASSERT
|
|
77
|
+
assert primes == sorted(set(primes))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("value", [0, -1, -100])
|
|
81
|
+
def test_non_positive_raises(value):
|
|
82
|
+
with pytest.raises(ValueError):
|
|
83
|
+
list(factorise(value))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_error_message_is_formatted_properly():
|
|
87
|
+
with pytest.raises(ValueError, match="-5 is not valid"):
|
|
88
|
+
list(factorise(-5))
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from fractions import Fraction
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from units_lib.utils.factors import factorise
|
|
9
|
+
|
|
10
|
+
Base = int | str # primes or names of constants (e.g. 'pi')
|
|
11
|
+
|
|
12
|
+
_CONSTANTS = {
|
|
13
|
+
"pi": math.pi,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Scale:
|
|
19
|
+
"""A positive real number represented as a product of independant values raised to powers."""
|
|
20
|
+
|
|
21
|
+
factors: frozenset[tuple[Base, Fraction]] = frozenset()
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def _build(cls, exps: dict[Base, Fraction]) -> Self:
|
|
25
|
+
return cls(frozenset((base, exp) for base, exp in exps.items() if exp != 0))
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_number(cls, value: int | Fraction) -> Self:
|
|
29
|
+
"""Produce a `Scale` from a number."""
|
|
30
|
+
value = Fraction(value)
|
|
31
|
+
|
|
32
|
+
if value <= 0:
|
|
33
|
+
raise ValueError("A `Scale` must be positive")
|
|
34
|
+
|
|
35
|
+
exps: dict[Base, Fraction] = {}
|
|
36
|
+
|
|
37
|
+
for prime, power in factorise(value.numerator):
|
|
38
|
+
exps[prime] = Fraction(power)
|
|
39
|
+
|
|
40
|
+
for prime, power in factorise(value.denominator):
|
|
41
|
+
exps[prime] = exps.get(prime, Fraction(0)) - power
|
|
42
|
+
|
|
43
|
+
return cls._build(exps)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def constant(cls, value: str) -> Self:
|
|
47
|
+
"""Produce a `Scale` from a constant value."""
|
|
48
|
+
if value not in _CONSTANTS:
|
|
49
|
+
raise ValueError(f"Constant value {value!r} is not known")
|
|
50
|
+
|
|
51
|
+
return cls._build({value: Fraction(1)})
|
|
52
|
+
|
|
53
|
+
def __mul__(self, other: Scale | int | Fraction) -> Scale:
|
|
54
|
+
if isinstance(other, (int, Fraction)) and not isinstance(other, bool):
|
|
55
|
+
other = self.from_number(other)
|
|
56
|
+
|
|
57
|
+
if not isinstance(other, Scale):
|
|
58
|
+
return NotImplemented
|
|
59
|
+
|
|
60
|
+
exps = dict(self.factors)
|
|
61
|
+
for base, exp in other.factors:
|
|
62
|
+
exps[base] = exps.get(base, Fraction(0)) + exp
|
|
63
|
+
return self._build(exps)
|
|
64
|
+
|
|
65
|
+
__rmul__ = __mul__
|
|
66
|
+
|
|
67
|
+
def __truediv__(self, other: Scale | int | Fraction) -> Scale:
|
|
68
|
+
if isinstance(other, (int, Fraction)) and not isinstance(other, bool):
|
|
69
|
+
other = self.from_number(other)
|
|
70
|
+
|
|
71
|
+
if not isinstance(other, Scale):
|
|
72
|
+
return NotImplemented
|
|
73
|
+
|
|
74
|
+
return self * other**-1
|
|
75
|
+
|
|
76
|
+
def __rtruediv__(self, other: int | Fraction) -> Scale:
|
|
77
|
+
return other * self**-1
|
|
78
|
+
|
|
79
|
+
def __pow__(self, p: int | Fraction) -> Scale:
|
|
80
|
+
exps = {base: exp * p for base, exp in self.factors}
|
|
81
|
+
return self._build(exps)
|
|
82
|
+
|
|
83
|
+
def to_fraction(self) -> Fraction | None:
|
|
84
|
+
"""The exact rational value, or None if it isn't rational."""
|
|
85
|
+
result = Fraction(1)
|
|
86
|
+
|
|
87
|
+
for base, exp in self.factors:
|
|
88
|
+
if not isinstance(base, int) or exp.denominator != 1:
|
|
89
|
+
return None
|
|
90
|
+
result *= Fraction(base) ** int(exp)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
def __float__(self) -> float:
|
|
94
|
+
result = 1.0
|
|
95
|
+
|
|
96
|
+
for base, exp in self.factors:
|
|
97
|
+
if isinstance(base, str):
|
|
98
|
+
base = _CONSTANTS[base]
|
|
99
|
+
result *= float(base**exp)
|
|
100
|
+
|
|
101
|
+
return result
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
|
|
3
|
+
# fmt: off
|
|
4
|
+
_PRIMES = [
|
|
5
|
+
2, 3, 5, 7,
|
|
6
|
+
11, 13, 17, 19,
|
|
7
|
+
23, 29, 31, 37,
|
|
8
|
+
41, 43, 47,
|
|
9
|
+
53, 59,
|
|
10
|
+
61, 67,
|
|
11
|
+
71, 73, 79,
|
|
12
|
+
83, 89,
|
|
13
|
+
97,
|
|
14
|
+
]
|
|
15
|
+
# fmt: on
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def factorise(value: int) -> Generator[tuple[int, int], None, None]:
|
|
19
|
+
"""Calculate the prime factorisation on `value`.
|
|
20
|
+
|
|
21
|
+
Yields pairs of (prime, power) that make up the prime factorisation.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
value: The number to calculate the prime factorisation of
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A generator of tuples containing (prime, power) pairs
|
|
28
|
+
"""
|
|
29
|
+
if value < 1:
|
|
30
|
+
raise ValueError(f"Number must be positive - {value!r} is not valid")
|
|
31
|
+
|
|
32
|
+
for prime in _PRIMES:
|
|
33
|
+
if prime * prime > value:
|
|
34
|
+
break
|
|
35
|
+
|
|
36
|
+
exp = 0
|
|
37
|
+
while value % prime == 0:
|
|
38
|
+
value //= prime
|
|
39
|
+
exp += 1
|
|
40
|
+
|
|
41
|
+
if exp != 0:
|
|
42
|
+
yield prime, exp
|
|
43
|
+
|
|
44
|
+
# Handle any remaining factors beyond the precomputed prime list
|
|
45
|
+
div = 101
|
|
46
|
+
step = 2
|
|
47
|
+
|
|
48
|
+
while div * div <= value:
|
|
49
|
+
exp = 0
|
|
50
|
+
while value % div == 0:
|
|
51
|
+
exp += 1
|
|
52
|
+
value //= div
|
|
53
|
+
|
|
54
|
+
if exp != 0:
|
|
55
|
+
yield div, exp
|
|
56
|
+
|
|
57
|
+
div += step
|
|
58
|
+
# Alternate between steps of 2 and 4
|
|
59
|
+
step = 6 - step
|
|
60
|
+
|
|
61
|
+
if value != 1:
|
|
62
|
+
yield value, 1
|
|
@@ -9,16 +9,22 @@ uv.lock
|
|
|
9
9
|
tests/__init__.py
|
|
10
10
|
tests/hypothesis/__init__.py
|
|
11
11
|
tests/hypothesis/test_dimension.py
|
|
12
|
+
tests/hypothesis/test_scale.py
|
|
13
|
+
tests/hypothesis/utils/test_factors.py
|
|
12
14
|
tests/unit/__init__.py
|
|
13
15
|
tests/unit/conftest.py
|
|
14
16
|
tests/unit/test_dimension.py
|
|
15
17
|
tests/unit/test_kind.py
|
|
18
|
+
tests/unit/test_scale.py
|
|
19
|
+
tests/unit/utils/test_factors.py
|
|
16
20
|
units_lib/__init__.py
|
|
17
21
|
units_lib/dimensions.py
|
|
18
22
|
units_lib/kind.py
|
|
23
|
+
units_lib/scale.py
|
|
19
24
|
units_lib.egg-info/PKG-INFO
|
|
20
25
|
units_lib.egg-info/SOURCES.txt
|
|
21
26
|
units_lib.egg-info/dependency_links.txt
|
|
22
27
|
units_lib.egg-info/scm_file_list.json
|
|
23
28
|
units_lib.egg-info/scm_version.json
|
|
24
|
-
units_lib.egg-info/top_level.txt
|
|
29
|
+
units_lib.egg-info/top_level.txt
|
|
30
|
+
units_lib/utils/factors.py
|
|
@@ -10,13 +10,19 @@
|
|
|
10
10
|
"tests/__init__.py",
|
|
11
11
|
"tests/hypothesis/__init__.py",
|
|
12
12
|
"tests/hypothesis/test_dimension.py",
|
|
13
|
+
"tests/hypothesis/test_scale.py",
|
|
14
|
+
"tests/hypothesis/utils/test_factors.py",
|
|
13
15
|
"tests/unit/__init__.py",
|
|
14
16
|
"tests/unit/conftest.py",
|
|
15
17
|
"tests/unit/test_dimension.py",
|
|
16
18
|
"tests/unit/test_kind.py",
|
|
19
|
+
"tests/unit/test_scale.py",
|
|
20
|
+
"tests/unit/utils/test_factors.py",
|
|
17
21
|
"units_lib/__init__.py",
|
|
18
22
|
"units_lib/dimensions.py",
|
|
19
23
|
"units_lib/kind.py",
|
|
24
|
+
"units_lib/scale.py",
|
|
25
|
+
"units_lib/utils/factors.py",
|
|
20
26
|
"uv.lock"
|
|
21
27
|
]
|
|
22
28
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|