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.
@@ -71,10 +71,10 @@ class TestVectorEdgeCases(TestCase):
71
71
 
72
72
  def test_vector_equality_with_non_vector(self):
73
73
  v = Vector()
74
- with self.assertRaises(AssertionError):
75
- v == "not a vector"
76
- with self.assertRaises(AssertionError):
77
- v == None
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, vector: 'Vector') -> bool:
106
- assert isinstance(vector, Vector), "Can only compare Vector to another Vector"
107
- return tuple(self) == tuple(vector)
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.value == vector:
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.value - dimension.value)
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.value + dimension.value)
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.value * power
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
- return self.value == dimension.value
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
- return Number(quantity=self.quantity * scale_factor, unit=base_unit)
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
- return Number(quantity=self.quantity * factor, unit=target)
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
- return Number(quantity=converted_quantity, unit=target)
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=self.quantity * other.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
- return Number(quantity=num / den, unit=_none)
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 ``__pow__``.
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.none, aliases=('rad',))
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.none, aliases=('sr',))
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.4.2
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
- <img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/dde6c7d3b8a7d79eb1006ace03fb834e044cdebc/ucon-logo.png" align="left" width="200" />
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
  [![codecov](https://codecov.io/gh/withtwoemms/ucon/graph/badge.svg?token=BNONQTRJWG)](https://codecov.io/gh/withtwoemms/ucon)
46
51
  [![publish](https://github.com/withtwoemms/ucon/workflows/publish/badge.svg)](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.5** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
198
- | [**0.4.x**](https://github.com/withtwoemms/ucon/milestone/2) | Conversion System | `ConversionGraph`, `Number.to()`, callable units | 🚧 In Progress |
199
- | [**0.6.x**](https://github.com/withtwoemms/ucon/milestone/4) | Nonlinear / Specialized Units | Decibel, Percent, pH | Planned |
200
- | [**0.8.x**](https://github.com/withtwoemms/ucon/milestone/6) | Pydantic Integration | Type-safe quantity validation | Planned |
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,,
@@ -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