Dual-Graph Architecture¶
ucon uses two complementary graph structures to handle unit conversions:
| Graph | Domain | Nodes | Edges | Purpose |
|---|---|---|---|---|
| BasisGraph | Dimensional | Basis |
BasisTransform |
Validates cross-basis compatibility |
| ConversionGraph | Numeric | Unit |
Map (LinearMap, AffineMap) |
Executes conversions with scaling factors |
This separation reflects a fundamental distinction: dimensional compatibility is a structural property independent of numeric conversion factors.
Why Two Graphs?¶
The Problem with a Single Graph¶
A naive approach stores all conversions in one graph:
graph LR
meter -->|"×0.3048"| foot
meter -->|"×100"| centimeter
ampere -->|"×2.998e9"| statampere
This works for same-basis conversions but fails for cross-basis cases:
-
Dimensional validation — How do we know
ampere → statampereis valid? In SI, current has dimensionI. In CGS-ESU, it has dimensionL^(3/2) M^(1/2) T^(-2). A single graph can't express that these are "the same" dimension in different bases. -
Basis connectivity — If we add
meter → centimeter_cgs, can we also convertnewton → dyne? The answer depends on whether SI and CGS bases are connected — information a unit-level graph doesn't capture. -
Lossy projections — CGS can't represent temperature. Should
kelvin → ???fail at edge-addition time or conversion time? We need basis-level knowledge to decide.
The Solution: Separation of Concerns¶
BasisGraph answers: "Can dimensions from basis A be expressed in basis B?"
ConversionGraph answers: "What numeric factor converts unit X to unit Y?"
BasisGraph: The Dimensional Layer¶
graph LR
SI["<b>SI</b><br/>(T, L, M, I, Θ, J, N, B)"]
CGS["<b>CGS</b><br/>(L, M, T)"]
ESU["<b>CGS-ESU</b><br/>(L, M, T, Q)"]
SI -->|SI_TO_CGS| CGS
SI -->|SI_TO_CGS_ESU| ESU
Structure¶
- Nodes:
Basisobjects (SI, CGS, CGS_ESU) - Edges:
BasisTransformmatrices
BasisTransform¶
A BasisTransform is a matrix mapping dimension vectors between bases:
SI_TO_CGS_ESU:
L M T Q
T . . 1 . # time maps 1:1
L 1 . . . # length maps 1:1
M . 1 . . # mass maps 1:1
I 3/2 1/2 -2 . # current becomes derived!
Θ . . . . # temperature: not representable
...
Row 4 shows the key insight: SI current (I^1) transforms to CGS-ESU as L^(3/2) M^(1/2) T^(-2). This is a dimensional relationship, independent of the numeric factor (2.998×10⁹).
Operations¶
from ucon.basis import BasisGraph, BasisTransform
from ucon.bases import SI, CGS_ESU, SI_TO_CGS_ESU
bg = BasisGraph()
bg = bg.with_transform(SI_TO_CGS_ESU)
# Check connectivity
bg.are_connected(SI, CGS_ESU) # True
# Transform a dimension vector
si_current = units.ampere.dimension.vector
cgs_current = SI_TO_CGS_ESU(si_current) # L^(3/2) M^(1/2) T^(-2)
ConversionGraph: The Numeric Layer¶
graph LR
subgraph SI Basis
meter
foot
end
subgraph CGS-ESU Basis
centimeter
rebased["RebasedUnit(meter)"]
end
meter -->|"×0.3048"| foot
meter -.->|"basis_transform"| rebased
rebased -->|"×100"| centimeter
Structure¶
- Nodes:
Unitobjects (meter, foot, ampere, etc.) - Edges:
Mapobjects (LinearMap, AffineMap, LogMap)
Partitioning by Dimension¶
Edges are partitioned by Dimension. Within a partition, BFS finds conversion paths:
# Same-basis: direct BFS
graph.convert(units.meter, units.foot) # finds path via shared edges
# Cross-basis: requires RebasedUnit bridge
graph.convert(units.ampere, statampere) # ampere → RebasedUnit → statampere
RebasedUnit: The Bridge¶
When you add a cross-basis edge, ConversionGraph creates a RebasedUnit:
graph.add_edge(
src=units.ampere, # SI unit
dst=statampere, # CGS-ESU unit
map=LinearMap(2.998e9),
basis_transform=SI_TO_CGS_ESU,
)
Internally:
- Validate:
SI_TO_CGS_ESU(ampere.dimension.vector) == statampere.dimension.vector - Create:
RebasedUnit(original=ampere, rebased_dimension=cgs_esu_current, transform=SI_TO_CGS_ESU) - Store edge:
RebasedUnit → statamperewithLinearMap(2.998e9)
The RebasedUnit lives in the destination's dimension partition, enabling BFS to find paths across bases.
Interaction Flow¶
Adding a Cross-Basis Edge¶
sequenceDiagram
participant User
participant CG as ConversionGraph
participant BT as BasisTransform
User->>CG: add_edge(ampere, statampere, LinearMap(k), basis_transform)
CG->>BT: transform(ampere.dimension.vector)
BT-->>CG: transformed_vector
CG->>CG: validate: transformed == statampere.vector?
CG->>CG: create RebasedUnit(ampere) in CGS-ESU partition
CG->>CG: store edge: RebasedUnit ↔ statampere
CG-->>User: edge added
Converting Across Bases¶
sequenceDiagram
participant User
participant CG as ConversionGraph
User->>CG: convert(ampere, statampere)
CG->>CG: dimensions differ (different bases)
CG->>CG: lookup RebasedUnit(ampere)
Note right of CG: Found in CGS-ESU partition
CG->>CG: BFS: RebasedUnit → statampere
Note right of CG: Direct edge: LinearMap(2.998e9)
CG-->>User: return composed Map
Validating with BasisGraph¶
When _basis_graph is set on ConversionGraph:
sequenceDiagram
participant User
participant CG as ConversionGraph
participant BG as BasisGraph
User->>CG: add_edge(meter, weird_unit, ...)
CG->>BG: are_connected(SI, unknown_basis)?
BG-->>CG: False
CG-->>User: raise NoTransformPath
Design Rationale¶
Why Not Merge Them?¶
-
Different lifetimes — BasisGraph is typically static (standard physics). ConversionGraph grows as users add domain units.
-
Different semantics — BasisTransform is algebraic (matrix multiplication). Map is numeric (function application).
-
Validation vs. execution — BasisGraph catches structural errors early. ConversionGraph handles runtime computation.
-
Composability — Multiple ConversionGraphs can share one BasisGraph. Domain-specific graphs (aerospace, medical) inherit basis connectivity.
The Layered View¶
flowchart TB
subgraph api["User API"]
a1["Number.to()"]
a2["graph.convert()"]
end
subgraph cg["ConversionGraph"]
c1["Units, Maps, BFS, RebasedUnits"]
end
subgraph bg["BasisGraph"]
b1["Bases, BasisTransforms, Connectivity"]
end
subgraph dim["Dimension / Vector"]
d1["Exponent vectors, Basis-aware arithmetic"]
end
api --> cg --> bg --> dim
Each layer has a single responsibility:
- Dimension/Vector: Represent dimensional quantities
- BasisGraph: Validate structural compatibility
- ConversionGraph: Execute numeric conversions
- User API: Convenient access to conversions
Example: Full Cross-Basis Flow¶
from ucon import units, Dimension
from ucon.basis import BasisGraph, Vector
from ucon.bases import SI, CGS_ESU, SI_TO_CGS_ESU
from ucon.core import Unit
from ucon.graph import ConversionGraph
from ucon.maps import LinearMap
# 1. Set up BasisGraph
basis_graph = BasisGraph().with_transform(SI_TO_CGS_ESU)
# 2. Create CGS-ESU unit
cgs_current_dim = Dimension(
vector=Vector(CGS_ESU, (Fraction(3,2), Fraction(1,2), Fraction(-2), Fraction(0))),
name="cgs_current",
)
statampere = Unit(name="statampere", dimension=cgs_current_dim)
# 3. Create ConversionGraph with BasisGraph
graph = ConversionGraph()
graph._basis_graph = basis_graph
# 4. Add cross-basis edge (validated by both graphs)
graph.add_edge(
src=units.ampere,
dst=statampere,
map=LinearMap(2.998e9),
basis_transform=SI_TO_CGS_ESU,
)
# 5. Convert (BFS finds path via RebasedUnit)
result = graph.convert(units.ampere, statampere)
print(result(1)) # 2.998e9
Summary¶
| Concern | BasisGraph | ConversionGraph |
|---|---|---|
| Question answered | "Are these bases compatible?" | "What's the numeric factor?" |
| Node type | Basis | Unit |
| Edge type | BasisTransform (matrix) | Map (function) |
| Validation | Dimensional structure | Dimension equality |
| When used | Edge addition, compatibility checks | Path finding, conversion execution |
The dual-graph architecture cleanly separates what can be converted (BasisGraph) from how to convert it (ConversionGraph), enabling robust cross-basis unit handling while maintaining single-basis simplicity for common cases.
Context Scoping (v0.8.4+)¶
Both the default Basis and BasisGraph support thread-safe context scoping via ContextVar.
Why Context Scoping?¶
Different parts of an application may need different defaults:
- A CGS physics simulation vs. SI engineering calculations
- Test isolation without global state pollution
- Async tasks with independent basis configurations
API¶
from ucon import (
using_basis,
using_basis_graph,
get_default_basis,
get_basis_graph,
)
# Scoped basis override
with using_basis(CGS):
dim = Dimension.from_components(L=1) # Uses CGS
get_default_basis() # CGS
# Scoped BasisGraph override
custom_graph = BasisGraph()
with using_basis_graph(custom_graph):
get_basis_graph() is custom_graph # True
Relationship to using_graph()¶
| Context Manager | Scope | Affects |
|---|---|---|
using_graph() |
ConversionGraph | Unit conversions, name resolution |
using_basis() |
Default Basis | Dimension creation defaults |
using_basis_graph() |
BasisGraph | Cross-basis validation |
These are independent — you can combine them as needed: