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 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
- _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.1rc3
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
- ![Alt text](https://private-user-images.githubusercontent.com/7152453/505356617-0de63681-8c46-41d5-a0bf-a859ca9ab81b.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NjEzMjAzMjksIm5iZiI6MTc2MTMyMDAyOSwicGF0aCI6Ii83MTUyNDUzLzUwNTM1NjYxNy0wZGU2MzY4MS04YzQ2LTQxZDUtYTBiZi1hODU5Y2E5YWI4MWIucG5nP1gtQW16LUFsZ29yaXRobT1BV1M0LUhNQUMtU0hBMjU2JlgtQW16LUNyZWRlbnRpYWw9QUtJQVZDT0RZTFNBNTNQUUs0WkElMkYyMDI1MTAyNCUyRnVzLWVhc3QtMSUyRnMzJTJGYXdzNF9yZXF1ZXN0JlgtQW16LURhdGU9MjAyNTEwMjRUMTUzMzQ5WiZYLUFtei1FeHBpcmVzPTMwMCZYLUFtei1TaWduYXR1cmU9MzdhYjNiYjlmYTRlNDBiNzdkY2YxNzJiODU1ZGEwNGM2ZThlZGQ4MTc2OTgyY2Y1NWMxNjZhYzViMDg2MmY4NiZYLUFtei1TaWduZWRIZWFkZXJzPWhvc3QifQ.aQz0gq_44Yygw4wTMe3bER2xR6NiVM7Yb_rFIimD2hM "Data Model Diagram")
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=fD4c6K35RxCyaR5pHqBSr8DEHTqmMvYX9ZuVS6JdSwo,16408
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=DRymayBlXfVZMSHIt_Hi-xe8GPs9WYHOCCJ1QDaKn7Q,10643
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.1rc3.dist-info/licenses/LICENSE,sha256=-Djjiq2wM8Cc6fzTsdMbr_T2_uaX6Yorxcemr3GGkqc,1072
12
- ucon-0.3.1rc3.dist-info/METADATA,sha256=2RSmsE3LJM-isu7H89QzDGupZG04123OMN_FMsoPWvQ,11214
13
- ucon-0.3.1rc3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- ucon-0.3.1rc3.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
15
- ucon-0.3.1rc3.dist-info/RECORD,,
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