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.
- tests/ucon/test_algebra.py +4 -4
- tests/ucon/test_dimensionless_units.py +248 -0
- tests/ucon/test_quantity.py +60 -0
- ucon/algebra.py +4 -3
- ucon/core.py +50 -9
- ucon/graph.py +18 -0
- ucon/units.py +26 -2
- {ucon-0.4.1.dist-info → ucon-0.5.0.dist-info}/METADATA +34 -6
- {ucon-0.4.1.dist-info → ucon-0.5.0.dist-info}/RECORD +13 -12
- {ucon-0.4.1.dist-info → ucon-0.5.0.dist-info}/WHEEL +0 -0
- {ucon-0.4.1.dist-info → ucon-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.4.1.dist-info → ucon-0.5.0.dist-info}/licenses/NOTICE +0 -0
- {ucon-0.4.1.dist-info → ucon-0.5.0.dist-info}/top_level.txt +0 -0
tests/ucon/test_algebra.py
CHANGED
|
@@ -71,10 +71,10 @@ class TestVectorEdgeCases(TestCase):
|
|
|
71
71
|
|
|
72
72
|
def test_vector_equality_with_non_vector(self):
|
|
73
73
|
v = Vector()
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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()
|
tests/ucon/test_quantity.py
CHANGED
|
@@ -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,
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1095
|
-
numeric = self.numerator.quantity / self.denominator.quantity
|
|
1121
|
+
"""Evaluate the ratio to a Number.
|
|
1096
1122
|
|
|
1097
|
-
|
|
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
|
-
#
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
<
|
|
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
|
[](https://codecov.io/gh/withtwoemms/ucon)
|
|
46
51
|
[](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.
|
|
198
|
-
|
|
|
199
|
-
|
|
|
200
|
-
|
|
|
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=
|
|
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/
|
|
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=
|
|
12
|
-
ucon/core.py,sha256=
|
|
13
|
-
ucon/graph.py,sha256=
|
|
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=
|
|
17
|
-
ucon-0.
|
|
18
|
-
ucon-0.
|
|
19
|
-
ucon-0.
|
|
20
|
-
ucon-0.
|
|
21
|
-
ucon-0.
|
|
22
|
-
ucon-0.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|