test(dofs): add multifield DOF handler regression

This commit is contained in:
Jukka Aho
2026-05-09 18:38:14 +03:00
parent 6a658a9c9b
commit 1b0e10e8f4
+269
View File
@@ -0,0 +1,269 @@
# This file is a part of JuliaFEM.
# License is MIT: see https://github.com/JuliaFEM/JuliaFEM.jl/blob/master/LICENSE.md
"""
End-to-end multi-field tests for the new DOF system.
Exercises the path
@DOFSet → Element{K, P, S, N} → local_dof_layout → DOFHandler → create_elements!
→ DOFConnectivity → field_dof_range / element_dofs
for two representative coupled DOF specifications:
1. Thermo-mechanical `(T::DOF{Temperature, Vertex}, u::DOF{Displacement{3}, Vertex})`
2. Mixed u-p (Stokes) `(u::DOF{Displacement{3}, Vertex}, p::DOF{Float64, Cell})`
Both verify:
- compile-time DOF count matches `local_dof_layout` length,
- block-ordering of DOFs (all field-1 DOFs first, then field-2),
- per-entity DOF starts in the handler,
- per-element global DOF indices match handler's `field_starts`,
- `field_dof_range(elem, :name)` and `element_dofs(elem, :name)` agree
with `local_dof_layout`,
- `DOFConnectivity` is consistent with the element's DOF list.
"""
using Test
using JuliaFEM
using JuliaFEM: DOFLayoutEntry, local_dof_layout,
field_idx, entity_local, component
using JuliaFEM: DOFHandler, create_elements!, @DOFSet, DOF,
Displacement, Temperature, Vertex, Cell
using JuliaFEM: DOFConnectivity, elem_id, local_dof_idx
using Tensors
# ----------------------------------------------------------------------------
# Tiny meshes used by both testsets
# ----------------------------------------------------------------------------
"Single Tet4 with arbitrary geometry (geometry irrelevant for DOF tests)."
function _tet4_mesh()
nodes = Vec{3,Float64}[
Vec{3}((0.0, 0.0, 0.0)),
Vec{3}((1.0, 0.0, 0.0)),
Vec{3}((0.0, 1.0, 0.0)),
Vec{3}((0.0, 0.0, 1.0)),
]
conn = [(UInt32(1), UInt32(2), UInt32(3), UInt32(4))]
return Mesh{Tetrahedron{4}}(nodes, conn)
end
"Two Tet4s sharing the face (2,3,4) so node-shared DOFs are exercised."
function _two_tet4_mesh()
nodes = Vec{3,Float64}[
Vec{3}((0.0, 0.0, 0.0)), # node 1 — only in elem 1
Vec{3}((1.0, 0.0, 0.0)), # node 2 — shared
Vec{3}((0.0, 1.0, 0.0)), # node 3 — shared
Vec{3}((0.0, 0.0, 1.0)), # node 4 — shared
Vec{3}((1.0, 1.0, 1.0)), # node 5 — only in elem 2
]
conn = [
(UInt32(1), UInt32(2), UInt32(3), UInt32(4)),
(UInt32(2), UInt32(3), UInt32(4), UInt32(5)),
]
return Mesh{Tetrahedron{4}}(nodes, conn)
end
# ============================================================================
# Test 1 — Thermo-mechanical: (T::Vertex, u::Vertex)
# ============================================================================
@testset "Multi-field DOFs: thermo-mechanical (T+u, both Vertex)" begin
mesh = _two_tet4_mesh()
n_nodes = length(mesh.nodes) # 5
n_elems = length(mesh.connectivity) # 2
S = @DOFSet{T::DOF{Temperature, Vertex},
u::DOF{Displacement{3}, Vertex}}
ET = Element{Tetrahedron{4}, Lagrange{1}, S}
elements, handler = create_elements!(mesh, ET)
ET_concrete = eltype(elements)
# ----------------------------------------------------------------------
# 1.1 Element template: ndofs and local_dof_layout
# ----------------------------------------------------------------------
@test n_element_dofs(elements[1]) == 16 # 4*1 + 4*3
@test ndofs(Tetrahedron{4}, S) == 16
layout = local_dof_layout(ET_concrete)
@test layout isa NTuple{16, DOFLayoutEntry}
# First 4 entries: field T (idx 1), one component, vertices 1..4
for k in 1:4
e = layout[k]
@test field_idx(e) == 1
@test entity_local(e) == k
@test component(e) == 1
end
# Remaining 12 entries: field u (idx 2), 3 components per vertex 1..4
for k in 1:4, c in 1:3
e = layout[4 + 3*(k-1) + c]
@test field_idx(e) == 2
@test entity_local(e) == k
@test component(e) == c
end
# ----------------------------------------------------------------------
# 1.2 Handler block ordering: total = nT + nu, field_starts contiguous
# ----------------------------------------------------------------------
nT = n_nodes * 1
nU = n_nodes * 3
@test handler.total_dofs == nT + nU
@test length(handler.field_starts) == 2
starts_T = handler.field_starts[1]
starts_U = handler.field_starts[2]
@test length(starts_T) == n_nodes
@test length(starts_U) == n_nodes
# Field T occupies DOFs 1..nT, one per node
@test starts_T == collect(1:nT)
# Field u immediately follows, three per node
@test starts_U == collect((nT + 1):3:(nT + nU))
# ----------------------------------------------------------------------
# 1.3 Per-element DOFs match handler starts
# ----------------------------------------------------------------------
for (eid, elem) in enumerate(elements)
conn = mesh.connectivity[eid]
dofs = element_dofs(elem)
@test length(dofs) == 16
# First 4 = field T at the four nodes of this element
for k in 1:4
@test Int(dofs[k]) == starts_T[Int(conn[k])]
end
# Next 12 = field u, three components per node
for k in 1:4, c in 1:3
@test Int(dofs[4 + 3*(k-1) + c]) == starts_U[Int(conn[k])] + (c - 1)
end
end
# ----------------------------------------------------------------------
# 1.4 field_dof_range / element_dofs(elem, :field)
# ----------------------------------------------------------------------
elem1 = elements[1]
@test field_dof_range(elem1, :T) == 1:4
@test field_dof_range(elem1, :u) == 5:16
@test element_dofs(elem1, :T) == elem1.dof_indices[1:4]
@test element_dofs(elem1, :u) == elem1.dof_indices[5:16]
# ----------------------------------------------------------------------
# 1.5 Type stability of local_dof_layout
# ----------------------------------------------------------------------
GC.gc()
@test (@allocated local_dof_layout(ET_concrete)) == 0
# ----------------------------------------------------------------------
# 1.6 DOFConnectivity round-trips through the element's flat DOF list
# ----------------------------------------------------------------------
@test handler.dof_connectivity isa DOFConnectivity
dc = handler.dof_connectivity
@test dc.n_total_dofs == handler.total_dofs
for (eid, elem) in enumerate(elements)
for (li, gdof) in enumerate(elem.dof_indices)
conns = dc.dof_to_elements[Int(gdof)]
# The element must appear with the matching local index
hit = any(c -> elem_id(c) == eid && local_dof_idx(c) == li, conns)
@test hit
end
end
end
# ============================================================================
# Test 2 — Mixed u-p: (u::Vertex, p::Cell), Stokes/incompressible flavour
# ============================================================================
@testset "Multi-field DOFs: mixed u-p (Vertex + Cell)" begin
mesh = _two_tet4_mesh()
n_nodes = length(mesh.nodes) # 5
n_elems = length(mesh.connectivity) # 2
S = @DOFSet{u::DOF{Displacement{3}, Vertex},
p::DOF{Float64, Cell}}
ET = Element{Tetrahedron{4}, Lagrange{1}, S}
elements, handler = create_elements!(mesh, ET)
ET_concrete = eltype(elements)
# 13 = 4*3 (u at vertices) + 1*1 (p at cell)
@test n_element_dofs(elements[1]) == 13
@test ndofs(Tetrahedron{4}, S) == 13
# ----------------------------------------------------------------------
# 2.1 Layout: 12 u entries (field 1, vertices 1..4, comps 1..3),
# then 1 p entry (field 2, entity_local=1, comp=1).
# ----------------------------------------------------------------------
layout = local_dof_layout(ET_concrete)
@test layout isa NTuple{13, DOFLayoutEntry}
for k in 1:4, c in 1:3
e = layout[3*(k-1) + c]
@test field_idx(e) == 1
@test entity_local(e) == k
@test component(e) == c
end
p_entry = layout[13]
@test field_idx(p_entry) == 2
@test entity_local(p_entry) == 1
@test component(p_entry) == 1
# ----------------------------------------------------------------------
# 2.2 Handler block ordering: u block first (15 dofs), then p (2 dofs)
# ----------------------------------------------------------------------
nU = n_nodes * 3 # 15
nP = n_elems * 1 # 2
@test handler.total_dofs == nU + nP
starts_U = handler.field_starts[1]
starts_P = handler.field_starts[2]
@test length(starts_U) == n_nodes
@test length(starts_P) == n_elems
@test starts_U == collect(1:3:nU)
@test starts_P == collect((nU + 1):(nU + nP))
# ----------------------------------------------------------------------
# 2.3 Per-element DOFs: u dofs come from per-vertex starts, p dof comes
# from per-element start.
# ----------------------------------------------------------------------
for (eid, elem) in enumerate(elements)
conn = mesh.connectivity[eid]
dofs = element_dofs(elem)
for k in 1:4, c in 1:3
@test Int(dofs[3*(k-1) + c]) == starts_U[Int(conn[k])] + (c - 1)
end
@test Int(dofs[13]) == starts_P[eid]
end
# ----------------------------------------------------------------------
# 2.4 field_dof_range / element_dofs(elem, :field) for mixed entities
# ----------------------------------------------------------------------
elem1 = elements[1]
@test field_dof_range(elem1, :u) == 1:12
@test field_dof_range(elem1, :p) == 13:13
@test element_dofs(elem1, :u) == elem1.dof_indices[1:12]
@test element_dofs(elem1, :p) == elem1.dof_indices[13:13]
# ----------------------------------------------------------------------
# 2.5 The two cell DOFs are *not* shared — even though the elements
# share three vertices.
# ----------------------------------------------------------------------
p_dof_e1 = Int(elements[1].dof_indices[13])
p_dof_e2 = Int(elements[2].dof_indices[13])
@test p_dof_e1 != p_dof_e2
# The shared u DOFs *are* shared — verify a few node-shared components
shared_nodes = (2, 3, 4)
for ns in shared_nodes
# u-x DOF at node `ns` for both elements must coincide
i_in_e1 = findfirst(==(UInt32(ns)), mesh.connectivity[1])
i_in_e2 = findfirst(==(UInt32(ns)), mesh.connectivity[2])
@test elements[1].dof_indices[3*(i_in_e1 - 1) + 1] ==
elements[2].dof_indices[3*(i_in_e2 - 1) + 1]
end
# ----------------------------------------------------------------------
# 2.6 Type stability + zero-allocation lookup
# ----------------------------------------------------------------------
GC.gc()
@test (@allocated local_dof_layout(ET_concrete)) == 0
end