ucon 0.5.2__py3-none-any.whl → 0.6.1__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/__init__.py +4 -1
- ucon/core.py +7 -2
- ucon/graph.py +8 -6
- ucon/maps.py +116 -0
- ucon/mcp/__init__.py +8 -0
- ucon/mcp/server.py +250 -0
- ucon/pydantic.py +199 -0
- ucon/units.py +261 -12
- {ucon-0.5.2.dist-info → ucon-0.6.1.dist-info}/METADATA +119 -98
- ucon-0.6.1.dist-info/RECORD +17 -0
- ucon-0.6.1.dist-info/entry_points.txt +2 -0
- ucon-0.6.1.dist-info/top_level.txt +1 -0
- tests/ucon/__init__.py +0 -3
- tests/ucon/conversion/__init__.py +0 -0
- tests/ucon/conversion/test_graph.py +0 -409
- tests/ucon/conversion/test_map.py +0 -409
- tests/ucon/test_algebra.py +0 -239
- tests/ucon/test_basis_transform.py +0 -521
- tests/ucon/test_core.py +0 -827
- tests/ucon/test_default_graph_conversions.py +0 -443
- tests/ucon/test_dimensionless_units.py +0 -248
- tests/ucon/test_graph_basis_transform.py +0 -263
- tests/ucon/test_quantity.py +0 -615
- tests/ucon/test_rebased_unit.py +0 -184
- tests/ucon/test_uncertainty.py +0 -264
- tests/ucon/test_unit_system.py +0 -174
- tests/ucon/test_units.py +0 -25
- tests/ucon/test_vector_fraction.py +0 -185
- ucon-0.5.2.dist-info/RECORD +0 -29
- ucon-0.5.2.dist-info/top_level.txt +0 -2
- {ucon-0.5.2.dist-info → ucon-0.6.1.dist-info}/WHEEL +0 -0
- {ucon-0.5.2.dist-info → ucon-0.6.1.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.5.2.dist-info → ucon-0.6.1.dist-info}/licenses/NOTICE +0 -0
ucon/__init__.py
CHANGED
|
@@ -53,6 +53,7 @@ from ucon.core import (
|
|
|
53
53
|
Ratio,
|
|
54
54
|
)
|
|
55
55
|
from ucon.graph import get_default_graph, using_graph
|
|
56
|
+
from ucon.units import UnknownUnitError, get_unit_by_name
|
|
56
57
|
|
|
57
58
|
|
|
58
59
|
__all__ = [
|
|
@@ -69,7 +70,9 @@ __all__ = [
|
|
|
69
70
|
'UnitFactor',
|
|
70
71
|
'UnitProduct',
|
|
71
72
|
'UnitSystem',
|
|
73
|
+
'UnknownUnitError',
|
|
72
74
|
'get_default_graph',
|
|
73
|
-
'
|
|
75
|
+
'get_unit_by_name',
|
|
74
76
|
'units',
|
|
77
|
+
'using_graph',
|
|
75
78
|
]
|
ucon/core.py
CHANGED
|
@@ -1108,16 +1108,21 @@ class UnitProduct:
|
|
|
1108
1108
|
part = getattr(unit, "shorthand", "") or getattr(unit, "name", "") or ""
|
|
1109
1109
|
if not part:
|
|
1110
1110
|
return
|
|
1111
|
+
|
|
1112
|
+
def fmt_exp(p: float) -> str:
|
|
1113
|
+
"""Format exponent, using int when possible to avoid '2.0' → '²·⁰'."""
|
|
1114
|
+
return str(int(p) if p == int(p) else p).translate(cls._SUPERSCRIPTS)
|
|
1115
|
+
|
|
1111
1116
|
if power > 0:
|
|
1112
1117
|
if power == 1:
|
|
1113
1118
|
num.append(part)
|
|
1114
1119
|
else:
|
|
1115
|
-
num.append(part +
|
|
1120
|
+
num.append(part + fmt_exp(power))
|
|
1116
1121
|
elif power < 0:
|
|
1117
1122
|
if power == -1:
|
|
1118
1123
|
den.append(part)
|
|
1119
1124
|
else:
|
|
1120
|
-
den.append(part +
|
|
1125
|
+
den.append(part + fmt_exp(-power))
|
|
1121
1126
|
|
|
1122
1127
|
@property
|
|
1123
1128
|
def shorthand(self) -> str:
|
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.
|
|
593
|
-
graph.add_edge(src=units.
|
|
594
|
-
graph.add_edge(src=units.
|
|
595
|
-
graph.add_edge(src=units.
|
|
596
|
-
graph.add_edge(src=units.
|
|
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/mcp/__init__.py
ADDED
ucon/mcp/server.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# ucon MCP Server
|
|
2
|
+
#
|
|
3
|
+
# Provides unit conversion and dimensional analysis tools for AI agents.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# ucon-mcp # Run via entry point
|
|
7
|
+
# python -m ucon.mcp # Run as module
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import FastMCP
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from ucon import Dimension, get_unit_by_name
|
|
13
|
+
from ucon.core import Number, Scale, Unit, UnitProduct
|
|
14
|
+
from ucon.units import UnknownUnitError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
mcp = FastMCP("ucon")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# -----------------------------------------------------------------------------
|
|
21
|
+
# Response Models
|
|
22
|
+
# -----------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConversionResult(BaseModel):
|
|
26
|
+
"""Result of a unit conversion."""
|
|
27
|
+
|
|
28
|
+
quantity: float
|
|
29
|
+
unit: str | None
|
|
30
|
+
dimension: str
|
|
31
|
+
uncertainty: float | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UnitInfo(BaseModel):
|
|
35
|
+
"""Information about an available unit."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
shorthand: str
|
|
39
|
+
aliases: list[str]
|
|
40
|
+
dimension: str
|
|
41
|
+
scalable: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ScaleInfo(BaseModel):
|
|
45
|
+
"""Information about a scale prefix."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
prefix: str
|
|
49
|
+
factor: float
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DimensionCheck(BaseModel):
|
|
53
|
+
"""Result of a dimensional compatibility check."""
|
|
54
|
+
|
|
55
|
+
compatible: bool
|
|
56
|
+
dimension_a: str
|
|
57
|
+
dimension_b: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# -----------------------------------------------------------------------------
|
|
61
|
+
# Tools
|
|
62
|
+
# -----------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@mcp.tool()
|
|
66
|
+
def convert(value: float, from_unit: str, to_unit: str) -> ConversionResult:
|
|
67
|
+
"""
|
|
68
|
+
Convert a numeric value from one unit to another.
|
|
69
|
+
|
|
70
|
+
Units can be specified as:
|
|
71
|
+
- Base units: "meter", "m", "second", "s", "gram", "g"
|
|
72
|
+
- Scaled units: "km", "mL", "kg", "MHz" (use list_scales for prefixes)
|
|
73
|
+
- Composite units: "m/s", "kg*m/s^2", "N*m"
|
|
74
|
+
- Exponents: "m^2", "s^-1" (ASCII) or "m²", "s⁻¹" (Unicode)
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
value: The numeric quantity to convert.
|
|
78
|
+
from_unit: Source unit string.
|
|
79
|
+
to_unit: Target unit string.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
ConversionResult with converted quantity, unit, and dimension.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
UnknownUnitError: If a unit string cannot be parsed.
|
|
86
|
+
DimensionMismatch: If units have incompatible dimensions.
|
|
87
|
+
"""
|
|
88
|
+
src = get_unit_by_name(from_unit)
|
|
89
|
+
dst = get_unit_by_name(to_unit)
|
|
90
|
+
|
|
91
|
+
num = Number(quantity=value, unit=src)
|
|
92
|
+
result = num.to(dst)
|
|
93
|
+
|
|
94
|
+
unit_str = None
|
|
95
|
+
dim_name = "none"
|
|
96
|
+
|
|
97
|
+
if result.unit:
|
|
98
|
+
if isinstance(result.unit, UnitProduct):
|
|
99
|
+
unit_str = result.unit.shorthand
|
|
100
|
+
dim_name = result.unit.dimension.name
|
|
101
|
+
elif isinstance(result.unit, Unit):
|
|
102
|
+
unit_str = result.unit.shorthand
|
|
103
|
+
dim_name = result.unit.dimension.name
|
|
104
|
+
|
|
105
|
+
return ConversionResult(
|
|
106
|
+
quantity=result.quantity,
|
|
107
|
+
unit=unit_str,
|
|
108
|
+
dimension=dim_name,
|
|
109
|
+
uncertainty=result.uncertainty,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@mcp.tool()
|
|
114
|
+
def list_units(dimension: str | None = None) -> list[UnitInfo]:
|
|
115
|
+
"""
|
|
116
|
+
List available units, optionally filtered by dimension.
|
|
117
|
+
|
|
118
|
+
Returns base units only. Use scale prefixes (from list_scales) to form
|
|
119
|
+
scaled variants. For example, "meter" with prefix "k" becomes "km".
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
dimension: Optional filter by dimension name (e.g., "length", "mass", "time").
|
|
123
|
+
Use list_dimensions() to see available dimensions.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List of UnitInfo objects describing available units.
|
|
127
|
+
"""
|
|
128
|
+
import ucon.units as units_module
|
|
129
|
+
|
|
130
|
+
# Units that accept SI scale prefixes
|
|
131
|
+
SCALABLE_UNITS = {
|
|
132
|
+
"meter", "gram", "second", "ampere", "kelvin", "mole", "candela",
|
|
133
|
+
"hertz", "newton", "pascal", "joule", "watt", "coulomb", "volt",
|
|
134
|
+
"farad", "ohm", "siemens", "weber", "tesla", "henry", "lumen",
|
|
135
|
+
"lux", "becquerel", "gray", "sievert", "katal",
|
|
136
|
+
"liter", "byte",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
result = []
|
|
140
|
+
seen_names = set()
|
|
141
|
+
|
|
142
|
+
for name in dir(units_module):
|
|
143
|
+
obj = getattr(units_module, name)
|
|
144
|
+
if isinstance(obj, Unit) and obj.name and obj.name not in seen_names:
|
|
145
|
+
seen_names.add(obj.name)
|
|
146
|
+
|
|
147
|
+
if dimension and obj.dimension.name != dimension:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
result.append(
|
|
151
|
+
UnitInfo(
|
|
152
|
+
name=obj.name,
|
|
153
|
+
shorthand=obj.shorthand,
|
|
154
|
+
aliases=list(obj.aliases) if obj.aliases else [],
|
|
155
|
+
dimension=obj.dimension.name,
|
|
156
|
+
scalable=obj.name in SCALABLE_UNITS,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return sorted(result, key=lambda u: (u.dimension, u.name))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@mcp.tool()
|
|
164
|
+
def list_scales() -> list[ScaleInfo]:
|
|
165
|
+
"""
|
|
166
|
+
List available scale prefixes for units.
|
|
167
|
+
|
|
168
|
+
These prefixes can be combined with scalable units (see list_units).
|
|
169
|
+
For example, prefix "k" (kilo) with unit "m" (meter) forms "km".
|
|
170
|
+
|
|
171
|
+
Includes both SI decimal prefixes (kilo, mega, milli, micro, etc.)
|
|
172
|
+
and binary prefixes (kibi, mebi, gibi) for information units.
|
|
173
|
+
|
|
174
|
+
Note on bytes:
|
|
175
|
+
- SI prefixes: kB = 1000 B, MB = 1,000,000 B (decimal)
|
|
176
|
+
- Binary prefixes: KiB = 1024 B, MiB = 1,048,576 B (powers of 2)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
List of ScaleInfo objects with name, prefix symbol, and numeric factor.
|
|
180
|
+
"""
|
|
181
|
+
result = []
|
|
182
|
+
for scale in Scale:
|
|
183
|
+
if scale == Scale.one:
|
|
184
|
+
continue # Skip the identity scale
|
|
185
|
+
result.append(
|
|
186
|
+
ScaleInfo(
|
|
187
|
+
name=scale.name,
|
|
188
|
+
prefix=scale.shorthand,
|
|
189
|
+
factor=scale.descriptor.evaluated,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return sorted(result, key=lambda s: -s.factor)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@mcp.tool()
|
|
196
|
+
def check_dimensions(unit_a: str, unit_b: str) -> DimensionCheck:
|
|
197
|
+
"""
|
|
198
|
+
Check if two units have compatible dimensions.
|
|
199
|
+
|
|
200
|
+
Units with the same dimension can be converted between each other.
|
|
201
|
+
Units with different dimensions cannot be added or directly compared.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
unit_a: First unit string.
|
|
205
|
+
unit_b: Second unit string.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
DimensionCheck indicating compatibility and the dimension of each unit.
|
|
209
|
+
"""
|
|
210
|
+
a = get_unit_by_name(unit_a)
|
|
211
|
+
b = get_unit_by_name(unit_b)
|
|
212
|
+
|
|
213
|
+
dim_a = a.dimension if isinstance(a, Unit) else a.dimension
|
|
214
|
+
dim_b = b.dimension if isinstance(b, Unit) else b.dimension
|
|
215
|
+
|
|
216
|
+
return DimensionCheck(
|
|
217
|
+
compatible=(dim_a == dim_b),
|
|
218
|
+
dimension_a=dim_a.name,
|
|
219
|
+
dimension_b=dim_b.name,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@mcp.tool()
|
|
224
|
+
def list_dimensions() -> list[str]:
|
|
225
|
+
"""
|
|
226
|
+
List available physical dimensions.
|
|
227
|
+
|
|
228
|
+
Dimensions represent fundamental physical quantities (length, mass, time, etc.)
|
|
229
|
+
and derived quantities (velocity, force, energy, etc.).
|
|
230
|
+
|
|
231
|
+
Use these dimension names to filter list_units().
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
List of dimension names.
|
|
235
|
+
"""
|
|
236
|
+
return sorted([d.name for d in Dimension])
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# -----------------------------------------------------------------------------
|
|
240
|
+
# Entry Point
|
|
241
|
+
# -----------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main():
|
|
245
|
+
"""Run the ucon MCP server."""
|
|
246
|
+
mcp.run()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
main()
|
ucon/pydantic.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# © 2025 The Radiativity Company
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
# See the LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
ucon.pydantic
|
|
7
|
+
=============
|
|
8
|
+
|
|
9
|
+
Pydantic v2 integration for ucon.
|
|
10
|
+
|
|
11
|
+
Provides type-annotated wrappers for use in Pydantic models with full
|
|
12
|
+
JSON serialization support.
|
|
13
|
+
|
|
14
|
+
Usage
|
|
15
|
+
-----
|
|
16
|
+
>>> from pydantic import BaseModel
|
|
17
|
+
>>> from ucon.pydantic import Number
|
|
18
|
+
>>>
|
|
19
|
+
>>> class Measurement(BaseModel):
|
|
20
|
+
... value: Number
|
|
21
|
+
...
|
|
22
|
+
>>> m = Measurement(value={"quantity": 5, "unit": "km"})
|
|
23
|
+
>>> print(m.value)
|
|
24
|
+
<5 km>
|
|
25
|
+
>>> print(m.model_dump_json())
|
|
26
|
+
{"value": {"quantity": 5.0, "unit": "km", "uncertainty": null}}
|
|
27
|
+
|
|
28
|
+
Installation
|
|
29
|
+
------------
|
|
30
|
+
Requires Pydantic v2. Install with::
|
|
31
|
+
|
|
32
|
+
pip install ucon[pydantic]
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from typing import Annotated, Any
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
|
|
40
|
+
from pydantic.json_schema import JsonSchemaValue
|
|
41
|
+
from pydantic_core import CoreSchema, core_schema
|
|
42
|
+
except ImportError as e:
|
|
43
|
+
raise ImportError(
|
|
44
|
+
"Pydantic v2 is required for ucon.pydantic. "
|
|
45
|
+
"Install with: pip install ucon[pydantic]"
|
|
46
|
+
) from e
|
|
47
|
+
|
|
48
|
+
from ucon.core import Number as _Number
|
|
49
|
+
from ucon.units import UnknownUnitError, get_unit_by_name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_number(v: Any) -> _Number:
|
|
53
|
+
"""
|
|
54
|
+
Validate and convert input to Number.
|
|
55
|
+
|
|
56
|
+
Accepts:
|
|
57
|
+
- Number instance (passthrough)
|
|
58
|
+
- dict with 'quantity' and optional 'unit', 'uncertainty'
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If input cannot be converted to Number.
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(v, _Number):
|
|
64
|
+
return v
|
|
65
|
+
|
|
66
|
+
if isinstance(v, dict):
|
|
67
|
+
quantity = v.get("quantity")
|
|
68
|
+
if quantity is None:
|
|
69
|
+
raise ValueError("Number dict must have 'quantity' field")
|
|
70
|
+
|
|
71
|
+
unit_str = v.get("unit")
|
|
72
|
+
uncertainty = v.get("uncertainty")
|
|
73
|
+
|
|
74
|
+
# Parse unit if provided
|
|
75
|
+
if unit_str:
|
|
76
|
+
try:
|
|
77
|
+
unit = get_unit_by_name(unit_str)
|
|
78
|
+
except UnknownUnitError as e:
|
|
79
|
+
raise ValueError(f"Unknown unit: {unit_str!r}") from e
|
|
80
|
+
else:
|
|
81
|
+
unit = None
|
|
82
|
+
|
|
83
|
+
return _Number(
|
|
84
|
+
quantity=quantity,
|
|
85
|
+
unit=unit,
|
|
86
|
+
uncertainty=uncertainty,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Cannot parse Number from {type(v).__name__}. "
|
|
91
|
+
"Expected Number instance or dict with 'quantity' field."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _serialize_number(n: _Number) -> dict:
|
|
96
|
+
"""
|
|
97
|
+
Serialize Number to JSON-compatible dict.
|
|
98
|
+
|
|
99
|
+
Output format::
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
"quantity": <float>,
|
|
103
|
+
"unit": <str | null>,
|
|
104
|
+
"uncertainty": <float | null>
|
|
105
|
+
}
|
|
106
|
+
"""
|
|
107
|
+
# Get unit shorthand
|
|
108
|
+
if n.unit is None:
|
|
109
|
+
unit_str = None
|
|
110
|
+
elif hasattr(n.unit, 'shorthand'):
|
|
111
|
+
unit_str = n.unit.shorthand
|
|
112
|
+
# Empty shorthand means dimensionless
|
|
113
|
+
if unit_str == "":
|
|
114
|
+
unit_str = None
|
|
115
|
+
else:
|
|
116
|
+
unit_str = None
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"quantity": float(n.quantity),
|
|
120
|
+
"unit": unit_str,
|
|
121
|
+
"uncertainty": float(n.uncertainty) if n.uncertainty is not None else None,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _NumberPydanticAnnotation:
|
|
126
|
+
"""
|
|
127
|
+
Pydantic annotation helper for ucon Number type.
|
|
128
|
+
|
|
129
|
+
This class provides the schema generation hooks that Pydantic v2 needs
|
|
130
|
+
to properly validate and serialize Number instances without introspecting
|
|
131
|
+
the internal Unit/UnitProduct types.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def __get_pydantic_core_schema__(
|
|
136
|
+
cls,
|
|
137
|
+
_source_type: Any,
|
|
138
|
+
_handler: GetCoreSchemaHandler,
|
|
139
|
+
) -> CoreSchema:
|
|
140
|
+
"""
|
|
141
|
+
Generate Pydantic core schema for Number validation/serialization.
|
|
142
|
+
|
|
143
|
+
Uses no_info_plain_validator_function to bypass Pydantic's default
|
|
144
|
+
introspection of the Number class fields.
|
|
145
|
+
"""
|
|
146
|
+
return core_schema.no_info_plain_validator_function(
|
|
147
|
+
_validate_number,
|
|
148
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
149
|
+
_serialize_number,
|
|
150
|
+
info_arg=False,
|
|
151
|
+
return_schema=core_schema.dict_schema(),
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def __get_pydantic_json_schema__(
|
|
157
|
+
cls,
|
|
158
|
+
_core_schema: CoreSchema,
|
|
159
|
+
handler: GetJsonSchemaHandler,
|
|
160
|
+
) -> JsonSchemaValue:
|
|
161
|
+
"""Generate JSON schema for OpenAPI documentation."""
|
|
162
|
+
return {
|
|
163
|
+
"type": "object",
|
|
164
|
+
"properties": {
|
|
165
|
+
"quantity": {"type": "number"},
|
|
166
|
+
"unit": {"type": "string", "nullable": True},
|
|
167
|
+
"uncertainty": {"type": "number", "nullable": True},
|
|
168
|
+
},
|
|
169
|
+
"required": ["quantity"],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
Number = Annotated[_Number, _NumberPydanticAnnotation]
|
|
174
|
+
"""
|
|
175
|
+
Pydantic-compatible Number type.
|
|
176
|
+
|
|
177
|
+
Use this as a type hint in Pydantic models to enable automatic validation
|
|
178
|
+
and JSON serialization of ucon Number instances.
|
|
179
|
+
|
|
180
|
+
Example::
|
|
181
|
+
|
|
182
|
+
from pydantic import BaseModel
|
|
183
|
+
from ucon.pydantic import Number
|
|
184
|
+
|
|
185
|
+
class Measurement(BaseModel):
|
|
186
|
+
value: Number
|
|
187
|
+
|
|
188
|
+
# From dict
|
|
189
|
+
m = Measurement(value={"quantity": 5, "unit": "m"})
|
|
190
|
+
|
|
191
|
+
# From Number instance
|
|
192
|
+
from ucon import units
|
|
193
|
+
m2 = Measurement(value=units.meter(10))
|
|
194
|
+
|
|
195
|
+
# Serialize to JSON
|
|
196
|
+
print(m.model_dump_json())
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
__all__ = ["Number"]
|