Template Expression Pattern in Underworld3 Solvers¶
Summary¶
The Template Expression Pattern is a design pattern used in Underworld3 solvers to create persistent expression containers that preserve object identity for lazy evaluation while allowing their symbolic content to be updated dynamically.
Problem Solved¶
Previously, solver properties like F0, F1, and PF0 created NEW expression objects every time they were accessed:
# OLD PATTERN (problematic)
@property
def F0(self):
f0 = expression( # Creates NEW expression each time!
r"f_0 \left( \mathbf{u} \right)",
-self.bodyforce.sym,
"Force term"
)
return f0
This caused:
Uniqueness warnings - Each access created a duplicate named expression
Object identity loss - Python
id(solver.F0)changed on each accessLazy evaluation issues - Templates couldn’t reliably reference sub-expressions
Memory inefficiency - Accumulating unused expression objects
Solution: ExpressionProperty Descriptor¶
The ExpressionProperty descriptor creates expression containers ONCE and preserves their identity:
# NEW PATTERN (correct)
class MySolver:
F0 = ExpressionProperty(
r"f_0 \left( \mathbf{u} \right)",
lambda self: -self.bodyforce.sym,
"Force term"
)
How It Works¶
First Access: Creates a persistent
UWexpressioncontainerSubsequent Accesses: Returns the SAME container (same Python id)
Content Updates: Only the
.symproperty changes when referenced values changeLazy Evaluation: Templates can reliably reference sub-expressions
Implementation Details¶
ExpressionProperty Class¶
Located in src/underworld3/utilities/_api_tools.py:
class ExpressionProperty:
"""
Property descriptor for persistent UWexpression template containers.
Parameters
----------
name_template : str or callable
LaTeX name for the expression
sym_template : callable
Function that returns the symbolic expression
description : str
Description of the expression
"""
def __init__(self, name_template, sym_template, description, attr_name=None):
self.name_template = name_template
self.sym_template = sym_template
self.description = description
self.attr_name = attr_name
def __get__(self, obj, objtype=None):
# Check if expression already exists
expr = getattr(obj, self.attr_name, None)
if expr is None:
# Create the expression ONCE
expr = expression(
name,
self.sym_template(obj),
self.description,
_unique_name_generation=True
)
setattr(obj, self.attr_name, expr)
else:
# Update content if needed
new_sym = self.sym_template(obj)
if expr.sym != new_sym:
expr.sym = new_sym
return expr
Usage in Solvers¶
All major solvers now use ExpressionProperty:
class SNES_Stokes(SNES_Stokes_SaddlePt):
# Template expressions with persistent identity
F0 = ExpressionProperty(
r"\mathbf{f}_0\left( \mathbf{u} \right)",
lambda self: -self.bodyforce.sym,
"Stokes pointwise force term"
)
F1 = ExpressionProperty(
r"\mathbf{F}_1\left( \mathbf{u} \right)",
lambda self: sympy.simplify(
self.stress + self.penalty * self.div_u * sympy.eye(self.mesh.dim)
),
"Stokes pointwise flux term"
)
PF0 = ExpressionProperty(
r"\mathbf{h}_0\left( \mathbf{p} \right)",
lambda self: sympy.simplify(sympy.Matrix((self.constraints))),
"Pressure constraint term"
)
Benefits¶
No Uniqueness Warnings: Expressions created once, not repeatedly
Preserved Identity:
id(solver.F0)remains constantLazy Evaluation: Templates can reliably reference sub-expressions
Memory Efficient: No accumulation of unused expressions
Clean Syntax: Declarative pattern at class level
Automatic Updates: Content updates when dependencies change
Comparison¶
Before (Property Pattern)¶
class Solver:
@property
def F0(self):
# Creates NEW expression every access
return expression(name, value, desc)
# Problem:
solver = Solver()
id1 = id(solver.F0) # e.g., 140234567
id2 = id(solver.F0) # e.g., 140234789 (DIFFERENT!)
After (ExpressionProperty Pattern)¶
class Solver:
F0 = ExpressionProperty(name, value_fn, desc)
# Solution:
solver = Solver()
id1 = id(solver.F0) # e.g., 140234567
id2 = id(solver.F0) # e.g., 140234567 (SAME!)
Updated Solvers¶
The following solvers have been migrated to use ExpressionProperty:
SNES_Poisson- F0, F1SNES_Darcy- F0, F1SNES_Stokes- F0, F1, PF0SNES_VE_Stokes- Inherits from StokesSNES_Projection- F0, F1SNES_Vector_Projection- F0, F1SNES_Tensor_Projection- Uses scalar projectionSNES_AdvectionDiffusion- Uses legacy pattern (time-dependent)SNES_Diffusion- Uses legacy pattern (time-dependent)SNES_NavierStokes- Uses legacy pattern (time-dependent)
Note: Time-dependent solvers still use the legacy pattern due to their complex interaction with DuDt and DFDt objects. These may be migrated in a future update.
Best Practices¶
Use ExpressionProperty for solver template expressions (F0, F1, PF0, etc.)
Use SymbolicProperty for simple symbolic inputs (uw_function, etc.)
Lambda functions in sym_template should capture dependencies properly
Avoid creating expressions in properties - use descriptors instead
Test object identity to ensure persistence is working
Migration Guide¶
To migrate a solver from property pattern to ExpressionProperty:
Remove the
@propertydecorator and methodAdd
ExpressionPropertyat class level:# OLD @property def F0(self): return expression(name, value, desc) # NEW F0 = ExpressionProperty( name, lambda self: value, desc )
Ensure lambda captures all needed dependencies
Test that object identity is preserved
Technical Notes¶
Expressions use
_unique_name_generation=Trueto avoid conflictsThe
.symproperty is updated lazily on accessAttributeError handling prevents issues during initialization
Weak references prevent circular dependencies
Compatible with JIT compilation and PETSc solvers
Future Work¶
Migrate time-dependent solvers to use ExpressionProperty
Consider caching strategies for expensive symbolic operations
Extend pattern to other persistent symbolic objects
Add debugging tools for tracking expression updates