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/units.py CHANGED
@@ -28,7 +28,18 @@ Notes
28
28
  The design allows for future extensibility: users can register their own units,
29
29
  systems, or aliases dynamically, without modifying the core definitions.
30
30
  """
31
- from ucon.core import Dimension, Unit, UnitSystem
31
+ import re
32
+ from typing import Dict, Tuple, Union
33
+
34
+ from ucon.core import Dimension, Scale, Unit, UnitFactor, UnitProduct, UnitSystem
35
+
36
+
37
+ class UnknownUnitError(Exception):
38
+ """Raised when a unit string cannot be resolved to a known unit."""
39
+
40
+ def __init__(self, name: str):
41
+ self.name = name
42
+ super().__init__(f"Unknown unit: {name!r}")
32
43
 
33
44
 
34
45
  none = Unit()
@@ -79,23 +90,23 @@ day = Unit(name='day', dimension=Dimension.time, aliases=('d',))
79
90
 
80
91
  # -- Imperial / US Customary Units -------------------------------------
81
92
  # Length
82
- foot = Unit(name='foot', dimension=Dimension.length, aliases=('ft',))
83
- inch = Unit(name='inch', dimension=Dimension.length, aliases=('in',))
84
- yard = Unit(name='yard', dimension=Dimension.length, aliases=('yd',))
85
- mile = Unit(name='mile', dimension=Dimension.length, aliases=('mi',))
93
+ foot = Unit(name='foot', dimension=Dimension.length, aliases=('ft', 'feet'))
94
+ inch = Unit(name='inch', dimension=Dimension.length, aliases=('in', 'inches'))
95
+ yard = Unit(name='yard', dimension=Dimension.length, aliases=('yd', 'yards'))
96
+ mile = Unit(name='mile', dimension=Dimension.length, aliases=('mi', 'miles'))
86
97
 
87
98
  # Mass
88
99
  pound = Unit(name='pound', dimension=Dimension.mass, aliases=('lb', 'lbs'))
89
- ounce = Unit(name='ounce', dimension=Dimension.mass, aliases=('oz',))
100
+ ounce = Unit(name='ounce', dimension=Dimension.mass, aliases=('oz', 'ounces'))
90
101
 
91
102
  # Temperature
92
103
  fahrenheit = Unit(name='fahrenheit', dimension=Dimension.temperature, aliases=('°F', 'degF'))
93
104
 
94
105
  # Volume
95
- gallon = Unit(name='gallon', dimension=Dimension.volume, aliases=('gal',))
106
+ gallon = Unit(name='gallon', dimension=Dimension.volume, aliases=('gal', 'gallons'))
96
107
 
97
108
  # Energy
98
- calorie = Unit(name='calorie', dimension=Dimension.energy, aliases=('cal',))
109
+ calorie = Unit(name='calorie', dimension=Dimension.energy, aliases=('cal', 'calories'))
99
110
  btu = Unit(name='btu', dimension=Dimension.energy, aliases=('BTU',))
100
111
 
101
112
  # Power
@@ -103,14 +114,14 @@ horsepower = Unit(name='horsepower', dimension=Dimension.power, aliases=('hp',))
103
114
 
104
115
  # Pressure
105
116
  bar = Unit(name='bar', dimension=Dimension.pressure, aliases=('bar',))
106
- psi = Unit(name='psi', dimension=Dimension.pressure, aliases=('lbf/in²',))
117
+ psi = Unit(name='psi', dimension=Dimension.pressure, aliases=('psi', 'lbf/in²'))
107
118
  atmosphere = Unit(name='atmosphere', dimension=Dimension.pressure, aliases=('atm',))
108
119
  # ----------------------------------------------------------------------
109
120
 
110
121
 
111
122
  # -- Information Units -------------------------------------------------
112
- bit = Unit(name='bit', dimension=Dimension.information, aliases=('b',))
113
- byte = Unit(name='byte', dimension=Dimension.information, aliases=('B',))
123
+ bit = Unit(name='bit', dimension=Dimension.information, aliases=('b', 'bits'))
124
+ byte = Unit(name='byte', dimension=Dimension.information, aliases=('B', 'bytes'))
114
125
  # ----------------------------------------------------------------------
115
126
 
116
127
 
@@ -129,12 +140,13 @@ square_degree = Unit(name='square_degree', dimension=Dimension.solid_angle, alia
129
140
 
130
141
 
131
142
  # -- Ratio Units -------------------------------------------------------
132
- ratio_one = Unit(name='one', dimension=Dimension.ratio, aliases=('1',))
143
+ fraction = Unit(name='fraction', dimension=Dimension.ratio, aliases=('frac', '1'))
133
144
  percent = Unit(name='percent', dimension=Dimension.ratio, aliases=('%',))
134
145
  permille = Unit(name='permille', dimension=Dimension.ratio, aliases=('‰',))
135
146
  ppm = Unit(name='ppm', dimension=Dimension.ratio, aliases=())
136
147
  ppb = Unit(name='ppb', dimension=Dimension.ratio, aliases=())
137
148
  basis_point = Unit(name='basis_point', dimension=Dimension.ratio, aliases=('bp', 'bps'))
149
+ nines = Unit(name='nines', dimension=Dimension.ratio, aliases=('9s',))
138
150
  # ----------------------------------------------------------------------
139
151
 
140
152
 
@@ -184,3 +196,240 @@ def have(name: str) -> bool:
184
196
  if any((alias or "").lower() == target for alias in getattr(val, "aliases", ())):
185
197
  return True
186
198
  return False
199
+
200
+
201
+ # -- Unit String Parsing Infrastructure ------------------------------------
202
+
203
+ # Module-level registries (populated by _build_registry at module load)
204
+ _UNIT_REGISTRY: Dict[str, Unit] = {}
205
+ _UNIT_REGISTRY_CASE_SENSITIVE: Dict[str, Unit] = {}
206
+
207
+ # Scale prefix mapping (shorthand -> Scale)
208
+ # Sorted by length descending for greedy matching
209
+ _SCALE_PREFIXES: Dict[str, Scale] = {
210
+ # Binary (IEC) - must come before single-char metric
211
+ 'Gi': Scale.gibi,
212
+ 'Mi': Scale.mebi,
213
+ 'Ki': Scale.kibi,
214
+ # Metric (decimal) - multi-char first
215
+ 'da': Scale.deca,
216
+ # Single-char metric
217
+ 'P': Scale.peta,
218
+ 'T': Scale.tera,
219
+ 'G': Scale.giga,
220
+ 'M': Scale.mega,
221
+ 'k': Scale.kilo,
222
+ 'h': Scale.hecto,
223
+ 'd': Scale.deci,
224
+ 'c': Scale.centi,
225
+ 'm': Scale.milli,
226
+ 'u': Scale.micro, # ASCII alternative
227
+ 'μ': Scale.micro, # Unicode micro sign
228
+ 'µ': Scale.micro, # Unicode mu (common substitute)
229
+ 'n': Scale.nano,
230
+ 'p': Scale.pico,
231
+ 'f': Scale.femto,
232
+ }
233
+
234
+ # Sorted by length descending for greedy prefix matching
235
+ _SCALE_PREFIXES_SORTED = sorted(_SCALE_PREFIXES.keys(), key=len, reverse=True)
236
+
237
+ # Unicode superscript translation table
238
+ _SUPERSCRIPT_TO_DIGIT = str.maketrans('⁰¹²³⁴⁵⁶⁷⁸⁹⁻', '0123456789-')
239
+
240
+
241
+ def _build_registry() -> None:
242
+ """
243
+ Populate the unit registry from module globals.
244
+
245
+ Called once at module load time. Builds both case-insensitive and
246
+ case-sensitive lookup tables.
247
+ """
248
+ for obj in globals().values():
249
+ if isinstance(obj, Unit) and obj.name:
250
+ # Case-insensitive registry (for lookups by name)
251
+ _UNIT_REGISTRY[obj.name.lower()] = obj
252
+ # Case-sensitive registry (for alias lookups like 'L' for liter)
253
+ _UNIT_REGISTRY_CASE_SENSITIVE[obj.name] = obj
254
+ for alias in obj.aliases:
255
+ if alias:
256
+ _UNIT_REGISTRY[alias.lower()] = obj
257
+ _UNIT_REGISTRY_CASE_SENSITIVE[alias] = obj
258
+
259
+
260
+ def _parse_exponent(s: str) -> Tuple[str, float]:
261
+ """
262
+ Extract exponent from unit factor string.
263
+
264
+ Handles both formats:
265
+ - Unicode: 'm²' -> ('m', 2.0), 's⁻¹' -> ('s', -1.0)
266
+ - ASCII: 'm^2' -> ('m', 2.0), 's^-1' -> ('s', -1.0)
267
+
268
+ Returns:
269
+ Tuple of (base_unit_str, exponent) where exponent defaults to 1.0.
270
+ """
271
+ # Try ASCII caret notation first: "m^2", "s^-1"
272
+ if '^' in s:
273
+ base, exp_str = s.rsplit('^', 1)
274
+ try:
275
+ return base.strip(), float(exp_str)
276
+ except ValueError:
277
+ raise UnknownUnitError(s)
278
+
279
+ # Try Unicode superscripts: "m²", "s⁻¹"
280
+ match = re.search(r'[⁰¹²³⁴⁵⁶⁷⁸⁹⁻]+$', s)
281
+ if match:
282
+ base = s[:match.start()]
283
+ exp_str = match.group().translate(_SUPERSCRIPT_TO_DIGIT)
284
+ try:
285
+ return base, float(exp_str)
286
+ except ValueError:
287
+ raise UnknownUnitError(s)
288
+
289
+ # No exponent found
290
+ return s, 1.0
291
+
292
+
293
+ def _lookup_factor(s: str) -> Tuple[Unit, Scale]:
294
+ """
295
+ Look up a single unit factor, handling scale prefixes.
296
+
297
+ Prioritizes prefix+unit interpretation over direct unit lookup.
298
+ This means "kg" returns (gram, Scale.kilo) rather than (kilogram, Scale.one).
299
+
300
+ Examples:
301
+ - 'meter' -> (meter, Scale.one)
302
+ - 'm' -> (meter, Scale.one)
303
+ - 'km' -> (meter, Scale.kilo)
304
+ - 'kg' -> (gram, Scale.kilo)
305
+ - 'mL' -> (liter, Scale.milli)
306
+
307
+ Returns:
308
+ Tuple of (unit, scale).
309
+
310
+ Raises:
311
+ UnknownUnitError: If the unit cannot be resolved.
312
+ """
313
+ # Try scale prefix + unit first (prioritize decomposition)
314
+ # Only case-sensitive matching for remainder (e.g., "fT" = femto-tesla, "ft" = foot)
315
+ for prefix in _SCALE_PREFIXES_SORTED:
316
+ if s.startswith(prefix) and len(s) > len(prefix):
317
+ remainder = s[len(prefix):]
318
+ if remainder in _UNIT_REGISTRY_CASE_SENSITIVE:
319
+ return _UNIT_REGISTRY_CASE_SENSITIVE[remainder], _SCALE_PREFIXES[prefix]
320
+
321
+ # Fall back to exact case-sensitive match (for aliases like 'L', 'B', 'm')
322
+ if s in _UNIT_REGISTRY_CASE_SENSITIVE:
323
+ return _UNIT_REGISTRY_CASE_SENSITIVE[s], Scale.one
324
+
325
+ # Fall back to case-insensitive match
326
+ s_lower = s.lower()
327
+ if s_lower in _UNIT_REGISTRY:
328
+ return _UNIT_REGISTRY[s_lower], Scale.one
329
+
330
+ raise UnknownUnitError(s)
331
+
332
+
333
+ def _parse_composite(s: str) -> UnitProduct:
334
+ """
335
+ Parse composite unit string into UnitProduct.
336
+
337
+ Accepts both Unicode and ASCII notation:
338
+ - Unicode: 'm/s²', 'kg·m/s²', 'N·m'
339
+ - ASCII: 'm/s^2', 'kg*m/s^2', 'N*m'
340
+
341
+ Delimiters:
342
+ - Division: '/'
343
+ - Multiplication: '·' (Unicode) or '*' (ASCII)
344
+
345
+ Returns:
346
+ UnitProduct representing the parsed composite unit.
347
+ """
348
+ # Normalize multiplication operator
349
+ s = s.replace('*', '·')
350
+
351
+ # Split numerator and denominator
352
+ if '/' in s:
353
+ num_part, den_part = s.split('/', 1)
354
+ else:
355
+ num_part, den_part = s, ''
356
+
357
+ factors: Dict[UnitFactor, float] = {}
358
+
359
+ # Parse numerator factors
360
+ if num_part:
361
+ for factor_str in num_part.split('·'):
362
+ factor_str = factor_str.strip()
363
+ if not factor_str:
364
+ continue
365
+ base_str, exp = _parse_exponent(factor_str)
366
+ unit, scale = _lookup_factor(base_str)
367
+ uf = UnitFactor(unit, scale)
368
+ factors[uf] = factors.get(uf, 0) + exp
369
+
370
+ # Parse denominator factors (negative exponents)
371
+ if den_part:
372
+ for factor_str in den_part.split('·'):
373
+ factor_str = factor_str.strip()
374
+ if not factor_str:
375
+ continue
376
+ base_str, exp = _parse_exponent(factor_str)
377
+ unit, scale = _lookup_factor(base_str)
378
+ uf = UnitFactor(unit, scale)
379
+ factors[uf] = factors.get(uf, 0) - exp
380
+
381
+ return UnitProduct(factors)
382
+
383
+
384
+ def get_unit_by_name(name: str) -> Union[Unit, UnitProduct]:
385
+ """
386
+ Look up a unit by name, alias, or shorthand.
387
+
388
+ Handles:
389
+ - Plain units: "meter", "m", "second", "s"
390
+ - Scaled units: "km", "mL", "kg"
391
+ - Composite units: "m/s", "kg*m/s^2", "N·m"
392
+ - Exponents: "m²", "m^2", "s⁻¹", "s^-1"
393
+
394
+ Args:
395
+ name: Unit string to parse.
396
+
397
+ Returns:
398
+ Unit for simple unscaled units, UnitProduct for scaled or composite.
399
+
400
+ Raises:
401
+ UnknownUnitError: If the unit cannot be resolved.
402
+
403
+ Examples:
404
+ >>> get_unit_by_name("meter")
405
+ <Unit m>
406
+ >>> get_unit_by_name("km")
407
+ <UnitProduct km>
408
+ >>> get_unit_by_name("m/s^2")
409
+ <UnitProduct m/s²>
410
+ """
411
+ if not name or not name.strip():
412
+ raise UnknownUnitError(name if name else "")
413
+
414
+ name = name.strip()
415
+
416
+ # Check for composite (has operators)
417
+ if '/' in name or '·' in name or '*' in name:
418
+ return _parse_composite(name)
419
+
420
+ # Check for exponent
421
+ base_str, exp = _parse_exponent(name)
422
+ if exp != 1.0:
423
+ unit, scale = _lookup_factor(base_str)
424
+ return UnitProduct({UnitFactor(unit, scale): exp})
425
+
426
+ # Simple unit or scaled unit
427
+ unit, scale = _lookup_factor(name)
428
+ if scale == Scale.one:
429
+ return unit
430
+ else:
431
+ return UnitProduct({UnitFactor(unit, scale): 1})
432
+
433
+
434
+ # Build the registry at module load time
435
+ _build_registry()
@@ -1,18 +1,20 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucon
3
- Version: 0.5.2
4
- Summary: a tool for dimensional analysis: a "Unit CONverter"
3
+ Version: 0.6.1
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
7
+ Author-email: "Emmanuel I. Obi" <withtwoemms@gmail.com>
7
8
  Maintainer: Emmanuel I. Obi
8
- Maintainer-email: withtwoemms@gmail.com
9
+ Maintainer-email: "Emmanuel I. Obi" <withtwoemms@gmail.com>
9
10
  License: Apache-2.0
11
+ Project-URL: Homepage, https://github.com/withtwoemms/ucon
12
+ Project-URL: Repository, https://github.com/withtwoemms/ucon
10
13
  Classifier: Development Status :: 4 - Beta
11
14
  Classifier: Intended Audience :: Developers
12
15
  Classifier: Intended Audience :: Education
13
16
  Classifier: Intended Audience :: Science/Research
14
17
  Classifier: Topic :: Software Development :: Build Tools
15
- Classifier: License :: OSI Approved :: Apache Software License
16
18
  Classifier: Programming Language :: Python :: 3.7
17
19
  Classifier: Programming Language :: Python :: 3.8
18
20
  Classifier: Programming Language :: Python :: 3.9
@@ -21,19 +23,20 @@ Classifier: Programming Language :: Python :: 3.11
21
23
  Classifier: Programming Language :: Python :: 3.12
22
24
  Classifier: Programming Language :: Python :: 3.13
23
25
  Classifier: Programming Language :: Python :: 3.14
26
+ Requires-Python: >=3.7
24
27
  Description-Content-Type: text/markdown
25
28
  License-File: LICENSE
26
29
  License-File: NOTICE
30
+ Provides-Extra: test
31
+ Requires-Dist: coverage[toml]>=5.5; extra == "test"
32
+ Provides-Extra: pydantic
33
+ Requires-Dist: pydantic>=2.0; extra == "pydantic"
34
+ Provides-Extra: mcp
35
+ Requires-Dist: mcp>=1.0; python_version >= "3.10" and extra == "mcp"
27
36
  Dynamic: author
28
- Dynamic: classifier
29
- Dynamic: description
30
- Dynamic: description-content-type
31
37
  Dynamic: home-page
32
- Dynamic: license
33
38
  Dynamic: license-file
34
39
  Dynamic: maintainer
35
- Dynamic: maintainer-email
36
- Dynamic: summary
37
40
 
38
41
  <table>
39
42
  <tr>
@@ -68,6 +71,7 @@ It combines **units**, **scales**, and **dimensions** into a composable algebra
68
71
  - Metric and binary prefixes (`kilo`, `kibi`, `micro`, `mebi`, etc.)
69
72
  - Pseudo-dimensions for angles, solid angles, and ratios with semantic isolation
70
73
  - Uncertainty propagation through arithmetic and conversions
74
+ - Pydantic v2 integration for API validation and JSON serialization
71
75
  - A clean foundation for physics, chemistry, data modeling, and beyond
72
76
 
73
77
  Think of it as **`decimal.Decimal` for the physical world** — precise, predictable, and type-safe.
@@ -90,12 +94,13 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
90
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. |
91
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. |
92
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). |
93
- | **`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). |
94
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. |
95
99
  | **`UnitSystem`** | `ucon.core` | Named mapping from dimensions to base units (e.g., SI, Imperial). | Defining coherent unit systems; grouping base units by dimension. |
96
100
  | **`BasisTransform`** | `ucon.core` | Matrix-based transformation between dimensional exponent spaces. | Converting between incompatible dimensional structures; exact arithmetic with `Fraction`. |
97
101
  | **`RebasedUnit`** | `ucon.core` | A unit rebased to another system's dimension, preserving provenance. | Cross-basis conversions; tracking original unit through basis changes. |
98
102
  | **`units` module** | `ucon.units` | Defines canonical unit instances (SI, imperial, information, and derived units). | Quick access to standard physical units (`units.meter`, `units.foot`, `units.byte`, etc.). |
103
+ | **`pydantic` module** | `ucon.pydantic` | Pydantic v2 integration with `Number` type for model validation and JSON serialization. | Using `Number` in Pydantic models; API request/response validation; JSON round-trip serialization. |
99
104
 
100
105
  ### Under the Hood
101
106
 
@@ -142,35 +147,40 @@ Simple:
142
147
  pip install ucon
143
148
  ```
144
149
 
150
+ With Pydantic v2 support:
151
+ ```bash
152
+ pip install ucon[pydantic]
153
+ ```
154
+
145
155
  ## Usage
146
156
 
147
- This sort of dimensional analysis:
157
+ ### Quantities and Arithmetic
158
+
159
+ Dimensional analysis like this:
148
160
  ```
149
161
  2 mL bromine | 3.119 g bromine
150
162
  --------------x----------------- #=> 6.238 g bromine
151
163
  1 | 1 mL bromine
152
164
  ```
153
- becomes straightforward when you define a measurement:
165
+ becomes straightforward:
154
166
  ```python
155
167
  from ucon import Number, Scale, units
156
168
  from ucon.quantity import Ratio
157
169
 
158
- # Two milliliters of bromine
159
170
  mL = Scale.milli * units.liter
160
171
  two_mL_bromine = Number(quantity=2, unit=mL)
161
172
 
162
- # Density of bromine: 3.119 g/mL
163
173
  bromine_density = Ratio(
164
174
  numerator=Number(unit=units.gram, quantity=3.119),
165
175
  denominator=Number(unit=mL),
166
176
  )
167
177
 
168
- # Multiply to find mass
169
178
  grams_bromine = bromine_density.evaluate() * two_mL_bromine
170
179
  print(grams_bromine) # <6.238 g>
171
180
  ```
172
181
 
173
- Scale prefixes compose naturally:
182
+ ### Scale Prefixes
183
+
174
184
  ```python
175
185
  km = Scale.kilo * units.meter # UnitProduct with kilo-scaled meter
176
186
  mg = Scale.milli * units.gram # UnitProduct with milli-scaled gram
@@ -178,12 +188,12 @@ mg = Scale.milli * units.gram # UnitProduct with milli-scaled gram
178
188
  print(km.shorthand) # 'km'
179
189
  print(mg.shorthand) # 'mg'
180
190
 
181
- # Scale arithmetic
182
191
  print(km.fold_scale()) # 1000.0
183
192
  print(mg.fold_scale()) # 0.001
184
193
  ```
185
194
 
186
- Units are callable for ergonomic quantity construction:
195
+ ### Callable Units and Conversion
196
+
187
197
  ```python
188
198
  from ucon import units, Scale
189
199
 
@@ -202,118 +212,122 @@ distance_mi = distance.to(units.mile)
202
212
  print(distance_mi) # <3.107... mi>
203
213
  ```
204
214
 
205
- Dimensionless units have semantic isolation — angles, solid angles, and ratios are distinct:
215
+ ### Dimensionless Units
216
+
217
+ Angles, solid angles, and ratios are semantically isolated:
206
218
  ```python
207
219
  import math
208
220
  from ucon import units
209
221
 
210
- # Angle conversions
211
222
  angle = units.radian(math.pi)
212
223
  print(angle.to(units.degree)) # <180.0 deg>
213
224
 
214
- # Ratio conversions
215
225
  ratio = units.percent(50)
216
226
  print(ratio.to(units.ppm)) # <500000.0 ppm>
217
227
 
218
228
  # Cross-family conversions are prevented
219
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>
220
234
  ```
221
235
 
222
- Uncertainty propagates through arithmetic and conversions:
236
+ ### Uncertainty Propagation
237
+
223
238
  ```python
224
239
  from ucon import units
225
240
 
226
- # Measurements with uncertainty
227
241
  length = units.meter(1.234, uncertainty=0.005)
228
242
  width = units.meter(0.567, uncertainty=0.003)
229
243
 
230
244
  print(length) # <1.234 ± 0.005 m>
231
245
 
232
- # Uncertainty propagates through arithmetic (quadrature)
246
+ # Propagates through arithmetic (quadrature)
233
247
  area = length * width
234
248
  print(area) # <0.699678 ± 0.00424... m²>
235
249
 
236
- # Uncertainty propagates through conversion
250
+ # Propagates through conversion
237
251
  length_ft = length.to(units.foot)
238
252
  print(length_ft) # <4.048... ± 0.0164... ft>
239
253
  ```
240
254
 
241
- Unit systems and basis transforms enable conversions between incompatible dimensional structures.
242
- This goes beyond simple unit conversion (meter → foot) into structural transformation:
255
+ ### Pydantic Integration
243
256
 
244
257
  ```python
245
- from fractions import Fraction
246
- from ucon import BasisTransform, Dimension, Unit, UnitSystem, units
247
- from ucon.graph import ConversionGraph
248
- from ucon.maps import LinearMap
249
-
250
- # The realm of Valdris has three fundamental dimensions:
251
- # - Aether (A): magical energy substrate
252
- # - Resonance (R): vibrational frequency of magic
253
- # - Substance (S): physical matter
254
- #
255
- # These combine into SI dimensions via a transformation matrix:
256
- #
257
- # | L | | 2 0 0 | | A |
258
- # | M | = | 1 0 1 | × | R |
259
- # | T | |-2 -1 0 | | S |
260
- #
261
- # Reading the columns:
262
- # - 1 aether contributes: L², M, T⁻² (energy-like)
263
- # - 1 resonance contributes: T⁻¹ (frequency-like)
264
- # - 1 substance contributes: M (mass-like)
265
-
266
- # Fantasy base units
267
- mote = Unit(name='mote', dimension=Dimension.energy, aliases=('mt',))
268
- chime = Unit(name='chime', dimension=Dimension.frequency, aliases=('ch',))
269
- ite = Unit(name='ite', dimension=Dimension.mass, aliases=('it',))
270
-
271
- valdris = UnitSystem(
272
- name="Valdris",
273
- bases={
274
- Dimension.energy: mote,
275
- Dimension.frequency: chime,
276
- Dimension.mass: ite,
277
- }
278
- )
258
+ from pydantic import BaseModel
259
+ from ucon.pydantic import Number
260
+ from ucon import units
279
261
 
280
- # The basis transform encodes how Valdris dimensions compose into SI
281
- valdris_to_si = BasisTransform(
282
- src=valdris,
283
- dst=units.si,
284
- src_dimensions=(Dimension.energy, Dimension.frequency, Dimension.mass),
285
- dst_dimensions=(Dimension.energy, Dimension.frequency, Dimension.mass),
286
- matrix=(
287
- (2, 0, 0), # energy: 2 × aether
288
- (1, 0, 1), # frequency: aether + substance
289
- (-2, -1, 0), # mass: -2×aether - resonance
290
- ),
291
- )
262
+ class Measurement(BaseModel):
263
+ value: Number
292
264
 
293
- # Physical calibration: how many SI units per fantasy unit
294
- graph = ConversionGraph()
295
- graph.connect_systems(
296
- basis_transform=valdris_to_si,
297
- edges={
298
- (mote, units.joule): LinearMap(42), # 1 mote = 42 J
299
- (chime, units.hertz): LinearMap(7), # 1 chime = 7 Hz
300
- (ite, units.kilogram): LinearMap(Fraction(1, 2)), # 1 ite = 0.5 kg
301
- }
302
- )
265
+ # From JSON/dict input
266
+ m = Measurement(value={"quantity": 5, "unit": "km"})
267
+ print(m.value) # <5 km>
303
268
 
304
- # Game engine converts between physics systems
305
- energy_map = graph.convert(src=mote, dst=units.joule)
306
- energy_map(10) # 420 joules from 10 motes
269
+ # From Number instance
270
+ m2 = Measurement(value=units.meter(10))
307
271
 
308
- # Inverse: display real-world values in game units
309
- joule_to_mote = graph.convert(src=units.joule, dst=mote)
310
- joule_to_mote(420) # 10 motes
272
+ # Serialize to JSON
273
+ print(m.model_dump_json())
274
+ # {"value": {"quantity": 5.0, "unit": "km", "uncertainty": null}}
311
275
 
312
- # The transform is invertible with exact Fraction arithmetic
313
- valdris_to_si.is_invertible # True
276
+ # Supports both Unicode and ASCII unit notation
277
+ m3 = Measurement(value={"quantity": 9.8, "unit": "m/s^2"}) # ASCII
278
+ m4 = Measurement(value={"quantity": 9.8, "unit": "m/s²"}) # Unicode
314
279
  ```
315
280
 
316
- This enables fantasy game physics, or any field where the dimensional structure differs from SI.
281
+ **Design notes:**
282
+ - **Serialization format**: Units serialize as human-readable shorthand strings (`"km"`, `"m/s^2"`) rather than structured dicts, aligning with how scientists express units.
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.
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
+
326
+ ### Custom Unit Systems
327
+
328
+ `BasisTransform` enables conversions between incompatible dimensional structures (e.g., fantasy game physics, CGS units, domain-specific systems).
329
+
330
+ See full example: [docs/examples/basis-transform-fantasy-units.md](./docs/examples/basis-transform-fantasy-units.md)
317
331
 
318
332
  ---
319
333
 
@@ -326,7 +340,7 @@ This enables fantasy game physics, or any field where the dimensional structure
326
340
  | **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
327
341
  | **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
328
342
  | **0.5.x** | Unit Systems | `BasisTransform`, `UnitSystem`, cross-basis conversion | ✅ Complete |
329
- | **0.6.x** | Pydantic Integration | Type-safe quantity validation | Planned |
343
+ | **0.6.x** | Pydantic + MCP | API validation, AI agent integration | Complete |
330
344
  | **0.7.x** | NumPy Arrays | Vectorized conversion and arithmetic | ⏳ Planned |
331
345
 
332
346
  See full roadmap: [ROADMAP.md](./ROADMAP.md)
@@ -336,14 +350,21 @@ See full roadmap: [ROADMAP.md](./ROADMAP.md)
336
350
  ## Contributing
337
351
 
338
352
  Contributions, issues, and pull requests are welcome!
339
- Ensure `nox` is installed.
353
+
354
+ Set up your development environment:
355
+ ```bash
356
+ make venv
357
+ source .ucon-3.12/bin/activate
340
358
  ```
341
- pip install -r requirements.txt
359
+
360
+ Run the test suite before committing:
361
+ ```bash
362
+ make test
342
363
  ```
343
- Then run the full test suite (against all supported python versions) before committing:
344
364
 
365
+ Run tests across all supported Python versions:
345
366
  ```bash
346
- nox -s test
367
+ make test-all
347
368
  ```
348
369
  ---
349
370