ucon 0.3.5rc2__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.
@@ -19,38 +19,38 @@ class TestNumber(unittest.TestCase):
19
19
  self.assertEqual(ratio.numerator, self.number)
20
20
  self.assertEqual(ratio.denominator, Number())
21
21
 
22
- @unittest.skip("Requires ConversionGraph implementation")
23
- def test_simplify(self):
24
- decagram = UnitFactor(dimension=Dimension.mass, name='gram', scale=Scale.deca)
25
- kibigram = UnitFactor(dimension=Dimension.mass, name='gram', scale=Scale.kibi)
26
-
22
+ def test_simplify_scaled_unit(self):
23
+ """Test simplify() removes scale prefix and adjusts quantity."""
24
+ decagram = Scale.deca * units.gram
27
25
  ten_decagrams = Number(unit=decagram, quantity=10)
28
- point_one_decagrams = Number(unit=decagram, quantity=0.1)
29
- two_kibigrams = Number(unit=kibigram, quantity=2)
30
-
31
- self.assertEqual(Number(unit=units.gram, quantity=100), ten_decagrams.simplify())
32
- self.assertEqual(Number(unit=units.gram, quantity=1), point_one_decagrams.simplify())
33
- self.assertEqual(Number(unit=units.gram, quantity=2048), two_kibigrams.simplify())
34
-
35
- @unittest.skip("Requires ConversionGraph implementation")
36
- def test_to(self):
37
- kg = UnitFactor(dimension=Dimension.mass, name='gram', scale=Scale.kilo)
38
- mg = UnitFactor(dimension=Dimension.mass, name='gram', scale=Scale.milli)
39
- kibigram = UnitFactor(dimension=Dimension.mass, name='gram', scale=Scale.kibi)
40
-
41
- thousandth_of_a_kilogram = Number(unit=kg, quantity=0.001)
42
- thousand_milligrams = Number(unit=mg, quantity=1000)
43
- kibigram_fraction = Number(unit=kibigram, quantity=0.0009765625)
26
+ result = ten_decagrams.simplify()
27
+ # 10 decagrams = 100 grams
28
+ self.assertAlmostEqual(result.quantity, 100.0, places=10)
29
+ # Unit should be base gram (Scale.one)
30
+ self.assertEqual(result.unit.shorthand, "g")
31
+
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)
44
43
 
45
- self.assertEqual(thousandth_of_a_kilogram, self.number.to(Scale.kilo))
46
- self.assertEqual(thousand_milligrams, self.number.to(Scale.milli))
47
- 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)
48
48
 
49
- @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
50
49
  def test___repr__(self):
51
- self.assertIn(str(self.number.quantity), str(self.number))
52
- self.assertIn(str(self.number.unit.scale.value.evaluated), str(self.number))
53
- 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)
54
54
 
55
55
  def test___truediv__(self):
56
56
  dal = Scale.deca * units.gram
@@ -154,32 +154,32 @@ class TestNumberEdgeCases(unittest.TestCase):
154
154
  assert evaluated.unit == units.gram
155
155
  assert abs(evaluated.quantity - 6.238) < 1e-12
156
156
 
157
- @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
158
157
  def test_default_number_is_dimensionless_one(self):
158
+ """Default Number() is dimensionless with quantity=1."""
159
159
  n = Number()
160
160
  self.assertEqual(n.unit, units.none)
161
- self.assertEqual(n.unit.scale, Scale.one)
162
161
  self.assertEqual(n.quantity, 1)
163
162
  self.assertAlmostEqual(n.value, 1.0)
164
163
  self.assertIn("1", repr(n))
165
164
 
166
- @unittest.skip("Requires ConversionGraph implementation")
167
- def test_to_new_scale_changes_value(self):
168
- thousand = UnitFactor(dimension=Dimension.none, name='', scale=Scale.kilo)
169
- n = Number(quantity=1000, unit=thousand)
170
- converted = n.to(Scale.one)
171
- self.assertNotEqual(n.value, converted.value)
172
- 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)
173
173
 
174
- @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
175
- @unittest.skip("Requires ConversionGraph implementation")
176
174
  def test_simplify_uses_value_as_quantity(self):
177
- thousand = UnitFactor(dimension=Dimension.none, name='', scale=Scale.kilo)
178
- 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
179
178
  simplified = n.simplify()
180
- self.assertEqual(simplified.quantity, n.value)
181
- self.assertNotEqual(simplified.unit.scale, n.unit.scale)
182
- 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)
183
183
 
184
184
  def test_multiplication_combines_units_and_quantities(self):
185
185
  n1 = Number(unit=units.joule, quantity=2)
@@ -188,14 +188,13 @@ class TestNumberEdgeCases(unittest.TestCase):
188
188
  self.assertEqual(result.quantity, 6)
189
189
  self.assertEqual(result.unit.dimension, Dimension.energy * Dimension.time)
190
190
 
191
- @unittest.skip("TODO: revamp: Unit.scale is deprecated.")
192
- @unittest.skip("Requires ConversionGraph implementation")
193
191
  def test_division_combines_units_scales_and_quantities(self):
194
- km = UnitFactor('m', name='meter', dimension=Dimension.length, scale=Scale.kilo)
195
- 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
196
195
  n2 = Number(unit=units.second, quantity=2)
197
196
 
198
- result = n1 / n2 # should yield <500 km/s>
197
+ result = n1 / n2 # should yield <500 km/s>
199
198
 
200
199
  cu = result.unit
201
200
  self.assertIsInstance(cu, UnitProduct)
@@ -206,18 +205,13 @@ class TestNumberEdgeCases(unittest.TestCase):
206
205
  # --- dimension check ---
207
206
  self.assertEqual(cu.dimension, Dimension.velocity)
208
207
 
209
- # --- scale check: km/s should have a kilo-scaled meter in the numerator ---
210
- # find the meter-like unit in the components
211
- meter_like = next(u for u, exp in cu.components.items() if u.dimension == Dimension.length)
212
- self.assertEqual(meter_like.scale, Scale.kilo)
213
- self.assertEqual(cu.components[meter_like], 1) # exponent = 1 in numerator
214
-
215
208
  # --- symbolic shorthand ---
216
209
  self.assertEqual(cu.shorthand, "km/s")
217
210
 
218
- # --- optional canonicalization ---
219
- canonical = result.to(Scale.one)
220
- 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)
221
215
  self.assertEqual(canonical.unit.shorthand, "m/s")
222
216
 
223
217
  def test_equality_with_non_number_raises_value_error(self):
@@ -232,7 +226,7 @@ class TestNumberEdgeCases(unittest.TestCase):
232
226
  self.assertTrue(r == Number())
233
227
 
234
228
  def test_repr_includes_scale_and_unit(self):
235
- kV = Scale.kilo * Unit('V', name='volt', dimension=Dimension.voltage)
229
+ kV = Scale.kilo * Unit(name='volt', dimension=Dimension.voltage, aliases=('V',))
236
230
  n = Number(unit=kV, quantity=5)
237
231
  rep = repr(n)
238
232
  self.assertIn("kV", rep)
@@ -269,7 +263,7 @@ class TestRatio(unittest.TestCase):
269
263
  self.assertEqual(self.one_half * self.three_halves, self.three_fourths)
270
264
 
271
265
  def test___mul__(self):
272
- mL = Scale.milli * Unit('L', name='liter', dimension=Dimension.volume)
266
+ mL = Scale.milli * Unit(name='liter', dimension=Dimension.volume, aliases=('L',))
273
267
  n1 = Number(unit=units.gram, quantity=3.119)
274
268
  n2 = Number(unit=mL)
275
269
  bromine_density = Ratio(n1, n2)
@@ -289,7 +283,7 @@ class TestRatio(unittest.TestCase):
289
283
 
290
284
  # How many Wh from 20 kJ?
291
285
  twenty_kilojoules = Number(
292
- unit=Scale.kilo * Unit('J', name='joule', dimension=Dimension.energy),
286
+ unit=Scale.kilo * Unit(name='joule', dimension=Dimension.energy, aliases=('J',)),
293
287
  quantity=20
294
288
  )
295
289
  ratio = twenty_kilojoules.as_ratio() / seconds_per_hour
@@ -368,3 +362,194 @@ class TestRatioEdgeCases(unittest.TestCase):
368
362
  r = Ratio(n1, n2)
369
363
  rep = repr(r)
370
364
  self.assertIn("/", rep)
365
+
366
+
367
+ class TestCallableUnits(unittest.TestCase):
368
+ """Tests for the callable unit syntax: unit(quantity) -> Number."""
369
+
370
+ def test_unit_callable_returns_number(self):
371
+ result = units.meter(5)
372
+ self.assertIsInstance(result, Number)
373
+ self.assertEqual(result.quantity, 5)
374
+
375
+ def test_unit_callable_shorthand(self):
376
+ result = units.meter(5)
377
+ self.assertIn("m", result.unit.shorthand)
378
+
379
+ def test_unit_product_callable_returns_number(self):
380
+ velocity = units.meter / units.second
381
+ result = velocity(10)
382
+ self.assertIsInstance(result, Number)
383
+ self.assertEqual(result.quantity, 10)
384
+ self.assertEqual(result.unit.dimension, Dimension.velocity)
385
+
386
+ def test_scaled_unit_callable_returns_number(self):
387
+ km = Scale.kilo * units.meter
388
+ result = km(5)
389
+ self.assertIsInstance(result, Number)
390
+ self.assertEqual(result.quantity, 5)
391
+ self.assertIn("km", result.unit.shorthand)
392
+
393
+ def test_composite_scaled_unit_callable(self):
394
+ mph = units.mile / units.hour
395
+ result = mph(60)
396
+ self.assertIsInstance(result, Number)
397
+ self.assertEqual(result.quantity, 60)
398
+
399
+
400
+ class TestScaledUnitConversion(unittest.TestCase):
401
+ """Tests for conversions involving scaled units.
402
+
403
+ Regression tests for bug where scale was applied twice during conversion.
404
+ """
405
+
406
+ def test_km_to_mile_conversion(self):
407
+ """5 km should be approximately 3.10686 miles."""
408
+ km = Scale.kilo * units.meter
409
+ result = km(5).to(units.mile)
410
+ # 5 km = 5000 m = 5000 / 1609.34 miles ≈ 3.10686
411
+ self.assertAlmostEqual(result.quantity, 3.10686, places=4)
412
+
413
+ def test_km_to_meter_conversion(self):
414
+ """1 km should be 1000 meters."""
415
+ km = Scale.kilo * units.meter
416
+ result = km(1).to(units.meter)
417
+ self.assertAlmostEqual(result.quantity, 1000.0, places=6)
418
+
419
+ def test_meter_to_mm_conversion(self):
420
+ """1 meter should be 1000 millimeters."""
421
+ mm = Scale.milli * units.meter
422
+ result = units.meter(1).to(mm)
423
+ self.assertAlmostEqual(result.quantity, 1000.0, places=6)
424
+
425
+ def test_mm_to_inch_conversion(self):
426
+ """25.4 mm should be approximately 1 inch."""
427
+ mm = Scale.milli * units.meter
428
+ result = mm(25.4).to(units.inch)
429
+ self.assertAlmostEqual(result.quantity, 1.0, places=4)
430
+
431
+ def test_scaled_velocity_conversion(self):
432
+ """1 km/h should be approximately 0.27778 m/s."""
433
+ km_per_h = (Scale.kilo * units.meter) / units.hour
434
+ m_per_s = units.meter / units.second
435
+ result = km_per_h(1).to(m_per_s)
436
+ # 1 km/h = 1000m / 3600s = 0.27778 m/s
437
+ self.assertAlmostEqual(result.quantity, 0.27778, places=4)
438
+
439
+ def test_mph_to_m_per_s_conversion(self):
440
+ """60 mph should be approximately 26.8224 m/s."""
441
+ mph = units.mile / units.hour
442
+ m_per_s = units.meter / units.second
443
+ result = mph(60).to(m_per_s)
444
+ # 60 mph = 60 * 1609.34 / 3600 m/s ≈ 26.8224
445
+ self.assertAlmostEqual(result.quantity, 26.8224, places=2)
446
+
447
+
448
+ class TestNumberSimplify(unittest.TestCase):
449
+ """Tests for Number.simplify() method."""
450
+
451
+ def test_simplify_kilo_prefix(self):
452
+ """5 km simplifies to 5000 m."""
453
+ km = Scale.kilo * units.meter
454
+ result = km(5).simplify()
455
+ self.assertAlmostEqual(result.quantity, 5000.0, places=10)
456
+ self.assertEqual(result.unit.shorthand, "m")
457
+
458
+ def test_simplify_milli_prefix(self):
459
+ """500 mg simplifies to 0.5 g."""
460
+ mg = Scale.milli * units.gram
461
+ result = mg(500).simplify()
462
+ self.assertAlmostEqual(result.quantity, 0.5, places=10)
463
+ self.assertEqual(result.unit.shorthand, "g")
464
+
465
+ def test_simplify_binary_prefix(self):
466
+ """2 kibibytes simplifies to 2048 bytes."""
467
+ kibibyte = Scale.kibi * units.byte
468
+ result = kibibyte(2).simplify()
469
+ self.assertAlmostEqual(result.quantity, 2048.0, places=10)
470
+ self.assertEqual(result.unit.shorthand, "B")
471
+
472
+ def test_simplify_composite_unit(self):
473
+ """1 km/h simplifies to base scales."""
474
+ km_per_h = (Scale.kilo * units.meter) / units.hour
475
+ result = km_per_h(1).simplify()
476
+ # 1 km/h = 1000 m / 1 h (hour stays hour since it's base unit)
477
+ self.assertAlmostEqual(result.quantity, 1000.0, places=10)
478
+ self.assertEqual(result.unit.shorthand, "m/h")
479
+
480
+ def test_simplify_plain_unit_unchanged(self):
481
+ """Plain unit without scale returns equivalent Number."""
482
+ result = units.meter(5).simplify()
483
+ self.assertAlmostEqual(result.quantity, 5.0, places=10)
484
+ self.assertEqual(result.unit.shorthand, "m")
485
+
486
+ def test_simplify_preserves_dimension(self):
487
+ """Simplified Number has same dimension."""
488
+ km = Scale.kilo * units.meter
489
+ original = km(5)
490
+ simplified = original.simplify()
491
+ self.assertEqual(original.unit.dimension, simplified.unit.dimension)
492
+
493
+ def test_simplify_idempotent(self):
494
+ """Simplifying twice gives same result."""
495
+ km = Scale.kilo * units.meter
496
+ result1 = km(5).simplify()
497
+ result2 = result1.simplify()
498
+ self.assertAlmostEqual(result1.quantity, result2.quantity, places=10)
499
+ self.assertEqual(result1.unit.shorthand, result2.unit.shorthand)
500
+
501
+
502
+ class TestInformationDimension(unittest.TestCase):
503
+ """Tests for Dimension.information and information units (bit, byte)."""
504
+
505
+ def test_dimension_information_exists(self):
506
+ """Dimension.information should be a valid dimension."""
507
+ self.assertEqual(Dimension.information.name, 'information')
508
+ self.assertNotEqual(Dimension.information, Dimension.none)
509
+
510
+ def test_bit_unit_exists(self):
511
+ """units.bit should have Dimension.information."""
512
+ self.assertEqual(units.bit.dimension, Dimension.information)
513
+ self.assertIn('b', units.bit.aliases)
514
+
515
+ def test_byte_unit_exists(self):
516
+ """units.byte should have Dimension.information."""
517
+ self.assertEqual(units.byte.dimension, Dimension.information)
518
+ self.assertIn('B', units.byte.aliases)
519
+
520
+ def test_byte_to_bit_conversion(self):
521
+ """1 byte should be 8 bits."""
522
+ result = units.byte(1).to(units.bit)
523
+ self.assertAlmostEqual(result.quantity, 8.0, places=10)
524
+
525
+ def test_bit_to_byte_conversion(self):
526
+ """8 bits should be 1 byte."""
527
+ result = units.bit(8).to(units.byte)
528
+ self.assertAlmostEqual(result.quantity, 1.0, places=10)
529
+
530
+ def test_kibibyte_simplify(self):
531
+ """1 kibibyte simplifies to 1024 bytes."""
532
+ kibibyte = Scale.kibi * units.byte
533
+ result = kibibyte(1).simplify()
534
+ self.assertAlmostEqual(result.quantity, 1024.0, places=10)
535
+ self.assertEqual(result.unit.shorthand, "B")
536
+
537
+ def test_kilobyte_simplify(self):
538
+ """1 kilobyte simplifies to 1000 bytes."""
539
+ kilobyte = Scale.kilo * units.byte
540
+ result = kilobyte(1).simplify()
541
+ self.assertAlmostEqual(result.quantity, 1000.0, places=10)
542
+ self.assertEqual(result.unit.shorthand, "B")
543
+
544
+ def test_data_rate_dimension(self):
545
+ """bytes/second should have information/time dimension."""
546
+ data_rate = units.byte / units.second
547
+ expected_dim = Dimension.information / Dimension.time
548
+ self.assertEqual(data_rate.dimension, expected_dim)
549
+
550
+ def test_information_orthogonal_to_physical(self):
551
+ """Information dimension should be orthogonal to physical dimensions."""
552
+ # byte * meter should have both information and length
553
+ composite = units.byte * units.meter
554
+ self.assertNotEqual(composite.dimension, Dimension.information)
555
+ self.assertNotEqual(composite.dimension, Dimension.length)
ucon/__init__.py CHANGED
@@ -38,8 +38,8 @@ Design Philosophy
38
38
  """
39
39
  from ucon import units
40
40
  from ucon.algebra import Exponent
41
- from ucon.core import Dimension, Scale, Unit
42
- from ucon.quantity import Number, Ratio
41
+ from ucon.core import Dimension, Scale, Unit, UnitFactor, UnitProduct, Number, Ratio
42
+ from ucon.graph import get_default_graph, using_graph
43
43
 
44
44
 
45
45
  __all__ = [
@@ -49,5 +49,9 @@ __all__ = [
49
49
  'Ratio',
50
50
  'Scale',
51
51
  'Unit',
52
+ 'UnitFactor',
53
+ 'UnitProduct',
54
+ 'get_default_graph',
55
+ 'using_graph',
52
56
  'units',
53
57
  ]
ucon/algebra.py CHANGED
@@ -34,18 +34,20 @@ class Vector:
34
34
  """
35
35
  Represents the **exponent vector** of a physical quantity.
36
36
 
37
- Each component corresponds to the power of a base dimension in the SI system:
37
+ Each component corresponds to the power of a base dimension in the SI system
38
+ plus information (B) as an orthogonal non-SI dimension:
38
39
  time (T), length (L), mass (M), current (I), temperature (Θ),
39
- luminous intensity (J), and amount of substance (N).
40
+ luminous intensity (J), amount of substance (N), and information (B).
40
41
 
41
42
  Arithmetic operations correspond to dimensional composition:
42
43
  - Addition (`+`) → multiplication of quantities
43
44
  - Subtraction (`-`) → division of quantities
44
45
 
45
46
  e.g.
46
- Vector(T=1, L=0, M=0, I=0, Θ=0, J=0, N=0) => "time"
47
- Vector(T=0, L=2, M=0, I=0, Θ=0, J=0, N=0) => "area"
48
- Vector(T=-2, L=1, M=1, I=0, Θ=0, J=0, N=0) => "force"
47
+ Vector(T=1, L=0, M=0, I=0, Θ=0, J=0, N=0, B=0) => "time"
48
+ Vector(T=0, L=2, M=0, I=0, Θ=0, J=0, N=0, B=0) => "area"
49
+ Vector(T=-2, L=1, M=1, I=0, Θ=0, J=0, N=0, B=0) => "force"
50
+ Vector(T=0, L=0, M=0, I=0, Θ=0, J=0, N=0, B=1) => "information"
49
51
  """
50
52
  T: int = 0 # time
51
53
  L: int = 0 # length
@@ -54,6 +56,7 @@ class Vector:
54
56
  Θ: int = 0 # temperature
55
57
  J: int = 0 # luminous intensity
56
58
  N: int = 0 # amount of substance
59
+ B: int = 0 # information (bits)
57
60
 
58
61
  def __iter__(self) -> Iterator[int]:
59
62
  yield self.T
@@ -63,6 +66,7 @@ class Vector:
63
66
  yield self.Θ
64
67
  yield self.J
65
68
  yield self.N
69
+ yield self.B
66
70
 
67
71
  def __len__(self) -> int:
68
72
  return sum(tuple(1 for x in self))