18. Nonlinear elasticity with TDNNS and MCS#
This notebook shows how the TDNNS and MCS methods can be extended to nonlinear elasticity by introducing the deformation gradient as an independent unknown and enforce its relation to the displacement weakly. This yields a Hu-Washizu formulation in which the nonlinear material law is evaluated on a regular field, while the method-dependent weak pairing handles the low regularity of the displacement.
The two methods differ mainly in the chosen spaces for the involved fields. TDNNS uses tangential-continuous displacements and normal-normal continuous stresses, while MCS uses normal-continuous displacements and tangential-normal continuous stresses. By this, the nonlinear formulations share the same lifting principle but target different robustness regimes.
18.1. Hyperelasticity in reference configuration#
Let \(u:\Omega\to\mathbb R^d\) be the displacement and
the deformation gradient. A hyperelastic material is described by an energy density \(\Psi(F)\), and the first Piola-Kirchhoff stress is
For conforming \(H^1\) displacements, the primal problem formally minimizes
For TDNNS- and MCS-type discretizations, however, \(\nabla u\) is only a distribution. Nonlinear expressions such as \(\Psi(I+\nabla u)\), \(\det(I+\nabla u)\), or \((I+\nabla u)^T(I+\nabla u)\) are then not meaningful in the classical sense. The deformation gradient therefore has to be lifted to an independent regular field.
18.2. Three-field Hu-Washizu lifting#
Introduce \(F\) as an independent unknown and enforce
with a stress-like Lagrange multiplier \(P\). The common Hu-Washizu Lagrangian is
The roles of the three fields are:
unknown |
role |
|---|---|
\(u\) |
displacement |
\(F\) |
lifted deformation gradient / strain representative |
\(P\) |
first Piola-Kirchhoff stress or stress-like Lagrange multiplier |
pairing |
method-dependent weak enforcement of \(F=I+\nabla u\) |
The material law is evaluated on \(F\), which is chosen in an \(L^2\)-type finite element space. The singular part of the formulation is isolated in the weak pairing between \(\nabla u\) and \(P\).
18.3. TDNNS and MCS as dual choices#
The nonlinear methods inherit their structure from the corresponding linear mixed methods.
method |
displacement trace |
stress trace |
typical target |
|---|---|---|---|
TDNNS |
tangential-continuous, \(H(\operatorname{curl})\) |
normal-normal continuous |
thin/anisotropic structures |
MCS |
normal-continuous, \(H(\operatorname{div})\) |
tangential-normal continuous |
incompressibility |
Both methods avoid a fully \(H^1\)-conforming displacement field, and both need a weak interpretation of \(\nabla u\) in nonlinear elasticity.
18.4. Nonlinear TDNNS#
For TDNNS, the displacement is placed in an \(H(\operatorname{curl})\)-conforming space. The stress variable uses the TDNNS stress structure, and the distributional gradient is replaced by the TDNNS pairing
A full nonlinear TDNNS lifting seeks [Neunteufel, Pechstein, Schöberl. Three-field mixed finite element methods for nonlinear elasticity. Computer Methods in Applied Mechanics and Engineering, (2021).]
where \(\widetilde H(\operatorname{div}\operatorname{div})=H(\operatorname{div}\operatorname{div}) \times [L^2]^{d\times d}_{\mathrm{skew}}\), for the Lagrangian
Here \(F\) is a regular unknown, so general nonlinear material laws can be used without applying them to a distributional displacement gradient.
18.4.1. Symmetric TDNNS lifting#
Since
there is no need to lift the complete deformation gradient. It is enough to lift its symmetric part. The deformation gradient is represented as
and only the symmetric part of the stress multiplier is needed. With hybridization, one obtains the Lagrangian for \((u,F_{\mathrm{sym}},P_{\mathrm{sym}})\in H(\operatorname{curl})\times [L^2(\Omega)]^{d\times d}_{\mathrm{sym}}\times H(\operatorname{div}\operatorname{div})\)
The next example uses this reduced lifting.
18.5. Cook’s membrane#
from ngsolve import *
from ngsolve.webgui import Draw
import ipywidgets as widgets
from ngsolve.meshes import MakeQuadMesh
L_1 = 48 # 48m
L_2 = 44 # 44m
L_3 = 16 # 16m
def GenerateMesh(num_el=2):
mesh = MakeQuadMesh(
nx=num_el,
ny=num_el,
mapping=lambda x, y: (L_1 * x, L_2 * x + L_2 * y - (L_2 - L_3) * x * y),
)
return mesh
mesh = GenerateMesh()
mu = Parameter(80.194) # N/mm^2
lam = Parameter(400889.8) # N/mm^2
par = Parameter(0)
force = CoefficientFunction((0, par * 32))
def NeoHooke(F):
C = F.trans * F
return mu / 2 * (Trace(C - Id(2)) - log(Det(C))) + lam / 2 * (Det(F) - 1) ** 2
def SkewT(v):
if v.dim == 3:
return 0.5 * CoefficientFunction(
(0, -v[2], v[1], v[2], 0, -v[0], -v[1], v[0], 0), dims=(3, 3)
)
else:
return 0.5 * CoefficientFunction((0, -v, v, 0), dims=(2, 2))
order = 3
# clamped at left, free elsewhere
V = HCurl(mesh, order=order, dirichlet="left")
Sigma = Discontinuous(HDivDiv(mesh, order=order))
Hyb = NormalFacetFESpace(mesh, order=order, dirichlet="left")
Gamma = MatrixValued(L2(mesh, order=order + 1), symmetric=True)
fes = V * Sigma * Gamma * Hyb
gf_solution = GridFunction(fes)
u, Psym, Fsym, uh = fes.TrialFunction()
Fmat = Fsym + SkewT(curl(u))
n = specialcf.normal(2)
a = BilinearForm(fes, symmetric=True, condense=True)
a += Variation((NeoHooke(Fmat)) * dx).Compile()
a += Variation(InnerProduct(Fsym - Grad(u) - Id(2), Psym) * dx).Compile()
a += Variation((u - uh) * n * Psym[n, n] * dx(element_boundary=True)).Compile()
a += Variation(-(force * uh.Trace() + force * u.Trace()) * ds("right")).Compile()
gf_u, gf_Psym, gf_Fsym, gf_uh = gf_solution.components
gf_F = gf_Fsym + SkewT(curl(gf_u))
# F=I
gf_Fsym.Set(Id(2))
scene = Draw(
gf_u,
mesh,
deformation=True,
settings={
"camera": {
"transformations": [{"type": "move", "dir": (0, 0, 1), "dist": -1.5}]
}
},
)
tw = widgets.Text(value="step = 0")
display(tw)
gf_history = GridFunction(fes, multidim=0)
num_steps = 40
with TaskManager():
for step in range(num_steps):
par.Set((step + 1) / num_steps)
solvers.Newton(
a,
gf_solution,
maxerr=1e-8,
printing=False,
inverse="sparsecholesky",
)
scene.Redraw()
tw.value = f"step = {step+1}/{num_steps}"
gf_history.AddMultiDimComponent(gf_solution.vec)
Draw(
gf_history.components[0],
mesh,
animate=True,
min=0,
max=25,
autoscale=True,
deformation=True,
settings={
"camera": {
"transformations": [{"type": "move", "dir": (0, 0, 1), "dist": -1.5}]
}
},
);
18.6. Thin cylindrical shell#
The next benchmark is a compressed cylindrical shell. The shell is a half cylinder of length \(L=15\) and thickness \(t=0.2\). The right lower edge is fixed, and a vertical load is applied on the right upper face. The mesh is intentionally coarse so that the example remains notebook-sized.
from ngsolve.meshes import MakeHexMesh
from netgen.occ import *
from math import pi, cos, sin
def GenerateCylindricalShellMesh(thickness=0.2, length=15, maxh=2, order=2):
inner_radius = 9 - thickness / 2
outer_radius = inner_radius + thickness
outer_cylinder = Cylinder(
(0, 0, 0), (0, 0, 1), r=outer_radius, h=length, mantle="outer"
)
inner_cylinder = Cylinder(
(0, 0, 0), (0, 0, 1), r=inner_radius, h=length, mantle="inner"
)
shell = (outer_cylinder - inner_cylinder) * Box(
(-length + thickness, -(length + thickness), 0), (0, length + thickness, length)
)
shell.faces.Min(Z).name = "back"
shell.faces.Max(Z).name = "front"
shell.faces.Max(X).name = "rightbot"
shell.faces.Max(Y).name = "righttop"
trf = gp_GTrsf(
(
outer_radius / inner_radius,
0,
0,
0,
outer_radius / inner_radius,
0,
0,
0,
1,
),
(0, 0, 0),
)
shell.faces["inner"].Identify(
shell.faces["outer"], "cs", IdentificationType.CLOSESURFACES, trf
)
return Mesh(
OCCGeometry(shell).GenerateMesh(maxh=maxh, quad_dominated=True)
).Curve(order)
def CylindricalShellNeoHooke(mu, lam, F):
return (
mu / 2 * (Trace(F.trans * F) - 3)
- mu * log(Det(F))
+ lam / 2 * log(Det(F)) ** 2
)
def CylindricalShellSkew(v):
return 0.5 * CoefficientFunction(
(0, -v[2], v[1], v[2], 0, -v[0], -v[1], v[0], 0), dims=(3, 3)
)
def CylindricalShellPoint(thickness=0.2):
inner_radius = 9 - thickness / 2
return (-0.5 * thickness, 2 * inner_radius + 0.5 * thickness, 15 - 0.5 * thickness)
def CylindricalShellDeflection(displacement, mesh, thickness=0.2):
inner_radius = 9 - thickness / 2
points = [
CylindricalShellPoint(thickness),
(-0.5 * thickness, inner_radius + 0.5 * thickness, 15 - 0.5 * thickness),
(-0.5 * thickness, inner_radius + 0.5 * thickness, 7.5),
(-1.0, inner_radius - 0.5 * thickness, 7.5),
]
for point in points:
try:
return displacement(mesh(*point))[1]
except Exception:
pass
raise RuntimeError("Could not find a cylindrical-shell evaluation point in the mesh.")
cyl_shell_mesh = GenerateCylindricalShellMesh()
Draw(cyl_shell_mesh);
def SolveCylindricalShellStandard(
mesh,
order=2,
thickness=0.2,
mu=6000,
lam=24000,
force_magnitude=-2.7,
num_steps=15,
printing=False,
):
par = Parameter(0)
force = CoefficientFunction((0, force_magnitude, 0))
I3 = Id(3)
fes = VectorH1(
mesh,
order=order,
dirichletx="righttop|rightbot",
dirichlety="rightbot",
dirichletz="rightbot|back",
)
gf_solution = GridFunction(fes, name="standard shell")
u = fes.TrialFunction()
F = Grad(u) + I3
a = BilinearForm(fes, symmetric=True, condense=True)
a += Variation(
CylindricalShellNeoHooke(mu, lam, F) * dx
- par * force * u * ds("righttop")
).Compile()
history = GridFunction(fes, multidim=0)
for step in range(num_steps):
print(f"Step {step+1}/{num_steps}")
par.Set((step + 1) / num_steps)
solvers.Newton(
a,
gf_solution,
maxerr=1e-8,
dampfactor=0.4,
maxit=20,
printing=printing,
inverse="sparsecholesky",
)
history.AddMultiDimComponent(gf_solution.vec)
return {
"name": "standard",
"u": gf_solution,
"history": history,
"deflection": CylindricalShellDeflection(gf_solution, mesh, thickness),
"dofs": sum(fes.FreeDofs()),
}
def SolveCylindricalShellTDNNS(
mesh,
order=2,
thickness=0.2,
mu=6000,
lam=24000,
force_magnitude=-2.7,
num_steps=15,
printing=False,
):
par = Parameter(0)
force = CoefficientFunction((0, force_magnitude, 0))
I3 = Id(3)
n = specialcf.normal(3)
V = HCurl(mesh, order=order, dirichlet="rightbot")
Sigma = Discontinuous(HDivDiv(mesh, order=order))
Gamma = Discontinuous(HCurlCurl(mesh, order=order, orderinner=order + 1))
Hyb = NormalFacetFESpace(mesh, order=order, dirichlet="righttop|rightbot|back")
fes = V * Sigma * Gamma * Hyb
gf_solution = GridFunction(fes, name="TDNNS shell")
u, Psym, Fsym, uhat = fes.TrialFunction()
F = Fsym + CylindricalShellSkew(curl(u))
a = BilinearForm(fes, symmetric=True, symmetric_storage=True, condense=True)
a += Variation(CylindricalShellNeoHooke(mu, lam, F) * dx).Compile()
a += Variation(InnerProduct(Fsym - Grad(u) - I3, Psym) * dx).Compile()
a += Variation(
(u - uhat) * n * (Psym * n) * n * dx(element_boundary=True, bonus_intorder=0)
).Compile(False)
a += Variation(-par * (force * uhat.Trace() + force * u.Trace()) * ds("righttop")).Compile()
gf_u, gf_Psym, gf_Fsym, gf_uhat = gf_solution.components
gf_Fsym.Set(I3)
history = GridFunction(fes, multidim=0)
for step in range(num_steps):
print(f"Step {step+1}/{num_steps}")
par.Set((step + 1) / num_steps)
solvers.Newton(
a,
gf_solution,
maxerr=1e-6,
dampfactor=0.4,
maxit=12,
printing=printing,
inverse="sparsecholesky",
)
history.AddMultiDimComponent(gf_solution.vec)
return {
"name": "TDNNS",
"u": gf_u,
"F": gf_Fsym + CylindricalShellSkew(curl(gf_u)),
"history": history,
"deflection": CylindricalShellDeflection(gf_u, mesh, thickness),
"dofs": sum(fes.FreeDofs(True)),
}
shell_steps = 15
SetHeapSize(1000 * 1000 * 1000)
with TaskManager():
shell_standard = SolveCylindricalShellStandard(
cyl_shell_mesh,
num_steps=shell_steps,
)
shell_tdnns = SolveCylindricalShellTDNNS(
cyl_shell_mesh,
num_steps=shell_steps,
)
for result in [shell_standard, shell_tdnns]:
print(
f"{result['name']:>8s}: "
f"u_y(A) = {result['deflection']: .5e}"
)
Step 1/15
Step 2/15
Step 3/15
Step 4/15
Step 5/15
Step 6/15
Step 7/15
Step 8/15
Step 9/15
Step 10/15
Step 11/15
Step 12/15
Step 13/15
Step 14/15
Step 15/15
WARNING: kwarg 'orderinner' is an undocumented flags option for class <class 'ngsolve.comp.HCurlCurl'>, maybe there is a typo?
Step 1/15
Step 2/15
Step 3/15
Step 4/15
Step 5/15
Step 6/15
Step 7/15
Step 8/15
Step 9/15
Step 10/15
Step 11/15
Step 12/15
Step 13/15
Step 14/15
Step 15/15
standard: u_y(A) = -9.75124e+00
TDNNS: u_y(A) = -1.47853e+01
Draw(shell_standard["u"], cyl_shell_mesh, "standard shell", deformation=shell_standard["u"]);
Draw(BoundaryFromVolumeCF(shell_tdnns["u"]), cyl_shell_mesh, "TDNNS shell", deformation=BoundaryFromVolumeCF(shell_tdnns["u"]));
18.7. Nonlinear MCS#
The MCS extension follows the same Hu-Washizu idea but uses different spaces motivated by the linear MCS method. The displacement is normal-continuous, while the stress-like variable carries the tangential-normal continuity needed for the weak gradient pairing, [Fu, Neunteufel, Schöberl, Zdunek. A four-field mixed formulation for incompressible finite elasticity. Computer Methods in Applied Mechanics and Engineering, (2025).].
The nonlinear MCS formulation introduces a lifted deformation gradient and a pressure variable. A typical incompressible neo-Hookean density is
where \(p\) enforces the incompressibility constraint \(\det F=1\). In the MCS formulation the lifted gradient is the regular representative used in the determinant, while the relation to \(u\) is enforced weakly through the MCS pairing.
We have the following fields:
unknown |
possible space |
meaning |
|---|---|---|
\(u\) |
|
normal-continuous displacement |
\(P\) |
|
stress-like multiplier |
\(F\) |
discontinuous |
lifted deformation-gradient part |
\(\widehat u\) |
tangential facet space |
hybrid tangential displacement trace |
\(p\) |
|
pressure / incompressibility multiplier |
The practical robustness message is different from TDNNS: the MCS formulation is aimed at nearly or fully incompressible nonlinear elasticity, i.e. \(J=\det F\approx 1\).
18.8. Compressed block#
The geometry is a unit cube with a smaller upper corner block attached on the region \(x,y\geq 0.5\). The load acts downward on the small top face. We compare the nonlinear MCS method with a standard displacement-pressure formulation.
from ngsolve import *
from ngsolve.webgui import Draw
import ipywidgets as widgets
from netgen.occ import *
def GenerateCompressedBlockMesh(h0=0.55, h1=0.35, h2=0.35, h3=0.35):
box0 = Box(Pnt(0, 0, 0), Pnt(1, 1, 1))
box1 = Box(Pnt(0.5, 0.5, 0), Pnt(1, 1, 1))
left = box0 - box1
left.faces.Max(Z).name = "top"
left.faces.Min(Z).name = "bottom"
left.faces.Min(X).name = "left"
left.faces.Max(X).name = "right"
left.faces.Min(Y).name = "back"
left.faces.Max(Y).name = "front"
box1.faces.Max(Z).name = "top1"
box1.faces.Min(Z).name = "bottom"
box1.faces.Min(X).name = "interface"
box1.faces.Max(X).name = "right"
box1.faces.Min(Y).name = "interface"
box1.faces.Max(Y).name = "front"
box1.solids.maxh = h1
box1.faces.Max(Z).maxh = h2
box1.faces.Max(X).maxh = h3
box1.faces.Max(Y).maxh = h3
return Mesh(OCCGeometry(Glue([left, box1])).GenerateMesh(maxh=h0))
def NeoHookeIncompressible(mu, F, p, mesh):
return mu / 2 * (Trace(F.trans * F) - mesh.dim) - p * (Det(F) - 1)
def ProjectJacobian(jac_cf, mesh):
gf_jac = GridFunction(L2(mesh, order=0), name="Jacobian")
gf_jac.Set(jac_cf)
return gf_jac, min(gf_jac.vec), max(gf_jac.vec)
def PointValue(cf, mesh, point=(1, 1, 1)):
return cf(mesh(*point))
block_mesh = GenerateCompressedBlockMesh()
Draw(block_mesh);
def SolveCompressedBlockMCS(
mesh,
order=2,
mu=8.0194,
force0=-40,
stabD=800,
num_steps=8,
maxerr=1e-7,
printing=False,
):
par = Parameter(0)
force = CoefficientFunction((0, 0, force0))
n = specialcf.normal(mesh.dim)
h = specialcf.mesh_size
V = HDiv(mesh, order=order, RT=True, dirichlet="bottom|right|front")
Sigma = Discontinuous(HCurlDiv(mesh, order=order, ordertrace=-1))
Gamma = Discontinuous(HCurlDiv(mesh, order=order, ordertrace=-1))
Hyb = TangentialFacetFESpace(mesh, order=order, dirichlet="top|top1")
Q = L2(mesh, order=order, lowest_order_wb=False)
fes = V * Sigma * Gamma * Hyb * Q
gf_solution = GridFunction(fes, name="MCS")
u, P, F, uhat, p = fes.TrialFunction()
def tang(v):
return v - (v * n) * n
Fmat = F.trans + (div(u) / mesh.dim + 1) * Id(mesh.dim)
stab0 = GridFunction(L2(mesh, order=0))
stiff_region = IfPos(x - 0.4, 1, 0) * IfPos(y - 0.4, 1, 0)
stab0.Set(stiff_region * mu * stabD / h + (1 - stiff_region) * mu * stabD)
a = BilinearForm(fes, symmetric=True, symmetric_storage=True, condense=True)
a += Variation(
(
NeoHookeIncompressible(mu, Fmat, p, mesh)
- 0.5 * mu * 1e-6 * p**2
- InnerProduct(F.trans - Grad(u), P)
)
* dx
+ (0.5 * stab0 * tang(u - uhat) ** 2 - tang(u - uhat) * tang(P * n))
* dx(element_boundary=True)
- par * force * (u.Trace() + tang(uhat.Trace())) * ds("top1")
).Compile()
gf_u, gf_P, gf_F, gf_uhat, gf_p = gf_solution.components
gf_history = GridFunction(fes, multidim=0)
for step in range(num_steps):
par.Set((step + 1) / num_steps)
solvers.Newton(
a,
gf_solution,
maxerr=maxerr,
maxit=30,
printing=printing,
inverse="sparsecholesky",
)
gf_history.AddMultiDimComponent(gf_solution.vec)
Fh = gf_F.trans + (div(gf_u) / mesh.dim + 1) * Id(mesh.dim)
gf_jac, jac_min, jac_max = ProjectJacobian(Det(Fh), mesh)
return {
"name": "MCS",
"fes": fes,
"solution": gf_solution,
"u": gf_u,
"p": gf_p,
"F": Fh,
"J": gf_jac,
"J_min": jac_min,
"J_max": jac_max,
"history": gf_history,
"point_u": PointValue(gf_u, mesh),
}
def SolveCompressedBlockStandard(
mesh,
order=2,
mu=8.0194,
force0=-40,
num_steps=8,
maxerr=1e-7,
printing=False,
):
par = Parameter(0)
force = CoefficientFunction((0, 0, force0))
V = VectorH1(
mesh,
order=order,
dirichletx="right|top|top1",
dirichlety="front|top|top1",
dirichletz="bottom",
)
Q = H1(mesh, order=max(order - 1, 1))
fes = V * Q
gf_solution = GridFunction(fes, name="standard")
u, p = fes.TrialFunction()
Fmat = Grad(u) + Id(mesh.dim)
a = BilinearForm(fes, symmetric=True)
a += Variation(
(NeoHookeIncompressible(mu, Fmat, p, mesh) - 1e-8 * p**2) * dx
- par * force * u * ds("top1")
).Compile()
gf_u, gf_p = gf_solution.components
gf_history = GridFunction(fes, multidim=0)
for step in range(num_steps):
par.Set((step + 1) / num_steps)
solvers.Newton(
a,
gf_solution,
maxerr=maxerr,
maxit=30,
printing=printing,
inverse="sparsecholesky",
)
gf_history.AddMultiDimComponent(gf_solution.vec)
Fh = Grad(gf_u) + Id(mesh.dim)
gf_jac, jac_min, jac_max = ProjectJacobian(Det(Fh), mesh)
return {
"name": "standard",
"fes": fes,
"solution": gf_solution,
"u": gf_u,
"p": gf_p,
"F": Fh,
"J": gf_jac,
"J_min": jac_min,
"J_max": jac_max,
"history": gf_history,
"point_u": PointValue(gf_u, mesh),
}
block_order = 2
block_force = -40
block_steps = 8
with TaskManager():
block_mcs = SolveCompressedBlockMCS(
block_mesh,
order=block_order,
force0=block_force,
num_steps=block_steps,
)
block_std = SolveCompressedBlockStandard(
block_mesh,
order=block_order,
force0=block_force,
num_steps=block_steps,
)
for result in [block_mcs, block_std]:
print(
f"{result['name']:>8s}: "
f"u_z(1,1,1) = {result['point_u'][2]: .5e}, "
f"min J = {result['J_min']: .5e}, max J = {result['J_max']: .5e}"
)
MCS: u_z(1,1,1) = -7.45066e-01, min J = 9.93900e-01, max J = 1.00414e+00
standard: u_z(1,1,1) = -6.71251e-01, min J = -7.99520e-02, max J = 1.59053e+00
block_mcs_u_bnd = BoundaryFromVolumeCF(block_mcs["u"])
Draw(block_mcs_u_bnd, block_mesh, "MCS displacement", deformation=block_mcs_u_bnd);
Draw(block_std["u"], block_mesh, "standard displacement", deformation=block_std["u"]);