ucon 0.5.0__tar.gz → 0.5.1__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.5.0 → ucon-0.5.1}/PKG-INFO +23 -2
- {ucon-0.5.0 → ucon-0.5.1}/README.md +22 -1
- {ucon-0.5.0 → ucon-0.5.1}/ROADMAP.md +9 -8
- ucon-0.5.1/tests/ucon/test_uncertainty.py +264 -0
- {ucon-0.5.0 → ucon-0.5.1}/ucon/core.py +151 -12
- {ucon-0.5.0 → ucon-0.5.1}/ucon/maps.py +22 -1
- {ucon-0.5.0 → ucon-0.5.1}/ucon.egg-info/PKG-INFO +23 -2
- {ucon-0.5.0 → ucon-0.5.1}/ucon.egg-info/SOURCES.txt +1 -0
- {ucon-0.5.0 → ucon-0.5.1}/.github/workflows/publish.yaml +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/.github/workflows/tests.yaml +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/.gitignore +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/LICENSE +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/NOTICE +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/composable-unit-algebra.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/composite-units.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/pseudo-dimension-tuple-values.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/unit-algebra-naming.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/unity-distance-metric-for-nearest-scale.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/explainers/exponent-scale-relationship.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/explainers/type-operation-matrix.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/explainers/why-algebraic-closure-matters.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/explainers/why-type-safety-matters.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/proposals/interface-unifying-the-value-layer.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/proposals/project_unified-algebraic-core.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/proposals/support-for-fractional-exponents.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/docs/proposals/unified-unit-presentation.md +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/noxfile.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/requirements.txt +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/setup.cfg +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/setup.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/__init__.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/__init__.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/conversion/__init__.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/conversion/test_graph.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/conversion/test_map.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_algebra.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_core.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_default_graph_conversions.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_dimensionless_units.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_quantity.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_units.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/ucon/__init__.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/ucon/algebra.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/ucon/graph.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/ucon/quantity.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/ucon/units.py +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/ucon.egg-info/dependency_links.txt +0 -0
- {ucon-0.5.0 → ucon-0.5.1}/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.5.
|
|
3
|
+
Version: 0.5.1
|
|
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
|
|
@@ -67,6 +67,7 @@ It combines **units**, **scales**, and **dimensions** into a composable algebra
|
|
|
67
67
|
- Scale-aware arithmetic via `UnitFactor` and `UnitProduct`
|
|
68
68
|
- Metric and binary prefixes (`kilo`, `kibi`, `micro`, `mebi`, etc.)
|
|
69
69
|
- Pseudo-dimensions for angles, solid angles, and ratios with semantic isolation
|
|
70
|
+
- Uncertainty propagation through arithmetic and conversions
|
|
70
71
|
- A clean foundation for physics, chemistry, data modeling, and beyond
|
|
71
72
|
|
|
72
73
|
Think of it as **`decimal.Decimal` for the physical world** — precise, predictable, and type-safe.
|
|
@@ -215,6 +216,25 @@ print(ratio.to(units.ppm)) # <500000.0 ppm>
|
|
|
215
216
|
units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
216
217
|
```
|
|
217
218
|
|
|
219
|
+
Uncertainty propagates through arithmetic and conversions:
|
|
220
|
+
```python
|
|
221
|
+
from ucon import units
|
|
222
|
+
|
|
223
|
+
# Measurements with uncertainty
|
|
224
|
+
length = units.meter(1.234, uncertainty=0.005)
|
|
225
|
+
width = units.meter(0.567, uncertainty=0.003)
|
|
226
|
+
|
|
227
|
+
print(length) # <1.234 ± 0.005 m>
|
|
228
|
+
|
|
229
|
+
# Uncertainty propagates through arithmetic (quadrature)
|
|
230
|
+
area = length * width
|
|
231
|
+
print(area) # <0.699678 ± 0.00424... m²>
|
|
232
|
+
|
|
233
|
+
# Uncertainty propagates through conversion
|
|
234
|
+
length_ft = length.to(units.foot)
|
|
235
|
+
print(length_ft) # <4.048... ± 0.0164... ft>
|
|
236
|
+
```
|
|
237
|
+
|
|
218
238
|
---
|
|
219
239
|
|
|
220
240
|
## Roadmap Highlights
|
|
@@ -224,7 +244,8 @@ units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
|
224
244
|
| **0.3.x** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
|
|
225
245
|
| **0.4.x** | Conversion System | `ConversionGraph`, `Number.to()`, callable units | ✅ Complete |
|
|
226
246
|
| **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
|
|
227
|
-
| **0.5.x** |
|
|
247
|
+
| **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
|
|
248
|
+
| **0.5.x** | Unit Systems | `BasisMap`, `UnitSystem` | 🚧 In Progress |
|
|
228
249
|
| **0.7.x** | Pydantic Integration | Type-safe quantity validation | ⏳ Planned |
|
|
229
250
|
|
|
230
251
|
See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
@@ -30,6 +30,7 @@ It combines **units**, **scales**, and **dimensions** into a composable algebra
|
|
|
30
30
|
- Scale-aware arithmetic via `UnitFactor` and `UnitProduct`
|
|
31
31
|
- Metric and binary prefixes (`kilo`, `kibi`, `micro`, `mebi`, etc.)
|
|
32
32
|
- Pseudo-dimensions for angles, solid angles, and ratios with semantic isolation
|
|
33
|
+
- Uncertainty propagation through arithmetic and conversions
|
|
33
34
|
- A clean foundation for physics, chemistry, data modeling, and beyond
|
|
34
35
|
|
|
35
36
|
Think of it as **`decimal.Decimal` for the physical world** — precise, predictable, and type-safe.
|
|
@@ -178,6 +179,25 @@ print(ratio.to(units.ppm)) # <500000.0 ppm>
|
|
|
178
179
|
units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
179
180
|
```
|
|
180
181
|
|
|
182
|
+
Uncertainty propagates through arithmetic and conversions:
|
|
183
|
+
```python
|
|
184
|
+
from ucon import units
|
|
185
|
+
|
|
186
|
+
# Measurements with uncertainty
|
|
187
|
+
length = units.meter(1.234, uncertainty=0.005)
|
|
188
|
+
width = units.meter(0.567, uncertainty=0.003)
|
|
189
|
+
|
|
190
|
+
print(length) # <1.234 ± 0.005 m>
|
|
191
|
+
|
|
192
|
+
# Uncertainty propagates through arithmetic (quadrature)
|
|
193
|
+
area = length * width
|
|
194
|
+
print(area) # <0.699678 ± 0.00424... m²>
|
|
195
|
+
|
|
196
|
+
# Uncertainty propagates through conversion
|
|
197
|
+
length_ft = length.to(units.foot)
|
|
198
|
+
print(length_ft) # <4.048... ± 0.0164... ft>
|
|
199
|
+
```
|
|
200
|
+
|
|
181
201
|
---
|
|
182
202
|
|
|
183
203
|
## Roadmap Highlights
|
|
@@ -187,7 +207,8 @@ units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
|
187
207
|
| **0.3.x** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
|
|
188
208
|
| **0.4.x** | Conversion System | `ConversionGraph`, `Number.to()`, callable units | ✅ Complete |
|
|
189
209
|
| **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
|
|
190
|
-
| **0.5.x** |
|
|
210
|
+
| **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
|
|
211
|
+
| **0.5.x** | Unit Systems | `BasisMap`, `UnitSystem` | 🚧 In Progress |
|
|
191
212
|
| **0.7.x** | Pydantic Integration | Type-safe quantity validation | ⏳ Planned |
|
|
192
213
|
|
|
193
214
|
See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
@@ -23,7 +23,7 @@ ucon is a dimensional analysis library for engineers building systems where unit
|
|
|
23
23
|
| v0.3.x | Dimensional Algebra | Complete |
|
|
24
24
|
| v0.4.x | Core Conversion + Information | Complete |
|
|
25
25
|
| v0.5.0 | Dimensionless Units | Complete |
|
|
26
|
-
| v0.5.x | Uncertainty Propagation |
|
|
26
|
+
| v0.5.x | Uncertainty Propagation | Complete |
|
|
27
27
|
| v0.5.x | BasisMap + UnitSystem | Planned |
|
|
28
28
|
| v0.6.0 | NumPy Array Support | Planned |
|
|
29
29
|
| v0.7.0 | Pydantic + Serialization | Planned |
|
|
@@ -38,13 +38,14 @@ ucon is a dimensional analysis library for engineers building systems where unit
|
|
|
38
38
|
|
|
39
39
|
Building on v0.5.0 baseline:
|
|
40
40
|
- `ucon.core` (`Dimension`, `Scale`, `Unit`, `UnitFactor`, `UnitProduct`, `Number`, `Ratio`)
|
|
41
|
-
- `ucon.maps` (`Map`, `LinearMap`, `AffineMap`, `ComposedMap`)
|
|
41
|
+
- `ucon.maps` (`Map`, `LinearMap`, `AffineMap`, `ComposedMap` with `derivative()`)
|
|
42
42
|
- `ucon.graph` (`ConversionGraph`, default graph, `get_default_graph()`, `using_graph()`)
|
|
43
43
|
- `ucon.units` (SI + imperial + information + angle + ratio units, callable syntax)
|
|
44
44
|
- Callable unit API: `meter(5)`, `(mile / hour)(60)`
|
|
45
45
|
- `Number.simplify()` for base-scale normalization
|
|
46
46
|
- `Dimension.information` with `units.bit`, `units.byte`
|
|
47
47
|
- Pseudo-dimensions: `angle`, `solid_angle`, `ratio` with semantic isolation
|
|
48
|
+
- Uncertainty propagation: `meter(1.234, uncertainty=0.005)` with quadrature arithmetic
|
|
48
49
|
|
|
49
50
|
---
|
|
50
51
|
|
|
@@ -116,15 +117,15 @@ Building on v0.5.0 baseline:
|
|
|
116
117
|
|
|
117
118
|
---
|
|
118
119
|
|
|
119
|
-
## v0.5.x — Uncertainty Propagation
|
|
120
|
+
## v0.5.x — Uncertainty Propagation (Complete)
|
|
120
121
|
|
|
121
122
|
**Theme:** Metrology foundation.
|
|
122
123
|
|
|
123
|
-
- [
|
|
124
|
-
- [
|
|
125
|
-
- [
|
|
126
|
-
- [
|
|
127
|
-
- [
|
|
124
|
+
- [x] `Number.uncertainty: float | None`
|
|
125
|
+
- [x] Propagation through arithmetic (uncorrelated, quadrature)
|
|
126
|
+
- [x] Propagation through conversion via `Map.derivative()`
|
|
127
|
+
- [x] Construction: `meter(1.234, uncertainty=0.005)`
|
|
128
|
+
- [x] Display: `1.234 ± 0.005 meter`
|
|
128
129
|
|
|
129
130
|
**Outcomes:**
|
|
130
131
|
- First-class uncertainty support for scientific and engineering workflows
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# © 2026 The Radiativity Company
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
# See the LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Tests for v0.5.x uncertainty propagation.
|
|
7
|
+
|
|
8
|
+
Tests Number construction with uncertainty, display formatting,
|
|
9
|
+
arithmetic propagation, and conversion propagation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
from ucon import units, Scale
|
|
16
|
+
from ucon.core import Number, UnitProduct
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestUncertaintyConstruction(unittest.TestCase):
|
|
20
|
+
"""Test constructing Numbers with uncertainty."""
|
|
21
|
+
|
|
22
|
+
def test_number_with_uncertainty(self):
|
|
23
|
+
n = units.meter(1.234, uncertainty=0.005)
|
|
24
|
+
self.assertEqual(n.value, 1.234)
|
|
25
|
+
self.assertEqual(n.uncertainty, 0.005)
|
|
26
|
+
|
|
27
|
+
def test_number_without_uncertainty(self):
|
|
28
|
+
n = units.meter(1.234)
|
|
29
|
+
self.assertEqual(n.value, 1.234)
|
|
30
|
+
self.assertIsNone(n.uncertainty)
|
|
31
|
+
|
|
32
|
+
def test_unit_product_callable_with_uncertainty(self):
|
|
33
|
+
km = Scale.kilo * units.meter
|
|
34
|
+
n = km(5.0, uncertainty=0.1)
|
|
35
|
+
self.assertEqual(n.value, 5.0)
|
|
36
|
+
self.assertEqual(n.uncertainty, 0.1)
|
|
37
|
+
|
|
38
|
+
def test_composite_unit_with_uncertainty(self):
|
|
39
|
+
mps = units.meter / units.second
|
|
40
|
+
n = mps(10.0, uncertainty=0.5)
|
|
41
|
+
self.assertEqual(n.value, 10.0)
|
|
42
|
+
self.assertEqual(n.uncertainty, 0.5)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestUncertaintyDisplay(unittest.TestCase):
|
|
46
|
+
"""Test display formatting with uncertainty."""
|
|
47
|
+
|
|
48
|
+
def test_repr_with_uncertainty(self):
|
|
49
|
+
n = units.meter(1.234, uncertainty=0.005)
|
|
50
|
+
r = repr(n)
|
|
51
|
+
self.assertIn("1.234", r)
|
|
52
|
+
self.assertIn("±", r)
|
|
53
|
+
self.assertIn("0.005", r)
|
|
54
|
+
self.assertIn("m", r)
|
|
55
|
+
|
|
56
|
+
def test_repr_without_uncertainty(self):
|
|
57
|
+
n = units.meter(1.234)
|
|
58
|
+
r = repr(n)
|
|
59
|
+
self.assertIn("1.234", r)
|
|
60
|
+
self.assertNotIn("±", r)
|
|
61
|
+
|
|
62
|
+
def test_repr_dimensionless_with_uncertainty(self):
|
|
63
|
+
n = Number(quantity=0.5, uncertainty=0.01)
|
|
64
|
+
r = repr(n)
|
|
65
|
+
self.assertIn("0.5", r)
|
|
66
|
+
self.assertIn("±", r)
|
|
67
|
+
self.assertIn("0.01", r)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestAdditionSubtractionPropagation(unittest.TestCase):
|
|
71
|
+
"""Test uncertainty propagation through addition and subtraction."""
|
|
72
|
+
|
|
73
|
+
def test_addition_both_uncertain(self):
|
|
74
|
+
a = units.meter(10.0, uncertainty=0.3)
|
|
75
|
+
b = units.meter(5.0, uncertainty=0.4)
|
|
76
|
+
c = a + b
|
|
77
|
+
self.assertAlmostEqual(c.value, 15.0, places=9)
|
|
78
|
+
# sqrt(0.3² + 0.4²) = sqrt(0.09 + 0.16) = sqrt(0.25) = 0.5
|
|
79
|
+
self.assertAlmostEqual(c.uncertainty, 0.5, places=9)
|
|
80
|
+
|
|
81
|
+
def test_subtraction_both_uncertain(self):
|
|
82
|
+
a = units.meter(10.0, uncertainty=0.3)
|
|
83
|
+
b = units.meter(5.0, uncertainty=0.4)
|
|
84
|
+
c = a - b
|
|
85
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
86
|
+
# sqrt(0.3² + 0.4²) = 0.5
|
|
87
|
+
self.assertAlmostEqual(c.uncertainty, 0.5, places=9)
|
|
88
|
+
|
|
89
|
+
def test_addition_one_uncertain(self):
|
|
90
|
+
a = units.meter(10.0, uncertainty=0.3)
|
|
91
|
+
b = units.meter(5.0) # no uncertainty
|
|
92
|
+
c = a + b
|
|
93
|
+
self.assertAlmostEqual(c.value, 15.0, places=9)
|
|
94
|
+
self.assertAlmostEqual(c.uncertainty, 0.3, places=9)
|
|
95
|
+
|
|
96
|
+
def test_addition_neither_uncertain(self):
|
|
97
|
+
a = units.meter(10.0)
|
|
98
|
+
b = units.meter(5.0)
|
|
99
|
+
c = a + b
|
|
100
|
+
self.assertAlmostEqual(c.value, 15.0, places=9)
|
|
101
|
+
self.assertIsNone(c.uncertainty)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestMultiplicationPropagation(unittest.TestCase):
|
|
105
|
+
"""Test uncertainty propagation through multiplication."""
|
|
106
|
+
|
|
107
|
+
def test_multiplication_both_uncertain(self):
|
|
108
|
+
a = units.meter(10.0, uncertainty=0.2) # 2% relative
|
|
109
|
+
b = units.meter(5.0, uncertainty=0.15) # 3% relative
|
|
110
|
+
c = a * b
|
|
111
|
+
self.assertAlmostEqual(c.value, 50.0, places=9)
|
|
112
|
+
# relative: sqrt((0.2/10)² + (0.15/5)²) = sqrt(0.0004 + 0.0009) = sqrt(0.0013) ≈ 0.0361
|
|
113
|
+
# absolute: 50 * 0.0361 ≈ 1.803
|
|
114
|
+
expected = 50.0 * math.sqrt((0.2/10)**2 + (0.15/5)**2)
|
|
115
|
+
self.assertAlmostEqual(c.uncertainty, expected, places=6)
|
|
116
|
+
|
|
117
|
+
def test_multiplication_one_uncertain(self):
|
|
118
|
+
a = units.meter(10.0, uncertainty=0.2)
|
|
119
|
+
b = units.meter(5.0) # no uncertainty
|
|
120
|
+
c = a * b
|
|
121
|
+
self.assertAlmostEqual(c.value, 50.0, places=9)
|
|
122
|
+
# Only a contributes: |c| * (δa/a) = 50 * 0.02 = 1.0
|
|
123
|
+
expected = 50.0 * (0.2/10)
|
|
124
|
+
self.assertAlmostEqual(c.uncertainty, expected, places=9)
|
|
125
|
+
|
|
126
|
+
def test_multiplication_neither_uncertain(self):
|
|
127
|
+
a = units.meter(10.0)
|
|
128
|
+
b = units.meter(5.0)
|
|
129
|
+
c = a * b
|
|
130
|
+
self.assertAlmostEqual(c.value, 50.0, places=9)
|
|
131
|
+
self.assertIsNone(c.uncertainty)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestDivisionPropagation(unittest.TestCase):
|
|
135
|
+
"""Test uncertainty propagation through division."""
|
|
136
|
+
|
|
137
|
+
def test_division_both_uncertain(self):
|
|
138
|
+
a = units.meter(10.0, uncertainty=0.2) # 2% relative
|
|
139
|
+
b = units.second(2.0, uncertainty=0.04) # 2% relative
|
|
140
|
+
c = a / b
|
|
141
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
142
|
+
# relative: sqrt((0.2/10)² + (0.04/2)²) = sqrt(0.0004 + 0.0004) = sqrt(0.0008) ≈ 0.0283
|
|
143
|
+
# absolute: 5 * 0.0283 ≈ 0.1414
|
|
144
|
+
expected = 5.0 * math.sqrt((0.2/10)**2 + (0.04/2)**2)
|
|
145
|
+
self.assertAlmostEqual(c.uncertainty, expected, places=6)
|
|
146
|
+
|
|
147
|
+
def test_division_one_uncertain(self):
|
|
148
|
+
a = units.meter(10.0, uncertainty=0.2)
|
|
149
|
+
b = units.second(2.0) # no uncertainty
|
|
150
|
+
c = a / b
|
|
151
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
152
|
+
expected = 5.0 * (0.2/10)
|
|
153
|
+
self.assertAlmostEqual(c.uncertainty, expected, places=9)
|
|
154
|
+
|
|
155
|
+
def test_division_neither_uncertain(self):
|
|
156
|
+
a = units.meter(10.0)
|
|
157
|
+
b = units.second(2.0)
|
|
158
|
+
c = a / b
|
|
159
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
160
|
+
self.assertIsNone(c.uncertainty)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TestScalarOperations(unittest.TestCase):
|
|
164
|
+
"""Test uncertainty propagation with scalar multiplication/division."""
|
|
165
|
+
|
|
166
|
+
def test_scalar_multiply_number(self):
|
|
167
|
+
a = units.meter(10.0, uncertainty=0.2)
|
|
168
|
+
c = a * 3 # scalar multiplication
|
|
169
|
+
self.assertAlmostEqual(c.value, 30.0, places=9)
|
|
170
|
+
self.assertAlmostEqual(c.uncertainty, 0.6, places=9)
|
|
171
|
+
|
|
172
|
+
def test_scalar_divide_number(self):
|
|
173
|
+
a = units.meter(10.0, uncertainty=0.2)
|
|
174
|
+
c = a / 2 # scalar division
|
|
175
|
+
self.assertAlmostEqual(c.value, 5.0, places=9)
|
|
176
|
+
self.assertAlmostEqual(c.uncertainty, 0.1, places=9)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TestConversionPropagation(unittest.TestCase):
|
|
180
|
+
"""Test uncertainty propagation through unit conversion."""
|
|
181
|
+
|
|
182
|
+
def test_linear_conversion(self):
|
|
183
|
+
# meter to foot: factor ≈ 3.28084
|
|
184
|
+
length = units.meter(1.0, uncertainty=0.01)
|
|
185
|
+
length_ft = length.to(units.foot)
|
|
186
|
+
self.assertAlmostEqual(length_ft.value, 3.28084, places=4)
|
|
187
|
+
# uncertainty scales by same factor
|
|
188
|
+
self.assertAlmostEqual(length_ft.uncertainty, 0.01 * 3.28084, places=4)
|
|
189
|
+
|
|
190
|
+
def test_affine_conversion(self):
|
|
191
|
+
# Celsius to Kelvin: K = C + 273.15
|
|
192
|
+
# Derivative is 1, so uncertainty unchanged
|
|
193
|
+
temp = units.celsius(25.0, uncertainty=0.5)
|
|
194
|
+
temp_k = temp.to(units.kelvin)
|
|
195
|
+
self.assertAlmostEqual(temp_k.value, 298.15, places=9)
|
|
196
|
+
self.assertAlmostEqual(temp_k.uncertainty, 0.5, places=9)
|
|
197
|
+
|
|
198
|
+
def test_fahrenheit_to_celsius(self):
|
|
199
|
+
# F to C: C = (F - 32) * 5/9
|
|
200
|
+
# Derivative is 5/9 ≈ 0.5556
|
|
201
|
+
temp = units.fahrenheit(100.0, uncertainty=1.0)
|
|
202
|
+
temp_c = temp.to(units.celsius)
|
|
203
|
+
self.assertAlmostEqual(temp_c.value, 37.7778, places=3)
|
|
204
|
+
self.assertAlmostEqual(temp_c.uncertainty, 1.0 * (5/9), places=6)
|
|
205
|
+
|
|
206
|
+
def test_conversion_without_uncertainty(self):
|
|
207
|
+
length = units.meter(1.0)
|
|
208
|
+
length_ft = length.to(units.foot)
|
|
209
|
+
self.assertIsNone(length_ft.uncertainty)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestEdgeCases(unittest.TestCase):
|
|
213
|
+
"""Test edge cases for uncertainty handling."""
|
|
214
|
+
|
|
215
|
+
def test_zero_uncertainty(self):
|
|
216
|
+
a = units.meter(10.0, uncertainty=0.0)
|
|
217
|
+
b = units.meter(5.0, uncertainty=0.0)
|
|
218
|
+
c = a + b
|
|
219
|
+
self.assertAlmostEqual(c.uncertainty, 0.0, places=9)
|
|
220
|
+
|
|
221
|
+
def test_very_small_uncertainty(self):
|
|
222
|
+
a = units.meter(10.0, uncertainty=1e-15)
|
|
223
|
+
b = units.meter(5.0, uncertainty=1e-15)
|
|
224
|
+
c = a * b
|
|
225
|
+
self.assertIsNotNone(c.uncertainty)
|
|
226
|
+
self.assertGreater(c.uncertainty, 0)
|
|
227
|
+
|
|
228
|
+
def test_uncertainty_preserved_through_simplify(self):
|
|
229
|
+
km = Scale.kilo * units.meter
|
|
230
|
+
n = km(5.0, uncertainty=0.1)
|
|
231
|
+
simplified = n.simplify()
|
|
232
|
+
self.assertAlmostEqual(simplified.value, 5000.0, places=9)
|
|
233
|
+
# uncertainty also scales
|
|
234
|
+
self.assertAlmostEqual(simplified.uncertainty, 100.0, places=9)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TestMapDerivative(unittest.TestCase):
|
|
238
|
+
"""Test Map.derivative() implementations."""
|
|
239
|
+
|
|
240
|
+
def test_linear_map_derivative(self):
|
|
241
|
+
from ucon.maps import LinearMap
|
|
242
|
+
m = LinearMap(3.28084)
|
|
243
|
+
self.assertAlmostEqual(m.derivative(0), 3.28084, places=9)
|
|
244
|
+
self.assertAlmostEqual(m.derivative(100), 3.28084, places=9)
|
|
245
|
+
|
|
246
|
+
def test_affine_map_derivative(self):
|
|
247
|
+
from ucon.maps import AffineMap
|
|
248
|
+
m = AffineMap(5/9, -32 * 5/9) # F to C
|
|
249
|
+
self.assertAlmostEqual(m.derivative(0), 5/9, places=9)
|
|
250
|
+
self.assertAlmostEqual(m.derivative(100), 5/9, places=9)
|
|
251
|
+
|
|
252
|
+
def test_composed_map_derivative(self):
|
|
253
|
+
from ucon.maps import LinearMap, AffineMap
|
|
254
|
+
# Compose: first scale by 2, then add 10
|
|
255
|
+
inner = LinearMap(2)
|
|
256
|
+
outer = AffineMap(1, 10)
|
|
257
|
+
composed = outer @ inner
|
|
258
|
+
# d/dx [1*(2x) + 10] = 2
|
|
259
|
+
self.assertAlmostEqual(composed.derivative(0), 2, places=9)
|
|
260
|
+
self.assertAlmostEqual(composed.derivative(5), 2, places=9)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
unittest.main()
|
|
@@ -445,15 +445,24 @@ class Unit:
|
|
|
445
445
|
|
|
446
446
|
# ----------------- callable (creates Number) -----------------
|
|
447
447
|
|
|
448
|
-
def __call__(self, quantity: Union[int, float]) -> "Number":
|
|
448
|
+
def __call__(self, quantity: Union[int, float], uncertainty: Union[float, None] = None) -> "Number":
|
|
449
449
|
"""Create a Number with this unit.
|
|
450
450
|
|
|
451
|
+
Parameters
|
|
452
|
+
----------
|
|
453
|
+
quantity : int or float
|
|
454
|
+
The numeric value.
|
|
455
|
+
uncertainty : float, optional
|
|
456
|
+
The measurement uncertainty.
|
|
457
|
+
|
|
451
458
|
Example
|
|
452
459
|
-------
|
|
453
460
|
>>> meter(5)
|
|
454
461
|
<5 m>
|
|
462
|
+
>>> meter(1.234, uncertainty=0.005)
|
|
463
|
+
<1.234 ± 0.005 m>
|
|
455
464
|
"""
|
|
456
|
-
return Number(quantity=quantity, unit=UnitProduct.from_unit(self))
|
|
465
|
+
return Number(quantity=quantity, unit=UnitProduct.from_unit(self), uncertainty=uncertainty)
|
|
457
466
|
|
|
458
467
|
|
|
459
468
|
@dataclass(frozen=True)
|
|
@@ -872,15 +881,24 @@ class UnitProduct:
|
|
|
872
881
|
# Sort by name; UnitFactor exposes .name, so this is stable.
|
|
873
882
|
return hash(tuple(sorted(self.factors.items(), key=lambda x: x[0].name)))
|
|
874
883
|
|
|
875
|
-
def __call__(self, quantity: Union[int, float]) -> "Number":
|
|
884
|
+
def __call__(self, quantity: Union[int, float], uncertainty: Union[float, None] = None) -> "Number":
|
|
876
885
|
"""Create a Number with this unit product.
|
|
877
886
|
|
|
887
|
+
Parameters
|
|
888
|
+
----------
|
|
889
|
+
quantity : int or float
|
|
890
|
+
The numeric value.
|
|
891
|
+
uncertainty : float, optional
|
|
892
|
+
The measurement uncertainty.
|
|
893
|
+
|
|
878
894
|
Example
|
|
879
895
|
-------
|
|
880
896
|
>>> (meter / second)(10)
|
|
881
897
|
<10 m/s>
|
|
898
|
+
>>> (meter / second)(10, uncertainty=0.5)
|
|
899
|
+
<10 ± 0.5 m/s>
|
|
882
900
|
"""
|
|
883
|
-
return Number(quantity=quantity, unit=self)
|
|
901
|
+
return Number(quantity=quantity, unit=self, uncertainty=uncertainty)
|
|
884
902
|
|
|
885
903
|
|
|
886
904
|
# --------------------------------------------------------------------------------------
|
|
@@ -908,9 +926,16 @@ class Number:
|
|
|
908
926
|
>>> speed = length / time
|
|
909
927
|
>>> speed
|
|
910
928
|
<2.5 m/s>
|
|
929
|
+
|
|
930
|
+
Optionally includes measurement uncertainty for error propagation:
|
|
931
|
+
|
|
932
|
+
>>> length = meter(1.234, uncertainty=0.005)
|
|
933
|
+
>>> length
|
|
934
|
+
<1.234 ± 0.005 m>
|
|
911
935
|
"""
|
|
912
936
|
quantity: Union[float, int] = 1.0
|
|
913
937
|
unit: Union[Unit, UnitProduct] = None
|
|
938
|
+
uncertainty: Union[float, None] = None
|
|
914
939
|
|
|
915
940
|
def __post_init__(self):
|
|
916
941
|
if self.unit is None:
|
|
@@ -952,7 +977,7 @@ class Number:
|
|
|
952
977
|
"""
|
|
953
978
|
if not isinstance(self.unit, UnitProduct):
|
|
954
979
|
# Plain Unit already has no scale
|
|
955
|
-
return Number(quantity=self.quantity, unit=self.unit)
|
|
980
|
+
return Number(quantity=self.quantity, unit=self.unit, uncertainty=self.uncertainty)
|
|
956
981
|
|
|
957
982
|
# Compute the combined scale factor
|
|
958
983
|
scale_factor = self.unit.fold_scale()
|
|
@@ -965,8 +990,16 @@ class Number:
|
|
|
965
990
|
|
|
966
991
|
base_unit = UnitProduct(base_factors)
|
|
967
992
|
|
|
968
|
-
# Adjust quantity by the scale factor
|
|
969
|
-
|
|
993
|
+
# Adjust quantity and uncertainty by the scale factor
|
|
994
|
+
new_uncertainty = None
|
|
995
|
+
if self.uncertainty is not None:
|
|
996
|
+
new_uncertainty = self.uncertainty * abs(scale_factor)
|
|
997
|
+
|
|
998
|
+
return Number(
|
|
999
|
+
quantity=self.quantity * scale_factor,
|
|
1000
|
+
unit=base_unit,
|
|
1001
|
+
uncertainty=new_uncertainty,
|
|
1002
|
+
)
|
|
970
1003
|
|
|
971
1004
|
def to(self, target, graph=None):
|
|
972
1005
|
"""Convert this Number to a different unit expression.
|
|
@@ -999,7 +1032,10 @@ class Number:
|
|
|
999
1032
|
# Scale-only conversion (same base unit, different scale)
|
|
1000
1033
|
if self._is_scale_only_conversion(src, dst):
|
|
1001
1034
|
factor = src.fold_scale() / dst.fold_scale()
|
|
1002
|
-
|
|
1035
|
+
new_uncertainty = None
|
|
1036
|
+
if self.uncertainty is not None:
|
|
1037
|
+
new_uncertainty = self.uncertainty * abs(factor)
|
|
1038
|
+
return Number(quantity=self.quantity * factor, unit=target, uncertainty=new_uncertainty)
|
|
1003
1039
|
|
|
1004
1040
|
# Graph-based conversion (use default graph if none provided)
|
|
1005
1041
|
if graph is None:
|
|
@@ -1008,7 +1044,14 @@ class Number:
|
|
|
1008
1044
|
conversion_map = graph.convert(src=src, dst=dst)
|
|
1009
1045
|
# Use raw quantity - the conversion map handles scale via factorwise decomposition
|
|
1010
1046
|
converted_quantity = conversion_map(self.quantity)
|
|
1011
|
-
|
|
1047
|
+
|
|
1048
|
+
# Propagate uncertainty through conversion using derivative
|
|
1049
|
+
new_uncertainty = None
|
|
1050
|
+
if self.uncertainty is not None:
|
|
1051
|
+
derivative = abs(conversion_map.derivative(self.quantity))
|
|
1052
|
+
new_uncertainty = derivative * self.uncertainty
|
|
1053
|
+
|
|
1054
|
+
return Number(quantity=converted_quantity, unit=target, uncertainty=new_uncertainty)
|
|
1012
1055
|
|
|
1013
1056
|
def _is_scale_only_conversion(self, src: UnitProduct, dst: UnitProduct) -> bool:
|
|
1014
1057
|
"""Check if conversion is just a scale change (same base units)."""
|
|
@@ -1040,12 +1083,82 @@ class Number:
|
|
|
1040
1083
|
if isinstance(other, Ratio):
|
|
1041
1084
|
other = other.evaluate()
|
|
1042
1085
|
|
|
1086
|
+
# Scalar multiplication
|
|
1087
|
+
if isinstance(other, (int, float)):
|
|
1088
|
+
new_uncertainty = None
|
|
1089
|
+
if self.uncertainty is not None:
|
|
1090
|
+
new_uncertainty = abs(other) * self.uncertainty
|
|
1091
|
+
return Number(
|
|
1092
|
+
quantity=self.quantity * other,
|
|
1093
|
+
unit=self.unit,
|
|
1094
|
+
uncertainty=new_uncertainty,
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1043
1097
|
if not isinstance(other, Number):
|
|
1044
1098
|
return NotImplemented
|
|
1045
1099
|
|
|
1100
|
+
# Uncertainty propagation for multiplication
|
|
1101
|
+
# δc = |c| * sqrt((δa/a)² + (δb/b)²)
|
|
1102
|
+
new_uncertainty = None
|
|
1103
|
+
result_quantity = self.quantity * other.quantity
|
|
1104
|
+
if self.uncertainty is not None or other.uncertainty is not None:
|
|
1105
|
+
rel_a = (self.uncertainty / abs(self.quantity)) if (self.uncertainty and self.quantity != 0) else 0
|
|
1106
|
+
rel_b = (other.uncertainty / abs(other.quantity)) if (other.uncertainty and other.quantity != 0) else 0
|
|
1107
|
+
rel_c = math.sqrt(rel_a**2 + rel_b**2)
|
|
1108
|
+
new_uncertainty = abs(result_quantity) * rel_c if rel_c > 0 else None
|
|
1109
|
+
|
|
1046
1110
|
return Number(
|
|
1047
|
-
quantity=
|
|
1111
|
+
quantity=result_quantity,
|
|
1048
1112
|
unit=self.unit * other.unit,
|
|
1113
|
+
uncertainty=new_uncertainty,
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
def __add__(self, other: 'Number') -> 'Number':
|
|
1117
|
+
if not isinstance(other, Number):
|
|
1118
|
+
return NotImplemented
|
|
1119
|
+
|
|
1120
|
+
# Dimensions must match for addition
|
|
1121
|
+
if self.unit.dimension != other.unit.dimension:
|
|
1122
|
+
raise TypeError(
|
|
1123
|
+
f"Cannot add Numbers with different dimensions: "
|
|
1124
|
+
f"{self.unit.dimension} vs {other.unit.dimension}"
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
# Uncertainty propagation for addition: δc = sqrt(δa² + δb²)
|
|
1128
|
+
new_uncertainty = None
|
|
1129
|
+
if self.uncertainty is not None or other.uncertainty is not None:
|
|
1130
|
+
ua = self.uncertainty if self.uncertainty is not None else 0
|
|
1131
|
+
ub = other.uncertainty if other.uncertainty is not None else 0
|
|
1132
|
+
new_uncertainty = math.sqrt(ua**2 + ub**2)
|
|
1133
|
+
|
|
1134
|
+
return Number(
|
|
1135
|
+
quantity=self.quantity + other.quantity,
|
|
1136
|
+
unit=self.unit,
|
|
1137
|
+
uncertainty=new_uncertainty,
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
def __sub__(self, other: 'Number') -> 'Number':
|
|
1141
|
+
if not isinstance(other, Number):
|
|
1142
|
+
return NotImplemented
|
|
1143
|
+
|
|
1144
|
+
# Dimensions must match for subtraction
|
|
1145
|
+
if self.unit.dimension != other.unit.dimension:
|
|
1146
|
+
raise TypeError(
|
|
1147
|
+
f"Cannot subtract Numbers with different dimensions: "
|
|
1148
|
+
f"{self.unit.dimension} vs {other.unit.dimension}"
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
# Uncertainty propagation for subtraction: δc = sqrt(δa² + δb²)
|
|
1152
|
+
new_uncertainty = None
|
|
1153
|
+
if self.uncertainty is not None or other.uncertainty is not None:
|
|
1154
|
+
ua = self.uncertainty if self.uncertainty is not None else 0
|
|
1155
|
+
ub = other.uncertainty if other.uncertainty is not None else 0
|
|
1156
|
+
new_uncertainty = math.sqrt(ua**2 + ub**2)
|
|
1157
|
+
|
|
1158
|
+
return Number(
|
|
1159
|
+
quantity=self.quantity - other.quantity,
|
|
1160
|
+
unit=self.unit,
|
|
1161
|
+
uncertainty=new_uncertainty,
|
|
1049
1162
|
)
|
|
1050
1163
|
|
|
1051
1164
|
def __truediv__(self, other: Quantifiable) -> "Number":
|
|
@@ -1053,25 +1166,47 @@ class Number:
|
|
|
1053
1166
|
if isinstance(other, Ratio):
|
|
1054
1167
|
other = other.evaluate()
|
|
1055
1168
|
|
|
1169
|
+
# Scalar division
|
|
1170
|
+
if isinstance(other, (int, float)):
|
|
1171
|
+
new_uncertainty = None
|
|
1172
|
+
if self.uncertainty is not None:
|
|
1173
|
+
new_uncertainty = self.uncertainty / abs(other)
|
|
1174
|
+
return Number(
|
|
1175
|
+
quantity=self.quantity / other,
|
|
1176
|
+
unit=self.unit,
|
|
1177
|
+
uncertainty=new_uncertainty,
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1056
1180
|
if not isinstance(other, Number):
|
|
1057
1181
|
raise TypeError(f"Cannot divide Number by non-Number/Ratio type: {type(other)}")
|
|
1058
1182
|
|
|
1059
1183
|
# Symbolic quotient in the unit algebra
|
|
1060
1184
|
unit_quot = self.unit / other.unit
|
|
1061
1185
|
|
|
1186
|
+
# Uncertainty propagation for division
|
|
1187
|
+
# δc = |c| * sqrt((δa/a)² + (δb/b)²)
|
|
1188
|
+
def compute_uncertainty(result_quantity):
|
|
1189
|
+
if self.uncertainty is None and other.uncertainty is None:
|
|
1190
|
+
return None
|
|
1191
|
+
rel_a = (self.uncertainty / abs(self.quantity)) if (self.uncertainty and self.quantity != 0) else 0
|
|
1192
|
+
rel_b = (other.uncertainty / abs(other.quantity)) if (other.uncertainty and other.quantity != 0) else 0
|
|
1193
|
+
rel_c = math.sqrt(rel_a**2 + rel_b**2)
|
|
1194
|
+
return abs(result_quantity) * rel_c if rel_c > 0 else None
|
|
1195
|
+
|
|
1062
1196
|
# --- Case 1: Dimensionless result ----------------------------------
|
|
1063
1197
|
# If the net dimension is none, we want a pure scalar:
|
|
1064
1198
|
# fold *all* scale factors into the numeric magnitude.
|
|
1065
1199
|
if not unit_quot.dimension:
|
|
1066
1200
|
num = self._canonical_magnitude
|
|
1067
1201
|
den = other._canonical_magnitude
|
|
1068
|
-
|
|
1202
|
+
result = num / den
|
|
1203
|
+
return Number(quantity=result, unit=_none, uncertainty=compute_uncertainty(result))
|
|
1069
1204
|
|
|
1070
1205
|
# --- Case 2: Dimensionful result -----------------------------------
|
|
1071
1206
|
# For "real" physical results like g/mL, m/s², etc., preserve the
|
|
1072
1207
|
# user's chosen unit scales symbolically. Only divide the raw quantities.
|
|
1073
1208
|
new_quantity = self.quantity / other.quantity
|
|
1074
|
-
return Number(quantity=new_quantity, unit=unit_quot)
|
|
1209
|
+
return Number(quantity=new_quantity, unit=unit_quot, uncertainty=compute_uncertainty(new_quantity))
|
|
1075
1210
|
|
|
1076
1211
|
def __eq__(self, other: Quantifiable) -> bool:
|
|
1077
1212
|
if not isinstance(other, (Number, Ratio)):
|
|
@@ -1094,6 +1229,10 @@ class Number:
|
|
|
1094
1229
|
return True
|
|
1095
1230
|
|
|
1096
1231
|
def __repr__(self):
|
|
1232
|
+
if self.uncertainty is not None:
|
|
1233
|
+
if not self.unit.dimension:
|
|
1234
|
+
return f"<{self.quantity} ± {self.uncertainty}>"
|
|
1235
|
+
return f"<{self.quantity} ± {self.uncertainty} {self.unit.shorthand}>"
|
|
1097
1236
|
if not self.unit.dimension:
|
|
1098
1237
|
return f"<{self.quantity}>"
|
|
1099
1238
|
return f"<{self.quantity} {self.unit.shorthand}>"
|
|
@@ -25,7 +25,7 @@ from dataclasses import dataclass
|
|
|
25
25
|
class Map(ABC):
|
|
26
26
|
"""Abstract base for all conversion morphisms.
|
|
27
27
|
|
|
28
|
-
Subclasses must implement ``__call__``, ``inverse``, and ``
|
|
28
|
+
Subclasses must implement ``__call__``, ``inverse``, ``__pow__``, and ``derivative``.
|
|
29
29
|
Composition via ``@`` defaults to :class:`ComposedMap`; subclasses may
|
|
30
30
|
override for closed composition within their own type.
|
|
31
31
|
"""
|
|
@@ -50,6 +50,14 @@ class Map(ABC):
|
|
|
50
50
|
"""Raise map to a power (for exponent handling in factorwise conversion)."""
|
|
51
51
|
...
|
|
52
52
|
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def derivative(self, x: float) -> float:
|
|
55
|
+
"""Return the derivative of the map at point x.
|
|
56
|
+
|
|
57
|
+
Used for uncertainty propagation: δy = |f'(x)| * δx
|
|
58
|
+
"""
|
|
59
|
+
...
|
|
60
|
+
|
|
53
61
|
def is_identity(self, tol: float = 1e-9) -> bool:
|
|
54
62
|
"""Check if this map is approximately the identity."""
|
|
55
63
|
return abs(self(1.0) - 1.0) < tol and abs(self(0.0) - 0.0) < tol
|
|
@@ -86,6 +94,10 @@ class LinearMap(Map):
|
|
|
86
94
|
def __pow__(self, exp: float) -> LinearMap:
|
|
87
95
|
return LinearMap(self.a ** exp)
|
|
88
96
|
|
|
97
|
+
def derivative(self, x: float) -> float:
|
|
98
|
+
"""Derivative of y = a*x is a (constant)."""
|
|
99
|
+
return self.a
|
|
100
|
+
|
|
89
101
|
@classmethod
|
|
90
102
|
def identity(cls) -> LinearMap:
|
|
91
103
|
return cls(1.0)
|
|
@@ -128,6 +140,10 @@ class AffineMap(Map):
|
|
|
128
140
|
return self.inverse()
|
|
129
141
|
raise ValueError("AffineMap only supports exp=1 or exp=-1")
|
|
130
142
|
|
|
143
|
+
def derivative(self, x: float) -> float:
|
|
144
|
+
"""Derivative of y = a*x + b is a (constant)."""
|
|
145
|
+
return self.a
|
|
146
|
+
|
|
131
147
|
|
|
132
148
|
@dataclass(frozen=True)
|
|
133
149
|
class ComposedMap(Map):
|
|
@@ -159,3 +175,8 @@ class ComposedMap(Map):
|
|
|
159
175
|
if exp == -1:
|
|
160
176
|
return self.inverse()
|
|
161
177
|
raise ValueError("ComposedMap only supports exp=1 or exp=-1")
|
|
178
|
+
|
|
179
|
+
def derivative(self, x: float) -> float:
|
|
180
|
+
"""Chain rule: d/dx [outer(inner(x))] = outer'(inner(x)) * inner'(x)."""
|
|
181
|
+
inner_val = self.inner(x)
|
|
182
|
+
return self.outer.derivative(inner_val) * self.inner.derivative(x)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ucon
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
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
|
|
@@ -67,6 +67,7 @@ It combines **units**, **scales**, and **dimensions** into a composable algebra
|
|
|
67
67
|
- Scale-aware arithmetic via `UnitFactor` and `UnitProduct`
|
|
68
68
|
- Metric and binary prefixes (`kilo`, `kibi`, `micro`, `mebi`, etc.)
|
|
69
69
|
- Pseudo-dimensions for angles, solid angles, and ratios with semantic isolation
|
|
70
|
+
- Uncertainty propagation through arithmetic and conversions
|
|
70
71
|
- A clean foundation for physics, chemistry, data modeling, and beyond
|
|
71
72
|
|
|
72
73
|
Think of it as **`decimal.Decimal` for the physical world** — precise, predictable, and type-safe.
|
|
@@ -215,6 +216,25 @@ print(ratio.to(units.ppm)) # <500000.0 ppm>
|
|
|
215
216
|
units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
216
217
|
```
|
|
217
218
|
|
|
219
|
+
Uncertainty propagates through arithmetic and conversions:
|
|
220
|
+
```python
|
|
221
|
+
from ucon import units
|
|
222
|
+
|
|
223
|
+
# Measurements with uncertainty
|
|
224
|
+
length = units.meter(1.234, uncertainty=0.005)
|
|
225
|
+
width = units.meter(0.567, uncertainty=0.003)
|
|
226
|
+
|
|
227
|
+
print(length) # <1.234 ± 0.005 m>
|
|
228
|
+
|
|
229
|
+
# Uncertainty propagates through arithmetic (quadrature)
|
|
230
|
+
area = length * width
|
|
231
|
+
print(area) # <0.699678 ± 0.00424... m²>
|
|
232
|
+
|
|
233
|
+
# Uncertainty propagates through conversion
|
|
234
|
+
length_ft = length.to(units.foot)
|
|
235
|
+
print(length_ft) # <4.048... ± 0.0164... ft>
|
|
236
|
+
```
|
|
237
|
+
|
|
218
238
|
---
|
|
219
239
|
|
|
220
240
|
## Roadmap Highlights
|
|
@@ -224,7 +244,8 @@ units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
|
224
244
|
| **0.3.x** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
|
|
225
245
|
| **0.4.x** | Conversion System | `ConversionGraph`, `Number.to()`, callable units | ✅ Complete |
|
|
226
246
|
| **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
|
|
227
|
-
| **0.5.x** |
|
|
247
|
+
| **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
|
|
248
|
+
| **0.5.x** | Unit Systems | `BasisMap`, `UnitSystem` | 🚧 In Progress |
|
|
228
249
|
| **0.7.x** | Pydantic Integration | Type-safe quantity validation | ⏳ Planned |
|
|
229
250
|
|
|
230
251
|
See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
@@ -28,6 +28,7 @@ tests/ucon/test_core.py
|
|
|
28
28
|
tests/ucon/test_default_graph_conversions.py
|
|
29
29
|
tests/ucon/test_dimensionless_units.py
|
|
30
30
|
tests/ucon/test_quantity.py
|
|
31
|
+
tests/ucon/test_uncertainty.py
|
|
31
32
|
tests/ucon/test_units.py
|
|
32
33
|
tests/ucon/conversion/__init__.py
|
|
33
34
|
tests/ucon/conversion/test_graph.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ucon-0.5.0 → ucon-0.5.1}/NOTICE
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|