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.
Files changed (27) hide show
  1. {ucon-0.3.1rc4 → ucon-0.3.2}/PKG-INFO +1 -1
  2. ucon-0.3.2/docs/unity-distance-metric-for-nearest-scale.md +72 -0
  3. {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/test_core.py +102 -0
  4. {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/core.py +87 -27
  5. {ucon-0.3.1rc4 → ucon-0.3.2}/ucon.egg-info/PKG-INFO +1 -1
  6. {ucon-0.3.1rc4 → ucon-0.3.2}/ucon.egg-info/SOURCES.txt +1 -0
  7. {ucon-0.3.1rc4 → ucon-0.3.2}/.github/workflows/publish.yaml +0 -0
  8. {ucon-0.3.1rc4 → ucon-0.3.2}/.github/workflows/tests.yaml +0 -0
  9. {ucon-0.3.1rc4 → ucon-0.3.2}/.gitignore +0 -0
  10. {ucon-0.3.1rc4 → ucon-0.3.2}/LICENSE +0 -0
  11. {ucon-0.3.1rc4 → ucon-0.3.2}/README.md +0 -0
  12. {ucon-0.3.1rc4 → ucon-0.3.2}/ROADMAP.md +0 -0
  13. {ucon-0.3.1rc4 → ucon-0.3.2}/noxfile.py +0 -0
  14. {ucon-0.3.1rc4 → ucon-0.3.2}/requirements.txt +0 -0
  15. {ucon-0.3.1rc4 → ucon-0.3.2}/setup.cfg +0 -0
  16. {ucon-0.3.1rc4 → ucon-0.3.2}/setup.py +0 -0
  17. {ucon-0.3.1rc4 → ucon-0.3.2}/tests/__init__.py +0 -0
  18. {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/__init__.py +0 -0
  19. {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/test_dimension.py +0 -0
  20. {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/test_unit.py +0 -0
  21. {ucon-0.3.1rc4 → ucon-0.3.2}/tests/ucon/test_units.py +0 -0
  22. {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/__init__.py +0 -0
  23. {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/dimension.py +0 -0
  24. {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/unit.py +0 -0
  25. {ucon-0.3.1rc4 → ucon-0.3.2}/ucon/units.py +0 -0
  26. {ucon-0.3.1rc4 → ucon-0.3.2}/ucon.egg-info/dependency_links.txt +0 -0
  27. {ucon-0.3.1rc4 → ucon-0.3.2}/ucon.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucon
3
- Version: 0.3.1rc4
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
@@ -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
- _kibi = Exponent(2,-10)
146
- _mebi = Exponent(2,-20)
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
- def all():
150
- return dict(map(lambda x: ((x.value.base, x.value.power), x.name), Scale))
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
- def by_value():
154
- return dict(map(lambda x: (x.value.evaluated, x.name), Scale))
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, another_scale):
157
- power_diff = self.value.power - another_scale.value.power
158
- if self.value == another_scale.value:
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
- base_quotient = self.value.base / another_scale.value.base
164
- exp_quotient = round((base_quotient ** another_scale.value.power) * (self.value.base ** power_diff), 15)
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
- if Scale.one in [self, another_scale]:
167
- power = Exponent.bases[2](exp_quotient)
168
- return Scale[Scale.all()[Exponent(2, int(power)).parts()]]
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
- scale_exp_values = [Scale[Scale.all()[pair]].value.evaluated for pair in Scale.all().keys()]
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, another_scale):
175
- return self.value < another_scale.value
234
+ def __lt__(self, other: 'Scale'):
235
+ return self.value < other.value
176
236
 
177
- def __gt__(self, another_scale):
178
- return self.value > another_scale.value
237
+ def __gt__(self, other: 'Scale'):
238
+ return self.value > other.value
179
239
 
180
- def __eq__(self, another_scale):
181
- return self.value == another_scale.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.1rc4
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
@@ -7,6 +7,7 @@ requirements.txt
7
7
  setup.py
8
8
  .github/workflows/publish.yaml
9
9
  .github/workflows/tests.yaml
10
+ docs/unity-distance-metric-for-nearest-scale.md
10
11
  tests/__init__.py
11
12
  tests/ucon/__init__.py
12
13
  tests/ucon/test_core.py
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