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.
Files changed (33) hide show
  1. {units_lib-0.2.0 → units_lib-0.3.0}/PKG-INFO +1 -1
  2. units_lib-0.3.0/tests/hypothesis/test_scale.py +248 -0
  3. units_lib-0.3.0/tests/hypothesis/utils/test_factors.py +75 -0
  4. units_lib-0.3.0/tests/unit/test_scale.py +452 -0
  5. units_lib-0.3.0/tests/unit/utils/test_factors.py +88 -0
  6. units_lib-0.3.0/units_lib/scale.py +101 -0
  7. units_lib-0.3.0/units_lib/utils/factors.py +62 -0
  8. {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/PKG-INFO +1 -1
  9. {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/SOURCES.txt +7 -1
  10. {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/scm_file_list.json +6 -0
  11. units_lib-0.3.0/units_lib.egg-info/scm_version.json +8 -0
  12. units_lib-0.2.0/units_lib.egg-info/scm_version.json +0 -8
  13. {units_lib-0.2.0 → units_lib-0.3.0}/.github/workflows/ci.yml +0 -0
  14. {units_lib-0.2.0 → units_lib-0.3.0}/.github/workflows/pypi-release.yml +0 -0
  15. {units_lib-0.2.0 → units_lib-0.3.0}/.gitignore +0 -0
  16. {units_lib-0.2.0 → units_lib-0.3.0}/LICENSE +0 -0
  17. {units_lib-0.2.0 → units_lib-0.3.0}/README.md +0 -0
  18. {units_lib-0.2.0 → units_lib-0.3.0}/pyproject.toml +0 -0
  19. {units_lib-0.2.0 → units_lib-0.3.0}/ruff.toml +0 -0
  20. {units_lib-0.2.0 → units_lib-0.3.0}/setup.cfg +0 -0
  21. {units_lib-0.2.0 → units_lib-0.3.0}/tests/__init__.py +0 -0
  22. {units_lib-0.2.0 → units_lib-0.3.0}/tests/hypothesis/__init__.py +0 -0
  23. {units_lib-0.2.0 → units_lib-0.3.0}/tests/hypothesis/test_dimension.py +0 -0
  24. {units_lib-0.2.0 → units_lib-0.3.0}/tests/unit/__init__.py +0 -0
  25. {units_lib-0.2.0 → units_lib-0.3.0}/tests/unit/conftest.py +0 -0
  26. {units_lib-0.2.0 → units_lib-0.3.0}/tests/unit/test_dimension.py +0 -0
  27. {units_lib-0.2.0 → units_lib-0.3.0}/tests/unit/test_kind.py +0 -0
  28. {units_lib-0.2.0 → units_lib-0.3.0}/units_lib/__init__.py +0 -0
  29. {units_lib-0.2.0 → units_lib-0.3.0}/units_lib/dimensions.py +0 -0
  30. {units_lib-0.2.0 → units_lib-0.3.0}/units_lib/kind.py +0 -0
  31. {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/dependency_links.txt +0 -0
  32. {units_lib-0.2.0 → units_lib-0.3.0}/units_lib.egg-info/top_level.txt +0 -0
  33. {units_lib-0.2.0 → units_lib-0.3.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: units-lib
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Library for managing calculations of quantities with units.
5
5
  Author-email: blm34 <pypi.escalate394@passmail.net>
6
6
  License-Expression: GPL-3.0-or-later
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: units-lib
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Library for managing calculations of quantities with units.
5
5
  Author-email: blm34 <pypi.escalate394@passmail.net>
6
6
  License-Expression: GPL-3.0-or-later
@@ -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
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.0",
3
+ "distance": 0,
4
+ "node": "g33eda42578972c80b88fff334a28234ffffa89ef",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-31"
8
+ }
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.2.0",
3
- "distance": 0,
4
- "node": "g36791160c361d61bc58edf2c9ea01a1e1db39e68",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-25"
8
- }
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