In-class activity: Corrosion-induced stress in concrete

In this activity, we will explore one of the most critical durability issues in civil engineering: corrosion-induced cracking.

When a steel bar embedded in concrete corrodes, it releases iron ions (\(Fe^{2+}\)) that diffuse into the surrounding pore network. These ions react to form rust (iron oxides). The key physical conflict is volume: Rust occupies a much larger volume than the original steel.

This creates a complex, two-way coupled multiphysics problem:

  1. Forward Coupling (Chemistry \(\to\) Mechanics): As rust precipitates, it swells. This exerts massive internal pressure on the concrete, leading to tensile hoop stresses and eventually cracking.

  2. Backward Coupling (Mechanics \(\to\) Chemistry):

    • Thermodynamic Stifling: As the concrete resists expansion, compressive stress builds up around the pore. This stress acts as an energy barrier, making it thermodynamically unfavorable for more rust to form. Effectively, the pressure increases the solubility limit, stopping the reaction.
    • Kinetic Clogging: As solid rust fills the pore space, it physically blocks the path, reducing the diffusivity and preventing new ions from entering.
No Coupling Full Coupling

The Energy Formulation

We model this system using two field variables: the displacement field \(\boldsymbol{u}\) (mechanics) and the molar concentration \(c\) of iron species (chemistry).

We solve this using a Staggered Approach (Alternate Minimization), where we minimize two specific energy functionals sequentially.

Mechanical Energy Functional (Equilibrium)

The concrete seeks to minimize its elastic strain energy. However, the formation of solid rust introduces a Chemical Eigenstrain (\(\boldsymbol{\varepsilon}^*\))—a stress-free expansion that forces the material to deform.

\[ \Psi_{\text{mech}}(\boldsymbol{u}, c) = \int_{\Omega} \frac{1}{2} (\boldsymbol{\varepsilon} - \boldsymbol{\varepsilon}^*) : \mathbb{C} : (\boldsymbol{\varepsilon} - \boldsymbol{\varepsilon}^*) \, d\Omega \]

\[ \boldsymbol{\varepsilon}^* = \alpha \cdot \langle c - c_{sat} \rangle_+ \cdot \mathbf{I} \] where \(\alpha\) is the chemical expansion coefficient and \(\langle \cdot \rangle_+\) is the ramp function.

Chemical Energy Functional (Transport)

The transport of ions is driven by diffusion but resisted by the “clogging” effect. Since diffusion is time-dependent, we minimize an incremental rate potential for each time step \(\Delta t\):

\[ \Psi_{\text{chem}}(c) = \int_{\Omega} \left( \underbrace{\frac{1}{2 \Delta t}(c - c_{\text{prev}})^2}_{\text{Rate Term}} + \underbrace{\frac{1}{2} \kappa(c) \|\nabla c\|^2}_{\text{Diffusion}} \right) d\Omega \]

The Two-Way Coupling in the Solver

When we minimize the total energy w.r.t. concentration \(c\), the mechanical energy contributes a derivative term: \[ \frac{\partial \Psi_{\text{mech}}}{\partial c} = -3 \sigma_h \Omega \] This stress term naturally appears in the chemical potential. If the stress \(\sigma_h\) is compressive (negative), this term becomes positive, acting as an energy penalty that stops the diffusion of ions further into the pore and thus halting corrosion.

Workflow

In this activity, we want to observe how each coupling mechanism affects the corrosion-induced cracking process. We will run simulations with different combinations of the coupling effects enabled/disabled.

Case 1: No Coupling (Baseline)

  • Disable both thermodynamic stifling (\(\alpha = 0\)) and kinetic clogging (\(\kappa(c) = \kappa\))
  • Observe rapid diffusion of ions and precipitation of rust, but no stress effects.

Case 2: Thermodynamic Stifling Only

  • Enable the stress-dependent term (\(\alpha > 0\)) but keep constant diffusivity (\(\kappa(c) = \kappa\))
  • Obeserve buildup of compressive stresses around the pore.
  • Observe how compressive stresses slow down the corrosion process by comparison to Case 1.

Case 3: Kinetic Clogging Only

  • Disable stress effects (\(\alpha = 0\)) but enable concentration-dependent diffusivity (\(\kappa(c)\))
  • Observe how the diffusivity decreases as rust precipitates, slowing down the corrosion process.

Case 4: Full Coupling

  • Enable both thermodynamic stifling (\(\alpha > 0\)) and kinetic clogging (\(\kappa(c)\))
  • Observe the combined effects on corrosion-induced stresses.
Code: Import essential libraries
import os
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 equinox as eqx
import gmsh
import jax.numpy as jnp
import numpy as np
import matplotlib.pyplot as plt
import meshio
from jax import Array
from jax_autovmap import autovmap
from tatva import Mesh, Operator, element
from tatva.plotting import (
    STYLE_PATH,
    colors,
    plot_element_values,
    plot_nodal_values,
)

Model Setup

We will simulate a 2D rectangular concrete specimen with two regions:

  • Region 1: Concrete matrix with low porosity, which means slower diffusion.
  • Region 2: Concrete matrix with high porosity, which means faster diffusion.

Below we provide the function to generate the domain and the mesh for the simulation.

Code: Function to generate a mesh for a wavy pore geometry
def generate_wavy_pore_mesh(
    width: float,
    height: float,
    pore_amplitude: float,
    pore_wavelength: float,
    pore_thickness: float,
    mesh_size_matrix: float,
    mesh_size_pore: float,
    num_points_per_wave: int = 40,
):
    """
    Generates a 2D mesh for a rectangular block with a wavy (sine) pore channel.
    Models the domain as 3 stacked surfaces to avoid topological errors.
    """

    mesh_dir = os.path.join(os.getcwd(), "../meshes")
    os.makedirs(mesh_dir, exist_ok=True)
    output_filename = os.path.join(mesh_dir, "wavy_pore.msh")

    gmsh.initialize()
    gmsh.model.add("wavy_pore")

    # --- 1. Generate Coordinates for the Wavy Curves ---
    num_waves = width / pore_wavelength
    total_points = int(num_waves * num_points_per_wave)
    # Ensure exact start (0.0) and end (width)
    x_vals = np.linspace(0, width, total_points)
    
    # Calculate Y coordinates for the pore boundaries
    y_pore_bot = pore_amplitude * np.sin(2 * np.pi * x_vals / pore_wavelength) - pore_thickness / 2
    y_pore_top = pore_amplitude * np.sin(2 * np.pi * x_vals / pore_wavelength) + pore_thickness / 2

    # --- 2. Create Gmsh Points along the Curves ---
    # We use the fine mesh size for the interface
    bot_curve_tags = []
    top_curve_tags = []
    
    for x, y in zip(x_vals, y_pore_bot):
        bot_curve_tags.append(gmsh.model.geo.addPoint(x, y, 0, mesh_size_pore))
        
    for x, y in zip(x_vals, y_pore_top):
        top_curve_tags.append(gmsh.model.geo.addPoint(x, y, 0, mesh_size_pore))

    # --- 3. Create the Horizontal Curves ---
    # Pore Boundaries (Polyline is robust)
    l_pore_bot = gmsh.model.geo.addPolyline(bot_curve_tags)
    l_pore_top = gmsh.model.geo.addPolyline(top_curve_tags)

    # Domain Outer Boundaries (Top and Bottom of the box)
    p_bot_left = gmsh.model.geo.addPoint(0, -height/2, 0, mesh_size_matrix)
    p_bot_right = gmsh.model.geo.addPoint(width, -height/2, 0, mesh_size_matrix)
    p_top_right = gmsh.model.geo.addPoint(width, height/2, 0, mesh_size_matrix)
    p_top_left = gmsh.model.geo.addPoint(0, height/2, 0, mesh_size_matrix)

    l_domain_bot = gmsh.model.geo.addLine(p_bot_left, p_bot_right)
    l_domain_top = gmsh.model.geo.addLine(p_top_right, p_top_left)

    # --- 4. Create Vertical Connection Lines (Inlet/Outlet) ---
    # We need to split the inlet/outlet lines into segments for each layer
    
    # Left Side (Inlet) at x=0
    # From Bottom-Left Domain to Bottom-Start of Pore
    l_inlet_bot = gmsh.model.geo.addLine(p_bot_left, bot_curve_tags[0])
    # The Pore Inlet itself
    l_inlet_pore = gmsh.model.geo.addLine(bot_curve_tags[0], top_curve_tags[0])
    # From Top-Start of Pore to Top-Left Domain
    l_inlet_top = gmsh.model.geo.addLine(top_curve_tags[0], p_top_left)

    # Right Side (Outlet) at x=width
    # From Bottom-End of Pore to Bottom-Right Domain
    l_outlet_bot = gmsh.model.geo.addLine(bot_curve_tags[-1], p_bot_right)
    # The Pore Outlet itself
    l_outlet_pore = gmsh.model.geo.addLine(top_curve_tags[-1], bot_curve_tags[-1])
    # From Top-Right Domain to Top-End of Pore
    l_outlet_top = gmsh.model.geo.addLine(p_top_right, top_curve_tags[-1])

    # --- 5. Construct Surfaces (Loops) ---
    
    # Surface 1: Bottom Matrix
    # Loop: DomainBot -> OutletBot -> PoreBot(Reversed) -> InletBot
    loop_bot_matrix = gmsh.model.geo.addCurveLoop([l_domain_bot, -l_outlet_bot, -l_pore_bot, -l_inlet_bot])
    surf_bot_matrix = gmsh.model.geo.addPlaneSurface([loop_bot_matrix])

    # Surface 2: The Pore Channel
    # Loop: PoreBot -> OutletPore(Reversed) -> PoreTop(Reversed) -> InletPore(Reversed)
    # Note: Pay attention to point connectivity. 
    # l_pore_bot connects bot_start -> bot_end
    # l_outlet_pore connects top_end -> bot_end. So -l_outlet connects bot_end -> top_end
    # l_pore_top connects top_start -> top_end. So -l_pore_top connects top_end -> top_start
    # l_inlet_pore connects bot_start -> top_start. So -l_inlet connects top_start -> bot_start
    loop_pore = gmsh.model.geo.addCurveLoop([l_pore_bot, -l_outlet_pore, -l_pore_top, -l_inlet_pore])
    surf_pore = gmsh.model.geo.addPlaneSurface([loop_pore])

    # Surface 3: Top Matrix
    # Loop: PoreTop -> OutletTop(Reversed) -> DomainTop -> InletTop
    # l_pore_top: top_start -> top_end
    # l_outlet_top: top_right_domain -> top_end. So -l_outlet_top: top_end -> top_right_domain
    # l_domain_top: top_right_domain -> top_left_domain
    # l_inlet_top: top_start -> top_left_domain. So -l_inlet_top: top_left_domain -> top_start
    loop_top_matrix = gmsh.model.geo.addCurveLoop([l_pore_top, -l_outlet_top, l_domain_top, -l_inlet_top])
    surf_top_matrix = gmsh.model.geo.addPlaneSurface([loop_top_matrix])

    gmsh.model.geo.synchronize()

    # --- 6. Physical Groups ---
    # Group the top and bottom parts into one "matrix" group
    gmsh.model.addPhysicalGroup(2, [surf_bot_matrix, surf_top_matrix], 1, name="matrix")
    gmsh.model.addPhysicalGroup(2, [surf_pore], 2, name="pore")
    
    # Boundaries
    # Left Wall (Steel Interface) includes the matrix parts and the pore inlet
    gmsh.model.addPhysicalGroup(1, [l_inlet_bot, l_inlet_top], 3, name="left_wall_matrix")
    gmsh.model.addPhysicalGroup(1, [l_inlet_pore], 4, name="pore_inlet")

    # --- 7. Generate ---
    gmsh.model.mesh.generate(2)
    gmsh.write(output_filename)
    
    gmsh.finalize()

    # --- 8. Read back with Meshio ---
    _mesh = meshio.read(output_filename)

    mesh = Mesh(
        coords=_mesh.points[:, :2],
        elements=_mesh.cells_dict["triangle"],
    )

    # Extract indices
    matrix_indices = _mesh.cell_sets_dict["matrix"]["triangle"]
    pore_indices = _mesh.cell_sets_dict["pore"]["triangle"]

    return mesh, matrix_indices, pore_indices
pore_thickness = 0.5

mesh, matrix_element_indices, pore_element_indices = generate_wavy_pore_mesh(
        width=5.0,
        height=2.0,
        pore_amplitude=0.2,
        pore_wavelength=2.5,
        pore_thickness=pore_thickness,
        mesh_size_matrix=0.1,
        mesh_size_pore=0.05 # Fine mesh inside pore
    )
Code: Plot the mesh
# Create face color array
facecolors = np.zeros(mesh.elements.shape[0])
facecolors[pore_element_indices] = 1.0 # Highlight pore
    
plt.style.use(STYLE_PATH)
plt.figure(figsize=(6, 4))
ax = plt.axes()
ax.tripcolor(
        mesh.coords[:, 0], mesh.coords[:, 1], mesh.elements,
        facecolors=facecolors, edgecolors="k", lw=0.1, cmap="managua_r"
)
ax.set_aspect('equal')
ax.margins(0, 0)
plt.show()
Figure 21.1: Mesh with a wavy pore channel highlighted. Blue: Concrete matrix region with low porosity; Orange: Concrete region with high porosity.

We also need to define two meshes for the concrete matrix region with low porosity and the concrete matrix region with high porosity. We will use these meshes to assign different diffusivity values in the two regions.

matrix_mesh corresponds to the concrete matrix with low porosity,

pore_mesh corresponds to the concrete matrix with high porosity.

matrix_mesh = Mesh(mesh.coords, mesh.elements[matrix_element_indices])
pore_mesh = Mesh(mesh.coords, mesh.elements[pore_element_indices])

Since we will solve the coupled problem using the staggered approach, we need to define the degrees of freedom for each physical problem separately. Lets now define the degrees of freedom for the each physical problem. We will have displacement degrees of freedom for each node in the mesh and concentration degrees of freedom for each node in the mesh.

Since each node has 2 displacement degrees of freedom (for 2D problems) and 1 concentration degree of freedom, the total degrees of each problem will be:

  • Mechanical problem: \(2 \times \text{number of nodes}\)
  • Concentration problem: \(1 \times \text{number of nodes}\)
n_nodes = mesh.coords.shape[0]
n_elasticity_dofs_per_node = 2

n_elasticity_dofs = n_elasticity_dofs_per_node * n_nodes
n_conc_dofs = n_nodes

Material Properties

The two regions have different material properties.

The concrete matrix region with low porosity (matrix_mesh) has the following properties:

Material Property Symbol Value
Young’s Modulus \(E\) 100
Poisson’s Ratio \(\nu\) 0.2
Diffusivity \(\kappa\) 0.01

The concrete matrix region with high porosity (pore_mesh) has the following properties:

Material Property Symbol Value
Young’s Modulus \(E\) 10
Poisson’s Ratio \(\nu\) 0.2
Diffusivity \(\kappa\) 1.0

The chemical and mechanical properties for the rust are as follows:

Material Property Symbol Value
Saturation Concentration \(c_{sat}\) 0.4
Chemical Expansion Coefficient \(\alpha\) 0.5
Pore Capacity/ Concentration Limit \(\phi_{limit}\) 0.5
from typing import NamedTuple


class Material(NamedTuple):
    """Properties of the thermal material"""

    kappa: float  # Diffusivity
    alpha: float  # Rust expansion coefficient
    mu: float  # Shear modulus
    lmbda: float  # First Lamé parameter
    

E = 100.0
nu = 0.2
lmbda = E * nu / (1 + nu) / (1 - 2 * nu)
mu = E / 2 / (1 + nu)
c_sat = 0.4
pore_capacity = 0.5

dt = 0.02
alpha = 0.5

mu_pore = mu * 0.1
lmbda_pore = lmbda * 0.1
kappa_pore = 1.0
kappa_matrix = 1e-2

reduction_factor = 0.5

mat_pore = Material(kappa=kappa_pore, alpha=alpha, mu=mu_pore, lmbda=lmbda_pore)
mat_matrix = Material(kappa=kappa_matrix, alpha=alpha, mu=mu, lmbda=lmbda)

We now define the Operators for the top and bottom meshes separately to compute the various energy functionals

tri = element.Tri3()
op_pore = Operator(pore_mesh, tri)
op_matrix = Operator(matrix_mesh, tri)

Defining energy functionals for mechanical problem

The total stresses in the strip aare given as

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

were \(\mu\) and \(\lambda\) are the Lamé’s parameters, \(\boldsymbol{\epsilon}\) is the strain tensor, and \(\mathbf{I}\) is the identity tensor. The elastic strain tensor is defined as:

\[ \boldsymbol{\varepsilon}^\text{elas} = \boldsymbol{\varepsilon} - \boldsymbol{\varepsilon}^\text{corro} \]

where \(\boldsymbol{\varepsilon}\) is the total strain tensor and \(\boldsymbol{\varepsilon}^\text{corro}\) is the corrosion-induced strain tensor.

The corrosion-induced strain tensor is defined as: \[ \boldsymbol{\varepsilon}^\text{corro} = \alpha \langle c - c_\text{sat}\rangle_{+} \mathbf{I} \]

and \(\alpha\) is the expansion coefficient of rust and \(c\) is the concentration of the corrosive species. The total strain tensor is defined as:

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

The total elastic energy is thus given by:

\[ \Psi_\text{elastic} = \int_{\Omega} \frac{1}{2} \boldsymbol{\sigma} : \boldsymbol{\varepsilon}^\text{elas} ~\text{d}\Omega \]

where \(\Omega\) is the domain of the strip.

@jax.jit
def macaulay_bracket(x):
    return jnp.where(x > 0, x, 0)

@autovmap(grad_u=2)
def compute_strain(grad_u: Array) -> Array:
    """Compute the strain tensor from the gradient of the displacement."""
    return 0.5 * (grad_u + grad_u.T)


@autovmap(eps=2, mu=0, lmbda=0)
def compute_stress(eps: Array, mu: float, lmbda: float) -> Array:
    """Compute the stress tensor from the strain tensor."""
    I = jnp.eye(2)
    return 2 * mu * eps + lmbda * jnp.trace(eps) * I


@autovmap(grad_u=2, c=0, mu=0, lmbda=0, alpha=0)
def strain_energy_density(
    grad_u: Array, c: Array, mu: float, lmbda: float, alpha: float
) -> Array:
    """Compute the strain energy density.

    Args:
        grad_u (Array): Gradient of the displacement field.
        c (Array): Concentration field.
        mu (float): Shear modulus.
        lmbda (float): First Lamé parameter.
        alpha (float): Rust expansion coefficient.
    Returns:
        Array: Strain energy density.
    """
    eps = compute_strain(grad_u)
    eps_corro = alpha * macaulay_bracket(c-c_sat) * jnp.eye(2)
    eps_elastic = eps - eps_corro
    sig = compute_stress(eps_elastic, mu, lmbda)
    return 0.5 * jnp.einsum("ij,ij->", sig, eps_elastic)


@jax.jit
def total_elastic_energy(u_flat: Array, c_flat: Array) -> Array:
    """Computes the total elastic energy of the cement speciment.
    Args:
        u_flat (Array): flattened displacement field
        c_flat (Array): flattened concentration field
    Returns:
        Array: total elastic energy
    """

    u = u_flat.reshape(-1, n_elasticity_dofs_per_node)
    u_grad_matrix = op_matrix.grad(u)
    u_grad_pore = op_pore.grad(u)

    c_matrix = op_matrix.eval(c_flat)
    c_pore = op_pore.eval(c_flat)

    elasticity_energy_density_matrix = strain_energy_density(
        u_grad_matrix,
        c_matrix,
        mu=mat_matrix.mu,
        lmbda=mat_matrix.lmbda,
        alpha=mat_matrix.alpha,
    )

    elasticity_energy_density_pore = strain_energy_density(
        u_grad_pore, c_pore, mu=mat_pore.mu, lmbda=mat_pore.lmbda, alpha=mat_pore.alpha
    )

    elastic_energy_matrix = op_matrix.integrate(elasticity_energy_density_matrix)
    elastic_energy_pore = op_pore.integrate(elasticity_energy_density_pore)

    return elastic_energy_matrix + elastic_energy_pore

Defining energy functional for corrosion problem

The corrosion energy density is given by:

\[ \psi_\text{corro} = \frac{1}{2} \phi_\text{clog} \cdot{} \kappa(c) \cdot{} (\nabla c)^2 \]

where \(\kappa(c)\) is the diffusivity parameter that depends on concentration \(c\) of the corrosion species. We define this using a simple relation given as:

\[ \kappa(c) = \kappa \cdot{} \phi_\text{clog}(c) \]

and \(\phi_\text{clog}\) is the clogging function defined as:

\[ \phi_\text{clog}(c) =\text{max}(\epsilon, 1 - \frac{\langle c - c_\text{sat} \rangle_{+}}{ \phi_\text{limit}}) \]

where \(\epsilon\) is a small number to avoid numerical issues, \(c_\text{sat}\) is the saturation concentration, and \(\phi_\text{limit}\) is the maximum concentration limit.

Note

Ideally, we should compute the \(\kappa(c)\) based on the current concentration field to accurately capture the effect of clogging on diffusivity. However, this can lead to convergence issues as the problem becomes highly nonlinear. Therefore, we use the concentration from the previous time step \(c_{\text{prev}}\) to compute \(\kappa(c_{\text{prev}})\), which helps in stabilizing the numerical solution. Therefore, we define \(\kappa(c_{\text{prev}})\) as:

\[ \kappa(c_{\text{prev}}) = \kappa \cdot{} \phi_\text{clog}(c_{\text{prev}}) \]

where

\[ \phi_\text{clog}(c_{\text{prev}}) =\text{max}(\epsilon, 1 - \frac{\langle c_{\text{prev}} - c_\text{sat} \rangle_{+}}{ \phi_\text{limit}}) \]

As we mentioned diffusion is time-dependent, therefore we define the energy functional for a time step \(\Delta t\) at time \(t\):

\[ \dfrac{\partial c}{\partial t} = \nabla \kappa (c_{\text{prev}}) \nabla c \]

We solve the above equation by discretizing in time using forward Euler method.

\[ \dfrac{c - c_{\text{prev}}}{\Delta t} = \nabla \kappa (c_{\text{prev}}) \nabla c \]

We can express the discretization as an energy functional given as:

\[ \Psi_{\text{chem}}(c) = \int_{\Omega} \left( \underbrace{\frac{1}{2 \Delta t}(c - c_{\text{prev}})^2}_{\text{Rate Term}} + \underbrace{\frac{1}{2} \kappa(c_{\text{prev}}) \|\nabla c\|^2}_{\text{Diffusion}} \right) d\Omega \]

where \(c_{\text{prev}}\) is the concentration field from the previous time step at time \(t - \Delta t\).

@jax.jit
def get_clogging_factor(c):
    """
    Calculates diffusivity scaling factor based on precipitate amount.

    Args:
        c: Total concentration
    """
    # Use jax.nn.relu to handle the "max(0, x)" logic smoothly
    # RELU is (c - c_sat) if positive, 0 if negative.
    # precipitate = macaulay_bracket(c - c_sat)
    precipitate = macaulay_bracket(c - c_sat)
    #precipitate = jax.nn.relu(c - c_sat)

    # Calculate the fraction of the pore that is FULL
    fraction_full = precipitate / pore_capacity

    # Diffusivity is the remaining empty fraction (1 - full)
    # We clip it so it never goes below 0.001 (to keep solver stable)
    # We don't need to clip the top at 1.0 because RELU handles the bottom end,
    # and fraction_full naturally starts at 0.
    phi = jnp.clip(1.0 - fraction_full, reduction_factor, 1.0)
    return phi


@autovmap(grad_c=1, c=0, c_prev=0, kappa=0)
def diffusion_energy_density(
    grad_c: Array, c: Array, c_prev: Array, kappa: float
) -> Array:
    """compute diffusion energy density

    Args:
        grad_theta (Array): gradient of concentration field
        c (Array): concentration field at current time step
        c_prev (Array): concentration field at previous time step
        kappa (float): diffusivity

    Returns:
        Array: diffusion energy density
    """

    kappa_effective = kappa * get_clogging_factor(c_prev)

    return (
        0.5 * kappa_effective * jnp.einsum("i, i->", grad_c, grad_c)
        + 0.5 * ((c - c_prev) ** 2) / dt
    )


@jax.jit
def total_diffusion_energy(c_flat: Array, c_prev_flat: Array) -> Array:
    """Computes the total thermal energy in the bimaterial strip.
    Args:
        c_flat (Array): flattened concentration field
        c_prev_flat (Array): flattened concentration field at previous time step
    Returns:
        Array: total diffusion energy
    """

    grad_c_pore = op_pore.grad(c_flat)
    grad_c_matrix = op_matrix.grad(c_flat)

    c_pore = op_pore.eval(c_flat)
    c_matrix = op_matrix.eval(c_flat)

    c_prev_pore = op_pore.eval(c_prev_flat)
    c_prev_matrix = op_matrix.eval(c_prev_flat)

    diffusion_energy_density_matrix = diffusion_energy_density(
        grad_c_matrix, c=c_matrix, c_prev=c_prev_matrix, kappa=mat_matrix.kappa
    )
    diffusion_energy_density_pore = diffusion_energy_density(
        grad_c_pore, c=c_pore, c_prev=c_prev_pore, kappa=mat_pore.kappa
    )

    diffusion_energy_matrix = op_matrix.integrate(diffusion_energy_density_matrix)
    diffusion_energy_pore = op_pore.integrate(diffusion_energy_density_pore)

    return diffusion_energy_matrix + diffusion_energy_pore

Define the total energy functional

\[ \Psi_\text{total}(u, c^{t}, c^{t-1}) = \Psi_\text{el}(u, c^{t}) + \Psi_\text{corro}(c^{t}, c^{t-1}) \]

@jax.jit
def total_energy(u_flat: Array, c_flat: Array, c_prev_flat: Array) -> Array:
    elastic_energy = total_elastic_energy(u_flat, c_flat)
    diffusion_energy = total_diffusion_energy(c_flat, c_prev_flat)
    return elastic_energy + diffusion_energy

def total_energy_elastic(u_flat: Array, c_flat: Array, c_prev_flat: Array) -> Array:
    return total_energy(u_flat=u_flat, c_flat=c_flat, c_prev_flat=c_prev_flat)

def total_energy_diffusion(c_flat: Array, u_flat: Array, c_prev_flat: Array) -> Array:
    return total_energy(u_flat=u_flat, c_flat=c_flat, c_prev_flat=c_prev_flat)

Defining boundary conditions

The Dirichlet boundary condition (for mechanical problem) is applied on the left, top and bottom sides of the strip.

\[ u_x(x=x_\text{min}) = 0, \quad u_y(y=y_\text{max}) = 0, \quad u_y(y=y_\text{min}) = 0 \]

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

left_nodes = jnp.where(jnp.isclose(mesh.coords[:, 0], x_min))[0]
right_nodes = jnp.where(jnp.isclose(mesh.coords[:, 0], x_max))[0]
top_nodes = jnp.where(jnp.isclose(mesh.coords[:, 1], y_max))[0]
bottom_nodes = jnp.where(jnp.isclose(mesh.coords[:, 1], y_min))[0]
fixed_dofs_u = jnp.concatenate(
    [
        2 * top_nodes + 1,
        2 * left_nodes,
        2 * left_nodes + 1,
        2 * bottom_nodes + 1,
    ]
)
fixed_dofs_conc = jnp.array([] , dtype=jnp.int32)

prescribed_values_u = jnp.zeros(n_elasticity_dofs)

For the concentration problem, we apply a flux boundary condition on the left side (only at the pore inlet) of the strip to simulate the ingress of corrosive species into the pore.

\[ \psi_{\text{c, BC}} = - \int_{\Gamma_q} \bar{q} c \, \text{d}A \quad \text{where} \quad \Gamma_q \text{ is the left edge of the sample, i.e., } x = x_\text{min}~ \text{and}~ y \in \text{pore region} \]

The corrosive flux \(\bar{q}\) is assumed to be constant over the entire left edge of the sample and is given as

\[ \bar{q} = 1 \]

To compute the corresponding external flux vector, we need to take the variation of this energy term with respect to the concentration field \(c\).

\[ \boldsymbol{f}_\text{ext, c} = - \dfrac{\partial \psi_{\text{c, BC}}}{\partial c} \]

Below we define a few helper functions to compute the external heat flux vector.

Code: Function to extract 1D elements for the top edge of the mesh
def get_elements_on_curve(mesh, curve_func, tol=1e-3):
    coords = mesh.coords
    elements_2d = mesh.elements
    # 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 jnp.array([], dtype=int)

    return jnp.array(elements_1d)


def flux_line(coord: jnp.ndarray, tol: float) -> bool:
    return jnp.logical_and(
        jnp.isclose(
            coord[0],
            x_min,
            atol=tol,
        ),
        jnp.abs(coord[1]) <= pore_thickness / 2,
    )
flux_elements = get_elements_on_curve(mesh, flux_line, tol=1e-8)

line_mesh = Mesh(mesh.coords, flux_elements)
line2 = element.Line2()
line_op = Operator(line_mesh, line2)


@jax.jit
def flux_energy(x_flat: Array, q_bar: float) -> Array:
    u_quad = line_op.eval(x_flat)
    return line_op.integrate(q_bar * u_quad)

q_bar = 1.0 # External heat flux magnitude

fext_corro = jax.jacrev(flux_energy)(jnp.ones(n_nodes).flatten(), q_bar)

Using Matrix-Free solver

We solve the thermo-mechanical problem using a matrix-free Newton-Raphson 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.

Remeber we need to define the internal force vector for both diffusion and mechanical problems separately i.e

\[ \boldsymbol{f}_\text{int, diff} = \dfrac{\partial \Psi_\text{total}(c, \boldsymbol{u}, c_\text{prev})}{\partial c} \]

\[ \boldsymbol{f}_\text{int, elas} = \dfrac{\partial \Psi_\text{total}(\boldsymbol{u}, c, c_\text{prev})}{\partial \boldsymbol{u}} \]

Similarly, we need to define the JVP functions for both diffusion and mechanical problems separately.

Code: Functions to implement the matrix-free solvers
gradient_elastic = jax.jacrev(total_energy_elastic, argnums=0)
gradient_diffusion = jax.jacrev(total_energy_diffusion, argnums=0)

@eqx.filter_jit
def compute_tangent(dx, x_prev, gradient, fixed_dofs):
    dx_projected = dx.at[fixed_dofs].set(0)
    tangent = jax.jvp(gradient, (x_prev,), (dx_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.


@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,
    compute_tangent,
    fixed_dofs,
):
    fint = gradient(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, x_prev=u, gradient=gradient, fixed_dofs=fixed_dofs
        )
        du, cg_iiter = conjugate_gradient(A=A, b=residual, atol=1e-8, max_iter=1000)
        
        u = u.at[:].add(du)
        fint = gradient(u)
        residual = fext - fint
        residual = residual.at[fixed_dofs].set(0)
        norm_res = jnp.linalg.norm(residual)
        iiter += 1

    return u, norm_res

Solving the coupled problem

We will use the staggered approach (also known as alternate minimization) to solve the coupled problem. In this approach, we will solve the mechanical and corrosion problems sequentially until convergence is achieved.

Since corrosion is a time-dependent problem, we will solve the coupled problem for each time step. In total we will solve the coupled problem for 500 time steps. The time step size is taken as dt = 0.05.

The time scale for the mechanical problem is much smaller than the time scale for the corrosion problem. Thus, we will assume that the mechanical problem reaches equilibrium instantaneously for each time step of the corrosion problem.

Here are the algorithmic steps corresponding to the code provided. This represents a single-pass operator-split scheme (solving physics sequentially within a time step without iterating to equilibrium between them).

Algorithm: Transient Chemo-Mechanical Operator Split

Let \(\boldsymbol{u}_n\) and \(c_n\) be the displacement and concentration fields at time step \(t_n\). Let \(\Delta t\) be the time increment.

1. Time Stepping Loop For each time step \(n = 0, 1, \dots, N\):

2. Diffusion/Corrosion (Transient) Step Solve for the new concentration field \(c_{n+1}\) while holding the mechanical state frozen at the previous configuration \(\boldsymbol{u}_n\).

Find \(c_{n+1}\) such that the first variation of the incremental chemical potential is zero:

\[ \frac{\partial \Psi_{\text{total}}}{\partial c} (c_{n+1}; c_n, \boldsymbol{u}_n) = 0 \]

  • \(c_n\) is required for the backward-Euler rate term: \(\frac{1}{2\Delta t}(c - c_n)^2\).
  • \(\boldsymbol{u}_n\) provides the stress \(\sigma_h(\boldsymbol{u}_n)\) for the coupling term.

3. Mechanical (Static) Step Solve for the new displacement field \(\boldsymbol{u}_{n+1}\) using the newly computed concentration field \(c_{n+1}\).

Find \(\boldsymbol{u}_{n+1}\) such that the first variation of the mechanical potential energy is zero:

\[ \frac{\partial \Psi_{\text{total}}}{\partial \boldsymbol{u}} (\boldsymbol{u}_{n+1}; c_{n+1}: c_n) = 0 \]

  • \(c_{n+1}\) provides the chemical eigenstrain \(\boldsymbol{\varepsilon}^*(c_{n+1})\) for the constitutive law.

4. Update Store \(\boldsymbol{u}_{n+1}\) and \(c_{n+1}\) and proceed to the next time step.

Hint

The main time-stepping and staggered solution loop is given below. You need to fill in the missing parts to complete the code.


n_steps = 2

# main load/time stepping loop
for step in range(n_steps):
    print(f"Step {step + 1}/{n_steps}")

    # define the external force vectors for diffusion and elastic problem for this step
    ...

    # apply the dirichlet boundary conditions for diffusion and elastic problem
    # also use the previous step concentration field 
    ...


    # solve for concentration field using the displacement field from previous iteration
    ...
        
    # solve for elastic field using the concentration from above
    ...

Store the final displacement and concentration fields for ions and the rust for post-processing.

n_steps = 500

u = jnp.zeros(n_elasticity_dofs)
c = jnp.zeros(n_conc_dofs)

fext_u = jnp.zeros(n_elasticity_dofs)

fext_c = jnp.zeros(n_conc_dofs)


dfxt_c = fext_corro / n_steps

u_per_step = []
c_per_step = []


for step in range(n_steps):
    print(f"Step {step + 1}/{n_steps}")
    u = u.at[fixed_dofs_u].set(0.0)
    u_prev = u
    c_prev = c

    fext_c = fext_c.at[:].add(dfxt_c)

    # solve for concentration field
    print(" Solving for concentration field")
    partial_gradient_diffusion = eqx.Partial(
        gradient_diffusion,
        u_flat=u_prev.flatten(),
        c_prev_flat=c_prev.flatten(),
    )

    c, rnorm = newton_krylov_solver(
        c_prev,
        fext=fext_c,
        gradient=partial_gradient_diffusion,
        compute_tangent=compute_tangent,
        fixed_dofs=fixed_dofs_conc,
    )
    print(f"    Residual norm in concentration solve: {rnorm:.8e}")

    # solve for elastic field
    print(" Solving for elastic field")

    partial_gradient_elastic = eqx.Partial(
        gradient_elastic,
        c_flat=c.flatten(),
        c_prev_flat=c_prev.flatten(),
    )

    u, rnorm = newton_krylov_solver(
        u=u_prev,
        fext=fext_u,
        gradient=partial_gradient_elastic,
        fixed_dofs=fixed_dofs_u,
        compute_tangent=compute_tangent,
    )
    print(f"    Residual norm in elastic solve: {rnorm:.8e}")

    u_per_step.append(u.reshape(n_nodes, n_elasticity_dofs_per_node))
    c_per_step.append(
        c.reshape(
            n_nodes,
        )
    )


u_solution = u.reshape(n_nodes, n_elasticity_dofs_per_node)
T_solution = c.reshape(
    n_nodes,
)

Plot the concentration and stress fields

We can now plot the concentration field of the corrosive species and the von-mises stress field in the concrete specimen after the final time step.

Code: Plotting the deformed shape and displacement
@autovmap(stress=2)
def von_mises_stress(stress):
    s_xx, s_yy = stress[0, 0], stress[1, 1]
    s_xy = stress[0, 1]
    return jnp.sqrt(s_xx**2 - s_xx * s_yy + s_yy**2 + 3 * s_xy**2)

plt.style.use(STYLE_PATH)
fig, axs = plt.subplots(
    2, 2, figsize=(6, 4), layout="constrained", gridspec_kw={"hspace": 0.01}
)

scale_deformation = 1.0

plot_nodal_values(
    mesh=mesh,
    nodal_values=u_solution[:, 1],
    #u=u_solution,
    ax=axs[0, 0],
    cmap="managua",
    scale=scale_deformation,
    label=r"$u_x$",
)
axs[0, 0].set_aspect("equal")
axs[0, 0].margins(0.0, 0.0)

op = Operator(mesh, tri)


grad_u = op.grad(u_solution).squeeze()
strains = compute_strain(grad_u)
theta_quad = op.eval(T_solution).squeeze()
stresses_matrix = compute_stress(strains, mat_matrix.mu, mat_matrix.lmbda)
stresses_pore = compute_stress(strains, mat_pore.mu, mat_pore.lmbda)
stresses = jnp.zeros_like(stresses_matrix)
stresses = stresses.at[matrix_element_indices].set(stresses_matrix[matrix_element_indices])
stresses = stresses.at[pore_element_indices].set(stresses_pore[pore_element_indices])
stress_vm = von_mises_stress(stresses)

plot_element_values(
    mesh=mesh,
    values=stress_vm.flatten(),
    u=u_solution,
    ax=axs[1, 0],
    cmap="berlin",
    scale=scale_deformation,
    label=r"$\sigma_{vm} [N/m^2]$",
)
axs[1, 0].set_aspect("equal")
axs[1, 0].margins(0.0, 0.0)

plot_nodal_values(
    mesh=mesh,
    nodal_values=T_solution,
    u=u_solution,
    ax=axs[0, 1],
    cmap="Spectral",
    scale=scale_deformation,
    label=r"$c$",
)
axs[0, 1].set_aspect("equal")
axs[0, 1].margins(0.0, 0.0)

plot_nodal_values(
    mesh=mesh,
    nodal_values=macaulay_bracket(T_solution.flatten() - c_sat),
    u=u_solution,
    ax=axs[1, 1],
    cmap="magma_r",
    scale=scale_deformation,
    label=r"$c_{precipitate}$",
)
axs[1, 1].set_aspect("equal")
axs[1, 1].margins(0.0, 0.0)
plt.savefig("../figures/corrosion_full_coupling.svg")
plt.show()
Figure 21.2: Deformed shape of the strip with displacement, von-mises stress, concentration of ferrous ions and precipitation concentration for the full coupling case.

Time evolution of concentration fields

We now plot the time evolution of the concentration field of the ferrous ions and the rust in the concrete specimen over the entire simulation time. We plot the results along the centerline of the specimen (y = 0.).

Code: Function to find the index of the containing polygon for each point.
import numpy as np


@jax.jit
def find_containing_polygons(
    points: jnp.ndarray,
    polygons: jnp.ndarray,
) -> jnp.ndarray:
    """
    Finds the index of the containing polygon for each point.

    This function uses a vectorized Ray Casting algorithm and is JIT-compiled
    for maximum performance. It assumes polygons are non-overlapping.

    Args:
        points (jnp.ndarray): An array of points to test, shape (num_points, 2).
        polygons (jnp.ndarray): A 3D array of polygons, where each polygon is a
                                list of vertices. Shape (num_polygons, num_vertices, 2).

    Returns:
        jnp.ndarray: An array of shape (num_points,) where each element is the
                     index of the polygon containing the corresponding point.
                     Returns -1 if a point is not in any polygon.
    """

    # --- Core function for a single point and a single polygon ---
    def is_inside(point, vertices):
        px, py = point

        # Get all edges of the polygon by pairing vertices with the next one
        p1s = vertices
        p2s = jnp.roll(vertices, -1, axis=0)  # Get p_{i+1} for each p_i

        # Conditions for a valid intersection of the horizontal ray from the point
        # 1. The point's y-coord must be between the edge's y-endpoints
        y_cond = (p1s[:, 1] <= py) & (p2s[:, 1] > py) | (p2s[:, 1] <= py) & (
            p1s[:, 1] > py
        )

        # 2. The point's x-coord must be to the left of the edge's x-intersection
        # Calculate the x-intersection of the ray with the edge
        x_intersect = (p2s[:, 0] - p1s[:, 0]) * (py - p1s[:, 1]) / (
            p2s[:, 1] - p1s[:, 1]
        ) + p1s[:, 0]
        x_cond = px < x_intersect

        # An intersection occurs if both conditions are met.
        intersections = jnp.sum(y_cond & x_cond)

        # The point is inside if the number of intersections is odd.
        return intersections % 2 == 1

    # --- Vectorize and apply the function ---
    # Create a boolean matrix: matrix[i, j] is True if point i is in polygon j
    # Vmap over points (axis 0) and polygons (axis 0)
    # in_axes=(0, None) -> maps over points, polygon is fixed
    # in_axes=(None, 0) -> maps over polygons, point is fixed
    # We vmap the second case over all points
    is_inside_matrix = jax.vmap(
        lambda p: jax.vmap(lambda poly: is_inside(p, poly))(polygons)
    )(points)

    # Find the index of the first 'True' value for each point (row).
    # This gives the index of the containing polygon.
    # We add a 'False' column to handle points outside all polygons.
    # jnp.argmax will then return the index of this last column.
    padded_matrix = jnp.pad(
        is_inside_matrix, ((0, 0), (0, 1)), "constant", constant_values=False
    )
    indices = jnp.argmax(padded_matrix, axis=1)

    # If the index is the last one, it means the point was not in any polygon.
    # We map this index to -1 for clarity.
    return jnp.where(indices == is_inside_matrix.shape[1], -1, indices)
op = Operator(mesh, tri)

x = np.linspace(x_min, 0.99*x_max, 100)
y = np.full_like(x, fill_value=0)

points = np.stack([x, y], axis=1)
mid_containing_indices = find_containing_polygons(points, mesh.coords[mesh.elements])
Code:Plotting the time evolution of concentration along the centerline
steps_to_plot = np.arange(0, len(c_per_step))

mycolor = plt.get_cmap("Spectral")(np.linspace(0, 1, len(steps_to_plot)))


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

for n, color in zip(steps_to_plot, mycolor):
    if n % 50 != 0:
        continue
    T_step = c_per_step[n]
    T_quad_step = op.eval(T_step).squeeze()
    precipitate_step = macaulay_bracket(T_quad_step - c_sat)
    ax.plot(
        x,
        T_quad_step[mid_containing_indices],
        "-",
        color=color,
        lw=1.0,
    )

    ax.plot(
        x,
        precipitate_step[mid_containing_indices],
        "--",
        color=color,
        lw=1.0,
    )
ax.axvline(x=0, color="k", lw=0.4, ls="--")
ax.set_xlabel(r"$x$")
ax.set_ylabel(r"$c$")
ax.grid(True)
ax.margins(0.0, 0.0)
plt.savefig("../figures/corrosion_concentration_profiles_full_coupling.svg")
plt.show()
Figure 21.3
No Coupling Full Coupling

Solid lines represent the concentration of ferrous ions, while dashed lines represent the concentration of rust. Red lines represent the initial time step, while blue lines represent the final time step.

Question

Run the simulation for the four different cases mentioned above and compare the time evolution of the concentration fields (both ferrous ions and rust).

How do the different coupling mechanisms affect the corrosion process? Explain your observations.

To show the effect of each coupling mechanism, plot the concentration profiles for all four cases side by side for comparison.