Cohesive Fracture with a Hybrid Matrix-Free Newton Solver
This tutorial demonstrates quasi-static fracture simulation using FEAX's hybrid matrix-free Newton–Krylov solver and a cohesive zone model. We solve a 3D Mode I fracture problem where the residual combines a bulk elastic contribution with a cohesive interface contribution.
Overview
The bulk elasticity is handled by FEAX's standard residual assembly, while the cohesive interface — which couples arbitrary node pairs and does not fit the element weak form — is supplied as an extra residual term:
- The bulk residual is assembled by FEAX from the elastic energy density (
get_energy_density→σ = jax.grad(ψ)), giving the sparse bulk Jacobian. - The cohesive residual is passed via
extra_residual_fn. - At each Newton step the combined tangent applies the bulk Jacobian (sparse) plus the cohesive tangent matrix-free via
jax.jvp. - A Krylov solver (CG) solves the Newton system using matrix–vector products.
This is the hybrid matrix-free Newton–Krylov path in FEAX, activated by passing extra_residual_fn together with KrylovSolverOptions. (Alternatively, extra_residual_fn with DirectSolverOptions detects the extra term's sparsity via automatic sparse differentiation and factorizes the exact combined tangent — see the Solver Guide.)
Energy-Based Formulation
Bulk Elastic Energy
The bulk elastic strain energy density for linear elasticity is:
where is the infinitesimal strain tensor, and , are the Lamé constants:
The total bulk energy is the volume integral:
In FEAX, the bulk is defined through get_energy_density(). When get_tensor_map() is not defined, the assembler automatically uses σ = jax.grad(ψ) for the residual and Jacobian, so the bulk needs no hand-written stress:
class Elasticity3D(fe.problem.Problem):
def get_energy_density(self):
def psi(u_grad):
eps = 0.5 * (u_grad + u_grad.T)
return 0.5 * lmbda * np.trace(eps)**2 + mu * np.sum(eps * eps)
return psi
# Scalar bulk energy — used for energy decomposition / post-processing only;
# the solve itself uses the residual assembled from get_energy_density.
elastic_energy = fe.create_energy_fn(problem) # ∫ ψ(∇u) dΩ
Cohesive Interface Energy
The cohesive zone model introduces an energy contribution along the fracture interface . For a node pair across the interface, the displacement jump is:
This jump is decomposed into normal and tangential components:
The effective opening combines both modes:
where is the Macaulay bracket (no energy in compression) and is the mode-mixity ratio.
Xu–Needleman Exponential Potential
The cohesive potential per node follows the Xu–Needleman law:
where is the characteristic opening, is the fracture energy, and is the critical cohesive traction. The traction-separation relation is:
with peak traction at .
Irreversibility
Unloading follows a secant path to prevent energy recovery:
where is the historical maximum opening. This requires tracking as a state variable across load steps.
Total Cohesive Energy
The total cohesive energy is a weighted sum over interface nodes:
where are the integration weights (lumped area per node).
interface = CohesiveInterface.from_axis(
top_nodes=coh_top, bottom_nodes=coh_bottom,
weights=weights, normal_axis=1, vec=3, beta=0.0,
)
cohesive_energy = interface.create_energy_fn(
exponential_potential, Gamma=Gamma, sigma_c=sigma_c,
)
Cohesive Residual
The cohesive contribution enters the global residual as the gradient of the interface potential at the current history :
def cohesive_residual(u_flat, delta_max):
return jax.grad(lambda u: cohesive_energy(u, delta_max))(u_flat)
The Newton solver finds such that . At each Newton iteration the linear system uses the combined tangent
where the bulk Jacobian is assembled (and provides a Jacobi preconditioner) and the cohesive tangent is applied matrix-free via jax.jvp — at roughly the cost of one cohesive-residual evaluation. The CG solver needs only this combined matrix–vector product.
Problem Setup
Material and Geometry
import jax
import jax.numpy as np
import numpy as onp
import feax as fe
from feax.mechanics.cohesive import (
CohesiveInterface, compute_lumped_area_weights, exponential_potential,
)
# Material parameters
E = 106e3 # Young's modulus [Pa]
nu = 0.35 # Poisson's ratio
Gamma = 15.0 # Fracture energy [J/m²]
sigma_c = 20e3 # Critical cohesive traction [Pa]
mu = E / (2 * (1 + nu))
lmbda = E * nu / ((1 + nu) * (1 - 2 * nu))
The geometry is scaled by the Griffith length:
where is the far-field stress. The specimen is with an initial crack of length .
Mesh with Split Interface
The mesh consists of two half-blocks (top/bottom) separated at . Nodes on the interface are duplicated to allow displacement discontinuity. A pre-crack extends from to (free surfaces with no cohesive traction).
mesh = fe.mesh.Mesh(points=np.array(coords), cells=np.array(elements))
problem = Elasticity3D(mesh, vec=3, dim=3, ele_type='HEX8')
Cohesive Interface Setup
Integration weights are computed from the quad elements on the interface surface using lumped area weighting — each quad contributes 1/4 of its area to each of its 4 nodes:
weights = compute_lumped_area_weights(coh_bottom, coords, active_quads_bottom)
interface = CohesiveInterface.from_axis(
top_nodes=coh_top, bottom_nodes=coh_bottom,
weights=weights, normal_axis=1, vec=3, beta=0.0,
)
beta=0.0 gives pure Mode I (only normal opening contributes to the effective opening).
Boundary Conditions
Mode I loading via prescribed displacement on top/bottom faces:
def make_bc(disp):
specs = [
fe.DCboundary.DirichletBCSpec(top_face, 'x', 0.0),
fe.DCboundary.DirichletBCSpec(top_face, 'y', disp / 2),
fe.DCboundary.DirichletBCSpec(top_face, 'z', 0.0),
fe.DCboundary.DirichletBCSpec(bottom_face, 'x', 0.0),
fe.DCboundary.DirichletBCSpec(bottom_face, 'y', -disp / 2),
fe.DCboundary.DirichletBCSpec(bottom_face, 'z', 0.0),
fe.DCboundary.DirichletBCSpec(left_face, 'x', 0.0),
fe.DCboundary.DirichletBCSpec(front_face, 'z', 0.0),
fe.DCboundary.DirichletBCSpec(back_face, 'z', 0.0),
]
return fe.DCboundary.DirichletBCConfig(specs).create_bc(problem)
Solver
Build the solver once with extra_residual_fn and KrylovSolverOptions. The cohesive history is a quasi-static state variable updated between load steps; flow it into the extra residual through a small mutable holder so the solver need not be rebuilt:
bc0 = make_bc(0.0)
history = {'delta_max': np.zeros(interface.n_nodes)}
solver = fe.create_solver(
problem, bc0,
solver_options=fe.KrylovSolverOptions(
solver='cg', atol=1e-8, maxiter=200,
use_jacobi_preconditioner=True, verbose=True,
),
newton_options=fe.NewtonOptions(tol=1e-8, max_iter=200),
extra_residual_fn=lambda u: cohesive_residual(u, history['delta_max']),
linear=False,
)
EMPTY_IV = fe.TracedParams() # bulk elasticity carries no internal variables
Key points:
extra_residual_fnadds the cohesive residual; the bulk residual/Jacobian come fromget_energy_densityKrylovSolverOptions+linear=Falseselects the hybrid matrix-free Newton–Krylov path- The bulk Jacobian is assembled (and gives a Jacobi preconditioner); the cohesive tangent is matrix-free
verbose=Trueprints Newton convergence info
Quasi-Static Loading
Each load step increments the prescribed displacement. The solver reuses the previous solution as the initial guess, and is updated after convergence:
u_flat = np.zeros(problem.num_total_dofs_all_vars)
delta_max = np.zeros(interface.n_nodes)
for step in range(1, n_steps + 1):
disp = applied_disp * step / n_steps
# Apply BC values to initial guess, publish the current history, then solve
bc = make_bc(disp)
u_flat = u_flat.at[bc.bc_rows].set(bc.bc_vals)
history['delta_max'] = delta_max
u_flat = solver(EMPTY_IV, u_flat, bc=bc).dofs
# Update irreversibility state
delta_current = interface.get_opening(u_flat)
delta_max = np.maximum(delta_max, delta_current)
Note: solver(EMPTY_IV, u_flat, bc=bc) — the bulk has no internal variables, u_flat is the initial guess, and bc= supplies the current load step's prescribed values. The solver returns a fe.Solution; taking .dofs keeps u_flat a flat JAX array so the in-place BC update (u_flat.at[...].set(...)) works on the next step (Solution has no .at).
Post-Processing
Reaction Force via Energy Gradient
The reaction force is the internal force vector — the gradient of the total potential energy (bulk + cohesive). The scalar energies are kept purely for this post-processing:
def total_energy(u_flat, delta_max):
return elastic_energy(u_flat) + cohesive_energy(u_flat, delta_max)
fint = jax.grad(total_energy)(u_flat, delta_max)
reaction_force = float(np.sum(fint[upper_y_dofs]))
Energy Decomposition
Track elastic and cohesive energy separately to monitor fracture progression:
e_elastic = elastic_energy(u_flat)
e_cohesive = cohesive_energy(u_flat, delta_max)
When the cohesive energy reaches the total fracture work (where is the ligament area), complete separation has occurred.
Visualization
Save VTK files with displacement and fields:
fe.utils.save_sol(
mesh=mesh, sol_file='fracture3d.vtu',
point_infos=[("displacement", u), ("delta_max", d_max_full[:, None])]
)
Summary
Key concepts:
- Bulk via residual assembly —
get_energy_density()lets FEAX assemble the bulk residual and Jacobian (σ = jax.grad(ψ)); no hand-written stress - Cohesive via
extra_residual_fn— the interface contribution is added to the global residual and applied matrix-free CohesiveInterface— encodes interface geometry (node pairs, normals, weights) and builds the cohesive energy function- Hybrid Newton–Krylov — assembled bulk Jacobian (with Jacobi preconditioner) + JVP cohesive tangent; CG solves with matvecs only
- Irreversibility — tracks the maximum opening; updated between load steps and flowed in through a mutable holder
Why this split?
- Cohesive contributions couple arbitrary node pairs — hard to fit into standard FE sparse assembly, so they enter as an extra residual
- The bulk keeps the assembled Jacobian, giving a preconditioner the pure matrix-free path would lack
- The cohesive JVP costs ≈ 1 cohesive-residual evaluation, independent of the sparsity pattern
Further Reading
examples/advance/cohesive_fracture.py— Complete 3D working example- Solver Guide —
extra_residual_fnand the hybrid Newton–Krylov path - API: feax.mechanics.cohesive — Cohesive zone models