ucon 0.5.0__py3-none-any.whl → 0.5.2__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.
- tests/ucon/test_basis_transform.py +521 -0
- tests/ucon/test_graph_basis_transform.py +263 -0
- tests/ucon/test_rebased_unit.py +184 -0
- tests/ucon/test_uncertainty.py +264 -0
- tests/ucon/test_unit_system.py +174 -0
- tests/ucon/test_vector_fraction.py +185 -0
- ucon/__init__.py +20 -2
- ucon/algebra.py +36 -14
- ucon/core.py +558 -12
- ucon/graph.py +167 -10
- ucon/maps.py +22 -1
- ucon/units.py +28 -1
- {ucon-0.5.0.dist-info → ucon-0.5.2.dist-info}/METADATA +105 -3
- ucon-0.5.2.dist-info/RECORD +29 -0
- ucon-0.5.0.dist-info/RECORD +0 -23
- {ucon-0.5.0.dist-info → ucon-0.5.2.dist-info}/WHEEL +0 -0
- {ucon-0.5.0.dist-info → ucon-0.5.2.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.5.0.dist-info → ucon-0.5.2.dist-info}/licenses/NOTICE +0 -0
- {ucon-0.5.0.dist-info → ucon-0.5.2.dist-info}/top_level.txt +0 -0
ucon/graph.py
CHANGED
|
@@ -28,7 +28,15 @@ from contextvars import ContextVar
|
|
|
28
28
|
from dataclasses import dataclass, field
|
|
29
29
|
from typing import Union
|
|
30
30
|
|
|
31
|
-
from ucon.core import
|
|
31
|
+
from ucon.core import (
|
|
32
|
+
BasisTransform,
|
|
33
|
+
Dimension,
|
|
34
|
+
RebasedUnit,
|
|
35
|
+
Unit,
|
|
36
|
+
UnitFactor,
|
|
37
|
+
UnitProduct,
|
|
38
|
+
Scale,
|
|
39
|
+
)
|
|
32
40
|
from ucon.maps import Map, LinearMap, AffineMap
|
|
33
41
|
|
|
34
42
|
|
|
@@ -66,6 +74,9 @@ class ConversionGraph:
|
|
|
66
74
|
# Edges between UnitProducts (keyed by frozen factor representation)
|
|
67
75
|
_product_edges: dict[tuple, dict[tuple, Map]] = field(default_factory=dict)
|
|
68
76
|
|
|
77
|
+
# Rebased units: original unit → RebasedUnit (for cross-basis edges)
|
|
78
|
+
_rebased: dict[Unit, RebasedUnit] = field(default_factory=dict)
|
|
79
|
+
|
|
69
80
|
# ------------- Edge Management -------------------------------------------
|
|
70
81
|
|
|
71
82
|
def add_edge(
|
|
@@ -74,6 +85,7 @@ class ConversionGraph:
|
|
|
74
85
|
src: Union[Unit, UnitProduct],
|
|
75
86
|
dst: Union[Unit, UnitProduct],
|
|
76
87
|
map: Map,
|
|
88
|
+
basis_transform: BasisTransform | None = None,
|
|
77
89
|
) -> None:
|
|
78
90
|
"""Register a conversion edge. Also registers the inverse.
|
|
79
91
|
|
|
@@ -85,15 +97,31 @@ class ConversionGraph:
|
|
|
85
97
|
Destination unit expression.
|
|
86
98
|
map : Map
|
|
87
99
|
The conversion morphism (src → dst).
|
|
100
|
+
basis_transform : BasisTransform, optional
|
|
101
|
+
If provided, creates a cross-basis edge. The src unit is rebased
|
|
102
|
+
to the dst's dimension and the edge connects the rebased unit
|
|
103
|
+
to dst.
|
|
88
104
|
|
|
89
105
|
Raises
|
|
90
106
|
------
|
|
91
107
|
DimensionMismatch
|
|
92
|
-
If src and dst have different dimensions.
|
|
108
|
+
If src and dst have different dimensions (and no basis_transform).
|
|
93
109
|
CyclicInconsistency
|
|
94
110
|
If the reverse edge exists and round-trip is not identity.
|
|
95
111
|
"""
|
|
96
|
-
#
|
|
112
|
+
# Cross-basis edge with BasisTransform
|
|
113
|
+
if basis_transform is not None:
|
|
114
|
+
if isinstance(src, Unit) and not isinstance(src, UnitProduct):
|
|
115
|
+
if isinstance(dst, Unit) and not isinstance(dst, UnitProduct):
|
|
116
|
+
self._add_cross_basis_edge(
|
|
117
|
+
src=src,
|
|
118
|
+
dst=dst,
|
|
119
|
+
map=map,
|
|
120
|
+
basis_transform=basis_transform,
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Handle Unit vs UnitProduct dispatch (normal case)
|
|
97
125
|
if isinstance(src, Unit) and not isinstance(src, UnitProduct):
|
|
98
126
|
if isinstance(dst, Unit) and not isinstance(dst, UnitProduct):
|
|
99
127
|
self._add_unit_edge(src=src, dst=dst, map=map)
|
|
@@ -142,6 +170,114 @@ class ConversionGraph:
|
|
|
142
170
|
self._product_edges.setdefault(src_key, {})[dst_key] = map
|
|
143
171
|
self._product_edges.setdefault(dst_key, {})[src_key] = map.inverse()
|
|
144
172
|
|
|
173
|
+
def _add_cross_basis_edge(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
src: Unit,
|
|
177
|
+
dst: Unit,
|
|
178
|
+
map: Map,
|
|
179
|
+
basis_transform: BasisTransform,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Add cross-basis edge between Units via BasisTransform.
|
|
182
|
+
|
|
183
|
+
Creates a RebasedUnit for src in the destination's dimension partition,
|
|
184
|
+
then stores the edge from the rebased unit to dst.
|
|
185
|
+
"""
|
|
186
|
+
# Validate that the transform maps src to dst's dimension
|
|
187
|
+
if not basis_transform.validate_edge(src, dst):
|
|
188
|
+
raise DimensionMismatch(
|
|
189
|
+
f"Transform {basis_transform.src.name} -> {basis_transform.dst.name} "
|
|
190
|
+
f"does not map {src.name} to {dst.name}'s dimension"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Create RebasedUnit in destination's dimension partition
|
|
194
|
+
rebased = RebasedUnit(
|
|
195
|
+
original=src,
|
|
196
|
+
rebased_dimension=dst.dimension,
|
|
197
|
+
basis_transform=basis_transform,
|
|
198
|
+
)
|
|
199
|
+
self._rebased[src] = rebased
|
|
200
|
+
|
|
201
|
+
# Store edge from rebased to dst (same dimension now)
|
|
202
|
+
dim = dst.dimension
|
|
203
|
+
self._ensure_dimension(dim)
|
|
204
|
+
self._unit_edges[dim].setdefault(rebased, {})[dst] = map
|
|
205
|
+
self._unit_edges[dim].setdefault(dst, {})[rebased] = map.inverse()
|
|
206
|
+
|
|
207
|
+
def connect_systems(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
basis_transform: BasisTransform,
|
|
211
|
+
edges: dict[tuple[Unit, Unit], Map],
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Bulk-add edges between systems.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
basis_transform : BasisTransform
|
|
218
|
+
The transform bridging the two systems.
|
|
219
|
+
edges : dict
|
|
220
|
+
Mapping from (src_unit, dst_unit) to Map.
|
|
221
|
+
"""
|
|
222
|
+
for (src, dst), edge_map in edges.items():
|
|
223
|
+
self.add_edge(
|
|
224
|
+
src=src,
|
|
225
|
+
dst=dst,
|
|
226
|
+
map=edge_map,
|
|
227
|
+
basis_transform=basis_transform,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def list_rebased_units(self) -> dict[Unit, RebasedUnit]:
|
|
231
|
+
"""Return all rebased units in the graph.
|
|
232
|
+
|
|
233
|
+
Returns
|
|
234
|
+
-------
|
|
235
|
+
dict[Unit, RebasedUnit]
|
|
236
|
+
Mapping from original unit to its RebasedUnit.
|
|
237
|
+
"""
|
|
238
|
+
return dict(self._rebased)
|
|
239
|
+
|
|
240
|
+
def list_transforms(self) -> list[BasisTransform]:
|
|
241
|
+
"""Return all BasisTransforms active in the graph.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
list[BasisTransform]
|
|
246
|
+
Unique transforms used by rebased units.
|
|
247
|
+
"""
|
|
248
|
+
seen = set()
|
|
249
|
+
result = []
|
|
250
|
+
for rebased in self._rebased.values():
|
|
251
|
+
bt = rebased.basis_transform
|
|
252
|
+
if id(bt) not in seen:
|
|
253
|
+
seen.add(id(bt))
|
|
254
|
+
result.append(bt)
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
def edges_for_transform(self, transform: BasisTransform) -> list[tuple[Unit, Unit]]:
|
|
258
|
+
"""Return all edges that use a specific BasisTransform.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
transform : BasisTransform
|
|
263
|
+
The transform to filter by.
|
|
264
|
+
|
|
265
|
+
Returns
|
|
266
|
+
-------
|
|
267
|
+
list[tuple[Unit, Unit]]
|
|
268
|
+
List of (original_unit, destination_unit) pairs.
|
|
269
|
+
"""
|
|
270
|
+
result = []
|
|
271
|
+
for original, rebased in self._rebased.items():
|
|
272
|
+
if rebased.basis_transform == transform:
|
|
273
|
+
# Find the destination unit (the one the rebased unit connects to)
|
|
274
|
+
dim = rebased.dimension
|
|
275
|
+
if dim in self._unit_edges and rebased in self._unit_edges[dim]:
|
|
276
|
+
for dst in self._unit_edges[dim][rebased]:
|
|
277
|
+
if not isinstance(dst, RebasedUnit):
|
|
278
|
+
result.append((original, dst))
|
|
279
|
+
return result
|
|
280
|
+
|
|
145
281
|
def _ensure_dimension(self, dim: Dimension) -> None:
|
|
146
282
|
if dim not in self._unit_edges:
|
|
147
283
|
self._unit_edges[dim] = {}
|
|
@@ -206,10 +342,28 @@ class ConversionGraph:
|
|
|
206
342
|
return self._convert_products(src=src_prod, dst=dst_prod)
|
|
207
343
|
|
|
208
344
|
def _convert_units(self, *, src: Unit, dst: Unit) -> Map:
|
|
209
|
-
"""Convert between plain Units via BFS.
|
|
345
|
+
"""Convert between plain Units via BFS.
|
|
346
|
+
|
|
347
|
+
Handles cross-basis conversions via rebased units.
|
|
348
|
+
"""
|
|
210
349
|
if src == dst:
|
|
211
350
|
return LinearMap.identity()
|
|
212
351
|
|
|
352
|
+
# Check if src has a rebased version that can reach dst
|
|
353
|
+
if src in self._rebased:
|
|
354
|
+
rebased = self._rebased[src]
|
|
355
|
+
if rebased.dimension == dst.dimension:
|
|
356
|
+
# Convert via the rebased unit
|
|
357
|
+
return self._bfs_convert(start=rebased, target=dst, dim=dst.dimension)
|
|
358
|
+
|
|
359
|
+
# Check if dst has a rebased version (inverse conversion)
|
|
360
|
+
if dst in self._rebased:
|
|
361
|
+
rebased_dst = self._rebased[dst]
|
|
362
|
+
if rebased_dst.dimension == src.dimension:
|
|
363
|
+
# Convert from src to the rebased dst
|
|
364
|
+
return self._bfs_convert(start=src, target=rebased_dst, dim=src.dimension)
|
|
365
|
+
|
|
366
|
+
# Check for dimension mismatch
|
|
213
367
|
if src.dimension != dst.dimension:
|
|
214
368
|
raise DimensionMismatch(f"{src.dimension} != {dst.dimension}")
|
|
215
369
|
|
|
@@ -217,13 +371,16 @@ class ConversionGraph:
|
|
|
217
371
|
if self._has_direct_unit_edge(src=src, dst=dst):
|
|
218
372
|
return self._get_direct_unit_edge(src=src, dst=dst)
|
|
219
373
|
|
|
220
|
-
# BFS
|
|
221
|
-
|
|
374
|
+
# BFS in same dimension
|
|
375
|
+
return self._bfs_convert(start=src, target=dst, dim=src.dimension)
|
|
376
|
+
|
|
377
|
+
def _bfs_convert(self, *, start, target, dim: Dimension) -> Map:
|
|
378
|
+
"""BFS to find conversion path within a dimension."""
|
|
222
379
|
if dim not in self._unit_edges:
|
|
223
380
|
raise ConversionNotFound(f"No edges for dimension {dim}")
|
|
224
381
|
|
|
225
|
-
visited: dict
|
|
226
|
-
queue = deque([
|
|
382
|
+
visited: dict = {start: LinearMap.identity()}
|
|
383
|
+
queue = deque([start])
|
|
227
384
|
|
|
228
385
|
while queue:
|
|
229
386
|
current = queue.popleft()
|
|
@@ -239,12 +396,12 @@ class ConversionGraph:
|
|
|
239
396
|
composed = edge_map @ current_map
|
|
240
397
|
visited[neighbor] = composed
|
|
241
398
|
|
|
242
|
-
if neighbor ==
|
|
399
|
+
if neighbor == target:
|
|
243
400
|
return composed
|
|
244
401
|
|
|
245
402
|
queue.append(neighbor)
|
|
246
403
|
|
|
247
|
-
raise ConversionNotFound(f"No path from {
|
|
404
|
+
raise ConversionNotFound(f"No path from {start} to {target}")
|
|
248
405
|
|
|
249
406
|
def _convert_products(self, *, src: UnitProduct, dst: UnitProduct) -> Map:
|
|
250
407
|
"""Convert between UnitProducts.
|
ucon/maps.py
CHANGED
|
@@ -25,7 +25,7 @@ from dataclasses import dataclass
|
|
|
25
25
|
class Map(ABC):
|
|
26
26
|
"""Abstract base for all conversion morphisms.
|
|
27
27
|
|
|
28
|
-
Subclasses must implement ``__call__``, ``inverse``, and ``
|
|
28
|
+
Subclasses must implement ``__call__``, ``inverse``, ``__pow__``, and ``derivative``.
|
|
29
29
|
Composition via ``@`` defaults to :class:`ComposedMap`; subclasses may
|
|
30
30
|
override for closed composition within their own type.
|
|
31
31
|
"""
|
|
@@ -50,6 +50,14 @@ class Map(ABC):
|
|
|
50
50
|
"""Raise map to a power (for exponent handling in factorwise conversion)."""
|
|
51
51
|
...
|
|
52
52
|
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def derivative(self, x: float) -> float:
|
|
55
|
+
"""Return the derivative of the map at point x.
|
|
56
|
+
|
|
57
|
+
Used for uncertainty propagation: δy = |f'(x)| * δx
|
|
58
|
+
"""
|
|
59
|
+
...
|
|
60
|
+
|
|
53
61
|
def is_identity(self, tol: float = 1e-9) -> bool:
|
|
54
62
|
"""Check if this map is approximately the identity."""
|
|
55
63
|
return abs(self(1.0) - 1.0) < tol and abs(self(0.0) - 0.0) < tol
|
|
@@ -86,6 +94,10 @@ class LinearMap(Map):
|
|
|
86
94
|
def __pow__(self, exp: float) -> LinearMap:
|
|
87
95
|
return LinearMap(self.a ** exp)
|
|
88
96
|
|
|
97
|
+
def derivative(self, x: float) -> float:
|
|
98
|
+
"""Derivative of y = a*x is a (constant)."""
|
|
99
|
+
return self.a
|
|
100
|
+
|
|
89
101
|
@classmethod
|
|
90
102
|
def identity(cls) -> LinearMap:
|
|
91
103
|
return cls(1.0)
|
|
@@ -128,6 +140,10 @@ class AffineMap(Map):
|
|
|
128
140
|
return self.inverse()
|
|
129
141
|
raise ValueError("AffineMap only supports exp=1 or exp=-1")
|
|
130
142
|
|
|
143
|
+
def derivative(self, x: float) -> float:
|
|
144
|
+
"""Derivative of y = a*x + b is a (constant)."""
|
|
145
|
+
return self.a
|
|
146
|
+
|
|
131
147
|
|
|
132
148
|
@dataclass(frozen=True)
|
|
133
149
|
class ComposedMap(Map):
|
|
@@ -159,3 +175,8 @@ class ComposedMap(Map):
|
|
|
159
175
|
if exp == -1:
|
|
160
176
|
return self.inverse()
|
|
161
177
|
raise ValueError("ComposedMap only supports exp=1 or exp=-1")
|
|
178
|
+
|
|
179
|
+
def derivative(self, x: float) -> float:
|
|
180
|
+
"""Chain rule: d/dx [outer(inner(x))] = outer'(inner(x)) * inner'(x)."""
|
|
181
|
+
inner_val = self.inner(x)
|
|
182
|
+
return self.outer.derivative(inner_val) * self.inner.derivative(x)
|
ucon/units.py
CHANGED
|
@@ -28,7 +28,7 @@ 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
|
+
from ucon.core import Dimension, Unit, UnitSystem
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
none = Unit()
|
|
@@ -49,6 +49,7 @@ joule_per_kelvin = Unit(name='joule_per_kelvin', dimension=Dimension.entropy, al
|
|
|
49
49
|
kelvin = Unit(name='kelvin', dimension=Dimension.temperature, aliases=('K',))
|
|
50
50
|
kilogram = Unit(name='kilogram', dimension=Dimension.mass, aliases=('kg',))
|
|
51
51
|
liter = Unit(name='liter', dimension=Dimension.volume, aliases=('L', 'l'))
|
|
52
|
+
candela = Unit(name='candela', dimension=Dimension.luminous_intensity, aliases=('cd',))
|
|
52
53
|
lumen = Unit(name='lumen', dimension=Dimension.luminous_intensity, aliases=('lm',))
|
|
53
54
|
lux = Unit(name='lux', dimension=Dimension.illuminance, aliases=('lx',))
|
|
54
55
|
meter = Unit(name='meter', dimension=Dimension.length, aliases=('m',))
|
|
@@ -141,6 +142,32 @@ basis_point = Unit(name='basis_point', dimension=Dimension.ratio, aliases=('bp',
|
|
|
141
142
|
webers = weber
|
|
142
143
|
|
|
143
144
|
|
|
145
|
+
# -- Predefined Unit Systems -----------------------------------------------
|
|
146
|
+
si = UnitSystem(
|
|
147
|
+
name="SI",
|
|
148
|
+
bases={
|
|
149
|
+
Dimension.length: meter,
|
|
150
|
+
Dimension.mass: kilogram,
|
|
151
|
+
Dimension.time: second,
|
|
152
|
+
Dimension.temperature: kelvin,
|
|
153
|
+
Dimension.current: ampere,
|
|
154
|
+
Dimension.amount_of_substance: mole,
|
|
155
|
+
Dimension.luminous_intensity: candela,
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
imperial = UnitSystem(
|
|
160
|
+
name="Imperial",
|
|
161
|
+
bases={
|
|
162
|
+
Dimension.length: foot,
|
|
163
|
+
Dimension.mass: pound,
|
|
164
|
+
Dimension.time: second,
|
|
165
|
+
Dimension.temperature: fahrenheit,
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
# --------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
144
171
|
def have(name: str) -> bool:
|
|
145
172
|
assert name, "Must provide a unit name to check"
|
|
146
173
|
assert isinstance(name, str), "Unit name must be a string"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ucon
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
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
|
|
@@ -67,6 +67,7 @@ It combines **units**, **scales**, and **dimensions** into a composable algebra
|
|
|
67
67
|
- Scale-aware arithmetic via `UnitFactor` and `UnitProduct`
|
|
68
68
|
- Metric and binary prefixes (`kilo`, `kibi`, `micro`, `mebi`, etc.)
|
|
69
69
|
- Pseudo-dimensions for angles, solid angles, and ratios with semantic isolation
|
|
70
|
+
- Uncertainty propagation through arithmetic and conversions
|
|
70
71
|
- A clean foundation for physics, chemistry, data modeling, and beyond
|
|
71
72
|
|
|
72
73
|
Think of it as **`decimal.Decimal` for the physical world** — precise, predictable, and type-safe.
|
|
@@ -91,6 +92,9 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
|
|
|
91
92
|
| **`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). |
|
|
92
93
|
| **`Map`** hierarchy | `ucon.maps` | Composable conversion morphisms: `LinearMap`, `AffineMap`, `ComposedMap`. | Defining conversion functions between units (e.g., meter→foot, celsius→kelvin). |
|
|
93
94
|
| **`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
|
+
| **`UnitSystem`** | `ucon.core` | Named mapping from dimensions to base units (e.g., SI, Imperial). | Defining coherent unit systems; grouping base units by dimension. |
|
|
96
|
+
| **`BasisTransform`** | `ucon.core` | Matrix-based transformation between dimensional exponent spaces. | Converting between incompatible dimensional structures; exact arithmetic with `Fraction`. |
|
|
97
|
+
| **`RebasedUnit`** | `ucon.core` | A unit rebased to another system's dimension, preserving provenance. | Cross-basis conversions; tracking original unit through basis changes. |
|
|
94
98
|
| **`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.). |
|
|
95
99
|
|
|
96
100
|
### Under the Hood
|
|
@@ -215,6 +219,102 @@ print(ratio.to(units.ppm)) # <500000.0 ppm>
|
|
|
215
219
|
units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
216
220
|
```
|
|
217
221
|
|
|
222
|
+
Uncertainty propagates through arithmetic and conversions:
|
|
223
|
+
```python
|
|
224
|
+
from ucon import units
|
|
225
|
+
|
|
226
|
+
# Measurements with uncertainty
|
|
227
|
+
length = units.meter(1.234, uncertainty=0.005)
|
|
228
|
+
width = units.meter(0.567, uncertainty=0.003)
|
|
229
|
+
|
|
230
|
+
print(length) # <1.234 ± 0.005 m>
|
|
231
|
+
|
|
232
|
+
# Uncertainty propagates through arithmetic (quadrature)
|
|
233
|
+
area = length * width
|
|
234
|
+
print(area) # <0.699678 ± 0.00424... m²>
|
|
235
|
+
|
|
236
|
+
# Uncertainty propagates through conversion
|
|
237
|
+
length_ft = length.to(units.foot)
|
|
238
|
+
print(length_ft) # <4.048... ± 0.0164... ft>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Unit systems and basis transforms enable conversions between incompatible dimensional structures.
|
|
242
|
+
This goes beyond simple unit conversion (meter → foot) into structural transformation:
|
|
243
|
+
|
|
244
|
+
```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
|
+
)
|
|
279
|
+
|
|
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
|
+
)
|
|
292
|
+
|
|
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
|
+
)
|
|
303
|
+
|
|
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
|
|
307
|
+
|
|
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
|
|
311
|
+
|
|
312
|
+
# The transform is invertible with exact Fraction arithmetic
|
|
313
|
+
valdris_to_si.is_invertible # True
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
This enables fantasy game physics, or any field where the dimensional structure differs from SI.
|
|
317
|
+
|
|
218
318
|
---
|
|
219
319
|
|
|
220
320
|
## Roadmap Highlights
|
|
@@ -224,8 +324,10 @@ units.radian(1).to(units.percent) # raises ConversionNotFound
|
|
|
224
324
|
| **0.3.x** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
|
|
225
325
|
| **0.4.x** | Conversion System | `ConversionGraph`, `Number.to()`, callable units | ✅ Complete |
|
|
226
326
|
| **0.5.0** | Dimensionless Units | Pseudo-dimensions for angle, solid angle, ratio | ✅ Complete |
|
|
227
|
-
| **0.5.x** |
|
|
228
|
-
| **0.
|
|
327
|
+
| **0.5.x** | Uncertainty | Propagation through arithmetic and conversions | ✅ Complete |
|
|
328
|
+
| **0.5.x** | Unit Systems | `BasisTransform`, `UnitSystem`, cross-basis conversion | ✅ Complete |
|
|
329
|
+
| **0.6.x** | Pydantic Integration | Type-safe quantity validation | ⏳ Planned |
|
|
330
|
+
| **0.7.x** | NumPy Arrays | Vectorized conversion and arithmetic | ⏳ Planned |
|
|
229
331
|
|
|
230
332
|
See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
231
333
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
tests/ucon/__init__.py,sha256=9BAHYTs27Ed3VgqiMUH4XtVttAmOPgK0Zvj-dUNo7D8,119
|
|
2
|
+
tests/ucon/test_algebra.py,sha256=NnoQOSMW8NJlOTnbr3M_5epvnDPXpVTgO21L2LcytRY,8503
|
|
3
|
+
tests/ucon/test_basis_transform.py,sha256=P7ccr9wgDPCwCmsj4dceu3V0A72qFbTavjzm9kB3xP8,16710
|
|
4
|
+
tests/ucon/test_core.py,sha256=bmwSRWPlhwossy5NJ9rcPWujFmzBBPOeZzPAzN1acFg,32631
|
|
5
|
+
tests/ucon/test_default_graph_conversions.py,sha256=rkcDcSV1_kZeuPf4ModHDpgfkOPZS32xcKq7KPDRN-0,15760
|
|
6
|
+
tests/ucon/test_dimensionless_units.py,sha256=K6BrIPOFL9IO_ksR8t_oJUXmjTgqBUzMdgaV-hZc52w,8410
|
|
7
|
+
tests/ucon/test_graph_basis_transform.py,sha256=5-WglJaR1N_mJlqR6i8NuxLJ_FASqb5a8WoO_177smU,8249
|
|
8
|
+
tests/ucon/test_quantity.py,sha256=md5nbmy0u2cFBdqNeu-ROhoj29vYrIlGm_AjlmCttgc,24519
|
|
9
|
+
tests/ucon/test_rebased_unit.py,sha256=n2mksEYSJ8UJJXXwlgaLKg3THaf7_LKzWB7kwjoaXEU,5150
|
|
10
|
+
tests/ucon/test_uncertainty.py,sha256=KkJw2dJR0EToxPpBN24x735jr9fv6a2myxjvhOH4MPU,9649
|
|
11
|
+
tests/ucon/test_unit_system.py,sha256=gRc3fMvo9ded1tBUQWLKpcbpBepMAb7gPu8XvFzQZaM,5860
|
|
12
|
+
tests/ucon/test_units.py,sha256=SILymDtDNDyxEhkYQubrfkakKCMexwEwjyHfhrkDrMI,869
|
|
13
|
+
tests/ucon/test_vector_fraction.py,sha256=fTgxlV9aSP15sA4gATTXBzIDbtKXwWqG1Ip0o-V2B4Y,6369
|
|
14
|
+
tests/ucon/conversion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
tests/ucon/conversion/test_graph.py,sha256=fs0aP6qNf8eE1uI7SoGSCW2XAkHYb7T9aaI-kzmO02c,16955
|
|
16
|
+
tests/ucon/conversion/test_map.py,sha256=DVFQ3xwp16Nuy9EtZRjKlWbkXfRUcM1mOzFrS4HhOaw,13886
|
|
17
|
+
ucon/__init__.py,sha256=M_sijIUYPvU87Jtnq_O2X7TS4x9RW1LHZJ8bbm3gfk0,2255
|
|
18
|
+
ucon/algebra.py,sha256=6QrPyD23L93XSrnIORcYEx2CLDv4WDcrh6H_hxeeOus,8668
|
|
19
|
+
ucon/core.py,sha256=j2Xw73-Xuh0CkaUYEv5ljsxjt-XdthiH-EbqUBgG1a8,63139
|
|
20
|
+
ucon/graph.py,sha256=Ec0Q2QiAGUm2RaxrKnpFHtwpNvTf4PYbvo62BWtGJG8,21159
|
|
21
|
+
ucon/maps.py,sha256=tWP4ayYCEazJzf81EP1_fmtADhg18D1eHldudAMEY0U,5460
|
|
22
|
+
ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
|
|
23
|
+
ucon/units.py,sha256=MWCJhicK6jQb71fREyW_HSfGFKL8KEQej2JnySL_MjE,8285
|
|
24
|
+
ucon-0.5.2.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
|
|
25
|
+
ucon-0.5.2.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
|
|
26
|
+
ucon-0.5.2.dist-info/METADATA,sha256=CTRgYp67awjnjy7ZzKL2opd81T2RK1feQ6AXMnXmSj4,17200
|
|
27
|
+
ucon-0.5.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
28
|
+
ucon-0.5.2.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
|
|
29
|
+
ucon-0.5.2.dist-info/RECORD,,
|
ucon-0.5.0.dist-info/RECORD
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
tests/ucon/__init__.py,sha256=9BAHYTs27Ed3VgqiMUH4XtVttAmOPgK0Zvj-dUNo7D8,119
|
|
2
|
-
tests/ucon/test_algebra.py,sha256=NnoQOSMW8NJlOTnbr3M_5epvnDPXpVTgO21L2LcytRY,8503
|
|
3
|
-
tests/ucon/test_core.py,sha256=bmwSRWPlhwossy5NJ9rcPWujFmzBBPOeZzPAzN1acFg,32631
|
|
4
|
-
tests/ucon/test_default_graph_conversions.py,sha256=rkcDcSV1_kZeuPf4ModHDpgfkOPZS32xcKq7KPDRN-0,15760
|
|
5
|
-
tests/ucon/test_dimensionless_units.py,sha256=K6BrIPOFL9IO_ksR8t_oJUXmjTgqBUzMdgaV-hZc52w,8410
|
|
6
|
-
tests/ucon/test_quantity.py,sha256=md5nbmy0u2cFBdqNeu-ROhoj29vYrIlGm_AjlmCttgc,24519
|
|
7
|
-
tests/ucon/test_units.py,sha256=SILymDtDNDyxEhkYQubrfkakKCMexwEwjyHfhrkDrMI,869
|
|
8
|
-
tests/ucon/conversion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
tests/ucon/conversion/test_graph.py,sha256=fs0aP6qNf8eE1uI7SoGSCW2XAkHYb7T9aaI-kzmO02c,16955
|
|
10
|
-
tests/ucon/conversion/test_map.py,sha256=DVFQ3xwp16Nuy9EtZRjKlWbkXfRUcM1mOzFrS4HhOaw,13886
|
|
11
|
-
ucon/__init__.py,sha256=jaFcNFZC5gxHKzM8OkS1pLxltp6ToLRVpuuhJQY9FKQ,2000
|
|
12
|
-
ucon/algebra.py,sha256=4JiT_SHHep86Sv3tVkgKsRY95lBRASMkyH4vOUA-gfM,7459
|
|
13
|
-
ucon/core.py,sha256=GjLKV0ERyYLhBZBpyIfCrKL718EN1RlUKwqxx2B3Rc4,43606
|
|
14
|
-
ucon/graph.py,sha256=lPoYSvHNGBZxeZ-4dyZIu2OS5R1JTo0qPZ9wd0vg-s4,15566
|
|
15
|
-
ucon/maps.py,sha256=yyZ7RqnohO2joTUvvKh40in7E6SKMQIQ8jkECO0-_NA,4753
|
|
16
|
-
ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
|
|
17
|
-
ucon/units.py,sha256=u1ILwGllzNiwGLadlg5jguKPyFV1u-CZSUMgUDWTen4,7509
|
|
18
|
-
ucon-0.5.0.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
|
|
19
|
-
ucon-0.5.0.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
|
|
20
|
-
ucon-0.5.0.dist-info/METADATA,sha256=TZDRVKaAMyCbbwanZ44Ej5JsCGNf0iWch1PD_CFnIx4,12901
|
|
21
|
-
ucon-0.5.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
22
|
-
ucon-0.5.0.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
|
|
23
|
-
ucon-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|