ucon 0.3.1rc3__py3-none-any.whl → 0.3.2__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 +102 -0
- ucon/core.py +87 -27
- {ucon-0.3.1rc3.dist-info → ucon-0.3.2.dist-info}/METADATA +2 -2
- {ucon-0.3.1rc3.dist-info → ucon-0.3.2.dist-info}/RECORD +7 -7
- {ucon-0.3.1rc3.dist-info → ucon-0.3.2.dist-info}/WHEEL +0 -0
- {ucon-0.3.1rc3.dist-info → ucon-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.3.1rc3.dist-info → ucon-0.3.2.dist-info}/top_level.txt +0 -0
tests/ucon/test_core.py
CHANGED
|
@@ -105,6 +105,7 @@ class TestScale(TestCase):
|
|
|
105
105
|
self.assertEqual(Scale.deca, Scale.kilo / Scale.hecto)
|
|
106
106
|
self.assertEqual(Scale._kibi, Scale.one / Scale.kibi)
|
|
107
107
|
self.assertEqual(Scale.kibi, Scale.kibi / Scale.one)
|
|
108
|
+
self.assertEqual(Scale.one, Scale.one / Scale.one)
|
|
108
109
|
self.assertEqual(Scale.one, Scale.kibi / Scale.kibi)
|
|
109
110
|
self.assertEqual(Scale.one, Scale.kibi / Scale.kilo)
|
|
110
111
|
|
|
@@ -120,6 +121,92 @@ class TestScale(TestCase):
|
|
|
120
121
|
self.assertIsInstance(Scale.all(), dict)
|
|
121
122
|
|
|
122
123
|
|
|
124
|
+
class TestScaleDivisionAdditional(TestCase):
|
|
125
|
+
|
|
126
|
+
def test_division_same_base_large_gap(self):
|
|
127
|
+
# kilo / milli = mega
|
|
128
|
+
self.assertEqual(Scale.kilo / Scale.milli, Scale.mega)
|
|
129
|
+
# milli / kilo = micro
|
|
130
|
+
self.assertEqual(Scale.milli / Scale.kilo, Scale.micro)
|
|
131
|
+
|
|
132
|
+
def test_division_cross_base_scales(self):
|
|
133
|
+
# Decimal vs binary cross-base — should return nearest matching scale
|
|
134
|
+
result = Scale.kilo / Scale.kibi
|
|
135
|
+
self.assertIsInstance(result, Scale)
|
|
136
|
+
# They’re roughly equal, so nearest should be Scale.one
|
|
137
|
+
self.assertEqual(result, Scale.one)
|
|
138
|
+
|
|
139
|
+
def test_division_binary_inverse_scales(self):
|
|
140
|
+
self.assertEqual(Scale.kibi / Scale.kibi, Scale.one)
|
|
141
|
+
self.assertEqual(Scale.kibi / Scale.mebi, Scale._kibi)
|
|
142
|
+
self.assertEqual(Scale.mebi / Scale.kibi, Scale.kibi)
|
|
143
|
+
|
|
144
|
+
def test_division_unmatched_returns_nearest(self):
|
|
145
|
+
# giga / kibi is a weird combo → nearest mega or similar
|
|
146
|
+
result = Scale.giga / Scale.kibi
|
|
147
|
+
self.assertIsInstance(result, Scale)
|
|
148
|
+
self.assertIn(result, Scale)
|
|
149
|
+
|
|
150
|
+
def test_division_type_safety(self):
|
|
151
|
+
# Ensure non-Scale raises NotImplemented
|
|
152
|
+
with self.assertRaises(TypeError):
|
|
153
|
+
Scale.kilo / 42
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestScaleNearestAdditional(TestCase):
|
|
157
|
+
|
|
158
|
+
def test_nearest_handles_zero(self):
|
|
159
|
+
self.assertEqual(Scale.nearest(0), Scale.one)
|
|
160
|
+
|
|
161
|
+
def test_nearest_handles_negative_values(self):
|
|
162
|
+
# Only magnitude matters, not sign
|
|
163
|
+
self.assertEqual(Scale.nearest(-1000), Scale.kilo)
|
|
164
|
+
self.assertEqual(Scale.nearest(-0.001), Scale.milli)
|
|
165
|
+
|
|
166
|
+
def test_nearest_with_undershoot_bias_effect(self):
|
|
167
|
+
# Lower bias should make undershoot (ratios < 1) less penalized
|
|
168
|
+
# This test ensures the bias argument doesn’t break ordering
|
|
169
|
+
s_default = Scale.nearest(50_000, undershoot_bias=0.75)
|
|
170
|
+
s_stronger_bias = Scale.nearest(50_000, undershoot_bias=0.9)
|
|
171
|
+
# The result shouldn't flip to something wildly different
|
|
172
|
+
self.assertIn(s_default, [Scale.kilo, Scale.mega])
|
|
173
|
+
self.assertIn(s_stronger_bias, [Scale.kilo, Scale.mega])
|
|
174
|
+
|
|
175
|
+
def test_nearest_respects_binary_preference_flag(self):
|
|
176
|
+
# Confirm that enabling binary changes candidate set
|
|
177
|
+
decimal_result = Scale.nearest(2**10)
|
|
178
|
+
binary_result = Scale.nearest(2**10, include_binary=True)
|
|
179
|
+
self.assertNotEqual(decimal_result, binary_result)
|
|
180
|
+
self.assertEqual(binary_result, Scale.kibi)
|
|
181
|
+
|
|
182
|
+
def test_nearest_upper_and_lower_extremes(self):
|
|
183
|
+
self.assertEqual(Scale.nearest(10**9), Scale.giga)
|
|
184
|
+
self.assertEqual(Scale.nearest(10**-9), Scale.nano)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TestScaleInternals(TestCase):
|
|
188
|
+
|
|
189
|
+
def test_decimal_and_binary_sets_are_disjoint(self):
|
|
190
|
+
decimal_bases = {s.value.base for s in Scale._decimal_scales()}
|
|
191
|
+
binary_bases = {s.value.base for s in Scale._binary_scales()}
|
|
192
|
+
self.assertNotEqual(decimal_bases, binary_bases)
|
|
193
|
+
self.assertEqual(decimal_bases, {10})
|
|
194
|
+
self.assertEqual(binary_bases, {2})
|
|
195
|
+
|
|
196
|
+
def test_all_and_by_value_consistency(self):
|
|
197
|
+
mapping = Scale.all()
|
|
198
|
+
value_map = Scale.by_value()
|
|
199
|
+
# Each value’s evaluated form should appear in by_value keys
|
|
200
|
+
for (base, power), name in mapping.items():
|
|
201
|
+
val = Scale[name].value.evaluated
|
|
202
|
+
self.assertIn(round(val, 15), value_map)
|
|
203
|
+
|
|
204
|
+
def test_all_and_by_value_are_cached(self):
|
|
205
|
+
# Call multiple times and ensure they’re same object (cached)
|
|
206
|
+
self.assertIs(Scale.all(), Scale.all())
|
|
207
|
+
self.assertIs(Scale.by_value(), Scale.by_value())
|
|
208
|
+
|
|
209
|
+
|
|
123
210
|
class TestNumber(TestCase):
|
|
124
211
|
|
|
125
212
|
number = Number(unit=units.gram, quantity=1)
|
|
@@ -288,6 +375,21 @@ class TestExponentEdgeCases(TestCase):
|
|
|
288
375
|
|
|
289
376
|
class TestScaleEdgeCases(TestCase):
|
|
290
377
|
|
|
378
|
+
def test_nearest_prefers_decimal_by_default(self):
|
|
379
|
+
self.assertEqual(Scale.nearest(1024), Scale.kilo)
|
|
380
|
+
self.assertEqual(Scale.nearest(50_000), Scale.kilo)
|
|
381
|
+
self.assertEqual(Scale.nearest(1/1024), Scale.milli)
|
|
382
|
+
|
|
383
|
+
def test_nearest_includes_binary_when_opted_in(self):
|
|
384
|
+
self.assertEqual(Scale.nearest(1/1024, include_binary=True), Scale._kibi)
|
|
385
|
+
self.assertEqual(Scale.nearest(1024, include_binary=True), Scale.kibi)
|
|
386
|
+
self.assertEqual(Scale.nearest(50_000, include_binary=True), Scale.kibi)
|
|
387
|
+
self.assertEqual(Scale.nearest(2**20, include_binary=True), Scale.mebi)
|
|
388
|
+
|
|
389
|
+
def test_nearest_subunit_behavior(self):
|
|
390
|
+
self.assertEqual(Scale.nearest(0.0009), Scale.milli)
|
|
391
|
+
self.assertEqual(Scale.nearest(1e-7), Scale.micro)
|
|
392
|
+
|
|
291
393
|
def test_division_same_base_scales(self):
|
|
292
394
|
result = Scale.kilo / Scale.milli
|
|
293
395
|
self.assertIsInstance(result, Scale)
|
ucon/core.py
CHANGED
|
@@ -16,10 +16,10 @@ Together, these classes allow full arithmetic, conversion, and introspection
|
|
|
16
16
|
of physical quantities with explicit dimensional semantics.
|
|
17
17
|
"""
|
|
18
18
|
from enum import Enum
|
|
19
|
-
from functools import reduce, total_ordering
|
|
19
|
+
from functools import lru_cache, reduce, total_ordering
|
|
20
20
|
from math import log2
|
|
21
21
|
from math import log10
|
|
22
|
-
from typing import Tuple, Union
|
|
22
|
+
from typing import Dict, Tuple, Union
|
|
23
23
|
|
|
24
24
|
from ucon import units
|
|
25
25
|
from ucon.unit import Unit
|
|
@@ -131,8 +131,10 @@ class Scale(Enum):
|
|
|
131
131
|
|
|
132
132
|
Each entry stores its numeric scaling factor (e.g., `kilo = 10³`).
|
|
133
133
|
"""
|
|
134
|
+
gibi = Exponent(2, 30)
|
|
134
135
|
mebi = Exponent(2, 20)
|
|
135
136
|
kibi = Exponent(2, 10)
|
|
137
|
+
giga = Exponent(10, 9)
|
|
136
138
|
mega = Exponent(10, 6)
|
|
137
139
|
kilo = Exponent(10, 3)
|
|
138
140
|
hecto = Exponent(10, 2)
|
|
@@ -142,43 +144,101 @@ class Scale(Enum):
|
|
|
142
144
|
centi = Exponent(10,-2)
|
|
143
145
|
milli = Exponent(10,-3)
|
|
144
146
|
micro = Exponent(10,-6)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
nano = Exponent(10,-9)
|
|
148
|
+
_kibi = Exponent(2,-10) # "kibi" inverse
|
|
149
|
+
_mebi = Exponent(2,-20) # "mebi" inverse
|
|
150
|
+
_gibi = Exponent(2,-30) # "gibi" inverse
|
|
147
151
|
|
|
148
152
|
@staticmethod
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
@lru_cache(maxsize=1)
|
|
154
|
+
def all() -> Dict[Tuple[int, int], str]:
|
|
155
|
+
"""Return a map from (base, power) → Scale name."""
|
|
156
|
+
return {(s.value.base, s.value.power): s.name for s in Scale}
|
|
151
157
|
|
|
152
158
|
@staticmethod
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
@lru_cache(maxsize=1)
|
|
160
|
+
def by_value() -> Dict[float, str]:
|
|
161
|
+
"""
|
|
162
|
+
Return a map from evaluated numeric value → Scale name.
|
|
163
|
+
Cached after first access.
|
|
164
|
+
"""
|
|
165
|
+
return {round(s.value.evaluated, 15): s.name for s in Scale}
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
@lru_cache(maxsize=1)
|
|
169
|
+
def _decimal_scales(cls):
|
|
170
|
+
"""Return decimal (base-10) scales only."""
|
|
171
|
+
return list(s for s in cls if s.value.base == 10)
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
@lru_cache(maxsize=1)
|
|
175
|
+
def _binary_scales(cls):
|
|
176
|
+
"""Return binary (base-2) scales only."""
|
|
177
|
+
return list(s for s in cls if s.value.base == 2)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def nearest(cls, value: float, include_binary: bool = False, undershoot_bias: float = 0.75) -> "Scale":
|
|
181
|
+
"""
|
|
182
|
+
Return the Scale that best normalizes `value` toward 1 in log-space.
|
|
183
|
+
Optionally restricts to base-10 prefixes unless `include_binary=True`.
|
|
184
|
+
"""
|
|
185
|
+
if value == 0:
|
|
186
|
+
return Scale.one
|
|
187
|
+
|
|
188
|
+
abs_val = abs(value)
|
|
189
|
+
candidates = cls._decimal_scales() if not include_binary else list(cls)
|
|
190
|
+
|
|
191
|
+
def distance(scale: "Scale") -> float:
|
|
192
|
+
ratio = abs_val / scale.value.evaluated
|
|
193
|
+
diff = log10(ratio)
|
|
194
|
+
# Bias overshoots slightly more than undershoots
|
|
195
|
+
if ratio < 1:
|
|
196
|
+
diff /= undershoot_bias
|
|
197
|
+
return abs(diff)
|
|
198
|
+
|
|
199
|
+
return min(candidates, key=distance)
|
|
155
200
|
|
|
156
|
-
def __truediv__(self,
|
|
157
|
-
|
|
158
|
-
|
|
201
|
+
def __truediv__(self, other: 'Scale'):
|
|
202
|
+
"""
|
|
203
|
+
Divide one Scale by another.
|
|
204
|
+
|
|
205
|
+
Always returns a `Scale`, representing the resulting order of magnitude.
|
|
206
|
+
If no exact prefix match exists, returns the nearest known Scale.
|
|
207
|
+
"""
|
|
208
|
+
if not isinstance(other, Scale):
|
|
209
|
+
return NotImplemented
|
|
210
|
+
|
|
211
|
+
if self == other:
|
|
159
212
|
return Scale.one
|
|
160
|
-
if self.value.base == another_scale.value.base:
|
|
161
|
-
return Scale[Scale.all()[Exponent(self.value.base, power_diff).parts()]]
|
|
162
213
|
|
|
163
|
-
|
|
164
|
-
|
|
214
|
+
if other is Scale.one:
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
should_consider_binary = (self.value.base == 2) or (other.value.base == 2)
|
|
218
|
+
|
|
219
|
+
if self is Scale.one:
|
|
220
|
+
result = Exponent(other.value.base, -other.value.power)
|
|
221
|
+
name = Scale.all().get((result.base, result.power))
|
|
222
|
+
if name:
|
|
223
|
+
return Scale[name]
|
|
224
|
+
return Scale.nearest(float(result), include_binary=should_consider_binary)
|
|
165
225
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
226
|
+
result: Union[Exponent, float] = self.value / other.value
|
|
227
|
+
if isinstance(result, Exponent):
|
|
228
|
+
match = Scale.all().get(result.parts())
|
|
229
|
+
if match:
|
|
230
|
+
return Scale[match]
|
|
169
231
|
else:
|
|
170
|
-
|
|
171
|
-
closest_val = min(scale_exp_values, key=lambda val: abs(val - exp_quotient))
|
|
172
|
-
return Scale[Scale.by_value()[closest_val]]
|
|
232
|
+
return Scale.nearest(float(result), include_binary=should_consider_binary)
|
|
173
233
|
|
|
174
|
-
def __lt__(self,
|
|
175
|
-
return self.value <
|
|
234
|
+
def __lt__(self, other: 'Scale'):
|
|
235
|
+
return self.value < other.value
|
|
176
236
|
|
|
177
|
-
def __gt__(self,
|
|
178
|
-
return self.value >
|
|
237
|
+
def __gt__(self, other: 'Scale'):
|
|
238
|
+
return self.value > other.value
|
|
179
239
|
|
|
180
|
-
def __eq__(self,
|
|
181
|
-
return self.value ==
|
|
240
|
+
def __eq__(self, other: 'Scale'):
|
|
241
|
+
return self.value == other.value
|
|
182
242
|
|
|
183
243
|
|
|
184
244
|
# TODO -- consider using a dataclass
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ucon
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
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
|
|
@@ -82,7 +82,7 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
|
|
|
82
82
|
|
|
83
83
|
`ucon` models unit math through a hierarchy where each layer builds on the last:
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
<img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/f3518d37445301950026fc9ffd1bd062768005fe/ucon.data-model.png align="center" alt="ucon Data Model" width=600/>
|
|
86
86
|
|
|
87
87
|
## Why `ucon`?
|
|
88
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=rpKCH5olc9EI0tPXuiGXRlEfMFnsFGJ-Ntrn-ZV34fY,20854
|
|
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=is6cgQ7iwYo6_41S1b9VOydMFlN_kDtfbyH224Vjjcw,12463
|
|
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.2.dist-info/licenses/LICENSE,sha256=-Djjiq2wM8Cc6fzTsdMbr_T2_uaX6Yorxcemr3GGkqc,1072
|
|
12
|
+
ucon-0.3.2.dist-info/METADATA,sha256=2fLCgE7B-OlfN1fIOWI_B4JUxJBJAE9HDYph6JDyeFQ,10603
|
|
13
|
+
ucon-0.3.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
ucon-0.3.2.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
|
|
15
|
+
ucon-0.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|