ucon 0.3.2rc6__py3-none-any.whl → 0.3.3rc1__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_core.py +54 -6
- ucon/core.py +74 -26
- {ucon-0.3.2rc6.dist-info → ucon-0.3.3rc1.dist-info}/METADATA +5 -32
- {ucon-0.3.2rc6.dist-info → ucon-0.3.3rc1.dist-info}/RECORD +7 -7
- {ucon-0.3.2rc6.dist-info → ucon-0.3.3rc1.dist-info}/WHEEL +0 -0
- {ucon-0.3.2rc6.dist-info → ucon-0.3.3rc1.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.3.2rc6.dist-info → ucon-0.3.3rc1.dist-info}/top_level.txt +0 -0
tests/ucon/test_core.py
CHANGED
|
@@ -109,6 +109,15 @@ class TestScale(TestCase):
|
|
|
109
109
|
self.assertEqual(Scale.one, Scale.kibi / Scale.kibi)
|
|
110
110
|
self.assertEqual(Scale.one, Scale.kibi / Scale.kilo)
|
|
111
111
|
|
|
112
|
+
def test___mul__(self):
|
|
113
|
+
self.assertEqual(Scale.kilo, Scale.kilo * Scale.one)
|
|
114
|
+
self.assertEqual(Scale.kilo, Scale.one * Scale.kilo)
|
|
115
|
+
self.assertEqual(Scale.one, Scale.kilo * Scale.milli)
|
|
116
|
+
self.assertEqual(Scale.deca, Scale.hecto * Scale.deci)
|
|
117
|
+
self.assertEqual(Scale.mega, Scale.kilo * Scale.kibi)
|
|
118
|
+
self.assertEqual(Scale.giga, Scale.mega * Scale.kilo)
|
|
119
|
+
self.assertEqual(Scale.one, Scale.one * Scale.one)
|
|
120
|
+
|
|
112
121
|
def test___lt__(self):
|
|
113
122
|
self.assertLess(Scale.one, Scale.kilo)
|
|
114
123
|
|
|
@@ -121,6 +130,43 @@ class TestScale(TestCase):
|
|
|
121
130
|
self.assertIsInstance(Scale.all(), dict)
|
|
122
131
|
|
|
123
132
|
|
|
133
|
+
class TestScaleMultiplicationAdditional(TestCase):
|
|
134
|
+
|
|
135
|
+
def test_decimal_combinations(self):
|
|
136
|
+
self.assertEqual(Scale.kilo * Scale.centi, Scale.deca)
|
|
137
|
+
self.assertEqual(Scale.kilo * Scale.milli, Scale.one)
|
|
138
|
+
self.assertEqual(Scale.hecto * Scale.deci, Scale.deca)
|
|
139
|
+
|
|
140
|
+
def test_binary_combinations(self):
|
|
141
|
+
# kibi (2^10) * mebi (2^20) = 2^30 (should round to nearest known)
|
|
142
|
+
result = Scale.kibi * Scale.mebi
|
|
143
|
+
self.assertEqual(result.value.base, 2)
|
|
144
|
+
self.assertTrue(isinstance(result, Scale))
|
|
145
|
+
|
|
146
|
+
def test_mixed_base_combination(self):
|
|
147
|
+
self.assertEqual(Scale.mega, Scale.kilo * Scale.kibi)
|
|
148
|
+
|
|
149
|
+
def test_result_has_no_exact_match_fallbacks_to_nearest(self):
|
|
150
|
+
# Suppose the exponent product is not in Scale.all()
|
|
151
|
+
# e.g. kilo (10^3) * deci (10^-1) = 10^2 = hecto
|
|
152
|
+
result = Scale.kilo * Scale.deci
|
|
153
|
+
self.assertEqual(result, Scale.hecto)
|
|
154
|
+
|
|
155
|
+
def test_order_independence(self):
|
|
156
|
+
# Associativity of multiplication
|
|
157
|
+
self.assertEqual(Scale.kilo * Scale.centi, Scale.centi * Scale.kilo)
|
|
158
|
+
|
|
159
|
+
def test_non_scale_operand_returns_not_implemented(self):
|
|
160
|
+
with self.assertRaises(TypeError):
|
|
161
|
+
Scale.kilo * 2
|
|
162
|
+
|
|
163
|
+
def test_large_exponent_clamping(self):
|
|
164
|
+
# simulate a very large multiplication, should still resolve
|
|
165
|
+
result = Scale.mega * Scale.mega # 10^12, not defined -> nearest Scale
|
|
166
|
+
self.assertIsInstance(result, Scale)
|
|
167
|
+
self.assertEqual(result.value.base, 10)
|
|
168
|
+
|
|
169
|
+
|
|
124
170
|
class TestScaleDivisionAdditional(TestCase):
|
|
125
171
|
|
|
126
172
|
def test_division_same_base_large_gap(self):
|
|
@@ -255,7 +301,7 @@ class TestNumber(TestCase):
|
|
|
255
301
|
|
|
256
302
|
def test___eq__(self):
|
|
257
303
|
self.assertEqual(self.number, Ratio(self.number)) # 1 gram / 1
|
|
258
|
-
with self.assertRaises(
|
|
304
|
+
with self.assertRaises(TypeError):
|
|
259
305
|
self.number == 1
|
|
260
306
|
|
|
261
307
|
|
|
@@ -290,10 +336,12 @@ class TestRatio(TestCase):
|
|
|
290
336
|
self.assertEqual(self.one_half * self.three_halves, self.three_fourths)
|
|
291
337
|
|
|
292
338
|
def test___mul__(self):
|
|
293
|
-
|
|
339
|
+
n1 = Number(unit=units.gram, quantity=3.119)
|
|
340
|
+
n2 = Number(unit=units.liter, scale=Scale.milli)
|
|
341
|
+
bromine_density = Ratio(n1, n2)
|
|
294
342
|
|
|
295
343
|
# How many grams of bromine are in 2 milliliters?
|
|
296
|
-
two_milliliters_bromine = Number(units.liter, Scale.milli, 2)
|
|
344
|
+
two_milliliters_bromine = Number(unit=units.liter, scale=Scale.milli, quantity=2)
|
|
297
345
|
ratio = two_milliliters_bromine.as_ratio() * bromine_density
|
|
298
346
|
answer = ratio.evaluate()
|
|
299
347
|
self.assertEqual(answer.unit.dimension, Dimension.mass)
|
|
@@ -319,7 +367,7 @@ class TestRatio(TestCase):
|
|
|
319
367
|
|
|
320
368
|
def test___repr__(self):
|
|
321
369
|
self.assertEqual(str(self.one_ratio), '<1.0 >')
|
|
322
|
-
self.assertEqual(str(self.two_ratio), '<2 > / <1 >')
|
|
370
|
+
self.assertEqual(str(self.two_ratio), '<2 > / <1.0 >')
|
|
323
371
|
self.assertEqual(str(self.two_ratio.evaluate()), '<2.0 >')
|
|
324
372
|
|
|
325
373
|
|
|
@@ -458,8 +506,8 @@ class TestNumberEdgeCases(TestCase):
|
|
|
458
506
|
|
|
459
507
|
def test_equality_with_non_number_raises_value_error(self):
|
|
460
508
|
n = Number()
|
|
461
|
-
with self.assertRaises(
|
|
462
|
-
|
|
509
|
+
with self.assertRaises(TypeError):
|
|
510
|
+
n == '5'
|
|
463
511
|
|
|
464
512
|
def test_equality_between_numbers_and_ratios(self):
|
|
465
513
|
n1 = Number(quantity=10)
|
ucon/core.py
CHANGED
|
@@ -15,6 +15,7 @@ Classes
|
|
|
15
15
|
Together, these classes allow full arithmetic, conversion, and introspection
|
|
16
16
|
of physical quantities with explicit dimensional semantics.
|
|
17
17
|
"""
|
|
18
|
+
from dataclasses import dataclass, field
|
|
18
19
|
from enum import Enum
|
|
19
20
|
from functools import lru_cache, reduce, total_ordering
|
|
20
21
|
from math import log2
|
|
@@ -33,6 +34,8 @@ class Exponent:
|
|
|
33
34
|
|
|
34
35
|
Provides comparison and division semantics used internally to represent
|
|
35
36
|
magnitude prefixes (e.g., kilo, mega, micro).
|
|
37
|
+
|
|
38
|
+
TODO (wittwemms): embrace fractional exponents for closure on multiplication/division.
|
|
36
39
|
"""
|
|
37
40
|
bases = {2: log2, 10: log10}
|
|
38
41
|
|
|
@@ -198,6 +201,31 @@ class Scale(Enum):
|
|
|
198
201
|
|
|
199
202
|
return min(candidates, key=distance)
|
|
200
203
|
|
|
204
|
+
def __mul__(self, other: 'Scale'):
|
|
205
|
+
"""
|
|
206
|
+
Multiply two Scales together.
|
|
207
|
+
|
|
208
|
+
Always returns a `Scale`, representing the resulting order of magnitude.
|
|
209
|
+
If no exact prefix match exists, returns the nearest known Scale.
|
|
210
|
+
"""
|
|
211
|
+
if not isinstance(other, Scale):
|
|
212
|
+
return NotImplemented
|
|
213
|
+
|
|
214
|
+
if self is Scale.one:
|
|
215
|
+
return other
|
|
216
|
+
if other is Scale.one:
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
result = self.value * other.value # delegates to Exponent.__mul__
|
|
220
|
+
include_binary = 2 in {self.value.base, other.value.base}
|
|
221
|
+
|
|
222
|
+
if isinstance(result, Exponent):
|
|
223
|
+
match = Scale.all().get(result.parts())
|
|
224
|
+
if match:
|
|
225
|
+
return Scale[match]
|
|
226
|
+
|
|
227
|
+
return Scale.nearest(float(result), include_binary=include_binary)
|
|
228
|
+
|
|
201
229
|
def __truediv__(self, other: 'Scale'):
|
|
202
230
|
"""
|
|
203
231
|
Divide one Scale by another.
|
|
@@ -210,7 +238,6 @@ class Scale(Enum):
|
|
|
210
238
|
|
|
211
239
|
if self == other:
|
|
212
240
|
return Scale.one
|
|
213
|
-
|
|
214
241
|
if other is Scale.one:
|
|
215
242
|
return self
|
|
216
243
|
|
|
@@ -241,7 +268,9 @@ class Scale(Enum):
|
|
|
241
268
|
return self.value == other.value
|
|
242
269
|
|
|
243
270
|
|
|
244
|
-
|
|
271
|
+
Quantifiable = Union['Number', 'Ratio']
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
245
274
|
class Number:
|
|
246
275
|
"""
|
|
247
276
|
Represents a **numeric quantity** with an associated :class:`Unit` and :class:`Scale`.
|
|
@@ -256,11 +285,14 @@ class Number:
|
|
|
256
285
|
>>> speed
|
|
257
286
|
<2.5 (m/s)>
|
|
258
287
|
"""
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
288
|
+
quantity: Union[float, int] = 1.0
|
|
289
|
+
unit: Unit = units.none
|
|
290
|
+
scale: Scale = field(default_factory=lambda: Scale.one)
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def value(self) -> float:
|
|
294
|
+
"""Return numeric magnitude as quantity × scale factor."""
|
|
295
|
+
return round(self.quantity * self.scale.value.evaluated, 15)
|
|
264
296
|
|
|
265
297
|
def simplify(self):
|
|
266
298
|
return Number(unit=self.unit, quantity=self.value)
|
|
@@ -272,28 +304,44 @@ class Number:
|
|
|
272
304
|
def as_ratio(self):
|
|
273
305
|
return Ratio(self)
|
|
274
306
|
|
|
275
|
-
def __mul__(self,
|
|
307
|
+
def __mul__(self, other: Quantifiable) -> 'Number':
|
|
308
|
+
if not isinstance(other, (Number, Ratio)):
|
|
309
|
+
return NotImplemented
|
|
310
|
+
|
|
311
|
+
if isinstance(other, Ratio):
|
|
312
|
+
other = other.evaluate()
|
|
313
|
+
|
|
276
314
|
return Number(
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
315
|
+
quantity=self.quantity * other.quantity,
|
|
316
|
+
unit=self.unit * other.unit,
|
|
317
|
+
scale=self.scale * other.scale,
|
|
280
318
|
)
|
|
281
319
|
|
|
282
|
-
def __truediv__(self,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
320
|
+
def __truediv__(self, other: Quantifiable) -> 'Number':
|
|
321
|
+
if not isinstance(other, (Number, Ratio)):
|
|
322
|
+
return NotImplemented
|
|
323
|
+
|
|
324
|
+
if isinstance(other, Ratio):
|
|
325
|
+
other = other.evaluate()
|
|
326
|
+
|
|
327
|
+
return Number(
|
|
328
|
+
quantity=self.quantity / other.quantity,
|
|
329
|
+
unit=self.unit / other.unit,
|
|
330
|
+
scale=self.scale / other.scale,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def __eq__(self, other: Quantifiable) -> bool:
|
|
334
|
+
if not isinstance(other, (Number, Ratio)):
|
|
335
|
+
raise TypeError(f'Cannot compare Number to non-Number/Ratio type: {type(other)}')
|
|
336
|
+
|
|
337
|
+
elif isinstance(other, Ratio):
|
|
338
|
+
other = other.evaluate()
|
|
339
|
+
|
|
340
|
+
# Compare on evaluated numeric magnitude and exact unit
|
|
341
|
+
return (
|
|
342
|
+
self.unit == other.unit and
|
|
343
|
+
abs(self.value - other.value) < 1e-12
|
|
344
|
+
)
|
|
297
345
|
|
|
298
346
|
def __repr__(self):
|
|
299
347
|
return f'<{self.quantity} {"" if self.scale.name == "one" else self.scale.name}{self.unit.name}>'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ucon
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3rc1
|
|
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
|
|
@@ -34,17 +34,18 @@ Dynamic: maintainer
|
|
|
34
34
|
Dynamic: maintainer-email
|
|
35
35
|
Dynamic: summary
|
|
36
36
|
|
|
37
|
-
<img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/dde6c7d3b8a7d79eb1006ace03fb834e044cdebc/ucon-logo.png" align="left" width="
|
|
37
|
+
<img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/dde6c7d3b8a7d79eb1006ace03fb834e044cdebc/ucon-logo.png" align="left" width="200" />
|
|
38
38
|
|
|
39
39
|
# ucon
|
|
40
40
|
|
|
41
41
|
> Pronounced: _yoo · cahn_
|
|
42
|
-
> A lightweight, **unit-aware computation library** for Python — built on first-principles.
|
|
43
42
|
|
|
44
43
|
[](https://github.com/withtwoemms/ucon/actions?query=workflow%3Atests)
|
|
45
44
|
[](https://codecov.io/gh/withtwoemms/ucon)
|
|
46
45
|
[](https://github.com/withtwoemms/ucon/actions?query=workflow%3Apublish)
|
|
47
46
|
|
|
47
|
+
> A lightweight, **unit-aware computation library** for Python — built on first-principles.
|
|
48
|
+
|
|
48
49
|
---
|
|
49
50
|
|
|
50
51
|
## Overview
|
|
@@ -81,35 +82,7 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
|
|
|
81
82
|
|
|
82
83
|
`ucon` models unit math through a hierarchy where each layer builds on the last:
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
---
|
|
86
|
-
config:
|
|
87
|
-
layout: elk
|
|
88
|
-
elk:
|
|
89
|
-
mergeEdges: true # Combines parallel edges
|
|
90
|
-
nodePlacementStrategy: SIMPLE # Other options: SIMPLE, NETWORK_SIMPLEX, BRANDES_KOEPF (default)
|
|
91
|
-
---
|
|
92
|
-
flowchart LR
|
|
93
|
-
%% --- Algebraic substrate ---
|
|
94
|
-
subgraph "Algebraic Substrate"
|
|
95
|
-
A[Exponent] --> B[Scale]
|
|
96
|
-
end
|
|
97
|
-
%% --- Physical ontology ---
|
|
98
|
-
subgraph "Physical Ontology"
|
|
99
|
-
D[Dimension] --> E[Unit]
|
|
100
|
-
end
|
|
101
|
-
%% --- Value layer ---
|
|
102
|
-
subgraph "Value Layer"
|
|
103
|
-
F[Number]
|
|
104
|
-
G[Ratio]
|
|
105
|
-
end
|
|
106
|
-
%% --- Cross-layer relationships ---
|
|
107
|
-
E --> F
|
|
108
|
-
B --> F
|
|
109
|
-
%% Ratio composes Numbers and also evaluates to a Number
|
|
110
|
-
F --> G
|
|
111
|
-
G --> F
|
|
112
|
-
```
|
|
85
|
+
<img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/f3518d37445301950026fc9ffd1bd062768005fe/ucon.data-model.png align="center" alt="ucon Data Model" width=600/>
|
|
113
86
|
|
|
114
87
|
## Why `ucon`?
|
|
115
88
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
tests/ucon/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
tests/ucon/test_core.py,sha256=
|
|
2
|
+
tests/ucon/test_core.py,sha256=a48nfTqg-WJ_jzjVT6KtsBb6E8oPUZaqCmscKDzlG_g,22871
|
|
3
3
|
tests/ucon/test_dimension.py,sha256=JyA9ySFvohs2l6oK77ehCQ7QvvFVqB_9t0iC7CUErjw,7296
|
|
4
4
|
tests/ucon/test_unit.py,sha256=vEPOeSxFBqcRBAUczCN9KPo_dTmLk4LQExPSt6UGVa4,5712
|
|
5
5
|
tests/ucon/test_units.py,sha256=248JZbo8RVvG_q3T0IhKG43vxM4F_2Xgf4_RjGZNsFM,704
|
|
6
6
|
ucon/__init__.py,sha256=ZWWLodIiG17OgCfoAm532wpwmJzdRXlUGX3w6OBxFeQ,1743
|
|
7
|
-
ucon/core.py,sha256=
|
|
7
|
+
ucon/core.py,sha256=QI0aayUm0rgggdD7_zvdrmV26dbEARCJ6Yj5gn5PitI,13729
|
|
8
8
|
ucon/dimension.py,sha256=uUP05bPE8r15oFeD36DrclNIfBsugV7uFhvtJRYy4qI,6598
|
|
9
9
|
ucon/unit.py,sha256=KxOBcQNxciljGskhZCfktLhRF5u-rWgrTg565Flo3eI,3213
|
|
10
10
|
ucon/units.py,sha256=e1j7skYMghlMZi7l94EAgxq4_lNRDC7FcSooJoE_U50,3689
|
|
11
|
-
ucon-0.3.
|
|
12
|
-
ucon-0.3.
|
|
13
|
-
ucon-0.3.
|
|
14
|
-
ucon-0.3.
|
|
15
|
-
ucon-0.3.
|
|
11
|
+
ucon-0.3.3rc1.dist-info/licenses/LICENSE,sha256=-Djjiq2wM8Cc6fzTsdMbr_T2_uaX6Yorxcemr3GGkqc,1072
|
|
12
|
+
ucon-0.3.3rc1.dist-info/METADATA,sha256=j9qlRr2LbkyV4WbRFcMjfMSI_hOEujAHb5iBGHO-g0Y,10606
|
|
13
|
+
ucon-0.3.3rc1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
ucon-0.3.3rc1.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
|
|
15
|
+
ucon-0.3.3rc1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|