ucon 0.5.2__py3-none-any.whl → 0.6.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.
@@ -0,0 +1,17 @@
1
+ ucon/__init__.py,sha256=SAXuDNMmxzaLVr4JpUHsz-feQotVhpzsCUj2WiXYA-0,2361
2
+ ucon/algebra.py,sha256=6QrPyD23L93XSrnIORcYEx2CLDv4WDcrh6H_hxeeOus,8668
3
+ ucon/core.py,sha256=V10GfKTf28GFgoO9NMI7ciqKHI_p8wG8qICR6u85gv0,63300
4
+ ucon/graph.py,sha256=vBIKwppYCAu5sw6R_3zTBlnOk2WmYMClIb6j5kDvJHI,21342
5
+ ucon/maps.py,sha256=-rPMOHylcQYUn62R9IU23bXdCRCRNBhdH3UD4G5IUEk,9123
6
+ ucon/pydantic.py,sha256=64ZR1EYFRnBGHj3VIF5pc3swdAiR2ZlYrgcntdbKN4k,5189
7
+ ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
8
+ ucon/units.py,sha256=49Xart5orKUKOKs0vIIWoEs9n4jrJBLdyomi9Y4dvqY,16006
9
+ ucon/mcp/__init__.py,sha256=WoFOQ7JeDIzbjjkFIJ0Uv53VVLu-4lrjzG5vpVGGfT4,123
10
+ ucon/mcp/server.py,sha256=uUrdevEaR65Qjh9xn8Q-_IusNjPGxdkLF9iQmiSTs0g,7016
11
+ ucon-0.6.1.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
12
+ ucon-0.6.1.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
13
+ ucon-0.6.1.dist-info/METADATA,sha256=IxzC5b5a8THVSZFfx9nr-vtU534Atj5vVT5lruQKdgQ,17397
14
+ ucon-0.6.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
15
+ ucon-0.6.1.dist-info/entry_points.txt,sha256=jbfLf0FbOulgGa0nM_sRiTNfiCAkJcHnSSK_oj3g0cQ,50
16
+ ucon-0.6.1.dist-info/top_level.txt,sha256=Vv3KDuZ86fmH5yOYLbYap9DbBblK1YUkmlThffF71jA,5
17
+ ucon-0.6.1.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ucon-mcp = ucon.mcp.server:main
@@ -0,0 +1 @@
1
+ ucon
tests/ucon/__init__.py DELETED
@@ -1,3 +0,0 @@
1
- # © 2025 The Radiativity Company
2
- # Licensed under the Apache License, Version 2.0
3
- # See the LICENSE file for details.
File without changes
@@ -1,409 +0,0 @@
1
- # © 2026 The Radiativity Company
2
- # Licensed under the Apache License, Version 2.0
3
- # See the LICENSE file for details.
4
-
5
- import unittest
6
-
7
- from ucon import units
8
- from ucon.core import Dimension, Scale, Unit, UnitFactor, UnitProduct
9
- from ucon.graph import (
10
- ConversionGraph,
11
- DimensionMismatch,
12
- ConversionNotFound,
13
- CyclicInconsistency,
14
- get_default_graph,
15
- set_default_graph,
16
- reset_default_graph,
17
- using_graph,
18
- )
19
- from ucon.maps import LinearMap, AffineMap
20
-
21
-
22
- class TestConversionGraphEdgeManagement(unittest.TestCase):
23
-
24
- def setUp(self):
25
- self.graph = ConversionGraph()
26
- self.meter = units.meter
27
- self.foot = Unit(name='foot', dimension=Dimension.length, aliases=('ft',))
28
- self.inch = Unit(name='inch', dimension=Dimension.length, aliases=('in',))
29
- self.gram = units.gram
30
-
31
- def test_add_and_retrieve_edge(self):
32
- self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
33
- m = self.graph.convert(src=self.meter, dst=self.foot)
34
- self.assertAlmostEqual(m(1), 3.28084, places=4)
35
-
36
- def test_inverse_auto_stored(self):
37
- self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
38
- m = self.graph.convert(src=self.foot, dst=self.meter)
39
- self.assertAlmostEqual(m(1), 0.3048, places=4)
40
-
41
- def test_dimension_mismatch_rejected(self):
42
- with self.assertRaises(DimensionMismatch):
43
- self.graph.add_edge(src=self.meter, dst=self.gram, map=LinearMap(1))
44
-
45
- def test_cyclic_consistency_check(self):
46
- self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
47
- # Adding inconsistent reverse should raise
48
- with self.assertRaises(CyclicInconsistency):
49
- self.graph.add_edge(src=self.foot, dst=self.meter, map=LinearMap(0.5)) # wrong!
50
-
51
-
52
- class TestConversionGraphUnitConversion(unittest.TestCase):
53
-
54
- def setUp(self):
55
- self.graph = ConversionGraph()
56
- self.meter = units.meter
57
- self.foot = Unit(name='foot', dimension=Dimension.length, aliases=('ft',))
58
- self.inch = Unit(name='inch', dimension=Dimension.length, aliases=('in',))
59
- self.yard = Unit(name='yard', dimension=Dimension.length, aliases=('yd',))
60
-
61
- def test_identity_conversion(self):
62
- m = self.graph.convert(src=self.meter, dst=self.meter)
63
- self.assertTrue(m.is_identity())
64
-
65
- def test_direct_edge(self):
66
- self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
67
- m = self.graph.convert(src=self.meter, dst=self.foot)
68
- self.assertAlmostEqual(m(1), 3.28084, places=4)
69
-
70
- def test_composed_path(self):
71
- self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
72
- self.graph.add_edge(src=self.foot, dst=self.inch, map=LinearMap(12))
73
- m = self.graph.convert(src=self.meter, dst=self.inch)
74
- self.assertAlmostEqual(m(1), 39.37008, places=3)
75
-
76
- def test_multi_hop_path(self):
77
- self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
78
- self.graph.add_edge(src=self.foot, dst=self.inch, map=LinearMap(12))
79
- self.graph.add_edge(src=self.inch, dst=self.yard, map=LinearMap(1/36))
80
- m = self.graph.convert(src=self.meter, dst=self.yard)
81
- self.assertAlmostEqual(m(1), 1.0936, places=3)
82
-
83
- def test_no_path_raises(self):
84
- mile = Unit(name='mile', dimension=Dimension.length, aliases=('mi',))
85
- with self.assertRaises(ConversionNotFound):
86
- self.graph.convert(src=self.meter, dst=mile)
87
-
88
- def test_dimension_mismatch_on_convert(self):
89
- with self.assertRaises(DimensionMismatch):
90
- self.graph.convert(src=self.meter, dst=units.second)
91
-
92
-
93
- class TestConversionGraphProductConversion(unittest.TestCase):
94
-
95
- def setUp(self):
96
- self.graph = ConversionGraph()
97
- self.meter = units.meter
98
- self.second = units.second
99
- self.mile = Unit(name='mile', dimension=Dimension.length, aliases=('mi',))
100
- self.hour = Unit(name='hour', dimension=Dimension.time, aliases=('h',))
101
-
102
- # Register unit conversions
103
- self.graph.add_edge(src=self.meter, dst=self.mile, map=LinearMap(0.000621371))
104
- self.graph.add_edge(src=self.second, dst=self.hour, map=LinearMap(1/3600))
105
-
106
- def test_factorwise_velocity_conversion(self):
107
- m_per_s = UnitProduct({
108
- UnitFactor(self.meter, Scale.one): 1,
109
- UnitFactor(self.second, Scale.one): -1,
110
- })
111
- mi_per_hr = UnitProduct({
112
- UnitFactor(self.mile, Scale.one): 1,
113
- UnitFactor(self.hour, Scale.one): -1,
114
- })
115
-
116
- m = self.graph.convert(src=m_per_s, dst=mi_per_hr)
117
- # 1 m/s = 2.23694 mi/h
118
- self.assertAlmostEqual(m(1), 2.237, places=2)
119
-
120
- def test_scale_ratio_in_factorwise(self):
121
- km = UnitProduct({UnitFactor(self.meter, Scale.kilo): 1})
122
- m = UnitProduct({UnitFactor(self.meter, Scale.one): 1})
123
-
124
- conversion = self.graph.convert(src=km, dst=m)
125
- self.assertAlmostEqual(conversion(1), 1000, places=6)
126
-
127
- def test_direct_product_edge(self):
128
- # Define joule and watt_hour as UnitProducts
129
- g = units.gram
130
- joule = UnitProduct({
131
- UnitFactor(g, Scale.one): 1,
132
- UnitFactor(self.meter, Scale.one): 2,
133
- UnitFactor(self.second, Scale.one): -2,
134
- })
135
- watt = UnitProduct({
136
- UnitFactor(g, Scale.one): 1,
137
- UnitFactor(self.meter, Scale.one): 2,
138
- UnitFactor(self.second, Scale.one): -3,
139
- })
140
- watt_hour = watt * UnitProduct({UnitFactor(self.hour, Scale.one): 1})
141
-
142
- # Register direct edge
143
- self.graph.add_edge(src=joule, dst=watt_hour, map=LinearMap(1/3600))
144
-
145
- m = self.graph.convert(src=joule, dst=watt_hour)
146
- self.assertAlmostEqual(m(7200), 2.0, places=6)
147
-
148
- def test_product_identity(self):
149
- m_per_s = UnitProduct({
150
- UnitFactor(self.meter, Scale.one): 1,
151
- UnitFactor(self.second, Scale.one): -1,
152
- })
153
- m = self.graph.convert(src=m_per_s, dst=m_per_s)
154
- self.assertTrue(m.is_identity())
155
-
156
-
157
- class TestConversionGraphTemperature(unittest.TestCase):
158
-
159
- def setUp(self):
160
- self.graph = ConversionGraph()
161
- self.celsius = Unit(name='celsius', dimension=Dimension.temperature, aliases=('°C',))
162
- self.kelvin = Unit(name='kelvin', dimension=Dimension.temperature, aliases=('K',))
163
- self.fahrenheit = Unit(name='fahrenheit', dimension=Dimension.temperature, aliases=('°F',))
164
-
165
- def test_celsius_to_kelvin(self):
166
- self.graph.add_edge(src=self.celsius, dst=self.kelvin, map=AffineMap(1, 273.15))
167
- m = self.graph.convert(src=self.celsius, dst=self.kelvin)
168
- self.assertAlmostEqual(m(0), 273.15, places=2)
169
- self.assertAlmostEqual(m(100), 373.15, places=2)
170
-
171
- def test_celsius_to_fahrenheit_via_kelvin(self):
172
- # C → K: K = C + 273.15
173
- self.graph.add_edge(src=self.celsius, dst=self.kelvin, map=AffineMap(1, 273.15))
174
- # F → K: K = (F - 32) * 5/9 + 273.15 = 5/9 * F + 255.372
175
- self.graph.add_edge(src=self.fahrenheit, dst=self.kelvin, map=AffineMap(5/9, 255.372))
176
-
177
- m = self.graph.convert(src=self.celsius, dst=self.fahrenheit)
178
- self.assertAlmostEqual(m(0), 32, places=0)
179
- self.assertAlmostEqual(m(100), 212, places=0)
180
-
181
-
182
- class TestConversionGraphProductEdgeManagement(unittest.TestCase):
183
- """Tests for UnitProduct edge management."""
184
-
185
- def setUp(self):
186
- self.graph = ConversionGraph()
187
- self.meter = units.meter
188
- self.second = units.second
189
- self.gram = units.gram
190
-
191
- def test_add_product_edge(self):
192
- """Test adding edge between two UnitProducts with different scales."""
193
- # Create two energy-like products with different scales
194
- energy_g = UnitProduct({
195
- UnitFactor(self.gram, Scale.one): 1,
196
- UnitFactor(self.meter, Scale.one): 2,
197
- UnitFactor(self.second, Scale.one): -2,
198
- })
199
- energy_kg = UnitProduct({
200
- UnitFactor(self.gram, Scale.kilo): 1,
201
- UnitFactor(self.meter, Scale.one): 2,
202
- UnitFactor(self.second, Scale.one): -2,
203
- })
204
- # Register direct edge: 1 g·m²/s² = 0.001 kg·m²/s²
205
- self.graph.add_edge(src=energy_g, dst=energy_kg, map=LinearMap(0.001))
206
- m = self.graph.convert(src=energy_g, dst=energy_kg)
207
- self.assertAlmostEqual(m(1), 0.001, places=6)
208
-
209
- def test_add_mixed_unit_and_product_edge(self):
210
- """Test adding edge with Unit on one side, UnitProduct on other."""
211
- m_prod = UnitProduct({UnitFactor(self.meter, Scale.one): 1})
212
- self.graph.add_edge(src=self.meter, dst=m_prod, map=LinearMap(1.0))
213
- m = self.graph.convert(src=self.meter, dst=m_prod)
214
- self.assertTrue(m.is_identity())
215
-
216
- def test_product_edge_dimension_mismatch(self):
217
- """Test that dimension mismatch raises for UnitProduct edges."""
218
- length_prod = UnitProduct({UnitFactor(self.meter, Scale.one): 1})
219
- time_prod = UnitProduct({UnitFactor(self.second, Scale.one): 1})
220
- with self.assertRaises(DimensionMismatch):
221
- self.graph.add_edge(src=length_prod, dst=time_prod, map=LinearMap(1))
222
-
223
- def test_product_edge_cyclic_consistency(self):
224
- """Test cyclic consistency check for UnitProduct edges."""
225
- energy1 = UnitProduct({
226
- UnitFactor(self.gram, Scale.one): 1,
227
- UnitFactor(self.meter, Scale.one): 2,
228
- UnitFactor(self.second, Scale.one): -2,
229
- })
230
- energy2 = UnitProduct({
231
- UnitFactor(self.gram, Scale.kilo): 1,
232
- UnitFactor(self.meter, Scale.one): 2,
233
- UnitFactor(self.second, Scale.one): -2,
234
- })
235
- self.graph.add_edge(src=energy1, dst=energy2, map=LinearMap(0.001))
236
- # Adding inconsistent reverse should raise
237
- with self.assertRaises(CyclicInconsistency):
238
- self.graph.add_edge(src=energy2, dst=energy1, map=LinearMap(500)) # wrong!
239
-
240
-
241
- class TestConversionGraphFactorwiseEdgeCases(unittest.TestCase):
242
- """Tests for factorwise conversion edge cases."""
243
-
244
- def setUp(self):
245
- self.graph = ConversionGraph()
246
- self.meter = units.meter
247
- self.second = units.second
248
- self.foot = Unit(name='foot', dimension=Dimension.length, aliases=('ft',))
249
-
250
- def test_factorwise_exponent_mismatch(self):
251
- """Test that exponent mismatch raises ConversionNotFound."""
252
- # m^2 vs m^1 - different exponents for same dimension
253
- m_squared = UnitProduct({UnitFactor(self.meter, Scale.one): 2})
254
- m_single = UnitProduct({UnitFactor(self.meter, Scale.one): 1})
255
- # These have different dimensions, so dimension check fails first
256
- with self.assertRaises(DimensionMismatch):
257
- self.graph.convert(src=m_squared, dst=m_single)
258
-
259
- def test_factorwise_misaligned_structures(self):
260
- """Test that misaligned factor structures raise ConversionNotFound."""
261
- # m/s vs m (missing time dimension in target)
262
- m_per_s = UnitProduct({
263
- UnitFactor(self.meter, Scale.one): 1,
264
- UnitFactor(self.second, Scale.one): -1,
265
- })
266
- m_only = UnitProduct({UnitFactor(self.meter, Scale.one): 1})
267
- with self.assertRaises(DimensionMismatch):
268
- self.graph.convert(src=m_per_s, dst=m_only)
269
-
270
- def test_convert_unit_to_product(self):
271
- """Test conversion from plain Unit to UnitProduct."""
272
- self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
273
- m_prod = UnitProduct({UnitFactor(self.meter, Scale.one): 1})
274
- ft_prod = UnitProduct({UnitFactor(self.foot, Scale.one): 1})
275
- m = self.graph.convert(src=m_prod, dst=ft_prod)
276
- self.assertAlmostEqual(m(1), 3.28084, places=4)
277
-
278
- def test_convert_product_dimension_mismatch(self):
279
- """Test dimension mismatch in _convert_products."""
280
- length = UnitProduct({UnitFactor(self.meter, Scale.one): 1})
281
- time = UnitProduct({UnitFactor(self.second, Scale.one): 1})
282
- with self.assertRaises(DimensionMismatch):
283
- self.graph.convert(src=length, dst=time)
284
-
285
-
286
- class TestConversionGraphBFSEdgeCases(unittest.TestCase):
287
- """Tests for BFS path-finding edge cases."""
288
-
289
- def setUp(self):
290
- self.graph = ConversionGraph()
291
-
292
- def test_no_edges_for_dimension(self):
293
- """Test ConversionNotFound when dimension has no edges at all."""
294
- custom_unit1 = Unit(name='custom1', dimension=Dimension.length)
295
- custom_unit2 = Unit(name='custom2', dimension=Dimension.length)
296
- with self.assertRaises(ConversionNotFound):
297
- self.graph.convert(src=custom_unit1, dst=custom_unit2)
298
-
299
- def test_source_has_no_outgoing_edges(self):
300
- """Test when source exists in graph but has no path to target."""
301
- a = Unit(name='a', dimension=Dimension.length)
302
- b = Unit(name='b', dimension=Dimension.length)
303
- c = Unit(name='c', dimension=Dimension.length)
304
- # Only add a→b, no connection to c
305
- self.graph.add_edge(src=a, dst=b, map=LinearMap(2.0))
306
- with self.assertRaises(ConversionNotFound):
307
- self.graph.convert(src=a, dst=c)
308
-
309
- def test_disconnected_subgraphs(self):
310
- """Test when units are in same dimension but disconnected subgraphs."""
311
- a = Unit(name='a', dimension=Dimension.length)
312
- b = Unit(name='b', dimension=Dimension.length)
313
- c = Unit(name='c', dimension=Dimension.length)
314
- d = Unit(name='d', dimension=Dimension.length)
315
- # Subgraph 1: a ↔ b
316
- self.graph.add_edge(src=a, dst=b, map=LinearMap(2.0))
317
- # Subgraph 2: c ↔ d
318
- self.graph.add_edge(src=c, dst=d, map=LinearMap(3.0))
319
- # Cannot convert between subgraphs
320
- with self.assertRaises(ConversionNotFound):
321
- self.graph.convert(src=a, dst=c)
322
-
323
-
324
- class TestDefaultGraphManagement(unittest.TestCase):
325
- """Tests for default graph management functions."""
326
-
327
- def setUp(self):
328
- reset_default_graph()
329
-
330
- def tearDown(self):
331
- reset_default_graph()
332
-
333
- def test_get_default_graph_returns_graph(self):
334
- """Test that get_default_graph returns a ConversionGraph."""
335
- graph = get_default_graph()
336
- self.assertIsInstance(graph, ConversionGraph)
337
-
338
- def test_get_default_graph_is_cached(self):
339
- """Test that get_default_graph returns same instance."""
340
- graph1 = get_default_graph()
341
- graph2 = get_default_graph()
342
- self.assertIs(graph1, graph2)
343
-
344
- def test_set_default_graph(self):
345
- """Test that set_default_graph replaces the default."""
346
- custom = ConversionGraph()
347
- set_default_graph(custom)
348
- self.assertIs(get_default_graph(), custom)
349
-
350
- def test_reset_default_graph(self):
351
- """Test that reset_default_graph clears the cached graph."""
352
- graph1 = get_default_graph()
353
- custom = ConversionGraph()
354
- set_default_graph(custom)
355
- reset_default_graph()
356
- graph2 = get_default_graph()
357
- self.assertIsNot(graph2, custom)
358
- # Should be a fresh standard graph
359
- self.assertIsInstance(graph2, ConversionGraph)
360
-
361
- def test_using_graph_context_manager(self):
362
- """Test using_graph provides scoped override."""
363
- default = get_default_graph()
364
- custom = ConversionGraph()
365
-
366
- with using_graph(custom) as g:
367
- self.assertIs(g, custom)
368
- self.assertIs(get_default_graph(), custom)
369
-
370
- # After context, back to default
371
- self.assertIs(get_default_graph(), default)
372
-
373
- def test_using_graph_nested(self):
374
- """Test nested using_graph contexts."""
375
- default = get_default_graph()
376
- custom1 = ConversionGraph()
377
- custom2 = ConversionGraph()
378
-
379
- with using_graph(custom1):
380
- self.assertIs(get_default_graph(), custom1)
381
- with using_graph(custom2):
382
- self.assertIs(get_default_graph(), custom2)
383
- self.assertIs(get_default_graph(), custom1)
384
-
385
- self.assertIs(get_default_graph(), default)
386
-
387
- def test_using_graph_exception_safety(self):
388
- """Test using_graph restores graph even on exception."""
389
- default = get_default_graph()
390
- custom = ConversionGraph()
391
-
392
- try:
393
- with using_graph(custom):
394
- self.assertIs(get_default_graph(), custom)
395
- raise ValueError("test error")
396
- except ValueError:
397
- pass
398
-
399
- self.assertIs(get_default_graph(), default)
400
-
401
- def test_default_graph_has_standard_conversions(self):
402
- """Test that default graph includes expected conversions."""
403
- graph = get_default_graph()
404
- # Test a few known conversions
405
- m = graph.convert(src=units.meter, dst=units.foot)
406
- self.assertAlmostEqual(m(1), 3.28084, places=4)
407
-
408
- m = graph.convert(src=units.kilogram, dst=units.pound)
409
- self.assertAlmostEqual(m(1), 2.20462, places=4)