The classical first finite element problem to solve in solid mechanics is a linear balance
of momentum problem. We will use this to introduce a vector valued field, the displacements
u(x). In addition, some features of the
Tensors.jl
toolbox are demonstrated.
The strong form of the balance of momentum for quasi-static loading is given by div(σ)+b=0x∈Ωu=uDx∈ΓDn⋅σ=tNx∈ΓN
In this tutorial, we use linear elasticity, such that σ=C:ε,ε=[grad(u)]sym
The resulting weak form is given as follows: Find u∈U such that $$ \int_\Omega \mathrm{grad}(\delta \boldsymbol{u}) : \boldsymbol{\sigma} , \mathrm{d}\Omega = \int_{\Gamma} \delta \boldsymbol{u} \cdot \boldsymbol{t} , \mathrm{d}\Gamma
\int_\Omega \delta \boldsymbol{u} \cdot \boldsymbol{b} , \mathrm{d}\Omega \quad \forall , \delta \boldsymbol{u} \in \mathbb{T}, where$U$and$T$denotesuitabletrialandtestfunctionspaces.$δu$isavectorvaluedtestfunctionand$t=σ⋅n$isthetractionvectorontheboundary.Inthistutorial,wewillneglectbodyforces(i.e.$b=0$)andtheweakformreducesto
Finally, the finite element form is obtained by introducing the finite element shape functions. Since the displacement field, u, is vector valued, we use vector valued shape functions δNi and Ni to approximate the test and trial functions: u≈N∑i=1Ni(x)ˆuiδu≈N∑i=1δNi(x)δˆui
Inserting the these into the weak form, and noting that that the equation should hold for all δˆui, we get ∫Ωgrad(δNi):σ dΩ⏟finti=∫ΓδNi⋅t dΓ⏟fexti
First we load Ferrite, and some other packages we need.
using Ferrite, FerriteGmsh, SparseArrays
As in the heat equation tutorial, we will use a unit square - but here we'll load the grid of the Ferrite logo!
This is done by downloading logo.geo
and loading it using FerriteGmsh.jl,
using Downloads: download
logo_mesh = "logo.geo"
asset_url = "https://raw.githubusercontent.com/Ferrite-FEM/Ferrite.jl/gh-pages/assets/"
isfile(logo_mesh) || download(string(asset_url, logo_mesh), logo_mesh)
FerriteGmsh.Gmsh.initialize() #hide
FerriteGmsh.Gmsh.gmsh.option.set_number("General.Verbosity", 2) #hide
grid = togrid(logo_mesh);
FerriteGmsh.Gmsh.finalize(); #hide
The generated grid lacks the facetsets for the boundaries, so we add them by using Ferrite's
addfacetset!
. It allows us to add facetsets to the grid based on coordinates.
Note that approximate comparison to 0.0 doesn't work well, so we use a tolerance instead.
addfacetset!(grid, "top", x -> x[2] ≈ 1.0) # facets for which x[2] ≈ 1.0 for all nodes
addfacetset!(grid, "left", x -> abs(x[1]) < 1.0e-6)
addfacetset!(grid, "bottom", x -> abs(x[2]) < 1.0e-6);
In this tutorial, we use the same linear Lagrange shape functions to approximate both the
test and trial spaces, i.e. δNi=Ni.
As our grid is composed of triangular elements, we need the Lagrange functions defined
on a RefTriangle
. All currently available interpolations can be found under
Interpolation
.
Here we use linear triangular elements (also called constant strain triangles).
The vector valued shape functions are constructed by raising the interpolation
to the power dim
(the dimension) since the displacement field has one component in each
spatial dimension.
dim = 2
order = 1 # linear interpolation
ip = Lagrange{RefTriangle, order}()^dim; # vector valued interpolation
In order to evaluate the integrals, we need to specify the quadrature rules to use. Due to the linear interpolation, a single quadrature point suffices, both inside the cell and on the facet. In 2d, a facet is the edge of the element.
qr = QuadratureRule{RefTriangle}(1) # 1 quadrature point
qr_face = FacetQuadratureRule{RefTriangle}(1);
Finally, we collect the interpolations and quadrature rules into the CellValues
and FacetValues
buffers, which we will later use to evaluate the integrals over the cells and facets.
cellvalues = CellValues(qr, ip)
facetvalues = FacetValues(qr_face, ip);
For distributing degrees of freedom, we define a DofHandler
. The DofHandler
knows that
u
has two degrees of freedom per node because we vectorized the interpolation above.
dh = DofHandler(grid)
add!(dh, :u, ip)
close!(dh);
Numbering of degrees of freedom
A common assumption is that the numbering of degrees of freedom follows the global numbering of the nodes in the grid. This is NOT the case in Ferrite. For more details, see the Ferrite numbering rules.
We set Dirichlet boundary conditions by fixing the motion normal to the bottom and left
boundaries. The last argument to Dirichlet
determines which components of the field should be
constrained. If no argument is given, all components are constrained by default.
ch = ConstraintHandler(dh)
add!(ch, Dirichlet(:u, getfacetset(grid, "bottom"), (x, t) -> 0.0, 2))
add!(ch, Dirichlet(:u, getfacetset(grid, "left"), (x, t) -> 0.0, 1))
close!(ch);
In addition, we will use Neumann boundary conditions on the top surface, where we add a traction vector of the form tN(x)=(20e3)x1e2 N/mm2
traction(x) = Vec(0.0, 20.0e3 * x[1]);
On the right boundary, we don't do anything, resulting in a zero traction Neumann boundary.
In order to assemble the external forces, fexti, we need to iterate over all
facets in the relevant facetset. We do this by using the FacetIterator
.
function assemble_external_forces!(f_ext, dh, facetset, facetvalues, prescribed_traction)
# Create a temporary array for the facet's local contributions to the external force vector
fe_ext = zeros(getnbasefunctions(facetvalues))
for facet in FacetIterator(dh, facetset)
# Update the facetvalues to the correct facet number
reinit!(facetvalues, facet)
# Reset the temporary array for the next facet
fill!(fe_ext, 0.0)
# Access the cell's coordinates
cell_coordinates = getcoordinates(facet)
for qp in 1:getnquadpoints(facetvalues)
# Calculate the global coordinate of the quadrature point.
x = spatial_coordinate(facetvalues, qp, cell_coordinates)
tₚ = prescribed_traction(x)
# Get the integration weight for the current quadrature point.
dΓ = getdetJdV(facetvalues, qp)
for i in 1:getnbasefunctions(facetvalues)
Nᵢ = shape_value(facetvalues, qp, i)
fe_ext[i] += tₚ ⋅ Nᵢ * dΓ
end
end
# Add the local contributions to the correct indices in the global external force vector
assemble!(f_ext, celldofs(facet), fe_ext)
end
return f_ext
end
assemble_external_forces! (generic function with 1 method)
Next, we need to define the material behavior, specifically the elastic stiffness tensor, C. In this tutorial, we use plane strain linear isotropic elasticity, with Hooke's law as σ=2Gεdev+3Kεvol
Starting from Young's modulus, E, and Poisson's ratio, ν, the shear and bulk modulus are G=E2(1+ν),K=E3(1−2ν)
Emod = 200.0e3 # Young's modulus [MPa]
ν = 0.3 # Poisson's ratio [-]
Gmod = Emod / (2(1 + ν)) # Shear modulus
Kmod = Emod / (3(1 - 2ν)) # Bulk modulus
166666.66666666663
Finally, we demonstrate Tensors.jl
's automatic differentiation capabilities when
calculating the elastic stiffness tensor
C = gradient(ϵ -> 2 * Gmod * dev(ϵ) + 3 * Kmod * vol(ϵ), zero(SymmetricTensor{2, 2}));
To calculate the global stiffness matrix, Kij, the element routine computes the
local stiffness matrix ke
for a single element and assembles it into the global matrix.
ke
is pre-allocated and reused for all elements.
Note that the elastic stiffness tensor C is constant. Thus is needs to be computed and once and can then be used for all integration points.
function assemble_cell!(ke, cellvalues, C)
for q_point in 1:getnquadpoints(cellvalues)
# Get the integration weight for the quadrature point
dΩ = getdetJdV(cellvalues, q_point)
for i in 1:getnbasefunctions(cellvalues)
# Gradient of the test function
∇Nᵢ = shape_gradient(cellvalues, q_point, i)
for j in 1:getnbasefunctions(cellvalues)
# Symmetric gradient of the trial function
∇ˢʸᵐNⱼ = shape_symmetric_gradient(cellvalues, q_point, j)
ke[i, j] += (∇Nᵢ ⊡ C ⊡ ∇ˢʸᵐNⱼ) * dΩ
end
end
end
return ke
end
assemble_cell! (generic function with 1 method)
We define the function assemble_global
to loop over the elements and do the global
assembly. The function takes the preallocated sparse matrix K
, our DofHandler dh
, our
cellvalues
and the elastic stiffness tensor C
as input arguments and computes the
global stiffness matrix K
.
function assemble_global!(K, dh, cellvalues, C)
# Allocate the element stiffness matrix
n_basefuncs = getnbasefunctions(cellvalues)
ke = zeros(n_basefuncs, n_basefuncs)
# Create an assembler
assembler = start_assemble(K)
# Loop over all cells
for cell in CellIterator(dh)
# Update the shape function gradients based on the cell coordinates
reinit!(cellvalues, cell)
# Reset the element stiffness matrix
fill!(ke, 0.0)
# Compute element contribution
assemble_cell!(ke, cellvalues, C)
# Assemble ke into K
assemble!(assembler, celldofs(cell), ke)
end
return K
end
assemble_global! (generic function with 1 method)
The last step is to solve the system. First we allocate the global stiffness matrix K
and assemble it.
K = allocate_matrix(dh)
assemble_global!(K, dh, cellvalues, C);
Then we allocate and assemble the external force vector.
f_ext = zeros(ndofs(dh))
assemble_external_forces!(f_ext, dh, getfacetset(grid, "top"), facetvalues, traction);
To account for the Dirichlet boundary conditions we use the apply!
function.
This modifies elements in K
and f
, such that we can get the
correct solution vector u
by using solving the linear equation system Kijˆuj=fexti,
apply!(K, f_ext, ch)
u = K \ f_ext;
Numbering of degrees of freedom
Once again, recall that numbering of degrees of freedom does NOT follow the global numbering of the nodes in the grid. Specifically,
u[2 * i - 1]
andu[2 * i]
are NOT the displacements at nodei
.
In this case, we want to analyze the displacements, as well as the stress field. We calculate the stress in each quadrature point, and then export it in two different ways:
L2Projector
.function calculate_stresses(grid, dh, cv, u, C)
qp_stresses = [
[zero(SymmetricTensor{2, 2}) for _ in 1:getnquadpoints(cv)]
for _ in 1:getncells(grid)
]
avg_cell_stresses = tuple((zeros(getncells(grid)) for _ in 1:3)...)
for cell in CellIterator(dh)
reinit!(cv, cell)
cell_stresses = qp_stresses[cellid(cell)]
for q_point in 1:getnquadpoints(cv)
ε = function_symmetric_gradient(cv, q_point, u, celldofs(cell))
cell_stresses[q_point] = C ⊡ ε
end
σ_avg = sum(cell_stresses) / getnquadpoints(cv)
avg_cell_stresses[1][cellid(cell)] = σ_avg[1, 1]
avg_cell_stresses[2][cellid(cell)] = σ_avg[2, 2]
avg_cell_stresses[3][cellid(cell)] = σ_avg[1, 2]
end
return qp_stresses, avg_cell_stresses
end
qp_stresses, avg_cell_stresses = calculate_stresses(grid, dh, cellvalues, u, C);
We now use the the L2Projector to project the stress-field onto the piecewise linear finite element space that we used to solve the problem.
proj = L2Projector(Lagrange{RefTriangle, 1}(), grid)
stress_field = project(proj, qp_stresses, qr);
color_data = zeros(Int, getncells(grid)) #hide
colors = [ #hide
"1" => 1, "5" => 1, # purple #hide
"2" => 2, "3" => 2, # red #hide
"4" => 3, # blue #hide
"6" => 4, # green #hide
] #hide
for (key, color) in colors #hide
for i in getcellset(grid, key) #hide
color_data[i] = color #hide
end #hide
end #hide
To visualize the result we export to a VTK-file. Specifically, an unstructured
grid file, .vtu
, is created, which can be viewed in e.g.
ParaView.
VTKGridFile("linear_elasticity", dh) do vtk
write_solution(vtk, dh, u)
for (i, key) in enumerate(("11", "22", "12"))
write_cell_data(vtk, avg_cell_stresses[i], "sigma_" * key)
end
write_projection(vtk, proj, stress_field, "stress field")
Ferrite.write_cellset(vtk, grid)
write_cell_data(vtk, color_data, "colors") #hide
end
VTKGridFile for the closed file "linear_elasticity.vtu".
We used the displacement field to visualize the deformed logo in Figure 1, and in Figure 2, we demonstrate the difference between the interpolated stress field and the constant stress in each cell.
Figure 2: Vertical normal stresses (MPa) exported using the L2Projector
(left)
and constant stress in each cell (right).
using Test #hide
linux_result = 0.31742879147646924 #hide
@test abs(norm(u) - linux_result) < 0.01 #hide
Sys.islinux() && @test norm(u) ≈ linux_result #hide
nothing #hide
This notebook was generated using Literate.jl.