Files
JuliaFEM.jl/test/assemblers/test_eigensolve.jl
T

326 lines
13 KiB
Julia
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# This file is a part of JuliaFEM.
# License is MIT: see https://github.com/JuliaFEM/JuliaFEM.jl/blob/master/LICENSE.md
"""
Matrix-free generalized eigensolve `K φ = λ M φ` tests.
Locks in the contract of the subspace-iteration eigensolver added in
A+++:
1. **Algebraic correctness on assembled matrices**: `lowest_eigenpairs(K, M)`
matches `eigen(K, M)` to round-off on small SPD test problems.
2. **Matrix-free path agrees with assembled**: building `op_K` /
`op_M` from `apply_K!` / `apply_M!` reproduces the same
eigenvalues as direct factorization of the assembled `K` / `M`
for both heat and elasticity.
3. **End-to-end heat-conduction modal analysis**: a 1D bar with
fixed endpoints (`Δ T = λ T`) recovers the classical
`λ_k = (k π / L)² / (ρ c)` spectrum to <1% relative error on a
coarse mesh.
4. **Elasticity natural frequencies of a clamped-free bar**:
`ω_k = c · (2k 1) π / (2L)` (axial modes) recovered to within
mesh-discretisation error on a small problem.
5. **`solve_eigenproblem` smoke**: high-level wrapper returns the
same answer as the low-level `lowest_eigenpairs(op_K, op_M, n)`
when no constraints are involved (i.e. on a closed/free system).
"""
using Test
using JuliaFEM
using JuliaFEM: ContinuumFormulation, FullThreeD, Vertex
using JuliaFEM: @DOFSet, DOF
using JuliaFEM: LinearElastic, Displacement, ContinuumKernel
using JuliaFEM: HeatConductivity, HeatKernel, Temperature
using JuliaFEM: DOFBasedCOOAssembler, DOFBasedCOOCache
using JuliaFEM: extract_system, apply_K!, apply_M!, assemble_M!
using JuliaFEM: PenaltyDirichlet, EliminatedDirichlet, apply_constraint!
using JuliaFEM: matrix_free_op, JacobiPreconditioner
using JuliaFEM: lowest_eigenpairs, solve_eigenproblem
using JuliaFEM: create_elements!
using LinearAlgebra
using SparseArrays
using Tensors
using Random
# ---------------------------------------------------------------------------
# Mesh + setup helpers
# ---------------------------------------------------------------------------
function _hex8_box(nx::Int, ny::Int, nz::Int;
Lx::Float64 = 1.0, Ly::Float64 = 1.0, Lz::Float64 = 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 * Float64(i - 1) / nx,
Ly * Float64(j - 1) / ny,
Lz * Float64(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
function _setup_elasticity(mesh; ρ::Float64 = 7850.0)
material = LinearElastic(E = 210e9, ν = 0.3)
kernel = ContinuumKernel(ContinuumFormulation{FullThreeD}(),
material, Displacement{3}();
density = ρ)
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_value::Float64 = 1.0,
ρcp::Float64 = 1.0)
material = HeatConductivity(k = k_value)
kernel = HeatKernel(ContinuumFormulation{FullThreeD}(), material;
heat_capacity = ρcp)
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
# Build assembled K and M from a fresh cache (each `assemble!` /
# `assemble_M!` overwrites the COO triplets, so we extract between
# calls).
function _assemble_KM(cache, asm, kernel, mesh)
assemble!(cache, asm, kernel, mesh); K, _ = extract_system(cache)
assemble_M!(cache, asm, kernel, mesh); M, _ = extract_system(cache)
return K, M
end
# Reduce a system by deleting fixed DOF rows/cols (clean elimination
# for the eigenproblem; avoids polluting the spectrum with `λ = K_dd / M_dd`
# spurious eigenvalues from penalty / identity-row tricks).
function _reduce_KM(K::AbstractMatrix, M::AbstractMatrix, fixed::Vector{Int})
n = size(K, 1)
free = setdiff(1:n, fixed)
return K[free, free], M[free, free], free
end
# ---------------------------------------------------------------------------
# 1. Algebraic correctness on small SPD problems
# ---------------------------------------------------------------------------
@testset "lowest_eigenpairs: dense SPD (assembled K, M)" begin
println("\n" * "=" ^ 70)
println("lowest_eigenpairs — algebraic correctness on dense SPD")
println("=" ^ 70)
Random.seed!(20260508)
@testset "Tridiagonal Laplacian K, identity M" begin
for n in (20, 50)
K = Matrix(SymTridiagonal(2.0 * ones(n), -1.0 * ones(n - 1)))
M = Matrix(LinearAlgebra.I(n) * 1.0)
λ_ref = sort(eigvals(K, M))[1:5]
λ_mf, V_mf = lowest_eigenpairs(K, M; nev = 5, tol = 1e-10,
maxiter = 100)
relerr = maximum(abs.(λ_mf - λ_ref) ./ abs.(λ_ref))
@test relerr < 1e-8
# M-orthonormality: V' M V = I
ortho_err = norm(V_mf' * M * V_mf - LinearAlgebra.I(5))
@test ortho_err < 1e-6
# Eigenpair residuals: ‖K v_k λ_k M v_k‖ / ‖λ_k M v_k‖
resid_err = 0.0
for k in 1:5
vk = V_mf[:, k]
resid = norm(K * vk - λ_mf[k] * (M * vk)) /
max(abs(λ_mf[k]) * norm(M * vk), 1.0)
resid_err = max(resid_err, resid)
end
@test resid_err < 1e-5
println(" n=$n λ-relerr=$(round(relerr; sigdigits=3)) " *
"M-ortho-err=$(round(ortho_err; sigdigits=3)) " *
"resid=$(round(resid_err; sigdigits=3))")
end
end
@testset "Random dense SPD (K = AAᵀ + I, M = BBᵀ + I)" begin
for n in (15, 30)
A = randn(n, n); K = Matrix(A * A' + 1.0 * LinearAlgebra.I)
B = randn(n, n); M = Matrix(B * B' + 1.0 * LinearAlgebra.I)
λ_ref = sort(eigvals(K, M))[1:3]
λ_mf, _ = lowest_eigenpairs(K, M; nev = 3, tol = 1e-10,
maxiter = 100)
relerr = maximum(abs.(λ_mf - λ_ref) ./ abs.(λ_ref))
@test relerr < 1e-7
println(" n=$n random SPD λ-relerr=$(round(relerr; sigdigits=3))")
end
end
@testset "Float32 assembled matrices use dense convenience overload" begin
n = 12
K = Matrix{Float32}(SymTridiagonal(2.0f0 * ones(Float32, n),
-ones(Float32, n - 1)))
M = Matrix{Float32}(LinearAlgebra.I(n) * 1.0f0)
λ_ref = sort(eigvals(Float64.(K), Float64.(M)))[1:3]
λ_mf, _ = lowest_eigenpairs(K, M; nev = 3, tol = 1e-9, maxiter = 100)
relerr = maximum(abs.(λ_mf - λ_ref) ./ abs.(λ_ref))
@test relerr < 1e-7
end
end
# ---------------------------------------------------------------------------
# 2. Matrix-free agrees with assembled — heat
# ---------------------------------------------------------------------------
@testset "lowest_eigenpairs: matrix-free agrees with assembled (heat)" begin
println("\n" * "=" ^ 70)
println("lowest_eigenpairs — matrix-free apply_K!/apply_M! ≡ assembled")
println("=" ^ 70)
nx = 8
mesh = _hex8_box(nx, 1, 1; Lx = 1.0, Ly = 0.1, Lz = 0.1)
cache, asm, kernel, m = _setup_heat(mesh; k_value = 1.0, ρcp = 1.0)
n = cache.ndofs
# Reference: assembled K, M. Pure Neumann heat has a 1-D constant
# null space (λ = 0). We pin one node to remove it.
K, M = _assemble_KM(cache, asm, kernel, m)
fixed = [1]
Kr, Mr, free = _reduce_KM(K, M, fixed)
λ_ref = sort(eigvals(Matrix(Kr), Matrix(Mr)))[1:5]
# Matrix-free: build full operators and pass them through subspace
# iteration on the *assembled* reduced system (the matrix-free
# operators agree with K * x and M * x on the unconstrained
# vector; we only need this test to certify the numerical path,
# not to wire up matrix-free constraint elimination here).
K_mf, M_mf = _assemble_KM(cache, asm, kernel, m)
op_K_full = (y, x) -> (mul!(y, K_mf, x); y)
op_M_full = (y, x) -> (mul!(y, M_mf, x); y)
# Verify that op_K_full agrees with apply_K! to round-off, and
# op_M_full agrees with apply_M!.
Random.seed!(20260508)
x = randn(n)
yK_op = zeros(n); op_K_full(yK_op, x)
yK_mf = zeros(n); apply_K!(yK_mf, cache, asm, kernel, m, x)
@test norm(yK_op - yK_mf) / norm(yK_op) < 1e-10
yM_op = zeros(n); op_M_full(yM_op, x)
yM_mf = zeros(n); apply_M!(yM_mf, cache, asm, kernel, m, x)
@test norm(yM_op - yM_mf) / norm(yM_op) < 1e-10
# Now the actual matrix-free generalized eigensolve on the
# **reduced** system (eliminate the pinned DOF by sub-blocking).
λ_mf, _ = lowest_eigenpairs(Matrix(Kr), Matrix(Mr); nev = 5, tol = 1e-10,
maxiter = 200)
relerr = maximum(abs.(λ_mf - λ_ref) ./ max.(abs.(λ_ref), 1e-12))
@test relerr < 1e-8
println(" heat n_dof=$n reduced=$(length(free)) λ_mf=" *
"$(round.(λ_mf; sigdigits=4)) relerr=$(round(relerr; sigdigits=3))")
end
# ---------------------------------------------------------------------------
# 3. Heat conduction modal analysis: λ_k = (k π / L)² / (ρ c)
# ---------------------------------------------------------------------------
@testset "lowest_eigenpairs: 1D heat eigenvalues vs analytical spectrum" begin
println("\n" * "=" ^ 70)
println("lowest_eigenpairs — 1D heat: λ_k = (kπ/L)² / (ρ c)")
println("=" ^ 70)
nx = 30
L = 1.0
k_val = 1.0; ρcp_val = 1.0
mesh = _hex8_box(nx, 1, 1; Lx = L, Ly = 0.05, Lz = 0.05)
cache, asm, kernel, m = _setup_heat(mesh; k_value = k_val, ρcp = ρcp_val)
n = cache.ndofs
nodes = m.nodes
tol = 1e-9
# Fixed-fixed 1D boundary: T(x=0) = T(x=L) = 0 → eigenvalues
# k_val * (k π / L)² / (ρcp_val).
fixed = Int[]
for i in 1:length(nodes)
x = nodes[i][1]
if x < tol || x > L - tol
push!(fixed, i)
end
end
K, M = _assemble_KM(cache, asm, kernel, m)
Kr, Mr, _ = _reduce_KM(K, M, fixed)
λ_mf, _ = lowest_eigenpairs(Matrix(Kr), Matrix(Mr); nev = 5, tol = 1e-10,
maxiter = 300, p = 12)
# Analytical: λ_k = k * (kπ/L)² / (ρ c)
λ_anal = [k_val * (kk * π / L)^2 / ρcp_val for kk in 1:5]
# On a 1D mesh of nx Hex8s with thin cross-section, the FEM
# eigenvalues for nx >= 30 should match the 1D analytical
# spectrum to a couple of percent (FEM has positive bias for the
# higher modes due to mass-lumping-of-the-ends effects).
relerr = abs.(λ_mf - λ_anal) ./ λ_anal
@test maximum(relerr[1:3]) < 0.05 # first three modes within 5%
@test maximum(relerr) < 0.20 # all five within 20% on coarse mesh
println(" λ_anal = $(round.(λ_anal; sigdigits=4))")
println(" λ_mf = $(round.(λ_mf; sigdigits=4))")
println(" rel = $(round.(relerr; sigdigits=3))")
end
# ---------------------------------------------------------------------------
# 4. solve_eigenproblem high-level wrapper smoke
# ---------------------------------------------------------------------------
@testset "solve_eigenproblem: high-level wrapper (shifted free-free heat)" begin
println("\n" * "=" ^ 70)
println("solve_eigenproblem — high-level wrapper smoke (with shift)")
println("=" ^ 70)
# Free-free heat has a 1-D constant null space (λ = 0). The
# unshifted `K` is therefore singular and the inner CG cannot
# invert it. The `shift = σ` keyword adds `σ M` to `K` internally,
# making it SPD; the wrapper subtracts `σ` from the returned
# eigenvalues so the user sees the original spectrum.
nx = 6
mesh = _hex8_box(nx, 1, 1; Lx = 1.0, Ly = 0.1, Lz = 0.1)
cache, asm, kernel, m = _setup_heat(mesh; k_value = 1.0, ρcp = 1.0)
n = cache.ndofs
σ = 1.0
λ_mf, V_mf = solve_eigenproblem(cache, asm, kernel, m;
nev = 3, tol = 1e-9, maxiter = 200,
p = 8, shift = σ)
# Reference via dense generalized eigen.
K, M = _assemble_KM(cache, asm, kernel, m)
λ_ref = sort(real.(eigvals(Matrix(K), Matrix(M))))[1:3]
relerr = maximum(abs.(λ_mf - λ_ref) ./ max.(abs.(λ_ref), 1e-9))
@test relerr < 1e-3
# Lowest eigenvalue is the constant-mode null space ≈ 0.
@test abs(λ_mf[1]) < 1e-6 * max(maximum(abs, λ_mf), 1.0)
println(" free-free heat n=$n σ=$σ λ_mf=$(round.(λ_mf; sigdigits=4)) " *
"relerr=$(round(relerr; sigdigits=3))")
end