DMA cantilever beam

A 3D cantilever beam under sinusoidal, displacement-controlled loading, solved with the VEVP_MOAMMM viscoelastic-viscoplastic model. The runnable script is examples/cantilever_beam_dma.jl and can be executed from the repository root with:

julia --project=. examples/cantilever_beam_dma.jl

The direct run writes plot and VTK output. Install Plots.jl and WriteVTK.jl in the active environment before running it with output enabled.

For a short run without plot or VTK output, start Julia from the repository root with julia --project=. and run:

include("examples/cantilever_beam_dma.jl")
result = run_cantilever_beam_dma(; nx=3, ny=1, nz=1, nsteps=4, vtk=nothing)
println("converged steps: ", result.converged_steps)

Problem setup

The beam extends along the x-axis from the clamped root at x = 0 to the controlled tip at Lx. The root face is fixed in all three displacement directions. The tip face is fixed in x and z, while the y-displacement follows u_y = A sin(4*pi*t/total_time).

This prescribed displacement creates a cyclic loading-unloading path. The force-displacement loop in Figure 1 shows the phase lag and indicates energy dissipation of the viscoelastic response.

Computed DMA cantilever response Figure 1. Hysteresis loop showing viscoelastic response of the cantilever.

Grid, interpolation, constraints

We set up a 3D hexahedral beam, a quadratic displacement field, and the two Dirichlet boundaries with standard Ferrite.jl calls:

nx, ny, nz = 8, 2, 2                              # default mesh resolution
Lx, Ly, Lz = 5.0, 1.0, 1.0                        # beam dimensions
grid = generate_grid(Hexahedron, (nx, ny, nz), Vec(0.0, 0.0, 0.0), Vec(Lx, Ly, Lz)) # create the beam mesh

interpolation = Serendipity{RefHexahedron, 2}()^3 # quadratic displacement shape functions in 3D
dh = DofHandler(grid)                             # create the dof handler for the grid
add!(dh, :u, interpolation)                       # add displacement dofs under the field name `:u`
close!(dh)                                        # finalize the dof numbering

amplitude = 0.05                                  # prescribed tip-displacement amplitude
total_time = 8.0                                  # duration of two loading cycles
ch = ConstraintHandler(dh)                        # collect Dirichlet boundary conditions
add!(ch, Dirichlet(:u, getfacetset(grid, "left"),  (x, t) -> [0.0, 0.0, 0.0], [1, 2, 3])) # clamp the root
add!(ch, Dirichlet(:u, getfacetset(grid, "right"), (x, t) -> [0.0, amplitude * sin(4.0 * pi * t / total_time), 0.0], [1, 2, 3])) # drive the tip in y
close!(ch)                                        # finalize the constraint data

For the tip reaction, only the y-component displacement DOFs on the right face are collected:

function _facet_component_dofs(dh, facets, interpolation, component)
    ncomp = Ferrite.n_components(interpolation)     # number of vector components in :u
    base_interpolation = Ferrite.get_base_interpolation(interpolation) # scalar shape functions behind the vector field
    facet_dofs = Ferrite.dirichlet_facetdof_indices(base_interpolation) # local scalar dofs on each facet
    facet_cache = FacetCache(dh)                    # reusable cache for cell/facet dof lookup
    dofs = Int[]                                    # collected global dof numbers

    for facet in facets
        reinit!(facet_cache, facet)                 # load the current boundary facet
        _, facet_id = facet                         # local face number in the current cell
        for local_shape_dof in facet_dofs[facet_id] # scalar shape dofs on this face
            local_component_dof = (local_shape_dof - 1) * ncomp + component # select component 2, i.e. y
            push!(dofs, celldofs(facet_cache)[local_component_dof]) # map local dof to global dof
        end
    end

    sort!(dofs)                                     # neighboring facets share edge/corner dofs
    unique!(dofs)                                   # keep each reaction dof once
    return dofs
end

react_dofs = _facet_component_dofs(dh, getfacetset(grid, "right"), interpolation, 2) # right-face y dofs

react_dofs is only used to extract the reaction force from the residual vector before constrained entries are zeroed. It does not affect assembly or the actual Dirichlet constraints.

Material

We apply VEVP_MOAMMM with eight bulk and eight shear Maxwell branches:

mat = VEVP_MOAMMM(
    6, 80.0, 40.0, 0.25, 0.3, 2.0, 1.0,
    40.0, 10.0, 5.0, 1.0, 45.0, 10.0, 5.0, 1.0,
    1.0, 0.1, 0.01,
    [60.0, 70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0],
    [0.2625, 0.3375, 0.4125, 0.4875, 0.60, 0.75, 0.9375, 1.20],
    [60.0, 70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0],
    [0.2625, 0.3375, 0.4125, 0.4875, 0.60, 0.75, 0.9375, 1.20],
)

The relaxation times are chosen near the loading period to cause a significant phase lag. With the default amplitude and parameters, the response stays in the viscoelastic range, so the visible hysteresis is driven by the Maxwell branches rather than by plastic flow. See VEVP MOAMMM for the parameter glossary.

Assembler

create_assembler combines the material, dof handler, and constraints into the reusable FerriteSolidMechanics object:

assembler = create_assembler(mat, dh, ch; quadrature_order=2)

The assembler stores one material state per quadrature point of each nonlinear cell and reuses its global stiffness matrix, residual vector, and per-cell element matrices/vectors across time steps.

Time and Newton loop

The prescribed tip displacement is advanced in equal physical time steps. stiffness_matrix evaluates the material response for the current trial displacement, and update_states!(assembler) commits the converged material state:

u = zeros(ndofs(dh))                    # allocate the displacement vector
nsteps = 96                             # number of equal physical time steps
max_newton = 180                        # maximum Newton iterations per time step
newton_tol = 1e-6                       # residual tolerance
dt = total_time / nsteps                # physical time increment

for step in 1:nsteps                    # advance the imposed displacement
    t = step * dt                       # current physical time
    update!(ch, t)                      # update the sinusoidal boundary value
    apply!(u, ch)                       # write prescribed displacements into `u`

    for iter in 1:max_newton            # Newton iterations for this time step
        K, r = stiffness_matrix(assembler, u; dt=dt) # constitutive evaluation and tangent/residual assembly
        fy_tip = isempty(react_dofs) ? 0.0 : sum(r[react_dofs]) # exract reaction before constrained dofs are zeroed
        apply_zero!(K, r, ch)           # apply constraints for constrained solving
        if norm(r) < newton_tol         # stop once the residual is small enough
            break
        end
        u .-= K \ r                     # update the displacement vector
    end
    update_states!(assembler)           # commit material states after convergence
end

Here, dt is the physical time increment of the sinusoidal loading. Because VEVP_MOAMMM is strain rate-dependent, this value enters the local viscoelastic-viscoplastic update and affects the result.

The tip reaction must be read before apply_zero!. After apply_zero!, the constrained residual entries are overwritten to consider the Dirichlet boundary conditions and no longer contain the reaction force.

Postprocessing

We record three histories at each converged step: time t, tip displacement u_y, and tip reaction F_y. After the last step, compute_stresses evaluates the Cauchy stress field:

stresses = compute_stresses(assembler, u)

phase_lag_degrees(result) estimates the first-harmonic phase lag between tip displacement and reaction force. plot_results(result) writes a three-panel figure with displacement history, reaction history, and the hysteresis loop.

With the default settings, the direct run prints a short summary similar to:

Solved 3D cantilever beam DMA
  cells: 32
  dofs: 783
  converged steps: 96
  peak tip uy: 0.05
  force/displacement phase lag: 38.92 degrees
  stress entries: 256

Output files are written to examples/results/cantilever_beam_dma/ when plotting or VTK output is enabled.

Swapping the material model

The solve loop above can drive any material model that is a subtype of AbstractMaterial. To swap VEVP_MOAMMM for another material model, change only the material constructor:

# Same script, different material:
mat = J2Plasticity(70e3, 0.3, 250.0, 1e3)
mat = VEVP_Zhao2021_AD(1e3, 1e3, 8, 0.1, 0.5, 0.8, 0.5, 1e-3, 1.0, 2.0, 4.0, 0.0, 0.5, 1.0)

The assembler calls the new material's element routine and state-update methods through the same interface.

Plain program

The full runnable source is rendered from examples/cantilever_beam_dma.jl:

using Ferrite
using FerriteSolidMechanics
using LinearAlgebra

"""
    run_cantilever_beam_dma(; kwargs...)

Solve a 3D cantilever beam under sinusoidal displacement control (DMA-style) using the
VEVP_MOAMMM viscoelastic-viscoplastic material model.

The beam lies along the x-axis from x=0 (root) to x=Lx (tip). The root face is fully
clamped. The tip face is clamped in x and z while a sinusoidal y-displacement is
prescribed, mimicking a driven jaw.

Quadratic serendipity hexahedral elements are used.

Pass `vtk="my/output/dir"` to write a ParaView PVD collection with the displacement field
at every converged time step.
"""
function run_cantilever_beam_dma(;
    nx::Int=8,
    ny::Int=2,
    nz::Int=2,
    Lx::Float64=5.0,
    Ly::Float64=1.0,
    Lz::Float64=1.0,
    amplitude::Float64=0.05,
    total_time::Float64=8.0,
    nsteps::Int=96,
    max_newton::Int=20,
    newton_tol::Float64=1e-6,
    vtk::Union{String,Nothing}=nothing,
)
    grid = generate_grid(Hexahedron, (nx, ny, nz), Vec(0.0, 0.0, 0.0), Vec(Lx, Ly, Lz))

    interpolation = Serendipity{RefHexahedron, 2}()^3
    dh = DofHandler(grid)
    add!(dh, :u, interpolation)
    close!(dh)

    # Boundary conditions
    # Root: fully clamped
    # Tip: clamped in x and z, sinusoidal y-displacement
    ch = ConstraintHandler(dh)
    add!(ch, Dirichlet(:u, getfacetset(grid, "left"),  (x, t) -> [0.0, 0.0, 0.0], [1, 2, 3]))
    add!(ch, Dirichlet(:u, getfacetset(grid, "right"), (x, t) -> [0.0, amplitude * sin(4.0 * pi * t / total_time), 0.0], [1, 2, 3]))
    close!(ch)

    # Reaction DOFs for the tip y-displacement. These are only used to read
    # reactions from the residual before constrained entries are zeroed.
    react_dofs = _facet_component_dofs(dh, getfacetset(grid, "right"), interpolation, 2)

    # Material: VEVP_MOAMMM with 8 Maxwell branches. This is more than
    # the demo needs, but shows the multi-branch interface.
    mat = VEVP_MOAMMM(
        6, 80.0, 40.0, 0.25, 0.3, 2.0, 1.0,
        40.0, 10.0, 5.0, 1.0, 45.0, 10.0, 5.0, 1.0,
        1.0, 0.1, 0.01,
        [60.0, 70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0],
        [0.2625, 0.3375, 0.4125, 0.4875, 0.60, 0.75, 0.9375, 1.20],
        [60.0, 70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0],
        [0.2625, 0.3375, 0.4125, 0.4875, 0.60, 0.75, 0.9375, 1.20],
    )

    assembler = create_assembler(mat, dh, ch; quadrature_order=2)

    u = zeros(ndofs(dh))
    u_prev = similar(u)
    dt = total_time / nsteps
    converged_steps = 0

    history_t = Float64[]
    history_uy = Float64[]
    history_fy = Float64[]

    # VTK: open PVD collection (requires WriteVTK)
    if vtk !== nothing
        Base.invokelatest(_dma_ensure_writevtk_loaded)
        mkpath(dirname(vtk))
    end
    pvd = vtk !== nothing ? Base.invokelatest(_dma_open_pvd, vtk) : nothing

    for step in 1:nsteps
        t = step * dt
        u_prev .= u
        update!(ch, t)
        apply!(u, ch)

        converged = false
        final_res_norm = 0.0
        fy_tip = 0.0
        for iter in 1:max_newton
            K, r = stiffness_matrix(assembler, u; dt=dt)
            # Reaction force: extract BEFORE apply_zero! zeros out constrained DOFs
            fy_tip = isempty(react_dofs) ? 0.0 : sum(r[react_dofs])
            apply_zero!(K, r, ch)
            final_res_norm = norm(r)
            if final_res_norm < newton_tol
                converged = true
                break
            end
            u .-= K \ r
        end
        if !converged
            u .= u_prev
            error("Newton iteration did not converge at time step $step (t=$t, norm(r)=$final_res_norm)")
        end
        update_states!(assembler)
        converged_steps += 1

        # VTK: write converged state to PVD collection
        if pvd !== nothing
            Base.invokelatest(_dma_write_vtk_step!, pvd, dh, u, t, "$(vtk)_$step")
        end

        # Tip displacement (average of prescribed DOFs)
        uy_tip = isempty(react_dofs) ? 0.0 : sum(u[react_dofs]) / length(react_dofs)

        push!(history_t, t)
        push!(history_uy, uy_tip)
        push!(history_fy, fy_tip)
    end

    # VTK: finalize PVD collection
    pvd !== nothing && Base.invokelatest(_dma_close_pvd, pvd)

    stresses = compute_stresses(assembler, u)
    return (u=u, stresses=stresses, grid=grid, dh=dh, ch=ch, assembler=assembler,
        converged_steps=converged_steps, history_t=history_t, history_uy=history_uy,
        history_fy=history_fy)
end

function _dma_ensure_writevtk_loaded()
    try
        @eval using WriteVTK
    catch
        throw(ArgumentError("VTK output requires WriteVTK.jl in the active environment"))
    end
    return nothing
end

# Internal helpers: called via invokelatest because WriteVTK is loaded at runtime
_dma_open_pvd(vtk) = paraview_collection(vtk)
_dma_close_pvd(pvd) = vtk_save(pvd)

function _dma_write_vtk_step!(pvd, dh, u, time, path)
    VTKGridFile(path, dh) do vtk_file
        write_solution(vtk_file, dh, u)
        pvd[time] = vtk_file
    end
    return nothing
end

function _facet_component_dofs(dh, facets, interpolation, component::Int)
    ncomp = Ferrite.n_components(interpolation)
    1 <= component <= ncomp || throw(ArgumentError("component must be in 1:$ncomp"))

    # Ferrite interleaves vector dofs by component, so use the scalar base
    # interpolation to find which shape functions live on each facet.
    base_interpolation = Ferrite.get_base_interpolation(interpolation)
    facet_dofs = Ferrite.dirichlet_facetdof_indices(base_interpolation)
    facet_cache = FacetCache(dh)
    dofs = Int[]

    for facet in facets
        reinit!(facet_cache, facet)
        _, facet_id = facet
        for local_shape_dof in facet_dofs[facet_id]
            # Scalar shape dof -> this component's local dof -> global dof
            local_component_dof = (local_shape_dof - 1) * ncomp + component
            push!(dofs, celldofs(facet_cache)[local_component_dof])
        end
    end

    sort!(dofs)
    unique!(dofs)
    return dofs
end

# Internal helper: called via invokelatest to avoid issues when Plots is loaded during runtime.
function _plot_results_impl(result, outdir)
    t = result.history_t
    uy = result.history_uy
    fy = result.history_fy

    # --- Panel 1: Tip displacement vs time ---
    p1 = plot(t, uy, linewidth=2, color=:royalblue, xlabel="Time", ylabel="Tip u_y", title="Tip Displacement", legend=false, marker=:circle, markersize=3)

    # --- Panel 2: Reaction force vs time ---
    p2 = plot(t, fy, linewidth=2, color=:firebrick, xlabel="Time", ylabel="Reaction F_y", title="Reaction Force", legend=false, marker=:diamond, markersize=3)

    # --- Panel 3: Hysteresis loop (F vs u) ---
    # Close the loop by prepending the origin
    p3 = plot(vcat(0.0, uy), vcat(0.0, fy), linewidth=2, color=:darkgreen, xlabel="Tip u_y", ylabel="Reaction F_y", title="Hysteresis Loop", legend=false, marker=:auto, markersize=2)

    # --- Compose figure ---
    plt = plot(p1, p2, p3, layout=(3, 1), size=(900, 900), dpi=150)

    mkpath(outdir)
    outpath = joinpath(outdir, "cantilever_dma.png")
    savefig(plt, outpath)
    println("Plot saved to: $outpath")
    return outpath
end

"""
    plot_results(result; outdir=".")

Write a three-panel PNG (tip displacement, reaction force, and force-displacement
hysteresis loop) from a DMA cantilever result. Requires `Plots.jl`.
"""
function plot_results(result; outdir=".")
    try
        @eval using Plots
    catch
        throw(ArgumentError("plot_results requires Plots.jl in the active environment"))
    end
    return Base.invokelatest(_plot_results_impl, result, outdir)
end

"""
    phase_lag_degrees(result)

Estimate the first-harmonic phase lag between the recorded tip displacement
and reaction force histories, in degrees.
"""
function phase_lag_degrees(result)
    t = result.history_t
    u = result.history_uy
    f = result.history_fy
    isempty(t) && return NaN

    total_time = maximum(t)
    omega = 4.0 * pi / total_time
    s = sin.(omega .* t)
    c = cos.(omega .* t)

    phase_u = atan(sum(u .* c), sum(u .* s))
    phase_f = atan(sum(f .* c), sum(f .* s))
    lag = abs(mod(phase_f - phase_u + pi, 2.0 * pi) - pi)
    return lag * 180.0 / pi
end

if abspath(PROGRAM_FILE) == @__FILE__
    results_dir = joinpath(@__DIR__, "results", "cantilever_beam_dma")
    result = run_cantilever_beam_dma(; vtk=joinpath(results_dir, "cantilever_dma"))
    println("Solved 3D cantilever beam DMA")
    println("  cells: $(getncells(result.grid))")
    println("  dofs: $(ndofs(result.dh))")
    println("  converged steps: $(result.converged_steps)")
    println("  peak tip uy: $(maximum(abs, result.history_uy))")
    println("  force/displacement phase lag: $(round(phase_lag_degrees(result); digits=2)) degrees")
    println("  stress entries: $(length(result.stresses))")
    plot_results(result; outdir=results_dir)
end