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/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 Dimension, Unit, UnitFactor, UnitProduct, Scale
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
- # Handle Unit vs UnitProduct dispatch
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
- dim = src.dimension
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[Unit, Map] = {src: LinearMap.identity()}
226
- queue = deque([src])
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 == dst:
399
+ if neighbor == target:
243
400
  return composed
244
401
 
245
402
  queue.append(neighbor)
246
403
 
247
- raise ConversionNotFound(f"No path from {src} to {dst}")
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
@@ -0,0 +1,8 @@
1
+ # ucon MCP server
2
+ #
3
+ # Install: pip install ucon[mcp]
4
+ # Run: ucon-mcp
5
+
6
+ from ucon.mcp.server import main
7
+
8
+ __all__ = ["main"]
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"]