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 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(ValueError):
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
- bromine_density = Ratio(Number(units.gram, quantity=3.119), Number(units.liter, Scale.milli))
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(ValueError):
462
- _ = (n == "5")
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
- # TODO -- consider using a dataclass
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
- def __init__(self, unit: Unit = units.none, scale: Scale = Scale.one, quantity = 1):
260
- self.unit = unit
261
- self.scale = scale
262
- self.quantity = quantity
263
- self.value = round(self.quantity * self.scale.value.evaluated, 15)
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, another_number: 'Number') -> 'Number':
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
- unit=self.unit * another_number.unit,
278
- scale=self.scale,
279
- quantity=self.quantity * another_number.quantity,
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, another_number: 'Number') -> 'Number':
283
- unit = self.unit / another_number.unit
284
- scale = self.scale / another_number.scale
285
- quantity = self.quantity / another_number.quantity
286
- return Number(unit, scale, quantity)
287
-
288
- def __eq__(self, another_number):
289
- if isinstance(another_number, Number):
290
- return (self.unit == another_number.unit) and \
291
- (self.quantity == another_number.quantity) and \
292
- (self.value == another_number.value)
293
- elif isinstance(another_number, Ratio):
294
- return self == another_number.evaluate()
295
- else:
296
- raise ValueError(f'"{another_number}" is not a Number or Ratio. Comparison not possible.')
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.2rc6
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="420" />
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
  [![tests](https://github.com/withtwoemms/ucon/workflows/tests/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Atests)
45
44
  [![codecov](https://codecov.io/gh/withtwoemms/ucon/graph/badge.svg?token=BNONQTRJWG)](https://codecov.io/gh/withtwoemms/ucon)
46
45
  [![publish](https://github.com/withtwoemms/ucon/workflows/publish/badge.svg)](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
- ```mermaid
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=rpKCH5olc9EI0tPXuiGXRlEfMFnsFGJ-Ntrn-ZV34fY,20854
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=is6cgQ7iwYo6_41S1b9VOydMFlN_kDtfbyH224Vjjcw,12463
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.2rc6.dist-info/licenses/LICENSE,sha256=-Djjiq2wM8Cc6fzTsdMbr_T2_uaX6Yorxcemr3GGkqc,1072
12
- ucon-0.3.2rc6.dist-info/METADATA,sha256=Zax97Y5GOFBLD7OFIMNSpWYcz_TPEJ5tM7eLARPDrT8,10996
13
- ucon-0.3.2rc6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- ucon-0.3.2rc6.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
15
- ucon-0.3.2rc6.dist-info/RECORD,,
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,,