ucon 0.5.1__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,174 @@
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 UnitSystem.
7
+
8
+ Verifies construction, validation, and query methods for named
9
+ unit system definitions.
10
+ """
11
+
12
+ import unittest
13
+
14
+ from ucon import units
15
+ from ucon.core import Dimension, Unit, UnitSystem, DimensionNotCovered
16
+
17
+
18
+ class TestUnitSystemConstruction(unittest.TestCase):
19
+ """Test UnitSystem construction and validation."""
20
+
21
+ def test_valid_construction(self):
22
+ system = UnitSystem(
23
+ name="SI",
24
+ bases={
25
+ Dimension.length: units.meter,
26
+ Dimension.mass: units.kilogram,
27
+ Dimension.time: units.second,
28
+ }
29
+ )
30
+ self.assertEqual(system.name, "SI")
31
+ self.assertEqual(len(system.bases), 3)
32
+
33
+ def test_single_base_allowed(self):
34
+ system = UnitSystem(
35
+ name="length-only",
36
+ bases={Dimension.length: units.meter}
37
+ )
38
+ self.assertEqual(len(system.bases), 1)
39
+
40
+ def test_empty_name_rejected(self):
41
+ with self.assertRaises(ValueError) as ctx:
42
+ UnitSystem(name="", bases={Dimension.length: units.meter})
43
+ self.assertIn("name", str(ctx.exception).lower())
44
+
45
+ def test_empty_bases_rejected(self):
46
+ with self.assertRaises(ValueError) as ctx:
47
+ UnitSystem(name="empty", bases={})
48
+ self.assertIn("base", str(ctx.exception).lower())
49
+
50
+ def test_mismatched_dimension_rejected(self):
51
+ # meter has Dimension.length, but we declare it as mass
52
+ with self.assertRaises(ValueError) as ctx:
53
+ UnitSystem(
54
+ name="bad",
55
+ bases={Dimension.mass: units.meter}
56
+ )
57
+ self.assertIn("dimension", str(ctx.exception).lower())
58
+
59
+ def test_partial_system_allowed(self):
60
+ # Imperial doesn't need mole or candela
61
+ system = UnitSystem(
62
+ name="Imperial",
63
+ bases={
64
+ Dimension.length: units.foot,
65
+ Dimension.mass: units.pound,
66
+ Dimension.time: units.second,
67
+ }
68
+ )
69
+ self.assertEqual(len(system.bases), 3)
70
+
71
+
72
+ class TestUnitSystemQueries(unittest.TestCase):
73
+ """Test UnitSystem query methods."""
74
+
75
+ def setUp(self):
76
+ self.si = UnitSystem(
77
+ name="SI",
78
+ bases={
79
+ Dimension.length: units.meter,
80
+ Dimension.mass: units.kilogram,
81
+ Dimension.time: units.second,
82
+ Dimension.temperature: units.kelvin,
83
+ }
84
+ )
85
+
86
+ def test_covers_returns_true_for_covered(self):
87
+ self.assertTrue(self.si.covers(Dimension.length))
88
+ self.assertTrue(self.si.covers(Dimension.mass))
89
+ self.assertTrue(self.si.covers(Dimension.time))
90
+
91
+ def test_covers_returns_false_for_uncovered(self):
92
+ self.assertFalse(self.si.covers(Dimension.current))
93
+ self.assertFalse(self.si.covers(Dimension.luminous_intensity))
94
+
95
+ def test_base_for_returns_correct_unit(self):
96
+ self.assertEqual(self.si.base_for(Dimension.length), units.meter)
97
+ self.assertEqual(self.si.base_for(Dimension.mass), units.kilogram)
98
+ self.assertEqual(self.si.base_for(Dimension.time), units.second)
99
+
100
+ def test_base_for_raises_for_uncovered(self):
101
+ with self.assertRaises(DimensionNotCovered) as ctx:
102
+ self.si.base_for(Dimension.current)
103
+ self.assertIn("current", str(ctx.exception).lower())
104
+
105
+ def test_dimensions_property(self):
106
+ dims = self.si.dimensions
107
+ self.assertIsInstance(dims, set)
108
+ self.assertEqual(len(dims), 4)
109
+ self.assertIn(Dimension.length, dims)
110
+ self.assertIn(Dimension.mass, dims)
111
+
112
+
113
+ class TestUnitSystemEquality(unittest.TestCase):
114
+ """Test UnitSystem equality and hashing."""
115
+
116
+ def test_same_systems_equal(self):
117
+ s1 = UnitSystem(
118
+ name="SI",
119
+ bases={Dimension.length: units.meter}
120
+ )
121
+ s2 = UnitSystem(
122
+ name="SI",
123
+ bases={Dimension.length: units.meter}
124
+ )
125
+ self.assertEqual(s1, s2)
126
+
127
+ def test_different_names_not_equal(self):
128
+ s1 = UnitSystem(name="SI", bases={Dimension.length: units.meter})
129
+ s2 = UnitSystem(name="CGS", bases={Dimension.length: units.meter})
130
+ self.assertNotEqual(s1, s2)
131
+
132
+ def test_different_bases_not_equal(self):
133
+ s1 = UnitSystem(name="test", bases={Dimension.length: units.meter})
134
+ s2 = UnitSystem(name="test", bases={Dimension.length: units.foot})
135
+ self.assertNotEqual(s1, s2)
136
+
137
+ def test_hashable(self):
138
+ s1 = UnitSystem(name="SI", bases={Dimension.length: units.meter})
139
+ s2 = UnitSystem(name="SI", bases={Dimension.length: units.meter})
140
+ self.assertEqual(hash(s1), hash(s2))
141
+ self.assertEqual(len({s1, s2}), 1)
142
+
143
+
144
+ class TestUnitSystemImmutability(unittest.TestCase):
145
+ """Test that UnitSystem is immutable."""
146
+
147
+ def test_frozen_dataclass(self):
148
+ system = UnitSystem(
149
+ name="SI",
150
+ bases={Dimension.length: units.meter}
151
+ )
152
+ with self.assertRaises(AttributeError):
153
+ system.name = "changed"
154
+
155
+
156
+ class TestPredefinedSystems(unittest.TestCase):
157
+ """Test predefined unit systems in ucon.units."""
158
+
159
+ def test_si_system_exists(self):
160
+ from ucon.units import si
161
+ self.assertEqual(si.name, "SI")
162
+ self.assertTrue(si.covers(Dimension.length))
163
+ self.assertTrue(si.covers(Dimension.mass))
164
+ self.assertTrue(si.covers(Dimension.time))
165
+
166
+ def test_imperial_system_exists(self):
167
+ from ucon.units import imperial
168
+ self.assertEqual(imperial.name, "Imperial")
169
+ self.assertTrue(imperial.covers(Dimension.length))
170
+ self.assertTrue(imperial.covers(Dimension.mass))
171
+
172
+
173
+ if __name__ == "__main__":
174
+ unittest.main()