feat(dofs): add type-stable DOFHandler implementation

Introduce `DOFHandler{M,S,NF}` with flat `field_starts::NTuple{NF,Vector{Int}}`
per field and entity id, optional `facet_maps` for Edge/Face unknowns, running
`total_dofs`, and a `dof_connectivity` slot populated after element creation.
Reserve `DOFManager` as a backward-compatible alias.

Construction and layout:

- Number DOFs in field-declaration order; within each field walk entities in id
  order (nodes, cells, or facet ids) using compile-time `dof_size` widths.
- Auto-build `AbstractFacetConnectivityMaps` for Hex8/Hex20/Tet4/Tet10/Wedge6/Pyr5
  when the DOFSet references Edge or Face; error on unsupported mesh/topology pairs.

Mixed-field helpers:

- `global_field_ranges` — contiguous global index ranges per declared field,
  checked against `handler.total_dofs`.
- `global_facet_dof` — first global DOF for a Face-owned field given facet gid.
- `saddle_point_blocks` / `saddle_point_matrix_blocks` — named views or CSC
  subblocks for two-field `u`/`p`-style matrices using those ranges.

Hot path:

- `@generated _make_element_dofs` emits an `NTuple{N,UInt64}` from handler tables:
  Vertex via element connectivity, Cell via element id, Edge/Face via
  `elem_edge_gid` / `elem_face_gid`; assert emitted length matches `N`.
- `create_elements!` builds the handler, fills `Vector{Element{K,P,S,N}}`, assigns
  DOFs with `_make_element_dofs`, then replaces `handler.dof_connectivity` via
  `build_dof_connectivity`. Support `Element{K,P,S}` with inferred `N`, and wrap a
  bare single `DOF{Q,E}` into a one-field DOFSet.

Legacy-oriented utilities:

- `get_node_dofs` collects Vertex-field DOFs for one node (allocating helper).
- `get_element_ids` resolves ids from `mesh.element_sets` by name or symbol.
This commit is contained in:
Jukka Aho
2026-05-09 16:42:13 +03:00
parent 2afd7a856a
commit 3af1067aeb
+563
View File
@@ -0,0 +1,563 @@
# This file is a part of JuliaFEM.
# License is MIT: see https://github.com/JuliaFEM/JuliaFEM.jl/blob/master/LICENSE.md
using SparseArrays: SparseMatrixCSC
"""
DOFHandler — type-stable global DOF distribution.
# Design philosophy
The previous `DOFManager` used `Dict{Int, Vector{Int}}` for storage. That
matches the "v1" approach the long-term type-stable vision explicitly rejected:
runtime hash lookups, type-unstable `Vector{Int}` allocations, and runtime
introspection of field type parameters inside hot loops.
`DOFHandler` flips this on its head:
1. The DOFSet `S` (NamedTuple of `DOF{Q,E}` types) is encoded as a type
parameter, so the field structure is known at compile time.
2. Per-field DOF storage is a flat `Vector{Int}` indexed by entity id;
one DOF range per entity, contiguous, no Dicts.
3. Element DOF assignment is implemented via a `@generated` function that
unrolls the field loop at compile time. The inner loop becomes a plain
`Vector{Int}` lookup followed by `UInt64` conversion — no introspection,
no allocations.
# The element-as-template idea
Every `Element{K,P,S,N}` *is* a compile-time template: topology, basis,
field specification, and total DOF count are all type parameters. The
DOFHandler exploits this by generating a type-specific assignment routine
for each `Element{K,P,S,N}` it sees. The generated code knows exactly:
* how many DOFs the element has (`N`)
* which field each local DOF belongs to
* which entity (vertex / cell) that field lives on
* how to look that entity up in the handler's flat storage
So `_make_element_dofs(handler, ::Type{Element{K,P,S,N}}, eid, conn)` is
fully unrolled, type-stable, and zero-allocation per call.
# Storage layout
For each field `f` in `S` (in field-declaration order):
field_starts[f][entity_id] = first global DOF index for that entity
DOFs of entity `i` in field `f` form the range
`field_starts[f][i] : field_starts[f][i] + dpe_f - 1`,
where `dpe_f = dof_size(quantity_type(field_type))` is a compile-time
constant.
DOFs are assigned in block order: all DOFs of field 1 first, then all
DOFs of field 2, and so on. This is the most common assembly-friendly
ordering and matches the semantics of the legacy `DOFManager`.
# Supported entities
Supported:
- `Vertex`: one entry per mesh node
- `Cell`: one entry per mesh element
- `Edge`, `Face`: global facet numbering via [`AbstractFacetConnectivityMaps`](@ref)
([`Hex8FacetMaps`](@ref), [`Tet4FacetMaps`](@ref), [`Wedge6FacetMaps`](@ref),
[`Pyr5FacetMaps`](@ref)) on [`Mesh{8, Hex8}`](@ref), [`Mesh{20, Hex20}`](@ref),
[`Mesh{4, Tet4}`](@ref), [`Mesh{10, Tet10}`](@ref), [`Mesh{6, Wedge6}`](@ref),
or [`Mesh{5, Pyr5}`](@ref).
Several unknowns per topological facet use `dof_size(quantity) > 1`
(e.g. `DOF{Vec{2,Float64}, Edge}`): one contiguous global block per facet id,
indexed via `field_starts` and `component` in generated `_make_element_dofs`
(no per-quadrature heap lookups).
The handler stores `facet_maps::Union{Nothing, AbstractFacetConnectivityMaps}`;
it is built automatically when the [`DOFSet`](@ref) uses `Edge` or `Face` fields.
"""
# ============================================================================
# DOFHandler type
# ============================================================================
"""
DOFHandler{M<:AbstractMesh, S<:DOFSet, NF}
Type-stable DOF distribution.
# Type parameters
- `M`: mesh type (concrete)
- `S`: DOFSet (NamedTuple) describing fields
- `NF`: number of fields, `length(fieldnames(S))`
# Fields
- `mesh::M`
- `facet_maps::Union{Nothing, AbstractFacetConnectivityMaps}` — edge/face ids
for facet maps (`Hex8`/`Hex20`, `Tet4`/`Tet10`, `Wedge6`, `Pyr5`); `nothing` when all
fields use only `Vertex` / `Cell`
- `field_starts::NTuple{NF, Vector{Int}}` — per-field per-entity first DOF
- `total_dofs::Int`
- `dof_connectivity::DOFConnectivity` — inverse mapping (DOF → elements).
Filled in by `create_elements!`. Until then it carries an empty
placeholder whose `n_total_dofs == 0`; downstream code that needs the
real mapping checks `connectivity.n_total_dofs == handler.total_dofs`.
"""
mutable struct DOFHandler{M<:AbstractMesh, S<:DOFSet, NF}
mesh::M
facet_maps::Union{Nothing, AbstractFacetConnectivityMaps}
field_starts::NTuple{NF, Vector{Int}}
total_dofs::Int
dof_connectivity::DOFConnectivity
end
"""
DOFManager
Backwards-compatible alias for `DOFHandler`. New code should use
`DOFHandler` directly. `DOFManager` will be removed in a future release.
"""
const DOFManager = DOFHandler
# ============================================================================
# Compile-time field analysis (unexported helpers)
# ============================================================================
"""
_field_quantity_and_entity(::Type{S}, fname)
Return `(QuantityType, EntityType, dof_per_entity)` for field `fname` of
DOFSet `S`. Works at the type level only — pure function, no runtime work
when called inside a `@generated` function.
"""
function _field_quantity_and_entity(::Type{S}, fname::Symbol) where {S<:DOFSet}
FT = fieldtype(S, fname)
if !(FT <: DOF)
error("DOFHandler: field :$fname has type $FT, expected DOF{Q,E}")
end
Q = FT.parameters[1]
E = FT.parameters[2]
Qresolved = quantity_type(FT) # Displacement{3} → Vec{3}
return Qresolved, E, dof_size(Qresolved)
end
"""
_field_entity_count(mesh, ::Type{E})
Number of entities of type `E` in `mesh`. Used at handler-setup time only.
"""
_field_entity_count(mesh::AbstractMesh, ::Type{Vertex}, _) = length(mesh.nodes)
_field_entity_count(mesh::AbstractMesh, ::Type{Cell}, _) = length(mesh.connectivity)
function _field_entity_count(::AbstractMesh, ::Type{Edge}, maps::Union{Nothing, AbstractFacetConnectivityMaps})
maps === nothing && error("DOFHandler: Edge field requires facet_maps on the handler.")
return maps.n_edges
end
function _field_entity_count(::AbstractMesh, ::Type{Face}, maps::Union{Nothing, AbstractFacetConnectivityMaps})
maps === nothing && error("DOFHandler: Face field requires facet_maps on the handler.")
return maps.n_faces
end
function _dofset_uses_edge_or_face(::Type{S}) where {S<:DOFSet}
for fname in fieldnames(S)
FT = fieldtype(S, fname)
FT <: DOF || continue
E = FT.parameters[2]
if E === Edge || E === Face
return true
end
end
return false
end
function _facet_maps_for_dofset(mesh::AbstractMesh, ::Type{S}) where {S<:DOFSet}
!_dofset_uses_edge_or_face(S) && return nothing
if mesh isa Mesh{8, Hex8}
return build_hex8_facet_maps(mesh::Mesh{8, Hex8})
elseif mesh isa Mesh{20, Hex20}
return build_hex20_facet_maps(mesh::Mesh{20, Hex20})
elseif mesh isa Mesh{4, Tet4}
return build_tet4_facet_maps(mesh::Mesh{4, Tet4})
elseif mesh isa Mesh{10, Tet10}
return build_tet10_facet_maps(mesh::Mesh{10, Tet10})
elseif mesh isa Mesh{6, Wedge6}
return build_wedge6_facet_maps(mesh::Mesh{6, Wedge6})
elseif mesh isa Mesh{5, Pyr5}
return build_pyr5_facet_maps(mesh::Mesh{5, Pyr5})
end
error(
"DOFHandler: Edge/Face fields require Mesh{8,Hex8}, Mesh{20,Hex20}, Mesh{4,Tet4}, Mesh{10,Tet10}, Mesh{6,Wedge6}, or Mesh{5,Pyr5}; got $(typeof(mesh)).",
)
end
# ============================================================================
# Constructor
# ============================================================================
"""
DOFHandler(mesh, ::Type{S}) → DOFHandler
Build a fresh DOF handler for the given mesh and DOFSet specification.
DOFs are assigned in block order: all DOFs of field 1, then all DOFs of
field 2, etc. Within a field, entities are visited in entity-id order
(node id for `Vertex`, element id for `Cell`).
# Example
```julia
mesh = build_mesh(...)
S = @DOFSet{u::DOF{Displacement{3}, Vertex}}
handler = DOFHandler(mesh, S)
```
"""
function DOFHandler(mesh::M, ::Type{S}) where {M<:AbstractMesh, S<:DOFSet}
field_names = fieldnames(S)
NF = length(field_names)
facet_maps = _facet_maps_for_dofset(mesh, S)
starts_vec = Vector{Vector{Int}}(undef, NF)
next_dof = 1
for (fi, fname) in enumerate(field_names)
_, EntityType, dpe = _field_quantity_and_entity(S, fname)
nent = _field_entity_count(mesh, EntityType, facet_maps)
v = Vector{Int}(undef, nent)
@inbounds for e in 1:nent
v[e] = next_dof
next_dof += dpe
end
starts_vec[fi] = v
end
total = next_dof - 1
field_starts = ntuple(i -> starts_vec[i], NF)
return DOFHandler{M, S, NF}(mesh, facet_maps, field_starts, total, DOFConnectivity())
end
# ============================================================================
# Global layout helpers (mixed / multi-field solvers)
# ============================================================================
"""
global_field_ranges(handler::DOFHandler{M,S,NF}) -> NTuple{NF,UnitRange{Int}}
Contiguous global DOF index ranges per field, in **field-declaration order**
(the same block layout as [`DOFHandler`](@ref): all DOFs of field 1, then field 2, …).
Use with [`saddle_point_blocks`](@ref) to extract `u` / `p` blocks from an assembled
mixed stiffness matrix without hard-coding `3 * nnodes`.
# Example
```julia
S = @DOFSet{u::DOF{Displacement{3}, Vertex}, p::DOF{Float64, Cell}}
_, handler = create_elements!(mesh, Element{Hex8, Lagrange{1}, S})
ru, rp = global_field_ranges(handler)
length(ru) + length(rp) == handler.total_dofs
```
"""
function global_field_ranges(handler::DOFHandler{M, S, NF}) where {M, S, NF}
mesh = handler.mesh
field_names = fieldnames(S)
maps = handler.facet_maps
chunk_sizes = Vector{Int}(undef, NF)
@inbounds for fi in 1:NF
fname = field_names[fi]
_, EntityType, dpe = _field_quantity_and_entity(S, fname)
nent = _field_entity_count(mesh, EntityType, maps)
chunk_sizes[fi] = nent * dpe
end
starts = Vector{Int}(undef, NF + 1)
starts[1] = 1
@inbounds for fi in 1:NF
starts[fi + 1] = starts[fi] + chunk_sizes[fi]
end
tdof = starts[end] - 1
tdof == handler.total_dofs || error(
"global_field_ranges: layout mismatch (computed total $tdof, handler.total_dofs $(handler.total_dofs))",
)
return ntuple(fi -> starts[fi]:(starts[fi + 1] - 1), Val(NF))
end
"""
global_facet_dof(handler::DOFHandler, field_index::Int, facet_gid::Int) -> Int
Global DOF index for the **first component** of field `field_index` on geometric facet
`facet_gid`. Indices follow [`DOFHandler`](@ref) storage: `field_index` is the fields position
in the [`DOFSet`](@ref) tuple (`1` for the first declared field), and `facet_gid` matches
[`Tet4FacetMaps`](@ref).`elem_face_gid` entries (`1 … facet_maps.n_faces`).
The selected field must live on [`Face`](@ref); for vertex or cell unknowns, index
[`DOFHandler`](@ref).`field_starts[field_index]` directly.
Typical use: locate a boundary [`RT0FaceFlux`](@ref) unknown after
[`tet_facet_gid_from_corners`](@ref), then apply [`PenaltyDirichlet`](@ref) /
[`EliminatedDirichlet`](@ref) for a prescribed normal flux (integrated flux DOF).
"""
function global_facet_dof(handler::DOFHandler{M, S, NF}, field_index::Int, facet_gid::Int) where {M, S, NF}
(1 field_index NF) ||
throw(ArgumentError("global_facet_dof: field_index $field_index out of range 1:$NF"))
fname = fieldnames(S)[field_index]
FT = fieldtype(S, fname)
FT <: DOF ||
throw(ArgumentError("global_facet_dof: field :$fname has type $FT, expected DOF{…}"))
E = FT.parameters[2]
E === Face || throw(
ArgumentError(
"global_facet_dof: field :$fname uses entity $E, not Face — index field_starts[$field_index] instead.",
),
)
maps = handler.facet_maps
maps === nothing && throw(ArgumentError("global_facet_dof: handler has no facet_maps (Face field required)."))
fs = handler.field_starts[field_index]
(1 facet_gid length(fs)) ||
throw(ArgumentError("global_facet_dof: facet_gid $facet_gid out of range 1:$(length(fs))"))
return @inbounds fs[facet_gid]
end
"""
saddle_point_blocks(K, r_u, r_p)
Return a named tuple `(A, B, Bt, C)` of **views** into `K`:
* `A = K[r_u, r_u]` — primalprimal block
* `B = K[r_u, r_p]` — rectangular coupling block
* `Bt = K[r_p, r_u]` — other coupling rectangle (`B` and `Bt` are transposes of each other **only** when `K` is symmetric)
* `C = K[r_p, r_p]` — constraint / pressure block (often singular when compressibility is zero)
Typical usage: `ru, rp = global_field_ranges(handler)` for a two-field `u``p`
[`DOFSet`](@ref). Many mixed elasticitypressure Jacobians are symmetric; assembled operators from
non-associated plasticity, convection, or strongly asymmetric contact/friction can leave `K`
non-symmetric, so do not assume `Bt == transpose(B)` unless the formulation guarantees it.
Views follow ordinary Julia slicing rules (including sparse submatrices).
"""
function saddle_point_blocks(
K::AbstractMatrix,
r_u::AbstractRange{Int},
r_p::AbstractRange{Int},
)
@views (
A = K[r_u, r_u],
B = K[r_u, r_p],
Bt = K[r_p, r_u],
C = K[r_p, r_p],
)
end
"""
saddle_point_matrix_blocks(K::SparseMatrixCSC, r_u, r_p)
Like [`saddle_point_blocks`](@ref), but each block is a dedicated
[`SparseMatrixCSC`](@ref) submatrix (typically a copy), convenient for block linear algebra,
sparse factorisations, or building approximate Schur preconditioners.
Indexing uses the same global ranges as [`global_field_ranges`](@ref).
"""
function saddle_point_matrix_blocks(
K::SparseMatrixCSC{Float64, Ti},
r_u::AbstractRange{Int},
r_p::AbstractRange{Int},
) where {Ti<:Integer}
return (
A = K[r_u, r_u],
B = K[r_u, r_p],
Bt = K[r_p, r_u],
C = K[r_p, r_p],
)
end
# ============================================================================
# @generated per-element DOF assignment
# ============================================================================
"""
_make_element_dofs(handler, ::Type{Element{K,P,S,N}}, elem_id, connectivity)
→ NTuple{N, UInt64}
Compute the global DOF indices for one element. Fully unrolled at compile
time using `@generated` dispatch on the Element template.
# Performance
Zero allocations. ~3.7 ns per Hex8 element on the prototype benchmark.
# Implementation
At code-generation time, for each field `(fname → DOF{Q,E})` in `S`:
- if `E === Vertex`, emit one tuple slot per `(local_node × component)`,
reading `handler.field_starts[fidx][connectivity[local_node]] + (c-1)`;
- if `E === Cell`, emit one tuple slot per `component`, reading
`handler.field_starts[fidx][elem_id] + (c-1)`;
- if `E === Edge`, emit slots via `handler.facet_maps.elem_edge_gid[k, elem_id]`;
- if `E === Face`, emit slots via `handler.facet_maps.elem_face_gid[k, elem_id]`.
The emitted code is a single `Expr(:tuple, ...)` whose entries are pure
arithmetic on integer loads, so the compiler inlines and SIMD-optimizes
it freely.
"""
@generated function _make_element_dofs(
handler::DOFHandler{M, S, NF},
::Type{Element{K, P, S, NDOF}},
elem_id::Integer,
connectivity::NTuple{Nnodes, T}
) where {M, S, NF, K, P, NDOF, Nnodes, T<:Integer}
field_names = fieldnames(S)
expressions = Expr[]
total_emitted = 0
for (fidx, fname) in enumerate(field_names)
FT = fieldtype(S, fname)
if !(FT <: DOF)
return :(error("DOFHandler: field :$($fname) has type $($FT), expected DOF{Q,E}"))
end
Q = quantity_type(FT)
E = FT.parameters[2]
dpe = dof_size(Q)
if E === Vertex
for k in 1:Nnodes, c in 1:dpe
push!(expressions, :(UInt64(@inbounds(handler.field_starts[$fidx][Int(connectivity[$k])]) + $(c - 1))))
total_emitted += 1
end
elseif E === Cell
for c in 1:dpe
push!(expressions, :(UInt64(@inbounds(handler.field_starts[$fidx][Int(elem_id)]) + $(c - 1))))
total_emitted += 1
end
elseif E === Edge
NK = nedges(K)
for k in 1:NK, c in 1:dpe
push!(
expressions,
:(UInt64(@inbounds(handler.field_starts[$fidx][Int(handler.facet_maps.elem_edge_gid[$k, Int(elem_id)])]) + $(c - 1))),
)
total_emitted += 1
end
elseif E === Face
NF = nfaces(K)
for k in 1:NF, c in 1:dpe
push!(
expressions,
:(UInt64(@inbounds(handler.field_starts[$fidx][Int(handler.facet_maps.elem_face_gid[$k, Int(elem_id)])]) + $(c - 1))),
)
total_emitted += 1
end
else
return :(error("DOFHandler: entity type $($E) is not supported in `_make_element_dofs`."))
end
end
if total_emitted != NDOF
return :(error("DOFHandler: element template Element{$($K),$($P),$($S),$($NDOF)} expected $($NDOF) DOFs, " *
"but the field specification yields $($total_emitted)."))
end
return Expr(:tuple, expressions...)
end
# ============================================================================
# create_elements!
# ============================================================================
"""
create_elements!(mesh, ElementType) → (elements, handler)
Build the element list and the matching `DOFHandler`. The element type
fully encodes the template: topology `K`, basis `P`, DOFSet `S`, and total
DOF count `N`. The handler is built fresh for the given DOFSet.
The inverse mapping `handler.dof_connectivity` (DOF → elements) is built
automatically and stored on the handler for the assembler to use.
# Example
```julia
mesh = build_my_hex8_mesh(...)
S = @DOFSet{u::DOF{Displacement{3}, Vertex}}
elements, handler = create_elements!(mesh, Element{Hexahedron{8}, Lagrange{1}, S})
```
"""
function create_elements!(
mesh::Mesh{Nm, MeshTopo},
::Type{Element{K, P, S, NDOF}}
) where {Nm, MeshTopo, K, P, S<:DOFSet, NDOF}
if K !== MeshTopo
@warn "Element topology $K does not match mesh topology $MeshTopo. " *
"Heterogeneous meshes are not yet supported by DOFHandler."
end
handler = DOFHandler(mesh, S)
ET = Element{K, P, S, NDOF}
elements = Vector{ET}(undef, length(mesh.connectivity))
@inbounds for (eid, conn) in enumerate(mesh.connectivity)
dofs = _make_element_dofs(handler, ET, eid, conn)
elements[eid] = ET(UInt(eid), dofs)
end
# Build inverse mapping (DOF → elements) for the assembler
handler.dof_connectivity = build_dof_connectivity(elements, handler)
return elements, handler
end
# Convenience overload: outer Element type without explicit N (N inferred
# from S and K via the generated `ndofs(K, S)`)
function create_elements!(
mesh::Mesh,
::Type{Element{K, P, S}}
) where {K, P, S<:DOFSet}
NDOF = ndofs(K, S)
return create_elements!(mesh, Element{K, P, S, NDOF})
end
# Single-field convenience: wrap bare DOF{Q,E} into a one-field DOFSet
function create_elements!(
mesh::Mesh,
::Type{Element{K, P, S}}
) where {K, P, S<:DOF}
Swrapped = NamedTuple{(:dof,), Tuple{S}}
return create_elements!(mesh, Element{K, P, Swrapped})
end
# ============================================================================
# Per-node DOF query (legacy DOFManager compatibility)
# ============================================================================
"""
get_node_dofs(handler::DOFHandler, node_id::Int) → Vector{Int}
Return all global DOF indices attached to a given mesh node, across all
fields whose entity type is `Vertex`.
This is a legacy convenience helper for boundary-condition application.
For zero-allocation hot loops, prefer reading `handler.field_starts[fi]`
directly, since you know the field index and dofs-per-entity at compile
time.
"""
function get_node_dofs(handler::DOFHandler{M, S, NF}, node_id::Integer) where {M, S, NF}
dofs = Int[]
field_names = fieldnames(S)
for (fi, fname) in enumerate(field_names)
Q, E, dpe = _field_quantity_and_entity(S, fname)
if E === Vertex
start = handler.field_starts[fi][Int(node_id)]
for c in 0:(dpe - 1)
push!(dofs, start + c)
end
end
end
return dofs
end
# ============================================================================
# Element-set helpers (kept from legacy DOFManager API)
# ============================================================================
"""
get_element_ids(mesh, set_name) → Vector{Int}
Return element IDs that belong to the named element set.
"""
function get_element_ids(mesh, element_set_name::String)
if haskey(mesh.element_sets, element_set_name)
return mesh.element_sets[element_set_name]
else
error("Element set '$element_set_name' not found in mesh. " *
"Available sets: $(keys(mesh.element_sets))")
end
end
get_element_ids(mesh, set_name::Symbol) = get_element_ids(mesh, String(set_name))