ucon 0.6.0__tar.gz → 0.6.2__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.6.0 → ucon-0.6.2}/.github/workflows/publish.yaml +5 -5
- {ucon-0.6.0 → ucon-0.6.2}/PKG-INFO +48 -3
- {ucon-0.6.0 → ucon-0.6.2}/README.md +47 -2
- {ucon-0.6.0 → ucon-0.6.2}/ROADMAP.md +43 -5
- {ucon-0.6.0 → ucon-0.6.2}/pyproject.toml +2 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_dimensionless_units.py +4 -4
- ucon-0.6.2/tests/ucon/test_logmap.py +289 -0
- ucon-0.6.2/tests/ucon/test_nines.py +290 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_unit_parsing.py +43 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon/graph.py +8 -6
- {ucon-0.6.0 → ucon-0.6.2}/ucon/maps.py +116 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon/units.py +20 -3
- {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/PKG-INFO +48 -3
- {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/SOURCES.txt +2 -0
- {ucon-0.6.0 → ucon-0.6.2}/.github/workflows/tests.yaml +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/.gitignore +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/LICENSE +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/Makefile +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/NOTICE +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/001-unity-distance-metric-for-nearest-scale.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/002-composite-units.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/003-composable-unit-algebra.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/004-unit-algebra-naming.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/005-pseudo-dimension-tuple-values.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/006-pydantic-integration-pattern.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/examples/basis-transform-fantasy-units.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/explainers/exponent-scale-relationship.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/explainers/type-operation-matrix.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/explainers/why-algebraic-closure-matters.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/explainers/why-type-safety-matters.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/proposals/interface-unifying-the-value-layer.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/proposals/project_unified-algebraic-core.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/proposals/support-for-fractional-exponents.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/docs/proposals/unified-unit-presentation.md +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/requirements.txt +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/setup.cfg +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/setup.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/__init__.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/__init__.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/conversion/__init__.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/conversion/test_graph.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/conversion/test_map.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/mcp/__init__.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/mcp/test_server.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_algebra.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_basis_transform.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_core.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_default_graph_conversions.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_graph_basis_transform.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_pickle.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_pydantic.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_quantity.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_rebased_unit.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_uncertainty.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_unit_system.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_units.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_vector_fraction.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon/__init__.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon/algebra.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon/core.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon/mcp/__init__.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon/mcp/server.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon/pydantic.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon/quantity.py +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/dependency_links.txt +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/entry_points.txt +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/requires.txt +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/top_level.txt +0 -0
- {ucon-0.6.0 → ucon-0.6.2}/uv.lock +0 -0
|
@@ -33,8 +33,8 @@ jobs:
|
|
|
33
33
|
with:
|
|
34
34
|
user: __token__
|
|
35
35
|
password: ${{ secrets.test_pypi_password }}
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
skip-existing: true
|
|
37
|
+
repository-url: https://test.pypi.org/legacy/
|
|
38
38
|
|
|
39
39
|
- name: Verify tag is on mainline
|
|
40
40
|
if: startsWith(github.ref, 'refs/tags/')
|
|
@@ -53,8 +53,8 @@ jobs:
|
|
|
53
53
|
with:
|
|
54
54
|
user: __token__
|
|
55
55
|
password: ${{ secrets.test_pypi_password }}
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
skip-existing: true
|
|
57
|
+
repository-url: https://test.pypi.org/legacy/
|
|
58
58
|
|
|
59
59
|
- name: 🚀 Publish to Prod PyPI
|
|
60
60
|
if: startsWith(github.ref, 'refs/tags/')
|
|
@@ -62,4 +62,4 @@ jobs:
|
|
|
62
62
|
with:
|
|
63
63
|
user: __token__
|
|
64
64
|
password: ${{ secrets.pypi_password }}
|
|
65
|
-
|
|
65
|
+
skip-existing: true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ucon
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -94,7 +94,7 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
|
|
|
94
94
|
| **`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. |
|
|
95
95
|
| **`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. |
|
|
96
96
|
| **`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). |
|
|
97
|
-
| **`Map`** hierarchy | `ucon.maps` | Composable conversion morphisms: `LinearMap`, `AffineMap`, `ComposedMap`.
|
|
97
|
+
| **`Map`** hierarchy | `ucon.maps` | Composable conversion morphisms: `LinearMap`, `AffineMap`, `LogMap`, `ExpMap`, `ComposedMap`. | Defining conversion functions between units (e.g., meter→foot, celsius→kelvin, availability→nines). |
|
|
98
98
|
| **`ConversionGraph`** | `ucon.graph` | Registry of unit conversion edges with BFS path composition. | Converting between units via `Number.to(target)`; managing default and custom graphs. |
|
|
99
99
|
| **`UnitSystem`** | `ucon.core` | Named mapping from dimensions to base units (e.g., SI, Imperial). | Defining coherent unit systems; grouping base units by dimension. |
|
|
100
100
|
| **`BasisTransform`** | `ucon.core` | Matrix-based transformation between dimensional exponent spaces. | Converting between incompatible dimensional structures; exact arithmetic with `Fraction`. |
|
|
@@ -227,6 +227,10 @@ print(ratio.to(units.ppm)) # <500000.0 ppm>
|
|
|
227
227
|
|
|
228
228
|
# Cross-family conversions are prevented
|
|
229
229
|
units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
230
|
+
|
|
231
|
+
# SRE "nines" for availability (99.999% = 5 nines)
|
|
232
|
+
uptime = units.percent(99.999)
|
|
233
|
+
print(uptime.to(units.nines)) # <5.0 nines>
|
|
230
234
|
```
|
|
231
235
|
|
|
232
236
|
### Uncertainty Propagation
|
|
@@ -278,6 +282,47 @@ m4 = Measurement(value={"quantity": 9.8, "unit": "m/s²"}) # Unicode
|
|
|
278
282
|
- **Serialization format**: Units serialize as human-readable shorthand strings (`"km"`, `"m/s^2"`) rather than structured dicts, aligning with how scientists express units.
|
|
279
283
|
- **Parsing priority**: When parsing `"kg"`, ucon returns `Scale.kilo * gram` rather than looking up a `kilogram` Unit, ensuring consistent round-trip serialization and avoiding redundant unit definitions.
|
|
280
284
|
|
|
285
|
+
### MCP Server
|
|
286
|
+
|
|
287
|
+
ucon ships with an MCP server for AI agent integration (Claude Desktop, Claude Code, Cursor, etc.):
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
pip install ucon[mcp]
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Configure in Claude Desktop (`claude_desktop_config.json`):
|
|
294
|
+
|
|
295
|
+
**Via uvx (recommended, zero-install):**
|
|
296
|
+
```json
|
|
297
|
+
{
|
|
298
|
+
"mcpServers": {
|
|
299
|
+
"ucon": {
|
|
300
|
+
"command": "uvx",
|
|
301
|
+
"args": ["--from", "ucon[mcp]", "ucon-mcp"]
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Local development:**
|
|
308
|
+
```json
|
|
309
|
+
{
|
|
310
|
+
"mcpServers": {
|
|
311
|
+
"ucon": {
|
|
312
|
+
"command": "uv",
|
|
313
|
+
"args": ["run", "--directory", "/path/to/ucon", "--extra", "mcp", "ucon-mcp"]
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Available tools:
|
|
320
|
+
- `convert(value, from_unit, to_unit)` — Unit conversion with dimensional validation
|
|
321
|
+
- `list_units(dimension?)` — Discover available units
|
|
322
|
+
- `list_scales()` — List SI and binary prefixes
|
|
323
|
+
- `check_dimensions(unit_a, unit_b)` — Check dimensional compatibility
|
|
324
|
+
- `list_dimensions()` — List physical dimensions
|
|
325
|
+
|
|
281
326
|
### Custom Unit Systems
|
|
282
327
|
|
|
283
328
|
`BasisTransform` enables conversions between incompatible dimensional structures (e.g., fantasy game physics, CGS units, domain-specific systems).
|
|
@@ -295,7 +340,7 @@ See full example: [docs/examples/basis-transform-fantasy-units.md](./docs/exampl
|
|
|
295
340
|
| **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
|
|
296
341
|
| **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
|
|
297
342
|
| **0.5.x** | Unit Systems | `BasisTransform`, `UnitSystem`, cross-basis conversion | ✅ Complete |
|
|
298
|
-
| **0.6.x** | Pydantic
|
|
343
|
+
| **0.6.x** | Pydantic + MCP | API validation, AI agent integration | ✅ Complete |
|
|
299
344
|
| **0.7.x** | NumPy Arrays | Vectorized conversion and arithmetic | ⏳ Planned |
|
|
300
345
|
|
|
301
346
|
See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
@@ -54,7 +54,7 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
|
|
|
54
54
|
| **`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. |
|
|
55
55
|
| **`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. |
|
|
56
56
|
| **`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). |
|
|
57
|
-
| **`Map`** hierarchy | `ucon.maps` | Composable conversion morphisms: `LinearMap`, `AffineMap`, `ComposedMap`.
|
|
57
|
+
| **`Map`** hierarchy | `ucon.maps` | Composable conversion morphisms: `LinearMap`, `AffineMap`, `LogMap`, `ExpMap`, `ComposedMap`. | Defining conversion functions between units (e.g., meter→foot, celsius→kelvin, availability→nines). |
|
|
58
58
|
| **`ConversionGraph`** | `ucon.graph` | Registry of unit conversion edges with BFS path composition. | Converting between units via `Number.to(target)`; managing default and custom graphs. |
|
|
59
59
|
| **`UnitSystem`** | `ucon.core` | Named mapping from dimensions to base units (e.g., SI, Imperial). | Defining coherent unit systems; grouping base units by dimension. |
|
|
60
60
|
| **`BasisTransform`** | `ucon.core` | Matrix-based transformation between dimensional exponent spaces. | Converting between incompatible dimensional structures; exact arithmetic with `Fraction`. |
|
|
@@ -187,6 +187,10 @@ print(ratio.to(units.ppm)) # <500000.0 ppm>
|
|
|
187
187
|
|
|
188
188
|
# Cross-family conversions are prevented
|
|
189
189
|
units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
190
|
+
|
|
191
|
+
# SRE "nines" for availability (99.999% = 5 nines)
|
|
192
|
+
uptime = units.percent(99.999)
|
|
193
|
+
print(uptime.to(units.nines)) # <5.0 nines>
|
|
190
194
|
```
|
|
191
195
|
|
|
192
196
|
### Uncertainty Propagation
|
|
@@ -238,6 +242,47 @@ m4 = Measurement(value={"quantity": 9.8, "unit": "m/s²"}) # Unicode
|
|
|
238
242
|
- **Serialization format**: Units serialize as human-readable shorthand strings (`"km"`, `"m/s^2"`) rather than structured dicts, aligning with how scientists express units.
|
|
239
243
|
- **Parsing priority**: When parsing `"kg"`, ucon returns `Scale.kilo * gram` rather than looking up a `kilogram` Unit, ensuring consistent round-trip serialization and avoiding redundant unit definitions.
|
|
240
244
|
|
|
245
|
+
### MCP Server
|
|
246
|
+
|
|
247
|
+
ucon ships with an MCP server for AI agent integration (Claude Desktop, Claude Code, Cursor, etc.):
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
pip install ucon[mcp]
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Configure in Claude Desktop (`claude_desktop_config.json`):
|
|
254
|
+
|
|
255
|
+
**Via uvx (recommended, zero-install):**
|
|
256
|
+
```json
|
|
257
|
+
{
|
|
258
|
+
"mcpServers": {
|
|
259
|
+
"ucon": {
|
|
260
|
+
"command": "uvx",
|
|
261
|
+
"args": ["--from", "ucon[mcp]", "ucon-mcp"]
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**Local development:**
|
|
268
|
+
```json
|
|
269
|
+
{
|
|
270
|
+
"mcpServers": {
|
|
271
|
+
"ucon": {
|
|
272
|
+
"command": "uv",
|
|
273
|
+
"args": ["run", "--directory", "/path/to/ucon", "--extra", "mcp", "ucon-mcp"]
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Available tools:
|
|
280
|
+
- `convert(value, from_unit, to_unit)` — Unit conversion with dimensional validation
|
|
281
|
+
- `list_units(dimension?)` — Discover available units
|
|
282
|
+
- `list_scales()` — List SI and binary prefixes
|
|
283
|
+
- `check_dimensions(unit_a, unit_b)` — Check dimensional compatibility
|
|
284
|
+
- `list_dimensions()` — List physical dimensions
|
|
285
|
+
|
|
241
286
|
### Custom Unit Systems
|
|
242
287
|
|
|
243
288
|
`BasisTransform` enables conversions between incompatible dimensional structures (e.g., fantasy game physics, CGS units, domain-specific systems).
|
|
@@ -255,7 +300,7 @@ See full example: [docs/examples/basis-transform-fantasy-units.md](./docs/exampl
|
|
|
255
300
|
| **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
|
|
256
301
|
| **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
|
|
257
302
|
| **0.5.x** | Unit Systems | `BasisTransform`, `UnitSystem`, cross-basis conversion | ✅ Complete |
|
|
258
|
-
| **0.6.x** | Pydantic
|
|
303
|
+
| **0.6.x** | Pydantic + MCP | API validation, AI agent integration | ✅ Complete |
|
|
259
304
|
| **0.7.x** | NumPy Arrays | Vectorized conversion and arithmetic | ⏳ Planned |
|
|
260
305
|
|
|
261
306
|
See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
@@ -26,6 +26,8 @@ ucon is a dimensional analysis library for engineers building systems where unit
|
|
|
26
26
|
| v0.5.x | Uncertainty Propagation | Complete |
|
|
27
27
|
| v0.5.x | BasisTransform + UnitSystem | Complete |
|
|
28
28
|
| v0.6.0 | Pydantic + Serialization | Complete |
|
|
29
|
+
| v0.6.x | MCP Server | Complete |
|
|
30
|
+
| v0.6.x | LogMap + Nines | Complete |
|
|
29
31
|
| v0.7.0 | NumPy Array Support | Planned |
|
|
30
32
|
| v0.8.0 | String Parsing | Planned |
|
|
31
33
|
| v0.9.0 | Constants + Logarithmic Units | Planned |
|
|
@@ -172,7 +174,6 @@ Building on v0.5.x baseline:
|
|
|
172
174
|
- [x] JSON serialization/deserialization
|
|
173
175
|
- [x] Pickle support
|
|
174
176
|
- [x] Unit string parsing: `get_unit_by_name()` with Unicode and ASCII notation
|
|
175
|
-
- [ ] Optional: MCP server for unit conversion tool (deferred)
|
|
176
177
|
|
|
177
178
|
**Outcomes:**
|
|
178
179
|
- Native validation and serialization for dimensioned quantities
|
|
@@ -182,6 +183,43 @@ Building on v0.5.x baseline:
|
|
|
182
183
|
|
|
183
184
|
---
|
|
184
185
|
|
|
186
|
+
## v0.6.x — MCP Server (Complete)
|
|
187
|
+
|
|
188
|
+
**Theme:** AI agent integration.
|
|
189
|
+
|
|
190
|
+
- [x] MCP server exposing unit conversion tools
|
|
191
|
+
- [x] `convert` tool with dimensional validation
|
|
192
|
+
- [x] `list_units`, `list_scales`, `list_dimensions` discovery tools
|
|
193
|
+
- [x] `check_dimensions` compatibility tool
|
|
194
|
+
- [x] stdio transport for Claude Desktop, Claude Code, Cursor
|
|
195
|
+
- [x] `ucon-mcp` CLI entry point
|
|
196
|
+
|
|
197
|
+
**Outcomes:**
|
|
198
|
+
- Zero-code adoption for AI tool users
|
|
199
|
+
- Agents can perform unit-safe arithmetic without codebase integration
|
|
200
|
+
- Dimensional errors become visible and correctable in conversation
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## v0.6.x — LogMap + Nines (Complete)
|
|
205
|
+
|
|
206
|
+
**Theme:** Logarithmic conversions.
|
|
207
|
+
|
|
208
|
+
- [x] `LogMap` class: `y = scale · log_base(x) + offset`
|
|
209
|
+
- [x] `ExpMap` class: `y = base^(scale · x + offset)`
|
|
210
|
+
- [x] Composition with existing maps via `@` operator
|
|
211
|
+
- [x] Derivative support for uncertainty propagation
|
|
212
|
+
- [x] `nines` unit for SRE availability (99.999% = 5 nines)
|
|
213
|
+
- [x] `fraction` unit (renamed from `ratio_one`)
|
|
214
|
+
|
|
215
|
+
**Outcomes:**
|
|
216
|
+
- Foundation for logarithmic unit conversions
|
|
217
|
+
- SRE teams can express availability in nines notation
|
|
218
|
+
- Uncertainty propagates correctly through nonlinear conversions
|
|
219
|
+
- Paves the way for decibels, pH in v0.9.0
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
185
223
|
## v0.7.0 — NumPy Array Support
|
|
186
224
|
|
|
187
225
|
**Theme:** Scientific computing integration.
|
|
@@ -219,15 +257,15 @@ Building on v0.5.x baseline:
|
|
|
219
257
|
**Theme:** Physical completeness.
|
|
220
258
|
|
|
221
259
|
- [ ] Physical constants with uncertainties: `c`, `h`, `G`, `k_B`, `N_A`, etc.
|
|
222
|
-
- [
|
|
223
|
-
- [ ] Logarithmic units: `decibel`, `bel`, `neper`
|
|
260
|
+
- [x] `LogMap`/`ExpMap` for logarithmic conversions (completed in v0.6.x)
|
|
261
|
+
- [ ] Logarithmic units with reference levels: `decibel`, `bel`, `neper`
|
|
224
262
|
- [ ] pH scale support
|
|
225
263
|
- [ ] Currency dimension (with caveats about exchange rates)
|
|
226
264
|
|
|
227
265
|
**Outcomes:**
|
|
228
|
-
- Support for function-based (nonlinear) physical conversions
|
|
229
|
-
- Enables acoustics (dB), chemistry (pH), and signal processing domains
|
|
230
266
|
- Physical constants with CODATA uncertainties
|
|
267
|
+
- Enables acoustics (dB), chemistry (pH), and signal processing domains
|
|
268
|
+
- Reference-level infrastructure for dBm, dBV, dBSPL variants
|
|
231
269
|
|
|
232
270
|
---
|
|
233
271
|
|
|
@@ -57,6 +57,8 @@ packages = ["ucon", "ucon.mcp"]
|
|
|
57
57
|
|
|
58
58
|
[tool.setuptools_scm]
|
|
59
59
|
# Version derived from git tags
|
|
60
|
+
# no-local-version prevents +gXXXXXX suffixes that PyPI rejects
|
|
61
|
+
local_scheme = "no-local-version"
|
|
60
62
|
|
|
61
63
|
# -----------------------------------------------------------------------------
|
|
62
64
|
# uv Configuration
|
|
@@ -185,17 +185,17 @@ class TestRatioConversions(unittest.TestCase):
|
|
|
185
185
|
"""Test ratio unit conversions."""
|
|
186
186
|
|
|
187
187
|
def test_one_to_percent(self):
|
|
188
|
-
r = units.
|
|
188
|
+
r = units.fraction(0.5)
|
|
189
189
|
result = r.to(units.percent)
|
|
190
190
|
self.assertAlmostEqual(result.value, 50, places=9)
|
|
191
191
|
|
|
192
192
|
def test_percent_to_one(self):
|
|
193
193
|
r = units.percent(25)
|
|
194
|
-
result = r.to(units.
|
|
194
|
+
result = r.to(units.fraction)
|
|
195
195
|
self.assertAlmostEqual(result.value, 0.25, places=9)
|
|
196
196
|
|
|
197
197
|
def test_one_to_ppm(self):
|
|
198
|
-
r = units.
|
|
198
|
+
r = units.fraction(0.001)
|
|
199
199
|
result = r.to(units.ppm)
|
|
200
200
|
self.assertAlmostEqual(result.value, 1000, places=9)
|
|
201
201
|
|
|
@@ -205,7 +205,7 @@ class TestRatioConversions(unittest.TestCase):
|
|
|
205
205
|
self.assertAlmostEqual(result.value, 1000, places=9)
|
|
206
206
|
|
|
207
207
|
def test_one_to_permille(self):
|
|
208
|
-
r = units.
|
|
208
|
+
r = units.fraction(0.005)
|
|
209
209
|
result = r.to(units.permille)
|
|
210
210
|
self.assertAlmostEqual(result.value, 5, places=9)
|
|
211
211
|
|
|
@@ -0,0 +1,289 @@
|
|
|
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 LogMap and ExpMap classes.
|
|
7
|
+
|
|
8
|
+
Tests logarithmic and exponential conversion morphisms including
|
|
9
|
+
forward transforms, derivatives, inverses, and composition.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
from ucon.maps import LogMap, ExpMap, AffineMap, ComposedMap
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestLogMapBasic(unittest.TestCase):
|
|
19
|
+
"""Test basic LogMap functionality."""
|
|
20
|
+
|
|
21
|
+
def test_default_log10(self):
|
|
22
|
+
m = LogMap()
|
|
23
|
+
self.assertAlmostEqual(m(10), 1.0)
|
|
24
|
+
self.assertAlmostEqual(m(100), 2.0)
|
|
25
|
+
self.assertAlmostEqual(m(1000), 3.0)
|
|
26
|
+
|
|
27
|
+
def test_scaled_log_decibel_power(self):
|
|
28
|
+
# 10 * log₁₀(x) — decibels for power ratios
|
|
29
|
+
m = LogMap(scale=10)
|
|
30
|
+
self.assertAlmostEqual(m(10), 10.0)
|
|
31
|
+
self.assertAlmostEqual(m(100), 20.0)
|
|
32
|
+
self.assertAlmostEqual(m(1000), 30.0)
|
|
33
|
+
|
|
34
|
+
def test_scaled_log_decibel_amplitude(self):
|
|
35
|
+
# 20 * log₁₀(x) — decibels for amplitude ratios
|
|
36
|
+
m = LogMap(scale=20)
|
|
37
|
+
self.assertAlmostEqual(m(10), 20.0)
|
|
38
|
+
self.assertAlmostEqual(m(100), 40.0)
|
|
39
|
+
|
|
40
|
+
def test_negative_scale_ph_style(self):
|
|
41
|
+
# -log₁₀(x) — pH-style
|
|
42
|
+
m = LogMap(scale=-1)
|
|
43
|
+
self.assertAlmostEqual(m(0.1), 1.0)
|
|
44
|
+
self.assertAlmostEqual(m(0.01), 2.0)
|
|
45
|
+
self.assertAlmostEqual(m(0.001), 3.0)
|
|
46
|
+
self.assertAlmostEqual(m(1e-7), 7.0)
|
|
47
|
+
|
|
48
|
+
def test_natural_log(self):
|
|
49
|
+
m = LogMap(base=math.e)
|
|
50
|
+
self.assertAlmostEqual(m(math.e), 1.0)
|
|
51
|
+
self.assertAlmostEqual(m(math.e ** 2), 2.0)
|
|
52
|
+
self.assertAlmostEqual(m(1), 0.0)
|
|
53
|
+
|
|
54
|
+
def test_with_offset(self):
|
|
55
|
+
m = LogMap(scale=1, base=10, offset=5)
|
|
56
|
+
# log₁₀(100) + 5 = 2 + 5 = 7
|
|
57
|
+
self.assertAlmostEqual(m(100), 7.0)
|
|
58
|
+
|
|
59
|
+
def test_log_of_one_is_zero(self):
|
|
60
|
+
m = LogMap()
|
|
61
|
+
self.assertAlmostEqual(m(1), 0.0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestLogMapErrors(unittest.TestCase):
|
|
65
|
+
"""Test LogMap error handling."""
|
|
66
|
+
|
|
67
|
+
def test_zero_raises_error(self):
|
|
68
|
+
m = LogMap()
|
|
69
|
+
with self.assertRaises(ValueError) as ctx:
|
|
70
|
+
m(0)
|
|
71
|
+
self.assertIn("positive", str(ctx.exception))
|
|
72
|
+
|
|
73
|
+
def test_negative_raises_error(self):
|
|
74
|
+
m = LogMap()
|
|
75
|
+
with self.assertRaises(ValueError) as ctx:
|
|
76
|
+
m(-1)
|
|
77
|
+
self.assertIn("positive", str(ctx.exception))
|
|
78
|
+
|
|
79
|
+
def test_derivative_at_zero_raises(self):
|
|
80
|
+
m = LogMap()
|
|
81
|
+
with self.assertRaises(ValueError):
|
|
82
|
+
m.derivative(0)
|
|
83
|
+
|
|
84
|
+
def test_derivative_at_negative_raises(self):
|
|
85
|
+
m = LogMap()
|
|
86
|
+
with self.assertRaises(ValueError):
|
|
87
|
+
m.derivative(-1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestLogMapDerivative(unittest.TestCase):
|
|
91
|
+
"""Test LogMap derivative for uncertainty propagation."""
|
|
92
|
+
|
|
93
|
+
def test_derivative_formula(self):
|
|
94
|
+
# d/dx[log₁₀(x)] = 1 / (x * ln(10))
|
|
95
|
+
m = LogMap()
|
|
96
|
+
x = 100
|
|
97
|
+
expected = 1 / (x * math.log(10))
|
|
98
|
+
self.assertAlmostEqual(m.derivative(x), expected)
|
|
99
|
+
|
|
100
|
+
def test_derivative_with_scale(self):
|
|
101
|
+
# d/dx[scale * log₁₀(x)] = scale / (x * ln(10))
|
|
102
|
+
m = LogMap(scale=10)
|
|
103
|
+
x = 100
|
|
104
|
+
expected = 10 / (x * math.log(10))
|
|
105
|
+
self.assertAlmostEqual(m.derivative(x), expected)
|
|
106
|
+
|
|
107
|
+
def test_derivative_natural_log(self):
|
|
108
|
+
# d/dx[ln(x)] = 1/x
|
|
109
|
+
m = LogMap(base=math.e)
|
|
110
|
+
x = 5
|
|
111
|
+
expected = 1 / x
|
|
112
|
+
self.assertAlmostEqual(m.derivative(x), expected)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestLogMapInverse(unittest.TestCase):
|
|
116
|
+
"""Test LogMap inverse returns ExpMap."""
|
|
117
|
+
|
|
118
|
+
def test_inverse_type(self):
|
|
119
|
+
m = LogMap()
|
|
120
|
+
inv = m.inverse()
|
|
121
|
+
self.assertIsInstance(inv, ExpMap)
|
|
122
|
+
|
|
123
|
+
def test_roundtrip(self):
|
|
124
|
+
m = LogMap(scale=2, base=10, offset=3)
|
|
125
|
+
x = 100
|
|
126
|
+
self.assertAlmostEqual(m.inverse()(m(x)), x)
|
|
127
|
+
|
|
128
|
+
def test_roundtrip_various_values(self):
|
|
129
|
+
m = LogMap(scale=-1)
|
|
130
|
+
for x in [0.001, 0.1, 1, 10, 100]:
|
|
131
|
+
self.assertAlmostEqual(m.inverse()(m(x)), x, places=9)
|
|
132
|
+
|
|
133
|
+
def test_non_invertible_raises(self):
|
|
134
|
+
m = LogMap(scale=0)
|
|
135
|
+
with self.assertRaises(ZeroDivisionError):
|
|
136
|
+
m.inverse()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestLogMapProperties(unittest.TestCase):
|
|
140
|
+
"""Test LogMap properties."""
|
|
141
|
+
|
|
142
|
+
def test_invertible_true(self):
|
|
143
|
+
m = LogMap(scale=1)
|
|
144
|
+
self.assertTrue(m.invertible)
|
|
145
|
+
|
|
146
|
+
def test_invertible_false(self):
|
|
147
|
+
m = LogMap(scale=0)
|
|
148
|
+
self.assertFalse(m.invertible)
|
|
149
|
+
|
|
150
|
+
def test_is_identity_false(self):
|
|
151
|
+
m = LogMap()
|
|
152
|
+
self.assertFalse(m.is_identity())
|
|
153
|
+
|
|
154
|
+
def test_pow_one(self):
|
|
155
|
+
m = LogMap(scale=2)
|
|
156
|
+
self.assertEqual(m ** 1, m)
|
|
157
|
+
|
|
158
|
+
def test_pow_minus_one(self):
|
|
159
|
+
m = LogMap(scale=2)
|
|
160
|
+
inv = m ** -1
|
|
161
|
+
self.assertIsInstance(inv, ExpMap)
|
|
162
|
+
|
|
163
|
+
def test_pow_other_raises(self):
|
|
164
|
+
m = LogMap()
|
|
165
|
+
with self.assertRaises(ValueError):
|
|
166
|
+
_ = m ** 2
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestExpMapBasic(unittest.TestCase):
|
|
170
|
+
"""Test basic ExpMap functionality."""
|
|
171
|
+
|
|
172
|
+
def test_default_exp10(self):
|
|
173
|
+
m = ExpMap()
|
|
174
|
+
self.assertAlmostEqual(m(0), 1.0)
|
|
175
|
+
self.assertAlmostEqual(m(1), 10.0)
|
|
176
|
+
self.assertAlmostEqual(m(2), 100.0)
|
|
177
|
+
self.assertAlmostEqual(m(3), 1000.0)
|
|
178
|
+
|
|
179
|
+
def test_with_scale(self):
|
|
180
|
+
m = ExpMap(scale=0.5)
|
|
181
|
+
# 10^(0.5 * 2) = 10^1 = 10
|
|
182
|
+
self.assertAlmostEqual(m(2), 10.0)
|
|
183
|
+
|
|
184
|
+
def test_with_offset(self):
|
|
185
|
+
m = ExpMap(scale=1, offset=1)
|
|
186
|
+
# 10^(1*0 + 1) = 10^1 = 10
|
|
187
|
+
self.assertAlmostEqual(m(0), 10.0)
|
|
188
|
+
|
|
189
|
+
def test_natural_exp(self):
|
|
190
|
+
m = ExpMap(base=math.e)
|
|
191
|
+
self.assertAlmostEqual(m(0), 1.0)
|
|
192
|
+
self.assertAlmostEqual(m(1), math.e)
|
|
193
|
+
self.assertAlmostEqual(m(2), math.e ** 2)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestExpMapDerivative(unittest.TestCase):
|
|
197
|
+
"""Test ExpMap derivative."""
|
|
198
|
+
|
|
199
|
+
def test_derivative_formula(self):
|
|
200
|
+
# d/dx[10^x] = ln(10) * 10^x
|
|
201
|
+
m = ExpMap()
|
|
202
|
+
x = 2
|
|
203
|
+
expected = math.log(10) * (10 ** x)
|
|
204
|
+
self.assertAlmostEqual(m.derivative(x), expected)
|
|
205
|
+
|
|
206
|
+
def test_derivative_with_scale(self):
|
|
207
|
+
# d/dx[10^(scale*x)] = ln(10) * scale * 10^(scale*x)
|
|
208
|
+
m = ExpMap(scale=2)
|
|
209
|
+
x = 1
|
|
210
|
+
expected = math.log(10) * 2 * (10 ** 2)
|
|
211
|
+
self.assertAlmostEqual(m.derivative(x), expected)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TestExpMapInverse(unittest.TestCase):
|
|
215
|
+
"""Test ExpMap inverse returns LogMap."""
|
|
216
|
+
|
|
217
|
+
def test_inverse_type(self):
|
|
218
|
+
m = ExpMap()
|
|
219
|
+
inv = m.inverse()
|
|
220
|
+
self.assertIsInstance(inv, LogMap)
|
|
221
|
+
|
|
222
|
+
def test_roundtrip(self):
|
|
223
|
+
m = ExpMap(scale=2, base=10, offset=1)
|
|
224
|
+
x = 3
|
|
225
|
+
self.assertAlmostEqual(m.inverse()(m(x)), x)
|
|
226
|
+
|
|
227
|
+
def test_non_invertible_raises(self):
|
|
228
|
+
m = ExpMap(scale=0)
|
|
229
|
+
with self.assertRaises(ZeroDivisionError):
|
|
230
|
+
m.inverse()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TestExpMapProperties(unittest.TestCase):
|
|
234
|
+
"""Test ExpMap properties."""
|
|
235
|
+
|
|
236
|
+
def test_invertible_true(self):
|
|
237
|
+
m = ExpMap(scale=1)
|
|
238
|
+
self.assertTrue(m.invertible)
|
|
239
|
+
|
|
240
|
+
def test_invertible_false(self):
|
|
241
|
+
m = ExpMap(scale=0)
|
|
242
|
+
self.assertFalse(m.invertible)
|
|
243
|
+
|
|
244
|
+
def test_is_identity_false(self):
|
|
245
|
+
m = ExpMap()
|
|
246
|
+
self.assertFalse(m.is_identity())
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestLogMapComposition(unittest.TestCase):
|
|
250
|
+
"""Test LogMap composition with other maps."""
|
|
251
|
+
|
|
252
|
+
def test_compose_with_affine(self):
|
|
253
|
+
# nines = -log₁₀(1 - x)
|
|
254
|
+
# = LogMap(scale=-1) @ AffineMap(a=-1, b=1)
|
|
255
|
+
nines = LogMap(scale=-1) @ AffineMap(a=-1, b=1)
|
|
256
|
+
self.assertIsInstance(nines, ComposedMap)
|
|
257
|
+
|
|
258
|
+
def test_nines_composition_values(self):
|
|
259
|
+
nines = LogMap(scale=-1) @ AffineMap(a=-1, b=1)
|
|
260
|
+
self.assertAlmostEqual(nines(0.9), 1.0)
|
|
261
|
+
self.assertAlmostEqual(nines(0.99), 2.0)
|
|
262
|
+
self.assertAlmostEqual(nines(0.999), 3.0)
|
|
263
|
+
self.assertAlmostEqual(nines(0.9999), 4.0)
|
|
264
|
+
self.assertAlmostEqual(nines(0.99999), 5.0)
|
|
265
|
+
|
|
266
|
+
def test_composed_derivative_chain_rule(self):
|
|
267
|
+
# nines'(x) = d/dx[-log₁₀(1-x)]
|
|
268
|
+
# = 1 / ((1-x) * ln(10))
|
|
269
|
+
nines = LogMap(scale=-1) @ AffineMap(a=-1, b=1)
|
|
270
|
+
x = 0.99999
|
|
271
|
+
expected = 1 / ((1 - x) * math.log(10))
|
|
272
|
+
self.assertAlmostEqual(nines.derivative(x), expected, places=5)
|
|
273
|
+
|
|
274
|
+
def test_composed_inverse(self):
|
|
275
|
+
nines = LogMap(scale=-1) @ AffineMap(a=-1, b=1)
|
|
276
|
+
inv = nines.inverse()
|
|
277
|
+
x = 0.99999
|
|
278
|
+
self.assertAlmostEqual(inv(nines(x)), x, places=9)
|
|
279
|
+
|
|
280
|
+
def test_nines_inverse_values(self):
|
|
281
|
+
nines = LogMap(scale=-1) @ AffineMap(a=-1, b=1)
|
|
282
|
+
inv = nines.inverse()
|
|
283
|
+
self.assertAlmostEqual(inv(2), 0.99)
|
|
284
|
+
self.assertAlmostEqual(inv(3), 0.999)
|
|
285
|
+
self.assertAlmostEqual(inv(5), 0.99999)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == '__main__':
|
|
289
|
+
unittest.main()
|