ConversionGraph Internals¶
How ucon finds and composes conversion paths between units.
Overview¶
ConversionGraph is a registry of conversion morphisms (Maps) between units. When you call number.to(target), the graph finds a path and composes the Maps along that path.
from ucon.graph import get_default_graph
graph = get_default_graph()
# ~90 edges across ~15 dimension partitions
Graph Topology¶
Dimension Partitioning¶
Edges between Unit nodes are partitioned by Dimension. This enables O(1) lookup to the relevant subgraph and prevents cross-dimension path searches.
_unit_edges: dict[Dimension, dict[Unit, dict[Unit, Map]]]
# Structure:
# {
# Dimension.length: {
# meter: {foot: LinearMap(3.28084), ...},
# foot: {meter: LinearMap(0.3048), inch: LinearMap(12), ...},
# },
# Dimension.mass: {...},
# ...
# }
Product Edges¶
For composite units like joule → watt_hour, edges exist between UnitProduct nodes:
_product_edges: dict[tuple, dict[tuple, Map]]
# Keys are frozen factor representations for hashing
# Example: BTU/hour → watt edge
Rebased Units¶
Cross-basis conversions (e.g., CGS → SI) use RebasedUnit as intermediate nodes:
_rebased: dict[Unit, list[RebasedUnit]]
# Original unit → list of rebased units (one per basis transform)
# A single source unit may have multiple rebased entries when
# registered with different basis transforms (e.g., ampere has
# rebased entries for both CGS-ESU and CGS-EMU)
BasisGraph Reference¶
For cross-basis validation, ConversionGraph can hold a reference to a BasisGraph:
_basis_graph: BasisGraph | None
# When set, validates that cross-basis edges connect compatible bases
See Dual-Graph Architecture for how these two graphs interact.
BFS Path Finding¶
Why BFS?¶
ucon uses breadth-first search, not Dijkstra, DFS, or A*:
| Algorithm | Why Not |
|---|---|
| Dijkstra | All edges have equal "cost" — there's no weight to minimize |
| DFS | Would find a path, not necessarily the shortest |
| A* | No useful heuristic for unit conversion distances |
BFS guarantees the shortest path (fewest hops), which is desirable for numerical stability — fewer composed Maps means less floating-point accumulation.
The Visited Dict's Dual Role¶
The visited dictionary serves two purposes:
- Cycle prevention — Don't revisit nodes
- Path accumulation — Store the composed Map to reach each node
def _bfs_convert(self, *, start, target, dim: Dimension) -> Map:
# visited[unit] = composed Map from start to unit
visited: dict = {start: LinearMap.identity()}
queue = deque([start])
while queue:
current = queue.popleft()
current_map = visited[current]
for neighbor, edge_map in self._unit_edges[dim][current].items():
if neighbor in visited:
continue
# Compose: edge_map @ current_map = start → neighbor
composed = edge_map @ current_map
visited[neighbor] = composed
if neighbor == target:
return composed
queue.append(neighbor)
raise ConversionNotFound(f"No path from {start} to {target}")
Composition Order¶
Maps compose right-to-left (like matrix multiplication):
# Path: meter → foot → inch
# foot_to_inch @ meter_to_foot means:
# apply meter_to_foot first, then foot_to_inch
Conversion Strategies¶
1. Direct Edge Lookup¶
If a direct edge exists, return it immediately:
if self._has_direct_unit_edge(src=src, dst=dst):
return self._get_direct_unit_edge(src=src, dst=dst)
2. BFS in Same Dimension¶
For units in the same dimension without a direct edge:
3. Factorwise Decomposition¶
For UnitProduct conversions where factor structures align:
The algorithm groups factors by effective dimensional vector:
# volume¹ (vector = L³) matches length³ (vector = L¹ × 3 = L³)
effective_vec = dim.vector * exponent
4. Product Edge Lookup¶
For composite units that don't decompose cleanly:
# BTU/h → W requires a direct product edge
# (energy/time structures differ: BTU vs J, h vs s)
graph.add_edge(src=units.btu / units.hour, dst=units.watt, map=LinearMap(0.29307))
5. Cross-Basis Conversion¶
For units from different dimensional bases (e.g., SI → CGS-ESU):
graph.add_edge(
src=units.ampere, # SI basis
dst=statampere, # CGS-ESU basis
map=LinearMap(2.998e9),
basis_transform=SI_TO_CGS_ESU, # validates dimensional compatibility
)
The basis_transform parameter triggers cross-basis handling:
- Validate —
SI_TO_CGS_ESU(ampere.dimension.vector) == statampere.dimension.vector - Create RebasedUnit — wraps
amperein the CGS-ESU dimension partition - Store edge —
RebasedUnit(ampere) ↔ statampere
graph LR
subgraph SI
ampere
end
subgraph CGS-ESU
rebased["RebasedUnit(ampere)"]
statampere
end
ampere -.->|basis_transform| rebased
rebased -->|"×2.998e9"| statampere
When _basis_graph is set, convert() also validates that bases are connected before searching:
if self._basis_graph is not None:
if not self._basis_graph.are_connected(src.basis, dst.basis):
raise NoTransformPath(src.basis, dst.basis)
6. Cross-Dimensional Context Conversion¶
For conversions between different physical dimensions (e.g., wavelength → frequency), ConversionContext edges must be active via using_context(). When dimensions don't match and no rebased path exists, the graph falls back to cross-dimensional BFS:
def _bfs_convert_cross_dimensional(self, *, start, target) -> Map:
"""BFS across ALL dimension partitions (used for context edges)."""
visited: dict = {start: LinearMap.identity()}
queue = deque([start])
while queue:
current = queue.popleft()
current_map = visited[current]
# Search all dimension partitions, not just the source dimension
for dim, dim_edges in self._unit_edges.items():
if current not in dim_edges:
continue
for neighbor, edge_map in dim_edges[current].items():
if neighbor in visited:
continue
composed = edge_map @ current_map
visited[neighbor] = composed
if neighbor == target:
return composed
queue.append(neighbor)
raise ConversionNotFound(...)
Context edges are stored in both dimension partitions (source and destination) so BFS can discover the cross-dimensional bridge from either direction. This is handled by _add_context_edge() in ucon/contexts.py.
The cross-dimensional BFS is only triggered when src.dimension != dst.dimension and no rebased path resolves the conversion. Outside a using_context() block, DimensionMismatch is raised as expected.
Map Types¶
LinearMap¶
For proportional conversions:
class LinearMap:
a: float # y = a * x
def __call__(self, x): return self.a * x
def inverse(self): return LinearMap(1 / self.a)
def derivative(self, x): return self.a
Examples: meter↔foot, gram↔pound, joule↔calorie
AffineMap¶
For offset conversions (temperature):
class AffineMap:
a: float # scale
b: float # offset
# y = a * x + b
def __call__(self, x): return self.a * x + self.b
def inverse(self): return AffineMap(1/self.a, -self.b/self.a)
def derivative(self, x): return self.a
Examples: celsius↔kelvin (K = C + 273.15), fahrenheit↔celsius
ReciprocalMap¶
For inversely proportional conversions:
class ReciprocalMap:
a: float # y = a / x
def __call__(self, x): return self.a / x
def inverse(self): return ReciprocalMap(self.a) # self-inverse
def derivative(self, x): return -self.a / (x * x)
Examples: wavelength↔frequency (f = c / λ), wavelength↔energy (E = hc / λ)
LogMap / ExpMap¶
For logarithmic scales:
class LogMap:
scale: float = 1.0
base: float = 10.0
offset: float = 0.0
# y = scale * log_base(x) + offset
Examples: fraction↔nines (-log₁₀(1 - x))
ComposedMap¶
Generic fallback for heterogeneous composition:
Edge Addition¶
Automatic Inverse¶
Adding an edge automatically registers the inverse:
def _add_unit_edge(self, *, src: Unit, dst: Unit, map: Map) -> None:
self._unit_edges[dim][src][dst] = map
self._unit_edges[dim][dst][src] = map.inverse()
Cyclic Consistency Check¶
If the reverse edge already exists, the round-trip must be identity:
if self._has_direct_unit_edge(src=dst, dst=src):
existing = self._get_direct_unit_edge(src=dst, dst=src)
roundtrip = existing @ map
if not roundtrip.is_identity():
raise CyclicInconsistency(f"Inconsistent: {src}→{dst}→{src}")
This catches contradictory conversion factors during graph construction.
Cross-Basis Edge with BasisTransform¶
For edges between units in different bases, pass basis_transform:
from ucon.bases import SI_TO_CGS_ESU
graph.add_edge(
src=units.ampere,
dst=statampere,
map=LinearMap(2.998e9),
basis_transform=SI_TO_CGS_ESU,
)
The transform is used to:
- Validate that the source dimension maps to the destination dimension
- Create a RebasedUnit linking the source into the destination's partition
- Store the transform for introspection via
list_transforms()
Bulk Cross-Basis Registration¶
Use connect_systems() to register multiple cross-basis edges:
graph.connect_systems(
basis_transform=SI_TO_CGS,
edges={
(units.meter, centimeter_cgs): LinearMap(100),
(units.gram, gram_cgs): LinearMap(1),
(units.second, second_cgs): LinearMap(1),
},
)
Caching¶
Since v0.10.0, the graph caches conversion paths, scale factors, and unit multiplication results for performance. Cache entries are invalidated when the graph is modified (e.g., via add_edge() or with_package()).
Graph-Local Name Resolution¶
Since v0.7.3, unit name resolution is graph-scoped:
# Each graph has its own registries
_name_registry: dict[str, Unit] # case-insensitive
_name_registry_cs: dict[str, Unit] # case-sensitive (m vs M)
# using_graph() sets both conversion AND parsing context
with using_graph(custom_graph):
unit = get_unit_by_name("custom_unit") # resolves in custom_graph
This enables domain-specific units without polluting the global namespace.
Unit Compatibility¶
Unit.basis Property¶
Each unit exposes its dimensional basis:
>>> units.meter.basis
Basis('SI', ['T', 'L', 'M', 'I', 'Θ', 'J', 'N', 'B'])
>>> statampere.basis
Basis('CGS-ESU', ['L', 'M', 'T', 'Q'])
Unit.is_compatible()¶
Check if two units can be converted:
def is_compatible(self, other: Unit, basis_graph: BasisGraph | None = None) -> bool:
"""
Returns True if:
1. Same dimension (same basis), OR
2. Bases are connected via basis_graph (cross-basis)
"""
Examples:
# Same dimension — always compatible
units.meter.is_compatible(units.foot) # True
# Different dimensions — incompatible
units.meter.is_compatible(units.second) # False
# Cross-basis — depends on BasisGraph connectivity
from ucon.bases import SI_TO_CGS_ESU
bg = BasisGraph().with_transform(SI_TO_CGS_ESU)
units.ampere.is_compatible(statampere, basis_graph=bg) # True
Default Graph Contents¶
The standard graph includes ~90 edges:
| Dimension | Units | Example Edges |
|---|---|---|
| length | meter, foot, inch, yard, mile | m↔ft, ft↔in, mi↔ft |
| mass | gram, kilogram, pound, ounce | kg↔g, kg↔lb, lb↔oz |
| time | second, minute, hour, day | s↔min, min↔h, h↔d |
| temperature | kelvin, celsius, fahrenheit, rankine | C↔K (affine), F↔C (affine) |
| pressure | pascal, bar, psi, atm, torr, mmHg, inHg | Pa↔bar, atm↔Pa |
| force | newton, pound_force, kilogram_force, dyne | lbf↔N, kgf↔N |
| volume | liter, gallon | L↔gal, L↔m³ |
| energy | joule, calorie, btu, watt_hour | J↔cal, J↔BTU, J↔Wh |
| power | watt, horsepower | W↔hp, BTU/h↔W |
| information | bit, byte | B↔b |
| angle | radian, degree, arcminute, arcsecond, turn, gradian | rad↔deg, deg↔arcmin |
| solid_angle | steradian, square_degree | sr↔sq_deg |
| ratio | fraction, percent, permille, ppm, ppb, basis_point, nines | frac↔%, frac↔ppm |