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/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()
|
|
@@ -49,6 +60,7 @@ joule_per_kelvin = Unit(name='joule_per_kelvin', dimension=Dimension.entropy, al
|
|
|
49
60
|
kelvin = Unit(name='kelvin', dimension=Dimension.temperature, aliases=('K',))
|
|
50
61
|
kilogram = Unit(name='kilogram', dimension=Dimension.mass, aliases=('kg',))
|
|
51
62
|
liter = Unit(name='liter', dimension=Dimension.volume, aliases=('L', 'l'))
|
|
63
|
+
candela = Unit(name='candela', dimension=Dimension.luminous_intensity, aliases=('cd',))
|
|
52
64
|
lumen = Unit(name='lumen', dimension=Dimension.luminous_intensity, aliases=('lm',))
|
|
53
65
|
lux = Unit(name='lux', dimension=Dimension.illuminance, aliases=('lx',))
|
|
54
66
|
meter = Unit(name='meter', dimension=Dimension.length, aliases=('m',))
|
|
@@ -78,23 +90,23 @@ day = Unit(name='day', dimension=Dimension.time, aliases=('d',))
|
|
|
78
90
|
|
|
79
91
|
# -- Imperial / US Customary Units -------------------------------------
|
|
80
92
|
# Length
|
|
81
|
-
foot = Unit(name='foot', dimension=Dimension.length, aliases=('ft',))
|
|
82
|
-
inch = Unit(name='inch', dimension=Dimension.length, aliases=('in',))
|
|
83
|
-
yard = Unit(name='yard', dimension=Dimension.length, aliases=('yd',))
|
|
84
|
-
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'))
|
|
85
97
|
|
|
86
98
|
# Mass
|
|
87
99
|
pound = Unit(name='pound', dimension=Dimension.mass, aliases=('lb', 'lbs'))
|
|
88
|
-
ounce = Unit(name='ounce', dimension=Dimension.mass, aliases=('oz',))
|
|
100
|
+
ounce = Unit(name='ounce', dimension=Dimension.mass, aliases=('oz', 'ounces'))
|
|
89
101
|
|
|
90
102
|
# Temperature
|
|
91
103
|
fahrenheit = Unit(name='fahrenheit', dimension=Dimension.temperature, aliases=('°F', 'degF'))
|
|
92
104
|
|
|
93
105
|
# Volume
|
|
94
|
-
gallon = Unit(name='gallon', dimension=Dimension.volume, aliases=('gal',))
|
|
106
|
+
gallon = Unit(name='gallon', dimension=Dimension.volume, aliases=('gal', 'gallons'))
|
|
95
107
|
|
|
96
108
|
# Energy
|
|
97
|
-
calorie = Unit(name='calorie', dimension=Dimension.energy, aliases=('cal',))
|
|
109
|
+
calorie = Unit(name='calorie', dimension=Dimension.energy, aliases=('cal', 'calories'))
|
|
98
110
|
btu = Unit(name='btu', dimension=Dimension.energy, aliases=('BTU',))
|
|
99
111
|
|
|
100
112
|
# Power
|
|
@@ -102,14 +114,14 @@ horsepower = Unit(name='horsepower', dimension=Dimension.power, aliases=('hp',))
|
|
|
102
114
|
|
|
103
115
|
# Pressure
|
|
104
116
|
bar = Unit(name='bar', dimension=Dimension.pressure, aliases=('bar',))
|
|
105
|
-
psi = Unit(name='psi', dimension=Dimension.pressure, aliases=('lbf/in²'
|
|
117
|
+
psi = Unit(name='psi', dimension=Dimension.pressure, aliases=('psi', 'lbf/in²'))
|
|
106
118
|
atmosphere = Unit(name='atmosphere', dimension=Dimension.pressure, aliases=('atm',))
|
|
107
119
|
# ----------------------------------------------------------------------
|
|
108
120
|
|
|
109
121
|
|
|
110
122
|
# -- Information Units -------------------------------------------------
|
|
111
|
-
bit = Unit(name='bit', dimension=Dimension.information, aliases=('b',))
|
|
112
|
-
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'))
|
|
113
125
|
# ----------------------------------------------------------------------
|
|
114
126
|
|
|
115
127
|
|
|
@@ -141,6 +153,32 @@ basis_point = Unit(name='basis_point', dimension=Dimension.ratio, aliases=('bp',
|
|
|
141
153
|
webers = weber
|
|
142
154
|
|
|
143
155
|
|
|
156
|
+
# -- Predefined Unit Systems -----------------------------------------------
|
|
157
|
+
si = UnitSystem(
|
|
158
|
+
name="SI",
|
|
159
|
+
bases={
|
|
160
|
+
Dimension.length: meter,
|
|
161
|
+
Dimension.mass: kilogram,
|
|
162
|
+
Dimension.time: second,
|
|
163
|
+
Dimension.temperature: kelvin,
|
|
164
|
+
Dimension.current: ampere,
|
|
165
|
+
Dimension.amount_of_substance: mole,
|
|
166
|
+
Dimension.luminous_intensity: candela,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
imperial = UnitSystem(
|
|
171
|
+
name="Imperial",
|
|
172
|
+
bases={
|
|
173
|
+
Dimension.length: foot,
|
|
174
|
+
Dimension.mass: pound,
|
|
175
|
+
Dimension.time: second,
|
|
176
|
+
Dimension.temperature: fahrenheit,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
# --------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
144
182
|
def have(name: str) -> bool:
|
|
145
183
|
assert name, "Must provide a unit name to check"
|
|
146
184
|
assert isinstance(name, str), "Unit name must be a string"
|
|
@@ -157,3 +195,240 @@ def have(name: str) -> bool:
|
|
|
157
195
|
if any((alias or "").lower() == target for alias in getattr(val, "aliases", ())):
|
|
158
196
|
return True
|
|
159
197
|
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# -- Unit String Parsing Infrastructure ------------------------------------
|
|
201
|
+
|
|
202
|
+
# Module-level registries (populated by _build_registry at module load)
|
|
203
|
+
_UNIT_REGISTRY: Dict[str, Unit] = {}
|
|
204
|
+
_UNIT_REGISTRY_CASE_SENSITIVE: Dict[str, Unit] = {}
|
|
205
|
+
|
|
206
|
+
# Scale prefix mapping (shorthand -> Scale)
|
|
207
|
+
# Sorted by length descending for greedy matching
|
|
208
|
+
_SCALE_PREFIXES: Dict[str, Scale] = {
|
|
209
|
+
# Binary (IEC) - must come before single-char metric
|
|
210
|
+
'Gi': Scale.gibi,
|
|
211
|
+
'Mi': Scale.mebi,
|
|
212
|
+
'Ki': Scale.kibi,
|
|
213
|
+
# Metric (decimal) - multi-char first
|
|
214
|
+
'da': Scale.deca,
|
|
215
|
+
# Single-char metric
|
|
216
|
+
'P': Scale.peta,
|
|
217
|
+
'T': Scale.tera,
|
|
218
|
+
'G': Scale.giga,
|
|
219
|
+
'M': Scale.mega,
|
|
220
|
+
'k': Scale.kilo,
|
|
221
|
+
'h': Scale.hecto,
|
|
222
|
+
'd': Scale.deci,
|
|
223
|
+
'c': Scale.centi,
|
|
224
|
+
'm': Scale.milli,
|
|
225
|
+
'u': Scale.micro, # ASCII alternative
|
|
226
|
+
'μ': Scale.micro, # Unicode micro sign
|
|
227
|
+
'µ': Scale.micro, # Unicode mu (common substitute)
|
|
228
|
+
'n': Scale.nano,
|
|
229
|
+
'p': Scale.pico,
|
|
230
|
+
'f': Scale.femto,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Sorted by length descending for greedy prefix matching
|
|
234
|
+
_SCALE_PREFIXES_SORTED = sorted(_SCALE_PREFIXES.keys(), key=len, reverse=True)
|
|
235
|
+
|
|
236
|
+
# Unicode superscript translation table
|
|
237
|
+
_SUPERSCRIPT_TO_DIGIT = str.maketrans('⁰¹²³⁴⁵⁶⁷⁸⁹⁻', '0123456789-')
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _build_registry() -> None:
|
|
241
|
+
"""
|
|
242
|
+
Populate the unit registry from module globals.
|
|
243
|
+
|
|
244
|
+
Called once at module load time. Builds both case-insensitive and
|
|
245
|
+
case-sensitive lookup tables.
|
|
246
|
+
"""
|
|
247
|
+
for obj in globals().values():
|
|
248
|
+
if isinstance(obj, Unit) and obj.name:
|
|
249
|
+
# Case-insensitive registry (for lookups by name)
|
|
250
|
+
_UNIT_REGISTRY[obj.name.lower()] = obj
|
|
251
|
+
# Case-sensitive registry (for alias lookups like 'L' for liter)
|
|
252
|
+
_UNIT_REGISTRY_CASE_SENSITIVE[obj.name] = obj
|
|
253
|
+
for alias in obj.aliases:
|
|
254
|
+
if alias:
|
|
255
|
+
_UNIT_REGISTRY[alias.lower()] = obj
|
|
256
|
+
_UNIT_REGISTRY_CASE_SENSITIVE[alias] = obj
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _parse_exponent(s: str) -> Tuple[str, float]:
|
|
260
|
+
"""
|
|
261
|
+
Extract exponent from unit factor string.
|
|
262
|
+
|
|
263
|
+
Handles both formats:
|
|
264
|
+
- Unicode: 'm²' -> ('m', 2.0), 's⁻¹' -> ('s', -1.0)
|
|
265
|
+
- ASCII: 'm^2' -> ('m', 2.0), 's^-1' -> ('s', -1.0)
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Tuple of (base_unit_str, exponent) where exponent defaults to 1.0.
|
|
269
|
+
"""
|
|
270
|
+
# Try ASCII caret notation first: "m^2", "s^-1"
|
|
271
|
+
if '^' in s:
|
|
272
|
+
base, exp_str = s.rsplit('^', 1)
|
|
273
|
+
try:
|
|
274
|
+
return base.strip(), float(exp_str)
|
|
275
|
+
except ValueError:
|
|
276
|
+
raise UnknownUnitError(s)
|
|
277
|
+
|
|
278
|
+
# Try Unicode superscripts: "m²", "s⁻¹"
|
|
279
|
+
match = re.search(r'[⁰¹²³⁴⁵⁶⁷⁸⁹⁻]+$', s)
|
|
280
|
+
if match:
|
|
281
|
+
base = s[:match.start()]
|
|
282
|
+
exp_str = match.group().translate(_SUPERSCRIPT_TO_DIGIT)
|
|
283
|
+
try:
|
|
284
|
+
return base, float(exp_str)
|
|
285
|
+
except ValueError:
|
|
286
|
+
raise UnknownUnitError(s)
|
|
287
|
+
|
|
288
|
+
# No exponent found
|
|
289
|
+
return s, 1.0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _lookup_factor(s: str) -> Tuple[Unit, Scale]:
|
|
293
|
+
"""
|
|
294
|
+
Look up a single unit factor, handling scale prefixes.
|
|
295
|
+
|
|
296
|
+
Prioritizes prefix+unit interpretation over direct unit lookup.
|
|
297
|
+
This means "kg" returns (gram, Scale.kilo) rather than (kilogram, Scale.one).
|
|
298
|
+
|
|
299
|
+
Examples:
|
|
300
|
+
- 'meter' -> (meter, Scale.one)
|
|
301
|
+
- 'm' -> (meter, Scale.one)
|
|
302
|
+
- 'km' -> (meter, Scale.kilo)
|
|
303
|
+
- 'kg' -> (gram, Scale.kilo)
|
|
304
|
+
- 'mL' -> (liter, Scale.milli)
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Tuple of (unit, scale).
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
UnknownUnitError: If the unit cannot be resolved.
|
|
311
|
+
"""
|
|
312
|
+
# Try scale prefix + unit first (prioritize decomposition)
|
|
313
|
+
# Only case-sensitive matching for remainder (e.g., "fT" = femto-tesla, "ft" = foot)
|
|
314
|
+
for prefix in _SCALE_PREFIXES_SORTED:
|
|
315
|
+
if s.startswith(prefix) and len(s) > len(prefix):
|
|
316
|
+
remainder = s[len(prefix):]
|
|
317
|
+
if remainder in _UNIT_REGISTRY_CASE_SENSITIVE:
|
|
318
|
+
return _UNIT_REGISTRY_CASE_SENSITIVE[remainder], _SCALE_PREFIXES[prefix]
|
|
319
|
+
|
|
320
|
+
# Fall back to exact case-sensitive match (for aliases like 'L', 'B', 'm')
|
|
321
|
+
if s in _UNIT_REGISTRY_CASE_SENSITIVE:
|
|
322
|
+
return _UNIT_REGISTRY_CASE_SENSITIVE[s], Scale.one
|
|
323
|
+
|
|
324
|
+
# Fall back to case-insensitive match
|
|
325
|
+
s_lower = s.lower()
|
|
326
|
+
if s_lower in _UNIT_REGISTRY:
|
|
327
|
+
return _UNIT_REGISTRY[s_lower], Scale.one
|
|
328
|
+
|
|
329
|
+
raise UnknownUnitError(s)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_composite(s: str) -> UnitProduct:
|
|
333
|
+
"""
|
|
334
|
+
Parse composite unit string into UnitProduct.
|
|
335
|
+
|
|
336
|
+
Accepts both Unicode and ASCII notation:
|
|
337
|
+
- Unicode: 'm/s²', 'kg·m/s²', 'N·m'
|
|
338
|
+
- ASCII: 'm/s^2', 'kg*m/s^2', 'N*m'
|
|
339
|
+
|
|
340
|
+
Delimiters:
|
|
341
|
+
- Division: '/'
|
|
342
|
+
- Multiplication: '·' (Unicode) or '*' (ASCII)
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
UnitProduct representing the parsed composite unit.
|
|
346
|
+
"""
|
|
347
|
+
# Normalize multiplication operator
|
|
348
|
+
s = s.replace('*', '·')
|
|
349
|
+
|
|
350
|
+
# Split numerator and denominator
|
|
351
|
+
if '/' in s:
|
|
352
|
+
num_part, den_part = s.split('/', 1)
|
|
353
|
+
else:
|
|
354
|
+
num_part, den_part = s, ''
|
|
355
|
+
|
|
356
|
+
factors: Dict[UnitFactor, float] = {}
|
|
357
|
+
|
|
358
|
+
# Parse numerator factors
|
|
359
|
+
if num_part:
|
|
360
|
+
for factor_str in num_part.split('·'):
|
|
361
|
+
factor_str = factor_str.strip()
|
|
362
|
+
if not factor_str:
|
|
363
|
+
continue
|
|
364
|
+
base_str, exp = _parse_exponent(factor_str)
|
|
365
|
+
unit, scale = _lookup_factor(base_str)
|
|
366
|
+
uf = UnitFactor(unit, scale)
|
|
367
|
+
factors[uf] = factors.get(uf, 0) + exp
|
|
368
|
+
|
|
369
|
+
# Parse denominator factors (negative exponents)
|
|
370
|
+
if den_part:
|
|
371
|
+
for factor_str in den_part.split('·'):
|
|
372
|
+
factor_str = factor_str.strip()
|
|
373
|
+
if not factor_str:
|
|
374
|
+
continue
|
|
375
|
+
base_str, exp = _parse_exponent(factor_str)
|
|
376
|
+
unit, scale = _lookup_factor(base_str)
|
|
377
|
+
uf = UnitFactor(unit, scale)
|
|
378
|
+
factors[uf] = factors.get(uf, 0) - exp
|
|
379
|
+
|
|
380
|
+
return UnitProduct(factors)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def get_unit_by_name(name: str) -> Union[Unit, UnitProduct]:
|
|
384
|
+
"""
|
|
385
|
+
Look up a unit by name, alias, or shorthand.
|
|
386
|
+
|
|
387
|
+
Handles:
|
|
388
|
+
- Plain units: "meter", "m", "second", "s"
|
|
389
|
+
- Scaled units: "km", "mL", "kg"
|
|
390
|
+
- Composite units: "m/s", "kg*m/s^2", "N·m"
|
|
391
|
+
- Exponents: "m²", "m^2", "s⁻¹", "s^-1"
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
name: Unit string to parse.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Unit for simple unscaled units, UnitProduct for scaled or composite.
|
|
398
|
+
|
|
399
|
+
Raises:
|
|
400
|
+
UnknownUnitError: If the unit cannot be resolved.
|
|
401
|
+
|
|
402
|
+
Examples:
|
|
403
|
+
>>> get_unit_by_name("meter")
|
|
404
|
+
<Unit m>
|
|
405
|
+
>>> get_unit_by_name("km")
|
|
406
|
+
<UnitProduct km>
|
|
407
|
+
>>> get_unit_by_name("m/s^2")
|
|
408
|
+
<UnitProduct m/s²>
|
|
409
|
+
"""
|
|
410
|
+
if not name or not name.strip():
|
|
411
|
+
raise UnknownUnitError(name if name else "")
|
|
412
|
+
|
|
413
|
+
name = name.strip()
|
|
414
|
+
|
|
415
|
+
# Check for composite (has operators)
|
|
416
|
+
if '/' in name or '·' in name or '*' in name:
|
|
417
|
+
return _parse_composite(name)
|
|
418
|
+
|
|
419
|
+
# Check for exponent
|
|
420
|
+
base_str, exp = _parse_exponent(name)
|
|
421
|
+
if exp != 1.0:
|
|
422
|
+
unit, scale = _lookup_factor(base_str)
|
|
423
|
+
return UnitProduct({UnitFactor(unit, scale): exp})
|
|
424
|
+
|
|
425
|
+
# Simple unit or scaled unit
|
|
426
|
+
unit, scale = _lookup_factor(name)
|
|
427
|
+
if scale == Scale.one:
|
|
428
|
+
return unit
|
|
429
|
+
else:
|
|
430
|
+
return UnitProduct({UnitFactor(unit, scale): 1})
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# Build the registry at module load time
|
|
434
|
+
_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.0
|
|
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.
|
|
@@ -92,7 +96,11 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
|
|
|
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
97
|
| **`Map`** hierarchy | `ucon.maps` | Composable conversion morphisms: `LinearMap`, `AffineMap`, `ComposedMap`. | Defining conversion functions between units (e.g., meter→foot, celsius→kelvin). |
|
|
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. |
|
|
99
|
+
| **`UnitSystem`** | `ucon.core` | Named mapping from dimensions to base units (e.g., SI, Imperial). | Defining coherent unit systems; grouping base units by dimension. |
|
|
100
|
+
| **`BasisTransform`** | `ucon.core` | Matrix-based transformation between dimensional exponent spaces. | Converting between incompatible dimensional structures; exact arithmetic with `Fraction`. |
|
|
101
|
+
| **`RebasedUnit`** | `ucon.core` | A unit rebased to another system's dimension, preserving provenance. | Cross-basis conversions; tracking original unit through basis changes. |
|
|
95
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. |
|
|
96
104
|
|
|
97
105
|
### Under the Hood
|
|
98
106
|
|
|
@@ -139,35 +147,40 @@ Simple:
|
|
|
139
147
|
pip install ucon
|
|
140
148
|
```
|
|
141
149
|
|
|
150
|
+
With Pydantic v2 support:
|
|
151
|
+
```bash
|
|
152
|
+
pip install ucon[pydantic]
|
|
153
|
+
```
|
|
154
|
+
|
|
142
155
|
## Usage
|
|
143
156
|
|
|
144
|
-
|
|
157
|
+
### Quantities and Arithmetic
|
|
158
|
+
|
|
159
|
+
Dimensional analysis like this:
|
|
145
160
|
```
|
|
146
161
|
2 mL bromine | 3.119 g bromine
|
|
147
162
|
--------------x----------------- #=> 6.238 g bromine
|
|
148
163
|
1 | 1 mL bromine
|
|
149
164
|
```
|
|
150
|
-
becomes straightforward
|
|
165
|
+
becomes straightforward:
|
|
151
166
|
```python
|
|
152
167
|
from ucon import Number, Scale, units
|
|
153
168
|
from ucon.quantity import Ratio
|
|
154
169
|
|
|
155
|
-
# Two milliliters of bromine
|
|
156
170
|
mL = Scale.milli * units.liter
|
|
157
171
|
two_mL_bromine = Number(quantity=2, unit=mL)
|
|
158
172
|
|
|
159
|
-
# Density of bromine: 3.119 g/mL
|
|
160
173
|
bromine_density = Ratio(
|
|
161
174
|
numerator=Number(unit=units.gram, quantity=3.119),
|
|
162
175
|
denominator=Number(unit=mL),
|
|
163
176
|
)
|
|
164
177
|
|
|
165
|
-
# Multiply to find mass
|
|
166
178
|
grams_bromine = bromine_density.evaluate() * two_mL_bromine
|
|
167
179
|
print(grams_bromine) # <6.238 g>
|
|
168
180
|
```
|
|
169
181
|
|
|
170
|
-
Scale
|
|
182
|
+
### Scale Prefixes
|
|
183
|
+
|
|
171
184
|
```python
|
|
172
185
|
km = Scale.kilo * units.meter # UnitProduct with kilo-scaled meter
|
|
173
186
|
mg = Scale.milli * units.gram # UnitProduct with milli-scaled gram
|
|
@@ -175,12 +188,12 @@ mg = Scale.milli * units.gram # UnitProduct with milli-scaled gram
|
|
|
175
188
|
print(km.shorthand) # 'km'
|
|
176
189
|
print(mg.shorthand) # 'mg'
|
|
177
190
|
|
|
178
|
-
# Scale arithmetic
|
|
179
191
|
print(km.fold_scale()) # 1000.0
|
|
180
192
|
print(mg.fold_scale()) # 0.001
|
|
181
193
|
```
|
|
182
194
|
|
|
183
|
-
|
|
195
|
+
### Callable Units and Conversion
|
|
196
|
+
|
|
184
197
|
```python
|
|
185
198
|
from ucon import units, Scale
|
|
186
199
|
|
|
@@ -199,16 +212,16 @@ distance_mi = distance.to(units.mile)
|
|
|
199
212
|
print(distance_mi) # <3.107... mi>
|
|
200
213
|
```
|
|
201
214
|
|
|
202
|
-
Dimensionless
|
|
215
|
+
### Dimensionless Units
|
|
216
|
+
|
|
217
|
+
Angles, solid angles, and ratios are semantically isolated:
|
|
203
218
|
```python
|
|
204
219
|
import math
|
|
205
220
|
from ucon import units
|
|
206
221
|
|
|
207
|
-
# Angle conversions
|
|
208
222
|
angle = units.radian(math.pi)
|
|
209
223
|
print(angle.to(units.degree)) # <180.0 deg>
|
|
210
224
|
|
|
211
|
-
# Ratio conversions
|
|
212
225
|
ratio = units.percent(50)
|
|
213
226
|
print(ratio.to(units.ppm)) # <500000.0 ppm>
|
|
214
227
|
|
|
@@ -216,25 +229,61 @@ print(ratio.to(units.ppm)) # <500000.0 ppm>
|
|
|
216
229
|
units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
217
230
|
```
|
|
218
231
|
|
|
219
|
-
Uncertainty
|
|
232
|
+
### Uncertainty Propagation
|
|
233
|
+
|
|
220
234
|
```python
|
|
221
235
|
from ucon import units
|
|
222
236
|
|
|
223
|
-
# Measurements with uncertainty
|
|
224
237
|
length = units.meter(1.234, uncertainty=0.005)
|
|
225
238
|
width = units.meter(0.567, uncertainty=0.003)
|
|
226
239
|
|
|
227
240
|
print(length) # <1.234 ± 0.005 m>
|
|
228
241
|
|
|
229
|
-
#
|
|
242
|
+
# Propagates through arithmetic (quadrature)
|
|
230
243
|
area = length * width
|
|
231
244
|
print(area) # <0.699678 ± 0.00424... m²>
|
|
232
245
|
|
|
233
|
-
#
|
|
246
|
+
# Propagates through conversion
|
|
234
247
|
length_ft = length.to(units.foot)
|
|
235
248
|
print(length_ft) # <4.048... ± 0.0164... ft>
|
|
236
249
|
```
|
|
237
250
|
|
|
251
|
+
### Pydantic Integration
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from pydantic import BaseModel
|
|
255
|
+
from ucon.pydantic import Number
|
|
256
|
+
from ucon import units
|
|
257
|
+
|
|
258
|
+
class Measurement(BaseModel):
|
|
259
|
+
value: Number
|
|
260
|
+
|
|
261
|
+
# From JSON/dict input
|
|
262
|
+
m = Measurement(value={"quantity": 5, "unit": "km"})
|
|
263
|
+
print(m.value) # <5 km>
|
|
264
|
+
|
|
265
|
+
# From Number instance
|
|
266
|
+
m2 = Measurement(value=units.meter(10))
|
|
267
|
+
|
|
268
|
+
# Serialize to JSON
|
|
269
|
+
print(m.model_dump_json())
|
|
270
|
+
# {"value": {"quantity": 5.0, "unit": "km", "uncertainty": null}}
|
|
271
|
+
|
|
272
|
+
# Supports both Unicode and ASCII unit notation
|
|
273
|
+
m3 = Measurement(value={"quantity": 9.8, "unit": "m/s^2"}) # ASCII
|
|
274
|
+
m4 = Measurement(value={"quantity": 9.8, "unit": "m/s²"}) # Unicode
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Design notes:**
|
|
278
|
+
- **Serialization format**: Units serialize as human-readable shorthand strings (`"km"`, `"m/s^2"`) rather than structured dicts, aligning with how scientists express units.
|
|
279
|
+
- **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.
|
|
280
|
+
|
|
281
|
+
### Custom Unit Systems
|
|
282
|
+
|
|
283
|
+
`BasisTransform` enables conversions between incompatible dimensional structures (e.g., fantasy game physics, CGS units, domain-specific systems).
|
|
284
|
+
|
|
285
|
+
See full example: [docs/examples/basis-transform-fantasy-units.md](./docs/examples/basis-transform-fantasy-units.md)
|
|
286
|
+
|
|
238
287
|
---
|
|
239
288
|
|
|
240
289
|
## Roadmap Highlights
|
|
@@ -245,8 +294,9 @@ print(length_ft) # <4.048... ± 0.0164... ft>
|
|
|
245
294
|
| **0.4.x** | Conversion System | `ConversionGraph`, `Number.to()`, callable units | ✅ Complete |
|
|
246
295
|
| **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
|
|
247
296
|
| **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
|
|
248
|
-
| **0.5.x** | Unit Systems | `
|
|
249
|
-
| **0.
|
|
297
|
+
| **0.5.x** | Unit Systems | `BasisTransform`, `UnitSystem`, cross-basis conversion | ✅ Complete |
|
|
298
|
+
| **0.6.x** | Pydantic Integration | Type-safe quantity validation, JSON serialization | ✅ Complete |
|
|
299
|
+
| **0.7.x** | NumPy Arrays | Vectorized conversion and arithmetic | ⏳ Planned |
|
|
250
300
|
|
|
251
301
|
See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
252
302
|
|
|
@@ -255,14 +305,21 @@ See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
|
255
305
|
## Contributing
|
|
256
306
|
|
|
257
307
|
Contributions, issues, and pull requests are welcome!
|
|
258
|
-
|
|
308
|
+
|
|
309
|
+
Set up your development environment:
|
|
310
|
+
```bash
|
|
311
|
+
make venv
|
|
312
|
+
source .ucon-3.12/bin/activate
|
|
259
313
|
```
|
|
260
|
-
|
|
314
|
+
|
|
315
|
+
Run the test suite before committing:
|
|
316
|
+
```bash
|
|
317
|
+
make test
|
|
261
318
|
```
|
|
262
|
-
Then run the full test suite (against all supported python versions) before committing:
|
|
263
319
|
|
|
320
|
+
Run tests across all supported Python versions:
|
|
264
321
|
```bash
|
|
265
|
-
|
|
322
|
+
make test-all
|
|
266
323
|
```
|
|
267
324
|
---
|
|
268
325
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
ucon/__init__.py,sha256=SAXuDNMmxzaLVr4JpUHsz-feQotVhpzsCUj2WiXYA-0,2361
|
|
2
|
+
ucon/algebra.py,sha256=6QrPyD23L93XSrnIORcYEx2CLDv4WDcrh6H_hxeeOus,8668
|
|
3
|
+
ucon/core.py,sha256=V10GfKTf28GFgoO9NMI7ciqKHI_p8wG8qICR6u85gv0,63300
|
|
4
|
+
ucon/graph.py,sha256=Ec0Q2QiAGUm2RaxrKnpFHtwpNvTf4PYbvo62BWtGJG8,21159
|
|
5
|
+
ucon/maps.py,sha256=tWP4ayYCEazJzf81EP1_fmtADhg18D1eHldudAMEY0U,5460
|
|
6
|
+
ucon/pydantic.py,sha256=64ZR1EYFRnBGHj3VIF5pc3swdAiR2ZlYrgcntdbKN4k,5189
|
|
7
|
+
ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
|
|
8
|
+
ucon/units.py,sha256=mcZ6LtHPjP4RYtzWTbf-tdjKtazcM1bDmxTI_tKrMxk,15924
|
|
9
|
+
ucon/mcp/__init__.py,sha256=WoFOQ7JeDIzbjjkFIJ0Uv53VVLu-4lrjzG5vpVGGfT4,123
|
|
10
|
+
ucon/mcp/server.py,sha256=uUrdevEaR65Qjh9xn8Q-_IusNjPGxdkLF9iQmiSTs0g,7016
|
|
11
|
+
ucon-0.6.0.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
|
|
12
|
+
ucon-0.6.0.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
|
|
13
|
+
ucon-0.6.0.dist-info/METADATA,sha256=qdS_SH1wMJHnmWRTebu1VzWO3T935GmRMIcD9vjZ9XM,16375
|
|
14
|
+
ucon-0.6.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
15
|
+
ucon-0.6.0.dist-info/entry_points.txt,sha256=jbfLf0FbOulgGa0nM_sRiTNfiCAkJcHnSSK_oj3g0cQ,50
|
|
16
|
+
ucon-0.6.0.dist-info/top_level.txt,sha256=Vv3KDuZ86fmH5yOYLbYap9DbBblK1YUkmlThffF71jA,5
|
|
17
|
+
ucon-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ucon
|
tests/ucon/__init__.py
DELETED
|
File without changes
|