ucon 0.5.2__tar.gz → 0.6.0__tar.gz

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.
Files changed (72) hide show
  1. {ucon-0.5.2 → ucon-0.6.0}/.github/workflows/publish.yaml +5 -5
  2. {ucon-0.5.2 → ucon-0.6.0}/.github/workflows/tests.yaml +4 -6
  3. ucon-0.6.0/Makefile +129 -0
  4. {ucon-0.5.2 → ucon-0.6.0}/PKG-INFO +73 -97
  5. {ucon-0.5.2 → ucon-0.6.0}/README.md +60 -87
  6. {ucon-0.5.2 → ucon-0.6.0}/ROADMAP.md +17 -12
  7. ucon-0.5.2/docs/decisions/unity-distance-metric-for-nearest-scale.md → ucon-0.6.0/docs/decisions/001-unity-distance-metric-for-nearest-scale.md +35 -17
  8. ucon-0.6.0/docs/decisions/002-composite-units.md +116 -0
  9. ucon-0.6.0/docs/decisions/003-composable-unit-algebra.md +123 -0
  10. ucon-0.5.2/docs/decisions/unit-algebra-naming.md → ucon-0.6.0/docs/decisions/004-unit-algebra-naming.md +49 -48
  11. ucon-0.5.2/docs/decisions/pseudo-dimension-tuple-values.md → ucon-0.6.0/docs/decisions/005-pseudo-dimension-tuple-values.md +39 -75
  12. ucon-0.6.0/docs/decisions/006-pydantic-integration-pattern.md +92 -0
  13. ucon-0.6.0/docs/examples/basis-transform-fantasy-units.md +89 -0
  14. ucon-0.6.0/pyproject.toml +86 -0
  15. ucon-0.6.0/requirements.txt +1 -0
  16. {ucon-0.5.2 → ucon-0.6.0}/setup.py +9 -0
  17. ucon-0.6.0/tests/ucon/mcp/__init__.py +1 -0
  18. ucon-0.6.0/tests/ucon/mcp/test_server.py +351 -0
  19. ucon-0.6.0/tests/ucon/test_pickle.py +197 -0
  20. ucon-0.6.0/tests/ucon/test_pydantic.py +350 -0
  21. ucon-0.6.0/tests/ucon/test_unit_parsing.py +290 -0
  22. {ucon-0.5.2 → ucon-0.6.0}/ucon/__init__.py +4 -1
  23. {ucon-0.5.2 → ucon-0.6.0}/ucon/core.py +7 -2
  24. ucon-0.6.0/ucon/mcp/__init__.py +8 -0
  25. ucon-0.6.0/ucon/mcp/server.py +250 -0
  26. ucon-0.6.0/ucon/pydantic.py +199 -0
  27. {ucon-0.5.2 → ucon-0.6.0}/ucon/units.py +259 -11
  28. {ucon-0.5.2 → ucon-0.6.0}/ucon.egg-info/PKG-INFO +73 -97
  29. {ucon-0.5.2 → ucon-0.6.0}/ucon.egg-info/SOURCES.txt +21 -7
  30. ucon-0.6.0/ucon.egg-info/entry_points.txt +2 -0
  31. ucon-0.6.0/ucon.egg-info/requires.txt +11 -0
  32. ucon-0.6.0/ucon.egg-info/top_level.txt +1 -0
  33. ucon-0.6.0/uv.lock +1603 -0
  34. ucon-0.5.2/docs/decisions/composable-unit-algebra.md +0 -241
  35. ucon-0.5.2/docs/decisions/composite-units.md +0 -160
  36. ucon-0.5.2/noxfile.py +0 -132
  37. ucon-0.5.2/requirements.txt +0 -2
  38. ucon-0.5.2/ucon.egg-info/top_level.txt +0 -2
  39. {ucon-0.5.2 → ucon-0.6.0}/.gitignore +0 -0
  40. {ucon-0.5.2 → ucon-0.6.0}/LICENSE +0 -0
  41. {ucon-0.5.2 → ucon-0.6.0}/NOTICE +0 -0
  42. {ucon-0.5.2 → ucon-0.6.0}/docs/explainers/exponent-scale-relationship.md +0 -0
  43. {ucon-0.5.2 → ucon-0.6.0}/docs/explainers/type-operation-matrix.md +0 -0
  44. {ucon-0.5.2 → ucon-0.6.0}/docs/explainers/why-algebraic-closure-matters.md +0 -0
  45. {ucon-0.5.2 → ucon-0.6.0}/docs/explainers/why-type-safety-matters.md +0 -0
  46. {ucon-0.5.2 → ucon-0.6.0}/docs/proposals/interface-unifying-the-value-layer.md +0 -0
  47. {ucon-0.5.2 → ucon-0.6.0}/docs/proposals/project_unified-algebraic-core.md +0 -0
  48. {ucon-0.5.2 → ucon-0.6.0}/docs/proposals/support-for-fractional-exponents.md +0 -0
  49. {ucon-0.5.2 → ucon-0.6.0}/docs/proposals/unified-unit-presentation.md +0 -0
  50. {ucon-0.5.2 → ucon-0.6.0}/setup.cfg +0 -0
  51. {ucon-0.5.2 → ucon-0.6.0}/tests/__init__.py +0 -0
  52. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/__init__.py +0 -0
  53. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/conversion/__init__.py +0 -0
  54. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/conversion/test_graph.py +0 -0
  55. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/conversion/test_map.py +0 -0
  56. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_algebra.py +0 -0
  57. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_basis_transform.py +0 -0
  58. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_core.py +0 -0
  59. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_default_graph_conversions.py +0 -0
  60. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_dimensionless_units.py +0 -0
  61. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_graph_basis_transform.py +0 -0
  62. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_quantity.py +0 -0
  63. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_rebased_unit.py +0 -0
  64. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_uncertainty.py +0 -0
  65. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_unit_system.py +0 -0
  66. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_units.py +0 -0
  67. {ucon-0.5.2 → ucon-0.6.0}/tests/ucon/test_vector_fraction.py +0 -0
  68. {ucon-0.5.2 → ucon-0.6.0}/ucon/algebra.py +0 -0
  69. {ucon-0.5.2 → ucon-0.6.0}/ucon/graph.py +0 -0
  70. {ucon-0.5.2 → ucon-0.6.0}/ucon/maps.py +0 -0
  71. {ucon-0.5.2 → ucon-0.6.0}/ucon/quantity.py +0 -0
  72. {ucon-0.5.2 → ucon-0.6.0}/ucon.egg-info/dependency_links.txt +0 -0
@@ -16,16 +16,16 @@ jobs:
16
16
  fetch-depth: 0 # needed for ancestry check
17
17
  ref: ${{ github.ref_name }}
18
18
 
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v5
21
+
19
22
  - name: Set up Python
20
23
  uses: actions/setup-python@v5
21
24
  with:
22
25
  python-version: '3.14'
23
26
 
24
- - name: Install dependencies
25
- run: pip install -r requirements.txt
26
-
27
27
  - name: Build distribution 📦
28
- run: PYTHONWARNINGS=ignore LOCAL_VERSION_SCHEME=true nox -s build
28
+ run: LOCAL_VERSION_SCHEME=true make build
29
29
 
30
30
  - name: Publish to Test PyPI
31
31
  if: github.ref == 'refs/heads/main'
@@ -39,7 +39,7 @@ jobs:
39
39
  - name: Verify tag is on mainline
40
40
  if: startsWith(github.ref, 'refs/tags/')
41
41
  run: |
42
- git fetch origin main --depth=1
42
+ git fetch origin main --unshallow 2>/dev/null || git fetch origin main
43
43
  TAG=${GITHUB_REF#refs/tags/}
44
44
  TAG_COMMIT=$(git rev-list -n 1 "$TAG")
45
45
  if ! git merge-base --is-ancestor "$TAG_COMMIT" origin/main; then
@@ -26,18 +26,16 @@ jobs:
26
26
  - name: Checkout code
27
27
  uses: actions/checkout@v5
28
28
 
29
+ - name: Install uv
30
+ uses: astral-sh/setup-uv@v5
31
+
29
32
  - name: Set up Python ${{ matrix.python-version }}
30
33
  uses: actions/setup-python@v6
31
34
  with:
32
35
  python-version: ${{ matrix.python-version }}
33
36
 
34
- - name: Install dependencies
35
- run: |
36
- python -m pip install --upgrade pip
37
- pip install -r requirements.txt
38
-
39
37
  - name: Run tests
40
- run: nox -s test
38
+ run: make test PYTHON=${{ matrix.python-version }}
41
39
 
42
40
  - name: Upload coverage to Codecov
43
41
  if: success() && github.repository_owner == 'withtwoemms'
ucon-0.6.0/Makefile ADDED
@@ -0,0 +1,129 @@
1
+ # © 2025 The Radiativity Company
2
+ # Licensed under the Apache License, Version 2.0
3
+ # See the LICENSE file for details.
4
+
5
+ # --- Configuration ---
6
+ PROJECTNAME := ucon
7
+ PYTHON ?= 3.12
8
+ SUPPORTED_PYTHONS := 3.7 3.8 3.9 3.10 3.11 3.12 3.13 3.14
9
+ UV_VENV ?= .${PROJECTNAME}-${PYTHON}
10
+ UV_INSTALLED := .uv-installed
11
+ DEPS_INSTALLED := ${UV_VENV}/.deps-installed
12
+ TESTDIR := tests/
13
+ TESTNAME ?=
14
+ COVERAGE ?= true
15
+
16
+ # --- Color Setup ---
17
+ GREEN := \033[0;32m
18
+ CYAN := \033[0;36m
19
+ YELLOW := \033[1;33m
20
+ RESET := \033[0m
21
+
22
+ # --- Help Command ---
23
+ .PHONY: help
24
+ help:
25
+ @echo "\n${YELLOW}ucon Development Commands${RESET}\n"
26
+ @echo " ${CYAN}install${RESET} - Install package with all extras"
27
+ @echo " ${CYAN}install-test${RESET} - Install with test dependencies only"
28
+ @echo " ${CYAN}test${RESET} - Run tests (PYTHON=X.Y for specific version)"
29
+ @echo " ${CYAN}test-all${RESET} - Run tests across all supported Python versions"
30
+ @echo " ${CYAN}coverage${RESET} - Generate coverage report"
31
+ @echo " ${CYAN}build${RESET} - Build source and wheel distributions"
32
+ @echo " ${CYAN}venv${RESET} - Create virtual environment"
33
+ @echo " ${CYAN}clean${RESET} - Remove build artifacts and caches"
34
+ @echo ""
35
+ @echo "${YELLOW}Variables:${RESET}\n"
36
+ @echo " PYTHON=${PYTHON} - Python version for test target"
37
+ @echo " UV_VENV=${UV_VENV} - Path to virtual environment"
38
+ @echo " TESTNAME= - Specific test to run (e.g., tests.ucon.test_core)"
39
+ @echo " COVERAGE=${COVERAGE} - Enable coverage (true/false)"
40
+ @echo ""
41
+
42
+ # --- uv Installation ---
43
+ ${UV_INSTALLED}:
44
+ @command -v uv >/dev/null 2>&1 || { \
45
+ echo "${GREEN}Installing uv...${RESET}"; \
46
+ curl -LsSf https://astral.sh/uv/install.sh | sh; \
47
+ }
48
+ @touch ${UV_INSTALLED}
49
+
50
+ # --- Virtual Environment ---
51
+ ${UV_VENV}: ${UV_INSTALLED}
52
+ @echo "${GREEN}Creating virtual environment at ${UV_VENV}...${RESET}"
53
+ @uv venv --python ${PYTHON} ${UV_VENV}
54
+
55
+ ${DEPS_INSTALLED}: pyproject.toml uv.lock | ${UV_VENV}
56
+ @echo "${GREEN}Syncing dependencies into ${UV_VENV}...${RESET}"
57
+ @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv sync --python ${PYTHON} --extra test --extra pydantic --extra mcp
58
+ @touch ${DEPS_INSTALLED}
59
+
60
+ .PHONY: venv
61
+ venv: ${DEPS_INSTALLED}
62
+ @echo "${CYAN}Virtual environment ready at ${UV_VENV}${RESET}"
63
+ @echo "${CYAN}Activate with:${RESET} source ${UV_VENV}/bin/activate"
64
+
65
+ # --- Installation ---
66
+ .PHONY: install-test
67
+ install-test: ${UV_VENV}
68
+ @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv sync --python ${PYTHON} --extra test
69
+
70
+ .PHONY: install
71
+ install: ${UV_VENV}
72
+ @echo "${GREEN}Installing with all extras into ${UV_VENV}...${RESET}"
73
+ @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv sync --python ${PYTHON} --extra test --extra pydantic --extra mcp
74
+
75
+ # --- Testing ---
76
+ .PHONY: test
77
+ test: ${DEPS_INSTALLED}
78
+ @echo "${GREEN}Running tests with Python ${PYTHON}...${RESET}"
79
+ ifeq ($(COVERAGE),true)
80
+ @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv run --python ${PYTHON} coverage run --source=ucon --branch \
81
+ --omit="**/tests/*,**/site-packages/*.py,setup.py" \
82
+ -m unittest $(if $(TESTNAME),$(TESTNAME),discover --start-directory ${TESTDIR} --top-level-directory .)
83
+ @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv run --python ${PYTHON} coverage report -m
84
+ @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv run --python ${PYTHON} coverage xml
85
+ else
86
+ @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv run --python ${PYTHON} -m unittest \
87
+ $(if $(TESTNAME),$(TESTNAME),discover --start-directory ${TESTDIR} --top-level-directory .)
88
+ endif
89
+
90
+ .PHONY: test-all
91
+ test-all: ${UV_INSTALLED}
92
+ @echo "${GREEN}Running tests across all supported Python versions...${RESET}"
93
+ @for pyver in $(SUPPORTED_PYTHONS); do \
94
+ echo "\n${CYAN}=== Python $$pyver ===${RESET}"; \
95
+ uv run --python $$pyver -m unittest discover \
96
+ --start-directory ${TESTDIR} --top-level-directory . \
97
+ || echo "${YELLOW}Python $$pyver: FAILED or not available${RESET}"; \
98
+ done
99
+
100
+ # --- Coverage ---
101
+ .PHONY: coverage
102
+ coverage: ${DEPS_INSTALLED}
103
+ @echo "${GREEN}Generating coverage report...${RESET}"
104
+ @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv run --python ${PYTHON} coverage report -m
105
+ @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv run --python ${PYTHON} coverage html
106
+ @echo "${CYAN}HTML report at htmlcov/index.html${RESET}"
107
+
108
+ # --- Building ---
109
+ .PHONY: build
110
+ build: ${UV_INSTALLED}
111
+ @echo "${GREEN}Building distributions...${RESET}"
112
+ @uv build
113
+ @echo "${CYAN}Distributions at dist/${RESET}"
114
+
115
+ # --- Cleaning ---
116
+ .PHONY: clean
117
+ clean:
118
+ @echo "${GREEN}Cleaning build artifacts...${RESET}"
119
+ @rm -rf dist/ build/ *.egg-info/
120
+ @rm -rf ${UV_VENV} ${DEPS_INSTALLED} ${UV_INSTALLED}
121
+ @rm -rf .uv_cache .pytest_cache htmlcov/
122
+ @rm -f coverage.xml .coverage
123
+ @find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
124
+ @echo "${CYAN}Clean complete.${RESET}"
125
+
126
+ .PHONY: clean-all
127
+ clean-all: clean
128
+ @echo "${YELLOW}Removing uv.lock...${RESET}"
129
+ @rm -f uv.lock
@@ -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.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.
@@ -96,6 +100,7 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
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,16 +212,16 @@ 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
 
@@ -219,101 +229,60 @@ print(ratio.to(units.ppm)) # <500000.0 ppm>
219
229
  units.radian(1).to(units.percent) # raises ConversionNotFound
220
230
  ```
221
231
 
222
- Uncertainty propagates through arithmetic and conversions:
232
+ ### Uncertainty Propagation
233
+
223
234
  ```python
224
235
  from ucon import units
225
236
 
226
- # Measurements with uncertainty
227
237
  length = units.meter(1.234, uncertainty=0.005)
228
238
  width = units.meter(0.567, uncertainty=0.003)
229
239
 
230
240
  print(length) # <1.234 ± 0.005 m>
231
241
 
232
- # Uncertainty propagates through arithmetic (quadrature)
242
+ # Propagates through arithmetic (quadrature)
233
243
  area = length * width
234
244
  print(area) # <0.699678 ± 0.00424... m²>
235
245
 
236
- # Uncertainty propagates through conversion
246
+ # Propagates through conversion
237
247
  length_ft = length.to(units.foot)
238
248
  print(length_ft) # <4.048... ± 0.0164... ft>
239
249
  ```
240
250
 
241
- Unit systems and basis transforms enable conversions between incompatible dimensional structures.
242
- This goes beyond simple unit conversion (meter → foot) into structural transformation:
251
+ ### Pydantic Integration
243
252
 
244
253
  ```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
- )
254
+ from pydantic import BaseModel
255
+ from ucon.pydantic import Number
256
+ from ucon import units
279
257
 
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
- )
258
+ class Measurement(BaseModel):
259
+ value: Number
292
260
 
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
- )
261
+ # From JSON/dict input
262
+ m = Measurement(value={"quantity": 5, "unit": "km"})
263
+ print(m.value) # <5 km>
303
264
 
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
265
+ # From Number instance
266
+ m2 = Measurement(value=units.meter(10))
307
267
 
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
268
+ # Serialize to JSON
269
+ print(m.model_dump_json())
270
+ # {"value": {"quantity": 5.0, "unit": "km", "uncertainty": null}}
311
271
 
312
- # The transform is invertible with exact Fraction arithmetic
313
- valdris_to_si.is_invertible # True
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
314
275
  ```
315
276
 
316
- This enables fantasy game physics, or any field where the dimensional structure differs from SI.
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)
317
286
 
318
287
  ---
319
288
 
@@ -326,7 +295,7 @@ This enables fantasy game physics, or any field where the dimensional structure
326
295
  | **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
327
296
  | **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
328
297
  | **0.5.x** | Unit Systems | `BasisTransform`, `UnitSystem`, cross-basis conversion | ✅ Complete |
329
- | **0.6.x** | Pydantic Integration | Type-safe quantity validation | Planned |
298
+ | **0.6.x** | Pydantic Integration | Type-safe quantity validation, JSON serialization | Complete |
330
299
  | **0.7.x** | NumPy Arrays | Vectorized conversion and arithmetic | ⏳ Planned |
331
300
 
332
301
  See full roadmap: [ROADMAP.md](./ROADMAP.md)
@@ -336,14 +305,21 @@ See full roadmap: [ROADMAP.md](./ROADMAP.md)
336
305
  ## Contributing
337
306
 
338
307
  Contributions, issues, and pull requests are welcome!
339
- Ensure `nox` is installed.
308
+
309
+ Set up your development environment:
310
+ ```bash
311
+ make venv
312
+ source .ucon-3.12/bin/activate
340
313
  ```
341
- pip install -r requirements.txt
314
+
315
+ Run the test suite before committing:
316
+ ```bash
317
+ make test
342
318
  ```
343
- Then run the full test suite (against all supported python versions) before committing:
344
319
 
320
+ Run tests across all supported Python versions:
345
321
  ```bash
346
- nox -s test
322
+ make test-all
347
323
  ```
348
324
  ---
349
325