ucon 0.3.2rc6__tar.gz → 0.3.3__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 (29) hide show
  1. {ucon-0.3.2rc6 → ucon-0.3.3}/.github/workflows/publish.yaml +1 -0
  2. {ucon-0.3.2rc6 → ucon-0.3.3}/PKG-INFO +5 -32
  3. {ucon-0.3.2rc6 → ucon-0.3.3}/README.md +4 -31
  4. {ucon-0.3.2rc6 → ucon-0.3.3}/ROADMAP.md +3 -3
  5. ucon-0.3.3/docs/proposals/interface-unifying-the-value-layer.md +131 -0
  6. ucon-0.3.3/docs/proposals/support-for-fractional-exponents.md +144 -0
  7. {ucon-0.3.2rc6 → ucon-0.3.3}/tests/ucon/test_core.py +54 -6
  8. {ucon-0.3.2rc6 → ucon-0.3.3}/ucon/core.py +74 -26
  9. {ucon-0.3.2rc6 → ucon-0.3.3}/ucon.egg-info/PKG-INFO +5 -32
  10. {ucon-0.3.2rc6 → ucon-0.3.3}/ucon.egg-info/SOURCES.txt +3 -1
  11. {ucon-0.3.2rc6 → ucon-0.3.3}/.github/workflows/tests.yaml +0 -0
  12. {ucon-0.3.2rc6 → ucon-0.3.3}/.gitignore +0 -0
  13. {ucon-0.3.2rc6 → ucon-0.3.3}/LICENSE +0 -0
  14. {ucon-0.3.2rc6/docs → ucon-0.3.3/docs/decisions}/unity-distance-metric-for-nearest-scale.md +0 -0
  15. {ucon-0.3.2rc6 → ucon-0.3.3}/noxfile.py +0 -0
  16. {ucon-0.3.2rc6 → ucon-0.3.3}/requirements.txt +0 -0
  17. {ucon-0.3.2rc6 → ucon-0.3.3}/setup.cfg +0 -0
  18. {ucon-0.3.2rc6 → ucon-0.3.3}/setup.py +0 -0
  19. {ucon-0.3.2rc6 → ucon-0.3.3}/tests/__init__.py +0 -0
  20. {ucon-0.3.2rc6 → ucon-0.3.3}/tests/ucon/__init__.py +0 -0
  21. {ucon-0.3.2rc6 → ucon-0.3.3}/tests/ucon/test_dimension.py +0 -0
  22. {ucon-0.3.2rc6 → ucon-0.3.3}/tests/ucon/test_unit.py +0 -0
  23. {ucon-0.3.2rc6 → ucon-0.3.3}/tests/ucon/test_units.py +0 -0
  24. {ucon-0.3.2rc6 → ucon-0.3.3}/ucon/__init__.py +0 -0
  25. {ucon-0.3.2rc6 → ucon-0.3.3}/ucon/dimension.py +0 -0
  26. {ucon-0.3.2rc6 → ucon-0.3.3}/ucon/unit.py +0 -0
  27. {ucon-0.3.2rc6 → ucon-0.3.3}/ucon/units.py +0 -0
  28. {ucon-0.3.2rc6 → ucon-0.3.3}/ucon.egg-info/dependency_links.txt +0 -0
  29. {ucon-0.3.2rc6 → ucon-0.3.3}/ucon.egg-info/top_level.txt +0 -0
@@ -14,6 +14,7 @@ jobs:
14
14
  uses: actions/checkout@v4
15
15
  with:
16
16
  fetch-depth: 0 # needed for ancestry check
17
+ ref: ${{ github.ref_name }}
17
18
 
18
19
  - name: Set up Python
19
20
  uses: actions/setup-python@v5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucon
3
- Version: 0.3.2rc6
3
+ Version: 0.3.3
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,14 +1,15 @@
1
- <img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/dde6c7d3b8a7d79eb1006ace03fb834e044cdebc/ucon-logo.png" align="left" width="420" />
1
+ <img src="https://gist.githubusercontent.com/withtwoemms/0cb9e6bc8df08f326771a89eeb790f8e/raw/dde6c7d3b8a7d79eb1006ace03fb834e044cdebc/ucon-logo.png" align="left" width="200" />
2
2
 
3
3
  # ucon
4
4
 
5
5
  > Pronounced: _yoo · cahn_
6
- > A lightweight, **unit-aware computation library** for Python — built on first-principles.
7
6
 
8
7
  [![tests](https://github.com/withtwoemms/ucon/workflows/tests/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Atests)
9
8
  [![codecov](https://codecov.io/gh/withtwoemms/ucon/graph/badge.svg?token=BNONQTRJWG)](https://codecov.io/gh/withtwoemms/ucon)
10
9
  [![publish](https://github.com/withtwoemms/ucon/workflows/publish/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Apublish)
11
10
 
11
+ > A lightweight, **unit-aware computation library** for Python — built on first-principles.
12
+
12
13
  ---
13
14
 
14
15
  ## Overview
@@ -45,35 +46,7 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
45
46
 
46
47
  `ucon` models unit math through a hierarchy where each layer builds on the last:
47
48
 
48
- ```mermaid
49
- ---
50
- config:
51
- layout: elk
52
- elk:
53
- mergeEdges: true # Combines parallel edges
54
- nodePlacementStrategy: SIMPLE # Other options: SIMPLE, NETWORK_SIMPLEX, BRANDES_KOEPF (default)
55
- ---
56
- flowchart LR
57
- %% --- Algebraic substrate ---
58
- subgraph "Algebraic Substrate"
59
- A[Exponent] --> B[Scale]
60
- end
61
- %% --- Physical ontology ---
62
- subgraph "Physical Ontology"
63
- D[Dimension] --> E[Unit]
64
- end
65
- %% --- Value layer ---
66
- subgraph "Value Layer"
67
- F[Number]
68
- G[Ratio]
69
- end
70
- %% --- Cross-layer relationships ---
71
- E --> F
72
- B --> F
73
- %% Ratio composes Numbers and also evaluates to a Number
74
- F --> G
75
- G --> F
76
- ```
49
+ <img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/f3518d37445301950026fc9ffd1bd062768005fe/ucon.data-model.png align="center" alt="ucon Data Model" width=600/>
77
50
 
78
51
  ## Why `ucon`?
79
52
 
@@ -25,9 +25,9 @@ Stable baseline for:
25
25
  - [x] Refactor `ucon.units` to use dimensional definitions
26
26
  - [ ] Publish documentation for dimensional operations
27
27
  - [x] Verify uniqueness and hashing correctness across all Dimensions
28
- - [ ] Redesign `Exponent` to support algebraic operations (`__mul__`, `__truediv__`, `to_base`, etc.)
29
- - [ ] Remove redundant evaluated caching in favor of property-based computation
30
- - [ ] Integrate `Scale` with Exponent for consistent prefix arithmetic
28
+ - [x] Redesign `Exponent` to support algebraic operations (`__mul__`, `__truediv__`, `to_base`, etc.)
29
+ - [x] Remove redundant evaluated caching in favor of property-based computation
30
+ - [x] Integrate `Scale` with Exponent for consistent prefix arithmetic
31
31
  - [ ] Update `Number` and `Ratio` to use Exponent-driven scaling
32
32
  - [ ] Add regression tests for prefix math (`kilo / milli → mega`, `2¹⁰ / 10³ → 1.024×`)
33
33
  - [ ] Document Exponent/Scale relationship in developer guide
@@ -0,0 +1,131 @@
1
+ # Design Note: Quantifiable and the Ucon Algebra
2
+
3
+ ## Overview
4
+
5
+ `Quantifiable` defines the core behavioral contract for all measurable entities in *ucon* —
6
+ including `Number`, `Ratio`, and any future algebraic quantity (e.g. `VectorQuantity`, `TensorQuantity`, or `UncertainQuantity`).
7
+
8
+ It establishes a unified interface for interacting with physical quantities that carry *unit*,
9
+ *scale*, and *magnitude*, providing both consistency and extensibility across the library.
10
+
11
+ ---
12
+
13
+ ## Why It Exists
14
+
15
+ Before `Quantifiable`, `Number` and `Ratio` implemented overlapping methods such as
16
+ `evaluated`, `simplify`, and `to`, but independently. This led to duplicated logic,
17
+ ad hoc comparisons, and specialized handling in conversions and arithmetic.
18
+
19
+ `Quantifiable` resolves this by formalizing the idea of “quantity-ness” as an algebraic
20
+ typeclass — a single interface for all objects that represent measurable magnitudes.
21
+
22
+ This abstraction brings:
23
+ - **Polymorphism**: shared behavior across `Number`, `Ratio`, and future types.
24
+ - **Code reuse**: common equality, serialization, and algebraic defaults.
25
+ - **Type safety**: all measurable types must define the same contract.
26
+ - **Simplified conversions**: the conversion graph can treat all quantities uniformly.
27
+
28
+ ---
29
+
30
+ ## The Quantifiable Contract
31
+
32
+ Every `Quantifiable` must define:
33
+
34
+ | Method | Purpose |
35
+ |---------|----------|
36
+ | `unit` | The dimensional anchor (e.g., meter, second, volt). |
37
+ | `scale` | The exponential prefix (e.g., kilo, milli, one). |
38
+ | `evaluated` | Numeric magnitude as `quantity × scale`. |
39
+ | `simplify()` | Collapse scale to base (Scale.one). |
40
+ | `to(target)` | Convert to another unit or scale. |
41
+
42
+ Optional methods (provided by defaults):
43
+ - `__eq__()` — Dimension-aware equality.
44
+ - `__repr__()` — Standardized developer representation.
45
+ - `as_dict()` — Serialization for persistence or validation.
46
+
47
+ ---
48
+
49
+ ## What It Enables
50
+
51
+ ### 1. Unified Algebraic Operations
52
+
53
+ Functions can now operate generically on *any measurable thing*:
54
+
55
+ ```python
56
+ def normalize(q: Quantifiable) -> Quantifiable:
57
+ return q.simplify()
58
+ ```
59
+
60
+ Works for both `Number` and `Ratio` — no branching required.
61
+
62
+ ### 2. Simplified Conversion Logic
63
+
64
+ The conversion system can be defined once:
65
+
66
+ ```python
67
+ def convert(q: Quantifiable, target_unit: Unit) -> Quantifiable:
68
+ factor = conversion_graph[q.unit, target_unit]
69
+ return q.__class__(
70
+ quantity=q.evaluated * factor,
71
+ unit=target_unit,
72
+ scale=q.scale
73
+ )
74
+ ```
75
+
76
+ ### 3. Pydantic Integration
77
+
78
+ A single model can represent any measurable value:
79
+
80
+ ```python
81
+ class Measurement(BaseModel):
82
+ value: Quantifiable
83
+ ```
84
+
85
+ This allows runtime validation of units, scales, and dimensions.
86
+
87
+ ### 4. Cross-Type Extensibility
88
+
89
+ New classes like `VectorQuantity`, `MatrixQuantity`, or `UncertainQuantity`
90
+ can integrate by simply implementing the same interface.
91
+
92
+ ```python
93
+ class VectorQuantity(Quantifiable):
94
+ def evaluated(self): ...
95
+ def simplify(self): ...
96
+ def to(self, target): ...
97
+ ```
98
+
99
+ ### 5. Formalization and Verification
100
+
101
+ The Quantifiable interface defines the algebraic properties of measurable quantities,
102
+ making it possible to express invariants in TLA+, Coq, or other formal tools:
103
+
104
+ ```
105
+ ∀ q1, q2 ∈ Quantifiable :
106
+ q1.unit == q2.unit ⇒ simplify(q1 + q2) == simplify(q2 + q1)
107
+ ```
108
+
109
+ This provides a pathway for formal verification of `ucon`'s correctness across languages.
110
+
111
+ ---
112
+
113
+ ## Summary
114
+
115
+ | Benefit | Description |
116
+ |----------|--------------|
117
+ | **Polymorphism** | All measurable types share a common interface. |
118
+ | **Code Simplification** | Shared behavior eliminates duplication. |
119
+ | **Conversion Consistency** | The conversion system can treat all quantities uniformly. |
120
+ | **Type Safety** | Explicitly enforces dimensional and scale semantics. |
121
+ | **Formal Verifiability** | Provides a foundation for mathematical proofs of correctness. |
122
+ | **Extensibility** | Future types can plug into `ucon`'s algebra seamlessly. |
123
+
124
+ ---
125
+
126
+ ## Closing Thoughts
127
+
128
+ `Quantifiable` transforms *ucon* from a collection of data structures into a true **domain algebra** —
129
+ a framework that defines measurable quantities as first-class algebraic entities. It bridges
130
+ type safety, composability, and mathematical rigor, making future extensions — from vectorization
131
+ to distributed computation — both safe and elegant.
@@ -0,0 +1,144 @@
1
+ # Discrete vs Continuous `Exponent` Comparison
2
+
3
+ ## Overview
4
+
5
+ This document compares the **discrete (integer-only)** `Exponent` implementation used in *ucon v0.3.2*
6
+ with the prospects of a new `Exponent` design that embraces fractional powers. It highlights how the
7
+ continuous model restores algebraic closure, improves precision, and simplifies higher-level `Scale`
8
+ operations.
9
+
10
+ ---
11
+
12
+ ## 1. Motivation
13
+
14
+ In *ucon v0.3.2*, `Exponent` was defined as an integer power over fixed bases (2 and 10).
15
+ This worked for standard prefixes like kilo or mebi but failed for mixed-base or non-integer scaling.
16
+
17
+ The continuous design instead treats `Exponent` as a **real-valued exponential** where a given exponent of base _b_ can be written in terms of another base, _a_:
18
+
19
+ $$
20
+ a^x \times b^y = a^{x + y \cdot \log_a(b)}
21
+ $$
22
+
23
+ This modification closes the algebra under all operations avoiding fallbacks or rounding while remaining fully compatible with integer-based scales.
24
+
25
+ ---
26
+
27
+ ## 2. Feature Comparison
28
+
29
+ | Property | Discrete Exponent | Continuous Exponent |
30
+ |-----------|------------------|---------------------|
31
+ | Power type | Integer | Float (real) |
32
+ | Closure | Partial | Complete |
33
+ | Mixed-base operations | Approximate or invalid | Exact |
34
+ | Reversibility (a×b)/b=a | Approximate | Exact |
35
+ | Fractional exponents | Not supported | Supported |
36
+ | Precision | Rounded to prefix | Full algebraic precision |
37
+ | Comparison | Magnitude-based | Power-difference-based |
38
+ | Scale interaction | Enum lookup | Continuous projection |
39
+
40
+ ---
41
+
42
+ ## 3. Example Comparisons
43
+
44
+ ### Same-Base Operations
45
+
46
+ | Operation | Discrete | Continuous |
47
+ |------------|-----------|-------------|
48
+ | 10³ × 10² | `<10^5>` | `<10^5.00000>` |
49
+ | 2¹⁰ × 2⁵ | `<2^15>` | `<2^15.00000>` |
50
+
51
+ No change: _integer powers remain consistent._
52
+
53
+ ### Mixed-Base Operations
54
+
55
+ | Operation | Discrete | Continuous |
56
+ |------------|-----------|-------------|
57
+ | 10³ × 2¹⁰ | `nearest(10⁶)` | `<10^6.01030>` |
58
+ | 2¹⁰ × 10³ | `nearest(2¹⁹)` | `<2^19.93157>` |
59
+ | 10⁶ ÷ 2¹⁰ | `nearest(10⁶)` | `<10^5.98970>` |
60
+
61
+ The continuous system captures **exact** mixed-base scaling.
62
+
63
+ ---
64
+
65
+ ## 4. Simplified Scale Operations
66
+
67
+ The continuous `Exponent` model greatly simplifies `Scale` arithmetic.
68
+
69
+ In v0.3.2, `Scale.__mul__` and `Scale.__truediv__` had to juggle base mismatches, lookups, and fallbacks.
70
+ Now, `Scale` can delegate directly to its underlying `Exponent`:
71
+
72
+ ```python
73
+ def __mul__(self, other: 'Scale') -> 'Scale':
74
+ result_exp = self.value * other.value # exact Exponent
75
+ return Scale.dynamic(result_exp)
76
+ ```
77
+
78
+ No more `nearest()` logic, no more rounding errors.
79
+
80
+ ### Before
81
+ ```python
82
+ # had to guess nearest scale name
83
+ return Scale.nearest(float(result), include_binary=include_binary)
84
+ ```
85
+
86
+ ### After
87
+ ```python
88
+ # exact algebraic scaling
89
+ return Scale.dynamic(self.value * other.value)
90
+ ```
91
+
92
+ This removes three major branches of logic in the old implementation, simplifying the `Scale` layer to a thin symbolic wrapper around continuous exponents.
93
+
94
+ ---
95
+
96
+ ## 5. Conceptual Model
97
+
98
+ | Level | Entity | Description |
99
+ |--------|---------|-------------|
100
+ | 1 | **Exponent** | Continuous exponential algebra (base^power) |
101
+ | 2 | **Scale** | Human-readable projection onto named prefixes |
102
+ | 3 | **Number** | Quantified magnitude combining Scale and Unit |
103
+
104
+ With closure restored at the core, everything beyond it becomes simpler and more expressive.
105
+ `Scale` no longer needs lookup heuristics or error handling.
106
+ It can always delegate cleanly to algebraic truth.
107
+
108
+ ---
109
+
110
+ ## 6. Practical Outcomes
111
+
112
+ | Aspect | Benefit |
113
+ |---------|----------|
114
+ | Algebraic closure | No undefined or lossy operations |
115
+ | Extensibility | Future support for arbitrary user-defined bases |
116
+ | Precision | Exact mixed-base conversions (e.g. kilo × kibi) |
117
+ | Maintainability | Scale logic reduced to 1–2 lines |
118
+ | Mathematical integrity | Continuous and reversible model |
119
+
120
+ ---
121
+
122
+ ## 7. Example
123
+
124
+ ```python
125
+ from ucon.core import Exponent
126
+
127
+ a = Exponent(10, 3) # kilo
128
+ b = Exponent(2, 10) # kibi
129
+
130
+ print(a * b) # <10^6.01030>
131
+ print(a / b) # <10^-0.01030>
132
+ print((a * b) / b) # <10^3.00000>
133
+ ```
134
+
135
+ Exact, reversible, and smooth: _no registry lookup or rounding required._
136
+
137
+ ---
138
+
139
+ ## 8. Closing Thoughts
140
+
141
+ This continuous approach:
142
+ - unifies all scaling operations algebraically,
143
+ - removes arbitrary lookup logic from the `Scale` layer, and
144
+ - lays the groundwork for reversibility across all scale relationships.
@@ -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)
@@ -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.3
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
 
@@ -7,7 +7,9 @@ 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
+ docs/decisions/unity-distance-metric-for-nearest-scale.md
11
+ docs/proposals/interface-unifying-the-value-layer.md
12
+ docs/proposals/support-for-fractional-exponents.md
11
13
  tests/__init__.py
12
14
  tests/ucon/__init__.py
13
15
  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