ucon 0.5.1__py3-none-any.whl → 0.6.0__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 +24 -3
- ucon/algebra.py +36 -14
- ucon/core.py +414 -2
- ucon/graph.py +167 -10
- ucon/mcp/__init__.py +8 -0
- ucon/mcp/server.py +250 -0
- ucon/pydantic.py +199 -0
- ucon/units.py +286 -11
- {ucon-0.5.1.dist-info → ucon-0.6.0.dist-info}/METADATA +88 -31
- ucon-0.6.0.dist-info/RECORD +17 -0
- ucon-0.6.0.dist-info/entry_points.txt +2 -0
- ucon-0.6.0.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_core.py +0 -827
- tests/ucon/test_default_graph_conversions.py +0 -443
- tests/ucon/test_dimensionless_units.py +0 -248
- tests/ucon/test_quantity.py +0 -615
- tests/ucon/test_uncertainty.py +0 -264
- tests/ucon/test_units.py +0 -25
- ucon-0.5.1.dist-info/RECORD +0 -24
- ucon-0.5.1.dist-info/top_level.txt +0 -2
- {ucon-0.5.1.dist-info → ucon-0.6.0.dist-info}/WHEEL +0 -0
- {ucon-0.5.1.dist-info → ucon-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.5.1.dist-info → ucon-0.6.0.dist-info}/licenses/NOTICE +0 -0
ucon/graph.py
CHANGED
|
@@ -28,7 +28,15 @@ from contextvars import ContextVar
|
|
|
28
28
|
from dataclasses import dataclass, field
|
|
29
29
|
from typing import Union
|
|
30
30
|
|
|
31
|
-
from ucon.core import
|
|
31
|
+
from ucon.core import (
|
|
32
|
+
BasisTransform,
|
|
33
|
+
Dimension,
|
|
34
|
+
RebasedUnit,
|
|
35
|
+
Unit,
|
|
36
|
+
UnitFactor,
|
|
37
|
+
UnitProduct,
|
|
38
|
+
Scale,
|
|
39
|
+
)
|
|
32
40
|
from ucon.maps import Map, LinearMap, AffineMap
|
|
33
41
|
|
|
34
42
|
|
|
@@ -66,6 +74,9 @@ class ConversionGraph:
|
|
|
66
74
|
# Edges between UnitProducts (keyed by frozen factor representation)
|
|
67
75
|
_product_edges: dict[tuple, dict[tuple, Map]] = field(default_factory=dict)
|
|
68
76
|
|
|
77
|
+
# Rebased units: original unit → RebasedUnit (for cross-basis edges)
|
|
78
|
+
_rebased: dict[Unit, RebasedUnit] = field(default_factory=dict)
|
|
79
|
+
|
|
69
80
|
# ------------- Edge Management -------------------------------------------
|
|
70
81
|
|
|
71
82
|
def add_edge(
|
|
@@ -74,6 +85,7 @@ class ConversionGraph:
|
|
|
74
85
|
src: Union[Unit, UnitProduct],
|
|
75
86
|
dst: Union[Unit, UnitProduct],
|
|
76
87
|
map: Map,
|
|
88
|
+
basis_transform: BasisTransform | None = None,
|
|
77
89
|
) -> None:
|
|
78
90
|
"""Register a conversion edge. Also registers the inverse.
|
|
79
91
|
|
|
@@ -85,15 +97,31 @@ class ConversionGraph:
|
|
|
85
97
|
Destination unit expression.
|
|
86
98
|
map : Map
|
|
87
99
|
The conversion morphism (src → dst).
|
|
100
|
+
basis_transform : BasisTransform, optional
|
|
101
|
+
If provided, creates a cross-basis edge. The src unit is rebased
|
|
102
|
+
to the dst's dimension and the edge connects the rebased unit
|
|
103
|
+
to dst.
|
|
88
104
|
|
|
89
105
|
Raises
|
|
90
106
|
------
|
|
91
107
|
DimensionMismatch
|
|
92
|
-
If src and dst have different dimensions.
|
|
108
|
+
If src and dst have different dimensions (and no basis_transform).
|
|
93
109
|
CyclicInconsistency
|
|
94
110
|
If the reverse edge exists and round-trip is not identity.
|
|
95
111
|
"""
|
|
96
|
-
#
|
|
112
|
+
# Cross-basis edge with BasisTransform
|
|
113
|
+
if basis_transform is not None:
|
|
114
|
+
if isinstance(src, Unit) and not isinstance(src, UnitProduct):
|
|
115
|
+
if isinstance(dst, Unit) and not isinstance(dst, UnitProduct):
|
|
116
|
+
self._add_cross_basis_edge(
|
|
117
|
+
src=src,
|
|
118
|
+
dst=dst,
|
|
119
|
+
map=map,
|
|
120
|
+
basis_transform=basis_transform,
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Handle Unit vs UnitProduct dispatch (normal case)
|
|
97
125
|
if isinstance(src, Unit) and not isinstance(src, UnitProduct):
|
|
98
126
|
if isinstance(dst, Unit) and not isinstance(dst, UnitProduct):
|
|
99
127
|
self._add_unit_edge(src=src, dst=dst, map=map)
|
|
@@ -142,6 +170,114 @@ class ConversionGraph:
|
|
|
142
170
|
self._product_edges.setdefault(src_key, {})[dst_key] = map
|
|
143
171
|
self._product_edges.setdefault(dst_key, {})[src_key] = map.inverse()
|
|
144
172
|
|
|
173
|
+
def _add_cross_basis_edge(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
src: Unit,
|
|
177
|
+
dst: Unit,
|
|
178
|
+
map: Map,
|
|
179
|
+
basis_transform: BasisTransform,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Add cross-basis edge between Units via BasisTransform.
|
|
182
|
+
|
|
183
|
+
Creates a RebasedUnit for src in the destination's dimension partition,
|
|
184
|
+
then stores the edge from the rebased unit to dst.
|
|
185
|
+
"""
|
|
186
|
+
# Validate that the transform maps src to dst's dimension
|
|
187
|
+
if not basis_transform.validate_edge(src, dst):
|
|
188
|
+
raise DimensionMismatch(
|
|
189
|
+
f"Transform {basis_transform.src.name} -> {basis_transform.dst.name} "
|
|
190
|
+
f"does not map {src.name} to {dst.name}'s dimension"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Create RebasedUnit in destination's dimension partition
|
|
194
|
+
rebased = RebasedUnit(
|
|
195
|
+
original=src,
|
|
196
|
+
rebased_dimension=dst.dimension,
|
|
197
|
+
basis_transform=basis_transform,
|
|
198
|
+
)
|
|
199
|
+
self._rebased[src] = rebased
|
|
200
|
+
|
|
201
|
+
# Store edge from rebased to dst (same dimension now)
|
|
202
|
+
dim = dst.dimension
|
|
203
|
+
self._ensure_dimension(dim)
|
|
204
|
+
self._unit_edges[dim].setdefault(rebased, {})[dst] = map
|
|
205
|
+
self._unit_edges[dim].setdefault(dst, {})[rebased] = map.inverse()
|
|
206
|
+
|
|
207
|
+
def connect_systems(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
basis_transform: BasisTransform,
|
|
211
|
+
edges: dict[tuple[Unit, Unit], Map],
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Bulk-add edges between systems.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
basis_transform : BasisTransform
|
|
218
|
+
The transform bridging the two systems.
|
|
219
|
+
edges : dict
|
|
220
|
+
Mapping from (src_unit, dst_unit) to Map.
|
|
221
|
+
"""
|
|
222
|
+
for (src, dst), edge_map in edges.items():
|
|
223
|
+
self.add_edge(
|
|
224
|
+
src=src,
|
|
225
|
+
dst=dst,
|
|
226
|
+
map=edge_map,
|
|
227
|
+
basis_transform=basis_transform,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def list_rebased_units(self) -> dict[Unit, RebasedUnit]:
|
|
231
|
+
"""Return all rebased units in the graph.
|
|
232
|
+
|
|
233
|
+
Returns
|
|
234
|
+
-------
|
|
235
|
+
dict[Unit, RebasedUnit]
|
|
236
|
+
Mapping from original unit to its RebasedUnit.
|
|
237
|
+
"""
|
|
238
|
+
return dict(self._rebased)
|
|
239
|
+
|
|
240
|
+
def list_transforms(self) -> list[BasisTransform]:
|
|
241
|
+
"""Return all BasisTransforms active in the graph.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
list[BasisTransform]
|
|
246
|
+
Unique transforms used by rebased units.
|
|
247
|
+
"""
|
|
248
|
+
seen = set()
|
|
249
|
+
result = []
|
|
250
|
+
for rebased in self._rebased.values():
|
|
251
|
+
bt = rebased.basis_transform
|
|
252
|
+
if id(bt) not in seen:
|
|
253
|
+
seen.add(id(bt))
|
|
254
|
+
result.append(bt)
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
def edges_for_transform(self, transform: BasisTransform) -> list[tuple[Unit, Unit]]:
|
|
258
|
+
"""Return all edges that use a specific BasisTransform.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
transform : BasisTransform
|
|
263
|
+
The transform to filter by.
|
|
264
|
+
|
|
265
|
+
Returns
|
|
266
|
+
-------
|
|
267
|
+
list[tuple[Unit, Unit]]
|
|
268
|
+
List of (original_unit, destination_unit) pairs.
|
|
269
|
+
"""
|
|
270
|
+
result = []
|
|
271
|
+
for original, rebased in self._rebased.items():
|
|
272
|
+
if rebased.basis_transform == transform:
|
|
273
|
+
# Find the destination unit (the one the rebased unit connects to)
|
|
274
|
+
dim = rebased.dimension
|
|
275
|
+
if dim in self._unit_edges and rebased in self._unit_edges[dim]:
|
|
276
|
+
for dst in self._unit_edges[dim][rebased]:
|
|
277
|
+
if not isinstance(dst, RebasedUnit):
|
|
278
|
+
result.append((original, dst))
|
|
279
|
+
return result
|
|
280
|
+
|
|
145
281
|
def _ensure_dimension(self, dim: Dimension) -> None:
|
|
146
282
|
if dim not in self._unit_edges:
|
|
147
283
|
self._unit_edges[dim] = {}
|
|
@@ -206,10 +342,28 @@ class ConversionGraph:
|
|
|
206
342
|
return self._convert_products(src=src_prod, dst=dst_prod)
|
|
207
343
|
|
|
208
344
|
def _convert_units(self, *, src: Unit, dst: Unit) -> Map:
|
|
209
|
-
"""Convert between plain Units via BFS.
|
|
345
|
+
"""Convert between plain Units via BFS.
|
|
346
|
+
|
|
347
|
+
Handles cross-basis conversions via rebased units.
|
|
348
|
+
"""
|
|
210
349
|
if src == dst:
|
|
211
350
|
return LinearMap.identity()
|
|
212
351
|
|
|
352
|
+
# Check if src has a rebased version that can reach dst
|
|
353
|
+
if src in self._rebased:
|
|
354
|
+
rebased = self._rebased[src]
|
|
355
|
+
if rebased.dimension == dst.dimension:
|
|
356
|
+
# Convert via the rebased unit
|
|
357
|
+
return self._bfs_convert(start=rebased, target=dst, dim=dst.dimension)
|
|
358
|
+
|
|
359
|
+
# Check if dst has a rebased version (inverse conversion)
|
|
360
|
+
if dst in self._rebased:
|
|
361
|
+
rebased_dst = self._rebased[dst]
|
|
362
|
+
if rebased_dst.dimension == src.dimension:
|
|
363
|
+
# Convert from src to the rebased dst
|
|
364
|
+
return self._bfs_convert(start=src, target=rebased_dst, dim=src.dimension)
|
|
365
|
+
|
|
366
|
+
# Check for dimension mismatch
|
|
213
367
|
if src.dimension != dst.dimension:
|
|
214
368
|
raise DimensionMismatch(f"{src.dimension} != {dst.dimension}")
|
|
215
369
|
|
|
@@ -217,13 +371,16 @@ class ConversionGraph:
|
|
|
217
371
|
if self._has_direct_unit_edge(src=src, dst=dst):
|
|
218
372
|
return self._get_direct_unit_edge(src=src, dst=dst)
|
|
219
373
|
|
|
220
|
-
# BFS
|
|
221
|
-
|
|
374
|
+
# BFS in same dimension
|
|
375
|
+
return self._bfs_convert(start=src, target=dst, dim=src.dimension)
|
|
376
|
+
|
|
377
|
+
def _bfs_convert(self, *, start, target, dim: Dimension) -> Map:
|
|
378
|
+
"""BFS to find conversion path within a dimension."""
|
|
222
379
|
if dim not in self._unit_edges:
|
|
223
380
|
raise ConversionNotFound(f"No edges for dimension {dim}")
|
|
224
381
|
|
|
225
|
-
visited: dict
|
|
226
|
-
queue = deque([
|
|
382
|
+
visited: dict = {start: LinearMap.identity()}
|
|
383
|
+
queue = deque([start])
|
|
227
384
|
|
|
228
385
|
while queue:
|
|
229
386
|
current = queue.popleft()
|
|
@@ -239,12 +396,12 @@ class ConversionGraph:
|
|
|
239
396
|
composed = edge_map @ current_map
|
|
240
397
|
visited[neighbor] = composed
|
|
241
398
|
|
|
242
|
-
if neighbor ==
|
|
399
|
+
if neighbor == target:
|
|
243
400
|
return composed
|
|
244
401
|
|
|
245
402
|
queue.append(neighbor)
|
|
246
403
|
|
|
247
|
-
raise ConversionNotFound(f"No path from {
|
|
404
|
+
raise ConversionNotFound(f"No path from {start} to {target}")
|
|
248
405
|
|
|
249
406
|
def _convert_products(self, *, src: UnitProduct, dst: UnitProduct) -> Map:
|
|
250
407
|
"""Convert between UnitProducts.
|
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"]
|