Exercise: Implement the LS model from the AT1 state#

Authors:

  • Jack S. Hale (University of Luxembourg)

  • Corrado Maurini (Sorbonne Université)

This notebook contains a slightly modified version of the AT1 gradient damage tutorial. Modify it so that it expresses the LS model using the 3rd parameterisation of Table 1 and case b) the intermediate-length bar in Figure 7:

Preamble#

We begin by importing the required Python modules.

import sys

from mpi4py import MPI
from petsc4py import PETSc

import matplotlib.pyplot as plt
import numpy as np

import basix
import dolfinx
import dolfinx.fem.petsc
import ufl
from dolfinx import fem, io, la, mesh, plot

sys.path.append("../utils/")

import pyvista
from evaluate_on_points import evaluate_on_points
from petsc_problems import SNESProblem
from plots import plot_damage_state
from pyvista.utilities.xvfb import start_xvfb

start_xvfb(wait=0.5)

Mesh#

We define the mesh using the built-in DOLFINx mesh generation functions for simply geometries.

L = 1.0
H = 0.3
ell_ = 0.1
cell_size = ell_ / 6

nx = int(L / cell_size)
ny = int(H / cell_size)

comm = MPI.COMM_WORLD
msh = mesh.create_rectangle(
    comm, [(0.0, 0.0), (L, H)], [nx, ny], cell_type=mesh.CellType.quadrilateral
)
ndim = msh.geometry.dim

topology, cell_types, geometry = plot.vtk_mesh(msh)
grid = pyvista.UnstructuredGrid(topology, cell_types, geometry)
plotter = pyvista.Plotter()
plotter.add_mesh(grid, show_edges=True, show_scalar_bar=True)
plotter.view_xy()
plotter.add_axes()
plotter.set_scale(5, 5)
if not pyvista.OFF_SCREEN:
    plotter.show()

Setting the stage#

We setup the finite element space, the states, the bound constraints on the states and UFL measures.

We use (vector-valued) linear Lagrange finite elements on quadrilaterals for displacement and damage.

element_u = basix.ufl.element("Lagrange", msh.basix_cell(), degree=1, shape=(msh.geometry.dim,))
V_u = fem.functionspace(msh, element_u)

element_alpha = basix.ufl.element("Lagrange", msh.basix_cell(), degree=1)
V_alpha = fem.functionspace(msh, element_alpha)

# Define the state
u = fem.Function(V_u, name="displacement")
alpha = fem.Function(V_alpha, name="damage")

# Domain measure.
dx = ufl.Measure("dx", domain=msh)

Boundary conditions#

We impose Dirichlet boundary conditions on the displacement and the damage field on the appropriate parts of the boundary.

We do this using predicates. DOLFINx will pass an array of the midpoints of all facets (edges) as an argument x with shape (3, num_edges) to our predicate. The predicate we define must return an boolean array of shape (num_edges) containing True if the edge is on the desired boundary, and False if not.

def right(x):
    return np.isclose(x[0], L)


def left(x):
    return np.isclose(x[0], 0.0)

The function mesh.locate_entities_boundary calculates the indices of the edges on the boundary defined by our predicate.

fdim = msh.topology.dim - 1

left_facets = mesh.locate_entities_boundary(msh, fdim, left)
right_facets = mesh.locate_entities_boundary(msh, fdim, right)

The function fem.locate_dofs_topological calculates the indices of the degrees of freedom associated with the edges. This is the information the assembler will need to apply Dirichlet boundary conditions.

left_boundary_dofs_ux = fem.locate_dofs_topological(V_u.sub(0), fdim, left_facets)
right_boundary_dofs_ux = fem.locate_dofs_topological(V_u.sub(0), fdim, right_facets)

Using fem.Constant will allow us to update the value of the boundary condition applied in the pseudo-time loop.

u_D = fem.Constant(msh, 0.5)
bc_ux_left = fem.dirichletbc(0.0, left_boundary_dofs_ux, V_u.sub(0))
bc_ux_right = fem.dirichletbc(u_D, right_boundary_dofs_ux, V_u.sub(0))

bcs_u = [
    bc_ux_left,
    bc_ux_right,
]

and similarly for the damage field.

left_boundary_dofs_alpha = fem.locate_dofs_topological(V_alpha, fdim, left_facets)
right_boundary_dofs_alpha = fem.locate_dofs_topological(V_alpha, fdim, right_facets)

bc_alpha_left = fem.dirichletbc(0.0, left_boundary_dofs_alpha, V_alpha)
bc_alpha_right = fem.dirichletbc(0.0, right_boundary_dofs_alpha, V_alpha)

bcs_alpha = [bc_alpha_left, bc_alpha_right]

Variational formulation of the problem#

Constitutive model#

To implement the S-LS you will need to define new parameters and modify the model functions appropriately.

E, nu = (
    fem.Constant(msh, dolfinx.default_scalar_type(100.0)),
    fem.Constant(msh, dolfinx.default_scalar_type(0.3)),
)
Gc = fem.Constant(msh, dolfinx.default_scalar_type(1.0))
ell = fem.Constant(msh, dolfinx.default_scalar_type(ell_))
c_w = fem.Constant(msh, dolfinx.default_scalar_type(8.0 / 3.0))
eps_c = 0.19364936095953816


def w(alpha):
    """Dissipated energy function as a function of the damage"""
    return alpha


def a(alpha, k_ell=1.0e-6):
    """Stiffness modulation as a function of the damage"""
    return (1 - alpha) ** 2 + k_ell


def eps(u):
    """Strain tensor as a function of the displacement"""
    return ufl.sym(ufl.grad(u))


def sigma_0(eps):
    """Stress tensor of the undamaged material as a function of the strain"""
    mu = E / (2.0 * (1.0 + nu))
    lmbda = E * nu / (1.0 - nu**2)
    return 2.0 * mu * eps + lmbda * ufl.tr(eps) * ufl.Identity(ndim)


def sigma(eps, alpha):
    """Stress tensor of the damaged material as a function of the displacement and the damage"""
    return a(alpha) * sigma_0(eps)

Energy functional and its derivatives#

We use the ufl package of FEniCS to define the energy functional. The residual (first Gateaux derivative of the energy functional) and Jacobian (second Gateaux derivative of the energy functional) can then be derived through automatic symbolic differentiation using ufl.derivative.

f = fem.Constant(msh, PETSc.ScalarType((0.0, 0.0)))
elastic_energy = 0.5 * ufl.inner(sigma(eps(u), alpha), eps(u)) * dx
dissipated_energy = (
    Gc / c_w * (w(alpha) / ell + ell * ufl.inner(ufl.grad(alpha), ufl.grad(alpha))) * dx
)
external_work = ufl.inner(f, u) * dx
total_energy = elastic_energy + dissipated_energy - external_work

To block rigid bode modes in a simple but effective way, we add a very weak elastic foundation.

k_springs = fem.Constant(msh, PETSc.ScalarType(1.0e-8))
very_weak_springs_to_block_rigid_body_modes = k_springs * ufl.inner(u, u) * dx
total_energy += very_weak_springs_to_block_rigid_body_modes

Solvers#

Displacement problem#

The displacement problem (\(u\)) at for fixed damage (\(\alpha\)) is a linear problem equivalent to linear elasticity with a spatially varying stiffness. We solve it with a standard linear solver. We use automatic differention to get the first derivative of the energy. We use a direct solve to solve the linear system, but you can also set iterative solvers and preconditioners when solving large problem in parallel.

E_u = ufl.derivative(total_energy, u, ufl.TestFunction(V_u))
E_u_u = ufl.derivative(E_u, u, ufl.TrialFunction(V_u))
elastic_problem = SNESProblem(E_u, u, bcs_u)

b_u = la.create_petsc_vector(V_u.dofmap.index_map, V_u.dofmap.index_map_bs)
J_u = dolfinx.fem.petsc.create_matrix(elastic_problem.a)

# Create Newton solver and solve
solver_u_snes = PETSc.SNES().create()
solver_u_snes.setType("ksponly")
solver_u_snes.setFunction(elastic_problem.F, b_u)
solver_u_snes.setJacobian(elastic_problem.J, J_u)
solver_u_snes.setTolerances(rtol=1.0e-9, max_it=50)
solver_u_snes.getKSP().setType("preonly")
solver_u_snes.getKSP().setTolerances(rtol=1.0e-9)
solver_u_snes.getKSP().getPC().setType("lu")

Damage problem with bound-constraint#

The damage problem (\(\alpha\)) at fixed displacement (\(u\)) is a variational inequality due to the irreversibility constraint and the bounds on the damage. We solve it using a specific solver for bound-constrained provided by PETSc, called SNESVI. To this end we define with a specific syntax a class defining the problem, and the lower (lb) and upper (ub) bounds.

E_alpha = ufl.derivative(total_energy, alpha, ufl.TestFunction(V_alpha))
E_alpha_alpha = ufl.derivative(E_alpha, alpha, ufl.TrialFunction(V_alpha))

We now set up the PETSc solver using petsc4py, a fully featured Python wrapper around PETSc.

damage_problem = SNESProblem(E_alpha, alpha, bcs_alpha, J=E_alpha_alpha)

b_alpha = la.create_petsc_vector(V_alpha.dofmap.index_map, V_alpha.dofmap.index_map_bs)
J_alpha = fem.petsc.create_matrix(damage_problem.a)

# Create Newton variational inequality solver and solve
solver_alpha_snes = PETSc.SNES().create()
solver_alpha_snes.setType("vinewtonrsls")
solver_alpha_snes.setFunction(damage_problem.F, b_alpha)
solver_alpha_snes.setJacobian(damage_problem.J, J_alpha)
solver_alpha_snes.setTolerances(rtol=1.0e-9, max_it=50)
solver_alpha_snes.getKSP().setType("preonly")
solver_alpha_snes.getKSP().setTolerances(rtol=1.0e-9)
solver_alpha_snes.getKSP().getPC().setType("lu")

# Lower bound for the damage field
alpha_lb = fem.Function(V_alpha, name="lower bound")
alpha_lb.x.array[:] = 0.0
# Upper bound for the damage field
alpha_ub = fem.Function(V_alpha, name="upper bound")
alpha_ub.x.array[:] = 1.0
solver_alpha_snes.setVariableBounds(alpha_lb.x.petsc_vec, alpha_ub.x.petsc_vec)

Before continuing we reset the displacement and damage to zero.

alpha.x.array[:] = 0.0
u.x.array[:] = 0.0

The static problem: solution with the alternate minimization algorithm#

We solve the non-linear problem in \((u,\alpha)\) at each pseudo-timestep by a fixed-point algorithm consisting of alternate minimization with respect to \(u\) at fixed \(\alpha\) and then for \(\alpha\) at fixed \(u\) until convergence is achieved.

We now define a function that alternate_minimization that performs the alternative minimisation algorithm and assesses convergence based on the \(L^2\) norm of the difference between the damage field at the current iterate and the previous iterate.

def simple_monitor(u, alpha, iteration, error_L2):
    print(f"Iteration: {iteration}, Error: {error_L2:3.4e}")


def alternate_minimization(u, alpha, atol=1e-8, max_iterations=100, monitor=simple_monitor):
    alpha_old = fem.Function(alpha.function_space)
    alpha_old.x.array[:] = alpha.x.array

    for iteration in range(max_iterations):
        # Solve for displacement
        solver_u_snes.solve(None, u.x.petsc_vec)
        # This forward scatter is necessary when `solver_u_snes` is of type `ksponly`.
        u.x.scatter_forward()

        # Solve for damage
        solver_alpha_snes.solve(None, alpha.x.petsc_vec)

        # Check error and update
        L2_error = ufl.inner(alpha - alpha_old, alpha - alpha_old) * dx
        error_L2 = np.sqrt(comm.allreduce(fem.assemble_scalar(fem.form(L2_error)), op=MPI.SUM))
        alpha_old.x.array[:] = alpha.x.array

        if monitor is not None:
            monitor(u, alpha, iteration, error_L2)

        if error_L2 <= atol:
            return (error_L2, iteration)

    raise RuntimeError(
        f"Could not converge after {max_iterations} iterations, error {error_L2:3.4e}"
    )

Time-stepping: solving a quasi-static problem#

load_c = eps_c * L  # reference value for the loading (imposed displacement)
loads = np.linspace(0, 1.5 * load_c, 20)

# Array to store results
energies = np.zeros((loads.shape[0], 3))

# File to store the results
file_name = "output/solution.xdmf"
with io.XDMFFile(comm, file_name, "w", encoding=io.XDMFFile.Encoding.HDF5) as file:
    file.write_mesh(msh)

for i_t, t in enumerate(loads):
    u_D.value = t
    energies[i_t, 0] = t

    # Update the lower bound to ensure irreversibility of damage field.
    alpha_lb.x.array[:] = alpha.x.array

    print(f"-- Solving for t = {t:3.2f} --")
    alternate_minimization(u, alpha)
    plot_damage_state(u, alpha)

    # Calculate the energies
    energies[i_t, 1] = comm.allreduce(
        dolfinx.fem.assemble_scalar(dolfinx.fem.form(elastic_energy)),
        op=MPI.SUM,
    )
    energies[i_t, 2] = comm.allreduce(
        dolfinx.fem.assemble_scalar(dolfinx.fem.form(dissipated_energy)),
        op=MPI.SUM,
    )
    with io.XDMFFile(comm, file_name, "a", encoding=io.XDMFFile.Encoding.HDF5) as file:
        file.write_function(u, t)
        file.write_function(alpha, t)
-- Solving for t = 0.00 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.02 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.03 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.05 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.06 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.08 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.09 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.11 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.12 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.14 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.15 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.17 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.18 --
Iteration: 0, Error: 0.0000e+00
-- Solving for t = 0.20 --
Iteration: 0, Error: 2.1272e-02
Iteration: 1, Error: 1.0519e-02
Iteration: 2, Error: 1.4078e-02
Iteration: 3, Error: 2.0070e-02
Iteration: 4, Error: 2.6720e-02
Iteration: 5, Error: 3.3630e-02
Iteration: 6, Error: 3.8487e-02
Iteration: 7, Error: 3.6490e-02
Iteration: 8, Error: 2.6996e-02
Iteration: 9, Error: 2.8528e-02
Iteration: 10, Error: 2.4109e-02
Iteration: 11, Error: 2.8169e-03
Iteration: 12, Error: 5.5855e-05
Iteration: 13, Error: 7.0199e-06
Iteration: 14, Error: 8.2771e-07
Iteration: 15, Error: 2.0786e-07
Iteration: 16, Error: 5.9002e-07
Iteration: 17, Error: 1.8849e-06
Iteration: 18, Error: 6.0224e-06
Iteration: 19, Error: 1.9242e-05
Iteration: 20, Error: 6.1489e-05
Iteration: 21, Error: 1.9671e-04
Iteration: 22, Error: 6.3662e-04
Iteration: 23, Error: 2.2716e-03
Iteration: 24, Error: 9.7398e-03
Iteration: 25, Error: 6.9821e-03
Iteration: 26, Error: 6.4579e-05
Iteration: 27, Error: 8.3245e-07
Iteration: 28, Error: 4.3245e-08
Iteration: 29, Error: 2.2360e-09
-- Solving for t = 0.21 --
Iteration: 0, Error: 1.8095e-04
Iteration: 1, Error: 3.1128e-06
Iteration: 2, Error: 1.1882e-07
Iteration: 3, Error: 4.5037e-09
-- Solving for t = 0.23 --
Iteration: 0, Error: 1.4476e-04
Iteration: 1, Error: 1.9031e-06
Iteration: 2, Error: 5.5300e-08
Iteration: 3, Error: 1.6031e-09
-- Solving for t = 0.24 --
Iteration: 0, Error: 1.1779e-04
Iteration: 1, Error: 1.2044e-06
Iteration: 2, Error: 2.7079e-08
Iteration: 3, Error: 6.1082e-10
-- Solving for t = 0.26 --
Iteration: 0, Error: 9.7227e-05
Iteration: 1, Error: 7.8531e-07
Iteration: 2, Error: 1.3858e-08
Iteration: 3, Error: 2.4690e-10
-- Solving for t = 0.28 --
Iteration: 0, Error: 8.1238e-05
Iteration: 1, Error: 5.2589e-07
Iteration: 2, Error: 7.3745e-09
-- Solving for t = 0.29 --
Iteration: 0, Error: 6.8604e-05
Iteration: 1, Error: 3.6080e-07
Iteration: 2, Error: 4.0656e-09

We now plot the total, elastic and dissipated energies throughout the pseudo-time evolution against the applied displacement.

(p3,) = plt.plot(energies[:, 0], energies[:, 1] + energies[:, 2], "ko", linewidth=2, label="Total")
(p1,) = plt.plot(energies[:, 0], energies[:, 1], "b*", linewidth=2, label="Elastic")
(p2,) = plt.plot(energies[:, 0], energies[:, 2], "r^", linewidth=2, label="Dissipated")
plt.legend()

# plt.axvline(x=eps_c * L, color="grey", linestyle="--", linewidth=2)
# plt.axhline(y=H, color="grey", linestyle="--", linewidth=2)

plt.xlabel("Displacement")
plt.ylabel("Energy")

plt.savefig("output/energies.png")
../../_images/0c1eca670fc06dc51ceb4c7ebbae03d1fa526f3f8f38a89bca926eaf3a27569b.png

Verification#

The plots above indicates that the crack appears at the elastic limit calculated analytically (see the gridlines) and that the dissipated energy coincides with the length of the crack times the fracture toughness \(G_c\). Let’s check the dissipated energy explicity.

surface_energy_value = comm.allreduce(
    dolfinx.fem.assemble_scalar(dolfinx.fem.form(dissipated_energy)), op=MPI.SUM
)
print(f"The numerical dissipated energy on the crack is {surface_energy_value:.3f}")
print(f"The expected analytical value is {H:.3f}")
The numerical dissipated energy on the crack is 0.318
The expected analytical value is 0.300

Let’s take a look at the damage profile and verify that we acheive the expected solution for the AT1 model. We can easily see that the solution is bounded between \(0\) and \(1\) and that the decay to zero of the damage profile happens around the theoretical half-width \(D\).

tol = 0.001  # Avoid hitting the boundary of the mesh
num_points = 101
points = np.zeros((num_points, 3))

y = np.linspace(0.0 + tol, L - tol, num=num_points)
points[:, 0] = y
points[:, 1] = H / 2.0

fig = plt.figure()
points_on_proc, alpha_val = evaluate_on_points(alpha, points)
plt.plot(points_on_proc[:, 0], alpha_val, "k", linewidth=2, label="damage")
# plt.axvline(x=0.5 - D, color="grey", linestyle="--", linewidth=2)
# plt.axvline(x=0.5 + D, color="grey", linestyle="--", linewidth=2)
plt.grid(True)
plt.xlabel("x")
plt.ylabel(r"damage $\alpha$")
plt.legend()

# If run in parallel as a Python file, we save a plot per processor
plt.savefig(f"output/damage_line_rank_{MPI.COMM_WORLD.rank:d}.png")
../../_images/533cf4af079ecc902b883ba72c70bdf42f029ee3a4dcf09455905c93d89e85bf.png