In-class activity: Quasistatic fracture

In this exercise, we apply the concepts learned in the In-class activity: Cohesive zone modelling to simulate crack propagation in a plate under quasistatic loading. We will use a 2D plate with a pre-crack of length slightly larger than the Griffith fracture length loaded under Mode I loading.

An animation of the crack propagation is shown below. The stress value shown is \(\sigma_{yy}\).

We will assume that the crack propagates in a straight line in the \(x\)-direction which is known. This assumption is valid enough for this exercise as the loading is in Mode I. Furthermore, becuase of this assumption we know the \(\Gamma_\text{coh}\) and we can use the line elements to tie the two blocks of the plate together using Traction-Separation law. Therefore, for the plate the total potential energy is given by:

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

where \(\Psi_\text{elastic}(u)\) is the linear elastic energy and \(\Psi_\text{cohesive}(u)\) is the cohesive energy which is given by:

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

For this exercise we will assume exponential cohesive potential:

\[ \psi(\delta) = \frac{1}{2} \Gamma \left(1 - ( 1 + \frac{\delta}{\delta_c}) \exp\left(-\frac{\delta}{\sigma_c}\right)\right) \]

At the end of the exercise, we will investigate:

Code: Importing the 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,
)

from jax_autovmap import autovmap

from typing import Callable, Optional, Tuple

import numpy as np

import matplotlib.pyplot as plt

Model setup

For our model setup, we consider a plate with a pre-crack of length \(a_0\) subjected to tensile loading on the top and bottom surfaces. The plate is subjected to a prestrain of \(\varepsilon^0 = 0.1\). In order to ensure that for this applied displacement, the crack \(a_0\) nucleates and propagates, we make use of the the energy balance criteria i._e

\[ G = \Gamma = \frac{K_I^2}{E'} \]

where \(G\) is the energy release rate, \(\Gamma\) is the fracture energy, \(K_I\) is the stress intensity factor and \(E'\) is the effective elastic modulus. For an infinite plate with a pre-crack of length \(a\), the stress intensity factor is given by:

\[ K_I = \sigma_\infty \sqrt{\pi a} \]

Equating the two relations, we get

\[ a = \dfrac{1}{\pi}\frac{\Gamma E } {(1-\nu^2 )\sigma_\infty^2} \] The above expression tells that for an infinite plate with the applied stress \(\sigma_\infty\) is inversely proportional to the critical crack length \(a\). This means that if we have a small pre-crack, the applied stress \(\sigma_\infty\) will be large to make it nucleate when compared to a plate with a larger pre-crack. This gives the minimum crack length \(a\) for which the crack will nucleate and propagate at the applied stress \(\sigma_\infty\).

In this notebook, we use \(L_G\) to denote the critical crack length

\[ L_G = a \]

We will use the above relation to determine the critical crack length for our model setup. We will use the following material parameters:

  • Young’s modulus \(E = 106 \times 10^3\) N/m \(^2\)
  • Poisson’s ratio \(\nu = 0.35\)
  • Fracture energy \(\Gamma = 15\) J/m \(^2\)
  • Critical stress \(\sigma_c = 20 \times 10^{3}\) N/m \(^2\)

and the applied stress \(\sigma_\infty = \varepsilon^0 /E_\text{eff}\) where \(E_\text{eff}\) is the effective Young’s modulus and \(E_\text{eff} = E/(1-\nu^2)\) is the effective Young’s modulus.

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 / (1 - nu**2)

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.003952597997069948 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)

    # 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 = 100  # Number of elements in X
Ny = 20  # 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.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 total elastic energy

The total elastic energy of the system is given by:

\[ \Psi_\text{e}(\boldsymbol{u}) = \frac{1}{2} \int_\Omega \psi_e d\Omega \]

where

\[ \psi_\text{e}(x) = \boldsymbol{\sigma}(x) : \boldsymbol{\varepsilon}(x) \]

where \(\boldsymbol{\sigma}\) is the stress tensor and \(\boldsymbol{\varepsilon}\) is the strain tensor.

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

and

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

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):
    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)

Defining the fracture energy

Now, we define the cohesive energy. The cohesive energy is defined as:

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

where

  • \(\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.

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 are two blocks have two surfaces along which the fracture can propagate (upper and lower), we can use any one of the two surfaces to define the cohesive zone. Here, we use the lower surface. We will define a new mesh that contains the line elements from one of the two interfaces. 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: Generate 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

Use the above defined function extract_interface_mesh to generate the interface mesh and then define the line_op operator.

interface_mesh = extract_interface_mesh(mesh, bottom_interface_elements)
line = element.Line2()
line_op = Operator(interface_mesh, line)

Defining the Traction-Separation potential

In order to define the traction-separation potential, we need

  • the critical stress \(\sigma_c\)
  • the fracture energy \(\Gamma\)
  • the penalty parameter \(\kappa\)
  • and a functional form of the traction-separation potential \(\psi(\delta)\)

Use the \(\sigma_c\) and \(\Gamma\) as we defined at the beginning of the notebook. Below you need to define the penalty parameter \(\kappa\).

penalty = 1e3

For this exercise, we chose an exponential cohesive potential:

\[ \psi(\delta) = \begin{cases} \Gamma \left(1 - \left(1 + \frac{\delta}{\delta_c}\right) e^{-\frac{\delta}{\delta_c}}\right) & \text{for}~ \delta \ge 0 \\ \frac{1}{2} \kappa \delta^2 & \text{for}~ \delta < 0 \end{cases} \]

where \(\delta_c = \frac{\Gamma e^{-1}}{\sigma_c}\) is the critical separation, \(\Gamma\) is the fracture energy, \(\sigma_c\) is the critical stress, and \(e\) is the base of the natural logarithm.

We define the effective crack opening as:

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

where \(\boldsymbol{t}\) is the tangential direction (which for this problem is the \(x\)-direction) and \(\boldsymbol{n}\) is the normal direction to the cohesive interface (which for this problem is the \(y\)-direction).

Note

If you notice, the potential is also defined for \(\delta < 0\). When \(\delta < 0\), it means that the two surfaces are actually penetrating each other. In this case, we use a penalty like term to penalize the interpenetration. This is exactly similar to the penalty method used in the contact mechanics.

Remember at this point we do not introduce the irreversibility in the cohesive potential. This will be done in the next lectures.

In order to compute the \(\psi(\delta)\) for an exponential traction-separation law we need to perform the following steps:

  1. Compute the opening \(\delta\) along the cohesive interface is defined as:

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

    where \(\boldsymbol{t}\) is the tangential direction (which for this problem is the \(x\)-direction) and \(\boldsymbol{n}\) is the normal direction to the cohesive interface (which for this problem is the \(y\)-direction).

  2. Compute the cohesive energy density \(\psi(\delta)\) using the exponential traction-separation potential.

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

Hint

The function to compute the opening of the cohesive element will look like this:

@autovmap(jump=1)
def compute_opening(jump: jnp.ndarray) -> float:
    """
    Compute the opening of the cohesive element.
    Args:
        jump: The jump in the displacement field, which is the difference between the displacement of the upper and lower bodies across the crack.
    Returns:
        The opening of the cohesive element.
    """
    opening = ... # TODO: Compute the opening of the cohesive element
    return opening

The function to compute the cohesive energy density will look like this:

@autovmap(jump=1)
def exponential_cohesive_energy(
    jump: jnp.ndarray,
    Gamma: float,
    sigma_c: float,
    penalty: float,
    delta_threshold: float = 0.0,
) -> 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 = ... # TODO: Compute the opening of the cohesive element

    ... # TODO: check if delta is greater than the threshold

    ... # TODO: if delta is greater than the threshold, compute the cohesive energy density using the exponential traction-separation potential

    ... # TODO: if delta is less than the threshold, return 0.5 * penalty * delta**2

    return cohesive_energy_density

In order to check if the delta is greater than the threshold, use the jax.lax.cond function.

Moreover, use the below defined safe_sqrt function to compute the square root of the delta. We redefine the square root function because default square root function is non-differentiable at zero because of the limit definition of the square root. Here we use the jnp.where function to return zero when the input is less than or equal to zero. This makes the square root function differentiable at zero.

@jax.jit
def safe_sqrt(x):
    return jnp.sqrt(jnp.where(x > 0., x, 0.))
@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 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)

Now use the above defined function and the line_op operator to compute the fracture energy. The fracture energy is then given by:

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

We will need the nodes associated with the interface elements to later compute the jump 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 fracture energy.

@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, Gamma, sigma_c, penalty
    )
    return line_op.integrate(cohesive_energy_density)

Defining the total potential energy

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

@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

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.

Code: Define the boundary conditions
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 = 1.0 * 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.

# creating functions to compute the gradient and
gradient = jax.jacrev(total_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, (u_prev,), (du_projected,))[1]
    tangent = tangent.at[fixed_dofs].set(0)
    return tangent

Below we define the Conjugate-gradient method and the Newton-Rahpson method to solve the nonlinear problem of contact. See Matrix-free solvers, In-class activity: Node-to-Surface Contact and In-class activity: Cohesive zone modelling for more details on how to implement Conjugate-gradient and Newton-Raphson methods for matrix-free solvers.

Hint

We need to define two functions:

def conjugate_gradient(...):
    ...

def newton_krylov_solver(...):
    ...
Code: Functions to implement the conjugate gradient method and Netwon-Raphson method
from functools import partial
import equinox as eqx

#@partial(jax.jit, static_argnames=["A", "atol", "max_iter"])
@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 = 80

    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} = 1.0 \times \varepsilon^0\).

For each case, the entire loading is divided into 150 increments. where for first 100 increments, the displacement is incremented uniformly till \(\varepsilon_{applied}\) is reached and for the last 50 increments, the displacement is kept constant at \(\varepsilon_{applied}\). 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. Along with the displacement and the force, we also store the total strain energy and the fracture energy. We will also store the residual norm for each step.

So the quantities we store at each step are:

  • total displacement in \(y\)-direction on the top surface
  • total force in \(y\)-direction on the top surface
  • total strain energy
  • total fracture energy
  • residual norm
  • displacement vector for the entire domain for each step (we will use this to plot the deformed configuration and the crack tip opening)
Hint

The loop will look like this:

for step in range(n_steps):
    print(f"Step {step+1}/{n_steps}")
    # ... Your code for each step will go inside this loop ...

Inside the Loop: Post-Processing and Data Storage

After each successful step, calculate and store the physical quantities you want to analyze later.

  • Calculate the total elastic_energy using the total_elastic_energy function and store it.
  • Calculate the total fracture_energy using the total_fracture_energy function and store it.
  • Calculate the total internal force at the top surface (the “load”) and store it.
  • Store the residual norm for the current step in rnorm_per_step.
  • Store the displacement vector for the entire domain in u_per_step.
u_prev = jnp.zeros(n_dofs)
fext = jnp.zeros(n_dofs)

n_steps = 100

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

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["cohesive"].append(
    total_cohesive_energy(u_prev.reshape(n_nodes, n_dofs_per_node))
)

du_total = prescribed_values / n_steps  # displacement increment

error_per_step = []

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

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

    u_prev = u_new
    force_on_top.append(jnp.sum(gradient(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["cohesive"].append(
        total_cohesive_energy(u_prev.reshape(n_nodes, n_dofs_per_node))
    )
    error_per_step.append(rnorm)

u_solution = u_prev.reshape(n_nodes, n_dofs_per_node)

Before, we move on to seeing how the results look like, we need to check if the code is converging to the solution. Fracture is a non-linear problem and to ensure that the reuslts we see are at least numerically correct, we need to check the convergence of the solution. Since we set the tolerance to \(10^{-8}\), we can plot the error per step to see if the solution is converging.

\[ r_\text{norm} = \| \boldsymbol{r} \| \]

Question

Does the residual norm is always below the tolerance level \(10^{-8}\) for your case for different applied strains? If not, what strategy did you apply to ensure that the solution is converged?

plt.figure(figsize=(4, 3), layout="constrained")
plt.semilogy(error_per_step, '-o')
plt.xlabel(r"Step number")
plt.ylabel(r"Residual norm, $||r||$")
plt.grid(True)
plt.axhline(1e-8, color="black", linewidth=0.5)
plt.ylim(1e-9, 1e-7)
plt.show()
Figure 17.1: Convergence of the solution.

Force-displacement response and energy curves

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.

Below we plot the deformed configuration and the force-displacement curve for \(\varepsilon_{applied} = 1.0 \times \varepsilon^0\) at one of the intermediate steps (step number 80). You can run your code for \(\varepsilon_{applied} = 1.0 \times \varepsilon^0\) and check with the plot below to see if your code is correct.

The total energy of the system must be conserved, i.e in order for the crack to propagate, the energy must be dissipated. This energy is taken from the elastic energy of the system. So, if we plot the two energies (elastic and cohesive) as a function of the displacement, we should see that the total energy is conserved. And changes from one to another.

Furthermore, we assumed that the fracture energy (\(\Gamma\)) of the material is constant. Therefore, the total dissipated from crack to propagate through the entire length (\(W\)) of the crack plane would be equal to

\[ \Gamma \cdot{} W \]

Below, we will plot the elastic and fracture energies as a function of the displacement and loading step to see how the energy is dissipated and changes from one to another.

Question

Comment on what you observe in the force-displacement curve and the energy curves. What is physically happening in the material as the crack propagates?

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

# squeeze to remove the quad point dimension (only 1 quad point)
step_number = 80
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=(8, 4))

gs = GridSpec(1, 3, figure=fig, width_ratios=[0.6, 0.4, 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("equal")
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))

ax = fig.add_subplot(gs[0, 2])
ax.plot(displacement_on_top, energies["elastic"], "o-", label=r"$\psi_\mathrm{elastic}$")
ax.plot(displacement_on_top, energies["cohesive"], "o-", label=r"$\psi_\mathrm{fracture}$", zorder=100)
ax.plot(displacement_on_top, np.array(energies["elastic"]) + np.array(energies["cohesive"]), "o-", label=r"$\psi_\mathrm{total}$")

ax.axhline(Gamma * (Lx - crack_length), color="black", linewidth=0.5)
ax.text(
    displacement_on_top[-1] / 2,
    0.95 * Gamma * (Lx - crack_length),
    r"$\Gamma\cdot W$",
    color="black",
    fontsize=8,
)
ax.set_xlabel("Displacement on top")
ax.set_ylabel("Energies")
ax.set_aspect(1 / ax.get_data_ratio())
ax.set_ylim(0, 1.1 * Gamma * (Lx - crack_length))
ax.grid(True)
ax.margins(0, 0)
ax.legend(frameon=False, loc="center left")
ax.ticklabel_format(axis="both", style="sci", scilimits=(1, -4))

plt.show()
Figure 17.2: Snapshot of deformed configuration with crack propagation at one of the intermediate steps and force-displacement curve, and energy curves. Left: Deformed configuration with stress field in y-direction. Middle: Force-displacement curve. Right: Energy curves.

Crack tip opening

Now, let us see how evolution of the crack tip opening 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. The quadrature points coordinates are also calculated using the line_op operator.

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

The jump vector is defined as:

\[ [\![\boldsymbol{u}]\!] = \boldsymbol{u}_+ - \boldsymbol{u}_- \]

where \(\boldsymbol{u}_+\) and \(\boldsymbol{u}_-\) are the displacement vectors at the top and bottom of the cohesive line, respectively. Below, we show how to use the eval method of the line_op operator to compute the jump vector at the quadrature points along the cohesive line.

Using this we can compute the opening at the quadrature points along the cohesive line.

Below compute the jump vector at the quadrature points and the opening at the quadrature points along the cohesive line for the last step of the quasistatic problem. Store the opening values in a variable named opening_quadrature.

jump_quadrature = line_op.eval(
    u_solution[top_cohesive_nodes] - u_solution[bottom_cohesive_nodes]
).squeeze()
opening_quadrature = compute_opening(jump_quadrature)

Now,we do the same for each step of the quasistatic problem. We plot the opening at the quadrature points along the cohesive line for each step. You will have to store the opening values for each step.

Hint

Use the quadrature_points and opening arrays stored at each step to plot the opening along the cohesive line for each step. Remember that the quadrature_points are the coordinates of the quadrature points along the cohesive line and thus the x-coordinates are the same for all the steps.

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.annotate(
    "",
    xy=(0.02, 0.3),
    xycoords="data",
    xytext=(0.05, 0.5),
    textcoords="data",
    arrowprops=dict(
        arrowstyle="<-",
        color="0.7",
        shrinkA=5,
        shrinkB=5,
        patchA=None,
        patchB=None,
        connectionstyle="arc3,rad=-0.2",
    ),
    zorder=-100,
)
ax.text(0.035, 0.3, r"Increasing loading", color="0.7", 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 17.3: 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.

Running the simulation for different applied strains

Now we want to see what happens to this plate configuration when we change the applied strain. We will run the simulation for 3 different applied strains:

  • \(\varepsilon_{applied} = 0.7 \times \varepsilon^0\)
  • \(\varepsilon_{applied} = 0.85 \times \varepsilon^0\)
  • \(\varepsilon_{applied} = 1.0 \times \varepsilon^0\)
Note

So in total, you will run the simulation for 3 different cases. The \(a_c\) is still calculated based on the \(\varepsilon^{0}=1.0\) so the plate geometry doesnot change and the crack length is the same, just the applied strain is changed.

What we want to see is:

  • How the force-displacement curve changes with the applied strain?
  • How the energy distribution (elastic and fracture) changes with the applied strain? Whether for given applied strain, we have enough energy to propagate the crack through the entire length of the cohesive line.
🐍 Download source code

In order to perform the above computations for different applied loadings, we will wrap the code in a function called solve_quasistatic. Click below to download the script that contains the function solve_quasistatic used below.

📥 Download script used below

Import the solve_quasistatic function from the downloaded script. This function is basically all the code that we have written above, wrapped in a function.

from solve_quasistatic import solve_quasistatic

energies_dict = {}
force_on_top_dict = {}
displacement_on_top_dict = {}
for applied_strain_percent in [0.7, 0.85, 1.0]:
    (
        _,
        energies_dict[applied_strain_percent],
        force_on_top_dict[applied_strain_percent],
        displacement_on_top_dict[applied_strain_percent],
    ) = solve_quasistatic(applied_strain_percent)

Load response

Code: Plot the load-displacement curves for different applied strains
from tatva.plotting import colors

plt.style.use(STYLE_PATH)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 3), layout="constrained")

my_colors = [colors.blue, colors.red, colors.green]

for i, applied_strain_percent in enumerate([1.0, 0.85, 0.7]):
    ax1.plot(
        displacement_on_top_dict[applied_strain_percent],
        force_on_top_dict[applied_strain_percent],
        "o-",
        color=my_colors[i],
        label=r"$\varepsilon_{applied}$"
        + rf"$= {applied_strain_percent} \times \varepsilon^0$",
    )


ax1.set_xlabel(r"$u_y$")
ax1.set_ylabel(r"Force on top")
ax1.grid(True)
ax1.legend(frameon=False)
ax1.set_xlim(0, displacement_on_top_dict[1.0][-1])
ax1.set_ylim(0, 1.1*np.max(force_on_top_dict[1.0]))


for i, applied_strain_percent in enumerate([1.0, 0.85, 0.7]):
    ax2.plot(
        force_on_top_dict[applied_strain_percent],
        "o-",
        color=my_colors[i],
        label=r"$\varepsilon_{applied}$"
        + rf"$= {applied_strain_percent} \times \varepsilon^0$",
    )
ax2.set_xlabel("loading step")
ax2.set_ylabel(r"Force on top")

ax2.text(
    1.05 * n_steps,
    1.1*np.max(force_on_top_dict[1.0])/4,
    r"applied strain constant",
    color="black",
    fontsize=7,
)
ax2.fill_betweenx(
    [0, 1.1*np.max(force_on_top_dict[1.0])],
    [n_steps, n_steps],
    [150, 150],
    color="gray",
    alpha=0.2,
)

ax2.grid(True)
ax2.legend(frameon=False, loc="upper left")
ax2.set_xlim(0, 150)
ax2.set_ylim(0, 1.1*np.max(force_on_top_dict[1.0]))

plt.show()
Figure 17.4: Load-displacement curves for different applied strains. Left: Load vs displacement curves. Right: Load vs loading step curves.

Energy conservation

Below, we will plot the elastic and fracture energies as a function of the displacement and loading step to see how the energy is dissipated and changes from one to another.

Question

Plot the elastic energy and the total potential energy curves for

  • \(\varepsilon_{applied} = 0.7 \times \varepsilon^0\),
  • \(\varepsilon_{applied} = 0.85 \times \varepsilon^0\)
  • \(\varepsilon_{applied} = 1.0 \times \varepsilon^0\)

as a function of the displacement and loading step.

What do you observe for different applied strains? What is physically happening in the material for different applied strains?

Code: Plot the energy curves
from tatva.plotting import colors

plt.style.use(STYLE_PATH)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 3), layout="constrained")

my_colors = [colors.blue, colors.red, colors.green]

for i, applied_strain_percent in enumerate([1.0, 0.85, 0.7]):
    ax1.plot(
        displacement_on_top_dict[applied_strain_percent],
        energies_dict[applied_strain_percent]["elastic"],
        "o-",
        color=my_colors[i],
        label=r"$\varepsilon_{applied}$"
        + rf"$= {applied_strain_percent} \times \varepsilon^0, \psi_\mathrm{{elastic}}$",
    )
    ax1.plot(
        displacement_on_top_dict[applied_strain_percent],
        np.array(energies_dict[applied_strain_percent]["elastic"])
        + np.array(energies_dict[applied_strain_percent]["cohesive"]),
        "-.",
        color=my_colors[i],
        label=r"$\psi_\mathrm{{total}}$",
    )


ax1.set_xlabel(r"$u_y$")
ax1.set_ylabel(r"$\psi$")
ax1.grid(True)
ax1.legend(frameon=False)
ax1.set_xlim(0, displacement_on_top_dict[1.0][-1])
ax1.set_ylim(0, 1.1 * Gamma * (Lx - crack_length))

for i, applied_strain_percent in enumerate([1.0, 0.85, 0.7]):
    ax2.plot(
        energies_dict[applied_strain_percent]["elastic"],
        "o-",
        color=my_colors[i],
        label=r"$\varepsilon_{applied}$"
        + rf"$= {applied_strain_percent} \times \varepsilon^0$",
    )
    ax2.plot(
        np.array(energies_dict[applied_strain_percent]["elastic"])
        + np.array(energies_dict[applied_strain_percent]["cohesive"]),
        "-.",
        color=my_colors[i],
        label=r"$\psi_\mathrm{{total}}$",
    )
ax2.set_xlabel("loading step")
ax2.set_ylabel(r"$\psi$")

ax2.text(
    1.05 * n_steps,
    0.5 * Gamma * (Lx - crack_length),
    r"applied strain constant",
    color="black",
    fontsize=7,
)
ax2.fill_betweenx(
    [0, 1.1 * Gamma * (Lx - crack_length)],
    [n_steps, n_steps],
    [150, 150],
    color="gray",
    alpha=0.2,
)

ax2.grid(True)
ax2.legend(frameon=False)
ax2.set_xlim(0, 150)
ax2.set_ylim(0, 1.1 * Gamma * (Lx - crack_length))

plt.show()
Figure 17.5: Total energy curves (elastic and total energies) for different applied strains. Left: Energy vs displacement curves. Right: Energy vs loading step curves.
Question

Plot the fracture energy curves for

  • \(\varepsilon_{applied} = 0.7 \times \varepsilon^0\),
  • \(\varepsilon_{applied} = 0.85 \times \varepsilon^0\)
  • \(\varepsilon_{applied} = 1.0 \times \varepsilon^0\)

as a function of the displacement and loading step.

Check if the total dissipated energy is equal to \(\Gamma \cdot{} W\) for all the cases. For which cases, the fracture energy is not equal to \(\Gamma \cdot{} W\)? For the cases where the fracture energy is not equal to \(\Gamma \cdot{} W\), would mean that crack nucleated and only propagated partially. What is the reason for this?

Code: Plot the fracture energy curves
plt.style.use(STYLE_PATH)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 3), layout="constrained")

for applied_strain_percent in [1.0, 0.85, 0.7]:
    ax1.plot(
        displacement_on_top_dict[applied_strain_percent],
        energies_dict[applied_strain_percent]["cohesive"],
        "o-",
        label=r"$\varepsilon_{applied}$"
        + rf"$= {applied_strain_percent} \times \varepsilon^0$",
    )

ax1.axhline(Gamma * (Lx - crack_length), color="black", linewidth=0.5)
ax1.text(
    displacement_on_top_dict[1.0][-1] / 2,
    0.95 * Gamma * (Lx - crack_length),
    r"$\Gamma\cdot W$",
    color="black",
    fontsize=8,
)
# ax.set_aspect(1/ax.get_data_ratio())
ax1.set_xlabel(r"$u_y$")
ax1.set_ylabel(r"$\psi_\mathrm{fracture}$")
ax1.grid(True)
ax1.legend(frameon=False)
ax1.set_xlim(0, displacement_on_top_dict[1.0][-1])
ax1.set_ylim(0, 1.1 * Gamma * (Lx - crack_length))

for applied_strain_percent in [1.0, 0.85, 0.7]:
    ax2.plot(
        energies_dict[applied_strain_percent]["cohesive"],
        "o-",
        label=r"$\varepsilon_{applied}$"
        + rf"$= {applied_strain_percent} \times \varepsilon^0$",
    )

ax2.axhline(Gamma * (Lx - crack_length), color="black", linewidth=0.5)
ax2.axvline(n_steps, color="gray", linewidth=0.5)
ax2.text(
    n_steps / 2,
    0.95 * Gamma * (Lx - crack_length),
    r"$\Gamma\cdot W$",
    color="black",
    fontsize=8,
)
ax2.text(
    1.05 * n_steps,
    0.5 * Gamma * (Lx - crack_length),
    r"applied strain constant",
    color="black",
    fontsize=7,
)
ax2.fill_betweenx(
    [0, 1.1 * Gamma * (Lx - crack_length)],
    [n_steps, n_steps],
    [150, 150],
    color="gray",
    alpha=0.2,
)
ax2.set_xlabel("loading step")
ax2.set_ylabel(r"$\psi_\mathrm{fracture}$")
ax2.grid(True)
ax2.legend(frameon=False)
ax2.set_xlim(0, 150)
ax2.set_ylim(0, 1.1 * Gamma * (Lx - crack_length))

plt.show()
Figure 17.6: Fracture energy curves for different applied strains. Left: Fracture energy vs displacement curves. Right: Fracture energy vs loading step curves.