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.
Files changed (69) hide show
  1. {ucon-0.6.0 → ucon-0.6.2}/.github/workflows/publish.yaml +5 -5
  2. {ucon-0.6.0 → ucon-0.6.2}/PKG-INFO +48 -3
  3. {ucon-0.6.0 → ucon-0.6.2}/README.md +47 -2
  4. {ucon-0.6.0 → ucon-0.6.2}/ROADMAP.md +43 -5
  5. {ucon-0.6.0 → ucon-0.6.2}/pyproject.toml +2 -0
  6. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_dimensionless_units.py +4 -4
  7. ucon-0.6.2/tests/ucon/test_logmap.py +289 -0
  8. ucon-0.6.2/tests/ucon/test_nines.py +290 -0
  9. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_unit_parsing.py +43 -0
  10. {ucon-0.6.0 → ucon-0.6.2}/ucon/graph.py +8 -6
  11. {ucon-0.6.0 → ucon-0.6.2}/ucon/maps.py +116 -0
  12. {ucon-0.6.0 → ucon-0.6.2}/ucon/units.py +20 -3
  13. {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/PKG-INFO +48 -3
  14. {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/SOURCES.txt +2 -0
  15. {ucon-0.6.0 → ucon-0.6.2}/.github/workflows/tests.yaml +0 -0
  16. {ucon-0.6.0 → ucon-0.6.2}/.gitignore +0 -0
  17. {ucon-0.6.0 → ucon-0.6.2}/LICENSE +0 -0
  18. {ucon-0.6.0 → ucon-0.6.2}/Makefile +0 -0
  19. {ucon-0.6.0 → ucon-0.6.2}/NOTICE +0 -0
  20. {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/001-unity-distance-metric-for-nearest-scale.md +0 -0
  21. {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/002-composite-units.md +0 -0
  22. {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/003-composable-unit-algebra.md +0 -0
  23. {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/004-unit-algebra-naming.md +0 -0
  24. {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/005-pseudo-dimension-tuple-values.md +0 -0
  25. {ucon-0.6.0 → ucon-0.6.2}/docs/decisions/006-pydantic-integration-pattern.md +0 -0
  26. {ucon-0.6.0 → ucon-0.6.2}/docs/examples/basis-transform-fantasy-units.md +0 -0
  27. {ucon-0.6.0 → ucon-0.6.2}/docs/explainers/exponent-scale-relationship.md +0 -0
  28. {ucon-0.6.0 → ucon-0.6.2}/docs/explainers/type-operation-matrix.md +0 -0
  29. {ucon-0.6.0 → ucon-0.6.2}/docs/explainers/why-algebraic-closure-matters.md +0 -0
  30. {ucon-0.6.0 → ucon-0.6.2}/docs/explainers/why-type-safety-matters.md +0 -0
  31. {ucon-0.6.0 → ucon-0.6.2}/docs/proposals/interface-unifying-the-value-layer.md +0 -0
  32. {ucon-0.6.0 → ucon-0.6.2}/docs/proposals/project_unified-algebraic-core.md +0 -0
  33. {ucon-0.6.0 → ucon-0.6.2}/docs/proposals/support-for-fractional-exponents.md +0 -0
  34. {ucon-0.6.0 → ucon-0.6.2}/docs/proposals/unified-unit-presentation.md +0 -0
  35. {ucon-0.6.0 → ucon-0.6.2}/requirements.txt +0 -0
  36. {ucon-0.6.0 → ucon-0.6.2}/setup.cfg +0 -0
  37. {ucon-0.6.0 → ucon-0.6.2}/setup.py +0 -0
  38. {ucon-0.6.0 → ucon-0.6.2}/tests/__init__.py +0 -0
  39. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/__init__.py +0 -0
  40. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/conversion/__init__.py +0 -0
  41. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/conversion/test_graph.py +0 -0
  42. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/conversion/test_map.py +0 -0
  43. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/mcp/__init__.py +0 -0
  44. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/mcp/test_server.py +0 -0
  45. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_algebra.py +0 -0
  46. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_basis_transform.py +0 -0
  47. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_core.py +0 -0
  48. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_default_graph_conversions.py +0 -0
  49. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_graph_basis_transform.py +0 -0
  50. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_pickle.py +0 -0
  51. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_pydantic.py +0 -0
  52. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_quantity.py +0 -0
  53. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_rebased_unit.py +0 -0
  54. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_uncertainty.py +0 -0
  55. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_unit_system.py +0 -0
  56. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_units.py +0 -0
  57. {ucon-0.6.0 → ucon-0.6.2}/tests/ucon/test_vector_fraction.py +0 -0
  58. {ucon-0.6.0 → ucon-0.6.2}/ucon/__init__.py +0 -0
  59. {ucon-0.6.0 → ucon-0.6.2}/ucon/algebra.py +0 -0
  60. {ucon-0.6.0 → ucon-0.6.2}/ucon/core.py +0 -0
  61. {ucon-0.6.0 → ucon-0.6.2}/ucon/mcp/__init__.py +0 -0
  62. {ucon-0.6.0 → ucon-0.6.2}/ucon/mcp/server.py +0 -0
  63. {ucon-0.6.0 → ucon-0.6.2}/ucon/pydantic.py +0 -0
  64. {ucon-0.6.0 → ucon-0.6.2}/ucon/quantity.py +0 -0
  65. {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/dependency_links.txt +0 -0
  66. {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/entry_points.txt +0 -0
  67. {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/requires.txt +0 -0
  68. {ucon-0.6.0 → ucon-0.6.2}/ucon.egg-info/top_level.txt +0 -0
  69. {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
- skip_existing: true
37
- repository_url: https://test.pypi.org/legacy/
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
- skip_existing: true
57
- repository_url: https://test.pypi.org/legacy/
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
- skip_existing: true
65
+ skip-existing: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucon
3
- Version: 0.6.0
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`. | Defining conversion functions between units (e.g., meter→foot, celsius→kelvin). |
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 Integration | Type-safe quantity validation, JSON serialization | ✅ Complete |
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`. | Defining conversion functions between units (e.g., meter→foot, celsius→kelvin). |
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 Integration | Type-safe quantity validation, JSON serialization | ✅ Complete |
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
- - [ ] `LogMap` for logarithmic conversions
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.ratio_one(0.5)
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.ratio_one)
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.ratio_one(0.001)
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.ratio_one(0.005)
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()