ucon 0.5.1__py3-none-any.whl → 0.6.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.
- ucon/__init__.py +24 -3
- ucon/algebra.py +36 -14
- ucon/core.py +414 -2
- ucon/graph.py +167 -10
- ucon/mcp/__init__.py +8 -0
- ucon/mcp/server.py +250 -0
- ucon/pydantic.py +199 -0
- ucon/units.py +286 -11
- {ucon-0.5.1.dist-info → ucon-0.6.0.dist-info}/METADATA +88 -31
- ucon-0.6.0.dist-info/RECORD +17 -0
- ucon-0.6.0.dist-info/entry_points.txt +2 -0
- ucon-0.6.0.dist-info/top_level.txt +1 -0
- tests/ucon/__init__.py +0 -3
- tests/ucon/conversion/__init__.py +0 -0
- tests/ucon/conversion/test_graph.py +0 -409
- tests/ucon/conversion/test_map.py +0 -409
- tests/ucon/test_algebra.py +0 -239
- tests/ucon/test_core.py +0 -827
- tests/ucon/test_default_graph_conversions.py +0 -443
- tests/ucon/test_dimensionless_units.py +0 -248
- tests/ucon/test_quantity.py +0 -615
- tests/ucon/test_uncertainty.py +0 -264
- tests/ucon/test_units.py +0 -25
- ucon-0.5.1.dist-info/RECORD +0 -24
- ucon-0.5.1.dist-info/top_level.txt +0 -2
- {ucon-0.5.1.dist-info → ucon-0.6.0.dist-info}/WHEEL +0 -0
- {ucon-0.5.1.dist-info → ucon-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.5.1.dist-info → ucon-0.6.0.dist-info}/licenses/NOTICE +0 -0
tests/ucon/test_quantity.py
DELETED
|
@@ -1,615 +0,0 @@
|
|
|
1
|
-
# © 2025 The Radiativity Company
|
|
2
|
-
# Licensed under the Apache License, Version 2.0
|
|
3
|
-
# See the LICENSE file for details.
|
|
4
|
-
|
|
5
|
-
import unittest
|
|
6
|
-
|
|
7
|
-
from ucon import units
|
|
8
|
-
from ucon.core import UnitProduct, UnitFactor, Dimension, Scale, Unit
|
|
9
|
-
from ucon.quantity import Number, Ratio
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class TestNumber(unittest.TestCase):
|
|
13
|
-
|
|
14
|
-
number = Number(unit=units.gram, quantity=1)
|
|
15
|
-
|
|
16
|
-
def test_as_ratio(self):
|
|
17
|
-
ratio = self.number.as_ratio()
|
|
18
|
-
self.assertIsInstance(ratio, Ratio)
|
|
19
|
-
self.assertEqual(ratio.numerator, self.number)
|
|
20
|
-
self.assertEqual(ratio.denominator, Number())
|
|
21
|
-
|
|
22
|
-
def test_simplify_scaled_unit(self):
|
|
23
|
-
"""Test simplify() removes scale prefix and adjusts quantity."""
|
|
24
|
-
decagram = Scale.deca * units.gram
|
|
25
|
-
ten_decagrams = Number(unit=decagram, quantity=10)
|
|
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)
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
def test___repr__(self):
|
|
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
|
-
|
|
55
|
-
def test___truediv__(self):
|
|
56
|
-
dal = Scale.deca * units.gram
|
|
57
|
-
mg = Scale.milli * units.gram
|
|
58
|
-
kibigram = Scale.kibi * units.gram
|
|
59
|
-
|
|
60
|
-
some_number = Number(unit=dal, quantity=10)
|
|
61
|
-
another_number = Number(unit=mg, quantity=10)
|
|
62
|
-
that_number = Number(unit=kibigram, quantity=10)
|
|
63
|
-
|
|
64
|
-
some_quotient = self.number / some_number
|
|
65
|
-
another_quotient = self.number / another_number
|
|
66
|
-
that_quotient = self.number / that_number
|
|
67
|
-
|
|
68
|
-
self.assertEqual(some_quotient.value, 0.01)
|
|
69
|
-
self.assertEqual(another_quotient.value, 100.0)
|
|
70
|
-
self.assertEqual(that_quotient.value, 0.00009765625)
|
|
71
|
-
|
|
72
|
-
def test___eq__(self):
|
|
73
|
-
self.assertEqual(self.number, Ratio(self.number)) # 1 gram / 1
|
|
74
|
-
with self.assertRaises(TypeError):
|
|
75
|
-
self.number == 1
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class TestNumberEdgeCases(unittest.TestCase):
|
|
79
|
-
|
|
80
|
-
def test_density_times_volume_preserves_user_scale(self):
|
|
81
|
-
mL = Scale.milli * units.liter
|
|
82
|
-
density = Ratio(Number(unit=units.gram, quantity=3.119),
|
|
83
|
-
Number(unit=mL, quantity=1))
|
|
84
|
-
two_mL = Number(unit=mL, quantity=2)
|
|
85
|
-
|
|
86
|
-
result = density.evaluate() * two_mL
|
|
87
|
-
self.assertIsInstance(result.unit, UnitProduct)
|
|
88
|
-
self.assertDictEqual(result.unit.factors, {units.gram: 1})
|
|
89
|
-
self.assertAlmostEqual(result.quantity, 6.238, places=12)
|
|
90
|
-
|
|
91
|
-
mg = Scale.milli * units.gram
|
|
92
|
-
mg_factor = UnitFactor(unit=units.gram, scale=Scale.milli)
|
|
93
|
-
mg_density = Ratio(Number(unit=mg, quantity=3119), Number(unit=mL, quantity=1))
|
|
94
|
-
|
|
95
|
-
mg_result = mg_density.evaluate() * two_mL
|
|
96
|
-
self.assertIsInstance(mg_result.unit, UnitProduct)
|
|
97
|
-
self.assertDictEqual(mg_result.unit.factors, {mg_factor: 1})
|
|
98
|
-
self.assertAlmostEqual(mg_result.quantity, 6238, places=12)
|
|
99
|
-
|
|
100
|
-
def test_number_mul_asymmetric_density_volume(self):
|
|
101
|
-
g = units.gram
|
|
102
|
-
mL = Scale.milli * units.liter
|
|
103
|
-
|
|
104
|
-
density = Number(unit=g, quantity=3.119) / Number(unit=mL, quantity=1)
|
|
105
|
-
two_mL = Number(unit=mL, quantity=2)
|
|
106
|
-
|
|
107
|
-
result = density * two_mL
|
|
108
|
-
|
|
109
|
-
assert result.unit == g
|
|
110
|
-
assert abs(result.quantity - 6.238) < 1e-12
|
|
111
|
-
|
|
112
|
-
def test_number_mul_retains_scale_when_scaling_lengths(self):
|
|
113
|
-
km = Scale.kilo * units.meter
|
|
114
|
-
m = units.meter
|
|
115
|
-
|
|
116
|
-
n1 = Number(unit=km, quantity=2) # 2 km
|
|
117
|
-
n2 = Number(unit=m, quantity=500) # 500 m
|
|
118
|
-
|
|
119
|
-
result = n1 * n2
|
|
120
|
-
|
|
121
|
-
assert result.unit.dimension == Dimension.area
|
|
122
|
-
# scale stays on unit expression, not folded into numeric
|
|
123
|
-
assert "km" in result.unit.shorthand or "m" in result.unit.shorthand
|
|
124
|
-
|
|
125
|
-
def test_number_mul_mixed_scales_do_not_auto_cancel(self):
|
|
126
|
-
km = Scale.kilo * units.meter
|
|
127
|
-
m = units.meter
|
|
128
|
-
|
|
129
|
-
result = Number(unit=km, quantity=1) * Number(unit=m, quantity=1)
|
|
130
|
-
|
|
131
|
-
# Should remain composite rather than collapsing to base m^2
|
|
132
|
-
assert isinstance(result.unit, UnitProduct)
|
|
133
|
-
assert "km" in result.unit.shorthand
|
|
134
|
-
assert "m" in result.unit.shorthand
|
|
135
|
-
|
|
136
|
-
def test_number_div_uses_canonical_rhs_value(self):
|
|
137
|
-
dal = Scale.deca * units.gram # 10 g
|
|
138
|
-
n = Number(unit=units.gram, quantity=1)
|
|
139
|
-
|
|
140
|
-
quotient = n / Number(unit=dal, quantity=10)
|
|
141
|
-
|
|
142
|
-
# 1 g / (10 × 10 g) = 0.01
|
|
143
|
-
assert abs(quotient.value - 0.01) < 1e-12
|
|
144
|
-
|
|
145
|
-
def test_ratio_times_number_preserves_user_scale(self):
|
|
146
|
-
mL = Scale.milli * units.liter
|
|
147
|
-
density = Ratio(Number(unit=units.gram, quantity=3.119),
|
|
148
|
-
Number(unit=mL, quantity=1))
|
|
149
|
-
two_mL = Number(unit=mL, quantity=2)
|
|
150
|
-
|
|
151
|
-
result = density * two_mL.as_ratio()
|
|
152
|
-
evaluated = result.evaluate()
|
|
153
|
-
|
|
154
|
-
assert evaluated.unit == units.gram
|
|
155
|
-
assert abs(evaluated.quantity - 6.238) < 1e-12
|
|
156
|
-
|
|
157
|
-
def test_default_number_is_dimensionless_one(self):
|
|
158
|
-
"""Default Number() is dimensionless with quantity=1."""
|
|
159
|
-
n = Number()
|
|
160
|
-
self.assertEqual(n.unit, units.none)
|
|
161
|
-
self.assertEqual(n.quantity, 1)
|
|
162
|
-
self.assertAlmostEqual(n.value, 1.0)
|
|
163
|
-
self.assertIn("1", repr(n))
|
|
164
|
-
|
|
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
|
-
|
|
174
|
-
def test_simplify_uses_value_as_quantity(self):
|
|
175
|
-
"""Simplify converts scaled quantity to base scale quantity."""
|
|
176
|
-
km = Scale.kilo * units.meter
|
|
177
|
-
n = Number(quantity=2, unit=km) # 2 km
|
|
178
|
-
simplified = n.simplify()
|
|
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
|
-
|
|
184
|
-
def test_multiplication_combines_units_and_quantities(self):
|
|
185
|
-
n1 = Number(unit=units.joule, quantity=2)
|
|
186
|
-
n2 = Number(unit=units.second, quantity=3)
|
|
187
|
-
result = n1 * n2
|
|
188
|
-
self.assertEqual(result.quantity, 6)
|
|
189
|
-
self.assertEqual(result.unit.dimension, Dimension.energy * Dimension.time)
|
|
190
|
-
|
|
191
|
-
def test_division_combines_units_scales_and_quantities(self):
|
|
192
|
-
"""Division creates composite unit with preserved scales."""
|
|
193
|
-
km = Scale.kilo * units.meter
|
|
194
|
-
n1 = Number(unit=km, quantity=1000) # 1000 km
|
|
195
|
-
n2 = Number(unit=units.second, quantity=2)
|
|
196
|
-
|
|
197
|
-
result = n1 / n2 # should yield <500 km/s>
|
|
198
|
-
|
|
199
|
-
cu = result.unit
|
|
200
|
-
self.assertIsInstance(cu, UnitProduct)
|
|
201
|
-
|
|
202
|
-
# --- quantity check ---
|
|
203
|
-
self.assertAlmostEqual(result.quantity, 500)
|
|
204
|
-
|
|
205
|
-
# --- dimension check ---
|
|
206
|
-
self.assertEqual(cu.dimension, Dimension.velocity)
|
|
207
|
-
|
|
208
|
-
# --- symbolic shorthand ---
|
|
209
|
-
self.assertEqual(cu.shorthand, "km/s")
|
|
210
|
-
|
|
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)
|
|
215
|
-
self.assertEqual(canonical.unit.shorthand, "m/s")
|
|
216
|
-
|
|
217
|
-
def test_equality_with_non_number_raises_value_error(self):
|
|
218
|
-
n = Number()
|
|
219
|
-
with self.assertRaises(TypeError):
|
|
220
|
-
n == '5'
|
|
221
|
-
|
|
222
|
-
def test_equality_between_numbers_and_ratios(self):
|
|
223
|
-
n1 = Number(quantity=10)
|
|
224
|
-
n2 = Number(quantity=10)
|
|
225
|
-
r = Ratio(n1, n2)
|
|
226
|
-
self.assertTrue(r == Number())
|
|
227
|
-
|
|
228
|
-
def test_repr_includes_scale_and_unit(self):
|
|
229
|
-
kV = Scale.kilo * Unit(name='volt', dimension=Dimension.voltage, aliases=('V',))
|
|
230
|
-
n = Number(unit=kV, quantity=5)
|
|
231
|
-
rep = repr(n)
|
|
232
|
-
self.assertIn("kV", rep)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
class TestRatio(unittest.TestCase):
|
|
236
|
-
|
|
237
|
-
point_five = Number(quantity=0.5)
|
|
238
|
-
one = Number()
|
|
239
|
-
two = Number(quantity=2)
|
|
240
|
-
three = Number(quantity=3)
|
|
241
|
-
four = Number(quantity=4)
|
|
242
|
-
|
|
243
|
-
one_half = Ratio(numerator=one, denominator=two)
|
|
244
|
-
three_fourths = Ratio(numerator=three, denominator=four)
|
|
245
|
-
one_ratio = Ratio(numerator=one)
|
|
246
|
-
three_halves = Ratio(numerator=three, denominator=two)
|
|
247
|
-
two_ratio = Ratio(numerator=two, denominator=one)
|
|
248
|
-
|
|
249
|
-
def test_evaluate(self):
|
|
250
|
-
self.assertEqual(self.one_ratio.numerator, self.one)
|
|
251
|
-
self.assertEqual(self.one_ratio.denominator, self.one)
|
|
252
|
-
self.assertEqual(self.one_ratio.evaluate(), self.one)
|
|
253
|
-
self.assertEqual(self.two_ratio.evaluate(), self.two)
|
|
254
|
-
|
|
255
|
-
def test_reciprocal(self):
|
|
256
|
-
self.assertEqual(self.two_ratio.reciprocal().numerator, self.one)
|
|
257
|
-
self.assertEqual(self.two_ratio.reciprocal().denominator, self.two)
|
|
258
|
-
self.assertEqual(self.two_ratio.reciprocal().evaluate(), self.point_five)
|
|
259
|
-
|
|
260
|
-
def test___mul__commutivity(self):
|
|
261
|
-
# Does commutivity hold?
|
|
262
|
-
self.assertEqual(self.three_halves * self.one_half, self.three_fourths)
|
|
263
|
-
self.assertEqual(self.one_half * self.three_halves, self.three_fourths)
|
|
264
|
-
|
|
265
|
-
def test___mul__(self):
|
|
266
|
-
mL = Scale.milli * Unit(name='liter', dimension=Dimension.volume, aliases=('L',))
|
|
267
|
-
n1 = Number(unit=units.gram, quantity=3.119)
|
|
268
|
-
n2 = Number(unit=mL)
|
|
269
|
-
bromine_density = Ratio(n1, n2)
|
|
270
|
-
|
|
271
|
-
# How many grams of bromine are in 2 milliliters?
|
|
272
|
-
two_milliliters_bromine = Number(unit=mL, quantity=2)
|
|
273
|
-
ratio = two_milliliters_bromine.as_ratio() * bromine_density
|
|
274
|
-
answer = ratio.evaluate()
|
|
275
|
-
self.assertEqual(answer.unit.dimension, Dimension.mass)
|
|
276
|
-
self.assertEqual(answer.value, 6.238) # Grams
|
|
277
|
-
|
|
278
|
-
def test___truediv__(self):
|
|
279
|
-
seconds_per_hour = Ratio(
|
|
280
|
-
numerator=Number(unit=units.second, quantity=3600),
|
|
281
|
-
denominator=Number(unit=units.hour, quantity=1)
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
# How many Wh from 20 kJ?
|
|
285
|
-
twenty_kilojoules = Number(
|
|
286
|
-
unit=Scale.kilo * Unit(name='joule', dimension=Dimension.energy, aliases=('J',)),
|
|
287
|
-
quantity=20
|
|
288
|
-
)
|
|
289
|
-
ratio = twenty_kilojoules.as_ratio() / seconds_per_hour
|
|
290
|
-
answer = ratio.evaluate()
|
|
291
|
-
self.assertEqual(answer.unit.dimension, Dimension.energy)
|
|
292
|
-
# When the ConversionGraph is implemented, conversion to watt-hours will be possible.
|
|
293
|
-
self.assertEqual(round(answer.value, 5), 0.00556) # kilowatt * hours
|
|
294
|
-
|
|
295
|
-
def test___eq__(self):
|
|
296
|
-
self.assertEqual(self.one_half, self.point_five)
|
|
297
|
-
with self.assertRaises(ValueError):
|
|
298
|
-
self.one_half == 1/2
|
|
299
|
-
|
|
300
|
-
def test___repr__(self):
|
|
301
|
-
self.assertEqual(str(self.one_ratio), '<1.0>')
|
|
302
|
-
self.assertEqual(str(self.two_ratio), '<2> / <1.0>')
|
|
303
|
-
self.assertEqual(str(self.two_ratio.evaluate()), '<2.0>')
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
class TestRatioEdgeCases(unittest.TestCase):
|
|
307
|
-
|
|
308
|
-
def test_default_ratio_is_dimensionless_one(self):
|
|
309
|
-
r = Ratio()
|
|
310
|
-
self.assertEqual(r.numerator.unit, units.none)
|
|
311
|
-
self.assertEqual(r.denominator.unit, units.none)
|
|
312
|
-
self.assertAlmostEqual(r.evaluate().value, 1.0)
|
|
313
|
-
|
|
314
|
-
def test_reciprocal_swaps_numerator_and_denominator(self):
|
|
315
|
-
n1 = Number(quantity=10)
|
|
316
|
-
n2 = Number(quantity=2)
|
|
317
|
-
r = Ratio(n1, n2)
|
|
318
|
-
reciprocal = r.reciprocal()
|
|
319
|
-
self.assertEqual(reciprocal.numerator, r.denominator)
|
|
320
|
-
self.assertEqual(reciprocal.denominator, r.numerator)
|
|
321
|
-
|
|
322
|
-
def test_evaluate_returns_number_division_result(self):
|
|
323
|
-
r = Ratio(Number(unit=units.meter), Number(unit=units.second))
|
|
324
|
-
result = r.evaluate()
|
|
325
|
-
self.assertIsInstance(result, Number)
|
|
326
|
-
self.assertEqual(result.unit.dimension, Dimension.velocity)
|
|
327
|
-
|
|
328
|
-
def test_multiplication_between_compatible_ratios(self):
|
|
329
|
-
r1 = Ratio(Number(unit=units.meter), Number(unit=units.second))
|
|
330
|
-
r2 = Ratio(Number(unit=units.second), Number(unit=units.meter))
|
|
331
|
-
product = r1 * r2
|
|
332
|
-
self.assertIsInstance(product, Ratio)
|
|
333
|
-
self.assertEqual(product.evaluate().unit.dimension, Dimension.none)
|
|
334
|
-
|
|
335
|
-
def test_multiplication_with_incompatible_units_fallback(self):
|
|
336
|
-
r1 = Ratio(Number(unit=units.meter), Number(unit=units.ampere))
|
|
337
|
-
r2 = Ratio(Number(unit=units.ampere), Number(unit=units.meter))
|
|
338
|
-
result = r1 * r2
|
|
339
|
-
self.assertIsInstance(result, Ratio)
|
|
340
|
-
|
|
341
|
-
def test_division_between_ratios_yields_new_ratio(self):
|
|
342
|
-
r1 = Ratio(Number(quantity=2), Number(quantity=1))
|
|
343
|
-
r2 = Ratio(Number(quantity=4), Number(quantity=2))
|
|
344
|
-
result = r1 / r2
|
|
345
|
-
self.assertIsInstance(result, Ratio)
|
|
346
|
-
self.assertAlmostEqual(result.evaluate().value, 1.0)
|
|
347
|
-
|
|
348
|
-
def test_equality_with_non_ratio_raises_value_error(self):
|
|
349
|
-
r = Ratio()
|
|
350
|
-
with self.assertRaises(ValueError):
|
|
351
|
-
_ = (r == "not_a_ratio")
|
|
352
|
-
|
|
353
|
-
def test_repr_handles_equal_numerator_denominator(self):
|
|
354
|
-
r = Ratio()
|
|
355
|
-
self.assertEqual(str(r.evaluate().value), "1.0")
|
|
356
|
-
rep = repr(r)
|
|
357
|
-
self.assertTrue(rep.startswith("<1"))
|
|
358
|
-
|
|
359
|
-
def test_repr_of_non_equal_ratio_includes_slash(self):
|
|
360
|
-
n1 = Number(quantity=2)
|
|
361
|
-
n2 = Number(quantity=1)
|
|
362
|
-
r = Ratio(n1, n2)
|
|
363
|
-
rep = repr(r)
|
|
364
|
-
self.assertIn("/", rep)
|
|
365
|
-
|
|
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
|
-
|
|
427
|
-
class TestCallableUnits(unittest.TestCase):
|
|
428
|
-
"""Tests for the callable unit syntax: unit(quantity) -> Number."""
|
|
429
|
-
|
|
430
|
-
def test_unit_callable_returns_number(self):
|
|
431
|
-
result = units.meter(5)
|
|
432
|
-
self.assertIsInstance(result, Number)
|
|
433
|
-
self.assertEqual(result.quantity, 5)
|
|
434
|
-
|
|
435
|
-
def test_unit_callable_shorthand(self):
|
|
436
|
-
result = units.meter(5)
|
|
437
|
-
self.assertIn("m", result.unit.shorthand)
|
|
438
|
-
|
|
439
|
-
def test_unit_product_callable_returns_number(self):
|
|
440
|
-
velocity = units.meter / units.second
|
|
441
|
-
result = velocity(10)
|
|
442
|
-
self.assertIsInstance(result, Number)
|
|
443
|
-
self.assertEqual(result.quantity, 10)
|
|
444
|
-
self.assertEqual(result.unit.dimension, Dimension.velocity)
|
|
445
|
-
|
|
446
|
-
def test_scaled_unit_callable_returns_number(self):
|
|
447
|
-
km = Scale.kilo * units.meter
|
|
448
|
-
result = km(5)
|
|
449
|
-
self.assertIsInstance(result, Number)
|
|
450
|
-
self.assertEqual(result.quantity, 5)
|
|
451
|
-
self.assertIn("km", result.unit.shorthand)
|
|
452
|
-
|
|
453
|
-
def test_composite_scaled_unit_callable(self):
|
|
454
|
-
mph = units.mile / units.hour
|
|
455
|
-
result = mph(60)
|
|
456
|
-
self.assertIsInstance(result, Number)
|
|
457
|
-
self.assertEqual(result.quantity, 60)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
class TestScaledUnitConversion(unittest.TestCase):
|
|
461
|
-
"""Tests for conversions involving scaled units.
|
|
462
|
-
|
|
463
|
-
Regression tests for bug where scale was applied twice during conversion.
|
|
464
|
-
"""
|
|
465
|
-
|
|
466
|
-
def test_km_to_mile_conversion(self):
|
|
467
|
-
"""5 km should be approximately 3.10686 miles."""
|
|
468
|
-
km = Scale.kilo * units.meter
|
|
469
|
-
result = km(5).to(units.mile)
|
|
470
|
-
# 5 km = 5000 m = 5000 / 1609.34 miles ≈ 3.10686
|
|
471
|
-
self.assertAlmostEqual(result.quantity, 3.10686, places=4)
|
|
472
|
-
|
|
473
|
-
def test_km_to_meter_conversion(self):
|
|
474
|
-
"""1 km should be 1000 meters."""
|
|
475
|
-
km = Scale.kilo * units.meter
|
|
476
|
-
result = km(1).to(units.meter)
|
|
477
|
-
self.assertAlmostEqual(result.quantity, 1000.0, places=6)
|
|
478
|
-
|
|
479
|
-
def test_meter_to_mm_conversion(self):
|
|
480
|
-
"""1 meter should be 1000 millimeters."""
|
|
481
|
-
mm = Scale.milli * units.meter
|
|
482
|
-
result = units.meter(1).to(mm)
|
|
483
|
-
self.assertAlmostEqual(result.quantity, 1000.0, places=6)
|
|
484
|
-
|
|
485
|
-
def test_mm_to_inch_conversion(self):
|
|
486
|
-
"""25.4 mm should be approximately 1 inch."""
|
|
487
|
-
mm = Scale.milli * units.meter
|
|
488
|
-
result = mm(25.4).to(units.inch)
|
|
489
|
-
self.assertAlmostEqual(result.quantity, 1.0, places=4)
|
|
490
|
-
|
|
491
|
-
def test_scaled_velocity_conversion(self):
|
|
492
|
-
"""1 km/h should be approximately 0.27778 m/s."""
|
|
493
|
-
km_per_h = (Scale.kilo * units.meter) / units.hour
|
|
494
|
-
m_per_s = units.meter / units.second
|
|
495
|
-
result = km_per_h(1).to(m_per_s)
|
|
496
|
-
# 1 km/h = 1000m / 3600s = 0.27778 m/s
|
|
497
|
-
self.assertAlmostEqual(result.quantity, 0.27778, places=4)
|
|
498
|
-
|
|
499
|
-
def test_mph_to_m_per_s_conversion(self):
|
|
500
|
-
"""60 mph should be approximately 26.8224 m/s."""
|
|
501
|
-
mph = units.mile / units.hour
|
|
502
|
-
m_per_s = units.meter / units.second
|
|
503
|
-
result = mph(60).to(m_per_s)
|
|
504
|
-
# 60 mph = 60 * 1609.34 / 3600 m/s ≈ 26.8224
|
|
505
|
-
self.assertAlmostEqual(result.quantity, 26.8224, places=2)
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
class TestNumberSimplify(unittest.TestCase):
|
|
509
|
-
"""Tests for Number.simplify() method."""
|
|
510
|
-
|
|
511
|
-
def test_simplify_kilo_prefix(self):
|
|
512
|
-
"""5 km simplifies to 5000 m."""
|
|
513
|
-
km = Scale.kilo * units.meter
|
|
514
|
-
result = km(5).simplify()
|
|
515
|
-
self.assertAlmostEqual(result.quantity, 5000.0, places=10)
|
|
516
|
-
self.assertEqual(result.unit.shorthand, "m")
|
|
517
|
-
|
|
518
|
-
def test_simplify_milli_prefix(self):
|
|
519
|
-
"""500 mg simplifies to 0.5 g."""
|
|
520
|
-
mg = Scale.milli * units.gram
|
|
521
|
-
result = mg(500).simplify()
|
|
522
|
-
self.assertAlmostEqual(result.quantity, 0.5, places=10)
|
|
523
|
-
self.assertEqual(result.unit.shorthand, "g")
|
|
524
|
-
|
|
525
|
-
def test_simplify_binary_prefix(self):
|
|
526
|
-
"""2 kibibytes simplifies to 2048 bytes."""
|
|
527
|
-
kibibyte = Scale.kibi * units.byte
|
|
528
|
-
result = kibibyte(2).simplify()
|
|
529
|
-
self.assertAlmostEqual(result.quantity, 2048.0, places=10)
|
|
530
|
-
self.assertEqual(result.unit.shorthand, "B")
|
|
531
|
-
|
|
532
|
-
def test_simplify_composite_unit(self):
|
|
533
|
-
"""1 km/h simplifies to base scales."""
|
|
534
|
-
km_per_h = (Scale.kilo * units.meter) / units.hour
|
|
535
|
-
result = km_per_h(1).simplify()
|
|
536
|
-
# 1 km/h = 1000 m / 1 h (hour stays hour since it's base unit)
|
|
537
|
-
self.assertAlmostEqual(result.quantity, 1000.0, places=10)
|
|
538
|
-
self.assertEqual(result.unit.shorthand, "m/h")
|
|
539
|
-
|
|
540
|
-
def test_simplify_plain_unit_unchanged(self):
|
|
541
|
-
"""Plain unit without scale returns equivalent Number."""
|
|
542
|
-
result = units.meter(5).simplify()
|
|
543
|
-
self.assertAlmostEqual(result.quantity, 5.0, places=10)
|
|
544
|
-
self.assertEqual(result.unit.shorthand, "m")
|
|
545
|
-
|
|
546
|
-
def test_simplify_preserves_dimension(self):
|
|
547
|
-
"""Simplified Number has same dimension."""
|
|
548
|
-
km = Scale.kilo * units.meter
|
|
549
|
-
original = km(5)
|
|
550
|
-
simplified = original.simplify()
|
|
551
|
-
self.assertEqual(original.unit.dimension, simplified.unit.dimension)
|
|
552
|
-
|
|
553
|
-
def test_simplify_idempotent(self):
|
|
554
|
-
"""Simplifying twice gives same result."""
|
|
555
|
-
km = Scale.kilo * units.meter
|
|
556
|
-
result1 = km(5).simplify()
|
|
557
|
-
result2 = result1.simplify()
|
|
558
|
-
self.assertAlmostEqual(result1.quantity, result2.quantity, places=10)
|
|
559
|
-
self.assertEqual(result1.unit.shorthand, result2.unit.shorthand)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
class TestInformationDimension(unittest.TestCase):
|
|
563
|
-
"""Tests for Dimension.information and information units (bit, byte)."""
|
|
564
|
-
|
|
565
|
-
def test_dimension_information_exists(self):
|
|
566
|
-
"""Dimension.information should be a valid dimension."""
|
|
567
|
-
self.assertEqual(Dimension.information.name, 'information')
|
|
568
|
-
self.assertNotEqual(Dimension.information, Dimension.none)
|
|
569
|
-
|
|
570
|
-
def test_bit_unit_exists(self):
|
|
571
|
-
"""units.bit should have Dimension.information."""
|
|
572
|
-
self.assertEqual(units.bit.dimension, Dimension.information)
|
|
573
|
-
self.assertIn('b', units.bit.aliases)
|
|
574
|
-
|
|
575
|
-
def test_byte_unit_exists(self):
|
|
576
|
-
"""units.byte should have Dimension.information."""
|
|
577
|
-
self.assertEqual(units.byte.dimension, Dimension.information)
|
|
578
|
-
self.assertIn('B', units.byte.aliases)
|
|
579
|
-
|
|
580
|
-
def test_byte_to_bit_conversion(self):
|
|
581
|
-
"""1 byte should be 8 bits."""
|
|
582
|
-
result = units.byte(1).to(units.bit)
|
|
583
|
-
self.assertAlmostEqual(result.quantity, 8.0, places=10)
|
|
584
|
-
|
|
585
|
-
def test_bit_to_byte_conversion(self):
|
|
586
|
-
"""8 bits should be 1 byte."""
|
|
587
|
-
result = units.bit(8).to(units.byte)
|
|
588
|
-
self.assertAlmostEqual(result.quantity, 1.0, places=10)
|
|
589
|
-
|
|
590
|
-
def test_kibibyte_simplify(self):
|
|
591
|
-
"""1 kibibyte simplifies to 1024 bytes."""
|
|
592
|
-
kibibyte = Scale.kibi * units.byte
|
|
593
|
-
result = kibibyte(1).simplify()
|
|
594
|
-
self.assertAlmostEqual(result.quantity, 1024.0, places=10)
|
|
595
|
-
self.assertEqual(result.unit.shorthand, "B")
|
|
596
|
-
|
|
597
|
-
def test_kilobyte_simplify(self):
|
|
598
|
-
"""1 kilobyte simplifies to 1000 bytes."""
|
|
599
|
-
kilobyte = Scale.kilo * units.byte
|
|
600
|
-
result = kilobyte(1).simplify()
|
|
601
|
-
self.assertAlmostEqual(result.quantity, 1000.0, places=10)
|
|
602
|
-
self.assertEqual(result.unit.shorthand, "B")
|
|
603
|
-
|
|
604
|
-
def test_data_rate_dimension(self):
|
|
605
|
-
"""bytes/second should have information/time dimension."""
|
|
606
|
-
data_rate = units.byte / units.second
|
|
607
|
-
expected_dim = Dimension.information / Dimension.time
|
|
608
|
-
self.assertEqual(data_rate.dimension, expected_dim)
|
|
609
|
-
|
|
610
|
-
def test_information_orthogonal_to_physical(self):
|
|
611
|
-
"""Information dimension should be orthogonal to physical dimensions."""
|
|
612
|
-
# byte * meter should have both information and length
|
|
613
|
-
composite = units.byte * units.meter
|
|
614
|
-
self.assertNotEqual(composite.dimension, Dimension.information)
|
|
615
|
-
self.assertNotEqual(composite.dimension, Dimension.length)
|