ucon 0.4.2__py3-none-any.whl → 0.5.1__py3-none-any.whl
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.
- tests/ucon/test_algebra.py +4 -4
- tests/ucon/test_dimensionless_units.py +248 -0
- tests/ucon/test_uncertainty.py +264 -0
- ucon/algebra.py +4 -3
- ucon/core.py +183 -17
- ucon/graph.py +18 -0
- ucon/maps.py +22 -1
- ucon/units.py +26 -2
- {ucon-0.4.2.dist-info → ucon-0.5.1.dist-info}/METADATA +55 -6
- ucon-0.5.1.dist-info/RECORD +24 -0
- ucon-0.4.2.dist-info/RECORD +0 -22
- {ucon-0.4.2.dist-info → ucon-0.5.1.dist-info}/WHEEL +0 -0
- {ucon-0.4.2.dist-info → ucon-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.4.2.dist-info → ucon-0.5.1.dist-info}/licenses/NOTICE +0 -0
- {ucon-0.4.2.dist-info → ucon-0.5.1.dist-info}/top_level.txt +0 -0
tests/ucon/test_algebra.py
CHANGED
|
@@ -71,10 +71,10 @@ class TestVectorEdgeCases(TestCase):
|
|
|
71
71
|
|
|
72
72
|
def test_vector_equality_with_non_vector(self):
|
|
73
73
|
v = Vector()
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
# Non-Vector comparisons return NotImplemented, which Python
|
|
75
|
+
# resolves to False (not equal) rather than raising an error
|
|
76
|
+
self.assertFalse(v == "not a vector")
|
|
77
|
+
self.assertFalse(v == None)
|
|
78
78
|
|
|
79
79
|
def test_hash_consistency_for_equal_vectors(self):
|
|
80
80
|
v1 = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# © 2026 The Radiativity Company
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
# See the LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Tests for v0.5.0 dimensionless units (pseudo-dimensions).
|
|
7
|
+
|
|
8
|
+
Tests pseudo-dimension isolation, angle/solid-angle/ratio unit conversions,
|
|
9
|
+
and cross-pseudo-dimension conversion failure.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
from ucon import units
|
|
16
|
+
from ucon.core import Dimension, Vector
|
|
17
|
+
from ucon.graph import ConversionNotFound
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestPseudoDimensionIsolation(unittest.TestCase):
|
|
21
|
+
"""Test that pseudo-dimensions are semantically isolated."""
|
|
22
|
+
|
|
23
|
+
def test_angle_not_equal_to_none(self):
|
|
24
|
+
self.assertNotEqual(Dimension.angle, Dimension.none)
|
|
25
|
+
|
|
26
|
+
def test_solid_angle_not_equal_to_none(self):
|
|
27
|
+
self.assertNotEqual(Dimension.solid_angle, Dimension.none)
|
|
28
|
+
|
|
29
|
+
def test_ratio_not_equal_to_none(self):
|
|
30
|
+
self.assertNotEqual(Dimension.ratio, Dimension.none)
|
|
31
|
+
|
|
32
|
+
def test_angle_not_equal_to_solid_angle(self):
|
|
33
|
+
self.assertNotEqual(Dimension.angle, Dimension.solid_angle)
|
|
34
|
+
|
|
35
|
+
def test_angle_not_equal_to_ratio(self):
|
|
36
|
+
self.assertNotEqual(Dimension.angle, Dimension.ratio)
|
|
37
|
+
|
|
38
|
+
def test_solid_angle_not_equal_to_ratio(self):
|
|
39
|
+
self.assertNotEqual(Dimension.solid_angle, Dimension.ratio)
|
|
40
|
+
|
|
41
|
+
def test_angle_equal_to_itself(self):
|
|
42
|
+
self.assertEqual(Dimension.angle, Dimension.angle)
|
|
43
|
+
|
|
44
|
+
def test_none_equal_to_itself(self):
|
|
45
|
+
self.assertEqual(Dimension.none, Dimension.none)
|
|
46
|
+
|
|
47
|
+
def test_all_pseudo_dimensions_have_zero_vector(self):
|
|
48
|
+
zero = Vector()
|
|
49
|
+
self.assertEqual(Dimension.none.vector, zero)
|
|
50
|
+
self.assertEqual(Dimension.angle.vector, zero)
|
|
51
|
+
self.assertEqual(Dimension.solid_angle.vector, zero)
|
|
52
|
+
self.assertEqual(Dimension.ratio.vector, zero)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestPseudoDimensionHashing(unittest.TestCase):
|
|
56
|
+
"""Test that pseudo-dimensions can coexist in sets and dicts."""
|
|
57
|
+
|
|
58
|
+
def test_all_pseudo_dimensions_in_set(self):
|
|
59
|
+
dims = {Dimension.none, Dimension.angle, Dimension.solid_angle, Dimension.ratio}
|
|
60
|
+
self.assertEqual(len(dims), 4)
|
|
61
|
+
|
|
62
|
+
def test_all_pseudo_dimensions_as_dict_keys(self):
|
|
63
|
+
d = {
|
|
64
|
+
Dimension.none: "none",
|
|
65
|
+
Dimension.angle: "angle",
|
|
66
|
+
Dimension.solid_angle: "solid_angle",
|
|
67
|
+
Dimension.ratio: "ratio",
|
|
68
|
+
}
|
|
69
|
+
self.assertEqual(len(d), 4)
|
|
70
|
+
self.assertEqual(d[Dimension.angle], "angle")
|
|
71
|
+
self.assertEqual(d[Dimension.ratio], "ratio")
|
|
72
|
+
|
|
73
|
+
def test_distinct_hashes(self):
|
|
74
|
+
hashes = {
|
|
75
|
+
hash(Dimension.none),
|
|
76
|
+
hash(Dimension.angle),
|
|
77
|
+
hash(Dimension.solid_angle),
|
|
78
|
+
hash(Dimension.ratio),
|
|
79
|
+
}
|
|
80
|
+
self.assertEqual(len(hashes), 4)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestAlgebraicResolution(unittest.TestCase):
|
|
84
|
+
"""Test that algebraic operations resolve to none, not pseudo-dimensions."""
|
|
85
|
+
|
|
86
|
+
def test_length_divided_by_length_is_none(self):
|
|
87
|
+
result = Dimension.length / Dimension.length
|
|
88
|
+
self.assertEqual(result, Dimension.none)
|
|
89
|
+
self.assertIs(result, Dimension.none)
|
|
90
|
+
|
|
91
|
+
def test_energy_divided_by_energy_is_none(self):
|
|
92
|
+
result = Dimension.energy / Dimension.energy
|
|
93
|
+
self.assertEqual(result, Dimension.none)
|
|
94
|
+
self.assertIs(result, Dimension.none)
|
|
95
|
+
|
|
96
|
+
def test_angle_times_length_is_length(self):
|
|
97
|
+
# Since angle has zero vector, angle * length = length
|
|
98
|
+
result = Dimension.angle * Dimension.length
|
|
99
|
+
self.assertEqual(result, Dimension.length)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestUnitDimensions(unittest.TestCase):
|
|
103
|
+
"""Test that units have correct dimensions."""
|
|
104
|
+
|
|
105
|
+
def test_radian_is_angle(self):
|
|
106
|
+
self.assertEqual(units.radian.dimension, Dimension.angle)
|
|
107
|
+
|
|
108
|
+
def test_degree_is_angle(self):
|
|
109
|
+
self.assertEqual(units.degree.dimension, Dimension.angle)
|
|
110
|
+
|
|
111
|
+
def test_steradian_is_solid_angle(self):
|
|
112
|
+
self.assertEqual(units.steradian.dimension, Dimension.solid_angle)
|
|
113
|
+
|
|
114
|
+
def test_square_degree_is_solid_angle(self):
|
|
115
|
+
self.assertEqual(units.square_degree.dimension, Dimension.solid_angle)
|
|
116
|
+
|
|
117
|
+
def test_percent_is_ratio(self):
|
|
118
|
+
self.assertEqual(units.percent.dimension, Dimension.ratio)
|
|
119
|
+
|
|
120
|
+
def test_ppm_is_ratio(self):
|
|
121
|
+
self.assertEqual(units.ppm.dimension, Dimension.ratio)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TestAngleConversions(unittest.TestCase):
|
|
125
|
+
"""Test angle unit conversions."""
|
|
126
|
+
|
|
127
|
+
def test_radian_to_degree(self):
|
|
128
|
+
angle = units.radian(math.pi)
|
|
129
|
+
result = angle.to(units.degree)
|
|
130
|
+
self.assertAlmostEqual(result.value, 180, places=9)
|
|
131
|
+
|
|
132
|
+
def test_degree_to_radian(self):
|
|
133
|
+
angle = units.degree(90)
|
|
134
|
+
result = angle.to(units.radian)
|
|
135
|
+
self.assertAlmostEqual(result.value, math.pi / 2, places=9)
|
|
136
|
+
|
|
137
|
+
def test_turn_to_degree(self):
|
|
138
|
+
angle = units.turn(1)
|
|
139
|
+
result = angle.to(units.degree)
|
|
140
|
+
self.assertAlmostEqual(result.value, 360, places=9)
|
|
141
|
+
|
|
142
|
+
def test_turn_to_radian(self):
|
|
143
|
+
angle = units.turn(1)
|
|
144
|
+
result = angle.to(units.radian)
|
|
145
|
+
self.assertAlmostEqual(result.value, 2 * math.pi, places=9)
|
|
146
|
+
|
|
147
|
+
def test_turn_to_gradian(self):
|
|
148
|
+
angle = units.turn(1)
|
|
149
|
+
result = angle.to(units.gradian)
|
|
150
|
+
self.assertAlmostEqual(result.value, 400, places=9)
|
|
151
|
+
|
|
152
|
+
def test_degree_to_arcminute(self):
|
|
153
|
+
angle = units.degree(1)
|
|
154
|
+
result = angle.to(units.arcminute)
|
|
155
|
+
self.assertAlmostEqual(result.value, 60, places=9)
|
|
156
|
+
|
|
157
|
+
def test_arcminute_to_arcsecond(self):
|
|
158
|
+
angle = units.arcminute(1)
|
|
159
|
+
result = angle.to(units.arcsecond)
|
|
160
|
+
self.assertAlmostEqual(result.value, 60, places=9)
|
|
161
|
+
|
|
162
|
+
def test_degree_to_arcsecond_composed(self):
|
|
163
|
+
angle = units.degree(1)
|
|
164
|
+
result = angle.to(units.arcsecond)
|
|
165
|
+
self.assertAlmostEqual(result.value, 3600, places=9)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TestSolidAngleConversions(unittest.TestCase):
|
|
169
|
+
"""Test solid angle unit conversions."""
|
|
170
|
+
|
|
171
|
+
def test_steradian_to_square_degree(self):
|
|
172
|
+
solid = units.steradian(1)
|
|
173
|
+
result = solid.to(units.square_degree)
|
|
174
|
+
expected = (180 / math.pi) ** 2
|
|
175
|
+
self.assertAlmostEqual(result.value, expected, places=1)
|
|
176
|
+
|
|
177
|
+
def test_square_degree_to_steradian(self):
|
|
178
|
+
solid = units.square_degree(1)
|
|
179
|
+
result = solid.to(units.steradian)
|
|
180
|
+
expected = (math.pi / 180) ** 2
|
|
181
|
+
self.assertAlmostEqual(result.value, expected, places=9)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class TestRatioConversions(unittest.TestCase):
|
|
185
|
+
"""Test ratio unit conversions."""
|
|
186
|
+
|
|
187
|
+
def test_one_to_percent(self):
|
|
188
|
+
r = units.ratio_one(0.5)
|
|
189
|
+
result = r.to(units.percent)
|
|
190
|
+
self.assertAlmostEqual(result.value, 50, places=9)
|
|
191
|
+
|
|
192
|
+
def test_percent_to_one(self):
|
|
193
|
+
r = units.percent(25)
|
|
194
|
+
result = r.to(units.ratio_one)
|
|
195
|
+
self.assertAlmostEqual(result.value, 0.25, places=9)
|
|
196
|
+
|
|
197
|
+
def test_one_to_ppm(self):
|
|
198
|
+
r = units.ratio_one(0.001)
|
|
199
|
+
result = r.to(units.ppm)
|
|
200
|
+
self.assertAlmostEqual(result.value, 1000, places=9)
|
|
201
|
+
|
|
202
|
+
def test_ppm_to_ppb(self):
|
|
203
|
+
r = units.ppm(1)
|
|
204
|
+
result = r.to(units.ppb)
|
|
205
|
+
self.assertAlmostEqual(result.value, 1000, places=9)
|
|
206
|
+
|
|
207
|
+
def test_one_to_permille(self):
|
|
208
|
+
r = units.ratio_one(0.005)
|
|
209
|
+
result = r.to(units.permille)
|
|
210
|
+
self.assertAlmostEqual(result.value, 5, places=9)
|
|
211
|
+
|
|
212
|
+
def test_basis_point_to_percent(self):
|
|
213
|
+
r = units.basis_point(100)
|
|
214
|
+
result = r.to(units.percent)
|
|
215
|
+
self.assertAlmostEqual(result.value, 1, places=9)
|
|
216
|
+
|
|
217
|
+
def test_percent_to_basis_point(self):
|
|
218
|
+
r = units.percent(0.25)
|
|
219
|
+
result = r.to(units.basis_point)
|
|
220
|
+
self.assertAlmostEqual(result.value, 25, places=9)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TestCrossPseudoDimensionFails(unittest.TestCase):
|
|
224
|
+
"""Test that cross-pseudo-dimension conversions fail."""
|
|
225
|
+
|
|
226
|
+
def test_radian_to_percent_fails(self):
|
|
227
|
+
with self.assertRaises(ConversionNotFound):
|
|
228
|
+
units.radian(1).to(units.percent)
|
|
229
|
+
|
|
230
|
+
def test_percent_to_degree_fails(self):
|
|
231
|
+
with self.assertRaises(ConversionNotFound):
|
|
232
|
+
units.percent(50).to(units.degree)
|
|
233
|
+
|
|
234
|
+
def test_radian_to_steradian_fails(self):
|
|
235
|
+
with self.assertRaises(ConversionNotFound):
|
|
236
|
+
units.radian(1).to(units.steradian)
|
|
237
|
+
|
|
238
|
+
def test_steradian_to_percent_fails(self):
|
|
239
|
+
with self.assertRaises(ConversionNotFound):
|
|
240
|
+
units.steradian(1).to(units.percent)
|
|
241
|
+
|
|
242
|
+
def test_ppm_to_arcminute_fails(self):
|
|
243
|
+
with self.assertRaises(ConversionNotFound):
|
|
244
|
+
units.ppm(1000).to(units.arcminute)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
unittest.main()
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# © 2026 The Radiativity Company
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
# See the LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Tests for v0.5.x uncertainty propagation.
|
|
7
|
+
|
|
8
|
+
Tests Number construction with uncertainty, display formatting,
|
|
9
|
+
arithmetic propagation, and conversion propagation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
from ucon import units, Scale
|
|
16
|
+
from ucon.core import Number, UnitProduct
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestUncertaintyConstruction(unittest.TestCase):
|
|
20
|
+
"""Test constructing Numbers with uncertainty."""
|
|
21
|
+
|
|
22
|
+
def test_number_with_uncertainty(self):
|
|
23
|
+
n = units.meter(1.234, uncertainty=0.005)
|
|
24
|
+
self.assertEqual(n.value, 1.234)
|
|
25
|
+
self.assertEqual(n.uncertainty, 0.005)
|
|
26
|
+
|
|
27
|
+
def test_number_without_uncertainty(self):
|
|
28
|
+
n = units.meter(1.234)
|
|
29
|
+
self.assertEqual(n.value, 1.234)
|
|
30
|
+
self.assertIsNone(n.uncertainty)
|
|
31
|
+
|
|
32
|
+
def test_unit_product_callable_with_uncertainty(self):
|
|
33
|
+
km = Scale.kilo * units.meter
|
|
34
|
+
n = km(5.0, uncertainty=0.1)
|
|
35
|
+
self.assertEqual(n.value, 5.0)
|
|
36
|
+
self.assertEqual(n.uncertainty, 0.1)
|
|
37
|
+
|
|
38
|
+
def test_composite_unit_with_uncertainty(self):
|
|
39
|
+
mps = units.meter / units.second
|
|
40
|
+
n = mps(10.0, uncertainty=0.5)
|
|
41
|
+
self.assertEqual(n.value, 10.0)
|
|
42
|
+
self.assertEqual(n.uncertainty, 0.5)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestUncertaintyDisplay(unittest.TestCase):
|
|
46
|
+
"""Test display formatting with uncertainty."""
|
|
47
|
+
|
|
48
|
+
def test_repr_with_uncertainty(self):
|
|
49
|
+
n = units.meter(1.234, uncertainty=0.005)
|
|
50
|
+
r = repr(n)
|
|
51
|
+
self.assertIn("1.234", r)
|
|
52
|
+
self.assertIn("±", r)
|
|
53
|
+
self.assertIn("0.005", r)
|
|
54
|
+
self.assertIn("m", r)
|
|
55
|
+
|
|
56
|
+
def test_repr_without_uncertainty(self):
|
|
57
|
+
n = units.meter(1.234)
|
|
58
|
+
r = repr(n)
|
|
59
|
+
self.assertIn("1.234", r)
|
|
60
|
+
self.assertNotIn("±", r)
|
|
61
|
+
|
|
62
|
+
def test_repr_dimensionless_with_uncertainty(self):
|
|
63
|
+
n = Number(quantity=0.5, uncertainty=0.01)
|
|
64
|
+
r = repr(n)
|
|
65
|
+
self.assertIn("0.5", r)
|
|
66
|
+
self.assertIn("±", r)
|
|
67
|
+
self.assertIn("0.01", r)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestAdditionSubtractionPropagation(unittest.TestCase):
|
|
71
|
+
"""Test uncertainty propagation through addition and subtraction."""
|
|
72
|
+
|
|
73
|
+
def test_addition_both_uncertain(self):
|
|
74
|
+
a = units.meter(10.0, uncertainty=0.3)
|
|
75
|
+
b = units.meter(5.0, uncertainty=0.4)
|
|
76
|
+
c = a + b
|
|
77
|
+
self.assertAlmostEqual(c.value, 15.0, places=9)
|
|
78
|
+
# sqrt(0.3² + 0.4²) = sqrt(0.09 + 0.16) = sqrt(0.25) = 0.5
|
|
79
|
+
self.assertAlmostEqual(c.uncertainty, 0.5, places=9)
|
|
80
|
+
|
|
81
|
+
def test_subtraction_both_uncertain(self):
|
|
82
|
+
a = units.meter(10.0, uncertainty=0.3)
|
|
83
|
+
b = units.meter(5.0, uncertainty=0.4)
|
|
84
|
+
c = a - b
|
|
85
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
86
|
+
# sqrt(0.3² + 0.4²) = 0.5
|
|
87
|
+
self.assertAlmostEqual(c.uncertainty, 0.5, places=9)
|
|
88
|
+
|
|
89
|
+
def test_addition_one_uncertain(self):
|
|
90
|
+
a = units.meter(10.0, uncertainty=0.3)
|
|
91
|
+
b = units.meter(5.0) # no uncertainty
|
|
92
|
+
c = a + b
|
|
93
|
+
self.assertAlmostEqual(c.value, 15.0, places=9)
|
|
94
|
+
self.assertAlmostEqual(c.uncertainty, 0.3, places=9)
|
|
95
|
+
|
|
96
|
+
def test_addition_neither_uncertain(self):
|
|
97
|
+
a = units.meter(10.0)
|
|
98
|
+
b = units.meter(5.0)
|
|
99
|
+
c = a + b
|
|
100
|
+
self.assertAlmostEqual(c.value, 15.0, places=9)
|
|
101
|
+
self.assertIsNone(c.uncertainty)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestMultiplicationPropagation(unittest.TestCase):
|
|
105
|
+
"""Test uncertainty propagation through multiplication."""
|
|
106
|
+
|
|
107
|
+
def test_multiplication_both_uncertain(self):
|
|
108
|
+
a = units.meter(10.0, uncertainty=0.2) # 2% relative
|
|
109
|
+
b = units.meter(5.0, uncertainty=0.15) # 3% relative
|
|
110
|
+
c = a * b
|
|
111
|
+
self.assertAlmostEqual(c.value, 50.0, places=9)
|
|
112
|
+
# relative: sqrt((0.2/10)² + (0.15/5)²) = sqrt(0.0004 + 0.0009) = sqrt(0.0013) ≈ 0.0361
|
|
113
|
+
# absolute: 50 * 0.0361 ≈ 1.803
|
|
114
|
+
expected = 50.0 * math.sqrt((0.2/10)**2 + (0.15/5)**2)
|
|
115
|
+
self.assertAlmostEqual(c.uncertainty, expected, places=6)
|
|
116
|
+
|
|
117
|
+
def test_multiplication_one_uncertain(self):
|
|
118
|
+
a = units.meter(10.0, uncertainty=0.2)
|
|
119
|
+
b = units.meter(5.0) # no uncertainty
|
|
120
|
+
c = a * b
|
|
121
|
+
self.assertAlmostEqual(c.value, 50.0, places=9)
|
|
122
|
+
# Only a contributes: |c| * (δa/a) = 50 * 0.02 = 1.0
|
|
123
|
+
expected = 50.0 * (0.2/10)
|
|
124
|
+
self.assertAlmostEqual(c.uncertainty, expected, places=9)
|
|
125
|
+
|
|
126
|
+
def test_multiplication_neither_uncertain(self):
|
|
127
|
+
a = units.meter(10.0)
|
|
128
|
+
b = units.meter(5.0)
|
|
129
|
+
c = a * b
|
|
130
|
+
self.assertAlmostEqual(c.value, 50.0, places=9)
|
|
131
|
+
self.assertIsNone(c.uncertainty)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestDivisionPropagation(unittest.TestCase):
|
|
135
|
+
"""Test uncertainty propagation through division."""
|
|
136
|
+
|
|
137
|
+
def test_division_both_uncertain(self):
|
|
138
|
+
a = units.meter(10.0, uncertainty=0.2) # 2% relative
|
|
139
|
+
b = units.second(2.0, uncertainty=0.04) # 2% relative
|
|
140
|
+
c = a / b
|
|
141
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
142
|
+
# relative: sqrt((0.2/10)² + (0.04/2)²) = sqrt(0.0004 + 0.0004) = sqrt(0.0008) ≈ 0.0283
|
|
143
|
+
# absolute: 5 * 0.0283 ≈ 0.1414
|
|
144
|
+
expected = 5.0 * math.sqrt((0.2/10)**2 + (0.04/2)**2)
|
|
145
|
+
self.assertAlmostEqual(c.uncertainty, expected, places=6)
|
|
146
|
+
|
|
147
|
+
def test_division_one_uncertain(self):
|
|
148
|
+
a = units.meter(10.0, uncertainty=0.2)
|
|
149
|
+
b = units.second(2.0) # no uncertainty
|
|
150
|
+
c = a / b
|
|
151
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
152
|
+
expected = 5.0 * (0.2/10)
|
|
153
|
+
self.assertAlmostEqual(c.uncertainty, expected, places=9)
|
|
154
|
+
|
|
155
|
+
def test_division_neither_uncertain(self):
|
|
156
|
+
a = units.meter(10.0)
|
|
157
|
+
b = units.second(2.0)
|
|
158
|
+
c = a / b
|
|
159
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
160
|
+
self.assertIsNone(c.uncertainty)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TestScalarOperations(unittest.TestCase):
|
|
164
|
+
"""Test uncertainty propagation with scalar multiplication/division."""
|
|
165
|
+
|
|
166
|
+
def test_scalar_multiply_number(self):
|
|
167
|
+
a = units.meter(10.0, uncertainty=0.2)
|
|
168
|
+
c = a * 3 # scalar multiplication
|
|
169
|
+
self.assertAlmostEqual(c.value, 30.0, places=9)
|
|
170
|
+
self.assertAlmostEqual(c.uncertainty, 0.6, places=9)
|
|
171
|
+
|
|
172
|
+
def test_scalar_divide_number(self):
|
|
173
|
+
a = units.meter(10.0, uncertainty=0.2)
|
|
174
|
+
c = a / 2 # scalar division
|
|
175
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
176
|
+
self.assertAlmostEqual(c.uncertainty, 0.1, places=9)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TestConversionPropagation(unittest.TestCase):
|
|
180
|
+
"""Test uncertainty propagation through unit conversion."""
|
|
181
|
+
|
|
182
|
+
def test_linear_conversion(self):
|
|
183
|
+
# meter to foot: factor ≈ 3.28084
|
|
184
|
+
length = units.meter(1.0, uncertainty=0.01)
|
|
185
|
+
length_ft = length.to(units.foot)
|
|
186
|
+
self.assertAlmostEqual(length_ft.value, 3.28084, places=4)
|
|
187
|
+
# uncertainty scales by same factor
|
|
188
|
+
self.assertAlmostEqual(length_ft.uncertainty, 0.01 * 3.28084, places=4)
|
|
189
|
+
|
|
190
|
+
def test_affine_conversion(self):
|
|
191
|
+
# Celsius to Kelvin: K = C + 273.15
|
|
192
|
+
# Derivative is 1, so uncertainty unchanged
|
|
193
|
+
temp = units.celsius(25.0, uncertainty=0.5)
|
|
194
|
+
temp_k = temp.to(units.kelvin)
|
|
195
|
+
self.assertAlmostEqual(temp_k.value, 298.15, places=9)
|
|
196
|
+
self.assertAlmostEqual(temp_k.uncertainty, 0.5, places=9)
|
|
197
|
+
|
|
198
|
+
def test_fahrenheit_to_celsius(self):
|
|
199
|
+
# F to C: C = (F - 32) * 5/9
|
|
200
|
+
# Derivative is 5/9 ≈ 0.5556
|
|
201
|
+
temp = units.fahrenheit(100.0, uncertainty=1.0)
|
|
202
|
+
temp_c = temp.to(units.celsius)
|
|
203
|
+
self.assertAlmostEqual(temp_c.value, 37.7778, places=3)
|
|
204
|
+
self.assertAlmostEqual(temp_c.uncertainty, 1.0 * (5/9), places=6)
|
|
205
|
+
|
|
206
|
+
def test_conversion_without_uncertainty(self):
|
|
207
|
+
length = units.meter(1.0)
|
|
208
|
+
length_ft = length.to(units.foot)
|
|
209
|
+
self.assertIsNone(length_ft.uncertainty)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestEdgeCases(unittest.TestCase):
|
|
213
|
+
"""Test edge cases for uncertainty handling."""
|
|
214
|
+
|
|
215
|
+
def test_zero_uncertainty(self):
|
|
216
|
+
a = units.meter(10.0, uncertainty=0.0)
|
|
217
|
+
b = units.meter(5.0, uncertainty=0.0)
|
|
218
|
+
c = a + b
|
|
219
|
+
self.assertAlmostEqual(c.uncertainty, 0.0, places=9)
|
|
220
|
+
|
|
221
|
+
def test_very_small_uncertainty(self):
|
|
222
|
+
a = units.meter(10.0, uncertainty=1e-15)
|
|
223
|
+
b = units.meter(5.0, uncertainty=1e-15)
|
|
224
|
+
c = a * b
|
|
225
|
+
self.assertIsNotNone(c.uncertainty)
|
|
226
|
+
self.assertGreater(c.uncertainty, 0)
|
|
227
|
+
|
|
228
|
+
def test_uncertainty_preserved_through_simplify(self):
|
|
229
|
+
km = Scale.kilo * units.meter
|
|
230
|
+
n = km(5.0, uncertainty=0.1)
|
|
231
|
+
simplified = n.simplify()
|
|
232
|
+
self.assertAlmostEqual(simplified.value, 5000.0, places=9)
|
|
233
|
+
# uncertainty also scales
|
|
234
|
+
self.assertAlmostEqual(simplified.uncertainty, 100.0, places=9)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TestMapDerivative(unittest.TestCase):
|
|
238
|
+
"""Test Map.derivative() implementations."""
|
|
239
|
+
|
|
240
|
+
def test_linear_map_derivative(self):
|
|
241
|
+
from ucon.maps import LinearMap
|
|
242
|
+
m = LinearMap(3.28084)
|
|
243
|
+
self.assertAlmostEqual(m.derivative(0), 3.28084, places=9)
|
|
244
|
+
self.assertAlmostEqual(m.derivative(100), 3.28084, places=9)
|
|
245
|
+
|
|
246
|
+
def test_affine_map_derivative(self):
|
|
247
|
+
from ucon.maps import AffineMap
|
|
248
|
+
m = AffineMap(5/9, -32 * 5/9) # F to C
|
|
249
|
+
self.assertAlmostEqual(m.derivative(0), 5/9, places=9)
|
|
250
|
+
self.assertAlmostEqual(m.derivative(100), 5/9, places=9)
|
|
251
|
+
|
|
252
|
+
def test_composed_map_derivative(self):
|
|
253
|
+
from ucon.maps import LinearMap, AffineMap
|
|
254
|
+
# Compose: first scale by 2, then add 10
|
|
255
|
+
inner = LinearMap(2)
|
|
256
|
+
outer = AffineMap(1, 10)
|
|
257
|
+
composed = outer @ inner
|
|
258
|
+
# d/dx [1*(2x) + 10] = 2
|
|
259
|
+
self.assertAlmostEqual(composed.derivative(0), 2, places=9)
|
|
260
|
+
self.assertAlmostEqual(composed.derivative(5), 2, places=9)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
unittest.main()
|
ucon/algebra.py
CHANGED
|
@@ -102,9 +102,10 @@ class Vector:
|
|
|
102
102
|
values = tuple(component * scalar for component in tuple(self))
|
|
103
103
|
return Vector(*values)
|
|
104
104
|
|
|
105
|
-
def __eq__(self,
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
def __eq__(self, other) -> bool:
|
|
106
|
+
if not isinstance(other, Vector):
|
|
107
|
+
return NotImplemented
|
|
108
|
+
return tuple(self) == tuple(other)
|
|
108
109
|
|
|
109
110
|
def __hash__(self) -> int:
|
|
110
111
|
# Hash based on the string because tuples have been shown to collide
|
ucon/core.py
CHANGED
|
@@ -36,6 +36,10 @@ class Dimension(Enum):
|
|
|
36
36
|
"""
|
|
37
37
|
Represents a **physical dimension** defined by a :class:`Vector`.
|
|
38
38
|
Algebra over multiplication/division & exponentiation, with dynamic resolution.
|
|
39
|
+
|
|
40
|
+
Pseudo-dimensions (angle, solid_angle, ratio) share the zero vector but are
|
|
41
|
+
semantically isolated via unique enum values. They use tuple values (Vector, tag)
|
|
42
|
+
to prevent Python's Enum from treating them as aliases of `none`.
|
|
39
43
|
"""
|
|
40
44
|
none = Vector()
|
|
41
45
|
|
|
@@ -50,6 +54,12 @@ class Dimension(Enum):
|
|
|
50
54
|
information = Vector(0, 0, 0, 0, 0, 0, 0, 1)
|
|
51
55
|
# ------------------------------------------------
|
|
52
56
|
|
|
57
|
+
# -- PSEUDO-DIMENSIONS (zero vector, distinct values via tuple) --
|
|
58
|
+
angle = (Vector(), "angle") # radian, degree, etc.
|
|
59
|
+
solid_angle = (Vector(), "solid_angle") # steradian, square_degree
|
|
60
|
+
ratio = (Vector(), "ratio") # percent, ppm, etc.
|
|
61
|
+
# ----------------------------------------------------------------
|
|
62
|
+
|
|
53
63
|
acceleration = Vector(-2, 1, 0, 0, 0, 0, 0, 0)
|
|
54
64
|
angular_momentum = Vector(-1, 2, 1, 0, 0, 0, 0, 0)
|
|
55
65
|
area = Vector(0, 2, 0, 0, 0, 0, 0, 0)
|
|
@@ -83,10 +93,22 @@ class Dimension(Enum):
|
|
|
83
93
|
voltage = Vector(-3, 2, 1, -1, 0, 0, 0, 0)
|
|
84
94
|
volume = Vector(0, 3, 0, 0, 0, 0, 0, 0)
|
|
85
95
|
|
|
96
|
+
@property
|
|
97
|
+
def vector(self) -> 'Vector':
|
|
98
|
+
"""Return the dimensional Vector, handling both regular and pseudo-dimensions."""
|
|
99
|
+
if isinstance(self.value, tuple):
|
|
100
|
+
vector, _ = self.value # discard the tag
|
|
101
|
+
return vector
|
|
102
|
+
return self.value
|
|
103
|
+
|
|
86
104
|
@classmethod
|
|
87
105
|
def _resolve(cls, vector: 'Vector') -> 'Dimension':
|
|
106
|
+
# Zero vector always resolves to none, never to pseudo-dimensions.
|
|
107
|
+
# This ensures algebraic operations like length/length yield none.
|
|
108
|
+
if vector == Vector():
|
|
109
|
+
return cls.none
|
|
88
110
|
for dim in cls:
|
|
89
|
-
if dim.
|
|
111
|
+
if dim.vector == vector:
|
|
90
112
|
return dim
|
|
91
113
|
dyn = object.__new__(cls)
|
|
92
114
|
dyn._name_ = f"derived({vector})"
|
|
@@ -96,27 +118,32 @@ class Dimension(Enum):
|
|
|
96
118
|
def __truediv__(self, dimension: 'Dimension') -> 'Dimension':
|
|
97
119
|
if not isinstance(dimension, Dimension):
|
|
98
120
|
raise TypeError(f"Cannot divide Dimension by non-Dimension type: {type(dimension)}")
|
|
99
|
-
return self._resolve(self.
|
|
121
|
+
return self._resolve(self.vector - dimension.vector)
|
|
100
122
|
|
|
101
123
|
def __mul__(self, dimension: 'Dimension') -> 'Dimension':
|
|
102
124
|
if not isinstance(dimension, Dimension):
|
|
103
125
|
raise TypeError(f"Cannot multiply Dimension by non-Dimension type: {type(dimension)}")
|
|
104
|
-
return self._resolve(self.
|
|
126
|
+
return self._resolve(self.vector + dimension.vector)
|
|
105
127
|
|
|
106
128
|
def __pow__(self, power: Union[int, float]) -> 'Dimension':
|
|
107
129
|
if power == 1:
|
|
108
130
|
return self
|
|
109
131
|
if power == 0:
|
|
110
132
|
return Dimension.none
|
|
111
|
-
new_vector = self.
|
|
133
|
+
new_vector = self.vector * power
|
|
112
134
|
return self._resolve(new_vector)
|
|
113
135
|
|
|
114
136
|
def __eq__(self, dimension) -> bool:
|
|
115
137
|
if not isinstance(dimension, Dimension):
|
|
116
138
|
raise TypeError(f"Cannot compare Dimension with non-Dimension type: {type(dimension)}")
|
|
117
|
-
|
|
139
|
+
# For pseudo-dimensions (tuple values), compare by identity
|
|
140
|
+
if isinstance(self.value, tuple) or isinstance(dimension.value, tuple):
|
|
141
|
+
return self is dimension
|
|
142
|
+
# For regular dimensions, compare by vector
|
|
143
|
+
return self.vector == dimension.vector
|
|
118
144
|
|
|
119
145
|
def __hash__(self) -> int:
|
|
146
|
+
# Use the raw value for hashing (tuples and Vectors hash differently)
|
|
120
147
|
return hash(self.value)
|
|
121
148
|
|
|
122
149
|
def __bool__(self):
|
|
@@ -418,15 +445,24 @@ class Unit:
|
|
|
418
445
|
|
|
419
446
|
# ----------------- callable (creates Number) -----------------
|
|
420
447
|
|
|
421
|
-
def __call__(self, quantity: Union[int, float]) -> "Number":
|
|
448
|
+
def __call__(self, quantity: Union[int, float], uncertainty: Union[float, None] = None) -> "Number":
|
|
422
449
|
"""Create a Number with this unit.
|
|
423
450
|
|
|
451
|
+
Parameters
|
|
452
|
+
----------
|
|
453
|
+
quantity : int or float
|
|
454
|
+
The numeric value.
|
|
455
|
+
uncertainty : float, optional
|
|
456
|
+
The measurement uncertainty.
|
|
457
|
+
|
|
424
458
|
Example
|
|
425
459
|
-------
|
|
426
460
|
>>> meter(5)
|
|
427
461
|
<5 m>
|
|
462
|
+
>>> meter(1.234, uncertainty=0.005)
|
|
463
|
+
<1.234 ± 0.005 m>
|
|
428
464
|
"""
|
|
429
|
-
return Number(quantity=quantity, unit=UnitProduct.from_unit(self))
|
|
465
|
+
return Number(quantity=quantity, unit=UnitProduct.from_unit(self), uncertainty=uncertainty)
|
|
430
466
|
|
|
431
467
|
|
|
432
468
|
@dataclass(frozen=True)
|
|
@@ -845,15 +881,24 @@ class UnitProduct:
|
|
|
845
881
|
# Sort by name; UnitFactor exposes .name, so this is stable.
|
|
846
882
|
return hash(tuple(sorted(self.factors.items(), key=lambda x: x[0].name)))
|
|
847
883
|
|
|
848
|
-
def __call__(self, quantity: Union[int, float]) -> "Number":
|
|
884
|
+
def __call__(self, quantity: Union[int, float], uncertainty: Union[float, None] = None) -> "Number":
|
|
849
885
|
"""Create a Number with this unit product.
|
|
850
886
|
|
|
887
|
+
Parameters
|
|
888
|
+
----------
|
|
889
|
+
quantity : int or float
|
|
890
|
+
The numeric value.
|
|
891
|
+
uncertainty : float, optional
|
|
892
|
+
The measurement uncertainty.
|
|
893
|
+
|
|
851
894
|
Example
|
|
852
895
|
-------
|
|
853
896
|
>>> (meter / second)(10)
|
|
854
897
|
<10 m/s>
|
|
898
|
+
>>> (meter / second)(10, uncertainty=0.5)
|
|
899
|
+
<10 ± 0.5 m/s>
|
|
855
900
|
"""
|
|
856
|
-
return Number(quantity=quantity, unit=self)
|
|
901
|
+
return Number(quantity=quantity, unit=self, uncertainty=uncertainty)
|
|
857
902
|
|
|
858
903
|
|
|
859
904
|
# --------------------------------------------------------------------------------------
|
|
@@ -881,9 +926,16 @@ class Number:
|
|
|
881
926
|
>>> speed = length / time
|
|
882
927
|
>>> speed
|
|
883
928
|
<2.5 m/s>
|
|
929
|
+
|
|
930
|
+
Optionally includes measurement uncertainty for error propagation:
|
|
931
|
+
|
|
932
|
+
>>> length = meter(1.234, uncertainty=0.005)
|
|
933
|
+
>>> length
|
|
934
|
+
<1.234 ± 0.005 m>
|
|
884
935
|
"""
|
|
885
936
|
quantity: Union[float, int] = 1.0
|
|
886
937
|
unit: Union[Unit, UnitProduct] = None
|
|
938
|
+
uncertainty: Union[float, None] = None
|
|
887
939
|
|
|
888
940
|
def __post_init__(self):
|
|
889
941
|
if self.unit is None:
|
|
@@ -925,7 +977,7 @@ class Number:
|
|
|
925
977
|
"""
|
|
926
978
|
if not isinstance(self.unit, UnitProduct):
|
|
927
979
|
# Plain Unit already has no scale
|
|
928
|
-
return Number(quantity=self.quantity, unit=self.unit)
|
|
980
|
+
return Number(quantity=self.quantity, unit=self.unit, uncertainty=self.uncertainty)
|
|
929
981
|
|
|
930
982
|
# Compute the combined scale factor
|
|
931
983
|
scale_factor = self.unit.fold_scale()
|
|
@@ -938,8 +990,16 @@ class Number:
|
|
|
938
990
|
|
|
939
991
|
base_unit = UnitProduct(base_factors)
|
|
940
992
|
|
|
941
|
-
# Adjust quantity by the scale factor
|
|
942
|
-
|
|
993
|
+
# Adjust quantity and uncertainty by the scale factor
|
|
994
|
+
new_uncertainty = None
|
|
995
|
+
if self.uncertainty is not None:
|
|
996
|
+
new_uncertainty = self.uncertainty * abs(scale_factor)
|
|
997
|
+
|
|
998
|
+
return Number(
|
|
999
|
+
quantity=self.quantity * scale_factor,
|
|
1000
|
+
unit=base_unit,
|
|
1001
|
+
uncertainty=new_uncertainty,
|
|
1002
|
+
)
|
|
943
1003
|
|
|
944
1004
|
def to(self, target, graph=None):
|
|
945
1005
|
"""Convert this Number to a different unit expression.
|
|
@@ -972,7 +1032,10 @@ class Number:
|
|
|
972
1032
|
# Scale-only conversion (same base unit, different scale)
|
|
973
1033
|
if self._is_scale_only_conversion(src, dst):
|
|
974
1034
|
factor = src.fold_scale() / dst.fold_scale()
|
|
975
|
-
|
|
1035
|
+
new_uncertainty = None
|
|
1036
|
+
if self.uncertainty is not None:
|
|
1037
|
+
new_uncertainty = self.uncertainty * abs(factor)
|
|
1038
|
+
return Number(quantity=self.quantity * factor, unit=target, uncertainty=new_uncertainty)
|
|
976
1039
|
|
|
977
1040
|
# Graph-based conversion (use default graph if none provided)
|
|
978
1041
|
if graph is None:
|
|
@@ -981,7 +1044,14 @@ class Number:
|
|
|
981
1044
|
conversion_map = graph.convert(src=src, dst=dst)
|
|
982
1045
|
# Use raw quantity - the conversion map handles scale via factorwise decomposition
|
|
983
1046
|
converted_quantity = conversion_map(self.quantity)
|
|
984
|
-
|
|
1047
|
+
|
|
1048
|
+
# Propagate uncertainty through conversion using derivative
|
|
1049
|
+
new_uncertainty = None
|
|
1050
|
+
if self.uncertainty is not None:
|
|
1051
|
+
derivative = abs(conversion_map.derivative(self.quantity))
|
|
1052
|
+
new_uncertainty = derivative * self.uncertainty
|
|
1053
|
+
|
|
1054
|
+
return Number(quantity=converted_quantity, unit=target, uncertainty=new_uncertainty)
|
|
985
1055
|
|
|
986
1056
|
def _is_scale_only_conversion(self, src: UnitProduct, dst: UnitProduct) -> bool:
|
|
987
1057
|
"""Check if conversion is just a scale change (same base units)."""
|
|
@@ -1013,12 +1083,82 @@ class Number:
|
|
|
1013
1083
|
if isinstance(other, Ratio):
|
|
1014
1084
|
other = other.evaluate()
|
|
1015
1085
|
|
|
1086
|
+
# Scalar multiplication
|
|
1087
|
+
if isinstance(other, (int, float)):
|
|
1088
|
+
new_uncertainty = None
|
|
1089
|
+
if self.uncertainty is not None:
|
|
1090
|
+
new_uncertainty = abs(other) * self.uncertainty
|
|
1091
|
+
return Number(
|
|
1092
|
+
quantity=self.quantity * other,
|
|
1093
|
+
unit=self.unit,
|
|
1094
|
+
uncertainty=new_uncertainty,
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1016
1097
|
if not isinstance(other, Number):
|
|
1017
1098
|
return NotImplemented
|
|
1018
1099
|
|
|
1100
|
+
# Uncertainty propagation for multiplication
|
|
1101
|
+
# δc = |c| * sqrt((δa/a)² + (δb/b)²)
|
|
1102
|
+
new_uncertainty = None
|
|
1103
|
+
result_quantity = self.quantity * other.quantity
|
|
1104
|
+
if self.uncertainty is not None or other.uncertainty is not None:
|
|
1105
|
+
rel_a = (self.uncertainty / abs(self.quantity)) if (self.uncertainty and self.quantity != 0) else 0
|
|
1106
|
+
rel_b = (other.uncertainty / abs(other.quantity)) if (other.uncertainty and other.quantity != 0) else 0
|
|
1107
|
+
rel_c = math.sqrt(rel_a**2 + rel_b**2)
|
|
1108
|
+
new_uncertainty = abs(result_quantity) * rel_c if rel_c > 0 else None
|
|
1109
|
+
|
|
1019
1110
|
return Number(
|
|
1020
|
-
quantity=
|
|
1111
|
+
quantity=result_quantity,
|
|
1021
1112
|
unit=self.unit * other.unit,
|
|
1113
|
+
uncertainty=new_uncertainty,
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
def __add__(self, other: 'Number') -> 'Number':
|
|
1117
|
+
if not isinstance(other, Number):
|
|
1118
|
+
return NotImplemented
|
|
1119
|
+
|
|
1120
|
+
# Dimensions must match for addition
|
|
1121
|
+
if self.unit.dimension != other.unit.dimension:
|
|
1122
|
+
raise TypeError(
|
|
1123
|
+
f"Cannot add Numbers with different dimensions: "
|
|
1124
|
+
f"{self.unit.dimension} vs {other.unit.dimension}"
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
# Uncertainty propagation for addition: δc = sqrt(δa² + δb²)
|
|
1128
|
+
new_uncertainty = None
|
|
1129
|
+
if self.uncertainty is not None or other.uncertainty is not None:
|
|
1130
|
+
ua = self.uncertainty if self.uncertainty is not None else 0
|
|
1131
|
+
ub = other.uncertainty if other.uncertainty is not None else 0
|
|
1132
|
+
new_uncertainty = math.sqrt(ua**2 + ub**2)
|
|
1133
|
+
|
|
1134
|
+
return Number(
|
|
1135
|
+
quantity=self.quantity + other.quantity,
|
|
1136
|
+
unit=self.unit,
|
|
1137
|
+
uncertainty=new_uncertainty,
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
def __sub__(self, other: 'Number') -> 'Number':
|
|
1141
|
+
if not isinstance(other, Number):
|
|
1142
|
+
return NotImplemented
|
|
1143
|
+
|
|
1144
|
+
# Dimensions must match for subtraction
|
|
1145
|
+
if self.unit.dimension != other.unit.dimension:
|
|
1146
|
+
raise TypeError(
|
|
1147
|
+
f"Cannot subtract Numbers with different dimensions: "
|
|
1148
|
+
f"{self.unit.dimension} vs {other.unit.dimension}"
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
# Uncertainty propagation for subtraction: δc = sqrt(δa² + δb²)
|
|
1152
|
+
new_uncertainty = None
|
|
1153
|
+
if self.uncertainty is not None or other.uncertainty is not None:
|
|
1154
|
+
ua = self.uncertainty if self.uncertainty is not None else 0
|
|
1155
|
+
ub = other.uncertainty if other.uncertainty is not None else 0
|
|
1156
|
+
new_uncertainty = math.sqrt(ua**2 + ub**2)
|
|
1157
|
+
|
|
1158
|
+
return Number(
|
|
1159
|
+
quantity=self.quantity - other.quantity,
|
|
1160
|
+
unit=self.unit,
|
|
1161
|
+
uncertainty=new_uncertainty,
|
|
1022
1162
|
)
|
|
1023
1163
|
|
|
1024
1164
|
def __truediv__(self, other: Quantifiable) -> "Number":
|
|
@@ -1026,25 +1166,47 @@ class Number:
|
|
|
1026
1166
|
if isinstance(other, Ratio):
|
|
1027
1167
|
other = other.evaluate()
|
|
1028
1168
|
|
|
1169
|
+
# Scalar division
|
|
1170
|
+
if isinstance(other, (int, float)):
|
|
1171
|
+
new_uncertainty = None
|
|
1172
|
+
if self.uncertainty is not None:
|
|
1173
|
+
new_uncertainty = self.uncertainty / abs(other)
|
|
1174
|
+
return Number(
|
|
1175
|
+
quantity=self.quantity / other,
|
|
1176
|
+
unit=self.unit,
|
|
1177
|
+
uncertainty=new_uncertainty,
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1029
1180
|
if not isinstance(other, Number):
|
|
1030
1181
|
raise TypeError(f"Cannot divide Number by non-Number/Ratio type: {type(other)}")
|
|
1031
1182
|
|
|
1032
1183
|
# Symbolic quotient in the unit algebra
|
|
1033
1184
|
unit_quot = self.unit / other.unit
|
|
1034
1185
|
|
|
1186
|
+
# Uncertainty propagation for division
|
|
1187
|
+
# δc = |c| * sqrt((δa/a)² + (δb/b)²)
|
|
1188
|
+
def compute_uncertainty(result_quantity):
|
|
1189
|
+
if self.uncertainty is None and other.uncertainty is None:
|
|
1190
|
+
return None
|
|
1191
|
+
rel_a = (self.uncertainty / abs(self.quantity)) if (self.uncertainty and self.quantity != 0) else 0
|
|
1192
|
+
rel_b = (other.uncertainty / abs(other.quantity)) if (other.uncertainty and other.quantity != 0) else 0
|
|
1193
|
+
rel_c = math.sqrt(rel_a**2 + rel_b**2)
|
|
1194
|
+
return abs(result_quantity) * rel_c if rel_c > 0 else None
|
|
1195
|
+
|
|
1035
1196
|
# --- Case 1: Dimensionless result ----------------------------------
|
|
1036
1197
|
# If the net dimension is none, we want a pure scalar:
|
|
1037
1198
|
# fold *all* scale factors into the numeric magnitude.
|
|
1038
1199
|
if not unit_quot.dimension:
|
|
1039
1200
|
num = self._canonical_magnitude
|
|
1040
1201
|
den = other._canonical_magnitude
|
|
1041
|
-
|
|
1202
|
+
result = num / den
|
|
1203
|
+
return Number(quantity=result, unit=_none, uncertainty=compute_uncertainty(result))
|
|
1042
1204
|
|
|
1043
1205
|
# --- Case 2: Dimensionful result -----------------------------------
|
|
1044
1206
|
# For "real" physical results like g/mL, m/s², etc., preserve the
|
|
1045
1207
|
# user's chosen unit scales symbolically. Only divide the raw quantities.
|
|
1046
1208
|
new_quantity = self.quantity / other.quantity
|
|
1047
|
-
return Number(quantity=new_quantity, unit=unit_quot)
|
|
1209
|
+
return Number(quantity=new_quantity, unit=unit_quot, uncertainty=compute_uncertainty(new_quantity))
|
|
1048
1210
|
|
|
1049
1211
|
def __eq__(self, other: Quantifiable) -> bool:
|
|
1050
1212
|
if not isinstance(other, (Number, Ratio)):
|
|
@@ -1067,6 +1229,10 @@ class Number:
|
|
|
1067
1229
|
return True
|
|
1068
1230
|
|
|
1069
1231
|
def __repr__(self):
|
|
1232
|
+
if self.uncertainty is not None:
|
|
1233
|
+
if not self.unit.dimension:
|
|
1234
|
+
return f"<{self.quantity} ± {self.uncertainty}>"
|
|
1235
|
+
return f"<{self.quantity} ± {self.uncertainty} {self.unit.shorthand}>"
|
|
1070
1236
|
if not self.unit.dimension:
|
|
1071
1237
|
return f"<{self.quantity}>"
|
|
1072
1238
|
return f"<{self.quantity} {self.unit.shorthand}>"
|
ucon/graph.py
CHANGED
|
@@ -420,4 +420,22 @@ def _build_standard_graph() -> ConversionGraph:
|
|
|
420
420
|
# --- Information ---
|
|
421
421
|
graph.add_edge(src=units.byte, dst=units.bit, map=LinearMap(8))
|
|
422
422
|
|
|
423
|
+
# --- Angle ---
|
|
424
|
+
import math
|
|
425
|
+
graph.add_edge(src=units.radian, dst=units.degree, map=LinearMap(180 / math.pi))
|
|
426
|
+
graph.add_edge(src=units.degree, dst=units.arcminute, map=LinearMap(60))
|
|
427
|
+
graph.add_edge(src=units.arcminute, dst=units.arcsecond, map=LinearMap(60))
|
|
428
|
+
graph.add_edge(src=units.turn, dst=units.radian, map=LinearMap(2 * math.pi))
|
|
429
|
+
graph.add_edge(src=units.turn, dst=units.gradian, map=LinearMap(400))
|
|
430
|
+
|
|
431
|
+
# --- Solid Angle ---
|
|
432
|
+
graph.add_edge(src=units.steradian, dst=units.square_degree, map=LinearMap((180 / math.pi) ** 2))
|
|
433
|
+
|
|
434
|
+
# --- Ratio ---
|
|
435
|
+
graph.add_edge(src=units.ratio_one, dst=units.percent, map=LinearMap(100))
|
|
436
|
+
graph.add_edge(src=units.ratio_one, dst=units.permille, map=LinearMap(1000))
|
|
437
|
+
graph.add_edge(src=units.ratio_one, dst=units.ppm, map=LinearMap(1e6))
|
|
438
|
+
graph.add_edge(src=units.ratio_one, dst=units.ppb, map=LinearMap(1e9))
|
|
439
|
+
graph.add_edge(src=units.ratio_one, dst=units.basis_point, map=LinearMap(10000))
|
|
440
|
+
|
|
423
441
|
return graph
|
ucon/maps.py
CHANGED
|
@@ -25,7 +25,7 @@ from dataclasses import dataclass
|
|
|
25
25
|
class Map(ABC):
|
|
26
26
|
"""Abstract base for all conversion morphisms.
|
|
27
27
|
|
|
28
|
-
Subclasses must implement ``__call__``, ``inverse``, and ``
|
|
28
|
+
Subclasses must implement ``__call__``, ``inverse``, ``__pow__``, and ``derivative``.
|
|
29
29
|
Composition via ``@`` defaults to :class:`ComposedMap`; subclasses may
|
|
30
30
|
override for closed composition within their own type.
|
|
31
31
|
"""
|
|
@@ -50,6 +50,14 @@ class Map(ABC):
|
|
|
50
50
|
"""Raise map to a power (for exponent handling in factorwise conversion)."""
|
|
51
51
|
...
|
|
52
52
|
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def derivative(self, x: float) -> float:
|
|
55
|
+
"""Return the derivative of the map at point x.
|
|
56
|
+
|
|
57
|
+
Used for uncertainty propagation: δy = |f'(x)| * δx
|
|
58
|
+
"""
|
|
59
|
+
...
|
|
60
|
+
|
|
53
61
|
def is_identity(self, tol: float = 1e-9) -> bool:
|
|
54
62
|
"""Check if this map is approximately the identity."""
|
|
55
63
|
return abs(self(1.0) - 1.0) < tol and abs(self(0.0) - 0.0) < tol
|
|
@@ -86,6 +94,10 @@ class LinearMap(Map):
|
|
|
86
94
|
def __pow__(self, exp: float) -> LinearMap:
|
|
87
95
|
return LinearMap(self.a ** exp)
|
|
88
96
|
|
|
97
|
+
def derivative(self, x: float) -> float:
|
|
98
|
+
"""Derivative of y = a*x is a (constant)."""
|
|
99
|
+
return self.a
|
|
100
|
+
|
|
89
101
|
@classmethod
|
|
90
102
|
def identity(cls) -> LinearMap:
|
|
91
103
|
return cls(1.0)
|
|
@@ -128,6 +140,10 @@ class AffineMap(Map):
|
|
|
128
140
|
return self.inverse()
|
|
129
141
|
raise ValueError("AffineMap only supports exp=1 or exp=-1")
|
|
130
142
|
|
|
143
|
+
def derivative(self, x: float) -> float:
|
|
144
|
+
"""Derivative of y = a*x + b is a (constant)."""
|
|
145
|
+
return self.a
|
|
146
|
+
|
|
131
147
|
|
|
132
148
|
@dataclass(frozen=True)
|
|
133
149
|
class ComposedMap(Map):
|
|
@@ -159,3 +175,8 @@ class ComposedMap(Map):
|
|
|
159
175
|
if exp == -1:
|
|
160
176
|
return self.inverse()
|
|
161
177
|
raise ValueError("ComposedMap only supports exp=1 or exp=-1")
|
|
178
|
+
|
|
179
|
+
def derivative(self, x: float) -> float:
|
|
180
|
+
"""Chain rule: d/dx [outer(inner(x))] = outer'(inner(x)) * inner'(x)."""
|
|
181
|
+
inner_val = self.inner(x)
|
|
182
|
+
return self.outer.derivative(inner_val) * self.inner.derivative(x)
|
ucon/units.py
CHANGED
|
@@ -56,10 +56,10 @@ mole = Unit(name='mole', dimension=Dimension.amount_of_substance, aliases=('mol'
|
|
|
56
56
|
newton = Unit(name='newton', dimension=Dimension.force, aliases=('N',))
|
|
57
57
|
ohm = Unit(name='ohm', dimension=Dimension.resistance, aliases=('Ω',))
|
|
58
58
|
pascal = Unit(name='pascal', dimension=Dimension.pressure, aliases=('Pa',))
|
|
59
|
-
radian = Unit(name='radian', dimension=Dimension.
|
|
59
|
+
radian = Unit(name='radian', dimension=Dimension.angle, aliases=('rad',))
|
|
60
60
|
siemens = Unit(name='siemens', dimension=Dimension.conductance, aliases=('S',))
|
|
61
61
|
sievert = Unit(name='sievert', dimension=Dimension.energy, aliases=('Sv',))
|
|
62
|
-
steradian = Unit(name='steradian', dimension=Dimension.
|
|
62
|
+
steradian = Unit(name='steradian', dimension=Dimension.solid_angle, aliases=('sr',))
|
|
63
63
|
tesla = Unit(name='tesla', dimension=Dimension.magnetic_flux_density, aliases=('T',))
|
|
64
64
|
volt = Unit(name='volt', dimension=Dimension.voltage, aliases=('V',))
|
|
65
65
|
watt = Unit(name='watt', dimension=Dimension.power, aliases=('W',))
|
|
@@ -113,6 +113,30 @@ byte = Unit(name='byte', dimension=Dimension.information, aliases=('B',))
|
|
|
113
113
|
# ----------------------------------------------------------------------
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
# -- Angle Units -------------------------------------------------------
|
|
117
|
+
degree = Unit(name='degree', dimension=Dimension.angle, aliases=('deg', '°'))
|
|
118
|
+
gradian = Unit(name='gradian', dimension=Dimension.angle, aliases=('grad', 'gon'))
|
|
119
|
+
arcminute = Unit(name='arcminute', dimension=Dimension.angle, aliases=('arcmin', "'"))
|
|
120
|
+
arcsecond = Unit(name='arcsecond', dimension=Dimension.angle, aliases=('arcsec', '"'))
|
|
121
|
+
turn = Unit(name='turn', dimension=Dimension.angle, aliases=('rev', 'revolution'))
|
|
122
|
+
# ----------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# -- Solid Angle Units -------------------------------------------------
|
|
126
|
+
square_degree = Unit(name='square_degree', dimension=Dimension.solid_angle, aliases=('deg²', 'sq_deg'))
|
|
127
|
+
# ----------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# -- Ratio Units -------------------------------------------------------
|
|
131
|
+
ratio_one = Unit(name='one', dimension=Dimension.ratio, aliases=('1',))
|
|
132
|
+
percent = Unit(name='percent', dimension=Dimension.ratio, aliases=('%',))
|
|
133
|
+
permille = Unit(name='permille', dimension=Dimension.ratio, aliases=('‰',))
|
|
134
|
+
ppm = Unit(name='ppm', dimension=Dimension.ratio, aliases=())
|
|
135
|
+
ppb = Unit(name='ppb', dimension=Dimension.ratio, aliases=())
|
|
136
|
+
basis_point = Unit(name='basis_point', dimension=Dimension.ratio, aliases=('bp', 'bps'))
|
|
137
|
+
# ----------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
116
140
|
# Backward compatibility alias
|
|
117
141
|
webers = weber
|
|
118
142
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ucon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: a tool for dimensional analysis: a "Unit CONverter"
|
|
5
5
|
Home-page: https://github.com/withtwoemms/ucon
|
|
6
6
|
Author: Emmanuel I. Obi
|
|
@@ -35,7 +35,12 @@ Dynamic: maintainer
|
|
|
35
35
|
Dynamic: maintainer-email
|
|
36
36
|
Dynamic: summary
|
|
37
37
|
|
|
38
|
-
<
|
|
38
|
+
<table>
|
|
39
|
+
<tr>
|
|
40
|
+
<td width="200">
|
|
41
|
+
<img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/221c60e85ac8361c7d202896b52c1a279081b54c/ucon-logo.png" align="left" width="200" />
|
|
42
|
+
</td>
|
|
43
|
+
<td>
|
|
39
44
|
|
|
40
45
|
# ucon
|
|
41
46
|
|
|
@@ -45,6 +50,10 @@ Dynamic: summary
|
|
|
45
50
|
[](https://codecov.io/gh/withtwoemms/ucon)
|
|
46
51
|
[](https://github.com/withtwoemms/ucon/actions?query=workflow%3Apublish)
|
|
47
52
|
|
|
53
|
+
</td>
|
|
54
|
+
</tr>
|
|
55
|
+
</table>
|
|
56
|
+
|
|
48
57
|
> A lightweight, **unit-aware computation library** for Python — built on first-principles.
|
|
49
58
|
|
|
50
59
|
---
|
|
@@ -57,6 +66,8 @@ It combines **units**, **scales**, and **dimensions** into a composable algebra
|
|
|
57
66
|
- Dimensional analysis through `Number` and `Ratio`
|
|
58
67
|
- Scale-aware arithmetic via `UnitFactor` and `UnitProduct`
|
|
59
68
|
- Metric and binary prefixes (`kilo`, `kibi`, `micro`, `mebi`, etc.)
|
|
69
|
+
- Pseudo-dimensions for angles, solid angles, and ratios with semantic isolation
|
|
70
|
+
- Uncertainty propagation through arithmetic and conversions
|
|
60
71
|
- A clean foundation for physics, chemistry, data modeling, and beyond
|
|
61
72
|
|
|
62
73
|
Think of it as **`decimal.Decimal` for the physical world** — precise, predictable, and type-safe.
|
|
@@ -188,16 +199,54 @@ distance_mi = distance.to(units.mile)
|
|
|
188
199
|
print(distance_mi) # <3.107... mi>
|
|
189
200
|
```
|
|
190
201
|
|
|
202
|
+
Dimensionless units have semantic isolation — angles, solid angles, and ratios are distinct:
|
|
203
|
+
```python
|
|
204
|
+
import math
|
|
205
|
+
from ucon import units
|
|
206
|
+
|
|
207
|
+
# Angle conversions
|
|
208
|
+
angle = units.radian(math.pi)
|
|
209
|
+
print(angle.to(units.degree)) # <180.0 deg>
|
|
210
|
+
|
|
211
|
+
# Ratio conversions
|
|
212
|
+
ratio = units.percent(50)
|
|
213
|
+
print(ratio.to(units.ppm)) # <500000.0 ppm>
|
|
214
|
+
|
|
215
|
+
# Cross-family conversions are prevented
|
|
216
|
+
units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Uncertainty propagates through arithmetic and conversions:
|
|
220
|
+
```python
|
|
221
|
+
from ucon import units
|
|
222
|
+
|
|
223
|
+
# Measurements with uncertainty
|
|
224
|
+
length = units.meter(1.234, uncertainty=0.005)
|
|
225
|
+
width = units.meter(0.567, uncertainty=0.003)
|
|
226
|
+
|
|
227
|
+
print(length) # <1.234 ± 0.005 m>
|
|
228
|
+
|
|
229
|
+
# Uncertainty propagates through arithmetic (quadrature)
|
|
230
|
+
area = length * width
|
|
231
|
+
print(area) # <0.699678 ± 0.00424... m²>
|
|
232
|
+
|
|
233
|
+
# Uncertainty propagates through conversion
|
|
234
|
+
length_ft = length.to(units.foot)
|
|
235
|
+
print(length_ft) # <4.048... ± 0.0164... ft>
|
|
236
|
+
```
|
|
237
|
+
|
|
191
238
|
---
|
|
192
239
|
|
|
193
240
|
## Roadmap Highlights
|
|
194
241
|
|
|
195
242
|
| Version | Theme | Focus | Status |
|
|
196
243
|
|----------|-------|--------|--------|
|
|
197
|
-
| **0.3.
|
|
198
|
-
|
|
|
199
|
-
|
|
|
200
|
-
|
|
|
244
|
+
| **0.3.x** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
|
|
245
|
+
| **0.4.x** | Conversion System | `ConversionGraph`, `Number.to()`, callable units | ✅ Complete |
|
|
246
|
+
| **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
|
|
247
|
+
| **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
|
|
248
|
+
| **0.5.x** | Unit Systems | `BasisMap`, `UnitSystem` | 🚧 In Progress |
|
|
249
|
+
| **0.7.x** | Pydantic Integration | Type-safe quantity validation | ⏳ Planned |
|
|
201
250
|
|
|
202
251
|
See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
203
252
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
tests/ucon/__init__.py,sha256=9BAHYTs27Ed3VgqiMUH4XtVttAmOPgK0Zvj-dUNo7D8,119
|
|
2
|
+
tests/ucon/test_algebra.py,sha256=NnoQOSMW8NJlOTnbr3M_5epvnDPXpVTgO21L2LcytRY,8503
|
|
3
|
+
tests/ucon/test_core.py,sha256=bmwSRWPlhwossy5NJ9rcPWujFmzBBPOeZzPAzN1acFg,32631
|
|
4
|
+
tests/ucon/test_default_graph_conversions.py,sha256=rkcDcSV1_kZeuPf4ModHDpgfkOPZS32xcKq7KPDRN-0,15760
|
|
5
|
+
tests/ucon/test_dimensionless_units.py,sha256=K6BrIPOFL9IO_ksR8t_oJUXmjTgqBUzMdgaV-hZc52w,8410
|
|
6
|
+
tests/ucon/test_quantity.py,sha256=md5nbmy0u2cFBdqNeu-ROhoj29vYrIlGm_AjlmCttgc,24519
|
|
7
|
+
tests/ucon/test_uncertainty.py,sha256=KkJw2dJR0EToxPpBN24x735jr9fv6a2myxjvhOH4MPU,9649
|
|
8
|
+
tests/ucon/test_units.py,sha256=SILymDtDNDyxEhkYQubrfkakKCMexwEwjyHfhrkDrMI,869
|
|
9
|
+
tests/ucon/conversion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
tests/ucon/conversion/test_graph.py,sha256=fs0aP6qNf8eE1uI7SoGSCW2XAkHYb7T9aaI-kzmO02c,16955
|
|
11
|
+
tests/ucon/conversion/test_map.py,sha256=DVFQ3xwp16Nuy9EtZRjKlWbkXfRUcM1mOzFrS4HhOaw,13886
|
|
12
|
+
ucon/__init__.py,sha256=jaFcNFZC5gxHKzM8OkS1pLxltp6ToLRVpuuhJQY9FKQ,2000
|
|
13
|
+
ucon/algebra.py,sha256=4JiT_SHHep86Sv3tVkgKsRY95lBRASMkyH4vOUA-gfM,7459
|
|
14
|
+
ucon/core.py,sha256=nuDmSuGiG3xUW_kRpOWu9FDhNmKE0aJPwpj30lMdrgo,49464
|
|
15
|
+
ucon/graph.py,sha256=lPoYSvHNGBZxeZ-4dyZIu2OS5R1JTo0qPZ9wd0vg-s4,15566
|
|
16
|
+
ucon/maps.py,sha256=tWP4ayYCEazJzf81EP1_fmtADhg18D1eHldudAMEY0U,5460
|
|
17
|
+
ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
|
|
18
|
+
ucon/units.py,sha256=u1ILwGllzNiwGLadlg5jguKPyFV1u-CZSUMgUDWTen4,7509
|
|
19
|
+
ucon-0.5.1.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
|
|
20
|
+
ucon-0.5.1.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
|
|
21
|
+
ucon-0.5.1.dist-info/METADATA,sha256=dkjXacaxxZbFq9vIBfJJ-2koy03T9Ozf_nimaNm2mwQ,13554
|
|
22
|
+
ucon-0.5.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
23
|
+
ucon-0.5.1.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
|
|
24
|
+
ucon-0.5.1.dist-info/RECORD,,
|
ucon-0.4.2.dist-info/RECORD
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
tests/ucon/__init__.py,sha256=9BAHYTs27Ed3VgqiMUH4XtVttAmOPgK0Zvj-dUNo7D8,119
|
|
2
|
-
tests/ucon/test_algebra.py,sha256=Esm38M1QBNsV7vdfoFgqRUluyvWX7yccB0RwZXk4DpA,8433
|
|
3
|
-
tests/ucon/test_core.py,sha256=bmwSRWPlhwossy5NJ9rcPWujFmzBBPOeZzPAzN1acFg,32631
|
|
4
|
-
tests/ucon/test_default_graph_conversions.py,sha256=rkcDcSV1_kZeuPf4ModHDpgfkOPZS32xcKq7KPDRN-0,15760
|
|
5
|
-
tests/ucon/test_quantity.py,sha256=md5nbmy0u2cFBdqNeu-ROhoj29vYrIlGm_AjlmCttgc,24519
|
|
6
|
-
tests/ucon/test_units.py,sha256=SILymDtDNDyxEhkYQubrfkakKCMexwEwjyHfhrkDrMI,869
|
|
7
|
-
tests/ucon/conversion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
tests/ucon/conversion/test_graph.py,sha256=fs0aP6qNf8eE1uI7SoGSCW2XAkHYb7T9aaI-kzmO02c,16955
|
|
9
|
-
tests/ucon/conversion/test_map.py,sha256=DVFQ3xwp16Nuy9EtZRjKlWbkXfRUcM1mOzFrS4HhOaw,13886
|
|
10
|
-
ucon/__init__.py,sha256=jaFcNFZC5gxHKzM8OkS1pLxltp6ToLRVpuuhJQY9FKQ,2000
|
|
11
|
-
ucon/algebra.py,sha256=wGl4jJVMd8SXQ4sYDBOxV00ymAzRWfDhea1o4t2kVp4,7482
|
|
12
|
-
ucon/core.py,sha256=PnQ68jwdkbgMb8AIEhy1aJDRxYjNNPi3TQ0B6IDRvqg,42201
|
|
13
|
-
ucon/graph.py,sha256=EH0Zi4yj8SI6o27V4uo4muucaqV5nCoL6S3syb-IfXc,14587
|
|
14
|
-
ucon/maps.py,sha256=yyZ7RqnohO2joTUvvKh40in7E6SKMQIQ8jkECO0-_NA,4753
|
|
15
|
-
ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
|
|
16
|
-
ucon/units.py,sha256=2hisCuB_kTDcNlG6tzze2ZNVpmsnEeEFGWhfbrbomzk,6096
|
|
17
|
-
ucon-0.4.2.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
|
|
18
|
-
ucon-0.4.2.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
|
|
19
|
-
ucon-0.4.2.dist-info/METADATA,sha256=wKqDKC_QGNnWmkeycDI-_str1RtVKeE-uRBltAcUCg0,12348
|
|
20
|
-
ucon-0.4.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
21
|
-
ucon-0.4.2.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
|
|
22
|
-
ucon-0.4.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|