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/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
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.5.1
4
- Summary: a tool for dimensional analysis: a "Unit CONverter"
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
- This sort of dimensional analysis:
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 when you define a measurement:
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 prefixes compose naturally:
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
- Units are callable for ergonomic quantity construction:
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 units have semantic isolation — angles, solid angles, and ratios are distinct:
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 propagates through arithmetic and conversions:
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
- # Uncertainty propagates through arithmetic (quadrature)
242
+ # Propagates through arithmetic (quadrature)
230
243
  area = length * width
231
244
  print(area) # <0.699678 ± 0.00424... m²>
232
245
 
233
- # Uncertainty propagates through conversion
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 | `BasisMap`, `UnitSystem` | 🚧 In Progress |
249
- | **0.7.x** | Pydantic Integration | Type-safe quantity validation | Planned |
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
- Ensure `nox` is installed.
308
+
309
+ Set up your development environment:
310
+ ```bash
311
+ make venv
312
+ source .ucon-3.12/bin/activate
259
313
  ```
260
- pip install -r requirements.txt
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
- nox -s test
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,2 @@
1
+ [console_scripts]
2
+ ucon-mcp = ucon.mcp.server:main
@@ -0,0 +1 @@
1
+ ucon
tests/ucon/__init__.py DELETED
@@ -1,3 +0,0 @@
1
- # © 2025 The Radiativity Company
2
- # Licensed under the Apache License, Version 2.0
3
- # See the LICENSE file for details.
File without changes