ucon 0.3.5rc2__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ucon/graph.py ADDED
@@ -0,0 +1,423 @@
1
+ # © 2026 The Radiativity Company
2
+ # Licensed under the Apache License, Version 2.0
3
+ # See the LICENSE file for details.
4
+
5
+ """
6
+ ucon.graph
7
+ ==========
8
+
9
+ Implements the **ConversionGraph** — the registry of unit conversion
10
+ morphisms that enables `Number.to()` conversions.
11
+
12
+ Classes
13
+ -------
14
+ - :class:`ConversionGraph` — Stores and composes conversion Maps between units.
15
+
16
+ Functions
17
+ ---------
18
+ - :func:`get_default_graph` — Get the current default graph.
19
+ - :func:`set_default_graph` — Replace the default graph.
20
+ - :func:`reset_default_graph` — Reset to standard graph on next access.
21
+ - :func:`using_graph` — Context manager for scoped graph override.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from collections import deque
26
+ from contextlib import contextmanager
27
+ from contextvars import ContextVar
28
+ from dataclasses import dataclass, field
29
+ from typing import Union
30
+
31
+ from ucon.core import Dimension, Unit, UnitFactor, UnitProduct, Scale
32
+ from ucon.maps import Map, LinearMap, AffineMap
33
+
34
+
35
+ class DimensionMismatch(Exception):
36
+ """Raised when attempting to convert between incompatible dimensions."""
37
+ pass
38
+
39
+
40
+ class ConversionNotFound(Exception):
41
+ """Raised when no conversion path exists between units."""
42
+ pass
43
+
44
+
45
+ class CyclicInconsistency(Exception):
46
+ """Raised when adding an edge creates an inconsistent cycle."""
47
+ pass
48
+
49
+
50
+ @dataclass
51
+ class ConversionGraph:
52
+ """Registry of conversion morphisms between units.
53
+
54
+ Stores edges between Unit nodes (partitioned by Dimension) and between
55
+ UnitProduct nodes (for composite unit conversions like joule → watt_hour).
56
+
57
+ Supports:
58
+ - Direct edge lookup
59
+ - BFS path composition for multi-hop conversions
60
+ - Factorwise decomposition for UnitProduct conversions
61
+ """
62
+
63
+ # Edges between Units, partitioned by Dimension
64
+ _unit_edges: dict[Dimension, dict[Unit, dict[Unit, Map]]] = field(default_factory=dict)
65
+
66
+ # Edges between UnitProducts (keyed by frozen factor representation)
67
+ _product_edges: dict[tuple, dict[tuple, Map]] = field(default_factory=dict)
68
+
69
+ # ------------- Edge Management -------------------------------------------
70
+
71
+ def add_edge(
72
+ self,
73
+ *,
74
+ src: Union[Unit, UnitProduct],
75
+ dst: Union[Unit, UnitProduct],
76
+ map: Map,
77
+ ) -> None:
78
+ """Register a conversion edge. Also registers the inverse.
79
+
80
+ Parameters
81
+ ----------
82
+ src : Unit or UnitProduct
83
+ Source unit expression.
84
+ dst : Unit or UnitProduct
85
+ Destination unit expression.
86
+ map : Map
87
+ The conversion morphism (src → dst).
88
+
89
+ Raises
90
+ ------
91
+ DimensionMismatch
92
+ If src and dst have different dimensions.
93
+ CyclicInconsistency
94
+ If the reverse edge exists and round-trip is not identity.
95
+ """
96
+ # Handle Unit vs UnitProduct dispatch
97
+ if isinstance(src, Unit) and not isinstance(src, UnitProduct):
98
+ if isinstance(dst, Unit) and not isinstance(dst, UnitProduct):
99
+ self._add_unit_edge(src=src, dst=dst, map=map)
100
+ return
101
+
102
+ # At least one is a UnitProduct
103
+ src_prod = src if isinstance(src, UnitProduct) else UnitProduct.from_unit(src)
104
+ dst_prod = dst if isinstance(dst, UnitProduct) else UnitProduct.from_unit(dst)
105
+ self._add_product_edge(src=src_prod, dst=dst_prod, map=map)
106
+
107
+ def _add_unit_edge(self, *, src: Unit, dst: Unit, map: Map) -> None:
108
+ """Add edge between plain Units."""
109
+ if src.dimension != dst.dimension:
110
+ raise DimensionMismatch(f"{src.dimension} != {dst.dimension}")
111
+
112
+ dim = src.dimension
113
+ self._ensure_dimension(dim)
114
+
115
+ # Check cyclic consistency if reverse exists
116
+ if self._has_direct_unit_edge(src=dst, dst=src):
117
+ existing = self._get_direct_unit_edge(src=dst, dst=src)
118
+ roundtrip = existing @ map
119
+ if not roundtrip.is_identity():
120
+ raise CyclicInconsistency(f"Inconsistent: {src}→{dst}→{src}")
121
+
122
+ # Store forward and inverse
123
+ self._unit_edges[dim].setdefault(src, {})[dst] = map
124
+ self._unit_edges[dim].setdefault(dst, {})[src] = map.inverse()
125
+
126
+ def _add_product_edge(self, *, src: UnitProduct, dst: UnitProduct, map: Map) -> None:
127
+ """Add edge between UnitProducts."""
128
+ if src.dimension != dst.dimension:
129
+ raise DimensionMismatch(f"{src.dimension} != {dst.dimension}")
130
+
131
+ src_key = self._product_key(src)
132
+ dst_key = self._product_key(dst)
133
+
134
+ # Check cyclic consistency
135
+ if dst_key in self._product_edges and src_key in self._product_edges.get(dst_key, {}):
136
+ existing = self._product_edges[dst_key][src_key]
137
+ roundtrip = existing @ map
138
+ if not roundtrip.is_identity():
139
+ raise CyclicInconsistency(f"Inconsistent product edge cycle")
140
+
141
+ # Store forward and inverse
142
+ self._product_edges.setdefault(src_key, {})[dst_key] = map
143
+ self._product_edges.setdefault(dst_key, {})[src_key] = map.inverse()
144
+
145
+ def _ensure_dimension(self, dim: Dimension) -> None:
146
+ if dim not in self._unit_edges:
147
+ self._unit_edges[dim] = {}
148
+
149
+ def _has_direct_unit_edge(self, *, src: Unit, dst: Unit) -> bool:
150
+ dim = src.dimension
151
+ return (
152
+ dim in self._unit_edges
153
+ and src in self._unit_edges[dim]
154
+ and dst in self._unit_edges[dim][src]
155
+ )
156
+
157
+ def _get_direct_unit_edge(self, *, src: Unit, dst: Unit) -> Map:
158
+ return self._unit_edges[src.dimension][src][dst]
159
+
160
+ def _product_key(self, product: UnitProduct) -> tuple:
161
+ """Create a hashable key for a UnitProduct."""
162
+ # Sort by unit name for stable ordering
163
+ items = sorted(
164
+ ((f.unit.name, f.unit.dimension, f.scale, exp) for f, exp in product.factors.items()),
165
+ key=lambda x: (x[0], str(x[1]))
166
+ )
167
+ return tuple(items)
168
+
169
+ # ------------- Conversion ------------------------------------------------
170
+
171
+ def convert(
172
+ self,
173
+ *,
174
+ src: Union[Unit, UnitProduct],
175
+ dst: Union[Unit, UnitProduct],
176
+ ) -> Map:
177
+ """Find or compose a conversion Map from src to dst.
178
+
179
+ Parameters
180
+ ----------
181
+ src : Unit or UnitProduct
182
+ Source unit expression.
183
+ dst : Unit or UnitProduct
184
+ Destination unit expression.
185
+
186
+ Returns
187
+ -------
188
+ Map
189
+ The conversion morphism.
190
+
191
+ Raises
192
+ ------
193
+ DimensionMismatch
194
+ If src and dst have different dimensions.
195
+ ConversionNotFound
196
+ If no conversion path exists.
197
+ """
198
+ # Both plain Units
199
+ if isinstance(src, Unit) and not isinstance(src, UnitProduct):
200
+ if isinstance(dst, Unit) and not isinstance(dst, UnitProduct):
201
+ return self._convert_units(src=src, dst=dst)
202
+
203
+ # At least one is a UnitProduct
204
+ src_prod = src if isinstance(src, UnitProduct) else UnitProduct.from_unit(src)
205
+ dst_prod = dst if isinstance(dst, UnitProduct) else UnitProduct.from_unit(dst)
206
+ return self._convert_products(src=src_prod, dst=dst_prod)
207
+
208
+ def _convert_units(self, *, src: Unit, dst: Unit) -> Map:
209
+ """Convert between plain Units via BFS."""
210
+ if src == dst:
211
+ return LinearMap.identity()
212
+
213
+ if src.dimension != dst.dimension:
214
+ raise DimensionMismatch(f"{src.dimension} != {dst.dimension}")
215
+
216
+ # Direct edge?
217
+ if self._has_direct_unit_edge(src=src, dst=dst):
218
+ return self._get_direct_unit_edge(src=src, dst=dst)
219
+
220
+ # BFS
221
+ dim = src.dimension
222
+ if dim not in self._unit_edges:
223
+ raise ConversionNotFound(f"No edges for dimension {dim}")
224
+
225
+ visited: dict[Unit, Map] = {src: LinearMap.identity()}
226
+ queue = deque([src])
227
+
228
+ while queue:
229
+ current = queue.popleft()
230
+ current_map = visited[current]
231
+
232
+ if current not in self._unit_edges[dim]:
233
+ continue
234
+
235
+ for neighbor, edge_map in self._unit_edges[dim][current].items():
236
+ if neighbor in visited:
237
+ continue
238
+
239
+ composed = edge_map @ current_map
240
+ visited[neighbor] = composed
241
+
242
+ if neighbor == dst:
243
+ return composed
244
+
245
+ queue.append(neighbor)
246
+
247
+ raise ConversionNotFound(f"No path from {src} to {dst}")
248
+
249
+ def _convert_products(self, *, src: UnitProduct, dst: UnitProduct) -> Map:
250
+ """Convert between UnitProducts.
251
+
252
+ Tries in order:
253
+ 1. Direct product edge
254
+ 2. Factorwise decomposition
255
+ """
256
+ if src.dimension != dst.dimension:
257
+ raise DimensionMismatch(f"{src.dimension} != {dst.dimension}")
258
+
259
+ # Check for direct product edge first
260
+ src_key = self._product_key(src)
261
+ dst_key = self._product_key(dst)
262
+
263
+ if src_key in self._product_edges and dst_key in self._product_edges.get(src_key, {}):
264
+ return self._product_edges[src_key][dst_key]
265
+
266
+ # Same product? Identity.
267
+ if src_key == dst_key:
268
+ return LinearMap.identity()
269
+
270
+ # Try factorwise decomposition
271
+ return self._convert_factorwise(src=src, dst=dst)
272
+
273
+ def _convert_factorwise(self, *, src: UnitProduct, dst: UnitProduct) -> Map:
274
+ """Factorwise conversion when factor structures align."""
275
+ try:
276
+ src_by_dim = src.factors_by_dimension()
277
+ dst_by_dim = dst.factors_by_dimension()
278
+ except ValueError as e:
279
+ raise ConversionNotFound(f"Ambiguous decomposition: {e}")
280
+
281
+ # Check that dimensions match exactly
282
+ if set(src_by_dim.keys()) != set(dst_by_dim.keys()):
283
+ raise ConversionNotFound(
284
+ f"Factor structures don't align: {set(src_by_dim.keys())} vs {set(dst_by_dim.keys())}"
285
+ )
286
+
287
+ result = LinearMap.identity()
288
+
289
+ for dim, (src_factor, src_exp) in src_by_dim.items():
290
+ dst_factor, dst_exp = dst_by_dim[dim]
291
+
292
+ if abs(src_exp - dst_exp) > 1e-12:
293
+ raise ConversionNotFound(
294
+ f"Exponent mismatch for {dim}: {src_exp} vs {dst_exp}"
295
+ )
296
+
297
+ # Scale ratio
298
+ src_scale_val = src_factor.scale.value.evaluated
299
+ dst_scale_val = dst_factor.scale.value.evaluated
300
+ scale_ratio = src_scale_val / dst_scale_val
301
+ scale_map = LinearMap(scale_ratio)
302
+
303
+ # Unit conversion (if different base units)
304
+ if src_factor.unit == dst_factor.unit:
305
+ unit_map = LinearMap.identity()
306
+ else:
307
+ unit_map = self._convert_units(
308
+ src=src_factor.unit,
309
+ dst=dst_factor.unit,
310
+ )
311
+
312
+ # Combine scale and unit conversion, apply exponent
313
+ factor_map = (scale_map @ unit_map) ** src_exp
314
+ result = result @ factor_map
315
+
316
+ return result
317
+
318
+
319
+ # -----------------------------------------------------------------------------
320
+ # Default Graph Management
321
+ # -----------------------------------------------------------------------------
322
+
323
+ _default_graph: ConversionGraph | None = None
324
+ _graph_context: ContextVar[ConversionGraph | None] = ContextVar("graph", default=None)
325
+
326
+
327
+ def get_default_graph() -> ConversionGraph:
328
+ """Get the current conversion graph.
329
+
330
+ Priority:
331
+ 1. Context-local graph (from `using_graph`)
332
+ 2. Module-level default graph (lazily built)
333
+ """
334
+ # Check context first
335
+ graph = _graph_context.get()
336
+ if graph is not None:
337
+ return graph
338
+
339
+ # Fall back to module default
340
+ global _default_graph
341
+ if _default_graph is None:
342
+ _default_graph = _build_standard_graph()
343
+ return _default_graph
344
+
345
+
346
+ def set_default_graph(graph: ConversionGraph) -> None:
347
+ """Replace the module-level default graph."""
348
+ global _default_graph
349
+ _default_graph = graph
350
+
351
+
352
+ def reset_default_graph() -> None:
353
+ """Reset to standard graph on next access."""
354
+ global _default_graph
355
+ _default_graph = None
356
+
357
+
358
+ @contextmanager
359
+ def using_graph(graph: ConversionGraph):
360
+ """Context manager for scoped graph override.
361
+
362
+ Usage::
363
+
364
+ with using_graph(custom_graph):
365
+ result = value.to(target) # uses custom_graph
366
+ """
367
+ token = _graph_context.set(graph)
368
+ try:
369
+ yield graph
370
+ finally:
371
+ _graph_context.reset(token)
372
+
373
+
374
+ def _build_standard_graph() -> ConversionGraph:
375
+ """Build the default graph with common conversions."""
376
+ from ucon import units
377
+
378
+ graph = ConversionGraph()
379
+
380
+ # --- Length ---
381
+ graph.add_edge(src=units.meter, dst=units.foot, map=LinearMap(3.28084))
382
+ graph.add_edge(src=units.foot, dst=units.inch, map=LinearMap(12))
383
+ graph.add_edge(src=units.foot, dst=units.yard, map=LinearMap(1/3))
384
+ graph.add_edge(src=units.mile, dst=units.foot, map=LinearMap(5280))
385
+
386
+ # --- Mass ---
387
+ graph.add_edge(src=units.kilogram, dst=units.gram, map=LinearMap(1000))
388
+ graph.add_edge(src=units.kilogram, dst=units.pound, map=LinearMap(2.20462))
389
+ graph.add_edge(src=units.pound, dst=units.ounce, map=LinearMap(16))
390
+
391
+ # --- Time ---
392
+ graph.add_edge(src=units.second, dst=units.minute, map=LinearMap(1/60))
393
+ graph.add_edge(src=units.minute, dst=units.hour, map=LinearMap(1/60))
394
+ graph.add_edge(src=units.hour, dst=units.day, map=LinearMap(1/24))
395
+
396
+ # --- Temperature ---
397
+ # C → K: K = C + 273.15
398
+ graph.add_edge(src=units.celsius, dst=units.kelvin, map=AffineMap(1, 273.15))
399
+ # F → C: C = (F - 32) * 5/9
400
+ graph.add_edge(src=units.fahrenheit, dst=units.celsius, map=AffineMap(5/9, -32 * 5/9))
401
+
402
+ # --- Pressure ---
403
+ # 1 Pa = 0.00001 bar, so 1 bar = 100000 Pa
404
+ graph.add_edge(src=units.pascal, dst=units.bar, map=LinearMap(1/100000))
405
+ # 1 Pa = 0.000145038 psi
406
+ graph.add_edge(src=units.pascal, dst=units.psi, map=LinearMap(0.000145038))
407
+ # 1 atm = 101325 Pa
408
+ graph.add_edge(src=units.atmosphere, dst=units.pascal, map=LinearMap(101325))
409
+
410
+ # --- Volume ---
411
+ graph.add_edge(src=units.liter, dst=units.gallon, map=LinearMap(0.264172))
412
+
413
+ # --- Energy ---
414
+ graph.add_edge(src=units.joule, dst=units.calorie, map=LinearMap(1/4.184))
415
+ graph.add_edge(src=units.joule, dst=units.btu, map=LinearMap(1/1055.06))
416
+
417
+ # --- Power ---
418
+ graph.add_edge(src=units.watt, dst=units.horsepower, map=LinearMap(1/745.7))
419
+
420
+ # --- Information ---
421
+ graph.add_edge(src=units.byte, dst=units.bit, map=LinearMap(8))
422
+
423
+ return graph
ucon/maps.py ADDED
@@ -0,0 +1,161 @@
1
+ # © 2026 The Radiativity Company
2
+ # Licensed under the Apache License, Version 2.0
3
+ # See the LICENSE file for details.
4
+
5
+ """
6
+ ucon.maps
7
+ =========
8
+
9
+ Implements the **Map** class hierarchy — the composable substrate for
10
+ all unit conversion morphisms in *ucon*.
11
+
12
+ Classes
13
+ -------
14
+ - :class:`Map` — Abstract base for conversion morphisms.
15
+ - :class:`LinearMap` — y = a * x
16
+ - :class:`AffineMap` — y = a * x + b
17
+ - :class:`ComposedMap` — Generic composition fallback: g(f(x))
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from abc import ABC, abstractmethod
22
+ from dataclasses import dataclass
23
+
24
+
25
+ class Map(ABC):
26
+ """Abstract base for all conversion morphisms.
27
+
28
+ Subclasses must implement ``__call__``, ``inverse``, and ``__pow__``.
29
+ Composition via ``@`` defaults to :class:`ComposedMap`; subclasses may
30
+ override for closed composition within their own type.
31
+ """
32
+
33
+ @abstractmethod
34
+ def __call__(self, x: float) -> float:
35
+ """Apply the map to a numeric value."""
36
+ ...
37
+
38
+ @abstractmethod
39
+ def inverse(self) -> Map:
40
+ """Return the inverse map."""
41
+ ...
42
+
43
+ @abstractmethod
44
+ def __matmul__(self, other: Map) -> Map:
45
+ """Compose: ``(f @ g)(x) == f(g(x))``."""
46
+ ...
47
+
48
+ @abstractmethod
49
+ def __pow__(self, exp: float) -> Map:
50
+ """Raise map to a power (for exponent handling in factorwise conversion)."""
51
+ ...
52
+
53
+ def is_identity(self, tol: float = 1e-9) -> bool:
54
+ """Check if this map is approximately the identity."""
55
+ return abs(self(1.0) - 1.0) < tol and abs(self(0.0) - 0.0) < tol
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class LinearMap(Map):
60
+ """A linear conversion: ``y = a * x``."""
61
+
62
+ a: float
63
+
64
+ def __call__(self, x: float) -> float:
65
+ return self.a * x
66
+
67
+ @property
68
+ def invertible(self) -> bool:
69
+ return self.a != 0
70
+
71
+ def inverse(self) -> LinearMap:
72
+ if not self.invertible:
73
+ raise ZeroDivisionError("LinearMap with a=0 is not invertible.")
74
+ return LinearMap(1.0 / self.a)
75
+
76
+ def __matmul__(self, other: Map) -> Map:
77
+ if isinstance(other, LinearMap):
78
+ return LinearMap(self.a * other.a)
79
+ if isinstance(other, AffineMap):
80
+ # a1 * (a2*x + b2) = (a1*a2)*x + (a1*b2)
81
+ return AffineMap(self.a * other.a, self.a * other.b)
82
+ if not isinstance(other, Map):
83
+ return NotImplemented
84
+ return ComposedMap(self, other)
85
+
86
+ def __pow__(self, exp: float) -> LinearMap:
87
+ return LinearMap(self.a ** exp)
88
+
89
+ @classmethod
90
+ def identity(cls) -> LinearMap:
91
+ return cls(1.0)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class AffineMap(Map):
96
+ """An affine conversion: ``y = a * x + b``."""
97
+
98
+ a: float
99
+ b: float
100
+
101
+ def __call__(self, x: float) -> float:
102
+ return self.a * x + self.b
103
+
104
+ @property
105
+ def invertible(self) -> bool:
106
+ return self.a != 0
107
+
108
+ def inverse(self) -> AffineMap:
109
+ if not self.invertible:
110
+ raise ZeroDivisionError("AffineMap with a=0 is not invertible.")
111
+ return AffineMap(1.0 / self.a, -self.b / self.a)
112
+
113
+ def __matmul__(self, other: Map) -> Map:
114
+ if isinstance(other, LinearMap):
115
+ # a1 * (a2*x) + b1 = (a1*a2)*x + b1
116
+ return AffineMap(self.a * other.a, self.b)
117
+ if isinstance(other, AffineMap):
118
+ # a1 * (a2*x + b2) + b1 = (a1*a2)*x + (a1*b2 + b1)
119
+ return AffineMap(self.a * other.a, self.a * other.b + self.b)
120
+ if not isinstance(other, Map):
121
+ return NotImplemented
122
+ return ComposedMap(self, other)
123
+
124
+ def __pow__(self, exp: float) -> Map:
125
+ if exp == 1:
126
+ return self
127
+ if exp == -1:
128
+ return self.inverse()
129
+ raise ValueError("AffineMap only supports exp=1 or exp=-1")
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class ComposedMap(Map):
134
+ """Generic composition fallback: ``(outer ∘ inner)(x) = outer(inner(x))``."""
135
+
136
+ outer: Map
137
+ inner: Map
138
+
139
+ def __call__(self, x: float) -> float:
140
+ return self.outer(self.inner(x))
141
+
142
+ @property
143
+ def invertible(self) -> bool:
144
+ return self.outer.invertible and self.inner.invertible
145
+
146
+ def inverse(self) -> ComposedMap:
147
+ if not self.invertible:
148
+ raise ValueError("ComposedMap is not invertible: one or both components are not invertible.")
149
+ return ComposedMap(self.inner.inverse(), self.outer.inverse())
150
+
151
+ def __matmul__(self, other: Map) -> ComposedMap:
152
+ if not isinstance(other, Map):
153
+ return NotImplemented
154
+ return ComposedMap(self, other)
155
+
156
+ def __pow__(self, exp: float) -> Map:
157
+ if exp == 1:
158
+ return self
159
+ if exp == -1:
160
+ return self.inverse()
161
+ raise ValueError("ComposedMap only supports exp=1 or exp=-1")