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.
- tests/ucon/conversion/__init__.py +0 -0
- tests/ucon/conversion/test_graph.py +409 -0
- tests/ucon/conversion/test_map.py +409 -0
- tests/ucon/test_algebra.py +34 -34
- tests/ucon/test_core.py +25 -26
- tests/ucon/test_default_graph_conversions.py +443 -0
- tests/ucon/test_quantity.py +246 -61
- ucon/__init__.py +6 -2
- ucon/algebra.py +9 -5
- ucon/core.py +366 -53
- ucon/graph.py +423 -0
- ucon/maps.py +161 -0
- ucon/quantity.py +7 -186
- ucon/units.py +79 -31
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.dist-info}/METADATA +28 -10
- ucon-0.4.1.dist-info/RECORD +22 -0
- ucon-0.3.5rc2.dist-info/RECORD +0 -16
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.dist-info}/WHEEL +0 -0
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.dist-info}/licenses/NOTICE +0 -0
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.dist-info}/top_level.txt +0 -0
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")
|