TVB-O supports surface-based partial differential equations using finite element methods via scikit-fem . The PDE backend generates a self-contained implicit Euler FEM solver from a YAML experiment description, supporting triangle and tetrahedral meshes, Dirichlet boundary conditions, and constant or time-dependent source terms.
\[
\partial_t u(t,\mathbf{x}) = D\,\Delta u(t,\mathbf{x}) \quad \text{in } \Omega, \qquad u(t,\mathbf{x}) = 0 \quad \text{on } \partial\Omega
\]
Example 1: Heat Equation on a 2D Grid
A minimal diffusion example on a unit square, using scikit-fem’s built-in mesh generators.
Step 1 — Create a triangle mesh and experiment YAML
import tempfile, os, yaml
import meshio
import numpy as np
from skfem import MeshTri
from bsplot import style
style.use("tvbo" )
# Generate a symmetric unit-square triangulation
mesh = MeshTri.init_symmetric().refined(3 )
tmpdir = tempfile.mkdtemp()
mesh_path = os.path.join(tmpdir, "unit_square.msh" )
meshio.Mesh(points= mesh.p.T, cells= [("triangle" , mesh.t.T)]).write(mesh_path)
print (f"Mesh: { mesh. p. shape[1 ]} vertices, { mesh. t. shape[1 ]} triangles" )
# Write experiment YAML
exp_dict = {
"label" : "Heat equation on unit square" ,
"field_dynamics" : {
"label" : "Heat / diffusion equation" ,
"mesh" : {
"label" : "unit_square" ,
"element_type" : "triangle" ,
"mesh_file" : mesh_path,
},
"parameters" : {"D" : {"name" : "D" , "value" : 0.01 }},
"state_variables" : [
{
"name" : "u" ,
"label" : "Temperature" ,
"initial_value" : 0.0 ,
"boundary_conditions" : [
{
"label" : "Zero Dirichlet" ,
"bc_type" : "Dirichlet" ,
"value" : {"rhs" : "0" },
}
],
"equation" : {"lhs" : "u_t" , "rhs" : "D * laplacian(u)" },
}
],
"operators" : [
{"label" : "Diffusion" , "operator_type" : "laplacian" , "coefficient" : "D" }
],
"solver" : {
"label" : "FEM implicit Euler" ,
"discretization" : "FEM" ,
"time_integrator" : "implicit Euler" ,
"dt" : 0.01 ,
},
},
"integration" : {"duration" : 1.0 },
}
yaml_path = os.path.join(tmpdir, "pde_heat_2d.yaml" )
with open (yaml_path, "w" ) as f:
yaml.dump(exp_dict, f)
Mesh: 145 vertices, 256 triangles
Step 2 — Run the simulation
from tvbo import SimulationExperiment
exp = SimulationExperiment.from_file(yaml_path)
ns = exp.execute("pde" )
nodes = ns["meta" ]["nodes" ]
x, y = nodes[0 ], nodes[1 ]
# Gaussian bump initial condition centred at (0.5, 0.5)
u0 = np.exp(- ((x - 0.5 )** 2 + (y - 0.5 )** 2 ) / 0.02 )
ts = exp.run("pde" , u0= u0)
print (f"TimeSeries: { ts. data. shape} (time, state_var, nodes)" )
print (f"Time: { ts. time[0 ]:.2f} to { ts. time[- 1 ]:.2f} , dt= { ts. time[1 ] - ts. time[0 ]:.3f} " )
TimeSeries: (101, 1, 145) (time, state_var, nodes)
Time: 0.00 to 1.00, dt=0.010
Step 3 — Animate the diffusion
import matplotlib.pyplot as plt
import matplotlib.tri as mtri
from matplotlib.colors import PowerNorm
from matplotlib.animation import PillowWriter
from IPython.display import Image
triang = mtri.Triangulation(x, y, ns["meta" ]["cells" ].T)
n_frames = ts.data.shape[0 ]
vmin, vmax = 0 , float (np.max (ts.data))
norm = PowerNorm(gamma= 0.3 , vmin= vmin, vmax= vmax)
fig, (ax_mesh, ax_ts) = plt.subplots(1 , 2 , figsize= (9 , 4 ), layout= "compressed" )
# Add a fixed colorbar (norm is constant across frames)
import matplotlib.cm as cm
sm = cm.ScalarMappable(norm= norm, cmap= "viridis" )
fig.colorbar(sm, ax= ax_mesh, label= "u" , shrink= 0.8 )
def update_2d(frame):
ax_mesh.clear()
ax_mesh.tripcolor(triang, np.asarray(ts.data[frame, 0 , :]), norm= norm, cmap= "viridis" )
ax_mesh.set_title(f"t = { ts. time[frame]:.2f} " )
ax_mesh.set_aspect("equal" )
ax_ts.clear()
ax_ts.plot(ts.time, np.asarray(ts.data[:, 0 , :]).mean(axis= 1 ), color= "black" )
ax_ts.axvline(ts.time[frame], color= "red" , lw= 1 )
ax_ts.set_xlabel("Time" )
ax_ts.set_ylabel("Mean u" )
ax_ts.set_title("Global mean" )
os.makedirs("_output" , exist_ok= True )
from matplotlib.animation import FuncAnimation
anim = FuncAnimation(fig, update_2d, frames= n_frames, interval= 100 )
anim.save("_output/pde_heat_2d.gif" , writer= "pillow" , fps= 10 )
plt.close()
Example 2: Cortical Surface Diffusion
Diffusion on a cortical surface mesh (fsLR 32k), using the mesh_file attribute to reference an external GIFTI file directly. The cortical midthickness surface is a closed manifold (no boundary), so the Dirichlet BC has no effect and total mass is conserved — the field spreads but doesn’t decay.
Setup and run
import templateflow.api as tfa
import nibabel as nib
# Get RH midthickness surface from templateflow
mesh_gii = str (tfa.get(template= "fsLR" , density= "32k" , suffix= "midthickness" , hemi= "R" , desc= None ))
# D=50 gives visible spreading on mm-scale cortical mesh (edge ≈ 1.6 mm)
exp_cortex = {
'label' : 'Cortical surface diffusion' ,
'field_dynamics' : {
'label' : 'Heat/diffusion equation' ,
'mesh' : {
'label' : 'cortex_rh' ,
'element_type' : 'triangle' ,
'mesh_file' : mesh_gii,
'mesh_format' : 'gifti' ,
},
'parameters' : {'D' : {'name' : 'D' , 'value' : 50.0 }},
'state_variables' : [{
'name' : 'u' , 'label' : 'u' ,
'initial_value' : 0.0 ,
'boundary_conditions' : [{'label' : 'Zero Dirichlet' , 'bc_type' : 'Dirichlet' , 'value' : {'rhs' : '0' }}],
'equation' : {'lhs' : 'u_t' , 'rhs' : 'D * laplacian(u)' },
}],
'operators' : [{'label' : 'Diffusion' , 'operator_type' : 'laplacian' , 'coefficient' : 'D' }],
'solver' : {'label' : 'FEM IE' , 'discretization' : 'FEM' , 'time_integrator' : 'implicit Euler' , 'dt' : 2.0 },
},
'integration' : {'duration' : 60 },
}
tmpdir2 = tempfile.mkdtemp()
yaml_path2 = os.path.join(tmpdir2, 'pde_cortex.yaml' )
with open (yaml_path2, 'w' ) as f:
yaml.dump(exp_cortex, f)
exp2 = SimulationExperiment.from_file(yaml_path2)
# Load mesh for initial condition
gi = nib.load(mesh_gii)
vertices = gi.darrays[0 ].data
# Gaussian centred at the most posterior vertex (occipital pole), σ ≈ 45 mm
seed_idx = np.argmin(vertices[:, 1 ])
seed = vertices[seed_idx]
dist_sq = np.sum ((vertices - seed)** 2 , axis= 1 )
u0 = np.exp(- dist_sq / 2000 )
ts2 = exp2.run("pde" , u0= u0)
print (f"Cortical PDE: { ts2. data. shape} , { vertices. shape[0 ]} vertices" )
Cortical PDE: (31, 1, 32492), 32492 vertices
Animate with bsplot
from matplotlib.colors import PowerNorm
from bsplot.surface import plot_surf
from bsplot.animate import animate_axes
n_frames = ts2.data.shape[0 ]
vmax2 = float (np.max (ts2.data))
norm2 = PowerNorm(gamma= 0.3 , vmin= 0 , vmax= vmax2)
fig2, (ax_surf, ax_ts) = plt.subplots(1 , 2 , figsize= (9 , 4 ), layout= "compressed" )
# Add a fixed colorbar
import matplotlib.cm as cm
sm2 = cm.ScalarMappable(norm= norm2, cmap= "inferno" )
fig2.colorbar(sm2, ax= ax_surf, label= "u" , shrink= 0.8 )
# Area-weighted mean (conserved on closed surface), not arithmetic mean
faces = gi.darrays[1 ].data
v0, v1, v2 = vertices[faces[:, 0 ]], vertices[faces[:, 1 ]], vertices[faces[:, 2 ]]
tri_areas = 0.5 * np.linalg.norm(np.cross(v1 - v0, v2 - v0), axis= 1 )
vertex_areas = np.zeros(len (vertices))
for k in range (3 ):
np.add.at(vertex_areas, faces[:, k], tri_areas / 3 )
global_mean = np.array([
(vertex_areas * np.asarray(ts2.data[t, 0 , :])).sum () / vertex_areas.sum ()
for t in range (n_frames)
])
def cortex_update(frame, ax, axis_idx, context):
ax.clear()
if axis_idx == 0 :
overlay = np.asarray(context["ts" ].data[frame, 0 , :])
plot_surf(
context["gii" ], overlay= overlay,
hemi= "rh" , view= "lateral" , ax= ax,
cmap= "inferno" , norm= context["norm" ],
)
ax.set_title(f"t = { context['ts' ]. time[frame]:.0f} " )
return []
else :
ax.plot(context["ts" ].time, context["mean" ], color= "black" )
ax.axvline(context["ts" ].time[frame], color= "red" , lw= 1 )
ax.set_xlabel("Time" )
ax.set_ylabel("Area-weighted mean u" )
ax.set_title("Mass conservation" )
return []
ctx = {"ts" : ts2, "gii" : gi, "norm" : norm2, "mean" : global_mean}
anim2 = animate_axes(fig2, [ax_surf, ax_ts], n_frames= n_frames,
update_fn= cortex_update, context= ctx, interval= 200 )
anim2.save("_output/pde_cortex.gif" , writer= "pillow" , fps= 6 )
plt.close()
Numerical details
The backward-Euler FEM update assembles mass (\(M\) ) and stiffness (\(K\) ) matrices over linear triangular elements and solves, at each step:
\[
(M + \Delta t \, D \, K) \, \mathbf{u}^{n+1} = M \, \mathbf{u}^{n} + \Delta t \, M \, \mathbf{f}^{n}
\]
Mesh Support
The Mesh class supports two ways to reference mesh geometry:
mesh_file
Path to external mesh file (GIFTI, VTK, MSH, FreeSurfer, etc.)
mesh_format
Explicit format override (gifti, freesurfer, meshio). Auto-detected from extension if omitted
dataLocation
Legacy attribute — still works, supports prefix syntax (gifti:path/to/file.gii)
Supported mesh formats:
GIFTI
.gii, .surf.gii
nibabel
FreeSurfer
.pial, .white, .inflated
nibabel
Gmsh
.msh
meshio
VTK
.vtk, .vtu
meshio
Wavefront OBJ
.obj
meshio
PLY
.ply
meshio
Generated Code
The PDE backend generates a self-contained scikit-fem solver from the YAML specification:
code = exp.render_code("pde" )
print (code[:500 ])
The generated module contains:
_load_mesh()
Load GIFTI, FreeSurfer, or meshio mesh formats
build()
Assemble mass and stiffness matrices, return solver closures
solve_pde()
Implicit Euler timestepping with optional source terms
visualize()
Matplotlib field plot via scikit-fem
Schema Classes
PDE
Top-level PDE problem definition
SpatialDomain
Coordinate space and geometry
Mesh
Triangle/tetrahedron element mesh with mesh_file/mesh_format
FieldStateVariable
Spatially distributed state variable
DifferentialOperator
Gradient, divergence, laplacian, curl
BoundaryCondition
Dirichlet, Neumann, Robin, Periodic
PDESolver
FEM/FDM/FVM discretization config