2D plate with a hole

A plane stress, displacement-controlled quarter plate with a circular hole, solved with a NeoHooke material wrapped by PlaneStress. The runnable script is examples/plate_with_hole_planestress.jl and can be executed from the repository root with:

julia --project=. examples/plate_with_hole_planestress.jl

Problem setup

We model a quarter of a square plate with a quarter-circle hole in the corner. The plate is plate_size × plate_size, the hole has radius radius. For symmetry, the bottom and left edges are clamped in their normal direction; the right edge is displaced uniformly outward and the top edge is left free. Consequently, the boundary conditions (BCs) follow as

  • y = 0 (bottom edge): u_y = 0 (symmetry),
  • x = 0 (left edge): u_x = 0 (symmetry),
  • x = plate_size (right edge): prescribed u_x = displacement * t (loading).

A structured polar-like mesh is generated by the helper quarter_plate_with_hole_grid. Figure 1 shows a schematic of the mesh and the applied BCs.

Quarter plate with hole setup Figure 1. Setup of the quarter plate with hole including boundary conditions.

Grid, interpolation, constraints

We set up the grid, displacement field, and boundary conditions with standard Ferrite.jl calls:

grid = quarter_plate_with_hole_grid(; nr=6, ntheta=16, radius=0.25, plate_size=1.0) # create the quarter-plate mesh (custom function)

interpolation = Lagrange{RefQuadrilateral,1}()^2 # linear displacement shape functions in 2D
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

displacement = 0.03                              # final prescribed right-edge displacement
ch = ConstraintHandler(dh)                       # collect Dirichlet boundary conditions
add!(ch, Dirichlet(:u, getfacetset(grid, "symmetry_x"), (x, t) -> [0.0], [2]))             # bottom symmetry: u_y = 0
add!(ch, Dirichlet(:u, getfacetset(grid, "symmetry_y"), (x, t) -> [0.0], [1]))             # left symmetry: u_x = 0
add!(ch, Dirichlet(:u, getfacetset(grid, "right"),     (x, t) -> [displacement * t], [1])) # right-edge loading: u_x = displacement*t
close!(ch)                                       # finalize the constraint data

Each Dirichlet call constrains one displacement direction on one facetset. The right-edge boundary scales linearly with the load parameter t ∈ [0,1].

Material

A compressible Neo–Hookean material in plane stress is applied using

E = 100.0
ν = 0.3
material = PlaneStress(NeoHooke(E, ν))

The PlaneStress wrapper turns the 3D NeoHooke into a 2D model by solving for the out-of-plane stretch F̄₃₃ at every quadrature point and statically condensing the tangent. It stores the converged out-of-plane stretch so the next local plane stress solve starts from the previous solution. See Wrappers for the math.

Assembler

create_assembler is the first FerriteSolidMechanics-specific step: it combines the material, dof handler, and constraints into the reusable object used by the solve loop.

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

The assembler stores the material state at each quadrature point and reuses the arrays needed to assemble the global stiffness matrix and internal force vector.

Load and Newton loop

The prescribed displacement is applied in load steps. stiffness_matrix is called inside each Newton iteration and update_states!(assembler) commits the final state after Newton converges:

u = zeros(ndofs(dh))                             # allocate the displacement vector
load_steps = 4                                   # number of equal load increments
max_newton = 20                                  # maximum Newton iterations per load step
newton_tol = 1e-8                                # residual tolerance

for step in 1:load_steps                         # advance the prescribed load
    t = step / load_steps                        # current load factor
    update!(ch, t)                               # update time-dependent boundary values
    apply!(u, ch)                                # write prescribed displacements into `u`

    for _ in 1:max_newton                        # Newton iterations for this load step
        K, r = stiffness_matrix(assembler, u; dt=1.0 / load_steps) # constitutive evaluation & tangent + residual assembly
        apply_zero!(K, r, ch)                    # apply homogeneous constraints to the correction
        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

The keyword dt is the time step by which the material model is evaluated in the current load step. For strain rate-dependent models, this is the physical time increment. As NeoHooke is rate-independent, using dt = 1.0 / load_steps is an arbitrary choice.

update_states!(assembler) is called after the load step has converged, which commits the internal material states at each quadrature point. For the stateless NeoHooke used here this is a no-op: NeoHooke carries no internal history, so update_state! on its NoState() does nothing. The call is kept because it is required for history-bearing models such as J2Plasticity, which can be swapped in without changing the loop.

Postprocessing

compute_stresses evaluates stresses from the converged displacement field without advancing material history:

stresses = compute_stresses(assembler, u)

The result is indexed by quadrature point and cell position and contains the in-plane Cauchy stress tensors.

Additionally, this script prints a short summary:

Solved plate with hole
  cells: 96
  dofs: 238
  max displacement: 0.03
  stress entries: 384

The final cell-averaged plane stress von Mises stress field from this run is shown in Figure 2. Note that plotting is not covered in this tutorial due to its focus.

Final cell-averaged von Mises stress

Figure 2. Final cell-averaged von Mises stress field.

Swapping the material model

The same Newton loop works with other material models. To swap NeoHooke for another model, change only the material constructor; the assembler, boundary conditions, and postprocessing remain identical:

# Same script, different material:
material = Hooke2D(E, ν; plane_stress=true) # recommended for linear 2D plane stress
material = PlaneStress(Hooke(E, ν))         # equivalent wrapper path, but more expensive
material = PlaneStrain(J2Plasticity(70e3, 0.3, 250.0, 1e3))  # small-strain plasticity
material = PlaneStress(VEPD_Detrez2010(2000.0, 0.3, 10.0, 5.0, 0.1, 1.0, 1.0, 1.0, 10.0, [100.0, 50.0], [0.1, 1.0]))

The same solver forwards dt to every material model assigned during assembly. Rate-independent models such as J2Plasticity ignore it; rate-dependent models such as VEPD_Detrez2010 use it in the local history update.

For pure linear 2D elasticity, prefer Hooke2D as it uses the analytical 2D stiffness directly. Use PlaneStrain(Hooke) or PlaneStress(Hooke) only when you want the 3D Hooke model to go through the same 2D wrapper path as other 3D materials, which lack an analytical 2D implementation.

Plain program

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

using Ferrite
using FerriteSolidMechanics
using LinearAlgebra

"""
    quarter_plate_with_hole_grid(; nr=6, ntheta=16, radius=0.25, plate_size=1.0)

Create a structured quadrilateral quarter-plate mesh.
The inner boundary is a quarter circle with radius `radius`; the outer boundary is the square `[0, plate_size] x [0, plate_size]`.
"""
function quarter_plate_with_hole_grid(; nr::Int=6, ntheta::Int=16, radius::Float64=0.25, plate_size::Float64=1.0)
    @assert nr >= 1
    @assert ntheta >= 2
    @assert 0.0 < radius < plate_size

    nodes = Node{2,Float64}[]
    node_id(i, j) = 1 + i * (ntheta + 1) + j

    for i in 0:nr
        s = i / nr
        for j in 0:ntheta
            theta = 0.5 * pi * j / ntheta
            inner = Vec((radius * cos(theta), radius * sin(theta)))
            outer = if theta <= 0.25 * pi
                Vec((plate_size, plate_size * tan(theta)))
            else
                Vec((plate_size / tan(theta), plate_size))
            end
            push!(nodes, Node((1.0 - s) * inner + s * outer))
        end
    end

    cells = Quadrilateral[]
    for i in 0:(nr - 1), j in 0:(ntheta - 1)
        push!(cells, Quadrilateral((
            node_id(i, j),
            node_id(i + 1, j),
            node_id(i + 1, j + 1),
            node_id(i, j + 1),
        )))
    end

    grid = Grid(cells, nodes)
    addfacetset!(grid, "symmetry_x", x -> isapprox(x[2], 0.0; atol=1e-10))
    addfacetset!(grid, "symmetry_y", x -> isapprox(x[1], 0.0; atol=1e-10))
    addfacetset!(grid, "right", x -> isapprox(x[1], plate_size; atol=1e-10))
    addfacetset!(grid, "top", x -> isapprox(x[2], plate_size; atol=1e-10))
    addfacetset!(grid, "hole", x -> isapprox(sqrt(x[1]^2 + x[2]^2), radius; atol=1e-8))
    return grid
end

"""
    run_plate_with_hole(; kwargs...)

Solve a displacement-driven quarter plate with a circular hole using `PlaneStress(NeoHooke(E, nu))`.
Returns a named tuple with the solution, stresses, grid, dof handler, constraint handler, and assembler.
"""
function run_plate_with_hole(;
    nr::Int=6,
    ntheta::Int=16,
    radius::Float64=0.25,
    plate_size::Float64=1.0,
    E::Float64=100.0,
    nu::Float64=0.3,
    displacement::Float64=0.03,
    load_steps::Int=4,
    max_newton::Int=20,
    newton_tol::Float64=1e-8,
)
    grid = quarter_plate_with_hole_grid(; nr, ntheta, radius, plate_size)

    interpolation = Lagrange{RefQuadrilateral,1}()^2
    dh = DofHandler(grid)
    add!(dh, :u, interpolation)
    close!(dh)

    ch = ConstraintHandler(dh)
    add!(ch, Dirichlet(:u, getfacetset(grid, "symmetry_x"), (x, t) -> [0.0], [2]))
    add!(ch, Dirichlet(:u, getfacetset(grid, "symmetry_y"), (x, t) -> [0.0], [1]))
    add!(ch, Dirichlet(:u, getfacetset(grid, "right"), (x, t) -> [displacement * t], [1]))
    close!(ch)

    material = PlaneStress(NeoHooke(E, nu))
    assembler = create_assembler(material, dh, ch; quadrature_order=2)

    u = zeros(ndofs(dh))
    converged_steps = 0
    for step in 1:load_steps
        t = step / load_steps
        update!(ch, t)
        apply!(u, ch)

        converged = false
        for _ in 1:max_newton
            K, r = stiffness_matrix(assembler, u; dt=1.0 / load_steps)
            apply_zero!(K, r, ch)
            if norm(r) < newton_tol
                converged = true
                break
            end
            u .-= K \ r
        end
        converged || error("Newton iteration did not converge at load step $step")
        update_states!(assembler)
        converged_steps += 1
    end

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

if abspath(PROGRAM_FILE) == @__FILE__
    result = run_plate_with_hole()
    println("Solved plate with hole")
    println("  cells: $(getncells(result.grid))")
    println("  dofs: $(ndofs(result.dh))")
    println("  max displacement: $(maximum(abs, result.u))")
    println("  stress entries: $(length(result.stresses))")
end