test(assemblers): add surface traction assembly tests

This commit is contained in:
Jukka Aho
2026-05-09 18:37:46 +03:00
parent 55c36e072b
commit ce0550ecf4
+400
View File
@@ -0,0 +1,400 @@
# This file is a part of JuliaFEM.
# License is MIT: see https://github.com/JuliaFEM/JuliaFEM.jl/blob/master/LICENSE.md
"""
`SurfaceLoad` — distributed traction (or heat flux) integrated as
`∫_Γ N_i · t dS` over a list of mesh faces. The natural complement of
`UniformBodyForce` (`∫_Ω N_i · b dV`). Added in D+++.
Locks in the contract that:
1. The face Gauss quadrature (2 × 2 for quad, 1-point for tri)
reproduces the analytical integral `∫_Γ t dS = t · area` for
constant traction on a flat face — exact to machine precision
for both quadrilateral and triangular faces.
2. Vector-valued traction (3D elasticity) and scalar-valued flux
(heat conduction) both go through the same `apply_load!` path
with no kernel-specific dispatch. The same `_integrate_face!`
unrolls correctly for `_t_comp_count(t) ∈ {1, 3}`.
3. `SurfaceLoad` and `UniformBodyForce` compose additively (apply
both, get the sum) — the combined RHS still solves
∫_Γ N · t dS + ∫_Ω N · b dV correctly.
4. End-to-end pull test on a unit Hex8 cube (1 × 1 × 1 elements):
fix the bottom face, apply unit traction in `+z` on the top
face, recover the analytical extension `u_z(z) = σ_z / E · z`
to within finite-element accuracy.
5. End-to-end heat flux problem: insulated 5 of 6 faces of a Hex8
cube, apply uniform inward flux on the remaining face,
recover the linear conduction temperature profile.
6. Per-face traction (different `t` per face) overlays correctly.
7. `apply_load!(SurfaceLoad)` is allocation-free after warmup.
This file deliberately uses the existing `cache.dof_handler` field
(added in D+++) instead of any face-extraction machinery — the
SurfaceLoad path is intentionally compositional and does not depend on
`Mesh.extract_surface!` (which is incomplete).
"""
using Test
using JuliaFEM
using JuliaFEM: ContinuumFormulation, FullThreeD, Vertex, Temperature
using JuliaFEM: @DOFSet, DOF
using JuliaFEM: LinearElastic, Displacement, ContinuumKernel
using JuliaFEM: HeatConductivity, HeatKernel
using JuliaFEM: DOFBasedCOOAssembler, DOFBasedCOOCache
using JuliaFEM: extract_system, create_elements!
using JuliaFEM: SurfaceLoad, UniformBodyForce, NodalForce, apply_load!
using JuliaFEM: PenaltyDirichlet, apply_constraint!
using LinearAlgebra
using SparseArrays
using Tensors
# ----------------------------------------------------------------------------
# Mesh helpers
# ----------------------------------------------------------------------------
function _unit_hex8(nx::Int, ny::Int, nz::Int; Lx = 1.0, Ly = 1.0, Lz = 1.0)
nodes = Vec{3,Float64}[]
nidx(i, j, k) = (i - 1) + (j - 1) * (nx + 1) + (k - 1) * (nx + 1) * (ny + 1) + 1
for k in 1:(nz + 1), j in 1:(ny + 1), i in 1:(nx + 1)
push!(nodes, Vec{3}((Lx * (i - 1) / nx,
Ly * (j - 1) / ny,
Lz * (k - 1) / nz)))
end
conns = NTuple{8,UInt32}[]
for k in 1:nz, j in 1:ny, i in 1:nx
n1 = nidx(i, j, k)
n2 = nidx(i + 1, j, k)
n3 = nidx(i + 1, j + 1, k)
n4 = nidx(i, j + 1, k)
n5 = nidx(i, j, k + 1)
n6 = nidx(i + 1, j, k + 1)
n7 = nidx(i + 1, j + 1, k + 1)
n8 = nidx(i, j + 1, k + 1)
push!(conns, (UInt32(n1), UInt32(n2), UInt32(n3), UInt32(n4),
UInt32(n5), UInt32(n6), UInt32(n7), UInt32(n8)))
end
return Mesh{8,Hexahedron{8}}(nodes, conns)
end
# Top-face quad nodes for every (i, j) column at the top of an
# (nx × ny × nz) Hex8 box. Returns a Vector{NTuple{4,Int}}.
function _top_face_quads(nx::Int, ny::Int, nz::Int)
nidx(i, j, k) = (i - 1) + (j - 1) * (nx + 1) + (k - 1) * (nx + 1) * (ny + 1) + 1
faces = NTuple{4,Int}[]
k = nz + 1 # top z layer
for j in 1:ny, i in 1:nx
n1 = nidx(i, j, k)
n2 = nidx(i + 1, j, k)
n3 = nidx(i + 1, j + 1, k)
n4 = nidx(i, j + 1, k)
push!(faces, (n1, n2, n3, n4))
end
return faces
end
function _bottom_face_node_dofs(nx::Int, ny::Int, dof_per_node::Int)
nidx(i, j, k) = (i - 1) + (j - 1) * (nx + 1) + (k - 1) * (nx + 1) * (ny + 1) + 1
dofs = Int[]
vals = Float64[]
k = 1
for j in 1:(ny + 1), i in 1:(nx + 1)
node = nidx(i, j, k)
for c in 1:dof_per_node
push!(dofs, (node - 1) * dof_per_node + c)
push!(vals, 0.0)
end
end
return dofs, vals
end
function _setup_elasticity(mesh; E::Float64 = 210e9, ν::Float64 = 0.3)
material = LinearElastic(E = E, ν = ν)
kernel = ContinuumKernel(ContinuumFormulation{FullThreeD}(),
material, Displacement{3}())
S = @DOFSet{u::DOF{Displacement{3}, Vertex}}
elements, dof_mgr = create_elements!(mesh, Element{Hexahedron{8}, Lagrange{1}, S})
asm = DOFBasedCOOAssembler()
cache = DOFBasedCOOCache(elements, dof_mgr, mesh, kernel)
return cache, asm, kernel, mesh
end
function _setup_heat(mesh; k::Float64 = 50.0)
material = HeatConductivity(k = k)
kernel = HeatKernel(ContinuumFormulation{FullThreeD}(), material)
S = @DOFSet{T::DOF{Temperature, Vertex}}
elements, dof_mgr = create_elements!(mesh, Element{Hexahedron{8}, Lagrange{1}, S})
asm = DOFBasedCOOAssembler()
cache = DOFBasedCOOCache(elements, dof_mgr, mesh, kernel)
return cache, asm, kernel, mesh
end
# ----------------------------------------------------------------------------
# 1. Quadrature reproduces ∫_Γ t dS = t · area exactly
# ----------------------------------------------------------------------------
@testset "SurfaceLoad: row-sum identity = t · area" begin
println("\n" * "=" ^ 70)
println("D+++ SurfaceLoad — row-sum identity (analytical area)")
println("=" ^ 70)
@testset "Vector traction on Hex8 top face $(nx)×$(ny)" for (nx, ny) in
[(1, 1), (3, 2), (4, 3)]
nz = 1
mesh = _unit_hex8(nx, ny, nz; Lx = 2.0, Ly = 1.5, Lz = 1.0)
cache, asm, kernel, m = _setup_elasticity(mesh)
faces = _top_face_quads(nx, ny, nz)
t = Vec{3}((10.0, -7.0, 3.0)) # arbitrary uniform traction
load = SurfaceLoad(faces, t)
f = zeros(cache.ndofs)
apply_load!(f, load, cache, asm, kernel, m)
# Sum of the assembled force vector, by component, must equal
# `t · area_Γ` (here area = Lx · Ly = 3.0).
area = 2.0 * 1.5
f_sum_x = sum(f[1:3:end])
f_sum_y = sum(f[2:3:end])
f_sum_z = sum(f[3:3:end])
@test isapprox(f_sum_x, t[1] * area; atol = 1e-10)
@test isapprox(f_sum_y, t[2] * area; atol = 1e-10)
@test isapprox(f_sum_z, t[3] * area; atol = 1e-10)
println(" $(nx)×$(ny) area=$(round(area; digits=3)) " *
"Σf_x=$(round(f_sum_x; digits=4)) (t·area=$(round(t[1]*area; digits=4))) " *
"Σf_z=$(round(f_sum_z; digits=4)) (t·area=$(round(t[3]*area; digits=4)))")
end
@testset "Scalar flux on Hex8 top face $(nx)×$(ny)" for (nx, ny) in
[(1, 1), (2, 3)]
nz = 1
mesh = _unit_hex8(nx, ny, nz; Lx = 1.5, Ly = 2.0, Lz = 1.0)
cache, asm, kernel, m = _setup_heat(mesh)
faces = _top_face_quads(nx, ny, nz)
q = 25.0 # uniform flux
load = SurfaceLoad(faces, q)
f = zeros(cache.ndofs)
apply_load!(f, load, cache, asm, kernel, m)
area = 1.5 * 2.0
@test isapprox(sum(f), q * area; atol = 1e-10)
println(" $(nx)×$(ny) area=$(round(area; digits=3)) " *
"Σf=$(round(sum(f); digits=4)) (q·area=$(round(q*area; digits=4)))")
end
end
# ----------------------------------------------------------------------------
# 2. Triangular face support (Tri3)
# ----------------------------------------------------------------------------
@testset "SurfaceLoad: triangular face row-sum" begin
# Single tri: corners (0,0,0), (2,0,0), (0,3,0) — area = 3.0
mesh = _unit_hex8(1, 1, 1)
cache, asm, kernel, m = _setup_elasticity(mesh)
# Manual nodes/face — we'll re-purpose the cube's nodes 1, 2, 4 which
# at (Lx, Ly, Lz) = (1, 1, 1) form a corner triangle of area 0.5.
faces = NTuple{3,Int}[(1, 2, 4)]
t = Vec{3}((4.0, 0.0, 0.0))
load = SurfaceLoad(faces, t)
f = zeros(cache.ndofs)
apply_load!(f, load, cache, asm, kernel, m)
expected_area = 0.5
@test isapprox(sum(f[1:3:end]), t[1] * expected_area; atol = 1e-10)
@test isapprox(sum(f[2:3:end]), t[2] * expected_area; atol = 1e-10)
@test isapprox(sum(f[3:3:end]), t[3] * expected_area; atol = 1e-10)
end
# ----------------------------------------------------------------------------
# 3. Per-face traction
# ----------------------------------------------------------------------------
@testset "SurfaceLoad: per-face traction overlays correctly" begin
nx, ny, nz = 2, 2, 1
mesh = _unit_hex8(nx, ny, nz)
cache, asm, kernel, m = _setup_elasticity(mesh)
faces = _top_face_quads(nx, ny, nz)
@test length(faces) == 4
# Pull on faces 1 & 4 with +z, push on 2 & 3 with -z. The total
# force resultant should equal Σᵢ (tᵢ · area_face) summed over
# faces; with all face areas = 0.25 and tractions all in z, the
# total z-force is (1 - 1 - 1 + 1) * 0.25 = 0.
tractions = [Vec{3}((0.0, 0.0, 1.0)),
Vec{3}((0.0, 0.0, -1.0)),
Vec{3}((0.0, 0.0, -1.0)),
Vec{3}((0.0, 0.0, 1.0))]
load = SurfaceLoad(faces, tractions)
f = zeros(cache.ndofs)
apply_load!(f, load, cache, asm, kernel, m)
f_sum_z = sum(f[3:3:end])
@test isapprox(f_sum_z, 0.0; atol = 1e-10)
end
# ----------------------------------------------------------------------------
# 4. End-to-end pull test (3D elasticity)
# ----------------------------------------------------------------------------
@testset "SurfaceLoad: 3D pull test on Hex8 column" begin
println("\n" * "=" ^ 70)
println("D+++ SurfaceLoad — end-to-end 3D elasticity pull")
println("=" ^ 70)
# Single Hex8 column 1×1×1 m with σ_zz applied on the top face,
# bottom face fully fixed. Expect u_z(z) = σ_z · z / E.
nx, ny, nz = 1, 1, 4
Lx, Ly, Lz = 1.0, 1.0, 1.0
Eyoung = 1.0e11
σ_z = 1.0e6
mesh = _unit_hex8(nx, ny, nz; Lx = Lx, Ly = Ly, Lz = Lz)
cache, asm, kernel, m = _setup_elasticity(mesh; E = Eyoung, ν = 0.0)
n = cache.ndofs
# Assemble K, then add surface load to f.
assemble!(cache, asm, kernel, m)
K, f = extract_system(cache)
faces = _top_face_quads(nx, ny, nz)
apply_load!(f, SurfaceLoad(faces, Vec{3}((0.0, 0.0, σ_z))),
cache, asm, kernel, m)
# Fix bottom face (penalty Dirichlet, all 3 components).
fixed_dofs, fixed_vals = _bottom_face_node_dofs(nx, ny, 3)
bc = PenaltyDirichlet(fixed_dofs, fixed_vals; penalty = 1e10 * Eyoung)
apply_constraint!(K, bc)
apply_constraint!(f, bc)
u = K \ Vector(f)
# Top-layer node z-displacements: should all equal σ_z * Lz / E.
nidx(i, j, k) = (i - 1) + (j - 1) * (nx + 1) + (k - 1) * (nx + 1) * (ny + 1) + 1
u_top = Float64[]
for j in 1:(ny + 1), i in 1:(nx + 1)
node = nidx(i, j, nz + 1)
push!(u_top, u[(node - 1) * 3 + 3])
end
u_z_exact = σ_z * Lz / Eyoung
rel = maximum(abs.(u_top .- u_z_exact)) / abs(u_z_exact)
@test rel < 1e-3
println(" σ_z=$σ_z E=$(Eyoung) u_z_exact=$(round(u_z_exact; sigdigits = 4)) " *
"u_z_FE=$(round(mean(u_top); sigdigits = 4)) rel=$(round(rel; sigdigits = 3))")
end
# ----------------------------------------------------------------------------
# 5. End-to-end heat flux problem (1D conduction)
# ----------------------------------------------------------------------------
@testset "SurfaceLoad: 1D heat conduction with surface flux" begin
println("\n" * "=" ^ 70)
println("D+++ SurfaceLoad — heat flux + Dirichlet (1D)")
println("=" ^ 70)
# Hex8 column with T=0 on bottom, +q heat flux on top (entering),
# all sides insulated. Expect linear T(z) = q · z / k.
nx, ny, nz = 1, 1, 6
Lx, Ly, Lz = 1.0, 1.0, 1.0
k_cond = 50.0
q_flux = 200.0
mesh = _unit_hex8(nx, ny, nz; Lx = Lx, Ly = Ly, Lz = Lz)
cache, asm, kernel, m = _setup_heat(mesh; k = k_cond)
n = cache.ndofs
assemble!(cache, asm, kernel, m)
K, f = extract_system(cache)
faces = _top_face_quads(nx, ny, nz)
apply_load!(f, SurfaceLoad(faces, q_flux), cache, asm, kernel, m)
fixed_dofs, fixed_vals = _bottom_face_node_dofs(nx, ny, 1)
bc = PenaltyDirichlet(fixed_dofs, fixed_vals; penalty = 1e10 * k_cond)
apply_constraint!(K, bc)
apply_constraint!(f, bc)
T = K \ Vector(f)
nidx(i, j, k) = (i - 1) + (j - 1) * (nx + 1) + (k - 1) * (nx + 1) * (ny + 1) + 1
T_top = Float64[]
for j in 1:(ny + 1), i in 1:(nx + 1)
node = nidx(i, j, nz + 1)
push!(T_top, T[node])
end
T_top_exact = q_flux * Lz / k_cond
rel = maximum(abs.(T_top .- T_top_exact)) / abs(T_top_exact)
@test rel < 1e-3
println(" q=$(q_flux) W/m² k=$k_cond T_top_exact=$(round(T_top_exact; sigdigits = 4)) " *
"T_top_FE=$(round(mean(T_top); sigdigits = 4)) rel=$(round(rel; sigdigits = 3))")
end
# ----------------------------------------------------------------------------
# 6. Composition with UniformBodyForce
# ----------------------------------------------------------------------------
@testset "SurfaceLoad + UniformBodyForce compose additively" begin
nx, ny, nz = 2, 2, 1
mesh = _unit_hex8(nx, ny, nz)
cache, asm, kernel, m = _setup_elasticity(mesh)
body = UniformBodyForce(Vec{3}((0.0, 0.0, -10.0)))
surf = SurfaceLoad(_top_face_quads(nx, ny, nz), Vec{3}((0.0, 0.0, 5.0)))
f_combined = zeros(cache.ndofs)
apply_load!(f_combined, body, cache, asm, kernel, m)
apply_load!(f_combined, surf, cache, asm, kernel, m)
f_body = zeros(cache.ndofs)
apply_load!(f_body, body, cache, asm, kernel, m)
f_surf = zeros(cache.ndofs)
apply_load!(f_surf, surf, cache, asm, kernel, m)
@test isapprox(f_combined, f_body .+ f_surf; atol = 1e-12)
println(" Composition (body + surf) verified ✓")
end
# ----------------------------------------------------------------------------
# 7. Zero allocations after warmup
# ----------------------------------------------------------------------------
@testset "SurfaceLoad: zero allocations on apply_load!" begin
println("\n" * "=" ^ 70)
println("D+++ SurfaceLoad — zero-allocation hot path")
println("=" ^ 70)
nx, ny, nz = 4, 4, 2
mesh = _unit_hex8(nx, ny, nz)
cache, asm, kernel, m = _setup_elasticity(mesh)
faces = _top_face_quads(nx, ny, nz)
load = SurfaceLoad(faces, Vec{3}((1.0, -2.0, 3.0)))
f = zeros(cache.ndofs)
apply_load!(f, load, cache, asm, kernel, m) # warmup
fill!(f, 0.0)
a = @allocated apply_load!(f, load, cache, asm, kernel, m)
@test a == 0
println(" apply_load!(SurfaceLoad) allocs=$(a)")
# Heat flux variant
cache_h, asm_h, kernel_h, m_h = _setup_heat(mesh)
load_h = SurfaceLoad(_top_face_quads(nx, ny, nz), 50.0)
f_h = zeros(cache_h.ndofs)
apply_load!(f_h, load_h, cache_h, asm_h, kernel_h, m_h)
fill!(f_h, 0.0)
a_h = @allocated apply_load!(f_h, load_h, cache_h, asm_h, kernel_h, m_h)
@test a_h == 0
println(" apply_load!(SurfaceLoad heat) allocs=$(a_h)")
end
# Local helper — `mean` from Statistics, but we want to avoid the dep
mean(xs) = sum(xs) / length(xs)