ucon 0.3.3rc2__py3-none-any.whl → 0.3.4__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 +237 -0
- tests/ucon/test_core.py +455 -364
- tests/ucon/test_quantity.py +363 -0
- tests/ucon/test_units.py +5 -3
- ucon/__init__.py +3 -3
- ucon/algebra.py +212 -0
- ucon/core.py +691 -286
- ucon/quantity.py +249 -0
- ucon/units.py +1 -2
- {ucon-0.3.3rc2.dist-info → ucon-0.3.4.dist-info}/METADATA +6 -5
- ucon-0.3.4.dist-info/RECORD +15 -0
- tests/ucon/test_dimension.py +0 -206
- tests/ucon/test_unit.py +0 -143
- ucon/dimension.py +0 -172
- ucon/unit.py +0 -92
- ucon-0.3.3rc2.dist-info/RECORD +0 -15
- {ucon-0.3.3rc2.dist-info → ucon-0.3.4.dist-info}/WHEEL +0 -0
- {ucon-0.3.3rc2.dist-info → ucon-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.3.3rc2.dist-info → ucon-0.3.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from ucon import units
|
|
6
|
+
from ucon.core import CompositeUnit, Dimension, Scale, Unit
|
|
7
|
+
from ucon.quantity import Number, Ratio
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestNumber(unittest.TestCase):
|
|
11
|
+
|
|
12
|
+
number = Number(unit=units.gram, quantity=1)
|
|
13
|
+
|
|
14
|
+
def test_as_ratio(self):
|
|
15
|
+
ratio = self.number.as_ratio()
|
|
16
|
+
self.assertIsInstance(ratio, Ratio)
|
|
17
|
+
self.assertEqual(ratio.numerator, self.number)
|
|
18
|
+
self.assertEqual(ratio.denominator, Number())
|
|
19
|
+
|
|
20
|
+
@unittest.skip("Requires ConversionGraph implementation")
|
|
21
|
+
def test_simplify(self):
|
|
22
|
+
decagram = Unit(dimension=Dimension.mass, name='gram', scale=Scale.deca)
|
|
23
|
+
kibigram = Unit(dimension=Dimension.mass, name='gram', scale=Scale.kibi)
|
|
24
|
+
|
|
25
|
+
ten_decagrams = Number(unit=decagram, quantity=10)
|
|
26
|
+
point_one_decagrams = Number(unit=decagram, quantity=0.1)
|
|
27
|
+
two_kibigrams = Number(unit=kibigram, quantity=2)
|
|
28
|
+
|
|
29
|
+
self.assertEqual(Number(unit=units.gram, quantity=100), ten_decagrams.simplify())
|
|
30
|
+
self.assertEqual(Number(unit=units.gram, quantity=1), point_one_decagrams.simplify())
|
|
31
|
+
self.assertEqual(Number(unit=units.gram, quantity=2048), two_kibigrams.simplify())
|
|
32
|
+
|
|
33
|
+
@unittest.skip("Requires ConversionGraph implementation")
|
|
34
|
+
def test_to(self):
|
|
35
|
+
kg = Unit(dimension=Dimension.mass, name='gram', scale=Scale.kilo)
|
|
36
|
+
mg = Unit(dimension=Dimension.mass, name='gram', scale=Scale.milli)
|
|
37
|
+
kibigram = Unit(dimension=Dimension.mass, name='gram', scale=Scale.kibi)
|
|
38
|
+
|
|
39
|
+
thousandth_of_a_kilogram = Number(unit=kg, quantity=0.001)
|
|
40
|
+
thousand_milligrams = Number(unit=mg, quantity=1000)
|
|
41
|
+
kibigram_fraction = Number(unit=kibigram, quantity=0.0009765625)
|
|
42
|
+
|
|
43
|
+
self.assertEqual(thousandth_of_a_kilogram, self.number.to(Scale.kilo))
|
|
44
|
+
self.assertEqual(thousand_milligrams, self.number.to(Scale.milli))
|
|
45
|
+
self.assertEqual(kibigram_fraction, self.number.to(Scale.kibi))
|
|
46
|
+
|
|
47
|
+
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))
|
|
51
|
+
|
|
52
|
+
def test___truediv__(self):
|
|
53
|
+
dal = Scale.deca * units.gram
|
|
54
|
+
mg = Scale.milli * units.gram
|
|
55
|
+
kibigram = Scale.kibi * units.gram
|
|
56
|
+
|
|
57
|
+
some_number = Number(unit=dal, quantity=10)
|
|
58
|
+
another_number = Number(unit=mg, quantity=10)
|
|
59
|
+
that_number = Number(unit=kibigram, quantity=10)
|
|
60
|
+
|
|
61
|
+
some_quotient = self.number / some_number
|
|
62
|
+
another_quotient = self.number / another_number
|
|
63
|
+
that_quotient = self.number / that_number
|
|
64
|
+
|
|
65
|
+
self.assertEqual(some_quotient.value, 0.01)
|
|
66
|
+
self.assertEqual(another_quotient.value, 100.0)
|
|
67
|
+
self.assertEqual(that_quotient.value, 0.00009765625)
|
|
68
|
+
|
|
69
|
+
def test___eq__(self):
|
|
70
|
+
self.assertEqual(self.number, Ratio(self.number)) # 1 gram / 1
|
|
71
|
+
with self.assertRaises(TypeError):
|
|
72
|
+
self.number == 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestNumberEdgeCases(unittest.TestCase):
|
|
76
|
+
|
|
77
|
+
def test_density_times_volume_preserves_user_scale(self):
|
|
78
|
+
mL = Scale.milli * units.liter
|
|
79
|
+
density = Ratio(Number(unit=units.gram, quantity=3.119),
|
|
80
|
+
Number(unit=mL, quantity=1))
|
|
81
|
+
two_mL = Number(unit=mL, quantity=2)
|
|
82
|
+
|
|
83
|
+
result = density.evaluate() * two_mL
|
|
84
|
+
self.assertIsInstance(result.unit, CompositeUnit)
|
|
85
|
+
self.assertDictEqual(result.unit.components, {units.gram: 1})
|
|
86
|
+
self.assertAlmostEqual(result.quantity, 6.238, places=12)
|
|
87
|
+
|
|
88
|
+
mg = Scale.milli * units.gram
|
|
89
|
+
mg_density = Ratio(Number(unit=mg, quantity=3119), Number(unit=mL, quantity=1))
|
|
90
|
+
|
|
91
|
+
mg_result = mg_density.evaluate() * two_mL
|
|
92
|
+
self.assertIsInstance(mg_result.unit, CompositeUnit)
|
|
93
|
+
self.assertDictEqual(mg_result.unit.components, {mg: 1})
|
|
94
|
+
self.assertAlmostEqual(mg_result.quantity, 6238, places=12)
|
|
95
|
+
|
|
96
|
+
def test_number_mul_asymmetric_density_volume(self):
|
|
97
|
+
g = units.gram
|
|
98
|
+
mL = Scale.milli * units.liter
|
|
99
|
+
|
|
100
|
+
density = Number(unit=g, quantity=3.119) / Number(unit=mL, quantity=1)
|
|
101
|
+
two_mL = Number(unit=mL, quantity=2)
|
|
102
|
+
|
|
103
|
+
result = density * two_mL
|
|
104
|
+
|
|
105
|
+
assert result.unit == g
|
|
106
|
+
assert abs(result.quantity - 6.238) < 1e-12
|
|
107
|
+
|
|
108
|
+
def test_number_mul_retains_scale_when_scaling_lengths(self):
|
|
109
|
+
km = Scale.kilo * units.meter
|
|
110
|
+
m = units.meter
|
|
111
|
+
|
|
112
|
+
n1 = Number(unit=km, quantity=2) # 2 km
|
|
113
|
+
n2 = Number(unit=m, quantity=500) # 500 m
|
|
114
|
+
|
|
115
|
+
result = n1 * n2
|
|
116
|
+
|
|
117
|
+
assert result.unit.dimension == Dimension.area
|
|
118
|
+
# scale stays on unit expression, not folded into numeric
|
|
119
|
+
assert "km" in result.unit.shorthand or "m" in result.unit.shorthand
|
|
120
|
+
|
|
121
|
+
def test_number_mul_mixed_scales_do_not_auto_cancel(self):
|
|
122
|
+
km = Scale.kilo * units.meter
|
|
123
|
+
m = units.meter
|
|
124
|
+
|
|
125
|
+
result = Number(unit=km, quantity=1) * Number(unit=m, quantity=1)
|
|
126
|
+
|
|
127
|
+
# Should remain composite rather than collapsing to base m^2
|
|
128
|
+
assert isinstance(result.unit, CompositeUnit)
|
|
129
|
+
assert "km" in result.unit.shorthand
|
|
130
|
+
assert "m" in result.unit.shorthand
|
|
131
|
+
|
|
132
|
+
def test_number_div_uses_canonical_rhs_value(self):
|
|
133
|
+
dal = Scale.deca * units.gram # 10 g
|
|
134
|
+
n = Number(unit=units.gram, quantity=1)
|
|
135
|
+
|
|
136
|
+
quotient = n / Number(unit=dal, quantity=10)
|
|
137
|
+
|
|
138
|
+
# 1 g / (10 × 10 g) = 0.01
|
|
139
|
+
assert abs(quotient.value - 0.01) < 1e-12
|
|
140
|
+
|
|
141
|
+
def test_ratio_times_number_preserves_user_scale(self):
|
|
142
|
+
mL = Scale.milli * units.liter
|
|
143
|
+
density = Ratio(Number(unit=units.gram, quantity=3.119),
|
|
144
|
+
Number(unit=mL, quantity=1))
|
|
145
|
+
two_mL = Number(unit=mL, quantity=2)
|
|
146
|
+
|
|
147
|
+
result = density * two_mL.as_ratio()
|
|
148
|
+
evaluated = result.evaluate()
|
|
149
|
+
|
|
150
|
+
assert evaluated.unit == units.gram
|
|
151
|
+
assert abs(evaluated.quantity - 6.238) < 1e-12
|
|
152
|
+
|
|
153
|
+
def test_default_number_is_dimensionless_one(self):
|
|
154
|
+
n = Number()
|
|
155
|
+
self.assertEqual(n.unit, units.none)
|
|
156
|
+
self.assertEqual(n.unit.scale, Scale.one)
|
|
157
|
+
self.assertEqual(n.quantity, 1)
|
|
158
|
+
self.assertAlmostEqual(n.value, 1.0)
|
|
159
|
+
self.assertIn("1", repr(n))
|
|
160
|
+
|
|
161
|
+
@unittest.skip("Requires ConversionGraph implementation")
|
|
162
|
+
def test_to_new_scale_changes_value(self):
|
|
163
|
+
thousand = Unit(dimension=Dimension.none, name='', scale=Scale.kilo)
|
|
164
|
+
n = Number(quantity=1000, unit=thousand)
|
|
165
|
+
converted = n.to(Scale.one)
|
|
166
|
+
self.assertNotEqual(n.value, converted.value)
|
|
167
|
+
self.assertAlmostEqual(converted.value, 1000)
|
|
168
|
+
|
|
169
|
+
@unittest.skip("Requires ConversionGraph implementation")
|
|
170
|
+
def test_simplify_uses_value_as_quantity(self):
|
|
171
|
+
thousand = Unit(dimension=Dimension.none, name='', scale=Scale.kilo)
|
|
172
|
+
n = Number(quantity=2, unit=thousand)
|
|
173
|
+
simplified = n.simplify()
|
|
174
|
+
self.assertEqual(simplified.quantity, n.value)
|
|
175
|
+
self.assertNotEqual(simplified.unit.scale, n.unit.scale)
|
|
176
|
+
self.assertEqual(simplified.value, n.value)
|
|
177
|
+
|
|
178
|
+
def test_multiplication_combines_units_and_quantities(self):
|
|
179
|
+
n1 = Number(unit=units.joule, quantity=2)
|
|
180
|
+
n2 = Number(unit=units.second, quantity=3)
|
|
181
|
+
result = n1 * n2
|
|
182
|
+
self.assertEqual(result.quantity, 6)
|
|
183
|
+
self.assertEqual(result.unit.dimension, Dimension.energy * Dimension.time)
|
|
184
|
+
|
|
185
|
+
@unittest.skip("Requires ConversionGraph implementation")
|
|
186
|
+
def test_division_combines_units_scales_and_quantities(self):
|
|
187
|
+
km = Unit('m', name='meter', dimension=Dimension.length, scale=Scale.kilo)
|
|
188
|
+
n1 = Number(unit=km, quantity=1000)
|
|
189
|
+
n2 = Number(unit=units.second, quantity=2)
|
|
190
|
+
|
|
191
|
+
result = n1 / n2 # should yield <500 km/s>
|
|
192
|
+
|
|
193
|
+
cu = result.unit
|
|
194
|
+
self.assertIsInstance(cu, CompositeUnit)
|
|
195
|
+
|
|
196
|
+
# --- quantity check ---
|
|
197
|
+
self.assertAlmostEqual(result.quantity, 500)
|
|
198
|
+
|
|
199
|
+
# --- dimension check ---
|
|
200
|
+
self.assertEqual(cu.dimension, Dimension.velocity)
|
|
201
|
+
|
|
202
|
+
# --- scale check: km/s should have a kilo-scaled meter in the numerator ---
|
|
203
|
+
# find the meter-like unit in the components
|
|
204
|
+
meter_like = next(u for u, exp in cu.components.items() if u.dimension == Dimension.length)
|
|
205
|
+
self.assertEqual(meter_like.scale, Scale.kilo)
|
|
206
|
+
self.assertEqual(cu.components[meter_like], 1) # exponent = 1 in numerator
|
|
207
|
+
|
|
208
|
+
# --- symbolic shorthand ---
|
|
209
|
+
self.assertEqual(cu.shorthand, "km/s")
|
|
210
|
+
|
|
211
|
+
# --- optional canonicalization ---
|
|
212
|
+
canonical = result.to(Scale.one)
|
|
213
|
+
self.assertAlmostEqual(canonical.quantity, 500000)
|
|
214
|
+
self.assertEqual(canonical.unit.shorthand, "m/s")
|
|
215
|
+
|
|
216
|
+
def test_equality_with_non_number_raises_value_error(self):
|
|
217
|
+
n = Number()
|
|
218
|
+
with self.assertRaises(TypeError):
|
|
219
|
+
n == '5'
|
|
220
|
+
|
|
221
|
+
def test_equality_between_numbers_and_ratios(self):
|
|
222
|
+
n1 = Number(quantity=10)
|
|
223
|
+
n2 = Number(quantity=10)
|
|
224
|
+
r = Ratio(n1, n2)
|
|
225
|
+
self.assertTrue(r == Number())
|
|
226
|
+
|
|
227
|
+
def test_repr_includes_scale_and_unit(self):
|
|
228
|
+
kV = Unit('V', name='volt', dimension=Dimension.voltage, scale=Scale.kilo)
|
|
229
|
+
n = Number(unit=kV, quantity=5)
|
|
230
|
+
rep = repr(n)
|
|
231
|
+
self.assertIn("kV", rep)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TestRatio(unittest.TestCase):
|
|
235
|
+
|
|
236
|
+
point_five = Number(quantity=0.5)
|
|
237
|
+
one = Number()
|
|
238
|
+
two = Number(quantity=2)
|
|
239
|
+
three = Number(quantity=3)
|
|
240
|
+
four = Number(quantity=4)
|
|
241
|
+
|
|
242
|
+
one_half = Ratio(numerator=one, denominator=two)
|
|
243
|
+
three_fourths = Ratio(numerator=three, denominator=four)
|
|
244
|
+
one_ratio = Ratio(numerator=one)
|
|
245
|
+
three_halves = Ratio(numerator=three, denominator=two)
|
|
246
|
+
two_ratio = Ratio(numerator=two, denominator=one)
|
|
247
|
+
|
|
248
|
+
def test_evaluate(self):
|
|
249
|
+
self.assertEqual(self.one_ratio.numerator, self.one)
|
|
250
|
+
self.assertEqual(self.one_ratio.denominator, self.one)
|
|
251
|
+
self.assertEqual(self.one_ratio.evaluate(), self.one)
|
|
252
|
+
self.assertEqual(self.two_ratio.evaluate(), self.two)
|
|
253
|
+
|
|
254
|
+
def test_reciprocal(self):
|
|
255
|
+
self.assertEqual(self.two_ratio.reciprocal().numerator, self.one)
|
|
256
|
+
self.assertEqual(self.two_ratio.reciprocal().denominator, self.two)
|
|
257
|
+
self.assertEqual(self.two_ratio.reciprocal().evaluate(), self.point_five)
|
|
258
|
+
|
|
259
|
+
def test___mul__commutivity(self):
|
|
260
|
+
# Does commutivity hold?
|
|
261
|
+
self.assertEqual(self.three_halves * self.one_half, self.three_fourths)
|
|
262
|
+
self.assertEqual(self.one_half * self.three_halves, self.three_fourths)
|
|
263
|
+
|
|
264
|
+
def test___mul__(self):
|
|
265
|
+
mL = Unit('L', name='liter', dimension=Dimension.volume, scale=Scale.milli)
|
|
266
|
+
n1 = Number(unit=units.gram, quantity=3.119)
|
|
267
|
+
n2 = Number(unit=mL)
|
|
268
|
+
bromine_density = Ratio(n1, n2)
|
|
269
|
+
|
|
270
|
+
# How many grams of bromine are in 2 milliliters?
|
|
271
|
+
two_milliliters_bromine = Number(unit=mL, quantity=2)
|
|
272
|
+
ratio = two_milliliters_bromine.as_ratio() * bromine_density
|
|
273
|
+
answer = ratio.evaluate()
|
|
274
|
+
self.assertEqual(answer.unit.dimension, Dimension.mass)
|
|
275
|
+
self.assertEqual(answer.value, 6.238) # Grams
|
|
276
|
+
|
|
277
|
+
def test___truediv__(self):
|
|
278
|
+
seconds_per_hour = Ratio(
|
|
279
|
+
numerator=Number(unit=units.second, quantity=3600),
|
|
280
|
+
denominator=Number(unit=units.hour, quantity=1)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# How many Wh from 20 kJ?
|
|
284
|
+
twenty_kilojoules = Number(
|
|
285
|
+
unit=Unit('J', name='joule', dimension=Dimension.energy, scale=Scale.kilo),
|
|
286
|
+
quantity=20
|
|
287
|
+
)
|
|
288
|
+
ratio = twenty_kilojoules.as_ratio() / seconds_per_hour
|
|
289
|
+
answer = ratio.evaluate()
|
|
290
|
+
self.assertEqual(answer.unit.dimension, Dimension.energy)
|
|
291
|
+
# When the ConversionGraph is implemented, conversion to watt-hours will be possible.
|
|
292
|
+
self.assertEqual(round(answer.value, 5), 0.00556) # kilowatt * hours
|
|
293
|
+
|
|
294
|
+
def test___eq__(self):
|
|
295
|
+
self.assertEqual(self.one_half, self.point_five)
|
|
296
|
+
with self.assertRaises(ValueError):
|
|
297
|
+
self.one_half == 1/2
|
|
298
|
+
|
|
299
|
+
def test___repr__(self):
|
|
300
|
+
self.assertEqual(str(self.one_ratio), '<1.0>')
|
|
301
|
+
self.assertEqual(str(self.two_ratio), '<2> / <1.0>')
|
|
302
|
+
self.assertEqual(str(self.two_ratio.evaluate()), '<2.0>')
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class TestRatioEdgeCases(unittest.TestCase):
|
|
306
|
+
|
|
307
|
+
def test_default_ratio_is_dimensionless_one(self):
|
|
308
|
+
r = Ratio()
|
|
309
|
+
self.assertEqual(r.numerator.unit, units.none)
|
|
310
|
+
self.assertEqual(r.denominator.unit, units.none)
|
|
311
|
+
self.assertAlmostEqual(r.evaluate().value, 1.0)
|
|
312
|
+
|
|
313
|
+
def test_reciprocal_swaps_numerator_and_denominator(self):
|
|
314
|
+
n1 = Number(quantity=10)
|
|
315
|
+
n2 = Number(quantity=2)
|
|
316
|
+
r = Ratio(n1, n2)
|
|
317
|
+
reciprocal = r.reciprocal()
|
|
318
|
+
self.assertEqual(reciprocal.numerator, r.denominator)
|
|
319
|
+
self.assertEqual(reciprocal.denominator, r.numerator)
|
|
320
|
+
|
|
321
|
+
def test_evaluate_returns_number_division_result(self):
|
|
322
|
+
r = Ratio(Number(unit=units.meter), Number(unit=units.second))
|
|
323
|
+
result = r.evaluate()
|
|
324
|
+
self.assertIsInstance(result, Number)
|
|
325
|
+
self.assertEqual(result.unit.dimension, Dimension.velocity)
|
|
326
|
+
|
|
327
|
+
def test_multiplication_between_compatible_ratios(self):
|
|
328
|
+
r1 = Ratio(Number(unit=units.meter), Number(unit=units.second))
|
|
329
|
+
r2 = Ratio(Number(unit=units.second), Number(unit=units.meter))
|
|
330
|
+
product = r1 * r2
|
|
331
|
+
self.assertIsInstance(product, Ratio)
|
|
332
|
+
self.assertEqual(product.evaluate().unit.dimension, Dimension.none)
|
|
333
|
+
|
|
334
|
+
def test_multiplication_with_incompatible_units_fallback(self):
|
|
335
|
+
r1 = Ratio(Number(unit=units.meter), Number(unit=units.ampere))
|
|
336
|
+
r2 = Ratio(Number(unit=units.ampere), Number(unit=units.meter))
|
|
337
|
+
result = r1 * r2
|
|
338
|
+
self.assertIsInstance(result, Ratio)
|
|
339
|
+
|
|
340
|
+
def test_division_between_ratios_yields_new_ratio(self):
|
|
341
|
+
r1 = Ratio(Number(quantity=2), Number(quantity=1))
|
|
342
|
+
r2 = Ratio(Number(quantity=4), Number(quantity=2))
|
|
343
|
+
result = r1 / r2
|
|
344
|
+
self.assertIsInstance(result, Ratio)
|
|
345
|
+
self.assertAlmostEqual(result.evaluate().value, 1.0)
|
|
346
|
+
|
|
347
|
+
def test_equality_with_non_ratio_raises_value_error(self):
|
|
348
|
+
r = Ratio()
|
|
349
|
+
with self.assertRaises(ValueError):
|
|
350
|
+
_ = (r == "not_a_ratio")
|
|
351
|
+
|
|
352
|
+
def test_repr_handles_equal_numerator_denominator(self):
|
|
353
|
+
r = Ratio()
|
|
354
|
+
self.assertEqual(str(r.evaluate().value), "1.0")
|
|
355
|
+
rep = repr(r)
|
|
356
|
+
self.assertTrue(rep.startswith("<1"))
|
|
357
|
+
|
|
358
|
+
def test_repr_of_non_equal_ratio_includes_slash(self):
|
|
359
|
+
n1 = Number(quantity=2)
|
|
360
|
+
n2 = Number(quantity=1)
|
|
361
|
+
r = Ratio(n1, n2)
|
|
362
|
+
rep = repr(r)
|
|
363
|
+
self.assertIn("/", rep)
|
tests/ucon/test_units.py
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
from unittest import TestCase
|
|
4
4
|
|
|
5
5
|
from ucon import units
|
|
6
|
-
from ucon.
|
|
7
|
-
from ucon.
|
|
6
|
+
from ucon.core import Dimension
|
|
7
|
+
from ucon.core import Unit
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class TestUnits(TestCase):
|
|
@@ -18,4 +18,6 @@ class TestUnits(TestCase):
|
|
|
18
18
|
self.assertEqual(units.none, units.gram / units.gram)
|
|
19
19
|
self.assertEqual(units.gram, units.gram / units.none)
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
composite_unit = units.gram / units.liter
|
|
22
|
+
self.assertEqual("g/L", composite_unit.shorthand)
|
|
23
|
+
self.assertEqual(Dimension.density, composite_unit.dimension)
|
ucon/__init__.py
CHANGED
|
@@ -33,9 +33,9 @@ Design Philosophy
|
|
|
33
33
|
data-driven framework that is generalizable to arbitrary unit systems.
|
|
34
34
|
"""
|
|
35
35
|
from ucon import units
|
|
36
|
-
from ucon.
|
|
37
|
-
from ucon.core import
|
|
38
|
-
from ucon.
|
|
36
|
+
from ucon.algebra import Exponent
|
|
37
|
+
from ucon.core import Dimension, Scale, Unit
|
|
38
|
+
from ucon.quantity import Number, Ratio
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
__all__ = [
|
ucon/algebra.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ucon.algebra
|
|
3
|
+
============
|
|
4
|
+
|
|
5
|
+
Provides the low-level algebraic primitives that power the rest of the *ucon*
|
|
6
|
+
stack. These building blocks model exponent vectors for physical dimensions and
|
|
7
|
+
numeric base-exponent pairs for scale prefixes, enabling higher-level modules to
|
|
8
|
+
compose dimensions, units, and quantities without reimplementing arithmetic.
|
|
9
|
+
|
|
10
|
+
Other modules depend on these structures to ensure dimensional calculations,
|
|
11
|
+
prefix handling, and unit simplification all share the same semantics.
|
|
12
|
+
|
|
13
|
+
Classes
|
|
14
|
+
-------
|
|
15
|
+
- :class:`Vector` — Exponent tuple representing a physical dimension basis.
|
|
16
|
+
- :class:`Exponent` — Base/power pair supporting prefix arithmetic.
|
|
17
|
+
"""
|
|
18
|
+
import math
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from functools import partial, reduce, total_ordering
|
|
21
|
+
from operator import __sub__ as subtraction
|
|
22
|
+
from typing import Callable, Iterable, Iterator, Tuple, Union
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
diff: Callable[[Iterable], int] = partial(reduce, subtraction)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Vector:
|
|
30
|
+
"""
|
|
31
|
+
Represents the **exponent vector** of a physical quantity.
|
|
32
|
+
|
|
33
|
+
Each component corresponds to the power of a base dimension in the SI system:
|
|
34
|
+
time (T), length (L), mass (M), current (I), temperature (Θ),
|
|
35
|
+
luminous intensity (J), and amount of substance (N).
|
|
36
|
+
|
|
37
|
+
Arithmetic operations correspond to dimensional composition:
|
|
38
|
+
- Addition (`+`) → multiplication of quantities
|
|
39
|
+
- Subtraction (`-`) → division of quantities
|
|
40
|
+
|
|
41
|
+
e.g.
|
|
42
|
+
Vector(T=1, L=0, M=0, I=0, Θ=0, J=0, N=0) => "time"
|
|
43
|
+
Vector(T=0, L=2, M=0, I=0, Θ=0, J=0, N=0) => "area"
|
|
44
|
+
Vector(T=-2, L=1, M=1, I=0, Θ=0, J=0, N=0) => "force"
|
|
45
|
+
"""
|
|
46
|
+
T: int = 0 # time
|
|
47
|
+
L: int = 0 # length
|
|
48
|
+
M: int = 0 # mass
|
|
49
|
+
I: int = 0 # current
|
|
50
|
+
Θ: int = 0 # temperature
|
|
51
|
+
J: int = 0 # luminous intensity
|
|
52
|
+
N: int = 0 # amount of substance
|
|
53
|
+
|
|
54
|
+
def __iter__(self) -> Iterator[int]:
|
|
55
|
+
yield self.T
|
|
56
|
+
yield self.L
|
|
57
|
+
yield self.M
|
|
58
|
+
yield self.I
|
|
59
|
+
yield self.Θ
|
|
60
|
+
yield self.J
|
|
61
|
+
yield self.N
|
|
62
|
+
|
|
63
|
+
def __len__(self) -> int:
|
|
64
|
+
return sum(tuple(1 for x in self))
|
|
65
|
+
|
|
66
|
+
def __add__(self, vector: 'Vector') -> 'Vector':
|
|
67
|
+
"""
|
|
68
|
+
Addition, here, comes from the multiplication of base quantities
|
|
69
|
+
|
|
70
|
+
e.g. F = m * a
|
|
71
|
+
F =
|
|
72
|
+
(s^-2 * m^1 * kg * A * K * cd * mol) +
|
|
73
|
+
(s * m * kg^1 * A * K * cd * mol)
|
|
74
|
+
"""
|
|
75
|
+
values = tuple(sum(pair) for pair in zip(tuple(self), tuple(vector)))
|
|
76
|
+
return Vector(*values)
|
|
77
|
+
|
|
78
|
+
def __sub__(self, vector: 'Vector') -> 'Vector':
|
|
79
|
+
"""
|
|
80
|
+
Subtraction, here, comes from the division of base quantities
|
|
81
|
+
"""
|
|
82
|
+
values = tuple(diff(pair) for pair in zip(tuple(self), tuple(vector)))
|
|
83
|
+
return Vector(*values)
|
|
84
|
+
|
|
85
|
+
def __mul__(self, scalar: Union[int, float]) -> 'Vector':
|
|
86
|
+
"""
|
|
87
|
+
Scalar multiplication of the exponent vector.
|
|
88
|
+
|
|
89
|
+
e.g., raising a dimension to a power:
|
|
90
|
+
|
|
91
|
+
>>> Dimension.length ** 2 # area
|
|
92
|
+
>>> Dimension.time ** -1 # frequency
|
|
93
|
+
"""
|
|
94
|
+
values = tuple(component * scalar for component in tuple(self))
|
|
95
|
+
return Vector(*values)
|
|
96
|
+
|
|
97
|
+
def __eq__(self, vector: 'Vector') -> bool:
|
|
98
|
+
assert isinstance(vector, Vector), "Can only compare Vector to another Vector"
|
|
99
|
+
return tuple(self) == tuple(vector)
|
|
100
|
+
|
|
101
|
+
def __hash__(self) -> int:
|
|
102
|
+
# Hash based on the string because tuples have been shown to collide
|
|
103
|
+
# Not the most performant, but effective
|
|
104
|
+
return hash(str(tuple(self)))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# TODO -- consider using a dataclass
|
|
108
|
+
@total_ordering
|
|
109
|
+
class Exponent:
|
|
110
|
+
"""
|
|
111
|
+
Represents a **base–exponent pair** (e.g., 10³ or 2¹⁰).
|
|
112
|
+
|
|
113
|
+
Provides comparison and division semantics used internally to represent
|
|
114
|
+
magnitude prefixes (e.g., kilo, mega, micro).
|
|
115
|
+
|
|
116
|
+
TODO (wittwemms): embrace fractional exponents for closure on multiplication/division.
|
|
117
|
+
"""
|
|
118
|
+
bases = {2: math.log2, 10: math.log10}
|
|
119
|
+
|
|
120
|
+
__slots__ = ("base", "power")
|
|
121
|
+
|
|
122
|
+
def __init__(self, base: int, power: Union[int, float]):
|
|
123
|
+
if base not in self.bases.keys():
|
|
124
|
+
raise ValueError(f'Only the following bases are supported: {reduce(lambda a,b: f"{a}, {b}", self.bases.keys())}')
|
|
125
|
+
self.base = base
|
|
126
|
+
self.power = power
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def evaluated(self) -> float:
|
|
130
|
+
"""Return the numeric value of base ** power."""
|
|
131
|
+
return self.base ** self.power
|
|
132
|
+
|
|
133
|
+
def parts(self) -> Tuple[int, Union[int, float]]:
|
|
134
|
+
"""Return (base, power) tuple, used for Scale lookups."""
|
|
135
|
+
return self.base, self.power
|
|
136
|
+
|
|
137
|
+
def __eq__(self, other: 'Exponent'):
|
|
138
|
+
if not isinstance(other, Exponent):
|
|
139
|
+
raise TypeError(f'Cannot compare Exponent to non-Exponent type: {type(other)}')
|
|
140
|
+
return self.evaluated == other.evaluated
|
|
141
|
+
|
|
142
|
+
def __lt__(self, other: 'Exponent'):
|
|
143
|
+
if not isinstance(other, Exponent):
|
|
144
|
+
return NotImplemented
|
|
145
|
+
return self.evaluated < other.evaluated
|
|
146
|
+
|
|
147
|
+
def __hash__(self):
|
|
148
|
+
# Hash by rounded numeric equivalence to maintain cross-base consistency
|
|
149
|
+
return hash(round(self.evaluated, 15))
|
|
150
|
+
|
|
151
|
+
# ---------- Arithmetic Semantics ----------
|
|
152
|
+
|
|
153
|
+
def __truediv__(self, other: 'Exponent'):
|
|
154
|
+
"""
|
|
155
|
+
Divide two Exponents.
|
|
156
|
+
- If bases match, returns a relative Exponent.
|
|
157
|
+
- If bases differ, returns a numeric ratio (float).
|
|
158
|
+
"""
|
|
159
|
+
if not isinstance(other, Exponent):
|
|
160
|
+
return NotImplemented
|
|
161
|
+
if self.base == other.base:
|
|
162
|
+
return Exponent(self.base, self.power - other.power)
|
|
163
|
+
return self.evaluated / other.evaluated
|
|
164
|
+
|
|
165
|
+
def __mul__(self, other: 'Exponent'):
|
|
166
|
+
if not isinstance(other, Exponent):
|
|
167
|
+
return NotImplemented
|
|
168
|
+
if self.base == other.base:
|
|
169
|
+
return Exponent(self.base, self.power + other.power)
|
|
170
|
+
return float(self.evaluated * other.evaluated)
|
|
171
|
+
|
|
172
|
+
def __pow__(self, exponent: Union[int, float]) -> "Exponent":
|
|
173
|
+
"""
|
|
174
|
+
Raise this Exponent to a numeric power.
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
Exponent(10, 3) ** 2
|
|
178
|
+
# → Exponent(base=10, power=6)
|
|
179
|
+
"""
|
|
180
|
+
return Exponent(self.base, self.power * exponent)
|
|
181
|
+
|
|
182
|
+
# ---------- Conversion Utilities ----------
|
|
183
|
+
|
|
184
|
+
def to_base(self, new_base: int) -> "Exponent":
|
|
185
|
+
"""
|
|
186
|
+
Convert this Exponent to another base representation.
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
Exponent(2, 10).to_base(10)
|
|
190
|
+
# → Exponent(base=10, power=3.010299956639812)
|
|
191
|
+
"""
|
|
192
|
+
if new_base not in self.bases:
|
|
193
|
+
supported = ", ".join(map(str, self.bases))
|
|
194
|
+
raise ValueError(f"Unsupported base {new_base!r}. Supported bases: {supported}")
|
|
195
|
+
new_power = self.bases[new_base](self.evaluated)
|
|
196
|
+
return Exponent(new_base, new_power)
|
|
197
|
+
|
|
198
|
+
# ---------- Numeric Interop ----------
|
|
199
|
+
|
|
200
|
+
def __float__(self) -> float:
|
|
201
|
+
return float(self.evaluated)
|
|
202
|
+
|
|
203
|
+
def __int__(self) -> int:
|
|
204
|
+
return int(self.evaluated)
|
|
205
|
+
|
|
206
|
+
# ---------- Representation ----------
|
|
207
|
+
|
|
208
|
+
def __repr__(self) -> str:
|
|
209
|
+
return f"Exponent(base={self.base}, power={self.power})"
|
|
210
|
+
|
|
211
|
+
def __str__(self) -> str:
|
|
212
|
+
return f"{self.base}^{self.power}"
|