In-class activity: Dynamic fracture

In this exercise, we will study and analyze the crack propagation under dynamic conditions. The case when the crack propagation is no longer quasi-static, but dynamic. Unlike, quasi-static fracture, a dynamic crack propagation means that we can no longer neglect the inertial terms in the equations of motion.

We simulate the dynamic propagation of a crack in a pre-strained plate. To ensure a controlled initiation and isolate the effects of inertia, we split the simulation into two distinct phases:

Phase 1: Quasi-static Pre-loading

First, we stretch the plate slowly to a critical displacement. We solve for the equilibrium state where internal forces balance external loads, ignoring time and inertia.

  • Interface Model: The potential crack path is tied together using a stiff penalty (binding) potential. This prevents separation but allows us to store elastic energy in the bulk material without triggering crack propagation.
  • Energy Balance: The solver minimizes the total potential energy: \[\Psi_{\text{static}} = \Psi_{\text{elastic}}(\boldsymbol{u}) + \Psi_{\text{penalty}}(\boldsymbol{u})\]

This is nothing but constraining the two surfaces of the crack to not separate and we have seen such problem in various previous exercises.

Phase 2: Dynamic Propagation

Once the plate is pre-loaded, we fix the displacement boundaries (clamped condition) and switch the solver to an explicit dynamic time-integration. We simultaneously switch the interface model to an reversible cohesive law. The crack is now free to propagate, driven solely by the release of stored strain energy.

  • Interface Model: An exponential cohesive zone model that dissipates energy as the surfaces separate.
  • Energy Balance: The total energy of the system must be conserved. The stored elastic potential is converted into kinetic energy (stress waves) and dissipated fracture energy: \[E_{\text{total}} = \underbrace{T_{\text{kinetic}}(\dot{\boldsymbol{u}})}_{\text{Inertia}} + \underbrace{\Psi_{\text{elastic}}(\boldsymbol{u})}_{\text{Stored}} + \underbrace{\Psi_{\text{fracture}}(\boldsymbol{u})}_{\text{Dissipated}} = \text{Constant}\]

Objective: We will track the conversion of \(\Psi_{\text{elastic}}\) into \(\Psi_{\text{fracture}}\) and observe the role of \(E_{\text{kinetic}}\) in limiting the crack tip velocity.

Let us start by importing the necessary libraries and modules. We will use the same libraries as in the previous exercise.

Code: Import essential libraries
import jax

jax.config.update("jax_enable_x64", True)  # use double-precision
jax.config.update("jax_persistent_cache_min_compile_time_secs", 0)
jax.config.update("jax_platforms", "cpu")
import jax.numpy as jnp
from jax import Array

from tatva import Mesh, Operator, element
from tatva.plotting import STYLE_PATH, colors, plot_element_values, plot_nodal_values

from jax_autovmap import autovmap
import equinox as eqx
import numpy as np
from typing import Callable, Optional, Tuple

import matplotlib.pyplot as plt

Model Setup

We use the same configuration and material properties as in In-class activity: Quasistatic fracture. We will load the plate under Mode-I loading quasi-statically without letting the crack to propagate. This is analogous to loading the plate until it reaches the critical load for crack propagation. One can think of this situation as that before the crack starts propagating, how it reaches the critical load is not relevant. Since we want to study what happens to a crack propagating under dynamic loading, we can neglect the effect of the loading prior to the crack propagation.

prestrain = 0.1
nu = 0.35

E = 106e3 # N/m^2
lmbda = nu * E / ((1 + nu) * (1 - 2 * nu))
mu = E / (2 * (1 + nu))

Gamma = 15 # J/m^2
sigma_c = 20e3 # N/m^2

print(f"mu: {mu} N/m^2")
print(f"lmbda: {lmbda} N/m^2")

sigma_inf = prestrain * E

L_G = 2 * mu * Gamma / (jnp.pi * (1 - nu) * sigma_inf**2)
print(f"L_G: {L_G} m")
mu: 39259.259259259255 N/m^2
lmbda: 91604.93827160491 N/m^2
L_G: 0.0051332024864342955 m

Using the above dimensions, we define a domain of length \(20\times L_G\) and height \(8\times L_G\). The plate has a pre-crack of length slightly larger than the Griffith fracture length \(L_G\).

For this exercise, we will use a pre-crack length of \(1.0\times L_G\). We use triangular elements to discretize the domain.

Code: Functions to generate the mesh
def generate_mesh_with_line_elements(
    nx: int,
    ny: int,
    lxs: Tuple[float, float],
    lys: Tuple[float, float],
    curve_func: Optional[Callable[[Array, float], bool]] = None,
    tol: float = 1e-6,
) -> Tuple[Array, Array, Optional[Array]]:
    """
    Generates a 2D triangular mesh for a rectangle and optionally extracts
    1D line elements along a specified curve.

    Args:
        nx: Number of elements along the x-direction.
        ny: Number of elements along the y-direction.
        lxs: Tuple of the x-coordinates of the left and right edges of the rectangle.
        lys: Tuple of the y-coordinates of the bottom and top edges of the rectangle.
        curve_func: An optional callable that takes a coordinate array [x, y] and
                    a tolerance, returning True if the point is on the curve.
        tol: Tolerance for floating-point comparisons.

    Returns:
        A tuple containing:
        - coords (jnp.ndarray): Nodal coordinates, shape (num_nodes, 2).
        - elements_2d (jnp.ndarray): 2D triangular element connectivity.
        - elements_1d (jnp.ndarray | None): 1D line element connectivity, or None.
    """

    x = jnp.linspace(lxs[0], lxs[1], nx + 1)
    y = jnp.linspace(lys[0], lys[1], ny + 1)
    xv, yv = jnp.meshgrid(x, y, indexing="ij")
    coords = jnp.stack([xv.ravel(), yv.ravel()], axis=-1)

    def node_id(i, j):
        return i * (ny + 1) + j

    elements_2d = []
    for i in range(nx):
        for j in range(ny):
            n0 = node_id(i, j)
            n1 = node_id(i + 1, j)
            n2 = node_id(i, j + 1)
            n3 = node_id(i + 1, j + 1)
            #elements_2d.append([n0, n1, n3])
            #elements_2d.append([n0, n3, n2])
            elements_2d.append([n0, n1, n2])
            elements_2d.append([n1, n3, n2])
    elements_2d = jnp.array(elements_2d)

    # --- 2. Extract 1D elements if a curve function is provided ---
    if curve_func is None:
        return coords, elements_2d, None

    # Efficiently find all nodes on the curve using jax.vmap
    on_curve_mask = jax.vmap(lambda c: curve_func(c, tol))(coords)

    elements_1d = []
    # Iterate through all 2D elements to find edges on the curve
    for tri in elements_2d:
        # Define the three edges of the triangle
        edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]
        for n_a, n_b in edges:
            # If both nodes of an edge are on the curve, add it to the set
            if on_curve_mask[n_a] and on_curve_mask[n_b]:
                # Sort to store canonical representation, e.g., (1, 2) not (2, 1)
                elements_1d.append(tuple(sorted((n_a, n_b))))

    if not elements_1d:
        return coords, elements_2d, jnp.array([], dtype=int)

    return coords, elements_2d, jnp.unique(jnp.array(elements_1d), axis=0)
Nx = 200  # Number of elements in X
Ny = 40  # Number of elements in Y
Lx = 20 * L_G  # Length in X
Ly = 8 * L_G  # Length in Y


crack_length = 1.0 * L_G


# function identifies nodes on the cohesive line at y = 0. and x > 2.0
def cohesive_line(coord: Array, tol: float) -> bool:
    return jnp.logical_and(
        jnp.isclose(coord[1], 0.0, atol=tol), coord[0] > crack_length
    )


upper_coords, upper_elements_2d, top_interface_elements = generate_mesh_with_line_elements(
    nx=Nx, ny=Ny, lxs=(0, Lx), lys=(0, Ly/2), curve_func=cohesive_line
)


lower_coords, lower_elements_2d, bottom_interface_elements = generate_mesh_with_line_elements(
    nx=Nx, ny=Ny, lxs=(0, Lx), lys=(-Ly/2, 0), curve_func=cohesive_line
)

coords = jnp.vstack((upper_coords, lower_coords))
elements = jnp.vstack((upper_elements_2d, lower_elements_2d + upper_coords.shape[0]))

mesh = Mesh(coords, elements)
n_nodes = mesh.coords.shape[0]
n_dofs_per_node = 2
n_dofs = n_dofs_per_node * n_nodes

bottom_interface_elements = bottom_interface_elements + upper_coords.shape[0]
Code: Plot the mesh with cohesive elements
plt.style.use(STYLE_PATH)
plt.figure(figsize=(6, 3))
ax = plt.axes()
# ax.triplot(coords[:, 0], coords[:, 1], elements, color="grey", lw=0.5)
ax.tripcolor(
    upper_coords[:, 0],
    upper_coords[:, 1],
    upper_elements_2d,
    edgecolors="black",
    linewidths=0.2,
    shading="flat",
    cmap="managua_r",
    facecolors=np.ones(upper_elements_2d.shape[0]),
)

ax.tripcolor(
    lower_coords[:, 0],
    lower_coords[:, 1],
    lower_elements_2d,
    edgecolors="black",
    linewidths=0.2,
    shading="flat",
    cmap="managua_r",
    facecolors=np.zeros(lower_elements_2d.shape[0]),
)


# Highlight the extracted 1D elements in red
for edge in top_interface_elements:
    ax.plot(upper_coords[edge, 0], upper_coords[edge, 1], colors.red, lw=1.5)

ax.set_aspect("equal")
ax.margins(0, 0)
plt.show()

Mesh with cohesive elements

To check that the elements along the cohesive line are in same order on both surface, we compare the node values for the two.

jnp.allclose(mesh.coords[top_interface_elements], mesh.coords[bottom_interface_elements], atol=1e-6)
Array(True, dtype=bool)

Defining material properties

Let us use the above defined material parameters to define the material properties.

from typing import NamedTuple


class Material(NamedTuple):
    """Material properties for the elasticity operator."""

    mu: float  # Diffusion coefficient
    lmbda: float  # Diffusion coefficient


mat = Material(mu=mu, lmbda=lmbda)

Defining the elastic energy density

We define a function to compute the linear elastic energy density based on the displacement gradients \(\nabla u\).

\[ \Psi(x) = \sigma(x) : \epsilon(x) \]

where \(\sigma\) is the stress tensor and \(\epsilon\) is the strain tensor.

\[ \sigma = \lambda \text{tr}(\epsilon) I + 2\mu \epsilon \]

and

\[ \epsilon = \frac{1}{2} (\nabla u + \nabla u^T) \]

The elastic strain energy density is then given by:

\[ \Psi_{elastic}(u) = \int_{\Omega} \Psi(x) dV \]

tri = element.Tri3()
op = Operator(mesh, tri)

@autovmap(grad_u=2)
def compute_strain(grad_u):
    return 0.5 * (grad_u + grad_u.T)


@autovmap(eps=2, mu=0, lmbda=0)
def compute_stress(eps, mu, lmbda):
    I = jnp.eye(2)
    return 2 * mu * eps + lmbda * jnp.trace(eps) * I


@autovmap(grad_u=2, mu=0, lmbda=0)
def strain_energy(grad_u, mu, lmbda):
    eps = compute_strain(grad_u)
    sigma = compute_stress(eps, mu, lmbda)
    return 0.5 * jnp.einsum("ij,ij->", sigma, eps)


@jax.jit
def total_strain_energy(u_flat: Array) -> float :
    u = u_flat.reshape(-1, n_dofs_per_node)
    u_grad = op.grad(u)
    energy_density = strain_energy(u_grad, mat.mu, mat.lmbda)
    return op.integrate(energy_density)

Phase 1: Quasi-static Pre-loading

We start with the quasi-static pre-loading phase. Here, we will apply a displacement boundary condition to the top and the bottom edge of the plate to stretch it in the vertical direction. We will use a stiff penalty potential along the cohesive line to prevent crack propagation during this phase.

Defining the penalty energy density to tie the crack surfaces

The constraint to prevent the two surfaces of the crack from separating is given as

\[ g(\boldsymbol{u}) = \delta( [\![\boldsymbol{u}]\!]) = 0 \]

that is the opening across the interface is zero where the opening \(\delta\) is given as

\[ \delta = \sqrt{ ([\![\boldsymbol{u}]\!] \cdot{}\boldsymbol{t})^2 + ([\![\boldsymbol{u}]\!] \cdot \boldsymbol{n})^2 } \]

Note

This is the same effective opening that we defined in In-class activity: Quasistatic fracture.

Now to constraint the two surfaces of the crack from separating, we define a penalty energy density based on the displacement jump across the interface.

The penalty energy is defined as: \[ \Psi_{\text{penalty}} = \frac{1}{2} \int \underbrace{\kappa_{\text{penalty}} (g(\boldsymbol{u}))^2}_{\psi_{\text{penalty}}} d\Gamma \]

where

  • \(\Gamma_{coh}\) is the cohesive interface.

  • \(\kappa_\text{binding}\) is the penalty stiffness parameter that controls the strength of the constraint.

To compute the total penalty energy, we will integrate the penalty energy density over the cohesive interface. So we need two things:

  • A line integral over the cohesive interface.
  • The penalty energy density function \(\psi_{\text{penalty}}\).

Defining the operator for line integration

We will begin by defining the operator for line integration similar to Operator that we have used for the triangular elements. To do so, we will have to first define the line elements along the potential fracture surface. We will use the Line2 element to define the line elements. This element has 2 nodes. Below, we use the function extract_interface_mesh to generate the mesh that consists of the line elements along the potential fracture surface. Since the two interfaces are discretized using the same number of elements, and occupy the same spatial domain, we can use any one of the two to perform the integration.

Code: Extract the interface mesh
def extract_interface_mesh(mesh, line_elements):
    """Generate a mesh for the interface between two materials."""

    # --- Interface mesh ---
    line_element_nodes = jnp.unique(line_elements.flatten())
    interface_coords = mesh.coords[line_element_nodes]
    interface_elements = jnp.array(
        [[index, index + 1] for index in range(len(line_elements))]
    )

    interface_mesh = Mesh(interface_coords, interface_elements)
    return interface_mesh    
interface_mesh = extract_interface_mesh(mesh, bottom_interface_elements)
line = element.Line2()
line_op = Operator(interface_mesh, line)

Defining the penalty energy density function

\[ \psi_{\text{penalty}} = \frac{1}{2} \kappa_{\text{penalty}} (g(\boldsymbol{u}))^2 \]

Define the function below to compute the penalty energy density.

@jax.jit
def safe_sqrt(x):
    return jnp.sqrt(jnp.where(x > 0.0, x, 0.0))


@autovmap(jump=1)
def compute_opening(jump: Array) -> float:
    """
    Compute the opening of the cohesive element.
    Args:
        jump: The jump in the displacement field.
    Returns:
        The opening of the cohesive element.
    """
    opening = safe_sqrt(jump[0] ** 2 + jump[1] ** 2)
    return opening


@autovmap(jump=1)
def binding_energy(jump: Array, binding_penalty=1e8) -> float:
    """
    Compute the binding energy of the cohesive element.
    Args:
        jump: The jump in the displacement field.
    Returns:
        The binding energy of the cohesive element.
    """
    delta = compute_opening(jump)
    return 0.5 * binding_penalty * delta**2

Now use the above defined function and the line_op operator to compute the penelty energy to tie the two surfaces of the crack. The penalty energy is then given by:

\[\Psi_\text{penalty}(u)= \int_{\Gamma_\text{coh}} \psi_{\text{penalty}} dA\]

We will need the nodes associated with the interface elements to later compute the effefctive opening between the lower and upper bodies across the crack.

Below get the nodes from the bottom and top interfaces.

def get_cohesive_nodes(mesh, bottom_interface_elements, top_interface_elements):
    bottom_cohesive_nodes = jnp.unique(bottom_interface_elements.flatten())
    top_cohesive_nodes = jnp.unique(top_interface_elements.flatten())

    bottom_cohesive_coords = mesh.coords[bottom_cohesive_nodes]
    top_cohesive_coords = mesh.coords[top_cohesive_nodes]

    bottom_cohesive_nodes = bottom_cohesive_nodes[
        jnp.argsort(bottom_cohesive_coords[:, 0])
    ]
    top_cohesive_nodes = top_cohesive_nodes[jnp.argsort(top_cohesive_coords[:, 0])]

    return bottom_cohesive_nodes, top_cohesive_nodes

bottom_cohesive_nodes, top_cohesive_nodes = get_cohesive_nodes(
    mesh, bottom_interface_elements, top_interface_elements
)

Once you have the nodes from the bottom and top interfaces, you can define the function to compute the penalty energy to constrain the two surfaces of the crack.

@jax.jit
def total_binding_energy(u_flat: Array) -> Array:
    u = u_flat.reshape(-1, n_dofs_per_node)
    jump = u.at[top_cohesive_nodes, :].get() - u.at[bottom_cohesive_nodes, :].get()
    jump_quad = line_op.eval(jump)
    binding_energy_density = binding_energy(jump_quad)
    return line_op.integrate(binding_energy_density)

Defining the total energy for quasistatic case

Finally, we can put everything together to compute the total energy. The total potential energy \(\Psi\) is the sum of the elastic strain energy \(\Psi_{elastic}\) and the penalty energy \(\Psi_\text{penalty}\).

\[\Psi(u)=\Psi_\text{elastic}(u)+\Psi_\text{penalty}(u)\]

@jax.jit
def total_static_energy(u_flat: Array) -> float:
    u = u_flat.reshape(-1, n_dofs_per_node)
    elastic_strain_energy = total_strain_energy(u)
    binding_energy = total_binding_energy(u)
    return elastic_strain_energy + binding_energy

Now lets us use the function defined above to compute the total energy of the system. This is just to check that there is no implementation error in the way we coded the functions above.

Applying Boundary Conditions

We now locate the dofs in the two domains to apply boundary conditions. We load the domain under Model I loading where both the top and bottom surfaces are loaded along the \(y\)-direction only. Furthermore, we assume symmetry about the \(x\)-axis at the left edge of the domain. Under symmetry, the domain represents a thorough crack in the middle of the domain. So the boundary conditions are:

  • \(u_x = 0\) on the left edge
  • \(u_y = -H\varepsilon_0/2\) on the bottom edge
  • \(u_y = H\varepsilon_0/2\) on the top edge
  • \(u_x = 0\) on the top and bottom edges

where \(H\) is the height of the domain and \(\varepsilon_0\) is the applied prestrain.

y_max = jnp.max(mesh.coords[:, 1])
y_min = jnp.min(mesh.coords[:, 1])
x_min = jnp.min(mesh.coords[:, 0])
height = y_max - y_min


upper_nodes = jnp.where(jnp.isclose(mesh.coords[:, 1], y_max))[0]
lower_nodes = jnp.where(jnp.isclose(mesh.coords[:, 1], y_min))[0]
left_nodes = jnp.where(jnp.isclose(mesh.coords[:, 0], x_min))[0]

fixed_dofs = jnp.concatenate(
    [
        2 * upper_nodes,
        2 * upper_nodes + 1,
        2 * lower_nodes,
        2 * lower_nodes + 1,
        2 * left_nodes,
    ]
)


applied_disp = 0.7 * prestrain * height

prescribed_values = jnp.zeros(n_dofs).at[2 * upper_nodes].set(0.0)
prescribed_values = prescribed_values.at[2 * upper_nodes + 1].set(applied_disp / 2.0)
prescribed_values = prescribed_values.at[2 * lower_nodes].set(0.0)
prescribed_values = prescribed_values.at[2 * lower_nodes + 1].set(-applied_disp / 2.0)

free_dofs = jnp.setdiff1d(jnp.arange(n_dofs), fixed_dofs)

Using matrix-free solver

Now, we define a few functions to implement the matrix-free solvers. We will use the conjugate gradient method to solve the linear system of equations. The Dirichlet boundary conditions are applied using projection method. As we use the matrix-free solver, we need to define the gradient of the total potential energy, which is also the residual function.

\[ \boldsymbol{r} = \frac{\partial \Psi}{\partial \boldsymbol{u}} = \boldsymbol{f}_{int} - \boldsymbol{f}_{ext} \]

We also define a function to compute the JVP product to compute the incremental residual for an increment in the displacement.

\[ \delta \boldsymbol{r} = \frac{\partial \boldsymbol{r}}{\partial \boldsymbol{u}}|_{\boldsymbol{u}=\boldsymbol{u}_\mathrm{prev}} \delta \boldsymbol{u} \]

Hint

Below define the function to compute the \(\boldsymbol{f}_{int}\) using jax.jacrev and then use this function to compute the Jacobian-vector product using jax.jvp. Remember to apply the Projection approach to enforce the Dirichlet boundary conditions.

gradient_static = jax.jacrev(total_static_energy)

# create a function to compute the JVP product
@jax.jit
def compute_tangent(du, u_prev):
    du_projected = du.at[fixed_dofs].set(0)
    tangent = jax.jvp(gradient_static, (u_prev,), (du_projected,))[1]
    tangent = tangent.at[fixed_dofs].set(0)
    return tangent
Code: Functions to implement the conjugate gradient method and Netwon-Raphson method
@eqx.filter_jit
def conjugate_gradient(A, b, atol=1e-8, max_iter=100):

    iiter = 0

    def body_fun(state):
        b, p, r, rsold, x, iiter = state
        Ap = A(p)
        alpha = rsold / jnp.vdot(p, Ap)
        x = x + jnp.dot(alpha, p)
        r = r - jnp.dot(alpha, Ap)
        rsnew = jnp.vdot(r, r)
        p = r + (rsnew / rsold) * p
        rsold = rsnew
        iiter = iiter + 1
        return (b, p, r, rsold, x, iiter)

    def cond_fun(state):
        b, p, r, rsold, x, iiter = state
        return jnp.logical_and(jnp.sqrt(rsold) > atol, iiter < max_iter)

    x = jnp.full_like(b, fill_value=0.0)
    r = b - A(x)
    p = r
    rsold = jnp.vdot(r, p)

    b, p, r, rsold, x, iiter = jax.lax.while_loop(
        cond_fun, body_fun, (b, p, r, rsold, x, iiter)
    )
    return x, iiter


def newton_krylov_solver(
    u,
    fext,
    gradient,
    fixed_dofs,
):
    fint = gradient(u)

    du = jnp.zeros_like(u)

    iiter = 0
    norm_res = 1.0

    tol = 1e-8
    max_iter = 50

    while norm_res > tol and iiter < max_iter:
        residual = fext - fint
        residual = residual.at[fixed_dofs].set(0)
        A = eqx.Partial(compute_tangent, u_prev=u)
        du, cg_iiter = conjugate_gradient(A=A, b=residual, atol=1e-8, max_iter=100)

        u = u.at[:].add(du)
        fint = gradient(u)
        residual = fext - fint
        residual = residual.at[fixed_dofs].set(0)
        norm_res = jnp.linalg.norm(residual)
        print(f"  Residual: {norm_res:.2e}")
        iiter += 1

    return u, norm_res

Implementing the Incremental Loading Loop

We now run the simulation for \(\varepsilon_{applied} = 0.7 \times \varepsilon^0\).

The entire loading is divided into 10 increments At each increment, we solve the Newton-Krylov system using the conjugate gradient method. After each increment, we store the displacement and the force on the top surface.

u_prev = jnp.zeros(n_dofs)
fext = jnp.zeros(n_dofs)

n_steps = 10

force_on_top = []
displacement_on_top = []
u_per_step = []
energies = {}
energies["elastic"] = []
energies["binding"] = []

force_on_top.append(0)
displacement_on_top.append(0)
u_per_step.append(u_prev.reshape(n_nodes, n_dofs_per_node))
energies["elastic"].append(
    total_strain_energy(u_prev.reshape(n_nodes, n_dofs_per_node))
)
energies["binding"].append(
    total_binding_energy(u_prev.reshape(n_nodes, n_dofs_per_node))
)

du_total = prescribed_values / n_steps  # displacement increment

for step in range(n_steps):
    print(f"Step {step+1}/{n_steps}")
    u_prev = u_prev.at[fixed_dofs].add(du_total[fixed_dofs])

    u_new, rnorm = newton_krylov_solver(
        u_prev,
        fext,
        gradient=gradient_static,
        fixed_dofs=fixed_dofs,
    )

    u_prev = u_new
    force_on_top.append(jnp.sum(gradient_static(u_prev)[2 * upper_nodes + 1]))
    displacement_on_top.append(jnp.mean(u_prev[2 * upper_nodes + 1]))
    u_per_step.append(u_prev.reshape(n_nodes, n_dofs_per_node))
    energies["elastic"].append(
        total_strain_energy(u_prev.reshape(n_nodes, n_dofs_per_node))
    )
    energies["binding"].append(
        total_binding_energy(u_prev.reshape(n_nodes, n_dofs_per_node))
    )

u_static = u_prev.reshape(n_nodes, n_dofs_per_node)

Force-displacement response and deformed configuration

Let us now plot the force-displacement curve. This is the curve that we get by plotting the force on the top surface as a function of the displacement on the top surface. This will tell us how the capacity of the material to carry load changes as the displacement on the top surface increases and the crack propagates.

Since we do not allow the crack to open, we expect the force-displacement curve to be linear.

Code: Plot the deformed configuration and the force-displacement curve
from matplotlib.gridspec import GridSpec

# squeeze to remove the quad point dimension (only 1 quad point)
step_number = -1
grad_u = op.grad(u_per_step[step_number]).squeeze()
strains = compute_strain(grad_u)
stresses = compute_stress(strains, mat.mu, mat.lmbda)

fig = plt.figure(layout="constrained", figsize=(6, 4))

gs = GridSpec(1, 2, figure=fig, width_ratios=[0.6, 0.4], wspace=0.05)

ax = fig.add_subplot(gs[0, 0])
plot_element_values(
    u=u_per_step[step_number],
    mesh=mesh,
    values=stresses[:, 1, 1].flatten(),
    ax=ax,
    label=r"$\sigma_{yy}$",
)
ax.set_aspect(1 / ax.get_data_ratio())
ax.margins(0, 0)

ax = fig.add_subplot(gs[0, 1])
ax.plot(displacement_on_top, force_on_top, "o-")
ax.set_aspect(1 / ax.get_data_ratio())
ax.set_xlabel("Displacement on top")
ax.set_ylabel("Force on top")
ax.grid(True)
ax.margins(0, 0)
ax.ticklabel_format(axis="both", style="sci", scilimits=(1, -4))
plt.show()
Figure 19.1: Snapshot of deformed configuration with crack propagation. Left figure shows the stress distribution, right figure shows the force-displacement curve.

Let us just check the opening along the cohesive line at the end of the loading. Since we have tied the two surfaces of the crack together using a stiff penalty potential, we expect the opening to be very small much less than the critical opening displacement \(\delta_c\).

Code: Plot the opening along the cohesive line
plt.style.use(STYLE_PATH)
plt.figure(figsize=(5, 3), layout="constrained")
ax = plt.axes()


quadrature_points = line_op.eval(mesh.coords[bottom_cohesive_nodes]).squeeze()


steps_to_plot = np.arange(0, n_steps, step=3)

mycolor = plt.get_cmap("Spectral")(np.linspace(0, 1, len(steps_to_plot)))
for i, c in zip(steps_to_plot, mycolor):
    jump_quadrature = line_op.eval(
        u_per_step[i][top_cohesive_nodes] - u_per_step[i][bottom_cohesive_nodes]
    ).squeeze()
    opening_quadrature = compute_opening(jump_quadrature)

    ax.plot(
        quadrature_points[:, 0] - crack_length,
        opening_quadrature / L_G,
        label=f"Step {i+1}",
        c=c,
    )


ax.axhline(
    Gamma * np.exp(-1) / sigma_c / L_G,
    color="black",
    linewidth=0.5,
    ls="--",
)
ax.text(
    0.012,
    1.2 * Gamma * np.exp(-1) / sigma_c / L_G,
    r"$\delta_c=\Gamma \exp(-1) / \sigma_c$",
    color="black",
    fontsize=8,
)

ax.set_xlabel(r"$x -L_G$")
ax.set_ylabel(r"$\delta/L_G$")
ax.grid(True)
ax.margins(0.0, 0.0)
plt.show()
Figure 19.2: Opening along the cohesive line at different loading steps. The loading steps increase from as the color changes from red to blue. Dashed line indicates the critical opening displacement.

Phase 2: Dynamic Propagation

So we have our plate stretched out to near the critical load for crack propagation. Now we will let the crack propagate dynamically. To do so, we will use an explicit time integration scheme to solve the equations of motion including the inertial terms.

Since we want to make the crack propagate we will have to replace the penalty energy with a cohesive energy that allows for separation of the two surfaces once the critical traction is reached.

The total energy for a dynamic fracture problem is given by: \[E_{\text{total}} = T_{\text{kinetic}}(\dot{\boldsymbol{u}}) + \Psi_{\text{elastic}}(\boldsymbol{u}) + \Psi_{\text{fracture}}(\boldsymbol{u})\]

From the above equatiion, we have already defined the elastic strain energy \(\Psi_{\text{elastic}}\) in the previous section. We will now define the fracture energy \(\Psi_{\text{fracture}}\) and the kinetic energy \(E_{\text{kinetic}}\).

Defining the fracture energy

The cohesive energy is defined as:

\[\Psi_\text{cohesive}(u)= \int_{\Gamma_\text{coh}} \psi(\delta( [\![\boldsymbol{u}]\!])) d\Gamma\]

  • \(\Gamma_\text{coh}\) is the cohesive interface.

  • \(\boldsymbol{\delta}(\boldsymbol{u}) = f([\![\boldsymbol{u}]\!])\) is the displacement jump across the interface.

  • \(\psi(\boldsymbol{\delta})\) is the cohesive potential, which defines the energy-separation relationship.

We will use the same exponential cohesive law as defined in In-class activity: Quasistatic fracture.

Define the function below to compute the total fracture energy based on the effective opening \(\delta\) and the exponential cohesive law.

class CohesiveMaterial(NamedTuple):
    """Material properties for the elasticity operator."""

    Gamma: float  # Fracture energy
    sigma_c: float  # Critical stress
    penalty: float  # Penalty parameter


cohesive_mat = CohesiveMaterial(Gamma=Gamma, sigma_c=sigma_c, penalty=1e3)


@autovmap(jump=1)
def exponential_cohesive_energy(
    jump: Array,
    Gamma: float,
    sigma_c: float,
    penalty: float,
    delta_threshold: float = 1e-8,
) -> float:
    """
    Compute the cohesive energy for a given jump.
    Args:
        jump: The jump in the displacement field.
        Gamma: Fracture energy of the material.
        sigma_c: The critical strength of the material.
        penalty: The penalty parameter for penalizing the interpenetration.
        delta_threshold: The threshold for the delta parameter.
    Returns:
        The cohesive energy.
    """
    delta = compute_opening(jump)
    delta_c = (Gamma * jnp.exp(-1)) / sigma_c

    def true_fun(delta):
        return Gamma * (1 - (1 + (delta / delta_c)) * (jnp.exp(-delta / delta_c)))

    def false_fun(delta):
        return 0.5 * penalty * delta**2

    return jax.lax.cond(delta > delta_threshold, true_fun, false_fun, delta)

@jax.jit
def total_cohesive_energy(u_flat: Array) -> float:
    u = u_flat.reshape(-1, n_dofs_per_node)
    jump = u.at[top_cohesive_nodes, :].get() - u.at[bottom_cohesive_nodes, :].get()
    jump_quad = line_op.eval(jump)
    cohesive_energy_density = exponential_cohesive_energy(
        jump_quad, cohesive_mat.Gamma, cohesive_mat.sigma_c, cohesive_mat.penalty
    )
    return line_op.integrate(cohesive_energy_density)

Define total energy for dynamic case

For the dynamic case the total potential energy is given by:

\[ E_{\text{total}} = T(\dot{\boldsymbol{u}}) + \Psi(u) \]

where \(T(\dot{\boldsymbol{u}})\) is the kinetic energy and the \(\Psi(\boldsymbol{u})\) is the sum of the elastic and fracture energy.

\[ \Psi (\boldsymbol{u}) = \Psi_\text{elastic}(\boldsymbol{u}) + \Psi_\text{fracture}(\boldsymbol{u}) \]

Defining the total potential energy (elastic + fracture) and internal force

We will define the total \(\Psi (\boldsymbol{u})\) as:

\[ \Psi(u) = \Psi_\text{elastic}(u) + \Psi_\text{fracture}(u) \]

We can use the elastic energy total_elastic_energy defined previously.

Now we need to define the fracture energy using the exponential cohesive potential.

The internal force will now be computed as the derivative of the total potential energy including the fracture energy.

\[ \boldsymbol{f}_{\text{int}}(\boldsymbol{u}) = \frac{\partial \Psi}{\partial \boldsymbol{u}} = \frac{\partial \Psi_{\text{elastic}}}{\partial \boldsymbol{u}} + \frac{\partial \Psi_{\text{fracture}}}{\partial \boldsymbol{u}} \]

Use jax.jacrev to compute the internal force from the total energy defined above.

Note

Define below the function to compute the total energy \(\Psi(\boldsymbol{u})\) using elastic and fracture energies. The use jax.jacrev to compute the internal force from the total energy.

@jax.jit
def total_energy(u_flat: Array) -> float:
    u = u_flat.reshape(-1, n_dofs_per_node)
    elastic_strain_energy = total_strain_energy(u)
    cohesive_energy = total_cohesive_energy(u)
    return elastic_strain_energy + cohesive_energy


# creating functions to compute the gradient and
gradient = jax.jacrev(total_energy)

Deriving the Equation of Motion from Energy \(E\)

In our quasi-static simulations, we found the equilibrium state by minimizing the total potential energy \(\Psi(\boldsymbol{u})\).

In dynamics, we must account for the motion of the material. To do this, we introduce the Kinetic Energy, \(T\):

\[ T(\dot{\boldsymbol{u}}) = \frac{1}{2} \int_{\Omega} \rho \dot{\boldsymbol{u}} \cdot \dot{\boldsymbol{u}} dV = \frac{1}{2} \dot{\boldsymbol{u}} \cdot \boldsymbol{m} \cdot \dot{\boldsymbol{u}} \]

where \(\boldsymbol{m}\) is the mass vector and \(\dot{\boldsymbol{u}}\) is the velocity vector.

To derive the governing equations, we define the Lagrangian \(\mathcal{L}\) of the system, which is the difference between the kinetic energy and the potential energy:

\[ \mathcal{L}(\boldsymbol{u}, \dot{\boldsymbol{u}}) = T(\dot{\boldsymbol{u}}) - \Psi(\boldsymbol{u}) \]

The path the system takes through time is determined by the Euler-Lagrange equation (Principle of Least Action):

\[ \frac{d}{dt} \left( \frac{\partial \mathcal{L}}{\partial \dot{\boldsymbol{u}}} \right) - \frac{\partial \mathcal{L}}{\partial \boldsymbol{u}} = \mathbf{0} \]

Let’s evaluate these terms:

  1. Inertial Term: The derivative with respect to velocity gives momentum, and its time derivative gives the inertial force: \[\frac{d}{dt} \left( \frac{\partial T}{\partial \dot{\boldsymbol{u}}} \right) = \frac{d}{dt} (\mathbf{M} \dot{\boldsymbol{u}}) = \mathbf{M} \ddot{\boldsymbol{u}}\]

  2. Internal Force Term: The derivative of the potential energy recovers our familiar internal and external forces: \[- \frac{\partial \mathcal{L}}{\partial \boldsymbol{u}} = \frac{\partial \Psi}{\partial \boldsymbol{u}} = \boldsymbol{f}_{\text{int}} - \boldsymbol{f}_{\text{ext}}\]

Combining these, we arrive at the dynamic equation of motion:

\[ \boldsymbol{m} \ddot{\boldsymbol{u}} + \boldsymbol{f}_{\text{int}}(\boldsymbol{u}) = \boldsymbol{f}_{\text{ext}} \]

This is the equation we must solve at every time step. To solve the above equation, we will use an explicit time integration scheme based on the Newmark-\(\beta\) method (Central Difference).

Explicit time integration scheme

We present the explicit time integration scheme based on the Newmark-\(\beta\) method (Central Difference).

For this specific problem, we are transitioning from a quasi-static state. The boundary conditions for the dynamic phase are: * Displacement: Fixed at the final quasi-static value (\(\boldsymbol{u}_{\text{static}}\)) on the boundaries. * Velocity & Acceleration: Clamped to zero on the boundaries.

1. Predictor

First, we predict the state at \(n+1\) based on the current kinematic state.

\[ u^{0}_{n+1} = u_{n} + \Delta t \dot{u}_{n} + \frac{1}{2} \Delta t^2 \ddot{u}_{n} \]

\[ \dot{u}^{0}_{n+1} = \dot{u}_{n} + \Delta t \ddot{u}_{n} \]

\[ \ddot{u}^{0}_{n+1} = \ddot{u}_{n} \]

Apply Boundary Conditions (Displacement): At the fixed boundary DOFs, we must overwrite the prediction to maintain the clamped state: \[u^{0}_{n+1} |_{\text{bnd}} = u_{\text{static}} |_{\text{bnd}}\]

2. Solve

We calculate the acceleration update \(\delta \ddot{u}\) required to satisfy the equation of motion.

\[ \boldsymbol{r} = f_{\text{ext}, n+1} - f_{\text{int}}(u_{n+1}^{0}) \]

Apply Boundary Conditions (Residual): To ensure the boundaries do not accelerate, we force the residual to zero at those DOFs: \[\boldsymbol{r} |_{\text{bnd}} = 0\]

\[ \delta \ddot{u} = \frac{1}{\boldsymbol{m}} (\boldsymbol{r} - \boldsymbol{m}\ddot{u}^{0}_{n+1}) \]

3. Corrector

Finally, we update the acceleration and velocity.

\[ \ddot{u}^{1}_{n+1} = \ddot{u}^{0}_{n+1} + \delta\ddot{u} \]

\[ \dot{u}^{1}_{n+1} = \dot{u}^{0}_{n+1} + \frac{1}{2} \Delta t \delta \ddot{u}^{1}_{n+1} \]

Apply Boundary Conditions (Velocity): As a final safety check to prevent numerical drift, we explicitly clamp the velocity and acceleration to zero at the boundaries: \[\dot{u}^{1}_{n+1} |_{\text{bnd}} = 0, \quad \ddot{u}^{1}_{n+1} |_{\text{bnd}} = 0\]

In order to correctly implement the explicit time integration scheme, we need two important things:

  • the mass vector \(\boldsymbol{m}\)
  • the critical time step \(\Delta t\) for stability.

Computing the mass vector \(\boldsymbol{m}\)

To compute the mass vector, we will use a lumped mass approach. In this approach, the mass associated with each node is computed by summing up the contributions from all elements connected to that node. The contribution from each element is computed based on the element’s volume and density. Below we define a function to compute the lumped mass vector mass_vector, \(\boldsymbol{m}\)

The dimension of the mass vector will be equal to the number of degrees of freedom in the system. Each entry in the mass vector corresponds to the mass associated with a specific degree of freedom (DOF) in the system.

rho = 1025 #  kg/m^3

@autovmap(u=1)
def compute_area(u : Array) -> float:
    return u * rho

def total_area(u_flat):
    u = u_flat.reshape(-1, n_dofs_per_node)
    quad_values = op.eval(u)
    density = compute_area(quad_values)
    return jnp.sum(op.integrate(density))


ones = jnp.ones(n_dofs)
mass_vector = jax.jacrev(total_area)(ones)
print(mass_vector)
[4.50141866e-05 4.50141866e-05 1.35042560e-04 ... 1.35042560e-04
 4.50141866e-05 4.50141866e-05]

Computing the \(\Delta t\)

The explicit time integration is conditionally stable. The critical time step is given by:

\[ \Delta t \leq \frac{h}{c} \]

where \(h\) is the minimum element size and \(c\) is the wave speed.

The wave speed is given by:

\[ c = \sqrt{\frac{\mu + \lambda}{\rho}} \]

where \(\mu\) and \(\lambda\) are the Lame parameters and \(\rho\) is the density.

The minimum element size is the radius of the inscribed circle of the element. Below we define a function to compute the radius of the inscribed circle of the element.

@autovmap(coords=2)
def get_inscribed_circle_radius(coords):
    c1, c2, c3 = coords
    a = jnp.linalg.norm(c2 - c1)
    b = jnp.linalg.norm(c3 - c2)
    c = jnp.linalg.norm(c1 - c3)
    s = (a + b + c) / 2
    area = jnp.sqrt(s * (s - a) * (s - b) * (s - c))
    return area / s / 2


h_min = jnp.min(get_inscribed_circle_radius(coords[elements]))
dt = 0.5 * h_min / (jnp.sqrt((2 * mat.mu + mat.lmbda) / rho))

print(f"critical time step: {dt}")
critical time step: 2.917544943000097e-06

Using the above two defined quantites, implement the explicit time integration scheme to simulate the dynamic propagation of the crack. The initial conditions are given by the static solution.

We will run the simulation for 4500 time steps. At every 100 time steps, we will store the following quantities:

  • Displacement field
  • Velocity field
  • Kinetic energy
  • Elastic energy
  • Fracture energy
  • Total energy
def predictor(u, v, a, dt):
    u_pred = u + dt * v + 0.5 * dt**2 * a
    v_pred = v + dt * a
    a_pred = a
    return u_pred, v_pred, a_pred

def corrector(u_pred, v_pred, a_pred, da, dt):
    a_new = a_pred + da
    v_new = v_pred + 0.5 * dt * da
    return u_pred, v_new, a_new



n_steps = 4500

u_dynamic = u_static.flatten()
v_dynamic = jnp.zeros_like(u_dynamic)
a_dynamic = jnp.zeros_like(u_dynamic)

f_ext = jnp.zeros_like(u_dynamic)


u_per_step_dynamic = []
v_per_step_dynamic = []

energies_dynamic = {}
energies_dynamic["kinetic"] = []
energies_dynamic["elastic"] = []
energies_dynamic["cohesive"] = []
energies_dynamic["total"] = []

for step in range(n_steps):

    u_dynamic = u_dynamic.at[fixed_dofs].set(
        prescribed_values.at[fixed_dofs].get()
    )


    u_pred, v_pred, a_pred = predictor(u_dynamic, v_dynamic, a_dynamic, dt)

    residual = f_ext - gradient(u_pred)

    residual = residual.at[fixed_dofs].set(0)
    da = residual / mass_vector - a_dynamic

    u_dynamic, v_dynamic, a_dynamic = corrector(u_pred, v_pred, a_pred, da, dt)
    v_dynamic = v_dynamic.at[fixed_dofs].set(0.0)
    a_dynamic = a_dynamic.at[fixed_dofs].set(0.0)


    if step % 100 == 0:
        u_per_step_dynamic.append(u_dynamic.reshape(n_nodes, n_dofs_per_node))
        v_per_step_dynamic.append(v_dynamic.reshape(n_nodes, n_dofs_per_node))

        energies_dynamic["kinetic"].append(0.5 * jnp.dot(mass_vector, v_dynamic**2))
        energies_dynamic["elastic"].append(total_strain_energy(u_dynamic.reshape(-1, n_dofs_per_node)))
        energies_dynamic["cohesive"].append(total_cohesive_energy(u_dynamic.reshape(-1, n_dofs_per_node)))
        energies_dynamic["total"].append(
            energies_dynamic["kinetic"][-1]
            + energies_dynamic["elastic"][-1]
            + energies_dynamic["cohesive"][-1]
        )

Plot the deformed confugration

We can now plot the deformed configuration of the plate with the propagating crack. We will plot the deformed configuration at last time step to visualize the stresses in y-direction and the magnitude of the velocity field.

from matplotlib.gridspec import GridSpec

# squeeze to remove the quad point dimension (only 1 quad point)
step_number = -1
grad_u = op.grad(u_per_step_dynamic[step_number]).squeeze()
strains = compute_strain(grad_u)
stresses = compute_stress(strains, mat.mu, mat.lmbda)

fig = plt.figure(layout="constrained", figsize=(6, 4))

gs = GridSpec(1, 2, figure=fig, width_ratios=[0.5, 0.5], wspace=0.05)

ax = fig.add_subplot(gs[0, 0])
plot_element_values(
    u=u_per_step_dynamic[step_number],
    mesh=mesh,
    values=stresses[:, 1, 1].flatten(),
    ax=ax,
    label=r"$\sigma_{yy}$",
)
ax.set_aspect('equal')
ax.margins(0, 0)

ax = fig.add_subplot(gs[0, 1])
plot_nodal_values(
    u=u_per_step_dynamic[step_number],
    mesh=mesh,
    nodal_values=jnp.linalg.norm(v_per_step_dynamic[step_number], axis=1).flatten(),
    ax=ax,
    label=r"$v$ magnitude",
    cmap="magma",
)
ax.set_aspect('equal')
ax.margins(0, 0)

plt.show()
Figure 20.1: Snapshot of deformed configuration with crack propagation. Left figure shows the stress distribution, right figure shows the velocity magnitude.

Energy conservation

We now plot the total energy of the system to check for energy conservation. The total energy is given by the sum of the kinetic energy, elastic strain energy, and the fracture energy. Since we ficed the displacement at the boundaries, the external work is zero, therefore the total energy should be constant.

What we should see is that while the total energy remains constant, there is an exchange between the kinetic energy, elastic strain energy, and the fracture energy as the crack propagates.

Code: Plot the energy curves
time_steps = jnp.arange(0, n_steps, 100) * dt

plt.style.use(STYLE_PATH)
fig = plt.figure(figsize=(5, 3), layout="constrained")
ax = plt.axes()
ax.plot(time_steps, energies_dynamic["elastic"], "o-", label="Elastic")
ax.plot(time_steps, energies_dynamic["cohesive"], "o-", label="Fracture")
ax.plot(time_steps, energies_dynamic["kinetic"], "o-", label="Kinetic")
ax.plot(
    time_steps,
    energies_dynamic["total"],
    "o-",
    label="Total",
)

# ax.set_aspect(1/ax.get_data_ratio())
ax.set_xlabel(r"Time,  [sec]")
ax.set_ylabel(r"$\psi$")
ax.grid(True)
ax.legend(frameon=False)

plt.show()
Figure 20.2: Energy curves for elastic, fracture and kinetic energies.

Crack tip opening

Now, let us see how crack tip opening \(\delta\) evolves as a function of time along the cohesive line looks like. We compute the jump \([\![\boldsymbol{u}]\!]\) vector at each quadrature point along the cohesive line. We use the line_op operator, defined earlier, to evaluate the jump at the quadrature points of the cohesive line. We will the use the jump values to compute the crack opening displacement (\(\delta\)) at each quadrature point along the cohesive line.

The quadrature points coordinates are also calculated using the line_op operator.

quadrature_points = line_op.eval(mesh.coords[bottom_cohesive_nodes]).squeeze()
Code: Plot the opening along the cohesive line
plt.style.use(STYLE_PATH)
plt.figure(figsize=(5, 3), layout="constrained")
ax = plt.axes()


quadrature_points = line_op.eval(mesh.coords[bottom_cohesive_nodes]).squeeze()

steps_to_plot = np.arange(0, len(u_per_step_dynamic))

mycolor = plt.get_cmap("Spectral")(np.linspace(0, 1, len(steps_to_plot)))
for i, c in zip(steps_to_plot, mycolor):
    jump_quadrature = line_op.eval(
        u_per_step_dynamic[i][top_cohesive_nodes] - u_per_step_dynamic[i][bottom_cohesive_nodes]
    ).squeeze()

    opening_quadrature = compute_opening(jump_quadrature)


    ax.plot(
        quadrature_points[:, 0] - crack_length,
        opening_quadrature / L_G,
        label=f"Step {i+1}",
        c=c,
    )


ax.axhline(
    cohesive_mat.Gamma * np.exp(-1) / cohesive_mat.sigma_c / L_G,
    color="black",
    linewidth=0.5,
    ls="--",
)
ax.text(
    0.012,
    1.2 * cohesive_mat.Gamma * np.exp(-1) / cohesive_mat.sigma_c / L_G,
    r"$\delta_c=\Gamma \exp(-1) / \sigma_c$",
    color="black",
    fontsize=8,
)

ax.set_xlabel(r"$x -L_G$")
ax.set_ylabel(r"$\delta/L_G$")
ax.grid(True)
ax.margins(0.0, 0.0)
plt.show()
Figure 20.3: Opening along the cohesive line at different time steps. The time increases from red to blue.

Crack Tip Velocity and the Speed Limit

Now we analyze the kinematics of the propagating crack. Unlike in the quasi-static case, the crack speed is governed by the full dynamic equation of motion, where the excess driving force (the difference between Energy Release Rate \(G\) and Fracture Energy \(\Gamma\)) is converted into Kinetic Energy.

However, a crack cannot accelerate indefinitely. Theoretical fracture mechanics predicts a universal speed limit: the Rayleigh wave speed (\(c_R\)​), which is the speed at which surface waves travel in the material. As the crack tip approaches \(c_R\)​, the stress waves can no longer transport energy to the tip fast enough to sustain acceleration. The Rayleigh wave speed is approximately 92% of the shear wave speed \(c_s\)​ which is given by

\[ c_s = \sqrt{\frac{\mu}{\rho}} \]

where \(\mu\) is the shear modulus and \(\rho\) is the density of the material.

In the plot below, we compare our numerical crack velocity against the theoretical prediction derived by Mott, which states that the velocity \(c_f\) evolves as

\[ c_\text{f}≈c_\text{R}​(1−L_\text{G}​/a), \]

where \(a\) is the current crack length.

Note
  • For every 100th time step for which we stored the displacement \(\boldsymbol{u}\), compute the crack tip opening \(\delta\).
  • Determine the crack tip position as the location where the crack opening \(\delta\) reaches the critical opening \(\delta_c = \Gamma e^{-1}/\sigma_c\). Find the furthest point where \(\delta\) >= \(\delta_c\) from the original crack tip position. This gives the current crack tip position.
  • Compute the crack tip velocity as \[ c_\text{f} = \frac{da}{dt} \]

by differentiating the crack tip position with respect to time. To reduce noise in the velocity data, apply a moving average filter with a window size of 5 time steps. - Finally, plot the normalized crack tip velocity \(c_f/c_R\) against the normalized crack length \(a/L_G\) and compare it with the theoretical prediction.

Code: Plot the crack tip velocity
def moving_average(data, window_size):
    """Computes the moving average of a 1D array."""
    window = jnp.ones(window_size) / window_size
    # mode='valid' returns an array of length N - K + 1
    return jnp.convolve(data, window, mode="valid")


plt.style.use(STYLE_PATH)
plt.figure(figsize=(5, 3), layout="constrained")
ax = plt.axes()


quadrature_points = line_op.eval(mesh.coords[bottom_cohesive_nodes]).squeeze()

steps_to_plot = np.arange(0, len(u_per_step_dynamic))
crack_tips = []
crack_tip_velocity = []
crack_times = []

time_steps = np.arange(0, n_steps, 100) * dt

delta_c = cohesive_mat.Gamma * jnp.exp(-1) / cohesive_mat.sigma_c
mycolor = plt.get_cmap("Spectral")(np.linspace(0, 1, len(steps_to_plot)))
for i, c in zip(steps_to_plot, mycolor):
    jump_quad = line_op.eval(
        u_per_step_dynamic[i][top_cohesive_nodes]
        - u_per_step_dynamic[i][bottom_cohesive_nodes]
    ).squeeze()

    opening_quadrature = compute_opening(jump_quad)

    idx = np.argwhere(opening_quadrature > 1.0 * delta_c)[-1][0]
    crack_tip = quadrature_points[idx, 0]
    crack_tips.append(crack_tip)
    crack_times.append(time_steps[i])

crack_tips = jnp.array(crack_tips).flatten()
crack_times = jnp.array(crack_times).flatten()
raw_velocity = np.gradient(crack_tips, crack_times)


window_size = 5

if len(raw_velocity) > window_size:
    smooth_velocity = moving_average(raw_velocity, window_size)

    # Because 'valid' convolution shortens the array, we need to trim
    # the x-axis (crack_tips) to match the new length.
    # We trim from the start/end to align the centers.
    trim_start = (window_size - 1) // 2
    trim_end = trim_start + len(smooth_velocity)

    tips_for_plotting = crack_tips[trim_start:trim_end]
else:
    # Fallback if not enough data points
    smooth_velocity = raw_velocity
    tips_for_plotting = crack_tips

c_shear = jnp.sqrt(mu / rho)
c_rayleigh = 0.92 * c_shear

ax.plot(crack_tips / L_G, 1 - L_G / crack_tips, color="black")

ax.plot(
    crack_tips / L_G,
    raw_velocity / c_rayleigh,
    "o-",
    label="Crack tip velocity",
)
ax.plot(
    tips_for_plotting / L_G,
    smooth_velocity / c_rayleigh,
    "o-",
    label="Smoothed velocity",
)

ax.axhline(1.0, color="black", linewidth=0.5, ls="--")
ax.text(4, 0.4, r"$c_f/c_R = 1- L_G/a$", color="black", fontsize=10)
ax.text(4, 1.02, r"$c_f/c_R = 1$", color="black", fontsize=10)
ax.axvline(1.0, color="grey", linewidth=0.5, ls="--")
ax.set_xlabel(r"$a/L_G$")
ax.set_ylabel(r"$c_f/c_R$")
ax.grid(True)
ax.legend(frameon=False)
ax.set_ylim(top=1.1, bottom=0.0)
plt.show()
Figure 20.4: Crack tip velocity as a function of crack length.