ucon 0.6.0__py3-none-any.whl → 0.6.2__py3-none-any.whl

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/graph.py CHANGED
@@ -37,7 +37,7 @@ from ucon.core import (
37
37
  UnitProduct,
38
38
  Scale,
39
39
  )
40
- from ucon.maps import Map, LinearMap, AffineMap
40
+ from ucon.maps import Map, LinearMap, AffineMap, LogMap
41
41
 
42
42
 
43
43
  class DimensionMismatch(Exception):
@@ -589,10 +589,12 @@ def _build_standard_graph() -> ConversionGraph:
589
589
  graph.add_edge(src=units.steradian, dst=units.square_degree, map=LinearMap((180 / math.pi) ** 2))
590
590
 
591
591
  # --- Ratio ---
592
- graph.add_edge(src=units.ratio_one, dst=units.percent, map=LinearMap(100))
593
- graph.add_edge(src=units.ratio_one, dst=units.permille, map=LinearMap(1000))
594
- graph.add_edge(src=units.ratio_one, dst=units.ppm, map=LinearMap(1e6))
595
- graph.add_edge(src=units.ratio_one, dst=units.ppb, map=LinearMap(1e9))
596
- graph.add_edge(src=units.ratio_one, dst=units.basis_point, map=LinearMap(10000))
592
+ graph.add_edge(src=units.fraction, dst=units.percent, map=LinearMap(100))
593
+ graph.add_edge(src=units.fraction, dst=units.permille, map=LinearMap(1000))
594
+ graph.add_edge(src=units.fraction, dst=units.ppm, map=LinearMap(1e6))
595
+ graph.add_edge(src=units.fraction, dst=units.ppb, map=LinearMap(1e9))
596
+ graph.add_edge(src=units.fraction, dst=units.basis_point, map=LinearMap(10000))
597
+ # nines: -log₁₀(1 - availability) for SRE uptime (0.99999 → 5 nines)
598
+ graph.add_edge(src=units.fraction, dst=units.nines, map=LogMap(scale=-1) @ AffineMap(a=-1, b=1))
597
599
 
598
600
  return graph
ucon/maps.py CHANGED
@@ -14,10 +14,13 @@ Classes
14
14
  - :class:`Map` — Abstract base for conversion morphisms.
15
15
  - :class:`LinearMap` — y = a * x
16
16
  - :class:`AffineMap` — y = a * x + b
17
+ - :class:`LogMap` — y = scale * log_base(x) + offset
18
+ - :class:`ExpMap` — y = base^(scale * x + offset)
17
19
  - :class:`ComposedMap` — Generic composition fallback: g(f(x))
18
20
  """
19
21
  from __future__ import annotations
20
22
 
23
+ import math
21
24
  from abc import ABC, abstractmethod
22
25
  from dataclasses import dataclass
23
26
 
@@ -145,6 +148,119 @@ class AffineMap(Map):
145
148
  return self.a
146
149
 
147
150
 
151
+ @dataclass(frozen=True)
152
+ class LogMap(Map):
153
+ """
154
+ Logarithmic map: ``y = scale * log_base(x) + offset``
155
+
156
+ Examples::
157
+
158
+ LogMap() # log₁₀(x)
159
+ LogMap(scale=10) # 10·log₁₀(x) — decibels (power)
160
+ LogMap(scale=20) # 20·log₁₀(x) — decibels (amplitude)
161
+ LogMap(scale=-1) # -log₁₀(x) — pH-style
162
+ LogMap(base=math.e) # ln(x) — neper
163
+
164
+ For transforms like nines ``(-log₁₀(1-x))``, compose with AffineMap::
165
+
166
+ LogMap(scale=-1) @ AffineMap(a=-1, b=1)
167
+ """
168
+
169
+ scale: float = 1.0
170
+ base: float = 10.0
171
+ offset: float = 0.0
172
+
173
+ def __call__(self, x: float) -> float:
174
+ if x <= 0:
175
+ raise ValueError(f"Logarithm argument must be positive, got {x}")
176
+ return self.scale * math.log(x, self.base) + self.offset
177
+
178
+ @property
179
+ def invertible(self) -> bool:
180
+ return self.scale != 0
181
+
182
+ def inverse(self) -> 'ExpMap':
183
+ """Return the inverse exponential map."""
184
+ if not self.invertible:
185
+ raise ZeroDivisionError("LogMap with scale=0 is not invertible.")
186
+ return ExpMap(
187
+ scale=1.0 / self.scale,
188
+ base=self.base,
189
+ offset=-self.offset / self.scale,
190
+ )
191
+
192
+ def __matmul__(self, other: Map) -> Map:
193
+ if not isinstance(other, Map):
194
+ return NotImplemented
195
+ return ComposedMap(self, other)
196
+
197
+ def __pow__(self, exp: float) -> Map:
198
+ if exp == 1:
199
+ return self
200
+ if exp == -1:
201
+ return self.inverse()
202
+ raise ValueError("LogMap only supports exp=1 or exp=-1")
203
+
204
+ def derivative(self, x: float) -> float:
205
+ """Derivative: d/dx[scale * log_base(x) + offset] = scale / (x * ln(base))"""
206
+ if x <= 0:
207
+ raise ValueError(f"Derivative undefined for x={x}")
208
+ return self.scale / (x * math.log(self.base))
209
+
210
+ def is_identity(self, tol: float = 1e-9) -> bool:
211
+ return False # Logarithm is never identity
212
+
213
+
214
+ @dataclass(frozen=True)
215
+ class ExpMap(Map):
216
+ """
217
+ Exponential map: ``y = base^(scale * x + offset)``
218
+
219
+ This is the inverse of :class:`LogMap`. Typically obtained via
220
+ ``LogMap.inverse()`` rather than constructed directly.
221
+ """
222
+
223
+ scale: float = 1.0
224
+ base: float = 10.0
225
+ offset: float = 0.0
226
+
227
+ def __call__(self, x: float) -> float:
228
+ return self.base ** (self.scale * x + self.offset)
229
+
230
+ @property
231
+ def invertible(self) -> bool:
232
+ return self.scale != 0
233
+
234
+ def inverse(self) -> LogMap:
235
+ """Return the inverse logarithmic map."""
236
+ if not self.invertible:
237
+ raise ZeroDivisionError("ExpMap with scale=0 is not invertible.")
238
+ return LogMap(
239
+ scale=1.0 / self.scale,
240
+ base=self.base,
241
+ offset=-self.offset / self.scale,
242
+ )
243
+
244
+ def __matmul__(self, other: Map) -> Map:
245
+ if not isinstance(other, Map):
246
+ return NotImplemented
247
+ return ComposedMap(self, other)
248
+
249
+ def __pow__(self, exp: float) -> Map:
250
+ if exp == 1:
251
+ return self
252
+ if exp == -1:
253
+ return self.inverse()
254
+ raise ValueError("ExpMap only supports exp=1 or exp=-1")
255
+
256
+ def derivative(self, x: float) -> float:
257
+ """Derivative: d/dx[base^(scale*x + offset)] = ln(base) * scale * base^(scale*x + offset)"""
258
+ return math.log(self.base) * self.scale * self(x)
259
+
260
+ def is_identity(self, tol: float = 1e-9) -> bool:
261
+ return False # Exponential is never identity
262
+
263
+
148
264
  @dataclass(frozen=True)
149
265
  class ComposedMap(Map):
150
266
  """Generic composition fallback: ``(outer ∘ inner)(x) = outer(inner(x))``."""
ucon/units.py CHANGED
@@ -140,12 +140,13 @@ square_degree = Unit(name='square_degree', dimension=Dimension.solid_angle, alia
140
140
 
141
141
 
142
142
  # -- Ratio Units -------------------------------------------------------
143
- ratio_one = Unit(name='one', dimension=Dimension.ratio, aliases=('1',))
143
+ fraction = Unit(name='fraction', dimension=Dimension.ratio, aliases=('frac', '1'))
144
144
  percent = Unit(name='percent', dimension=Dimension.ratio, aliases=('%',))
145
145
  permille = Unit(name='permille', dimension=Dimension.ratio, aliases=('‰',))
146
146
  ppm = Unit(name='ppm', dimension=Dimension.ratio, aliases=())
147
147
  ppb = Unit(name='ppb', dimension=Dimension.ratio, aliases=())
148
148
  basis_point = Unit(name='basis_point', dimension=Dimension.ratio, aliases=('bp', 'bps'))
149
+ nines = Unit(name='nines', dimension=Dimension.ratio, aliases=('9s',))
149
150
  # ----------------------------------------------------------------------
150
151
 
151
152
 
@@ -203,6 +204,10 @@ def have(name: str) -> bool:
203
204
  _UNIT_REGISTRY: Dict[str, Unit] = {}
204
205
  _UNIT_REGISTRY_CASE_SENSITIVE: Dict[str, Unit] = {}
205
206
 
207
+ # Priority aliases that must match exactly before prefix decomposition.
208
+ # Prevents ambiguous parses like "min" -> milli-inch instead of minute.
209
+ _PRIORITY_ALIASES: set = {'min'}
210
+
206
211
  # Scale prefix mapping (shorthand -> Scale)
207
212
  # Sorted by length descending for greedy matching
208
213
  _SCALE_PREFIXES: Dict[str, Scale] = {
@@ -293,7 +298,10 @@ def _lookup_factor(s: str) -> Tuple[Unit, Scale]:
293
298
  """
294
299
  Look up a single unit factor, handling scale prefixes.
295
300
 
296
- Prioritizes prefix+unit interpretation over direct unit lookup.
301
+ Prioritizes prefix+unit interpretation over direct unit lookup,
302
+ except for priority aliases (like 'min') which are checked first
303
+ to avoid ambiguous parses.
304
+
297
305
  This means "kg" returns (gram, Scale.kilo) rather than (kilogram, Scale.one).
298
306
 
299
307
  Examples:
@@ -302,6 +310,7 @@ def _lookup_factor(s: str) -> Tuple[Unit, Scale]:
302
310
  - 'km' -> (meter, Scale.kilo)
303
311
  - 'kg' -> (gram, Scale.kilo)
304
312
  - 'mL' -> (liter, Scale.milli)
313
+ - 'min' -> (minute, Scale.one) # priority alias, not milli-inch
305
314
 
306
315
  Returns:
307
316
  Tuple of (unit, scale).
@@ -309,7 +318,15 @@ def _lookup_factor(s: str) -> Tuple[Unit, Scale]:
309
318
  Raises:
310
319
  UnknownUnitError: If the unit cannot be resolved.
311
320
  """
312
- # Try scale prefix + unit first (prioritize decomposition)
321
+ # Check priority aliases first (prevents "min" -> milli-inch)
322
+ if s in _PRIORITY_ALIASES:
323
+ if s in _UNIT_REGISTRY_CASE_SENSITIVE:
324
+ return _UNIT_REGISTRY_CASE_SENSITIVE[s], Scale.one
325
+ s_lower = s.lower()
326
+ if s_lower in _UNIT_REGISTRY:
327
+ return _UNIT_REGISTRY[s_lower], Scale.one
328
+
329
+ # Try scale prefix + unit (prioritize decomposition)
313
330
  # Only case-sensitive matching for remainder (e.g., "fT" = femto-tesla, "ft" = foot)
314
331
  for prefix in _SCALE_PREFIXES_SORTED:
315
332
  if s.startswith(prefix) and len(s) > len(prefix):
@@ -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)
@@ -0,0 +1,17 @@
1
+ ucon/__init__.py,sha256=SAXuDNMmxzaLVr4JpUHsz-feQotVhpzsCUj2WiXYA-0,2361
2
+ ucon/algebra.py,sha256=6QrPyD23L93XSrnIORcYEx2CLDv4WDcrh6H_hxeeOus,8668
3
+ ucon/core.py,sha256=V10GfKTf28GFgoO9NMI7ciqKHI_p8wG8qICR6u85gv0,63300
4
+ ucon/graph.py,sha256=vBIKwppYCAu5sw6R_3zTBlnOk2WmYMClIb6j5kDvJHI,21342
5
+ ucon/maps.py,sha256=-rPMOHylcQYUn62R9IU23bXdCRCRNBhdH3UD4G5IUEk,9123
6
+ ucon/pydantic.py,sha256=64ZR1EYFRnBGHj3VIF5pc3swdAiR2ZlYrgcntdbKN4k,5189
7
+ ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
8
+ ucon/units.py,sha256=3reuG-G-BAAOiH_es8CE5jxAFTg5bkrd2JQ5C1iBTFg,16676
9
+ ucon/mcp/__init__.py,sha256=WoFOQ7JeDIzbjjkFIJ0Uv53VVLu-4lrjzG5vpVGGfT4,123
10
+ ucon/mcp/server.py,sha256=uUrdevEaR65Qjh9xn8Q-_IusNjPGxdkLF9iQmiSTs0g,7016
11
+ ucon-0.6.2.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
12
+ ucon-0.6.2.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
13
+ ucon-0.6.2.dist-info/METADATA,sha256=5fBukT2t1v_OV17FdanlgFq6mFsKhXZuovhN6yJmg2s,17397
14
+ ucon-0.6.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
15
+ ucon-0.6.2.dist-info/entry_points.txt,sha256=jbfLf0FbOulgGa0nM_sRiTNfiCAkJcHnSSK_oj3g0cQ,50
16
+ ucon-0.6.2.dist-info/top_level.txt,sha256=Vv3KDuZ86fmH5yOYLbYap9DbBblK1YUkmlThffF71jA,5
17
+ ucon-0.6.2.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- ucon/__init__.py,sha256=SAXuDNMmxzaLVr4JpUHsz-feQotVhpzsCUj2WiXYA-0,2361
2
- ucon/algebra.py,sha256=6QrPyD23L93XSrnIORcYEx2CLDv4WDcrh6H_hxeeOus,8668
3
- ucon/core.py,sha256=V10GfKTf28GFgoO9NMI7ciqKHI_p8wG8qICR6u85gv0,63300
4
- ucon/graph.py,sha256=Ec0Q2QiAGUm2RaxrKnpFHtwpNvTf4PYbvo62BWtGJG8,21159
5
- ucon/maps.py,sha256=tWP4ayYCEazJzf81EP1_fmtADhg18D1eHldudAMEY0U,5460
6
- ucon/pydantic.py,sha256=64ZR1EYFRnBGHj3VIF5pc3swdAiR2ZlYrgcntdbKN4k,5189
7
- ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
8
- ucon/units.py,sha256=mcZ6LtHPjP4RYtzWTbf-tdjKtazcM1bDmxTI_tKrMxk,15924
9
- ucon/mcp/__init__.py,sha256=WoFOQ7JeDIzbjjkFIJ0Uv53VVLu-4lrjzG5vpVGGfT4,123
10
- ucon/mcp/server.py,sha256=uUrdevEaR65Qjh9xn8Q-_IusNjPGxdkLF9iQmiSTs0g,7016
11
- ucon-0.6.0.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
12
- ucon-0.6.0.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
13
- ucon-0.6.0.dist-info/METADATA,sha256=qdS_SH1wMJHnmWRTebu1VzWO3T935GmRMIcD9vjZ9XM,16375
14
- ucon-0.6.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
15
- ucon-0.6.0.dist-info/entry_points.txt,sha256=jbfLf0FbOulgGa0nM_sRiTNfiCAkJcHnSSK_oj3g0cQ,50
16
- ucon-0.6.0.dist-info/top_level.txt,sha256=Vv3KDuZ86fmH5yOYLbYap9DbBblK1YUkmlThffF71jA,5
17
- ucon-0.6.0.dist-info/RECORD,,
File without changes