Simplified Units Architecture (November 2025)¶
STATUS: This document supersedes all previous units planning documents. Previous plans (
units_system_plan.md, etc.) are now historical reference only.
Core Principle: Gateway Pattern¶
Units are handled at boundaries (input/output), not during symbolic manipulation:
Input Gateway: User creates quantities with units → stored as Pint objects
Symbolic Layer: Operations produce expressions → units discoverable from atoms
Output Gateway:
evaluate()returns dimensionalUnitAwareArray
User Input Symbolic Layer Output
─────────── ────────────── ──────
uw.quantity() ─┐
├──► UWexpression ──► unwrap() ──► evaluate() ──► UnitAwareArray
uw.expression()┘ (lazy eval) (ND for (dimensional
solver) for user)
Type Hierarchy¶
UWQuantity¶
Purpose: Lightweight number with units (Pint-backed)
Use case: Simple arithmetic between quantities
Properties:
.value→ dimensional (what user sees).data→ non-dimensional (what solver sees).units→ Pint Unit object
Arithmetic: Pure Pint delegation for
UWQuantity ⊗ UWQuantity
UWexpression¶
Purpose: Lazy-evaluated wrapper, the preferred user-facing object
Use case: Parameters, constants, composite expressions
Properties:
.value→ dimensional value (from wrapped thing).data→ non-dimensional value (from wrapped thing).units→ discovered from wrapped thing.sym→ the wrapped SymPy expression or value
Arithmetic: Returns
UWexpressionwrapping result with combined units
MeshVariable¶
Purpose: Field data on mesh (the template for this design)
Properties:
.array→UnitAwareArray(dimensional).data→ non-dimensional values.sym→ SymPy Function for symbolic use.units→ Pint Unit object
Pattern: This is the model we follow for all unit-aware objects
UnitAwareArray¶
Purpose: NumPy array with units attached
Use case: Return type from
evaluate(), mesh coordinatesProperties:
Inherits from
np.ndarray.units→ Pint Unit object.to(units)→ convert to different units
Transparent Container Principle (2025-11-26)¶
Key Insight: A container cannot know in advance what it contains. All accessor methods must evaluate lazily - no cached state on composites.
The Atomic vs Container Distinction¶
Type |
Role |
What it stores |
|---|---|---|
UWQuantity |
Atomic leaf node |
Value + Units (indivisible, this IS the data) |
UWexpression |
Container |
Reference to contents only (derives everything) |
Why This Matters¶
UWexpression is always a container, whether it wraps:
A UWQuantity (atomic) → derives
.unitsfromself._value_with_units.unitsA SymPy tree (composite) → derives
.unitsfromget_units(self._sym)
The container never “owns” units or values - it provides access to what’s inside.
# Atomic: UWQuantity owns the value+units
qty = uw.quantity(3e-5, "1/K") # This IS 3e-5 per Kelvin
# Container wrapping atomic: derives from contents
alpha = uw.expression("α", qty)
alpha.units # → qty.units (derived, not stored separately)
# Container wrapping composite: derives from tree
product = alpha * beta # Returns SymPy Mul containing alpha, beta
get_units(product) # → traverses tree, finds atoms, combines units
Implementation Consequences¶
No stored units on composites - eliminates sync issues
Properties are lazy evaluations - always reflect current state
Convenience flags (e.g.,
_is_constant) - cached queries, not owned dataArithmetic returns raw SymPy - for
expr * expr, let SymPy handle it
class UWexpression:
@property
def units(self):
# Always derived, never stored separately
if self._value_with_units is not None:
return self._value_with_units.units # From contained atom
return get_units(self._sym) # From contained tree
Arithmetic Closure Table¶
Left ⊗ Right |
Result Type |
Units Preserved? |
|---|---|---|
|
|
✅ Pint arithmetic |
|
|
✅ Pint arithmetic |
|
|
✅ Wrapped with combined units |
|
|
✅ Wrapped with combined units |
|
|
✅ Wrapped (qty units preserved) |
|
|
✅ Discoverable from atoms (lazy) |
|
|
✅ Wrapped with combined units |
|
|
✅ Preserves expression units |
|
|
✅ Discoverable from atoms |
|
|
✅ MeshVar units preserved |
|
|
✅ Discoverable from atoms |
Key Rule: Any operation involving UWQuantity or UWexpression returns a type that preserves units. Pure SymPy operations between MeshVariable symbols are OK because get_units() can discover units from the atoms.
User-Facing Recommendations¶
Preferred Pattern¶
# PREFERRED: Use expressions for parameters
alpha = uw.expression(r"\alpha", uw.quantity(3e-5, "1/K"), "thermal expansivity")
rho0 = uw.expression(r"\rho_0", uw.quantity(3300, "kg/m^3"), "reference density")
# Arithmetic preserves units
buoyancy = rho0 * alpha * g * dT # Returns UWexpression with correct units
Acceptable Pattern¶
# OK for quick calculations, but prefer expressions for model parameters
viscosity = uw.quantity(1e21, "Pa*s")
velocity = uw.quantity(5, "cm/year")
Avoid¶
# AVOID: Raw quantities in symbolic expressions without wrapping
# This works but is less clear and loses symbolic meaning
result = uw.quantity(5, "m/s") * mesh.X[0] # Works but prefer expression
Implementation Requirements¶
1. UWQuantity.mul (and other operators)¶
def __mul__(self, other):
if isinstance(other, UWQuantity):
# Pure Pint arithmetic
return UWQuantity(result.magnitude, result.units)
elif isinstance(other, (int, float)):
# Scalar - Pint handles
return UWQuantity(result.magnitude, result.units)
else:
# Everything else: wrap in UWexpression
# Compute combined units, wrap result
return UWexpression(name, UWQuantity(value, combined_units))
2. UWexpression.mul (and other operators)¶
def __mul__(self, other):
if isinstance(other, UWQuantity):
# Delegate to UWQuantity which returns UWexpression
return other.__rmul__(self)
elif isinstance(other, UWexpression):
# TRANSPARENT CONTAINER PRINCIPLE (2025-11-26):
# Return raw SymPy product - units derived on demand via get_units()
# This preserves lazy evaluation and eliminates sync issues
return Symbol.__mul__(self, other)
elif isinstance(other, (int, float)):
# Scalar: wrap result preserving self's units
return UWexpression(name, UWQuantity(self.value * other, self.units))
else:
# Default to SymPy multiplication
return Symbol.__mul__(self, other)
3. unwrap() / unwrap_for_evaluate()¶
This is where all unit handling converges:
Extract units from expression atoms
Compute non-dimensional values for solver
Track result dimensionality for re-dimensionalization
4. evaluate()¶
Gateway function that:
Calls unwrap to get ND expression
Evaluates numerically
Re-dimensionalizes result
Returns
UnitAwareArraywith correct units
What We Deleted¶
The following are no longer used (preserved as *_old.py for reference):
UnitAwareExpression(~1500 lines) - tracked units through all arithmeticComplex unit-tracking in expression arithmetic
Multiple inheritance from UWQuantity in UWexpression
Benefits of This Design¶
Simplicity: Units handled in one place (unwrap/evaluate)
Consistency: Same pattern as MeshVariable
User Experience: Expressions are self-documenting with LaTeX names
Lazy Evaluation: Parameters can change (time-stepping)
Solver Compatibility: ND values for PETSc, dimensional for user
Files Affected¶
src/underworld3/function/quantities.py- Simplified UWQuantitysrc/underworld3/function/expressions.py- Simplified UWexpressionsrc/underworld3/function/functions_unit_system.py- evaluate() gatewaysrc/underworld3/units.py- get_units(), non_dimensionalise(), dimensionalise()
Testing Strategy¶
Tier A (Production): Core Stokes ND tests, basic quantity/expression tests
Tier B (Validated): Mixed arithmetic, evaluate combinations
Tier C (Experimental): Edge cases being developed
See docs/developer/TESTING-RELIABILITY-SYSTEM.md for test classification.