ucon 0.5.0__py3-none-any.whl → 0.5.2__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.
@@ -0,0 +1,263 @@
1
+ # (c) 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 ConversionGraph integration with BasisTransform.
7
+
8
+ Verifies cross-basis edge handling, RebasedUnit creation,
9
+ and conversion paths that span dimensional bases.
10
+ """
11
+
12
+ import unittest
13
+ from fractions import Fraction
14
+
15
+ from ucon.core import (
16
+ BasisTransform,
17
+ Dimension,
18
+ RebasedUnit,
19
+ Unit,
20
+ UnitSystem,
21
+ )
22
+ from ucon.graph import ConversionGraph, DimensionMismatch
23
+ from ucon.maps import LinearMap
24
+ from ucon import units
25
+
26
+
27
+ class TestGraphAddEdgeWithBasisTransform(unittest.TestCase):
28
+ """Test add_edge with basis_transform parameter."""
29
+
30
+ def setUp(self):
31
+ self.si = UnitSystem(
32
+ name="SI",
33
+ bases={
34
+ Dimension.length: units.meter,
35
+ Dimension.mass: units.kilogram,
36
+ Dimension.time: units.second,
37
+ }
38
+ )
39
+ self.imperial = UnitSystem(
40
+ name="Imperial",
41
+ bases={
42
+ Dimension.length: units.foot,
43
+ Dimension.mass: units.pound,
44
+ Dimension.time: units.second,
45
+ }
46
+ )
47
+ # Simple 1:1 transform for length
48
+ self.bt = BasisTransform(
49
+ src=self.imperial,
50
+ dst=self.si,
51
+ src_dimensions=(Dimension.length,),
52
+ dst_dimensions=(Dimension.length,),
53
+ matrix=((1,),),
54
+ )
55
+ self.graph = ConversionGraph()
56
+
57
+ def test_add_edge_with_basis_transform(self):
58
+ # foot -> meter with basis transform
59
+ self.graph.add_edge(
60
+ src=units.foot,
61
+ dst=units.meter,
62
+ map=LinearMap(0.3048),
63
+ basis_transform=self.bt,
64
+ )
65
+ # Verify the rebased unit was created
66
+ self.assertIn(units.foot, self.graph._rebased)
67
+ rebased = self.graph._rebased[units.foot]
68
+ self.assertIsInstance(rebased, RebasedUnit)
69
+ self.assertEqual(rebased.original, units.foot)
70
+ self.assertEqual(rebased.rebased_dimension, Dimension.length)
71
+
72
+ def test_add_edge_without_basis_transform_requires_same_dimension(self):
73
+ # foot and meter both have Dimension.length, so this should work
74
+ self.graph.add_edge(
75
+ src=units.foot,
76
+ dst=units.meter,
77
+ map=LinearMap(0.3048),
78
+ )
79
+ # Verify no rebased unit was created (normal edge)
80
+ self.assertNotIn(units.foot, self.graph._rebased)
81
+
82
+
83
+ class TestGraphConvertWithBasisTransform(unittest.TestCase):
84
+ """Test convert() with cross-basis edges."""
85
+
86
+ def setUp(self):
87
+ self.si = UnitSystem(
88
+ name="SI",
89
+ bases={
90
+ Dimension.length: units.meter,
91
+ Dimension.mass: units.kilogram,
92
+ Dimension.time: units.second,
93
+ }
94
+ )
95
+ self.imperial = UnitSystem(
96
+ name="Imperial",
97
+ bases={
98
+ Dimension.length: units.foot,
99
+ Dimension.mass: units.pound,
100
+ Dimension.time: units.second,
101
+ }
102
+ )
103
+ self.bt = BasisTransform(
104
+ src=self.imperial,
105
+ dst=self.si,
106
+ src_dimensions=(Dimension.length,),
107
+ dst_dimensions=(Dimension.length,),
108
+ matrix=((1,),),
109
+ )
110
+ self.graph = ConversionGraph()
111
+ # Add edge with basis transform
112
+ self.graph.add_edge(
113
+ src=units.foot,
114
+ dst=units.meter,
115
+ map=LinearMap(0.3048),
116
+ basis_transform=self.bt,
117
+ )
118
+
119
+ def test_convert_uses_rebased_path(self):
120
+ # Convert via the rebased edge
121
+ map = self.graph.convert(src=units.foot, dst=units.meter)
122
+ # 1 foot = 0.3048 meters
123
+ self.assertAlmostEqual(map(1), 0.3048, places=5)
124
+
125
+ def test_convert_inverse_works(self):
126
+ # The inverse edge should also be available
127
+ map = self.graph.convert(src=units.meter, dst=units.foot)
128
+ # 1 meter ≈ 3.28084 feet
129
+ self.assertAlmostEqual(map(1), 1/0.3048, places=5)
130
+
131
+
132
+ class TestGraphConnectSystems(unittest.TestCase):
133
+ """Test connect_systems convenience method."""
134
+
135
+ def setUp(self):
136
+ self.si = UnitSystem(
137
+ name="SI",
138
+ bases={
139
+ Dimension.length: units.meter,
140
+ Dimension.mass: units.kilogram,
141
+ Dimension.time: units.second,
142
+ }
143
+ )
144
+ self.imperial = UnitSystem(
145
+ name="Imperial",
146
+ bases={
147
+ Dimension.length: units.foot,
148
+ Dimension.mass: units.pound,
149
+ Dimension.time: units.second,
150
+ }
151
+ )
152
+ self.bt = BasisTransform(
153
+ src=self.imperial,
154
+ dst=self.si,
155
+ src_dimensions=(Dimension.length, Dimension.mass),
156
+ dst_dimensions=(Dimension.length, Dimension.mass),
157
+ matrix=((1, 0), (0, 1)),
158
+ )
159
+ self.graph = ConversionGraph()
160
+
161
+ def test_connect_systems_bulk_adds_edges(self):
162
+ self.graph.connect_systems(
163
+ basis_transform=self.bt,
164
+ edges={
165
+ (units.foot, units.meter): LinearMap(0.3048),
166
+ (units.pound, units.kilogram): LinearMap(0.453592),
167
+ }
168
+ )
169
+ # Both should be converted
170
+ length_map = self.graph.convert(src=units.foot, dst=units.meter)
171
+ self.assertAlmostEqual(length_map(1), 0.3048, places=5)
172
+
173
+ mass_map = self.graph.convert(src=units.pound, dst=units.kilogram)
174
+ self.assertAlmostEqual(mass_map(1), 0.453592, places=5)
175
+
176
+
177
+ class TestGraphListTransforms(unittest.TestCase):
178
+ """Test introspection methods for transforms."""
179
+
180
+ def setUp(self):
181
+ self.si = UnitSystem(
182
+ name="SI",
183
+ bases={
184
+ Dimension.length: units.meter,
185
+ }
186
+ )
187
+ self.imperial = UnitSystem(
188
+ name="Imperial",
189
+ bases={
190
+ Dimension.length: units.foot,
191
+ }
192
+ )
193
+ self.bt = BasisTransform(
194
+ src=self.imperial,
195
+ dst=self.si,
196
+ src_dimensions=(Dimension.length,),
197
+ dst_dimensions=(Dimension.length,),
198
+ matrix=((1,),),
199
+ )
200
+ self.graph = ConversionGraph()
201
+ self.graph.add_edge(
202
+ src=units.foot,
203
+ dst=units.meter,
204
+ map=LinearMap(0.3048),
205
+ basis_transform=self.bt,
206
+ )
207
+
208
+ def test_list_rebased_units(self):
209
+ rebased = self.graph.list_rebased_units()
210
+ self.assertEqual(len(rebased), 1)
211
+ self.assertIn(units.foot, rebased)
212
+ self.assertIsInstance(rebased[units.foot], RebasedUnit)
213
+
214
+ def test_list_transforms(self):
215
+ transforms = self.graph.list_transforms()
216
+ self.assertEqual(len(transforms), 1)
217
+ self.assertEqual(transforms[0], self.bt)
218
+
219
+ def test_edges_for_transform(self):
220
+ edges = self.graph.edges_for_transform(self.bt)
221
+ self.assertEqual(len(edges), 1)
222
+ self.assertEqual(edges[0], (units.foot, units.meter))
223
+
224
+ def test_list_transforms_multiple(self):
225
+ # Add another transform
226
+ custom = UnitSystem(
227
+ name="Custom",
228
+ bases={Dimension.mass: units.pound}
229
+ )
230
+ bt2 = BasisTransform(
231
+ src=custom,
232
+ dst=self.si,
233
+ src_dimensions=(Dimension.mass,),
234
+ dst_dimensions=(Dimension.mass,),
235
+ matrix=((1,),),
236
+ )
237
+ # Need to add a mass base to SI for this test
238
+ si_with_mass = UnitSystem(
239
+ name="SI",
240
+ bases={
241
+ Dimension.length: units.meter,
242
+ Dimension.mass: units.kilogram,
243
+ }
244
+ )
245
+ bt2 = BasisTransform(
246
+ src=custom,
247
+ dst=si_with_mass,
248
+ src_dimensions=(Dimension.mass,),
249
+ dst_dimensions=(Dimension.mass,),
250
+ matrix=((1,),),
251
+ )
252
+ self.graph.add_edge(
253
+ src=units.pound,
254
+ dst=units.kilogram,
255
+ map=LinearMap(0.453592),
256
+ basis_transform=bt2,
257
+ )
258
+ transforms = self.graph.list_transforms()
259
+ self.assertEqual(len(transforms), 2)
260
+
261
+
262
+ if __name__ == "__main__":
263
+ unittest.main()
@@ -0,0 +1,184 @@
1
+ # (c) 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 RebasedUnit.
7
+
8
+ Verifies that units transformed by a BasisTransform preserve
9
+ provenance while exposing the rebased dimension.
10
+ """
11
+
12
+ import unittest
13
+ from fractions import Fraction
14
+
15
+ from ucon.core import (
16
+ BasisTransform,
17
+ Dimension,
18
+ RebasedUnit,
19
+ Unit,
20
+ UnitSystem,
21
+ )
22
+ from ucon import units
23
+
24
+
25
+ class TestRebasedUnitConstruction(unittest.TestCase):
26
+ """Test RebasedUnit construction."""
27
+
28
+ def setUp(self):
29
+ self.si = UnitSystem(
30
+ name="SI",
31
+ bases={
32
+ Dimension.length: units.meter,
33
+ Dimension.mass: units.kilogram,
34
+ Dimension.time: units.second,
35
+ }
36
+ )
37
+ self.custom = UnitSystem(
38
+ name="Custom",
39
+ bases={
40
+ Dimension.length: units.foot,
41
+ Dimension.mass: units.pound,
42
+ Dimension.time: units.second,
43
+ }
44
+ )
45
+ self.bt = BasisTransform(
46
+ src=self.custom,
47
+ dst=self.si,
48
+ src_dimensions=(Dimension.length,),
49
+ dst_dimensions=(Dimension.length,),
50
+ matrix=((1,),),
51
+ )
52
+
53
+ def test_valid_construction(self):
54
+ rebased = RebasedUnit(
55
+ original=units.foot,
56
+ rebased_dimension=Dimension.length,
57
+ basis_transform=self.bt,
58
+ )
59
+ self.assertEqual(rebased.original, units.foot)
60
+ self.assertEqual(rebased.rebased_dimension, Dimension.length)
61
+ self.assertEqual(rebased.basis_transform, self.bt)
62
+
63
+ def test_dimension_property(self):
64
+ rebased = RebasedUnit(
65
+ original=units.foot,
66
+ rebased_dimension=Dimension.length,
67
+ basis_transform=self.bt,
68
+ )
69
+ self.assertEqual(rebased.dimension, Dimension.length)
70
+
71
+ def test_name_property(self):
72
+ rebased = RebasedUnit(
73
+ original=units.foot,
74
+ rebased_dimension=Dimension.length,
75
+ basis_transform=self.bt,
76
+ )
77
+ self.assertEqual(rebased.name, "foot")
78
+
79
+
80
+ class TestRebasedUnitEquality(unittest.TestCase):
81
+ """Test RebasedUnit equality and hashing."""
82
+
83
+ def setUp(self):
84
+ self.si = UnitSystem(
85
+ name="SI",
86
+ bases={
87
+ Dimension.length: units.meter,
88
+ Dimension.mass: units.kilogram,
89
+ Dimension.time: units.second,
90
+ }
91
+ )
92
+ self.custom = UnitSystem(
93
+ name="Custom",
94
+ bases={
95
+ Dimension.length: units.foot,
96
+ Dimension.mass: units.pound,
97
+ Dimension.time: units.second,
98
+ }
99
+ )
100
+ self.bt = BasisTransform(
101
+ src=self.custom,
102
+ dst=self.si,
103
+ src_dimensions=(Dimension.length,),
104
+ dst_dimensions=(Dimension.length,),
105
+ matrix=((1,),),
106
+ )
107
+
108
+ def test_equal_rebased_units(self):
109
+ r1 = RebasedUnit(
110
+ original=units.foot,
111
+ rebased_dimension=Dimension.length,
112
+ basis_transform=self.bt,
113
+ )
114
+ r2 = RebasedUnit(
115
+ original=units.foot,
116
+ rebased_dimension=Dimension.length,
117
+ basis_transform=self.bt,
118
+ )
119
+ self.assertEqual(r1, r2)
120
+
121
+ def test_hashable(self):
122
+ r1 = RebasedUnit(
123
+ original=units.foot,
124
+ rebased_dimension=Dimension.length,
125
+ basis_transform=self.bt,
126
+ )
127
+ r2 = RebasedUnit(
128
+ original=units.foot,
129
+ rebased_dimension=Dimension.length,
130
+ basis_transform=self.bt,
131
+ )
132
+ self.assertEqual(hash(r1), hash(r2))
133
+ self.assertEqual(len({r1, r2}), 1)
134
+
135
+ def test_different_original_not_equal(self):
136
+ r1 = RebasedUnit(
137
+ original=units.foot,
138
+ rebased_dimension=Dimension.length,
139
+ basis_transform=self.bt,
140
+ )
141
+ r2 = RebasedUnit(
142
+ original=units.inch,
143
+ rebased_dimension=Dimension.length,
144
+ basis_transform=self.bt,
145
+ )
146
+ self.assertNotEqual(r1, r2)
147
+
148
+
149
+ class TestRebasedUnitImmutability(unittest.TestCase):
150
+ """Test that RebasedUnit is immutable."""
151
+
152
+ def setUp(self):
153
+ self.si = UnitSystem(
154
+ name="SI",
155
+ bases={
156
+ Dimension.length: units.meter,
157
+ }
158
+ )
159
+ self.custom = UnitSystem(
160
+ name="Custom",
161
+ bases={
162
+ Dimension.length: units.foot,
163
+ }
164
+ )
165
+ self.bt = BasisTransform(
166
+ src=self.custom,
167
+ dst=self.si,
168
+ src_dimensions=(Dimension.length,),
169
+ dst_dimensions=(Dimension.length,),
170
+ matrix=((1,),),
171
+ )
172
+
173
+ def test_frozen_dataclass(self):
174
+ rebased = RebasedUnit(
175
+ original=units.foot,
176
+ rebased_dimension=Dimension.length,
177
+ basis_transform=self.bt,
178
+ )
179
+ with self.assertRaises(AttributeError):
180
+ rebased.original = units.meter
181
+
182
+
183
+ if __name__ == "__main__":
184
+ 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()