ucon 0.4.0__py3-none-any.whl → 0.4.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.
@@ -11,6 +11,10 @@ from ucon.graph import (
11
11
  DimensionMismatch,
12
12
  ConversionNotFound,
13
13
  CyclicInconsistency,
14
+ get_default_graph,
15
+ set_default_graph,
16
+ reset_default_graph,
17
+ using_graph,
14
18
  )
15
19
  from ucon.maps import LinearMap, AffineMap
16
20
 
@@ -173,3 +177,233 @@ class TestConversionGraphTemperature(unittest.TestCase):
173
177
  m = self.graph.convert(src=self.celsius, dst=self.fahrenheit)
174
178
  self.assertAlmostEqual(m(0), 32, places=0)
175
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)
@@ -67,6 +67,52 @@ class TestLinearMap(unittest.TestCase):
67
67
  result = m.__matmul__(42)
68
68
  self.assertIs(result, NotImplemented)
69
69
 
70
+ def test_matmul_with_affine(self):
71
+ lin = LinearMap(2.0)
72
+ aff = AffineMap(3.0, 5.0)
73
+ composed = lin @ aff
74
+ # lin(aff(x)) = 2 * (3x + 5) = 6x + 10
75
+ self.assertIsInstance(composed, AffineMap)
76
+ self.assertAlmostEqual(composed.a, 6.0)
77
+ self.assertAlmostEqual(composed.b, 10.0)
78
+ self.assertAlmostEqual(composed(1.0), 16.0)
79
+
80
+ def test_pow(self):
81
+ m = LinearMap(3.0)
82
+ squared = m ** 2
83
+ self.assertIsInstance(squared, LinearMap)
84
+ self.assertAlmostEqual(squared.a, 9.0)
85
+
86
+ def test_pow_negative(self):
87
+ m = LinearMap(4.0)
88
+ inv = m ** -1
89
+ self.assertIsInstance(inv, LinearMap)
90
+ self.assertAlmostEqual(inv.a, 0.25)
91
+
92
+ def test_pow_fractional(self):
93
+ m = LinearMap(4.0)
94
+ sqrt = m ** 0.5
95
+ self.assertIsInstance(sqrt, LinearMap)
96
+ self.assertAlmostEqual(sqrt.a, 2.0)
97
+
98
+ def test_is_identity_true(self):
99
+ m = LinearMap(1.0)
100
+ self.assertTrue(m.is_identity())
101
+
102
+ def test_is_identity_false(self):
103
+ m = LinearMap(2.0)
104
+ self.assertFalse(m.is_identity())
105
+
106
+ def test_is_identity_near_one(self):
107
+ m = LinearMap(1.0 + 1e-10)
108
+ self.assertTrue(m.is_identity())
109
+
110
+ def test_hash(self):
111
+ m1 = LinearMap(3.0)
112
+ m2 = LinearMap(3.0)
113
+ self.assertEqual(hash(m1), hash(m2))
114
+ self.assertEqual(len({m1, m2}), 1)
115
+
70
116
 
71
117
  class TestAffineMap(unittest.TestCase):
72
118
 
@@ -122,6 +168,57 @@ class TestAffineMap(unittest.TestCase):
122
168
  self.assertIn("1.8", r)
123
169
  self.assertIn("32.0", r)
124
170
 
171
+ def test_matmul_with_linear(self):
172
+ aff = AffineMap(2.0, 3.0)
173
+ lin = LinearMap(4.0)
174
+ composed = aff @ lin
175
+ # aff(lin(x)) = 2 * (4x) + 3 = 8x + 3
176
+ self.assertIsInstance(composed, AffineMap)
177
+ self.assertAlmostEqual(composed.a, 8.0)
178
+ self.assertAlmostEqual(composed.b, 3.0)
179
+ self.assertAlmostEqual(composed(1.0), 11.0)
180
+
181
+ def test_matmul_non_map_returns_not_implemented(self):
182
+ m = AffineMap(1.8, 32.0)
183
+ result = m.__matmul__("not a map")
184
+ self.assertIs(result, NotImplemented)
185
+
186
+ def test_pow_one(self):
187
+ m = AffineMap(1.8, 32.0)
188
+ result = m ** 1
189
+ self.assertIs(result, m)
190
+
191
+ def test_pow_negative_one(self):
192
+ m = AffineMap(1.8, 32.0)
193
+ result = m ** -1
194
+ inv = m.inverse()
195
+ self.assertEqual(result.a, inv.a)
196
+ self.assertEqual(result.b, inv.b)
197
+
198
+ def test_pow_invalid_raises(self):
199
+ m = AffineMap(1.8, 32.0)
200
+ with self.assertRaises(ValueError) as ctx:
201
+ m ** 2
202
+ self.assertIn("only supports exp=1 or exp=-1", str(ctx.exception))
203
+
204
+ def test_is_identity_true(self):
205
+ m = AffineMap(1.0, 0.0)
206
+ self.assertTrue(m.is_identity())
207
+
208
+ def test_is_identity_false_due_to_offset(self):
209
+ m = AffineMap(1.0, 5.0)
210
+ self.assertFalse(m.is_identity())
211
+
212
+ def test_is_identity_false_due_to_scale(self):
213
+ m = AffineMap(2.0, 0.0)
214
+ self.assertFalse(m.is_identity())
215
+
216
+ def test_hash(self):
217
+ m1 = AffineMap(1.8, 32.0)
218
+ m2 = AffineMap(1.8, 32.0)
219
+ self.assertEqual(hash(m1), hash(m2))
220
+ self.assertEqual(len({m1, m2}), 1)
221
+
125
222
 
126
223
  class TestComposedMap(unittest.TestCase):
127
224
 
@@ -155,9 +252,158 @@ class TestComposedMap(unittest.TestCase):
155
252
  self.assertIn("LinearMap", r)
156
253
  self.assertIn("AffineMap", r)
157
254
 
255
+ def test_matmul(self):
256
+ c1 = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
257
+ c2 = LinearMap(5.0)
258
+ composed = c1 @ c2
259
+ self.assertIsInstance(composed, ComposedMap)
260
+ # c1(c2(x)) = c1(5x) = 2*(3*5x + 1) = 30x + 2
261
+ self.assertAlmostEqual(composed(1.0), 32.0)
262
+ self.assertAlmostEqual(composed(0.0), 2.0)
263
+
264
+ def test_matmul_non_map_returns_not_implemented(self):
265
+ composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
266
+ result = composed.__matmul__(42)
267
+ self.assertIs(result, NotImplemented)
268
+
269
+ def test_pow_one(self):
270
+ composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
271
+ result = composed ** 1
272
+ self.assertIs(result, composed)
273
+
274
+ def test_pow_negative_one(self):
275
+ composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
276
+ result = composed ** -1
277
+ # Round-trip should be identity
278
+ for x in [0.0, 1.0, 5.0]:
279
+ self.assertAlmostEqual(result(composed(x)), x, places=10)
280
+
281
+ def test_pow_invalid_raises(self):
282
+ composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
283
+ with self.assertRaises(ValueError) as ctx:
284
+ composed ** 2
285
+ self.assertIn("only supports exp=1 or exp=-1", str(ctx.exception))
286
+
287
+ def test_invertible_both_non_invertible(self):
288
+ composed = ComposedMap(LinearMap(0), LinearMap(0))
289
+ self.assertFalse(composed.invertible)
290
+
291
+ def test_invertible_inner_non_invertible(self):
292
+ composed = ComposedMap(LinearMap(2.0), LinearMap(0))
293
+ self.assertFalse(composed.invertible)
294
+
295
+ def test_is_identity(self):
296
+ composed = ComposedMap(LinearMap(1.0), LinearMap(1.0))
297
+ self.assertTrue(composed.is_identity())
298
+
299
+ def test_is_identity_false(self):
300
+ composed = ComposedMap(LinearMap(2.0), LinearMap(0.5))
301
+ self.assertTrue(composed.is_identity()) # 2 * 0.5 = 1
302
+
303
+ def test_is_identity_with_offset(self):
304
+ composed = ComposedMap(LinearMap(1.0), AffineMap(1.0, 5.0))
305
+ self.assertFalse(composed.is_identity())
306
+
158
307
 
159
308
  class TestMapABC(unittest.TestCase):
160
309
 
161
310
  def test_cannot_instantiate(self):
162
311
  with self.assertRaises(TypeError):
163
312
  Map()
313
+
314
+
315
+ class TestCrossTypeComposition(unittest.TestCase):
316
+ """Tests for composition between different Map types."""
317
+
318
+ def test_linear_at_affine_at_linear(self):
319
+ """Chain: LinearMap @ AffineMap @ LinearMap"""
320
+ l1 = LinearMap(2.0)
321
+ a = AffineMap(3.0, 1.0)
322
+ l2 = LinearMap(4.0)
323
+ # l1 @ a = AffineMap(6, 2)
324
+ # (l1 @ a) @ l2 = AffineMap(24, 2)
325
+ composed = l1 @ a @ l2
326
+ self.assertIsInstance(composed, AffineMap)
327
+ self.assertAlmostEqual(composed(1.0), 26.0)
328
+
329
+ def test_affine_at_linear_at_affine(self):
330
+ """Chain: AffineMap @ LinearMap @ AffineMap"""
331
+ a1 = AffineMap(2.0, 1.0)
332
+ l = LinearMap(3.0)
333
+ a2 = AffineMap(4.0, 5.0)
334
+ # l @ a2 = AffineMap(12, 15)
335
+ # a1 @ (l @ a2) = AffineMap(24, 31)
336
+ composed = a1 @ l @ a2
337
+ self.assertIsInstance(composed, AffineMap)
338
+ self.assertAlmostEqual(composed(1.0), 55.0)
339
+
340
+ def test_composed_preserves_semantics(self):
341
+ """Verify f @ g computes f(g(x)) correctly for all type combinations."""
342
+ maps = [
343
+ LinearMap(2.0),
344
+ LinearMap(0.5),
345
+ AffineMap(3.0, 1.0),
346
+ AffineMap(1.0, -5.0),
347
+ ]
348
+ for f in maps:
349
+ for g in maps:
350
+ composed = f @ g
351
+ for x in [0.0, 1.0, -2.0, 10.0]:
352
+ expected = f(g(x))
353
+ actual = composed(x)
354
+ self.assertAlmostEqual(actual, expected, places=10,
355
+ msg=f"Failed for {type(f).__name__} @ {type(g).__name__} at x={x}")
356
+
357
+
358
+ class TestMapEdgeCases(unittest.TestCase):
359
+ """Edge case tests for Map hierarchy."""
360
+
361
+ def test_linear_map_with_negative_scale(self):
362
+ m = LinearMap(-3.0)
363
+ self.assertAlmostEqual(m(5.0), -15.0)
364
+ self.assertAlmostEqual(m.inverse()(m(5.0)), 5.0)
365
+
366
+ def test_affine_map_with_negative_scale(self):
367
+ m = AffineMap(-2.0, 10.0)
368
+ self.assertAlmostEqual(m(5.0), 0.0)
369
+ self.assertAlmostEqual(m.inverse()(m(5.0)), 5.0)
370
+
371
+ def test_linear_map_very_small_scale(self):
372
+ m = LinearMap(1e-10)
373
+ self.assertAlmostEqual(m(1e10), 1.0)
374
+ self.assertTrue(m.invertible)
375
+
376
+ def test_linear_map_very_large_scale(self):
377
+ m = LinearMap(1e10)
378
+ self.assertAlmostEqual(m(1e-10), 1.0)
379
+ self.assertTrue(m.invertible)
380
+
381
+ def test_affine_identity(self):
382
+ """AffineMap(1, 0) should be identity."""
383
+ m = AffineMap(1.0, 0.0)
384
+ for x in [0.0, 1.0, -100.0, 1e6]:
385
+ self.assertAlmostEqual(m(x), x)
386
+
387
+ def test_linear_identity(self):
388
+ """LinearMap(1) should be identity."""
389
+ m = LinearMap(1.0)
390
+ for x in [0.0, 1.0, -100.0, 1e6]:
391
+ self.assertAlmostEqual(m(x), x)
392
+
393
+ def test_composed_map_deep_nesting(self):
394
+ """Test deeply nested ComposedMap."""
395
+ m = LinearMap(2.0)
396
+ for _ in range(5):
397
+ m = ComposedMap(m, LinearMap(1.5))
398
+ # 2 * 1.5^5 = 2 * 7.59375 = 15.1875
399
+ self.assertAlmostEqual(m(1.0), 2.0 * (1.5 ** 5))
400
+
401
+ def test_inverse_of_inverse(self):
402
+ """(m.inverse()).inverse() == m"""
403
+ m = LinearMap(7.0)
404
+ self.assertEqual(m.inverse().inverse(), m)
405
+
406
+ m2 = AffineMap(3.0, 5.0)
407
+ double_inv = m2.inverse().inverse()
408
+ self.assertAlmostEqual(double_inv.a, m2.a)
409
+ self.assertAlmostEqual(double_inv.b, m2.b)
tests/ucon/test_core.py CHANGED
@@ -332,14 +332,13 @@ class TestScaleMultiplicationAdditional(unittest.TestCase):
332
332
  self.assertIsInstance(result, Scale)
333
333
  self.assertEqual(result.value.base, 10)
334
334
 
335
- @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
336
335
  def test_scale_multiplication_with_unit(self):
337
- meter = UnitFactor('m', name='meter', dimension=Dimension.length)
338
- kilometer = Scale.kilo * meter
339
- self.assertIsInstance(kilometer, UnitFactor)
340
- self.assertEqual(kilometer.scale, Scale.kilo)
336
+ """Scale * Unit returns a UnitProduct with the scaled unit."""
337
+ kilometer = Scale.kilo * units.meter
338
+ self.assertIsInstance(kilometer, UnitProduct)
341
339
  self.assertEqual(kilometer.dimension, Dimension.length)
342
- self.assertIn('meter', kilometer.name)
340
+ self.assertEqual(kilometer.shorthand, "km")
341
+ self.assertAlmostEqual(kilometer.fold_scale(), 1000.0, places=10)
343
342
 
344
343
  def test_scale_multiplication_with_unit_returns_not_implemented_for_invalid_type(self):
345
344
  with self.assertRaises(TypeError):
@@ -0,0 +1,443 @@
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 the default ConversionGraph with temperature, pressure, and base SI conversions.
7
+
8
+ These tests verify that Number.to() works correctly with the default graph for:
9
+ - Temperature conversions (Celsius, Kelvin, Fahrenheit) using AffineMap
10
+ - Pressure conversions (Pascal, Bar, PSI, Atmosphere)
11
+ - Base SI conversions (length, mass, time, volume, energy, power, information)
12
+ """
13
+
14
+ import unittest
15
+
16
+ from ucon import units
17
+ from ucon.graph import get_default_graph, reset_default_graph
18
+
19
+
20
+ class TestTemperatureConversions(unittest.TestCase):
21
+ """Tests for temperature conversions using AffineMap in the default graph."""
22
+
23
+ def setUp(self):
24
+ reset_default_graph()
25
+
26
+ def test_celsius_to_kelvin_freezing(self):
27
+ """0°C = 273.15 K"""
28
+ result = units.celsius(0).to(units.kelvin)
29
+ self.assertAlmostEqual(result.quantity, 273.15, places=2)
30
+
31
+ def test_celsius_to_kelvin_boiling(self):
32
+ """100°C = 373.15 K"""
33
+ result = units.celsius(100).to(units.kelvin)
34
+ self.assertAlmostEqual(result.quantity, 373.15, places=2)
35
+
36
+ def test_kelvin_to_celsius_absolute_zero(self):
37
+ """0 K = -273.15°C"""
38
+ result = units.kelvin(0).to(units.celsius)
39
+ self.assertAlmostEqual(result.quantity, -273.15, places=2)
40
+
41
+ def test_kelvin_to_celsius_room_temp(self):
42
+ """293.15 K = 20°C"""
43
+ result = units.kelvin(293.15).to(units.celsius)
44
+ self.assertAlmostEqual(result.quantity, 20, places=1)
45
+
46
+ def test_fahrenheit_to_celsius_freezing(self):
47
+ """32°F = 0°C"""
48
+ result = units.fahrenheit(32).to(units.celsius)
49
+ self.assertAlmostEqual(result.quantity, 0, places=1)
50
+
51
+ def test_fahrenheit_to_celsius_boiling(self):
52
+ """212°F = 100°C"""
53
+ result = units.fahrenheit(212).to(units.celsius)
54
+ self.assertAlmostEqual(result.quantity, 100, places=1)
55
+
56
+ def test_celsius_to_fahrenheit_freezing(self):
57
+ """0°C = 32°F"""
58
+ result = units.celsius(0).to(units.fahrenheit)
59
+ self.assertAlmostEqual(result.quantity, 32, places=1)
60
+
61
+ def test_celsius_to_fahrenheit_boiling(self):
62
+ """100°C = 212°F"""
63
+ result = units.celsius(100).to(units.fahrenheit)
64
+ self.assertAlmostEqual(result.quantity, 212, places=1)
65
+
66
+ def test_fahrenheit_to_kelvin_absolute_zero(self):
67
+ """-459.67°F = 0 K (approximately)"""
68
+ result = units.fahrenheit(-459.67).to(units.kelvin)
69
+ self.assertAlmostEqual(result.quantity, 0, places=0)
70
+
71
+ def test_kelvin_to_fahrenheit_boiling(self):
72
+ """373.15 K = 212°F"""
73
+ result = units.kelvin(373.15).to(units.fahrenheit)
74
+ self.assertAlmostEqual(result.quantity, 212, places=0)
75
+
76
+ def test_temperature_round_trip_celsius(self):
77
+ """Round-trip: C → K → C"""
78
+ original = 25.0
79
+ via_kelvin = units.celsius(original).to(units.kelvin)
80
+ back = via_kelvin.to(units.celsius)
81
+ self.assertAlmostEqual(back.quantity, original, places=10)
82
+
83
+ def test_temperature_round_trip_fahrenheit(self):
84
+ """Round-trip: F → C → F"""
85
+ original = 98.6 # body temperature
86
+ via_celsius = units.fahrenheit(original).to(units.celsius)
87
+ back = via_celsius.to(units.fahrenheit)
88
+ self.assertAlmostEqual(back.quantity, original, places=10)
89
+
90
+
91
+ class TestPressureConversions(unittest.TestCase):
92
+ """Tests for pressure conversions in the default graph."""
93
+
94
+ def setUp(self):
95
+ reset_default_graph()
96
+
97
+ def test_pascal_to_bar(self):
98
+ """100000 Pa = 1 bar"""
99
+ result = units.pascal(100000).to(units.bar)
100
+ self.assertAlmostEqual(result.quantity, 1.0, places=5)
101
+
102
+ def test_bar_to_pascal(self):
103
+ """1 bar = 100000 Pa"""
104
+ result = units.bar(1).to(units.pascal)
105
+ self.assertAlmostEqual(result.quantity, 100000, places=0)
106
+
107
+ def test_pascal_to_psi(self):
108
+ """6894.76 Pa ≈ 1 psi"""
109
+ result = units.pascal(6894.76).to(units.psi)
110
+ self.assertAlmostEqual(result.quantity, 1.0, places=2)
111
+
112
+ def test_psi_to_pascal(self):
113
+ """1 psi ≈ 6894.76 Pa"""
114
+ result = units.psi(1).to(units.pascal)
115
+ self.assertAlmostEqual(result.quantity, 6894.76, places=0)
116
+
117
+ def test_atmosphere_to_pascal(self):
118
+ """1 atm = 101325 Pa"""
119
+ result = units.atmosphere(1).to(units.pascal)
120
+ self.assertAlmostEqual(result.quantity, 101325, places=0)
121
+
122
+ def test_pascal_to_atmosphere(self):
123
+ """101325 Pa = 1 atm"""
124
+ result = units.pascal(101325).to(units.atmosphere)
125
+ self.assertAlmostEqual(result.quantity, 1.0, places=5)
126
+
127
+ def test_atmosphere_to_bar(self):
128
+ """1 atm ≈ 1.01325 bar"""
129
+ result = units.atmosphere(1).to(units.bar)
130
+ self.assertAlmostEqual(result.quantity, 1.01325, places=4)
131
+
132
+ def test_bar_to_atmosphere(self):
133
+ """1 bar ≈ 0.986923 atm"""
134
+ result = units.bar(1).to(units.atmosphere)
135
+ self.assertAlmostEqual(result.quantity, 0.986923, places=4)
136
+
137
+ def test_atmosphere_to_psi(self):
138
+ """1 atm ≈ 14.696 psi"""
139
+ result = units.atmosphere(1).to(units.psi)
140
+ self.assertAlmostEqual(result.quantity, 14.696, places=2)
141
+
142
+ def test_psi_to_atmosphere(self):
143
+ """14.696 psi ≈ 1 atm"""
144
+ result = units.psi(14.696).to(units.atmosphere)
145
+ self.assertAlmostEqual(result.quantity, 1.0, places=2)
146
+
147
+ def test_pressure_round_trip(self):
148
+ """Round-trip: Pa → bar → Pa"""
149
+ original = 250000
150
+ via_bar = units.pascal(original).to(units.bar)
151
+ back = via_bar.to(units.pascal)
152
+ self.assertAlmostEqual(back.quantity, original, places=5)
153
+
154
+
155
+ class TestBaseSILengthConversions(unittest.TestCase):
156
+ """Tests for length conversions in the default graph."""
157
+
158
+ def setUp(self):
159
+ reset_default_graph()
160
+
161
+ def test_meter_to_foot(self):
162
+ """1 m ≈ 3.28084 ft"""
163
+ result = units.meter(1).to(units.foot)
164
+ self.assertAlmostEqual(result.quantity, 3.28084, places=4)
165
+
166
+ def test_foot_to_meter(self):
167
+ """1 ft ≈ 0.3048 m"""
168
+ result = units.foot(1).to(units.meter)
169
+ self.assertAlmostEqual(result.quantity, 0.3048, places=4)
170
+
171
+ def test_foot_to_inch(self):
172
+ """1 ft = 12 in"""
173
+ result = units.foot(1).to(units.inch)
174
+ self.assertAlmostEqual(result.quantity, 12, places=10)
175
+
176
+ def test_inch_to_foot(self):
177
+ """12 in = 1 ft"""
178
+ result = units.inch(12).to(units.foot)
179
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
180
+
181
+ def test_meter_to_inch(self):
182
+ """1 m ≈ 39.37 in (via foot)"""
183
+ result = units.meter(1).to(units.inch)
184
+ self.assertAlmostEqual(result.quantity, 39.37, places=1)
185
+
186
+ def test_foot_to_yard(self):
187
+ """3 ft = 1 yd"""
188
+ result = units.foot(3).to(units.yard)
189
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
190
+
191
+ def test_yard_to_foot(self):
192
+ """1 yd = 3 ft"""
193
+ result = units.yard(1).to(units.foot)
194
+ self.assertAlmostEqual(result.quantity, 3.0, places=10)
195
+
196
+ def test_mile_to_foot(self):
197
+ """1 mi = 5280 ft"""
198
+ result = units.mile(1).to(units.foot)
199
+ self.assertAlmostEqual(result.quantity, 5280, places=0)
200
+
201
+ def test_foot_to_mile(self):
202
+ """5280 ft = 1 mi"""
203
+ result = units.foot(5280).to(units.mile)
204
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
205
+
206
+ def test_meter_to_mile(self):
207
+ """1609.34 m ≈ 1 mi (multi-hop: m → ft → mi)"""
208
+ result = units.meter(1609.34).to(units.mile)
209
+ self.assertAlmostEqual(result.quantity, 1.0, places=2)
210
+
211
+ def test_meter_to_yard(self):
212
+ """1 m ≈ 1.094 yd (multi-hop: m → ft → yd)"""
213
+ result = units.meter(1).to(units.yard)
214
+ self.assertAlmostEqual(result.quantity, 1.094, places=2)
215
+
216
+
217
+ class TestBaseSIMassConversions(unittest.TestCase):
218
+ """Tests for mass conversions in the default graph."""
219
+
220
+ def setUp(self):
221
+ reset_default_graph()
222
+
223
+ def test_kilogram_to_gram(self):
224
+ """1 kg = 1000 g"""
225
+ result = units.kilogram(1).to(units.gram)
226
+ self.assertAlmostEqual(result.quantity, 1000, places=10)
227
+
228
+ def test_gram_to_kilogram(self):
229
+ """1000 g = 1 kg"""
230
+ result = units.gram(1000).to(units.kilogram)
231
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
232
+
233
+ def test_kilogram_to_pound(self):
234
+ """1 kg ≈ 2.20462 lb"""
235
+ result = units.kilogram(1).to(units.pound)
236
+ self.assertAlmostEqual(result.quantity, 2.20462, places=4)
237
+
238
+ def test_pound_to_kilogram(self):
239
+ """1 lb ≈ 0.453592 kg"""
240
+ result = units.pound(1).to(units.kilogram)
241
+ self.assertAlmostEqual(result.quantity, 0.453592, places=4)
242
+
243
+ def test_pound_to_ounce(self):
244
+ """1 lb = 16 oz"""
245
+ result = units.pound(1).to(units.ounce)
246
+ self.assertAlmostEqual(result.quantity, 16, places=10)
247
+
248
+ def test_ounce_to_pound(self):
249
+ """16 oz = 1 lb"""
250
+ result = units.ounce(16).to(units.pound)
251
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
252
+
253
+ def test_gram_to_pound(self):
254
+ """453.592 g ≈ 1 lb (via kg)"""
255
+ result = units.gram(453.592).to(units.pound)
256
+ self.assertAlmostEqual(result.quantity, 1.0, places=2)
257
+
258
+ def test_kilogram_to_ounce(self):
259
+ """1 kg ≈ 35.274 oz (multi-hop: kg → lb → oz)"""
260
+ result = units.kilogram(1).to(units.ounce)
261
+ self.assertAlmostEqual(result.quantity, 35.274, places=1)
262
+
263
+
264
+ class TestBaseSITimeConversions(unittest.TestCase):
265
+ """Tests for time conversions in the default graph."""
266
+
267
+ def setUp(self):
268
+ reset_default_graph()
269
+
270
+ def test_second_to_minute(self):
271
+ """60 s = 1 min"""
272
+ result = units.second(60).to(units.minute)
273
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
274
+
275
+ def test_minute_to_second(self):
276
+ """1 min = 60 s"""
277
+ result = units.minute(1).to(units.second)
278
+ self.assertAlmostEqual(result.quantity, 60, places=10)
279
+
280
+ def test_minute_to_hour(self):
281
+ """60 min = 1 hr"""
282
+ result = units.minute(60).to(units.hour)
283
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
284
+
285
+ def test_hour_to_minute(self):
286
+ """1 hr = 60 min"""
287
+ result = units.hour(1).to(units.minute)
288
+ self.assertAlmostEqual(result.quantity, 60, places=10)
289
+
290
+ def test_hour_to_day(self):
291
+ """24 hr = 1 day"""
292
+ result = units.hour(24).to(units.day)
293
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
294
+
295
+ def test_day_to_hour(self):
296
+ """1 day = 24 hr"""
297
+ result = units.day(1).to(units.hour)
298
+ self.assertAlmostEqual(result.quantity, 24, places=10)
299
+
300
+ def test_second_to_hour(self):
301
+ """3600 s = 1 hr (multi-hop: s → min → hr)"""
302
+ result = units.second(3600).to(units.hour)
303
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
304
+
305
+ def test_second_to_day(self):
306
+ """86400 s = 1 day (multi-hop: s → min → hr → day)"""
307
+ result = units.second(86400).to(units.day)
308
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
309
+
310
+ def test_day_to_second(self):
311
+ """1 day = 86400 s"""
312
+ result = units.day(1).to(units.second)
313
+ self.assertAlmostEqual(result.quantity, 86400, places=0)
314
+
315
+
316
+ class TestBaseSIVolumeConversions(unittest.TestCase):
317
+ """Tests for volume conversions in the default graph."""
318
+
319
+ def setUp(self):
320
+ reset_default_graph()
321
+
322
+ def test_liter_to_gallon(self):
323
+ """1 L ≈ 0.264172 gal"""
324
+ result = units.liter(1).to(units.gallon)
325
+ self.assertAlmostEqual(result.quantity, 0.264172, places=5)
326
+
327
+ def test_gallon_to_liter(self):
328
+ """1 gal ≈ 3.78541 L"""
329
+ result = units.gallon(1).to(units.liter)
330
+ self.assertAlmostEqual(result.quantity, 3.78541, places=3)
331
+
332
+
333
+ class TestBaseSIEnergyConversions(unittest.TestCase):
334
+ """Tests for energy conversions in the default graph."""
335
+
336
+ def setUp(self):
337
+ reset_default_graph()
338
+
339
+ def test_joule_to_calorie(self):
340
+ """4.184 J = 1 cal"""
341
+ result = units.joule(4.184).to(units.calorie)
342
+ self.assertAlmostEqual(result.quantity, 1.0, places=3)
343
+
344
+ def test_calorie_to_joule(self):
345
+ """1 cal = 4.184 J"""
346
+ result = units.calorie(1).to(units.joule)
347
+ self.assertAlmostEqual(result.quantity, 4.184, places=3)
348
+
349
+ def test_joule_to_btu(self):
350
+ """1055.06 J ≈ 1 BTU"""
351
+ result = units.joule(1055.06).to(units.btu)
352
+ self.assertAlmostEqual(result.quantity, 1.0, places=2)
353
+
354
+ def test_btu_to_joule(self):
355
+ """1 BTU ≈ 1055.06 J"""
356
+ result = units.btu(1).to(units.joule)
357
+ self.assertAlmostEqual(result.quantity, 1055.06, places=0)
358
+
359
+ def test_calorie_to_btu(self):
360
+ """252 cal ≈ 1 BTU (multi-hop via joule)"""
361
+ result = units.calorie(252).to(units.btu)
362
+ self.assertAlmostEqual(result.quantity, 1.0, places=1)
363
+
364
+
365
+ class TestBaseSIPowerConversions(unittest.TestCase):
366
+ """Tests for power conversions in the default graph."""
367
+
368
+ def setUp(self):
369
+ reset_default_graph()
370
+
371
+ def test_watt_to_horsepower(self):
372
+ """745.7 W ≈ 1 hp"""
373
+ result = units.watt(745.7).to(units.horsepower)
374
+ self.assertAlmostEqual(result.quantity, 1.0, places=2)
375
+
376
+ def test_horsepower_to_watt(self):
377
+ """1 hp ≈ 745.7 W"""
378
+ result = units.horsepower(1).to(units.watt)
379
+ self.assertAlmostEqual(result.quantity, 745.7, places=0)
380
+
381
+
382
+ class TestInformationConversions(unittest.TestCase):
383
+ """Tests for information unit conversions in the default graph."""
384
+
385
+ def setUp(self):
386
+ reset_default_graph()
387
+
388
+ def test_byte_to_bit(self):
389
+ """1 B = 8 b"""
390
+ result = units.byte(1).to(units.bit)
391
+ self.assertAlmostEqual(result.quantity, 8, places=10)
392
+
393
+ def test_bit_to_byte(self):
394
+ """8 b = 1 B"""
395
+ result = units.bit(8).to(units.byte)
396
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
397
+
398
+ def test_kilobyte_to_bit(self):
399
+ """1 KB = 8000 b (using Scale.kilo)"""
400
+ from ucon.core import Scale
401
+ kilobyte = Scale.kilo * units.byte
402
+ result = kilobyte(1).to(units.bit)
403
+ self.assertAlmostEqual(result.quantity, 8000, places=0)
404
+
405
+
406
+ class TestConversionRoundTrips(unittest.TestCase):
407
+ """Tests verifying round-trip conversion accuracy."""
408
+
409
+ def setUp(self):
410
+ reset_default_graph()
411
+
412
+ def test_length_round_trip(self):
413
+ """meter → foot → meter"""
414
+ original = 42.5
415
+ via_foot = units.meter(original).to(units.foot)
416
+ back = via_foot.to(units.meter)
417
+ self.assertAlmostEqual(back.quantity, original, places=8)
418
+
419
+ def test_mass_round_trip(self):
420
+ """kilogram → pound → kilogram"""
421
+ original = 75.0
422
+ via_pound = units.kilogram(original).to(units.pound)
423
+ back = via_pound.to(units.kilogram)
424
+ self.assertAlmostEqual(back.quantity, original, places=8)
425
+
426
+ def test_time_round_trip(self):
427
+ """second → day → second"""
428
+ original = 172800 # 2 days
429
+ via_day = units.second(original).to(units.day)
430
+ back = via_day.to(units.second)
431
+ self.assertAlmostEqual(back.quantity, original, places=5)
432
+
433
+ def test_pressure_multi_hop_round_trip(self):
434
+ """pascal → atmosphere → bar → pascal"""
435
+ original = 500000
436
+ via_atm = units.pascal(original).to(units.atmosphere)
437
+ via_bar = via_atm.to(units.bar)
438
+ back = via_bar.to(units.pascal)
439
+ self.assertAlmostEqual(back.quantity, original, places=2)
440
+
441
+
442
+ if __name__ == '__main__':
443
+ unittest.main()
@@ -29,25 +29,28 @@ class TestNumber(unittest.TestCase):
29
29
  # Unit should be base gram (Scale.one)
30
30
  self.assertEqual(result.unit.shorthand, "g")
31
31
 
32
- @unittest.skip("Requires ConversionGraph implementation")
33
- def test_to(self):
34
- kg = UnitFactor(dimension=Dimension.mass, name='gram', scale=Scale.kilo)
35
- mg = UnitFactor(dimension=Dimension.mass, name='gram', scale=Scale.milli)
36
- kibigram = UnitFactor(dimension=Dimension.mass, name='gram', scale=Scale.kibi)
37
-
38
- thousandth_of_a_kilogram = Number(unit=kg, quantity=0.001)
39
- thousand_milligrams = Number(unit=mg, quantity=1000)
40
- kibigram_fraction = Number(unit=kibigram, quantity=0.0009765625)
32
+ def test_to_converts_between_units(self):
33
+ """Test Number.to() converts between compatible units."""
34
+ # 1 gram to kilogram
35
+ kg = Scale.kilo * units.gram
36
+ result = self.number.to(kg)
37
+ self.assertAlmostEqual(result.quantity, 0.001, places=10)
38
+
39
+ # 1 gram to milligram
40
+ mg = Scale.milli * units.gram
41
+ result = self.number.to(mg)
42
+ self.assertAlmostEqual(result.quantity, 1000.0, places=10)
41
43
 
42
- self.assertEqual(thousandth_of_a_kilogram, self.number.to(Scale.kilo))
43
- self.assertEqual(thousand_milligrams, self.number.to(Scale.milli))
44
- self.assertEqual(kibigram_fraction, self.number.to(Scale.kibi))
44
+ # 1 kilogram to gram
45
+ one_kg = Number(unit=kg, quantity=1)
46
+ result = one_kg.to(units.gram)
47
+ self.assertAlmostEqual(result.quantity, 1000.0, places=10)
45
48
 
46
- @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
47
49
  def test___repr__(self):
48
- self.assertIn(str(self.number.quantity), str(self.number))
49
- self.assertIn(str(self.number.unit.scale.value.evaluated), str(self.number))
50
- self.assertIn(self.number.unit.shorthand, str(self.number))
50
+ """Test Number repr contains quantity and unit shorthand."""
51
+ repr_str = repr(self.number)
52
+ self.assertIn(str(self.number.quantity), repr_str)
53
+ self.assertIn(self.number.unit.shorthand, repr_str)
51
54
 
52
55
  def test___truediv__(self):
53
56
  dal = Scale.deca * units.gram
@@ -151,32 +154,32 @@ class TestNumberEdgeCases(unittest.TestCase):
151
154
  assert evaluated.unit == units.gram
152
155
  assert abs(evaluated.quantity - 6.238) < 1e-12
153
156
 
154
- @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
155
157
  def test_default_number_is_dimensionless_one(self):
158
+ """Default Number() is dimensionless with quantity=1."""
156
159
  n = Number()
157
160
  self.assertEqual(n.unit, units.none)
158
- self.assertEqual(n.unit.scale, Scale.one)
159
161
  self.assertEqual(n.quantity, 1)
160
162
  self.assertAlmostEqual(n.value, 1.0)
161
163
  self.assertIn("1", repr(n))
162
164
 
163
- @unittest.skip("Requires ConversionGraph implementation")
164
- def test_to_new_scale_changes_value(self):
165
- thousand = UnitFactor(dimension=Dimension.none, name='', scale=Scale.kilo)
166
- n = Number(quantity=1000, unit=thousand)
167
- converted = n.to(Scale.one)
168
- self.assertNotEqual(n.value, converted.value)
169
- self.assertAlmostEqual(converted.value, 1000)
165
+ def test_to_different_scale_changes_quantity(self):
166
+ """Converting to a different scale changes the quantity."""
167
+ km = Scale.kilo * units.meter
168
+ n = Number(quantity=5, unit=km) # 5 km
169
+ converted = n.to(units.meter) # convert to meters
170
+ # quantity changes: 5 km = 5000 m
171
+ self.assertNotEqual(n.quantity, converted.quantity)
172
+ self.assertAlmostEqual(converted.quantity, 5000.0, places=10)
170
173
 
171
- @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
172
- @unittest.skip("Requires ConversionGraph implementation")
173
174
  def test_simplify_uses_value_as_quantity(self):
174
- thousand = UnitFactor(dimension=Dimension.none, name='', scale=Scale.kilo)
175
- n = Number(quantity=2, unit=thousand)
175
+ """Simplify converts scaled quantity to base scale quantity."""
176
+ km = Scale.kilo * units.meter
177
+ n = Number(quantity=2, unit=km) # 2 km
176
178
  simplified = n.simplify()
177
- self.assertEqual(simplified.quantity, n.value)
178
- self.assertNotEqual(simplified.unit.scale, n.unit.scale)
179
- self.assertEqual(simplified.value, n.value)
179
+ # simplified.quantity should be the canonical magnitude (2 * 1000 = 2000)
180
+ self.assertAlmostEqual(simplified.quantity, 2000.0, places=10)
181
+ # canonical magnitude (physical quantity) is preserved
182
+ self.assertAlmostEqual(simplified._canonical_magnitude, n._canonical_magnitude, places=10)
180
183
 
181
184
  def test_multiplication_combines_units_and_quantities(self):
182
185
  n1 = Number(unit=units.joule, quantity=2)
@@ -185,14 +188,13 @@ class TestNumberEdgeCases(unittest.TestCase):
185
188
  self.assertEqual(result.quantity, 6)
186
189
  self.assertEqual(result.unit.dimension, Dimension.energy * Dimension.time)
187
190
 
188
- @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
189
- @unittest.skip("Requires ConversionGraph implementation")
190
191
  def test_division_combines_units_scales_and_quantities(self):
191
- km = UnitFactor('m', name='meter', dimension=Dimension.length, scale=Scale.kilo)
192
- n1 = Number(unit=km, quantity=1000)
192
+ """Division creates composite unit with preserved scales."""
193
+ km = Scale.kilo * units.meter
194
+ n1 = Number(unit=km, quantity=1000) # 1000 km
193
195
  n2 = Number(unit=units.second, quantity=2)
194
196
 
195
- result = n1 / n2 # should yield <500 km/s>
197
+ result = n1 / n2 # should yield <500 km/s>
196
198
 
197
199
  cu = result.unit
198
200
  self.assertIsInstance(cu, UnitProduct)
@@ -203,18 +205,13 @@ class TestNumberEdgeCases(unittest.TestCase):
203
205
  # --- dimension check ---
204
206
  self.assertEqual(cu.dimension, Dimension.velocity)
205
207
 
206
- # --- scale check: km/s should have a kilo-scaled meter in the numerator ---
207
- # find the meter-like unit in the components
208
- meter_like = next(u for u, exp in cu.components.items() if u.dimension == Dimension.length)
209
- self.assertEqual(meter_like.scale, Scale.kilo)
210
- self.assertEqual(cu.components[meter_like], 1) # exponent = 1 in numerator
211
-
212
208
  # --- symbolic shorthand ---
213
209
  self.assertEqual(cu.shorthand, "km/s")
214
210
 
215
- # --- optional canonicalization ---
216
- canonical = result.to(Scale.one)
217
- self.assertAlmostEqual(canonical.quantity, 500000)
211
+ # --- convert to base units (m/s) ---
212
+ m_per_s = units.meter / units.second
213
+ canonical = result.to(m_per_s)
214
+ self.assertAlmostEqual(canonical.quantity, 500000, places=5)
218
215
  self.assertEqual(canonical.unit.shorthand, "m/s")
219
216
 
220
217
  def test_equality_with_non_number_raises_value_error(self):
@@ -465,7 +462,6 @@ class TestNumberSimplify(unittest.TestCase):
465
462
  self.assertAlmostEqual(result.quantity, 0.5, places=10)
466
463
  self.assertEqual(result.unit.shorthand, "g")
467
464
 
468
- @unittest.skip("Requires Dimension.information and units.byte (see user story)")
469
465
  def test_simplify_binary_prefix(self):
470
466
  """2 kibibytes simplifies to 2048 bytes."""
471
467
  kibibyte = Scale.kibi * units.byte
@@ -531,7 +527,6 @@ class TestInformationDimension(unittest.TestCase):
531
527
  result = units.bit(8).to(units.byte)
532
528
  self.assertAlmostEqual(result.quantity, 1.0, places=10)
533
529
 
534
- @unittest.skip("Requires Number.simplify() from ucon#93-numbers-can-be-simplified")
535
530
  def test_kibibyte_simplify(self):
536
531
  """1 kibibyte simplifies to 1024 bytes."""
537
532
  kibibyte = Scale.kibi * units.byte
@@ -539,7 +534,6 @@ class TestInformationDimension(unittest.TestCase):
539
534
  self.assertAlmostEqual(result.quantity, 1024.0, places=10)
540
535
  self.assertEqual(result.unit.shorthand, "B")
541
536
 
542
- @unittest.skip("Requires Number.simplify() from ucon#93-numbers-can-be-simplified")
543
537
  def test_kilobyte_simplify(self):
544
538
  """1 kilobyte simplifies to 1000 bytes."""
545
539
  kilobyte = Scale.kilo * units.byte
ucon/graph.py CHANGED
@@ -399,6 +399,14 @@ def _build_standard_graph() -> ConversionGraph:
399
399
  # F → C: C = (F - 32) * 5/9
400
400
  graph.add_edge(src=units.fahrenheit, dst=units.celsius, map=AffineMap(5/9, -32 * 5/9))
401
401
 
402
+ # --- Pressure ---
403
+ # 1 Pa = 0.00001 bar, so 1 bar = 100000 Pa
404
+ graph.add_edge(src=units.pascal, dst=units.bar, map=LinearMap(1/100000))
405
+ # 1 Pa = 0.000145038 psi
406
+ graph.add_edge(src=units.pascal, dst=units.psi, map=LinearMap(0.000145038))
407
+ # 1 atm = 101325 Pa
408
+ graph.add_edge(src=units.atmosphere, dst=units.pascal, map=LinearMap(101325))
409
+
402
410
  # --- Volume ---
403
411
  graph.add_edge(src=units.liter, dst=units.gallon, map=LinearMap(0.264172))
404
412
 
ucon/units.py CHANGED
@@ -99,6 +99,11 @@ btu = Unit(name='btu', dimension=Dimension.energy, aliases=('BTU',))
99
99
 
100
100
  # Power
101
101
  horsepower = Unit(name='horsepower', dimension=Dimension.power, aliases=('hp',))
102
+
103
+ # Pressure
104
+ bar = Unit(name='bar', dimension=Dimension.pressure, aliases=('bar',))
105
+ psi = Unit(name='psi', dimension=Dimension.pressure, aliases=('lbf/in²',))
106
+ atmosphere = Unit(name='atmosphere', dimension=Dimension.pressure, aliases=('atm',))
102
107
  # ----------------------------------------------------------------------
103
108
 
104
109
 
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucon
3
- Version: 0.4.0
3
+ Version: 0.4.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
7
7
  Maintainer: Emmanuel I. Obi
8
8
  Maintainer-email: withtwoemms@gmail.com
9
9
  License: Apache-2.0
10
- Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Intended Audience :: Education
13
13
  Classifier: Intended Audience :: Science/Research
@@ -0,0 +1,22 @@
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=7rFg4cpdmGV7vyCwg72Bt4EFBgkyM3cPFoRbRn0bqEI,21943
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=o3Q4posUOYoIhQVHl6bANCIcGKgGOpNZsnqGZw9ujYk,41523
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.1.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
18
+ ucon-0.4.1.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
19
+ ucon-0.4.1.dist-info/METADATA,sha256=bHAexnROmQ4BUaxkY83fLe6ptkCGwt7aVEckFrflg3U,12348
20
+ ucon-0.4.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
21
+ ucon-0.4.1.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
22
+ ucon-0.4.1.dist-info/RECORD,,
@@ -1,21 +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=NqmyKMkrF3T4ekucsFzVsxbkErdB6assbkhBAYH2fug,32664
4
- tests/ucon/test_quantity.py,sha256=qJ-dXOPgIp0SKelASu45IC6KquTjlcRwHVkSKicsrXw,22754
5
- tests/ucon/test_units.py,sha256=SILymDtDNDyxEhkYQubrfkakKCMexwEwjyHfhrkDrMI,869
6
- tests/ucon/conversion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- tests/ucon/conversion/test_graph.py,sha256=R3u-0FCpgffoUXvJmT7aBJCUDUpzmBYROh5I0WLHA_Y,7256
8
- tests/ucon/conversion/test_map.py,sha256=2_bxXCuga-lTGxU5rGferiziJmjzrltksA-6JJseEQ0,5359
9
- ucon/__init__.py,sha256=jaFcNFZC5gxHKzM8OkS1pLxltp6ToLRVpuuhJQY9FKQ,2000
10
- ucon/algebra.py,sha256=wGl4jJVMd8SXQ4sYDBOxV00ymAzRWfDhea1o4t2kVp4,7482
11
- ucon/core.py,sha256=o3Q4posUOYoIhQVHl6bANCIcGKgGOpNZsnqGZw9ujYk,41523
12
- ucon/graph.py,sha256=lgueyVdBI73pgqsydwRtOY9sxt02G-gy8-Lf7993RNw,14224
13
- ucon/maps.py,sha256=yyZ7RqnohO2joTUvvKh40in7E6SKMQIQ8jkECO0-_NA,4753
14
- ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
15
- ucon/units.py,sha256=PJFAqUoEq_0--Zo7JDHezZBPHPbAlS_5eArIVCLemxA,5852
16
- ucon-0.4.0.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
17
- ucon-0.4.0.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
18
- ucon-0.4.0.dist-info/METADATA,sha256=_hZdjrgY1lvorBac-JPQQVzNVuNet-QY2zPP0G8-hik,12349
19
- ucon-0.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
- ucon-0.4.0.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
21
- ucon-0.4.0.dist-info/RECORD,,
File without changes