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/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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
Summary:
|
|
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`.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
246
|
+
# Propagates through arithmetic (quadrature)
|
|
233
247
|
area = length * width
|
|
234
248
|
print(area) # <0.699678 ± 0.00424... m²>
|
|
235
249
|
|
|
236
|
-
#
|
|
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
|
-
|
|
242
|
-
This goes beyond simple unit conversion (meter → foot) into structural transformation:
|
|
255
|
+
### Pydantic Integration
|
|
243
256
|
|
|
244
257
|
```python
|
|
245
|
-
from
|
|
246
|
-
from ucon import
|
|
247
|
-
from ucon
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
#
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
#
|
|
305
|
-
|
|
306
|
-
energy_map(10) # 420 joules from 10 motes
|
|
269
|
+
# From Number instance
|
|
270
|
+
m2 = Measurement(value=units.meter(10))
|
|
307
271
|
|
|
308
|
-
#
|
|
309
|
-
|
|
310
|
-
|
|
272
|
+
# Serialize to JSON
|
|
273
|
+
print(m.model_dump_json())
|
|
274
|
+
# {"value": {"quantity": 5.0, "unit": "km", "uncertainty": null}}
|
|
311
275
|
|
|
312
|
-
#
|
|
313
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
353
|
+
|
|
354
|
+
Set up your development environment:
|
|
355
|
+
```bash
|
|
356
|
+
make venv
|
|
357
|
+
source .ucon-3.12/bin/activate
|
|
340
358
|
```
|
|
341
|
-
|
|
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
|
-
|
|
367
|
+
make test-all
|
|
347
368
|
```
|
|
348
369
|
---
|
|
349
370
|
|