ucon 0.3.5rc2__tar.gz → 0.4.0__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.5rc2 → ucon-0.4.0}/PKG-INFO +27 -9
- {ucon-0.3.5rc2 → ucon-0.4.0}/README.md +26 -8
- {ucon-0.3.5rc2 → ucon-0.4.0}/ROADMAP.md +28 -14
- ucon-0.4.0/tests/ucon/conversion/__init__.py +0 -0
- ucon-0.4.0/tests/ucon/conversion/test_graph.py +175 -0
- ucon-0.4.0/tests/ucon/conversion/test_map.py +163 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/tests/ucon/test_algebra.py +34 -34
- {ucon-0.3.5rc2 → ucon-0.4.0}/tests/ucon/test_core.py +20 -20
- {ucon-0.3.5rc2 → ucon-0.4.0}/tests/ucon/test_quantity.py +205 -14
- {ucon-0.3.5rc2 → ucon-0.4.0}/ucon/__init__.py +6 -2
- {ucon-0.3.5rc2 → ucon-0.4.0}/ucon/algebra.py +9 -5
- {ucon-0.3.5rc2 → ucon-0.4.0}/ucon/core.py +366 -53
- ucon-0.4.0/ucon/graph.py +415 -0
- ucon-0.4.0/ucon/maps.py +161 -0
- ucon-0.4.0/ucon/quantity.py +17 -0
- ucon-0.4.0/ucon/units.py +130 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/ucon.egg-info/PKG-INFO +27 -9
- {ucon-0.3.5rc2 → ucon-0.4.0}/ucon.egg-info/SOURCES.txt +5 -0
- ucon-0.3.5rc2/ucon/quantity.py +0 -196
- ucon-0.3.5rc2/ucon/units.py +0 -87
- {ucon-0.3.5rc2 → ucon-0.4.0}/.github/workflows/publish.yaml +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/.github/workflows/tests.yaml +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/.gitignore +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/LICENSE +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/NOTICE +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/decisions/composable-unit-algebra.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/decisions/composite-units.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/decisions/unit-algebra-naming.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/decisions/unity-distance-metric-for-nearest-scale.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/explainers/type-operation-matrix.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/explainers/why-algebraic-closure-matters.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/explainers/why-type-safety-matters.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/proposals/interface-unifying-the-value-layer.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/proposals/project_unified-algebraic-core.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/proposals/support-for-fractional-exponents.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/docs/proposals/unified-unit-presentation.md +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/noxfile.py +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/requirements.txt +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/setup.cfg +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/setup.py +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/tests/__init__.py +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/tests/ucon/__init__.py +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/tests/ucon/test_units.py +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/ucon.egg-info/dependency_links.txt +0 -0
- {ucon-0.3.5rc2 → ucon-0.4.0}/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
|
+
Version: 0.4.0
|
|
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
|
|
@@ -70,22 +70,24 @@ The crux of this tiny library is to provide abstractions that simplify the answe
|
|
|
70
70
|
To best answer this question, we turn to an age-old technique ([dimensional analysis](https://en.wikipedia.org/wiki/Dimensional_analysis)) which essentially allows for the solution to be written as a product of ratios. `ucon` comes equipped with some useful primitives:
|
|
71
71
|
| Type | Defined In | Purpose | Typical Use Cases |
|
|
72
72
|
| ----------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
73
|
-
| **`Vector`** | `ucon.algebra` | Represents the exponent tuple of a physical quantity's base dimensions (
|
|
73
|
+
| **`Vector`** | `ucon.algebra` | Represents the 8-component exponent tuple of a physical quantity's base dimensions (T, L, M, I, Θ, J, N, B). | Internal representation of dimensional algebra; building derived quantities (e.g., area, velocity, force). |
|
|
74
74
|
| **`Exponent`** | `ucon.algebra` | Represents base-power pairs (e.g., 10³, 2¹⁰) used by `Scale`. | Performing arithmetic on powers and bases; normalizing scales across conversions. |
|
|
75
75
|
| **`Dimension`** | `ucon.core` | Encapsulates physical dimensions (e.g., length, time, mass) as algebraic combinations of vectors. | Enforcing dimensional consistency; defining relationships between quantities (e.g., length / time = velocity). |
|
|
76
76
|
| **`Scale`** | `ucon.core` | Encodes powers of base magnitudes (binary or decimal prefixes like kilo-, milli-, mebi-). | Adjusting numeric scale without changing dimension (e.g., kilometer ↔ meter, byte ↔ kibibyte). |
|
|
77
77
|
| **`Unit`** | `ucon.core` | An atomic, scale-free measurement symbol (e.g., meter, second, joule) with a `Dimension`. | Defining base units; serving as graph nodes for future conversions. |
|
|
78
78
|
| **`UnitFactor`** | `ucon.core` | Pairs a `Unit` with a `Scale` (e.g., kilo + gram = kg). Used as keys inside `UnitProduct`. | Preserving user-provided scale prefixes through algebraic operations. |
|
|
79
79
|
| **`UnitProduct`** | `ucon.core` | A product/quotient of `UnitFactor`s with exponent tracking and simplification. | Representing composite units like m/s, kg·m/s², kJ·h. |
|
|
80
|
-
| **`Number`** | `ucon.
|
|
81
|
-
| **`Ratio`** | `ucon.
|
|
82
|
-
| **`
|
|
80
|
+
| **`Number`** | `ucon.core` | Combines a numeric quantity with a unit; the primary measurable type. | Performing arithmetic with units; representing physical quantities like 5 m/s. |
|
|
81
|
+
| **`Ratio`** | `ucon.core` | Represents the division of two `Number` objects; captures relationships between quantities. | Expressing rates, densities, efficiencies (e.g., energy / time = power, length / time = velocity). |
|
|
82
|
+
| **`Map`** hierarchy | `ucon.maps` | Composable conversion morphisms: `LinearMap`, `AffineMap`, `ComposedMap`. | Defining conversion functions between units (e.g., meter→foot, celsius→kelvin). |
|
|
83
|
+
| **`ConversionGraph`** | `ucon.graph` | Registry of unit conversion edges with BFS path composition. | Converting between units via `Number.to(target)`; managing default and custom graphs. |
|
|
84
|
+
| **`units` module** | `ucon.units` | Defines canonical unit instances (SI, imperial, information, and derived units). | Quick access to standard physical units (`units.meter`, `units.foot`, `units.byte`, etc.). |
|
|
83
85
|
|
|
84
86
|
### Under the Hood
|
|
85
87
|
|
|
86
88
|
`ucon` models unit math through a hierarchy where each layer builds on the last:
|
|
87
89
|
|
|
88
|
-
<img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/
|
|
90
|
+
<img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/5df6a7fb2a6426ee6804096c092c10bed1b30b6f/ucon.data-model_v040.png align="center" alt="ucon Data Model" width=600/>
|
|
89
91
|
|
|
90
92
|
## Why `ucon`?
|
|
91
93
|
|
|
@@ -167,8 +169,24 @@ print(km.fold_scale()) # 1000.0
|
|
|
167
169
|
print(mg.fold_scale()) # 0.001
|
|
168
170
|
```
|
|
169
171
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
+
Units are callable for ergonomic quantity construction:
|
|
173
|
+
```python
|
|
174
|
+
from ucon import units, Scale
|
|
175
|
+
|
|
176
|
+
# Callable syntax: unit(quantity) → Number
|
|
177
|
+
height = units.meter(1.8)
|
|
178
|
+
speed = (units.mile / units.hour)(60)
|
|
179
|
+
|
|
180
|
+
# Convert between units
|
|
181
|
+
height_ft = height.to(units.foot)
|
|
182
|
+
print(height_ft) # <5.905... ft>
|
|
183
|
+
|
|
184
|
+
# Scaled units work too
|
|
185
|
+
km = Scale.kilo * units.meter
|
|
186
|
+
distance = km(5)
|
|
187
|
+
distance_mi = distance.to(units.mile)
|
|
188
|
+
print(distance_mi) # <3.107... mi>
|
|
189
|
+
```
|
|
172
190
|
|
|
173
191
|
---
|
|
174
192
|
|
|
@@ -177,7 +195,7 @@ print(mg.fold_scale()) # 0.001
|
|
|
177
195
|
| Version | Theme | Focus | Status |
|
|
178
196
|
|----------|-------|--------|--------|
|
|
179
197
|
| **0.3.5** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
|
|
180
|
-
| [**0.4.x**](https://github.com/withtwoemms/ucon/milestone/2) | Conversion System | `ConversionGraph`, `Number.to()
|
|
198
|
+
| [**0.4.x**](https://github.com/withtwoemms/ucon/milestone/2) | Conversion System | `ConversionGraph`, `Number.to()`, callable units | 🚧 In Progress |
|
|
181
199
|
| [**0.6.x**](https://github.com/withtwoemms/ucon/milestone/4) | Nonlinear / Specialized Units | Decibel, Percent, pH | ⏳ Planned |
|
|
182
200
|
| [**0.8.x**](https://github.com/withtwoemms/ucon/milestone/6) | Pydantic Integration | Type-safe quantity validation | ⏳ Planned |
|
|
183
201
|
|
|
@@ -33,22 +33,24 @@ The crux of this tiny library is to provide abstractions that simplify the answe
|
|
|
33
33
|
To best answer this question, we turn to an age-old technique ([dimensional analysis](https://en.wikipedia.org/wiki/Dimensional_analysis)) which essentially allows for the solution to be written as a product of ratios. `ucon` comes equipped with some useful primitives:
|
|
34
34
|
| Type | Defined In | Purpose | Typical Use Cases |
|
|
35
35
|
| ----------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
36
|
-
| **`Vector`** | `ucon.algebra` | Represents the exponent tuple of a physical quantity's base dimensions (
|
|
36
|
+
| **`Vector`** | `ucon.algebra` | Represents the 8-component exponent tuple of a physical quantity's base dimensions (T, L, M, I, Θ, J, N, B). | Internal representation of dimensional algebra; building derived quantities (e.g., area, velocity, force). |
|
|
37
37
|
| **`Exponent`** | `ucon.algebra` | Represents base-power pairs (e.g., 10³, 2¹⁰) used by `Scale`. | Performing arithmetic on powers and bases; normalizing scales across conversions. |
|
|
38
38
|
| **`Dimension`** | `ucon.core` | Encapsulates physical dimensions (e.g., length, time, mass) as algebraic combinations of vectors. | Enforcing dimensional consistency; defining relationships between quantities (e.g., length / time = velocity). |
|
|
39
39
|
| **`Scale`** | `ucon.core` | Encodes powers of base magnitudes (binary or decimal prefixes like kilo-, milli-, mebi-). | Adjusting numeric scale without changing dimension (e.g., kilometer ↔ meter, byte ↔ kibibyte). |
|
|
40
40
|
| **`Unit`** | `ucon.core` | An atomic, scale-free measurement symbol (e.g., meter, second, joule) with a `Dimension`. | Defining base units; serving as graph nodes for future conversions. |
|
|
41
41
|
| **`UnitFactor`** | `ucon.core` | Pairs a `Unit` with a `Scale` (e.g., kilo + gram = kg). Used as keys inside `UnitProduct`. | Preserving user-provided scale prefixes through algebraic operations. |
|
|
42
42
|
| **`UnitProduct`** | `ucon.core` | A product/quotient of `UnitFactor`s with exponent tracking and simplification. | Representing composite units like m/s, kg·m/s², kJ·h. |
|
|
43
|
-
| **`Number`** | `ucon.
|
|
44
|
-
| **`Ratio`** | `ucon.
|
|
45
|
-
| **`
|
|
43
|
+
| **`Number`** | `ucon.core` | Combines a numeric quantity with a unit; the primary measurable type. | Performing arithmetic with units; representing physical quantities like 5 m/s. |
|
|
44
|
+
| **`Ratio`** | `ucon.core` | Represents the division of two `Number` objects; captures relationships between quantities. | Expressing rates, densities, efficiencies (e.g., energy / time = power, length / time = velocity). |
|
|
45
|
+
| **`Map`** hierarchy | `ucon.maps` | Composable conversion morphisms: `LinearMap`, `AffineMap`, `ComposedMap`. | Defining conversion functions between units (e.g., meter→foot, celsius→kelvin). |
|
|
46
|
+
| **`ConversionGraph`** | `ucon.graph` | Registry of unit conversion edges with BFS path composition. | Converting between units via `Number.to(target)`; managing default and custom graphs. |
|
|
47
|
+
| **`units` module** | `ucon.units` | Defines canonical unit instances (SI, imperial, information, and derived units). | Quick access to standard physical units (`units.meter`, `units.foot`, `units.byte`, etc.). |
|
|
46
48
|
|
|
47
49
|
### Under the Hood
|
|
48
50
|
|
|
49
51
|
`ucon` models unit math through a hierarchy where each layer builds on the last:
|
|
50
52
|
|
|
51
|
-
<img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/
|
|
53
|
+
<img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/5df6a7fb2a6426ee6804096c092c10bed1b30b6f/ucon.data-model_v040.png align="center" alt="ucon Data Model" width=600/>
|
|
52
54
|
|
|
53
55
|
## Why `ucon`?
|
|
54
56
|
|
|
@@ -130,8 +132,24 @@ print(km.fold_scale()) # 1000.0
|
|
|
130
132
|
print(mg.fold_scale()) # 0.001
|
|
131
133
|
```
|
|
132
134
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
Units are callable for ergonomic quantity construction:
|
|
136
|
+
```python
|
|
137
|
+
from ucon import units, Scale
|
|
138
|
+
|
|
139
|
+
# Callable syntax: unit(quantity) → Number
|
|
140
|
+
height = units.meter(1.8)
|
|
141
|
+
speed = (units.mile / units.hour)(60)
|
|
142
|
+
|
|
143
|
+
# Convert between units
|
|
144
|
+
height_ft = height.to(units.foot)
|
|
145
|
+
print(height_ft) # <5.905... ft>
|
|
146
|
+
|
|
147
|
+
# Scaled units work too
|
|
148
|
+
km = Scale.kilo * units.meter
|
|
149
|
+
distance = km(5)
|
|
150
|
+
distance_mi = distance.to(units.mile)
|
|
151
|
+
print(distance_mi) # <3.107... mi>
|
|
152
|
+
```
|
|
135
153
|
|
|
136
154
|
---
|
|
137
155
|
|
|
@@ -140,7 +158,7 @@ print(mg.fold_scale()) # 0.001
|
|
|
140
158
|
| Version | Theme | Focus | Status |
|
|
141
159
|
|----------|-------|--------|--------|
|
|
142
160
|
| **0.3.5** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
|
|
143
|
-
| [**0.4.x**](https://github.com/withtwoemms/ucon/milestone/2) | Conversion System | `ConversionGraph`, `Number.to()
|
|
161
|
+
| [**0.4.x**](https://github.com/withtwoemms/ucon/milestone/2) | Conversion System | `ConversionGraph`, `Number.to()`, callable units | 🚧 In Progress |
|
|
144
162
|
| [**0.6.x**](https://github.com/withtwoemms/ucon/milestone/4) | Nonlinear / Specialized Units | Decibel, Percent, pH | ⏳ Planned |
|
|
145
163
|
| [**0.8.x**](https://github.com/withtwoemms/ucon/milestone/6) | Pydantic Integration | Type-safe quantity validation | ⏳ Planned |
|
|
146
164
|
|
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## 🪜 Current Version: **v0.
|
|
7
|
+
## 🪜 Current Version: **v0.4.0** (in progress)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- `ucon.core` (`Dimension`, `Scale`, `Unit`, `UnitFactor`, `UnitProduct`)
|
|
11
|
-
- `ucon.
|
|
12
|
-
- `ucon.
|
|
13
|
-
-
|
|
9
|
+
Building on v0.3.5 baseline:
|
|
10
|
+
- `ucon.core` (`Dimension`, `Scale`, `Unit`, `UnitFactor`, `UnitProduct`, `Number`, `Ratio`)
|
|
11
|
+
- `ucon.maps` (`Map`, `LinearMap`, `AffineMap`, `ComposedMap`)
|
|
12
|
+
- `ucon.graph` (`ConversionGraph`, default graph, `get_default_graph()`, `using_graph()`)
|
|
13
|
+
- `ucon.units` (SI + imperial + information units, callable syntax)
|
|
14
|
+
- Callable unit API: `meter(5)`, `(mile / hour)(60)`
|
|
15
|
+
- `Number.simplify()` for base-scale normalization
|
|
16
|
+
- `Dimension.information` with `units.bit`, `units.byte`
|
|
14
17
|
|
|
15
18
|
---
|
|
16
19
|
|
|
@@ -46,18 +49,26 @@ Stable baseline for:
|
|
|
46
49
|
|
|
47
50
|
---
|
|
48
51
|
|
|
49
|
-
## ⚙️ v0.4.x — Conversion System Foundations (
|
|
52
|
+
## ⚙️ v0.4.x — Conversion System Foundations (In Progress)
|
|
50
53
|
|
|
51
54
|
### 🔹 Summary
|
|
52
55
|
> Implements unified conversion engine for standard, linear, and affine conversions.
|
|
56
|
+
> Introduces callable unit API for ergonomic quantity construction.
|
|
53
57
|
|
|
54
58
|
### ✅ Goals
|
|
55
|
-
- [
|
|
56
|
-
- [
|
|
57
|
-
- [
|
|
58
|
-
- [
|
|
59
|
-
- [
|
|
60
|
-
- [
|
|
59
|
+
- [x] Introduce `ConversionGraph` registry keyed by `Dimension`
|
|
60
|
+
- [x] Add support for `standard`, `linear`, and `affine` conversion types
|
|
61
|
+
- [x] Implement `Number.to(target_unit)` conversion API
|
|
62
|
+
- [x] Scale-only conversions short-circuit without graph lookup
|
|
63
|
+
- [x] Composite-to-composite conversion via per-component decomposition
|
|
64
|
+
- [x] Round-trip validation for reversible conversions (inverse maps)
|
|
65
|
+
- [x] Callable unit syntax: `meter(5)`, `(mile / hour)(60)`
|
|
66
|
+
- [x] Default graph with common SI and imperial conversions
|
|
67
|
+
- [x] Imperial units: `foot`, `mile`, `yard`, `inch`, `pound`, `ounce`, `fahrenheit`, `gallon`
|
|
68
|
+
- [x] `Number.simplify()` — Express in base scale
|
|
69
|
+
- [x] `Dimension.information` with `units.bit` and `units.byte`
|
|
70
|
+
- [x] `Vector` extended to 8 components (added B for information)
|
|
71
|
+
- [x] Information unit conversions in default graph (byte ↔ bit)
|
|
61
72
|
- [ ] Extend tests to include temperature, pressure, and base SI conversions
|
|
62
73
|
- [ ] Document Exponent/Scale relationship in developer guide
|
|
63
74
|
|
|
@@ -65,6 +76,9 @@ Stable baseline for:
|
|
|
65
76
|
- Unified conversion taxonomy
|
|
66
77
|
- Reversible, dimension-checked conversions
|
|
67
78
|
- Scale-aware graph that leverages the `Unit`/`UnitFactor` separation from v0.3.x
|
|
79
|
+
- Ergonomic API: units are callable, returning `Number` instances
|
|
80
|
+
- Information dimension support (bit, byte) with binary prefix compatibility
|
|
81
|
+
- `Number.simplify()` for expressing quantities in base scale
|
|
68
82
|
- Forms the basis for nonlinear and domain-specific conversion families
|
|
69
83
|
|
|
70
84
|
---
|
|
@@ -202,7 +216,7 @@ Stable baseline for:
|
|
|
202
216
|
| Version | Theme | Key Focus | Status |
|
|
203
217
|
|----------|--------|------------|---------|
|
|
204
218
|
| **0.3.5** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
|
|
205
|
-
| **0.4.0** | Conversion Engine | `ConversionGraph`, `Number.to()`,
|
|
219
|
+
| **0.4.0** | Conversion Engine | `ConversionGraph`, `Number.to()`, callable units | 🚧 In Progress |
|
|
206
220
|
| **0.5.0** | Unit Systems & Registries | Extensible registry system | ⏳ Planned |
|
|
207
221
|
| **0.6.0** | Nonlinear Conversions | Logarithmic / exponential families | ⏳ Planned |
|
|
208
222
|
| **0.7.0** | Testing & API Polish | Coverage, ergonomics, stability | ⏳ Planned |
|
|
File without changes
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# © 2026 The Radiativity Company
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
# See the LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from ucon import units
|
|
8
|
+
from ucon.core import Dimension, Scale, Unit, UnitFactor, UnitProduct
|
|
9
|
+
from ucon.graph import (
|
|
10
|
+
ConversionGraph,
|
|
11
|
+
DimensionMismatch,
|
|
12
|
+
ConversionNotFound,
|
|
13
|
+
CyclicInconsistency,
|
|
14
|
+
)
|
|
15
|
+
from ucon.maps import LinearMap, AffineMap
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestConversionGraphEdgeManagement(unittest.TestCase):
|
|
19
|
+
|
|
20
|
+
def setUp(self):
|
|
21
|
+
self.graph = ConversionGraph()
|
|
22
|
+
self.meter = units.meter
|
|
23
|
+
self.foot = Unit(name='foot', dimension=Dimension.length, aliases=('ft',))
|
|
24
|
+
self.inch = Unit(name='inch', dimension=Dimension.length, aliases=('in',))
|
|
25
|
+
self.gram = units.gram
|
|
26
|
+
|
|
27
|
+
def test_add_and_retrieve_edge(self):
|
|
28
|
+
self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
|
|
29
|
+
m = self.graph.convert(src=self.meter, dst=self.foot)
|
|
30
|
+
self.assertAlmostEqual(m(1), 3.28084, places=4)
|
|
31
|
+
|
|
32
|
+
def test_inverse_auto_stored(self):
|
|
33
|
+
self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
|
|
34
|
+
m = self.graph.convert(src=self.foot, dst=self.meter)
|
|
35
|
+
self.assertAlmostEqual(m(1), 0.3048, places=4)
|
|
36
|
+
|
|
37
|
+
def test_dimension_mismatch_rejected(self):
|
|
38
|
+
with self.assertRaises(DimensionMismatch):
|
|
39
|
+
self.graph.add_edge(src=self.meter, dst=self.gram, map=LinearMap(1))
|
|
40
|
+
|
|
41
|
+
def test_cyclic_consistency_check(self):
|
|
42
|
+
self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
|
|
43
|
+
# Adding inconsistent reverse should raise
|
|
44
|
+
with self.assertRaises(CyclicInconsistency):
|
|
45
|
+
self.graph.add_edge(src=self.foot, dst=self.meter, map=LinearMap(0.5)) # wrong!
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestConversionGraphUnitConversion(unittest.TestCase):
|
|
49
|
+
|
|
50
|
+
def setUp(self):
|
|
51
|
+
self.graph = ConversionGraph()
|
|
52
|
+
self.meter = units.meter
|
|
53
|
+
self.foot = Unit(name='foot', dimension=Dimension.length, aliases=('ft',))
|
|
54
|
+
self.inch = Unit(name='inch', dimension=Dimension.length, aliases=('in',))
|
|
55
|
+
self.yard = Unit(name='yard', dimension=Dimension.length, aliases=('yd',))
|
|
56
|
+
|
|
57
|
+
def test_identity_conversion(self):
|
|
58
|
+
m = self.graph.convert(src=self.meter, dst=self.meter)
|
|
59
|
+
self.assertTrue(m.is_identity())
|
|
60
|
+
|
|
61
|
+
def test_direct_edge(self):
|
|
62
|
+
self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
|
|
63
|
+
m = self.graph.convert(src=self.meter, dst=self.foot)
|
|
64
|
+
self.assertAlmostEqual(m(1), 3.28084, places=4)
|
|
65
|
+
|
|
66
|
+
def test_composed_path(self):
|
|
67
|
+
self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
|
|
68
|
+
self.graph.add_edge(src=self.foot, dst=self.inch, map=LinearMap(12))
|
|
69
|
+
m = self.graph.convert(src=self.meter, dst=self.inch)
|
|
70
|
+
self.assertAlmostEqual(m(1), 39.37008, places=3)
|
|
71
|
+
|
|
72
|
+
def test_multi_hop_path(self):
|
|
73
|
+
self.graph.add_edge(src=self.meter, dst=self.foot, map=LinearMap(3.28084))
|
|
74
|
+
self.graph.add_edge(src=self.foot, dst=self.inch, map=LinearMap(12))
|
|
75
|
+
self.graph.add_edge(src=self.inch, dst=self.yard, map=LinearMap(1/36))
|
|
76
|
+
m = self.graph.convert(src=self.meter, dst=self.yard)
|
|
77
|
+
self.assertAlmostEqual(m(1), 1.0936, places=3)
|
|
78
|
+
|
|
79
|
+
def test_no_path_raises(self):
|
|
80
|
+
mile = Unit(name='mile', dimension=Dimension.length, aliases=('mi',))
|
|
81
|
+
with self.assertRaises(ConversionNotFound):
|
|
82
|
+
self.graph.convert(src=self.meter, dst=mile)
|
|
83
|
+
|
|
84
|
+
def test_dimension_mismatch_on_convert(self):
|
|
85
|
+
with self.assertRaises(DimensionMismatch):
|
|
86
|
+
self.graph.convert(src=self.meter, dst=units.second)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestConversionGraphProductConversion(unittest.TestCase):
|
|
90
|
+
|
|
91
|
+
def setUp(self):
|
|
92
|
+
self.graph = ConversionGraph()
|
|
93
|
+
self.meter = units.meter
|
|
94
|
+
self.second = units.second
|
|
95
|
+
self.mile = Unit(name='mile', dimension=Dimension.length, aliases=('mi',))
|
|
96
|
+
self.hour = Unit(name='hour', dimension=Dimension.time, aliases=('h',))
|
|
97
|
+
|
|
98
|
+
# Register unit conversions
|
|
99
|
+
self.graph.add_edge(src=self.meter, dst=self.mile, map=LinearMap(0.000621371))
|
|
100
|
+
self.graph.add_edge(src=self.second, dst=self.hour, map=LinearMap(1/3600))
|
|
101
|
+
|
|
102
|
+
def test_factorwise_velocity_conversion(self):
|
|
103
|
+
m_per_s = UnitProduct({
|
|
104
|
+
UnitFactor(self.meter, Scale.one): 1,
|
|
105
|
+
UnitFactor(self.second, Scale.one): -1,
|
|
106
|
+
})
|
|
107
|
+
mi_per_hr = UnitProduct({
|
|
108
|
+
UnitFactor(self.mile, Scale.one): 1,
|
|
109
|
+
UnitFactor(self.hour, Scale.one): -1,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
m = self.graph.convert(src=m_per_s, dst=mi_per_hr)
|
|
113
|
+
# 1 m/s = 2.23694 mi/h
|
|
114
|
+
self.assertAlmostEqual(m(1), 2.237, places=2)
|
|
115
|
+
|
|
116
|
+
def test_scale_ratio_in_factorwise(self):
|
|
117
|
+
km = UnitProduct({UnitFactor(self.meter, Scale.kilo): 1})
|
|
118
|
+
m = UnitProduct({UnitFactor(self.meter, Scale.one): 1})
|
|
119
|
+
|
|
120
|
+
conversion = self.graph.convert(src=km, dst=m)
|
|
121
|
+
self.assertAlmostEqual(conversion(1), 1000, places=6)
|
|
122
|
+
|
|
123
|
+
def test_direct_product_edge(self):
|
|
124
|
+
# Define joule and watt_hour as UnitProducts
|
|
125
|
+
g = units.gram
|
|
126
|
+
joule = UnitProduct({
|
|
127
|
+
UnitFactor(g, Scale.one): 1,
|
|
128
|
+
UnitFactor(self.meter, Scale.one): 2,
|
|
129
|
+
UnitFactor(self.second, Scale.one): -2,
|
|
130
|
+
})
|
|
131
|
+
watt = UnitProduct({
|
|
132
|
+
UnitFactor(g, Scale.one): 1,
|
|
133
|
+
UnitFactor(self.meter, Scale.one): 2,
|
|
134
|
+
UnitFactor(self.second, Scale.one): -3,
|
|
135
|
+
})
|
|
136
|
+
watt_hour = watt * UnitProduct({UnitFactor(self.hour, Scale.one): 1})
|
|
137
|
+
|
|
138
|
+
# Register direct edge
|
|
139
|
+
self.graph.add_edge(src=joule, dst=watt_hour, map=LinearMap(1/3600))
|
|
140
|
+
|
|
141
|
+
m = self.graph.convert(src=joule, dst=watt_hour)
|
|
142
|
+
self.assertAlmostEqual(m(7200), 2.0, places=6)
|
|
143
|
+
|
|
144
|
+
def test_product_identity(self):
|
|
145
|
+
m_per_s = UnitProduct({
|
|
146
|
+
UnitFactor(self.meter, Scale.one): 1,
|
|
147
|
+
UnitFactor(self.second, Scale.one): -1,
|
|
148
|
+
})
|
|
149
|
+
m = self.graph.convert(src=m_per_s, dst=m_per_s)
|
|
150
|
+
self.assertTrue(m.is_identity())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestConversionGraphTemperature(unittest.TestCase):
|
|
154
|
+
|
|
155
|
+
def setUp(self):
|
|
156
|
+
self.graph = ConversionGraph()
|
|
157
|
+
self.celsius = Unit(name='celsius', dimension=Dimension.temperature, aliases=('°C',))
|
|
158
|
+
self.kelvin = Unit(name='kelvin', dimension=Dimension.temperature, aliases=('K',))
|
|
159
|
+
self.fahrenheit = Unit(name='fahrenheit', dimension=Dimension.temperature, aliases=('°F',))
|
|
160
|
+
|
|
161
|
+
def test_celsius_to_kelvin(self):
|
|
162
|
+
self.graph.add_edge(src=self.celsius, dst=self.kelvin, map=AffineMap(1, 273.15))
|
|
163
|
+
m = self.graph.convert(src=self.celsius, dst=self.kelvin)
|
|
164
|
+
self.assertAlmostEqual(m(0), 273.15, places=2)
|
|
165
|
+
self.assertAlmostEqual(m(100), 373.15, places=2)
|
|
166
|
+
|
|
167
|
+
def test_celsius_to_fahrenheit_via_kelvin(self):
|
|
168
|
+
# C → K: K = C + 273.15
|
|
169
|
+
self.graph.add_edge(src=self.celsius, dst=self.kelvin, map=AffineMap(1, 273.15))
|
|
170
|
+
# F → K: K = (F - 32) * 5/9 + 273.15 = 5/9 * F + 255.372
|
|
171
|
+
self.graph.add_edge(src=self.fahrenheit, dst=self.kelvin, map=AffineMap(5/9, 255.372))
|
|
172
|
+
|
|
173
|
+
m = self.graph.convert(src=self.celsius, dst=self.fahrenheit)
|
|
174
|
+
self.assertAlmostEqual(m(0), 32, places=0)
|
|
175
|
+
self.assertAlmostEqual(m(100), 212, places=0)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# © 2025 The Radiativity Company
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
# See the LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from ucon.maps import AffineMap, ComposedMap, LinearMap, Map
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestLinearMap(unittest.TestCase):
|
|
11
|
+
|
|
12
|
+
def test_apply(self):
|
|
13
|
+
m = LinearMap(39.37)
|
|
14
|
+
self.assertAlmostEqual(m(1.0), 39.37)
|
|
15
|
+
self.assertAlmostEqual(m(0.0), 0.0)
|
|
16
|
+
self.assertAlmostEqual(m(2.5), 98.425)
|
|
17
|
+
|
|
18
|
+
def test_inverse(self):
|
|
19
|
+
m = LinearMap(39.37)
|
|
20
|
+
inv = m.inverse()
|
|
21
|
+
self.assertIsInstance(inv, LinearMap)
|
|
22
|
+
self.assertAlmostEqual(inv.a, 1.0 / 39.37)
|
|
23
|
+
|
|
24
|
+
def test_inverse_zero_raises(self):
|
|
25
|
+
m = LinearMap(0)
|
|
26
|
+
with self.assertRaises(ZeroDivisionError):
|
|
27
|
+
m.inverse()
|
|
28
|
+
|
|
29
|
+
def test_round_trip(self):
|
|
30
|
+
m = LinearMap(39.37)
|
|
31
|
+
for x in [0.0, 1.0, -5.5, 1000.0]:
|
|
32
|
+
self.assertAlmostEqual(m.inverse()(m(x)), x, places=10)
|
|
33
|
+
|
|
34
|
+
def test_compose_closed(self):
|
|
35
|
+
f = LinearMap(39.37)
|
|
36
|
+
g = LinearMap(1.0 / 12.0)
|
|
37
|
+
composed = f @ g
|
|
38
|
+
self.assertIsInstance(composed, LinearMap)
|
|
39
|
+
self.assertAlmostEqual(composed.a, 39.37 / 12.0)
|
|
40
|
+
|
|
41
|
+
def test_compose_apply(self):
|
|
42
|
+
f = LinearMap(2.0)
|
|
43
|
+
g = LinearMap(3.0)
|
|
44
|
+
# (f @ g)(x) = f(g(x)) = 2 * (3 * x) = 6x
|
|
45
|
+
self.assertAlmostEqual((f @ g)(5.0), 30.0)
|
|
46
|
+
|
|
47
|
+
def test_identity(self):
|
|
48
|
+
ident = LinearMap.identity()
|
|
49
|
+
self.assertAlmostEqual(ident(42.0), 42.0)
|
|
50
|
+
m = LinearMap(7.0)
|
|
51
|
+
self.assertEqual(m @ ident, m)
|
|
52
|
+
self.assertEqual(ident @ m, m)
|
|
53
|
+
|
|
54
|
+
def test_invertible(self):
|
|
55
|
+
self.assertTrue(LinearMap(5.0).invertible)
|
|
56
|
+
self.assertFalse(LinearMap(0).invertible)
|
|
57
|
+
|
|
58
|
+
def test_eq(self):
|
|
59
|
+
self.assertEqual(LinearMap(3.0), LinearMap(3.0))
|
|
60
|
+
self.assertNotEqual(LinearMap(3.0), LinearMap(4.0))
|
|
61
|
+
|
|
62
|
+
def test_repr(self):
|
|
63
|
+
self.assertIn("39.37", repr(LinearMap(39.37)))
|
|
64
|
+
|
|
65
|
+
def test_matmul_non_map_returns_not_implemented(self):
|
|
66
|
+
m = LinearMap(2.0)
|
|
67
|
+
result = m.__matmul__(42)
|
|
68
|
+
self.assertIs(result, NotImplemented)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestAffineMap(unittest.TestCase):
|
|
72
|
+
|
|
73
|
+
def test_apply(self):
|
|
74
|
+
# Celsius to Fahrenheit: F = 1.8 * C + 32
|
|
75
|
+
c_to_f = AffineMap(1.8, 32.0)
|
|
76
|
+
self.assertAlmostEqual(c_to_f(0.0), 32.0)
|
|
77
|
+
self.assertAlmostEqual(c_to_f(100.0), 212.0)
|
|
78
|
+
self.assertAlmostEqual(c_to_f(-40.0), -40.0)
|
|
79
|
+
|
|
80
|
+
def test_inverse(self):
|
|
81
|
+
c_to_f = AffineMap(1.8, 32.0)
|
|
82
|
+
f_to_c = c_to_f.inverse()
|
|
83
|
+
self.assertIsInstance(f_to_c, AffineMap)
|
|
84
|
+
self.assertAlmostEqual(f_to_c(32.0), 0.0)
|
|
85
|
+
self.assertAlmostEqual(f_to_c(212.0), 100.0)
|
|
86
|
+
|
|
87
|
+
def test_inverse_zero_raises(self):
|
|
88
|
+
m = AffineMap(0, 5.0)
|
|
89
|
+
with self.assertRaises(ZeroDivisionError):
|
|
90
|
+
m.inverse()
|
|
91
|
+
|
|
92
|
+
def test_round_trip(self):
|
|
93
|
+
m = AffineMap(1.8, 32.0)
|
|
94
|
+
for x in [0.0, 100.0, -40.0, 37.5]:
|
|
95
|
+
self.assertAlmostEqual(m.inverse()(m(x)), x, places=10)
|
|
96
|
+
|
|
97
|
+
def test_compose_closed(self):
|
|
98
|
+
f = AffineMap(2.0, 3.0)
|
|
99
|
+
g = AffineMap(4.0, 5.0)
|
|
100
|
+
composed = f @ g
|
|
101
|
+
self.assertIsInstance(composed, AffineMap)
|
|
102
|
+
# f(g(x)) = 2*(4x+5)+3 = 8x+13
|
|
103
|
+
self.assertAlmostEqual(composed.a, 8.0)
|
|
104
|
+
self.assertAlmostEqual(composed.b, 13.0)
|
|
105
|
+
|
|
106
|
+
def test_compose_apply(self):
|
|
107
|
+
f = AffineMap(2.0, 3.0)
|
|
108
|
+
g = AffineMap(4.0, 5.0)
|
|
109
|
+
for x in [0.0, 1.0, -2.0]:
|
|
110
|
+
self.assertAlmostEqual((f @ g)(x), f(g(x)), places=10)
|
|
111
|
+
|
|
112
|
+
def test_invertible(self):
|
|
113
|
+
self.assertTrue(AffineMap(1.8, 32.0).invertible)
|
|
114
|
+
self.assertFalse(AffineMap(0, 32.0).invertible)
|
|
115
|
+
|
|
116
|
+
def test_eq(self):
|
|
117
|
+
self.assertEqual(AffineMap(1.8, 32.0), AffineMap(1.8, 32.0))
|
|
118
|
+
self.assertNotEqual(AffineMap(1.8, 32.0), AffineMap(1.8, 0.0))
|
|
119
|
+
|
|
120
|
+
def test_repr(self):
|
|
121
|
+
r = repr(AffineMap(1.8, 32.0))
|
|
122
|
+
self.assertIn("1.8", r)
|
|
123
|
+
self.assertIn("32.0", r)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestComposedMap(unittest.TestCase):
|
|
127
|
+
|
|
128
|
+
def test_heterogeneous_composition(self):
|
|
129
|
+
# LinearMap @ AffineMap now returns AffineMap (closed composition)
|
|
130
|
+
# Use ComposedMap directly to test the fallback behavior
|
|
131
|
+
lin = LinearMap(2.0)
|
|
132
|
+
aff = AffineMap(3.0, 1.0)
|
|
133
|
+
composed = ComposedMap(lin, aff)
|
|
134
|
+
# lin(aff(x)) = 2 * (3x + 1) = 6x + 2
|
|
135
|
+
self.assertAlmostEqual(composed(0.0), 2.0)
|
|
136
|
+
self.assertAlmostEqual(composed(1.0), 8.0)
|
|
137
|
+
|
|
138
|
+
def test_inverse(self):
|
|
139
|
+
composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
|
|
140
|
+
for x in [0.0, 1.0, -3.0, 10.0]:
|
|
141
|
+
self.assertAlmostEqual(composed.inverse()(composed(x)), x, places=10)
|
|
142
|
+
|
|
143
|
+
def test_invertible(self):
|
|
144
|
+
self.assertTrue(ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0)).invertible)
|
|
145
|
+
self.assertFalse(ComposedMap(LinearMap(0), AffineMap(3.0, 1.0)).invertible)
|
|
146
|
+
|
|
147
|
+
def test_non_invertible_raises(self):
|
|
148
|
+
composed = ComposedMap(LinearMap(0), AffineMap(3.0, 1.0))
|
|
149
|
+
with self.assertRaises(ValueError):
|
|
150
|
+
composed.inverse()
|
|
151
|
+
|
|
152
|
+
def test_repr(self):
|
|
153
|
+
composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
|
|
154
|
+
r = repr(composed)
|
|
155
|
+
self.assertIn("LinearMap", r)
|
|
156
|
+
self.assertIn("AffineMap", r)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestMapABC(unittest.TestCase):
|
|
160
|
+
|
|
161
|
+
def test_cannot_instantiate(self):
|
|
162
|
+
with self.assertRaises(TypeError):
|
|
163
|
+
Map()
|