ucon 0.3.1rc4__tar.gz → 0.3.2__tar.gz
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-0.3.1rc4 → ucon-0.3.2}/PKG-INFO +1 -1
- ucon-0.3.2/docs/unity-distance-metric-for-nearest-scale.md +72 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/test_core.py +102 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/core.py +87 -27
- {ucon-0.3.1rc4 → ucon-0.3.2}/ucon.egg-info/PKG-INFO +1 -1
- {ucon-0.3.1rc4 → ucon-0.3.2}/ucon.egg-info/SOURCES.txt +1 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/.github/workflows/publish.yaml +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/.github/workflows/tests.yaml +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/.gitignore +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/LICENSE +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/README.md +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/ROADMAP.md +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/noxfile.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/requirements.txt +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/setup.cfg +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/setup.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/tests/__init__.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/__init__.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/test_dimension.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/test_unit.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/test_units.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/__init__.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/dimension.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/unit.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/units.py +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/ucon.egg-info/dependency_links.txt +0 -0
- {ucon-0.3.1rc4 → ucon-0.3.2}/ucon.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# The Unity-Distance Metric
|
|
2
|
+
|
|
3
|
+
The **Unity-Distance Metric** provides a principled method for selecting the most natural scale prefix (e.g., kilo, mega, milli) for a given value.
|
|
4
|
+
|
|
5
|
+
It defines “nearness” not as linear numerical proximity, but as proximity in **order of magnitude** — that is, how close a value is to **unity (1)** when normalized by a candidate scale. This shift from linear to logarithmic thinking brings mathematical rigor to what users intuitively mean when they say a quantity _“belongs”_ to a certain scale.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Why Linear Distance Fails
|
|
10
|
+
|
|
11
|
+
Linear distance (`|x − s|`) feels intuitive because it mirrors ordinary subtraction. However, it collapses at extremes: the gap between 10³ and 10⁶ is treated as 999,000 rather than just _“three orders apart.”_
|
|
12
|
+
|
|
13
|
+
As magnitudes grow, linear distance overweights large scales and underweights small ones.
|
|
14
|
+
This causes the selection to favor higher prefixes (like **mega**) even when a value (like 50,000) clearly fits better under **kilo**.
|
|
15
|
+
In physical reasoning, a thousandfold difference should count equally regardless of where it occurs and **logarithmic distance** achieves that symmetry.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 2. Defining the Unity-Distance
|
|
20
|
+
|
|
21
|
+
For a given value _x_ and candidate scale _s_, the unity-distance is defined as:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
d(s, x) = | log₁₀(x / s) |
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This measures how far _x_ is from **unity (1)** after being divided by the scale.
|
|
28
|
+
|
|
29
|
+
- If dividing by _s_ yields exactly 1, then _d = 0_ (perfect match).
|
|
30
|
+
- If _x/s = 10_ or _0.1_, the distance is 1 (one order of magnitude away).
|
|
31
|
+
|
|
32
|
+
This formulation directly expresses the idea: _“How close to 1 does this value become when scaled?”_
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 3. The Bias Factor: Human Perception of Overshoot
|
|
37
|
+
|
|
38
|
+
While logarithmic distance correctly measures proportional difference, human intuition distinguishes between **overshooting** and **undershooting** unity.
|
|
39
|
+
|
|
40
|
+
Describing 50,000 as _“fifty thousands”_ feels natural, while _“0.05 millions”_ feels wrong even though both are one order of magnitude apart.
|
|
41
|
+
To capture this asymmetry, the Unity-Distance Metric introduces a **bias factor**:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
if ratio < 1:
|
|
45
|
+
diff /= undershoot_bias # undershoot_bias < 1
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
When the ratio `x/s < 1` (meaning the scale candidate is too large), the distance is **divided by a bias constant < 1**, penalizing undershoots more heavily.
|
|
49
|
+
This anchors the metric in _perceptual realism_ favoring scales yielding results slightly above 1 over those just below.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 4. Why Log Base 10 Works for Binary Prefixes
|
|
54
|
+
|
|
55
|
+
Even though binary prefixes (kibi, mebi) use base 2, the base-10 logarithm remains effective because it measures **proportional magnitude**, not representation base.
|
|
56
|
+
|
|
57
|
+
Key insight:
|
|
58
|
+
`log₁₀(2¹⁰) ≈ 3` — meaning a binary thousand (1024) is roughly one decimal order above 10³.
|
|
59
|
+
|
|
60
|
+
Thus, log₁₀ space preserves the **relative alignment** between decimal and binary prefixes.
|
|
61
|
+
A suitable bias factor ensures that **1024** can be interpreted as either _kilo_ or _kibi_, depending on user preference — without distorting order relationships.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 5. Summary
|
|
66
|
+
|
|
67
|
+
The Unity-Distance Metric offers a unified, perceptually accurate method for determining the most natural scale prefix.
|
|
68
|
+
By measuring distance in orders of magnitude and adjusting with a bias that reflects human expectation, it harmonizes **mathematical rigor** with **intuitive scale reasoning**.
|
|
69
|
+
|
|
70
|
+
Linear proximity is easy to compute, but logarithmic unity-distance expresses what users mean when they say:
|
|
71
|
+
|
|
72
|
+
> _“It’s about a thousand,”* or *“roughly a megabyte.”_
|
|
@@ -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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|