Viscoelasticity with state variables

In this example, we will use a generalization of the so-called Zener viscoelastic material model, which can be illustrated by the following rheological model

Zener

We assume a volumetric-deviatoric split of the strain, and consider isotropic behavior, such that the model can be described as

\[\begin{aligned} \boldsymbol{\sigma} &= \boldsymbol{\sigma}^\mathrm{vol} + \boldsymbol{\sigma}^\mathrm{dev} \\ \boldsymbol{\sigma}^\mathrm{vol} &= 3K\boldsymbol{\epsilon}^\mathrm{vol}\\ \boldsymbol{\sigma}^\mathrm{dev} &= 2G_1 \boldsymbol{\epsilon}^\mathrm{dev} + 2G_2 \boldsymbol{\epsilon}^\mathrm{dev}_\mathrm{e} \\ 2G_2 \boldsymbol{\epsilon}^\mathrm{dev}_\mathrm{e} &= \eta \dot{\boldsymbol{\epsilon}}_\mathrm{v}^\mathrm{dev} \\ \boldsymbol{\epsilon}^\mathrm{dev} &= \boldsymbol{\epsilon}^\mathrm{dev}_\mathrm{e} + \boldsymbol{\epsilon}^\mathrm{dev}_\mathrm{v} \end{aligned}\]

where we have the bulk modulus, $K$, shear modulii $G_1$ and $G_2$ (such that $\mathsf{E}_i=2G_i\mathsf{I}^\mathrm{dev}$), and viscosity $\eta$ (such that $\mathsf{V}=\eta\mathsf{I}$) Solving this equation system using the old viscous strain, $\boldsymbol{\epsilon}^\mathrm{dev}$, as a state variable, we obtain

\[\begin{aligned} \boldsymbol{\epsilon}^\mathrm{dev}_\mathrm{v} &= \frac{2\Delta t*G_2*\boldsymbol{\epsilon}^\mathrm{dev} + \eta {}^\mathrm{n}\boldsymbol{\epsilon}^\mathrm{dev}_\mathrm{v}}{\eta + 2\Delta t G_2} \\ \boldsymbol{\sigma} &= 3 K \boldsymbol{\epsilon}^\mathrm{vol} + 2G_1 \boldsymbol{\epsilon}^\mathrm{dev} + 2G_2 [\boldsymbol{\epsilon}^\mathrm{dev}-\boldsymbol{\epsilon}^\mathrm{dev}_\mathrm{v}] \end{aligned}\]

The full script without intermediate comments is available at the bottom of this page.

We start by loading required packages

using Ferrite, Tensors
using FerriteAssembly
import CairoMakie as CM

Material modeling

The easiest way to implement this behavior, would be to use the existing interface from MaterialModelsBase.jl. But for the purpose of this tutorial, we will do it from scratch to show how to handle state variables in the finite element code. To start the material modeling, we define a material struct with all parameters.

Base.@kwdef struct ZenerMaterial{T}
    K::T =5.0/3 # Bulk modulus
    G1::T=1.0   # Shear modulus, parallel
    G2::T=50.   # Shear modulus, series
    η::T =5.0   # Damping modulus
end;

We then define how to the initial state variables should look like, which also defines the structure of the state variables. In this case, we will just have states being a single tensor (viscous strain) for each integration point

function FerriteAssembly.create_cell_state(::ZenerMaterial, cv::AbstractCellValues, args...)
    ϵ_template = shape_symmetric_gradient(cv, 1, 1) # ::SymmetricTensor
    return [zero(ϵ_template) for _ in 1:getnquadpoints(cv)]
end;

Following this, we define the element_residual! function (we will use automatic differentiation to calculate the element stiffness).

function FerriteAssembly.element_residual!(re, state, ae, m::ZenerMaterial, cv::AbstractCellValues, buffer)
    Δt = FerriteAssembly.get_time_increment(buffer)
    old_ϵvs = FerriteAssembly.get_old_state(buffer)
    for q_point in 1:getnquadpoints(cv)
        old_ϵv = old_ϵvs[q_point]
        dΩ = getdetJdV(cv, q_point)
        ϵ = function_symmetric_gradient(cv, q_point, ae)
        ϵdev = dev(ϵ)
        ϵv = (Δt * 2 * m.G2 * ϵdev + m.η * old_ϵv)/(m.η + Δt * 2 * m.G2)
        σ = (m.G1 + m.G2) * 2 * ϵdev - 2 * m.G2 * ϵv + 3 * m.K * vol(ϵ)
        for i in 1:getnbasefunctions(cv)
            δ∇N = shape_symmetric_gradient(cv, q_point, i)
            re[i] += (δ∇N ⊡ σ) * dΩ
        end
        # We only want to save the value-part of the states, and FerriteAssembly comes with
        # the utility `FerriteAssembly.remove_dual` to do so for scalars and Tensors.
        # Note that using `state[q_point]` instead of ϵv for any calculations
        # affecting re, will result in wrong derivatives.
        state[q_point] = FerriteAssembly.remove_dual(ϵv)
    end
end;

Finite element setup

To setup our problem, we use a simple grid and define all interpolations, quadrature rules, etc. as normally for Ferrite simulations. We also define the Zener material and create the domain buffer.

grid = generate_grid(Quadrilateral, (20, 20))
ip = geometric_interpolation(Quadrilateral)
dh = DofHandler(grid)
add!(dh, :u, ip^2)
close!(dh)
qr = QuadratureRule{RefQuadrilateral}(2)
cv = CellValues(qr, ip^2, ip)
m = ZenerMaterial()
domain = DomainSpec(dh, m, cv)
buffer = setup_domainbuffer(domain; autodiffbuffer=true);

Fix left side of beam, vertical load on right side.

ch = ConstraintHandler(dh)
add!(ch, Dirichlet(:u, getfacetset(grid, "left"), Returns(zero(Vec{2}))))
close!(ch)
update!(ch, 0.0);

We use FerriteAssembly's LoadHandler to apply the Neumann boundary conditions, which consist of a ramp followed by a hold.

lh = LoadHandler(dh)
traction(t) = clamp(t, 0, 1)*Vec((0.0, 1.0))
add!(lh, Neumann(:u, 2, getfacetset(grid, "right"), (x, t, n) -> traction(t)));

Finite element solution

Given this setup, we define a function that steps through the time history, and for each time step, iterates to find the correct solution. After convergence, we update the state variables.

function solve_nonlinear_timehistory(buffer, dh, ch, lh; time_history)
    maxiter = 10
    tolerance = 1e-6
    K = allocate_matrix(dh)
    r = zeros(ndofs(dh))
    f = zeros(ndofs(dh))
    a = zeros(ndofs(dh))
    t_force = [0.0]
    u1_max = [0.0]
    told = 0.0
    for t in time_history
        # Update and apply the Dirichlet boundary conditions
        update!(ch, t)
        apply!(a, ch)
        # Update and apply the Neumann boundary conditions
        fill!(f, 0)
        apply!(f, lh, t)
        # Update the time increment (passed to `element_residual!`)
        set_time_increment!(buffer, t-told)
        for i in 1:maxiter
            # Assemble the system
            assembler = start_assemble(K, r)
            work!(assembler, buffer; a=a)
            r .-= f
            # Apply boundary conditions
            apply_zero!(K, r, ch)
            # Check convergence
            norm(r) < tolerance && break
            i == maxiter && error("Did not converge")
            # Solve the linear system and update the dof vector
            a .-= K\r
            apply!(a, ch) # Make sure Dirichlet BC are exactly fulfilled
        end
        # If converged, update the old state variables to the current.
        update_states!(buffer)

        # Save values for postprocessing
        push!(t_force, norm(traction(t)))
        push!(u1_max, maximum(a))
        told = t
    end
    return u1_max, t_force
end;

Define a time history with uneven time steps (shorter in the beginning)

time_history = collect(range(0,1,10)).^2
append!(time_history, 1 .+ collect(range(0,1,10)[2:end]).^2)

u1_max, t_force = solve_nonlinear_timehistory(buffer, dh, ch, lh; time_history=time_history[2:end]);

Plot the results

fig = CM.Figure()
ax_t = CM.Axis(fig[1,1]; xlabel="time", ylabel="traction")
ax_d = CM.Axis(fig[2,1]; xlabel="time", ylabel="displacement")
CM.lines!(ax_t, time_history, t_force)
CM.scatter!(ax_t, time_history, t_force)
CM.lines!(ax_d, time_history, u1_max)
CM.scatter!(ax_d, time_history, u1_max)
fig
Example block output

Plain program

Here follows a version of the program without any comments. The file is also available here: viscoelasticity.jl.

using Ferrite, Tensors
using FerriteAssembly
import CairoMakie as CM

Base.@kwdef struct ZenerMaterial{T}
    K::T =5.0/3 # Bulk modulus
    G1::T=1.0   # Shear modulus, parallel
    G2::T=50.   # Shear modulus, series
    η::T =5.0   # Damping modulus
end;

function FerriteAssembly.create_cell_state(::ZenerMaterial, cv::AbstractCellValues, args...)
    ϵ_template = shape_symmetric_gradient(cv, 1, 1) # ::SymmetricTensor
    return [zero(ϵ_template) for _ in 1:getnquadpoints(cv)]
end;

function FerriteAssembly.element_residual!(re, state, ae, m::ZenerMaterial, cv::AbstractCellValues, buffer)
    Δt = FerriteAssembly.get_time_increment(buffer)
    old_ϵvs = FerriteAssembly.get_old_state(buffer)
    for q_point in 1:getnquadpoints(cv)
        old_ϵv = old_ϵvs[q_point]
        dΩ = getdetJdV(cv, q_point)
        ϵ = function_symmetric_gradient(cv, q_point, ae)
        ϵdev = dev(ϵ)
        ϵv = (Δt * 2 * m.G2 * ϵdev + m.η * old_ϵv)/(m.η + Δt * 2 * m.G2)
        σ = (m.G1 + m.G2) * 2 * ϵdev - 2 * m.G2 * ϵv + 3 * m.K * vol(ϵ)
        for i in 1:getnbasefunctions(cv)
            δ∇N = shape_symmetric_gradient(cv, q_point, i)
            re[i] += (δ∇N ⊡ σ) * dΩ
        end
        # We only want to save the value-part of the states, and FerriteAssembly comes with
        # the utility `FerriteAssembly.remove_dual` to do so for scalars and Tensors.
        # Note that using `state[q_point]` instead of ϵv for any calculations
        # affecting re, will result in wrong derivatives.
        state[q_point] = FerriteAssembly.remove_dual(ϵv)
    end
end;

grid = generate_grid(Quadrilateral, (20, 20))
ip = geometric_interpolation(Quadrilateral)
dh = DofHandler(grid)
add!(dh, :u, ip^2)
close!(dh)
qr = QuadratureRule{RefQuadrilateral}(2)
cv = CellValues(qr, ip^2, ip)
m = ZenerMaterial()
domain = DomainSpec(dh, m, cv)
buffer = setup_domainbuffer(domain; autodiffbuffer=true);

ch = ConstraintHandler(dh)
add!(ch, Dirichlet(:u, getfacetset(grid, "left"), Returns(zero(Vec{2}))))
close!(ch)
update!(ch, 0.0);

lh = LoadHandler(dh)
traction(t) = clamp(t, 0, 1)*Vec((0.0, 1.0))
add!(lh, Neumann(:u, 2, getfacetset(grid, "right"), (x, t, n) -> traction(t)));

function solve_nonlinear_timehistory(buffer, dh, ch, lh; time_history)
    maxiter = 10
    tolerance = 1e-6
    K = allocate_matrix(dh)
    r = zeros(ndofs(dh))
    f = zeros(ndofs(dh))
    a = zeros(ndofs(dh))
    t_force = [0.0]
    u1_max = [0.0]
    told = 0.0
    for t in time_history
        # Update and apply the Dirichlet boundary conditions
        update!(ch, t)
        apply!(a, ch)
        # Update and apply the Neumann boundary conditions
        fill!(f, 0)
        apply!(f, lh, t)
        # Update the time increment (passed to `element_residual!`)
        set_time_increment!(buffer, t-told)
        for i in 1:maxiter
            # Assemble the system
            assembler = start_assemble(K, r)
            work!(assembler, buffer; a=a)
            r .-= f
            # Apply boundary conditions
            apply_zero!(K, r, ch)
            # Check convergence
            norm(r) < tolerance && break
            i == maxiter && error("Did not converge")
            # Solve the linear system and update the dof vector
            a .-= K\r
            apply!(a, ch) # Make sure Dirichlet BC are exactly fulfilled
        end
        # If converged, update the old state variables to the current.
        update_states!(buffer)

        # Save values for postprocessing
        push!(t_force, norm(traction(t)))
        push!(u1_max, maximum(a))
        told = t
    end
    return u1_max, t_force
end;

time_history = collect(range(0,1,10)).^2
append!(time_history, 1 .+ collect(range(0,1,10)[2:end]).^2)

u1_max, t_force = solve_nonlinear_timehistory(buffer, dh, ch, lh; time_history=time_history[2:end]);

fig = CM.Figure()
ax_t = CM.Axis(fig[1,1]; xlabel="time", ylabel="traction")
ax_d = CM.Axis(fig[2,1]; xlabel="time", ylabel="displacement")
CM.lines!(ax_t, time_history, t_force)
CM.scatter!(ax_t, time_history, t_force)
CM.lines!(ax_d, time_history, u1_max)
CM.scatter!(ax_d, time_history, u1_max)
fig

# This file was generated using Literate.jl, https://github.com/fredrikekre/Literate.jl

This page was generated using Literate.jl.