ucon 0.4.1__py3-none-any.whl → 0.5.0__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.
@@ -71,10 +71,10 @@ class TestVectorEdgeCases(TestCase):
71
71
 
72
72
  def test_vector_equality_with_non_vector(self):
73
73
  v = Vector()
74
- with self.assertRaises(AssertionError):
75
- v == "not a vector"
76
- with self.assertRaises(AssertionError):
77
- v == None
74
+ # Non-Vector comparisons return NotImplemented, which Python
75
+ # resolves to False (not equal) rather than raising an error
76
+ self.assertFalse(v == "not a vector")
77
+ self.assertFalse(v == None)
78
78
 
79
79
  def test_hash_consistency_for_equal_vectors(self):
80
80
  v1 = Vector(1, 0, 0, 0, 0, 0, 0, 0)
@@ -0,0 +1,248 @@
1
+ # © 2026 The Radiativity Company
2
+ # Licensed under the Apache License, Version 2.0
3
+ # See the LICENSE file for details.
4
+
5
+ """
6
+ Tests for v0.5.0 dimensionless units (pseudo-dimensions).
7
+
8
+ Tests pseudo-dimension isolation, angle/solid-angle/ratio unit conversions,
9
+ and cross-pseudo-dimension conversion failure.
10
+ """
11
+
12
+ import math
13
+ import unittest
14
+
15
+ from ucon import units
16
+ from ucon.core import Dimension, Vector
17
+ from ucon.graph import ConversionNotFound
18
+
19
+
20
+ class TestPseudoDimensionIsolation(unittest.TestCase):
21
+ """Test that pseudo-dimensions are semantically isolated."""
22
+
23
+ def test_angle_not_equal_to_none(self):
24
+ self.assertNotEqual(Dimension.angle, Dimension.none)
25
+
26
+ def test_solid_angle_not_equal_to_none(self):
27
+ self.assertNotEqual(Dimension.solid_angle, Dimension.none)
28
+
29
+ def test_ratio_not_equal_to_none(self):
30
+ self.assertNotEqual(Dimension.ratio, Dimension.none)
31
+
32
+ def test_angle_not_equal_to_solid_angle(self):
33
+ self.assertNotEqual(Dimension.angle, Dimension.solid_angle)
34
+
35
+ def test_angle_not_equal_to_ratio(self):
36
+ self.assertNotEqual(Dimension.angle, Dimension.ratio)
37
+
38
+ def test_solid_angle_not_equal_to_ratio(self):
39
+ self.assertNotEqual(Dimension.solid_angle, Dimension.ratio)
40
+
41
+ def test_angle_equal_to_itself(self):
42
+ self.assertEqual(Dimension.angle, Dimension.angle)
43
+
44
+ def test_none_equal_to_itself(self):
45
+ self.assertEqual(Dimension.none, Dimension.none)
46
+
47
+ def test_all_pseudo_dimensions_have_zero_vector(self):
48
+ zero = Vector()
49
+ self.assertEqual(Dimension.none.vector, zero)
50
+ self.assertEqual(Dimension.angle.vector, zero)
51
+ self.assertEqual(Dimension.solid_angle.vector, zero)
52
+ self.assertEqual(Dimension.ratio.vector, zero)
53
+
54
+
55
+ class TestPseudoDimensionHashing(unittest.TestCase):
56
+ """Test that pseudo-dimensions can coexist in sets and dicts."""
57
+
58
+ def test_all_pseudo_dimensions_in_set(self):
59
+ dims = {Dimension.none, Dimension.angle, Dimension.solid_angle, Dimension.ratio}
60
+ self.assertEqual(len(dims), 4)
61
+
62
+ def test_all_pseudo_dimensions_as_dict_keys(self):
63
+ d = {
64
+ Dimension.none: "none",
65
+ Dimension.angle: "angle",
66
+ Dimension.solid_angle: "solid_angle",
67
+ Dimension.ratio: "ratio",
68
+ }
69
+ self.assertEqual(len(d), 4)
70
+ self.assertEqual(d[Dimension.angle], "angle")
71
+ self.assertEqual(d[Dimension.ratio], "ratio")
72
+
73
+ def test_distinct_hashes(self):
74
+ hashes = {
75
+ hash(Dimension.none),
76
+ hash(Dimension.angle),
77
+ hash(Dimension.solid_angle),
78
+ hash(Dimension.ratio),
79
+ }
80
+ self.assertEqual(len(hashes), 4)
81
+
82
+
83
+ class TestAlgebraicResolution(unittest.TestCase):
84
+ """Test that algebraic operations resolve to none, not pseudo-dimensions."""
85
+
86
+ def test_length_divided_by_length_is_none(self):
87
+ result = Dimension.length / Dimension.length
88
+ self.assertEqual(result, Dimension.none)
89
+ self.assertIs(result, Dimension.none)
90
+
91
+ def test_energy_divided_by_energy_is_none(self):
92
+ result = Dimension.energy / Dimension.energy
93
+ self.assertEqual(result, Dimension.none)
94
+ self.assertIs(result, Dimension.none)
95
+
96
+ def test_angle_times_length_is_length(self):
97
+ # Since angle has zero vector, angle * length = length
98
+ result = Dimension.angle * Dimension.length
99
+ self.assertEqual(result, Dimension.length)
100
+
101
+
102
+ class TestUnitDimensions(unittest.TestCase):
103
+ """Test that units have correct dimensions."""
104
+
105
+ def test_radian_is_angle(self):
106
+ self.assertEqual(units.radian.dimension, Dimension.angle)
107
+
108
+ def test_degree_is_angle(self):
109
+ self.assertEqual(units.degree.dimension, Dimension.angle)
110
+
111
+ def test_steradian_is_solid_angle(self):
112
+ self.assertEqual(units.steradian.dimension, Dimension.solid_angle)
113
+
114
+ def test_square_degree_is_solid_angle(self):
115
+ self.assertEqual(units.square_degree.dimension, Dimension.solid_angle)
116
+
117
+ def test_percent_is_ratio(self):
118
+ self.assertEqual(units.percent.dimension, Dimension.ratio)
119
+
120
+ def test_ppm_is_ratio(self):
121
+ self.assertEqual(units.ppm.dimension, Dimension.ratio)
122
+
123
+
124
+ class TestAngleConversions(unittest.TestCase):
125
+ """Test angle unit conversions."""
126
+
127
+ def test_radian_to_degree(self):
128
+ angle = units.radian(math.pi)
129
+ result = angle.to(units.degree)
130
+ self.assertAlmostEqual(result.value, 180, places=9)
131
+
132
+ def test_degree_to_radian(self):
133
+ angle = units.degree(90)
134
+ result = angle.to(units.radian)
135
+ self.assertAlmostEqual(result.value, math.pi / 2, places=9)
136
+
137
+ def test_turn_to_degree(self):
138
+ angle = units.turn(1)
139
+ result = angle.to(units.degree)
140
+ self.assertAlmostEqual(result.value, 360, places=9)
141
+
142
+ def test_turn_to_radian(self):
143
+ angle = units.turn(1)
144
+ result = angle.to(units.radian)
145
+ self.assertAlmostEqual(result.value, 2 * math.pi, places=9)
146
+
147
+ def test_turn_to_gradian(self):
148
+ angle = units.turn(1)
149
+ result = angle.to(units.gradian)
150
+ self.assertAlmostEqual(result.value, 400, places=9)
151
+
152
+ def test_degree_to_arcminute(self):
153
+ angle = units.degree(1)
154
+ result = angle.to(units.arcminute)
155
+ self.assertAlmostEqual(result.value, 60, places=9)
156
+
157
+ def test_arcminute_to_arcsecond(self):
158
+ angle = units.arcminute(1)
159
+ result = angle.to(units.arcsecond)
160
+ self.assertAlmostEqual(result.value, 60, places=9)
161
+
162
+ def test_degree_to_arcsecond_composed(self):
163
+ angle = units.degree(1)
164
+ result = angle.to(units.arcsecond)
165
+ self.assertAlmostEqual(result.value, 3600, places=9)
166
+
167
+
168
+ class TestSolidAngleConversions(unittest.TestCase):
169
+ """Test solid angle unit conversions."""
170
+
171
+ def test_steradian_to_square_degree(self):
172
+ solid = units.steradian(1)
173
+ result = solid.to(units.square_degree)
174
+ expected = (180 / math.pi) ** 2
175
+ self.assertAlmostEqual(result.value, expected, places=1)
176
+
177
+ def test_square_degree_to_steradian(self):
178
+ solid = units.square_degree(1)
179
+ result = solid.to(units.steradian)
180
+ expected = (math.pi / 180) ** 2
181
+ self.assertAlmostEqual(result.value, expected, places=9)
182
+
183
+
184
+ class TestRatioConversions(unittest.TestCase):
185
+ """Test ratio unit conversions."""
186
+
187
+ def test_one_to_percent(self):
188
+ r = units.ratio_one(0.5)
189
+ result = r.to(units.percent)
190
+ self.assertAlmostEqual(result.value, 50, places=9)
191
+
192
+ def test_percent_to_one(self):
193
+ r = units.percent(25)
194
+ result = r.to(units.ratio_one)
195
+ self.assertAlmostEqual(result.value, 0.25, places=9)
196
+
197
+ def test_one_to_ppm(self):
198
+ r = units.ratio_one(0.001)
199
+ result = r.to(units.ppm)
200
+ self.assertAlmostEqual(result.value, 1000, places=9)
201
+
202
+ def test_ppm_to_ppb(self):
203
+ r = units.ppm(1)
204
+ result = r.to(units.ppb)
205
+ self.assertAlmostEqual(result.value, 1000, places=9)
206
+
207
+ def test_one_to_permille(self):
208
+ r = units.ratio_one(0.005)
209
+ result = r.to(units.permille)
210
+ self.assertAlmostEqual(result.value, 5, places=9)
211
+
212
+ def test_basis_point_to_percent(self):
213
+ r = units.basis_point(100)
214
+ result = r.to(units.percent)
215
+ self.assertAlmostEqual(result.value, 1, places=9)
216
+
217
+ def test_percent_to_basis_point(self):
218
+ r = units.percent(0.25)
219
+ result = r.to(units.basis_point)
220
+ self.assertAlmostEqual(result.value, 25, places=9)
221
+
222
+
223
+ class TestCrossPseudoDimensionFails(unittest.TestCase):
224
+ """Test that cross-pseudo-dimension conversions fail."""
225
+
226
+ def test_radian_to_percent_fails(self):
227
+ with self.assertRaises(ConversionNotFound):
228
+ units.radian(1).to(units.percent)
229
+
230
+ def test_percent_to_degree_fails(self):
231
+ with self.assertRaises(ConversionNotFound):
232
+ units.percent(50).to(units.degree)
233
+
234
+ def test_radian_to_steradian_fails(self):
235
+ with self.assertRaises(ConversionNotFound):
236
+ units.radian(1).to(units.steradian)
237
+
238
+ def test_steradian_to_percent_fails(self):
239
+ with self.assertRaises(ConversionNotFound):
240
+ units.steradian(1).to(units.percent)
241
+
242
+ def test_ppm_to_arcminute_fails(self):
243
+ with self.assertRaises(ConversionNotFound):
244
+ units.ppm(1000).to(units.arcminute)
245
+
246
+
247
+ if __name__ == "__main__":
248
+ unittest.main()
@@ -364,6 +364,66 @@ class TestRatioEdgeCases(unittest.TestCase):
364
364
  self.assertIn("/", rep)
365
365
 
366
366
 
367
+ class TestRatioExponentScaling(unittest.TestCase):
368
+ """Tests for Ratio.evaluate() using Exponent-based scaling.
369
+
370
+ Ensures Ratio.evaluate() behaves consistently with Number.__truediv__
371
+ when units cancel to dimensionless results.
372
+ """
373
+
374
+ def test_evaluate_dimensionless_with_different_scales(self):
375
+ """Ratio of same unit with different scales should fold scales."""
376
+ kg = Scale.kilo * units.gram
377
+ # 500 g / 1 kg = 0.5 (dimensionless)
378
+ ratio = Ratio(units.gram(500), kg(1))
379
+ result = ratio.evaluate()
380
+ self.assertAlmostEqual(result.quantity, 0.5, places=10)
381
+ self.assertEqual(result.unit.dimension, Dimension.none)
382
+
383
+ def test_evaluate_matches_number_truediv(self):
384
+ """Ratio.evaluate() should match Number.__truediv__ for dimensionless."""
385
+ kg = Scale.kilo * units.gram
386
+ num = units.gram(500)
387
+ den = kg(1)
388
+
389
+ ratio_result = Ratio(num, den).evaluate()
390
+ truediv_result = num / den
391
+
392
+ self.assertAlmostEqual(ratio_result.quantity, truediv_result.quantity, places=10)
393
+ self.assertEqual(ratio_result.unit.dimension, truediv_result.unit.dimension)
394
+
395
+ def test_evaluate_cross_base_scaling(self):
396
+ """Binary and decimal prefixes should combine correctly."""
397
+ kibigram = Scale.kibi * units.gram # 1024 g
398
+ kg = Scale.kilo * units.gram # 1000 g
399
+ # 1 kibigram / 1 kg = 1024/1000 = 1.024
400
+ ratio = Ratio(kibigram(1), kg(1))
401
+ result = ratio.evaluate()
402
+ self.assertAlmostEqual(result.quantity, 1.024, places=10)
403
+ self.assertEqual(result.unit.dimension, Dimension.none)
404
+
405
+ def test_evaluate_dimensionful_preserves_scales(self):
406
+ """Non-cancelling units should preserve symbolic scales."""
407
+ km = Scale.kilo * units.meter
408
+ # 100 km / 2 h = 50 km/h (scales preserved, not folded)
409
+ ratio = Ratio(km(100), units.hour(2))
410
+ result = ratio.evaluate()
411
+ self.assertAlmostEqual(result.quantity, 50.0, places=10)
412
+ self.assertEqual(result.unit.dimension, Dimension.velocity)
413
+ self.assertIn("km", result.unit.shorthand)
414
+
415
+ def test_evaluate_complex_composition(self):
416
+ """Composed ratios should maintain scale semantics."""
417
+ mL = Scale.milli * units.liter
418
+ # Density: 3.119 g/mL
419
+ density = Ratio(units.gram(3.119), mL(1))
420
+ # Volume: 2 mL
421
+ volume = Ratio(mL(2), Number())
422
+ # Mass = density * volume
423
+ result = (density * volume).evaluate()
424
+ self.assertAlmostEqual(result.quantity, 6.238, places=3)
425
+
426
+
367
427
  class TestCallableUnits(unittest.TestCase):
368
428
  """Tests for the callable unit syntax: unit(quantity) -> Number."""
369
429
 
ucon/algebra.py CHANGED
@@ -102,9 +102,10 @@ class Vector:
102
102
  values = tuple(component * scalar for component in tuple(self))
103
103
  return Vector(*values)
104
104
 
105
- def __eq__(self, vector: 'Vector') -> bool:
106
- assert isinstance(vector, Vector), "Can only compare Vector to another Vector"
107
- return tuple(self) == tuple(vector)
105
+ def __eq__(self, other) -> bool:
106
+ if not isinstance(other, Vector):
107
+ return NotImplemented
108
+ return tuple(self) == tuple(other)
108
109
 
109
110
  def __hash__(self) -> int:
110
111
  # Hash based on the string because tuples have been shown to collide
ucon/core.py CHANGED
@@ -36,6 +36,10 @@ class Dimension(Enum):
36
36
  """
37
37
  Represents a **physical dimension** defined by a :class:`Vector`.
38
38
  Algebra over multiplication/division & exponentiation, with dynamic resolution.
39
+
40
+ Pseudo-dimensions (angle, solid_angle, ratio) share the zero vector but are
41
+ semantically isolated via unique enum values. They use tuple values (Vector, tag)
42
+ to prevent Python's Enum from treating them as aliases of `none`.
39
43
  """
40
44
  none = Vector()
41
45
 
@@ -50,6 +54,12 @@ class Dimension(Enum):
50
54
  information = Vector(0, 0, 0, 0, 0, 0, 0, 1)
51
55
  # ------------------------------------------------
52
56
 
57
+ # -- PSEUDO-DIMENSIONS (zero vector, distinct values via tuple) --
58
+ angle = (Vector(), "angle") # radian, degree, etc.
59
+ solid_angle = (Vector(), "solid_angle") # steradian, square_degree
60
+ ratio = (Vector(), "ratio") # percent, ppm, etc.
61
+ # ----------------------------------------------------------------
62
+
53
63
  acceleration = Vector(-2, 1, 0, 0, 0, 0, 0, 0)
54
64
  angular_momentum = Vector(-1, 2, 1, 0, 0, 0, 0, 0)
55
65
  area = Vector(0, 2, 0, 0, 0, 0, 0, 0)
@@ -83,10 +93,22 @@ class Dimension(Enum):
83
93
  voltage = Vector(-3, 2, 1, -1, 0, 0, 0, 0)
84
94
  volume = Vector(0, 3, 0, 0, 0, 0, 0, 0)
85
95
 
96
+ @property
97
+ def vector(self) -> 'Vector':
98
+ """Return the dimensional Vector, handling both regular and pseudo-dimensions."""
99
+ if isinstance(self.value, tuple):
100
+ vector, _ = self.value # discard the tag
101
+ return vector
102
+ return self.value
103
+
86
104
  @classmethod
87
105
  def _resolve(cls, vector: 'Vector') -> 'Dimension':
106
+ # Zero vector always resolves to none, never to pseudo-dimensions.
107
+ # This ensures algebraic operations like length/length yield none.
108
+ if vector == Vector():
109
+ return cls.none
88
110
  for dim in cls:
89
- if dim.value == vector:
111
+ if dim.vector == vector:
90
112
  return dim
91
113
  dyn = object.__new__(cls)
92
114
  dyn._name_ = f"derived({vector})"
@@ -96,27 +118,32 @@ class Dimension(Enum):
96
118
  def __truediv__(self, dimension: 'Dimension') -> 'Dimension':
97
119
  if not isinstance(dimension, Dimension):
98
120
  raise TypeError(f"Cannot divide Dimension by non-Dimension type: {type(dimension)}")
99
- return self._resolve(self.value - dimension.value)
121
+ return self._resolve(self.vector - dimension.vector)
100
122
 
101
123
  def __mul__(self, dimension: 'Dimension') -> 'Dimension':
102
124
  if not isinstance(dimension, Dimension):
103
125
  raise TypeError(f"Cannot multiply Dimension by non-Dimension type: {type(dimension)}")
104
- return self._resolve(self.value + dimension.value)
126
+ return self._resolve(self.vector + dimension.vector)
105
127
 
106
128
  def __pow__(self, power: Union[int, float]) -> 'Dimension':
107
129
  if power == 1:
108
130
  return self
109
131
  if power == 0:
110
132
  return Dimension.none
111
- new_vector = self.value * power
133
+ new_vector = self.vector * power
112
134
  return self._resolve(new_vector)
113
135
 
114
136
  def __eq__(self, dimension) -> bool:
115
137
  if not isinstance(dimension, Dimension):
116
138
  raise TypeError(f"Cannot compare Dimension with non-Dimension type: {type(dimension)}")
117
- return self.value == dimension.value
139
+ # For pseudo-dimensions (tuple values), compare by identity
140
+ if isinstance(self.value, tuple) or isinstance(dimension.value, tuple):
141
+ return self is dimension
142
+ # For regular dimensions, compare by vector
143
+ return self.vector == dimension.vector
118
144
 
119
145
  def __hash__(self) -> int:
146
+ # Use the raw value for hashing (tuples and Vectors hash differently)
120
147
  return hash(self.value)
121
148
 
122
149
  def __bool__(self):
@@ -1091,13 +1118,27 @@ class Ratio:
1091
1118
  return Ratio(numerator=self.denominator, denominator=self.numerator)
1092
1119
 
1093
1120
  def evaluate(self) -> "Number":
1094
- # Pure arithmetic, no scale normalization.
1095
- numeric = self.numerator.quantity / self.denominator.quantity
1121
+ """Evaluate the ratio to a Number.
1096
1122
 
1097
- # Pure unit division, with UnitFactor preservation.
1123
+ Uses Exponent-derived arithmetic for scale handling:
1124
+ - If the result is dimensionless (units cancel), scales are folded
1125
+ into the magnitude using _canonical_magnitude.
1126
+ - If the result is dimensionful, raw quantities are divided and
1127
+ unit scales are preserved symbolically.
1128
+
1129
+ This matches the behavior of Number.__truediv__ for consistency.
1130
+ """
1131
+ # Symbolic quotient in the unit algebra
1098
1132
  unit = self.numerator.unit / self.denominator.unit
1099
1133
 
1100
- # DO NOT normalize, DO NOT fold scale.
1134
+ # Dimensionless result: fold all scale factors into magnitude
1135
+ if not unit.dimension:
1136
+ num = self.numerator._canonical_magnitude
1137
+ den = self.denominator._canonical_magnitude
1138
+ return Number(quantity=num / den, unit=_none)
1139
+
1140
+ # Dimensionful result: preserve user's chosen scales symbolically
1141
+ numeric = self.numerator.quantity / self.denominator.quantity
1101
1142
  return Number(quantity=numeric, unit=unit)
1102
1143
 
1103
1144
  def __mul__(self, another_ratio: 'Ratio') -> 'Ratio':
ucon/graph.py CHANGED
@@ -420,4 +420,22 @@ def _build_standard_graph() -> ConversionGraph:
420
420
  # --- Information ---
421
421
  graph.add_edge(src=units.byte, dst=units.bit, map=LinearMap(8))
422
422
 
423
+ # --- Angle ---
424
+ import math
425
+ graph.add_edge(src=units.radian, dst=units.degree, map=LinearMap(180 / math.pi))
426
+ graph.add_edge(src=units.degree, dst=units.arcminute, map=LinearMap(60))
427
+ graph.add_edge(src=units.arcminute, dst=units.arcsecond, map=LinearMap(60))
428
+ graph.add_edge(src=units.turn, dst=units.radian, map=LinearMap(2 * math.pi))
429
+ graph.add_edge(src=units.turn, dst=units.gradian, map=LinearMap(400))
430
+
431
+ # --- Solid Angle ---
432
+ graph.add_edge(src=units.steradian, dst=units.square_degree, map=LinearMap((180 / math.pi) ** 2))
433
+
434
+ # --- Ratio ---
435
+ graph.add_edge(src=units.ratio_one, dst=units.percent, map=LinearMap(100))
436
+ graph.add_edge(src=units.ratio_one, dst=units.permille, map=LinearMap(1000))
437
+ graph.add_edge(src=units.ratio_one, dst=units.ppm, map=LinearMap(1e6))
438
+ graph.add_edge(src=units.ratio_one, dst=units.ppb, map=LinearMap(1e9))
439
+ graph.add_edge(src=units.ratio_one, dst=units.basis_point, map=LinearMap(10000))
440
+
423
441
  return graph
ucon/units.py CHANGED
@@ -56,10 +56,10 @@ mole = Unit(name='mole', dimension=Dimension.amount_of_substance, aliases=('mol'
56
56
  newton = Unit(name='newton', dimension=Dimension.force, aliases=('N',))
57
57
  ohm = Unit(name='ohm', dimension=Dimension.resistance, aliases=('Ω',))
58
58
  pascal = Unit(name='pascal', dimension=Dimension.pressure, aliases=('Pa',))
59
- radian = Unit(name='radian', dimension=Dimension.none, aliases=('rad',))
59
+ radian = Unit(name='radian', dimension=Dimension.angle, aliases=('rad',))
60
60
  siemens = Unit(name='siemens', dimension=Dimension.conductance, aliases=('S',))
61
61
  sievert = Unit(name='sievert', dimension=Dimension.energy, aliases=('Sv',))
62
- steradian = Unit(name='steradian', dimension=Dimension.none, aliases=('sr',))
62
+ steradian = Unit(name='steradian', dimension=Dimension.solid_angle, aliases=('sr',))
63
63
  tesla = Unit(name='tesla', dimension=Dimension.magnetic_flux_density, aliases=('T',))
64
64
  volt = Unit(name='volt', dimension=Dimension.voltage, aliases=('V',))
65
65
  watt = Unit(name='watt', dimension=Dimension.power, aliases=('W',))
@@ -113,6 +113,30 @@ byte = Unit(name='byte', dimension=Dimension.information, aliases=('B',))
113
113
  # ----------------------------------------------------------------------
114
114
 
115
115
 
116
+ # -- Angle Units -------------------------------------------------------
117
+ degree = Unit(name='degree', dimension=Dimension.angle, aliases=('deg', '°'))
118
+ gradian = Unit(name='gradian', dimension=Dimension.angle, aliases=('grad', 'gon'))
119
+ arcminute = Unit(name='arcminute', dimension=Dimension.angle, aliases=('arcmin', "'"))
120
+ arcsecond = Unit(name='arcsecond', dimension=Dimension.angle, aliases=('arcsec', '"'))
121
+ turn = Unit(name='turn', dimension=Dimension.angle, aliases=('rev', 'revolution'))
122
+ # ----------------------------------------------------------------------
123
+
124
+
125
+ # -- Solid Angle Units -------------------------------------------------
126
+ square_degree = Unit(name='square_degree', dimension=Dimension.solid_angle, aliases=('deg²', 'sq_deg'))
127
+ # ----------------------------------------------------------------------
128
+
129
+
130
+ # -- Ratio Units -------------------------------------------------------
131
+ ratio_one = Unit(name='one', dimension=Dimension.ratio, aliases=('1',))
132
+ percent = Unit(name='percent', dimension=Dimension.ratio, aliases=('%',))
133
+ permille = Unit(name='permille', dimension=Dimension.ratio, aliases=('‰',))
134
+ ppm = Unit(name='ppm', dimension=Dimension.ratio, aliases=())
135
+ ppb = Unit(name='ppb', dimension=Dimension.ratio, aliases=())
136
+ basis_point = Unit(name='basis_point', dimension=Dimension.ratio, aliases=('bp', 'bps'))
137
+ # ----------------------------------------------------------------------
138
+
139
+
116
140
  # Backward compatibility alias
117
141
  webers = weber
118
142
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucon
3
- Version: 0.4.1
3
+ Version: 0.5.0
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
@@ -35,7 +35,12 @@ Dynamic: maintainer
35
35
  Dynamic: maintainer-email
36
36
  Dynamic: summary
37
37
 
38
- <img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/dde6c7d3b8a7d79eb1006ace03fb834e044cdebc/ucon-logo.png" align="left" width="200" />
38
+ <table>
39
+ <tr>
40
+ <td width="200">
41
+ <img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/221c60e85ac8361c7d202896b52c1a279081b54c/ucon-logo.png" align="left" width="200" />
42
+ </td>
43
+ <td>
39
44
 
40
45
  # ucon
41
46
 
@@ -45,6 +50,10 @@ Dynamic: summary
45
50
  [![codecov](https://codecov.io/gh/withtwoemms/ucon/graph/badge.svg?token=BNONQTRJWG)](https://codecov.io/gh/withtwoemms/ucon)
46
51
  [![publish](https://github.com/withtwoemms/ucon/workflows/publish/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Apublish)
47
52
 
53
+ </td>
54
+ </tr>
55
+ </table>
56
+
48
57
  > A lightweight, **unit-aware computation library** for Python — built on first-principles.
49
58
 
50
59
  ---
@@ -57,6 +66,7 @@ It combines **units**, **scales**, and **dimensions** into a composable algebra
57
66
  - Dimensional analysis through `Number` and `Ratio`
58
67
  - Scale-aware arithmetic via `UnitFactor` and `UnitProduct`
59
68
  - Metric and binary prefixes (`kilo`, `kibi`, `micro`, `mebi`, etc.)
69
+ - Pseudo-dimensions for angles, solid angles, and ratios with semantic isolation
60
70
  - A clean foundation for physics, chemistry, data modeling, and beyond
61
71
 
62
72
  Think of it as **`decimal.Decimal` for the physical world** — precise, predictable, and type-safe.
@@ -188,16 +198,34 @@ distance_mi = distance.to(units.mile)
188
198
  print(distance_mi) # <3.107... mi>
189
199
  ```
190
200
 
201
+ Dimensionless units have semantic isolation — angles, solid angles, and ratios are distinct:
202
+ ```python
203
+ import math
204
+ from ucon import units
205
+
206
+ # Angle conversions
207
+ angle = units.radian(math.pi)
208
+ print(angle.to(units.degree)) # <180.0 deg>
209
+
210
+ # Ratio conversions
211
+ ratio = units.percent(50)
212
+ print(ratio.to(units.ppm)) # <500000.0 ppm>
213
+
214
+ # Cross-family conversions are prevented
215
+ units.radian(1).to(units.percent) # raises ConversionNotFound
216
+ ```
217
+
191
218
  ---
192
219
 
193
220
  ## Roadmap Highlights
194
221
 
195
222
  | Version | Theme | Focus | Status |
196
223
  |----------|-------|--------|--------|
197
- | **0.3.5** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
198
- | [**0.4.x**](https://github.com/withtwoemms/ucon/milestone/2) | Conversion System | `ConversionGraph`, `Number.to()`, callable units | 🚧 In Progress |
199
- | [**0.6.x**](https://github.com/withtwoemms/ucon/milestone/4) | Nonlinear / Specialized Units | Decibel, Percent, pH | Planned |
200
- | [**0.8.x**](https://github.com/withtwoemms/ucon/milestone/6) | Pydantic Integration | Type-safe quantity validation | Planned |
224
+ | **0.3.x** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
225
+ | **0.4.x** | Conversion System | `ConversionGraph`, `Number.to()`, callable units | Complete |
226
+ | **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | Complete |
227
+ | **0.5.x** | Metrology | Uncertainty propagation, `UnitSystem` | 🚧 In Progress |
228
+ | **0.7.x** | Pydantic Integration | Type-safe quantity validation | ⏳ Planned |
201
229
 
202
230
  See full roadmap: [ROADMAP.md](./ROADMAP.md)
203
231
 
@@ -1,22 +1,23 @@
1
1
  tests/ucon/__init__.py,sha256=9BAHYTs27Ed3VgqiMUH4XtVttAmOPgK0Zvj-dUNo7D8,119
2
- tests/ucon/test_algebra.py,sha256=Esm38M1QBNsV7vdfoFgqRUluyvWX7yccB0RwZXk4DpA,8433
2
+ tests/ucon/test_algebra.py,sha256=NnoQOSMW8NJlOTnbr3M_5epvnDPXpVTgO21L2LcytRY,8503
3
3
  tests/ucon/test_core.py,sha256=bmwSRWPlhwossy5NJ9rcPWujFmzBBPOeZzPAzN1acFg,32631
4
4
  tests/ucon/test_default_graph_conversions.py,sha256=rkcDcSV1_kZeuPf4ModHDpgfkOPZS32xcKq7KPDRN-0,15760
5
- tests/ucon/test_quantity.py,sha256=7rFg4cpdmGV7vyCwg72Bt4EFBgkyM3cPFoRbRn0bqEI,21943
5
+ tests/ucon/test_dimensionless_units.py,sha256=K6BrIPOFL9IO_ksR8t_oJUXmjTgqBUzMdgaV-hZc52w,8410
6
+ tests/ucon/test_quantity.py,sha256=md5nbmy0u2cFBdqNeu-ROhoj29vYrIlGm_AjlmCttgc,24519
6
7
  tests/ucon/test_units.py,sha256=SILymDtDNDyxEhkYQubrfkakKCMexwEwjyHfhrkDrMI,869
7
8
  tests/ucon/conversion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
9
  tests/ucon/conversion/test_graph.py,sha256=fs0aP6qNf8eE1uI7SoGSCW2XAkHYb7T9aaI-kzmO02c,16955
9
10
  tests/ucon/conversion/test_map.py,sha256=DVFQ3xwp16Nuy9EtZRjKlWbkXfRUcM1mOzFrS4HhOaw,13886
10
11
  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
12
+ ucon/algebra.py,sha256=4JiT_SHHep86Sv3tVkgKsRY95lBRASMkyH4vOUA-gfM,7459
13
+ ucon/core.py,sha256=GjLKV0ERyYLhBZBpyIfCrKL718EN1RlUKwqxx2B3Rc4,43606
14
+ ucon/graph.py,sha256=lPoYSvHNGBZxeZ-4dyZIu2OS5R1JTo0qPZ9wd0vg-s4,15566
14
15
  ucon/maps.py,sha256=yyZ7RqnohO2joTUvvKh40in7E6SKMQIQ8jkECO0-_NA,4753
15
16
  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,,
17
+ ucon/units.py,sha256=u1ILwGllzNiwGLadlg5jguKPyFV1u-CZSUMgUDWTen4,7509
18
+ ucon-0.5.0.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
19
+ ucon-0.5.0.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
20
+ ucon-0.5.0.dist-info/METADATA,sha256=TZDRVKaAMyCbbwanZ44Ej5JsCGNf0iWch1PD_CFnIx4,12901
21
+ ucon-0.5.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
22
+ ucon-0.5.0.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
23
+ ucon-0.5.0.dist-info/RECORD,,
File without changes