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.
Files changed (48) hide show
  1. {ucon-0.5.0 → ucon-0.5.1}/PKG-INFO +23 -2
  2. {ucon-0.5.0 → ucon-0.5.1}/README.md +22 -1
  3. {ucon-0.5.0 → ucon-0.5.1}/ROADMAP.md +9 -8
  4. ucon-0.5.1/tests/ucon/test_uncertainty.py +264 -0
  5. {ucon-0.5.0 → ucon-0.5.1}/ucon/core.py +151 -12
  6. {ucon-0.5.0 → ucon-0.5.1}/ucon/maps.py +22 -1
  7. {ucon-0.5.0 → ucon-0.5.1}/ucon.egg-info/PKG-INFO +23 -2
  8. {ucon-0.5.0 → ucon-0.5.1}/ucon.egg-info/SOURCES.txt +1 -0
  9. {ucon-0.5.0 → ucon-0.5.1}/.github/workflows/publish.yaml +0 -0
  10. {ucon-0.5.0 → ucon-0.5.1}/.github/workflows/tests.yaml +0 -0
  11. {ucon-0.5.0 → ucon-0.5.1}/.gitignore +0 -0
  12. {ucon-0.5.0 → ucon-0.5.1}/LICENSE +0 -0
  13. {ucon-0.5.0 → ucon-0.5.1}/NOTICE +0 -0
  14. {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/composable-unit-algebra.md +0 -0
  15. {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/composite-units.md +0 -0
  16. {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/pseudo-dimension-tuple-values.md +0 -0
  17. {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/unit-algebra-naming.md +0 -0
  18. {ucon-0.5.0 → ucon-0.5.1}/docs/decisions/unity-distance-metric-for-nearest-scale.md +0 -0
  19. {ucon-0.5.0 → ucon-0.5.1}/docs/explainers/exponent-scale-relationship.md +0 -0
  20. {ucon-0.5.0 → ucon-0.5.1}/docs/explainers/type-operation-matrix.md +0 -0
  21. {ucon-0.5.0 → ucon-0.5.1}/docs/explainers/why-algebraic-closure-matters.md +0 -0
  22. {ucon-0.5.0 → ucon-0.5.1}/docs/explainers/why-type-safety-matters.md +0 -0
  23. {ucon-0.5.0 → ucon-0.5.1}/docs/proposals/interface-unifying-the-value-layer.md +0 -0
  24. {ucon-0.5.0 → ucon-0.5.1}/docs/proposals/project_unified-algebraic-core.md +0 -0
  25. {ucon-0.5.0 → ucon-0.5.1}/docs/proposals/support-for-fractional-exponents.md +0 -0
  26. {ucon-0.5.0 → ucon-0.5.1}/docs/proposals/unified-unit-presentation.md +0 -0
  27. {ucon-0.5.0 → ucon-0.5.1}/noxfile.py +0 -0
  28. {ucon-0.5.0 → ucon-0.5.1}/requirements.txt +0 -0
  29. {ucon-0.5.0 → ucon-0.5.1}/setup.cfg +0 -0
  30. {ucon-0.5.0 → ucon-0.5.1}/setup.py +0 -0
  31. {ucon-0.5.0 → ucon-0.5.1}/tests/__init__.py +0 -0
  32. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/__init__.py +0 -0
  33. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/conversion/__init__.py +0 -0
  34. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/conversion/test_graph.py +0 -0
  35. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/conversion/test_map.py +0 -0
  36. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_algebra.py +0 -0
  37. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_core.py +0 -0
  38. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_default_graph_conversions.py +0 -0
  39. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_dimensionless_units.py +0 -0
  40. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_quantity.py +0 -0
  41. {ucon-0.5.0 → ucon-0.5.1}/tests/ucon/test_units.py +0 -0
  42. {ucon-0.5.0 → ucon-0.5.1}/ucon/__init__.py +0 -0
  43. {ucon-0.5.0 → ucon-0.5.1}/ucon/algebra.py +0 -0
  44. {ucon-0.5.0 → ucon-0.5.1}/ucon/graph.py +0 -0
  45. {ucon-0.5.0 → ucon-0.5.1}/ucon/quantity.py +0 -0
  46. {ucon-0.5.0 → ucon-0.5.1}/ucon/units.py +0 -0
  47. {ucon-0.5.0 → ucon-0.5.1}/ucon.egg-info/dependency_links.txt +0 -0
  48. {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.0
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** | Metrology | Uncertainty propagation, `UnitSystem` | 🚧 In Progress |
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** | Metrology | Uncertainty propagation, `UnitSystem` | 🚧 In Progress |
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 | Planned |
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
- - [ ] `Number.uncertainty: float | None`
124
- - [ ] Propagation through arithmetic (uncorrelated, quadrature)
125
- - [ ] Propagation through conversion via `Map.derivative()`
126
- - [ ] Construction: `meter(1.234, uncertainty=0.005)`
127
- - [ ] Display: `1.234 ± 0.005 meter`
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
- return Number(quantity=self.quantity * scale_factor, unit=base_unit)
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
- return Number(quantity=self.quantity * factor, unit=target)
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
- return Number(quantity=converted_quantity, unit=target)
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=self.quantity * other.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
- return Number(quantity=num / den, unit=_none)
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 ``__pow__``.
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.0
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** | Metrology | Uncertainty propagation, `UnitSystem` | 🚧 In Progress |
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
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