Module: src/fem/kernel Version: 1.0 Date: 2026-01-20 Purpose: Comprehensive user guide for BELFEM's FEM Kernel and DOF management system
Revision History:
- v1.0 (2026-01-20) - Initial comprehensive guide
- Documented then-current bug in number_of_hanging_dofs()
- Added Safe Defaults Quick Start
- Added Glossary
- v1.1 (2026-08-14) - The number_of_hanging_dofs() bug has been fixed in the source; all "CONFIRMED BUG" sections rewritten as historical notes
- Added Ownership and Lifetime section
- Comprehensive JEDI system documentation
- MPI distribution patterns
- Calculator usage patterns
- v1.2 (2026-08-28) - Sections 19.7 and 20.3-20.7 rewritten against the current thin-shell code
- The per-material h_ts_* kernel family documented here no longer exists; kernel selection is by solver algorithm, and the thin-shell specifics live in the calculator::MaxwellData constructor dispatch
- The normal-field listing showed an unprojected master/slave average; the live compute_hn projects onto the facet normal
- Anisotropy section rewritten around bn_angle (unfolded to [0, pi]), with the Jc extrema corrected and bn_angle separated from bj_angle
Table of Contents
- Common Pitfalls (Read This First!)
- Safe Defaults Quick Start
- Glossary
- Overview and Architecture
- The Four Pillars of FEM Kernel
- DOF Allocation and Distribution
- The JEDI Block Matrix System
- MPI Parallel Distribution
- Calculator and Element-Level Assembly
- Boundary Conditions
- Hanging Nodes and T-Matrices
- Postprocessing (L2 Projection)
- Ownership and Lifetime
- Performance Considerations
- Thread Safety and MPI
- Known Issues and Bugs
- Development Notes
- Literature References
- H-Phi Mixed Formulation (Electromagnetics)
- Thin Shell Formulation
- Maxwell-Specific Implementation
1. Common Pitfalls (Read This First!)
⚠️ Critical bugs and common mistakes to avoid:
1.1 FIXED: number_of_hanging_dofs() copy-paste accessor (historical)
An earlier revision of cl_FEM_DofMgr_DofData.hpp returned mNumberOfFixedDofs from number_of_hanging_dofs(). The accessor is fixed — it returns mNumberOfHangingDofs — so both number_of_hanging_dofs() and my_number_of_hanging_dofs() are safe to use. The entry is kept only as a reminder that the count accessors in this header are copy-paste-prone; when adding one, check the returned member matches the name.
1.2 Forgetting to Call initialize()
The Mistake:
DofManager* field = kernel.create_field(iwg);
field->sideset(1)->impose_dirichlet(300.0);
field->compute_jacobian_and_rhs();
Correct Pattern:
DofManager* field = kernel.create_field(iwg);
field->sideset(1)->impose_dirichlet(300.0);
field->initialize();
field->compute_jacobian_and_rhs();
Why This Happens:
- initialize() orchestrates DOF creation, graph extraction, matrix allocation
- Without it, all internal pointers are nullptr
- Assembly functions will dereference null pointers → segfault
Detection:
BELFEM_ERROR(field->solver() !=
nullptr,
"Call initialize() first!");
#define BELFEM_ERROR(aCheck,...)
Definition assert.hpp:264
1.3 Accumulating Assembly Without Reset
The Mistake:
field->compute_jacobian_and_rhs(false);
field->compute_jacobian_and_rhs(false);
Result:
- Matrix and RHS values are added to previous values
- System becomes 2× (or N×) the correct magnitude
- Solver produces garbage results
Correct Pattern:
field->compute_jacobian_and_rhs();
field->compute_jacobian_and_rhs(true);
When to Use aReset=false:
- Multi-block assembly where you want to accumulate contributions
- Custom assembly loops where you manually reset at the start
1.4 Accessing JEDI Matrices Before Initialization
The Mistake:
DofManager* field = kernel.create_field(iwg);
SpMatrix* J = field->system_matrix();
J->set_value(0, 0, 1.0);
Correct Pattern:
DofManager* field = kernel.create_field(iwg);
field->initialize();
SpMatrix* J = field->system_matrix();
#define BELFEM_ASSERT(aCheck,...)
Definition assert.hpp:244
All JEDI Matrices:
SpMatrix* A = field->system_matrix();
SpMatrix* D = field->dirichlet();
SpMatrix* E = field->enforcement();
SpMatrix* I = field->imposition();
All are nullptr until initialize() is called.
1.5 Modifying DOF Values During Assembly
The Mistake:
for (Element* elem : block->elements()) {
iwg->compute_jacobian(elem, K);
for (Dof* dof : elem->dofs()) {
dof->set_value(some_new_value);
}
solver_data->assemble_jacobian(elem, K);
}
Why This Is Wrong:
- Assembly reads DOF values (for nonlinear terms, material properties)
- Modifying values during assembly creates race conditions
- Results depend on element traversal order (non-deterministic)
Correct Pattern:
field->init_dof_values();
field->compute_jacobian_and_rhs();
field->solve();
for (Dof* dof : field->dofs()) {
real new_value = dof->value();
}
1.6 Not Synchronizing Fields in MPI
The Mistake:
field->solve();
mesh.field(
"T")->data()[node_index];
Correct Pattern:
field->solve();
field->synchronize_fields({"T", "q"});
mesh.field(
"T")->data()[node_index];
Field Synchronization Methods:
field->collect_fields({"T"});
field->distribute_fields({"T"});
field->synchronize_fields({"T"});
1.7 Deleting IWG Before DofManager
The Mistake:
IWG* iwg = factory.create_iwg(...);
DofManager* field = kernel.create_field(iwg);
delete iwg;
field->compute_jacobian_and_rhs();
Correct Pattern 1: Let Kernel Manage Lifetime
Kernel kernel(¶ms);
IWG* iwg = kernel.create_equation(...);
DofManager* field = kernel.create_field(iwg);
Correct Pattern 2: Manual Management
IWG* iwg = factory.create_iwg(...);
DofManager* field = new DofManager(&kernel, 0);
field->set_equation(iwg);
delete field;
delete iwg;
See: Section 13: Ownership and Lifetime
1.8 Mixing DOF ID and DOF Index
The Mistake:
id_t dof_id = dof_data->node_dof_id(node->id(), dof_type);
Vector<real> values(num_dofs);
values(dof_id) = 1.0;
Correct Pattern:
id_t dof_id = dof_data->node_dof_id(node->id(), dof_type);
Dof* dof = dof_data->dof(dof_id);
index_t dof_index = dof->index();
Vector<real> values(num_dofs);
values(dof_index) = 1.0;
Key Difference:
- ID: Global unique identifier, permanent, can be sparse
- Index: Reordered position in [0, num_dofs), dense, changes after reordering
1.9 Creating DofManager Inside Element Loops
The Mistake:
for (Element* elem : block->elements()) {
DofManager* field = kernel.create_field(iwg);
field->initialize();
}
Why This Is Wrong:
- DOF creation is O(num_nodes + num_edges + num_faces + num_elements)
- Matrix allocation is O(num_dofs × avg_connectivity)
- Creating inside loop → O(num_elements × num_dofs²) complexity!
Correct Pattern:
DofManager* field = kernel.create_field(iwg);
field->initialize();
for (Element* elem : block->elements()) {
iwg->compute_jacobian(elem, K);
solver_data->assemble_jacobian(elem, K);
}
2. Safe Defaults Quick Start
Minimal working example with safe defaults:
{
Mesh*
mesh =
new Mesh(
"mesh.exo");
KernelParameters params(
mesh);
Kernel kernel(¶ms);
IWG* iwg = kernel.create_equation(
IwgType::TransientHeatConduction,
ModelDimensionality::ThreeD
);
iwg->select_blocks({1, 2, 3});
iwg->set_timestepping_method(EulerMethod::BackwardDifference2);
iwg->set_algorithm(SolverAlgorithm::NewtonRaphson);
iwg->set_omega(0.9);
DofManager* field = kernel.create_field(iwg);
field->sideset(1)->impose_dirichlet(300.0);
field->sideset(2)->impose_neumann(1000.0);
SolverParameters solver_params( SolverType::MUMPS );
solver_params.set_reordering_method( ReorderingMethod::METIS );
field->set_solver( solver_params );
field->initialize();
real dt = 0.01;
real t = 0.0;
real t_end = 1.0;
while (t < t_end) {
field->compute_jacobian_and_rhs(true);
field->solve();
field->postprocess();
field->synchronize_fields({"T"});
if (comm_rank() == 0) {
mesh->save(
"output_" + std::to_string(t) +
".exo");
}
t += dt;
}
return 0;
}
Why These Defaults Are Safe:
| Choice | Reason |
| BDF2 | A-stable, 2nd order accuracy, well-tested |
| MUMPS | Robust, parallel-ready, handles ill-conditioned systems |
| METIS | Optimal fill-minimization for sparse factorization |
| NewtonRaphson | Full Jacobian, quadratic convergence |
| aReset=true | Prevents accumulation bugs |
| synchronize_fields() | Safe in both serial and parallel |
| postprocess() | Ensures secondary fields are up-to-date |
3. Glossary
| Term | Full Name / Definition |
| DOF | Degree Of Freedom - unknowns in the FEM system (e.g., temperature at each node) |
| DofManager | Central orchestrator for DOF allocation, assembly, and solving |
| DofData | Low-level DOF container and ID calculation |
| JEDI | Jacobian/Enforcement/Dirichlet/Imposition - block matrix system for handling BCs |
| Free DOF | Unknown to solve for (not constrained by Dirichlet BC) |
| Fixed DOF | Constrained by Dirichlet BC (imposed value) |
| Hanging DOF | Constrained by mesh refinement (weighted sum of parent DOFs) |
| T-matrix | Transformation matrix for hanging node constraints |
| Aura | Halo of elements/nodes on neighboring MPI procs (for ghost data) |
| Owned | Entities owned by this MPI proc (master copy) |
| BC | Boundary Condition (Dirichlet, Neumann, Robin) |
| RCM | Reverse Cuthill-McKee - bandwidth reduction algorithm |
| METIS | Graph partitioning library for nested dissection and MPI decomposition |
| Calculator | Element-level computation engine (shape functions, gradients, Jacobian) |
| N | Shape function matrix (interpolation) |
| B | Gradient operator matrix (B = inv(J) * dN/dξ) |
| J (Jacobian) | Free → Free coupling matrix (main system to solve) |
| D (Dirichlet) | Free → Fixed coupling matrix (Dirichlet BCs) |
| E (Enforcement) | Fixed → Free coupling matrix (reaction forces) |
| I (Imposition) | Fixed → Fixed coupling matrix (self-coupling of imposed values) |
| L2 Projection | Least-squares projection of discontinuous fields to continuous mesh fields |
| Assembly | Accumulation of element contributions into global system |
| Facet | Mesh face used for boundary conditions (triangle, quadrilateral) |
| Lambda DOF | Lagrange multiplier DOF (for interface constraints) |
4. Overview and Architecture
4.1 What Is the FEM Kernel?
The FEM Kernel module is the core orchestration layer that connects:
- Mesh data structures (Mesh, Node, Element)
- Physics equations (IWG - Integral Weak Form)
- DOF management (allocation, numbering, constraints)
- Sparse linear algebra (matrix assembly, solvers)
- Parallel computing (MPI distribution, field synchronization)
Central Class: DofManager - orchestrates all DOF operations.
Top-Level Class: Kernel - owns mesh, materials, boundary conditions, and multiple DofManagers.
4.2 Class Hierarchy
Kernel (Top-level orchestrator)
├── Mesh* (borrowed - externally owned or kernel-owned)
├── Materials (owned)
├── BoundaryConditions (owned)
├── Cell<IWG*> (equations - owned)
└── Cell<DofManager*> (fields - owned)
DofManager (Central orchestrator)
├── Inherits from: DofManagerBase
├── Composition pattern: owns 8 specialized data managers
│ ├── Parameters*
│ ├── DofData*
│ ├── BlockData*
│ ├── SideSetData*
│ ├── BearingData*
│ ├── FieldData*
│ ├── SolverData* (JEDI block matrix system)
│ └── EigenValues*
└── Cell<Postprocessor*> (L2 projection - owned)
4.3 Composition Pattern
DofManager uses composition to delegate responsibilities:
| Manager | File | Responsibility |
| Parameters | cl_FEM_DofMgr_Parameters | Integration orders, scheme settings |
| DofData | cl_FEM_DofMgr_DofData | DOF creation, numbering, hanging nodes |
| BlockData | cl_FEM_DofMgr_BlockData | Block management, thin-shell facet linking |
| SideSetData | cl_FEM_DofMgr_SideSetData | Sideset management, BCs, wetted nodes |
| BearingData | cl_FEM_DofMgr_BearingData | Bearing element management |
| FieldData | cl_FEM_DofMgr_FieldData | Field collect/distribute, linear projection |
| SolverData | cl_FEM_DofMgr_SolverData | Matrix assembly, solver interface, JEDI system |
| EigenValues | cl_FEM_DofMgr_EigenValues | Eigenvalue problem setup |
Each manager is:
- Created in DofManager constructor
- Initialized in DofManager::initialize()
- Deleted in DofManager destructor
User interacts primarily with DofManager, which forwards calls to appropriate managers.
4.4 Key Abstractions
Group → Block / SideSet:
- Group is abstract base class
- Block represents volume elements (material domains)
- SideSet represents surface elements (boundary conditions)
- Both own a Calculator for element-level computations
Element (FEM wrapper):
- Wraps mesh::Element with FEM-specific data
- Links DOFs to mesh entities (nodes, edges, faces, elements, facets)
- Manages T-matrices for hanging nodes
- Stores edge directions for Nédélec elements
Dof (Degree of Freedom):
- Inherits from graph::Vertex (for graph algorithms)
- Links to mesh::Basis (Node, Edge, Face, Element, Facet)
- Stores value, fixed flag, Dirichlet BC value
- Manages hanging node sources and weights
5. The Four Pillars of FEM Kernel
Understanding DofManager requires understanding four core concepts:
Pillar 1: DOF Allocation
Process:
- IWG specifies DOF requirements (nodes, edges, faces, cells, lambdas)
- DofData counts DOFs per entity type
- DofData computes ID offsets for contiguous ranges
- DofData allocates DOF objects
- DofData creates fast lookup map (ID → Dof*)
Result: All DOFs have unique global IDs
Pillar 2: Graph Extraction and Reordering
Process:
- SolverData extracts DOF-DOF connectivity graph from mesh
- DofData splits DOFs into free and fixed based on BCs
- DofData reorders free DOFs (symrcm; solver-internal nested dissection happens in the solver wrapper)
- DofData reorders fixed DOFs separately
- Each DOF gets new index in [0, num_dofs)
Result: DOFs are renumbered for optimal sparse solver performance
Pillar 3: JEDI Matrix System
Process:
- SolverData allocates 4 sparse matrices (J, D, E, I)
- SolverData creates assembly tables for MPI distribution
- Element loop: IWG computes local matrices
- SolverData assembles into global JEDI matrices
- Solver solves J*x = f - D*y for free DOFs
- SolverData computes reaction forces g = E*x + I*y
Result: Solution vector + reaction forces at constraints
Pillar 4: MPI Field Synchronization
Process:
- Each proc has owned + aura DOFs
- Assembly: each proc contributes to global system
- Solve: master solves (or parallel solve with subdomain procs)
- Distribute: solution scattered to all procs
- Synchronize: round-trip to ensure consistency
Result: All procs have consistent field values
These four pillars execute sequentially in DofManager::initialize() (Pillar 1-2) and DofManager::solve() (Pillar 3-4).
6. DOF Allocation and Distribution
6.1 DOF Creation Workflow
The DofManager::initialize() method triggers DOF creation:
DofManager::initialize()
├── init_dofs()
│ └── DofData::create_dofs(IWG*)
│ ├── 1. Query IWG for DOF requirements
│ │ ├── IWG::number_of_dofs_per_node()
│ │ ├── IWG::number_of_dofs_per_edge()
│ │ ├── IWG::number_of_dofs_per_face()
│ │ ├── IWG::number_of_dofs_per_cell()
│ │ └── IWG::number_of_lambda_dofs()
│ │
│ ├── 2. Count DOFs per entity type
│ │ ├── count_node_dofs()
│ │ ├── count_edge_dofs()
│ │ ├── count_face_dofs()
│ │ ├── count_cell_dofs()
│ │ └── count_lambda_dofs()
│ │
│ ├── 3. Compute ID offsets
│ │ └── compute_dof_offsets(IWG*)
│ │ ├── mEdgeDofOffset = max_node_id * num_dof_types
│ │ ├── mFaceDofOffset = mEdgeDofOffset + max_edge_id * num_dof_types
│ │ ├── mCellDofOffset = mFaceDofOffset + max_face_id * num_dof_types
│ │ └── mLambdaDofOffset = mCellDofOffset + max_cell_id * num_dof_types
│ │
│ ├── 4. Allocate DOF objects
│ │ ├── Create Dof objects (one per DOF)
│ │ ├── Link to
mesh::Basis (Node, Edge, Face, Element, Facet)
│ │ └── Set DOF ID (unique global identifier)
│ │
│ └── 5. Create DOF map
│ └── create_dof_map()
│ └── mDofMap( dof_id ) = dof_pointer
│
├── init_work()
│ └── SolverData::extract_graph_from_mesh()
│ ├── compute_element_dof_connectivity()
│ ├── compute_dof_element_connectivity()
│ └── compute_dof_dof_connectivity()
│
└── init_matrices()
└── SolverData::allocate_matrices()
├── Reorder DOFs (symrcm)
├── Allocate sparse matrices (J, D, E, I)
├── Create assembly tables
└── Initialize solver
6.2 DOF ID Calculation
Contiguous ID ranges with offsets:
node_dof_id = nodeID * mNumDofTypes + dofType
edge_dof_id = mEdgeDofOffset + edgeID * mNumDofTypes + dofType
face_dof_id = mFaceDofOffset + faceID * mNumDofTypes + dofType
cell_dof_id = mCellDofOffset + cellID * mNumDofTypes + dofType
lambda_dof_id = mLambdaDofOffset + facetID * mNumDofTypes + dofType
Example: Heat conduction (1 DOF type: temperature)
- Node 5: DOF ID = 5 * 1 + 0 = 5
- Node 10: DOF ID = 10 * 1 + 0 = 10
Example: Electromagnetics (3 DOF types: Ax, Ay, Az)
- Edge 100, type 0 (Ax): DOF ID = edge_offset + 100 * 3 + 0
- Edge 100, type 1 (Ay): DOF ID = edge_offset + 100 * 3 + 1
- Edge 100, type 2 (Az): DOF ID = edge_offset + 100 * 3 + 2
Implementation: cl_FEM_DofMgr_DofData.hpp:428-474
6.3 DOF Numbering vs. Reordering
DOF ID (initial, global, permanent):
- Calculated from mesh entity IDs
- Unique across all procs
- Sparse (gaps in numbering)
- Never changes
DOF Index (reordered, dense, local):
- Position in reordered array [0, num_dofs)
- Dense (no gaps)
- Changes after reordering (RCM, METIS)
- Used for sparse matrix indexing
Workflow:
Dof* dof = new Dof();
dof->set_id(node_dof_id);
dof->set_index(dof->id());
Graph free_dofs;
symrcm(free_dofs);
for (index_t i = 0; i < free_dofs.size(); ++i) {
Dof* dof = (Dof*) free_dofs(i);
dof->set_index(i);
}
SpMatrix* J = new SpMatrix(num_free_dofs, num_free_dofs);
J->set_value(dof_i->index(), dof_j->index(), value);
6.4 Free vs. Fixed DOFs
Split Criteria:
DofData::split_dof_container(free_dofs, fixed_dofs)
{
for (Dof* dof : mDOFs) {
if (dof->is_fixed()) {
fixed_dofs.push(dof);
} else {
free_dofs.push(dof);
}
}
}
Free DOFs:
- Unknowns to solve for
- Form the main system J*x = f - D*y
- Numbered [0, num_free_dofs)
Fixed DOFs:
- Constrained by Dirichlet BCs
- Known values (imposed)
- Numbered [0, num_fixed_dofs)
- Used to compute reaction forces g = E*x + I*y
Hanging DOFs:
- NOT free or fixed
- Eliminated via T-matrix transformation
- NOT part of global system
6.5 Hanging DOF Detection and Elimination
What Are Hanging Nodes?
Hanging nodes occur at mesh refinement boundaries:
Coarse element: Refined element:
a-----------b a-----h-----b
^
Hanging node h = 0.5*a + 0.5*b
Data Structure:
class Dof {
uint mNumberOfSources;
Dof** mSources;
real* mCoefficients;
};
h->mNumberOfSources = 2;
h->mSources = [a_dof, b_dof];
h->mCoefficients = [0.5, 0.5];
Workflow:
DofData::collect_hanging_dofs()
├── 1. Traverse all nodes/edges/faces
├── 2. Check if entity is hanging (
mesh::Node::is_hanging())
├── 3. Get parent entities and weights
└── 4. Store in mHangingDOFs container
DofData::remove_hanging_dofs_from_container()
├── Remove hanging DOFs from mDOFs
└── Keep in separate mHangingDOFs container
Element::relink_dofs()
├── Replace hanging DOF pointers with T-matrix
└── T-matrix transforms element DOFs to free DOFs
Note: the historical number_of_hanging_dofs() accessor bug (§1.1, fixed) never affected the hanging-node elimination logic — that uses T-matrices.
7. The JEDI Block Matrix System
7.1 What Is JEDI?
JEDI = Jacobian / Enforcement / Dirichlet / Imposition
A block matrix system that separates free and fixed DOFs to enable:
- Efficient solving (J matrix is smaller and better conditioned)
- Reaction force computation (E matrix computes constraint forces)
- Static condensation (D and E couple free and fixed DOFs)
The JEDI system literally computes "the Force" (reaction forces at constraints)!
7.2 Block Matrix Structure
Full system:
[ J D ] [ x ] [ f ]
[ E I ] [ y ] = [ g ]
Where:
- x = free DOFs (unknowns to solve for)
- y = fixed DOFs (imposed Dirichlet values)
- f = RHS for free DOFs
- g = RHS for fixed DOFs (reaction forces)
Matrix meanings:
| Matrix | Dimension | Meaning | Physical Interpretation |
| J | (N_free × N_free) | Free → Free coupling | Stiffness/Jacobian for unknowns |
| D | (N_free × N_fixed) | Fixed → Free dependency | How imposed BCs affect free DOFs |
| E | (N_fixed × N_free) | Free → Fixed enforcement | Reaction forces at constraints |
| I | (N_fixed × N_fixed) | Fixed → Fixed self-coupling | BC self-interaction |
7.3 Solving the JEDI System
Step 1: Solve for free DOFs
- y is known (imposed Dirichlet values)
- D*y term moves fixed DOF effects to RHS
- Solve for x using sparse solver (UMFPACK, MUMPS, STRUMPACK, etc.)
Step 2: Compute reaction forces (optional)
- x is now known (from Step 1)
- g contains reaction forces at Dirichlet boundaries
- In structural mechanics, this is literally "the Force" at supports!
Implementation:
field->solve();
solver_data->use_jedi_force(true);
Vector<real>& g = solver_data->rhs_vector();
7.4 JEDI Assembly Pattern
Element stiffness matrix K_e maps element DOFs to element DOFs.
Split contributions based on free/fixed status:
for each (dof_i, dof_j) in K_e:
if (dof_i.is_free() && dof_j.is_free()):
J[i_free, j_free] += K_e[i, j]
elif (dof_i.is_free() && dof_j.is_fixed()):
D[i_free, j_fixed] += K_e[i, j]
elif (dof_i.is_fixed() && dof_j.is_free()):
E[i_fixed, j_free] += K_e[i, j]
elif (dof_i.is_fixed() && dof_j.is_fixed()):
I[i_fixed, j_fixed] += K_e[i, j]
Implementation: SolverData::assemble_jacobian() in cl_FEM_DofMgr_SolverData.cpp
7.5 JEDI vs. Standard FEM
Standard FEM:
K*u = f
// Apply Dirichlet BCs by:
// 1. Zero out rows/columns for fixed DOFs
// 2. Set diagonal to 1, RHS to imposed value
// 3. Lose reaction force information
JEDI FEM:
[ J D ] [ x ] [ f ]
[ E I ] [ y ] = [ g ]
// Advantages:
// 1. Preserve full system structure
// 2. Compute reaction forces explicitly
// 3. Better conditioning (J is smaller)
// 4. Support static condensation
When to use JEDI:
- Structural mechanics (need reaction forces at supports)
- Contact problems (enforce constraints, compute contact forces)
- Interface coupling (static condensation)
- Large Dirichlet BC count (J is much smaller than K)
When standard FEM is sufficient:
- Pure Neumann problems (no fixed DOFs)
- Don't need reaction forces
- Small Dirichlet BC count (overhead not worth it)
7.6 Accessing JEDI Matrices
field->initialize();
SpMatrix* A = field->system_matrix();
SpMatrix* D = field->dirichlet();
SpMatrix* E = field->enforcement();
SpMatrix* I = field->imposition();
BELFEM_ASSERT(J->n_rows() == num_free_dofs,
"J should be square");
BELFEM_ASSERT(D->n_cols() == num_fixed_dofs,
"D columns = fixed DOFs");
BELFEM_ASSERT(E->n_rows() == num_fixed_dofs,
"E rows = fixed DOFs");
BELFEM_ASSERT(I->n_rows() == num_fixed_dofs,
"I should be square");
All matrices are nullptr until initialize() is called!
8. MPI Parallel Distribution
8.1 MPI Architecture
Two-tier parallelism:
- Domain decomposition (MPI) - coarse-grained, distributed memory
- Sparse solver parallelism (optional) - fine-grained, shared memory (MUMPS, STRUMPACK)
Proc roles:
- Master (rank 0): Owns full mesh, assembles global system, runs solver (or coordinates parallel solver)
- Workers (rank 1-N): Own submesh, assemble local contributions, send to master
8.2 Mesh Partitioning
Partitioning strategy:
Kernel::distribute_mesh()
├── Master (rank 0):
│ ├── Partition
mesh using METIS/SCOTCH
│ ├── Create submeshes for each proc
│ ├── Add aura (ghost) elements for inter-proc boundaries
│ └── Send submeshes to workers
│
└── Workers (rank 1-N):
├── Receive submesh from master
└── Store owned + aura elements
Owned vs. Aura:
- Owned: Entities with proc_owner() == comm_rank()
- Aura: Halo of entities on neighboring procs (for interpolation)
CommTables:
- Define communication patterns (which DOFs to send/receive)
- Created during mesh distribution
- Used for field synchronization
8.3 DOF Distribution
DOF creation:
DofData::create_dofs(IWG*)
├── Count DOFs for owned entities
├── Count DOFs for aura entities
├── Allocate DOF objects
└── DOF numbering is globally consistent
Global consistency:
- All procs use same DOF ID calculation formula
- DOF IDs are unique and consistent across procs
- Reordering is done globally (master coordinates)
8.4 Matrix Assembly in Parallel
Assembly pattern:
for (Block* block : blocks) {
for (Element* elem : block->owned_elements()) {
iwg->compute_jacobian(elem, K_e);
iwg->compute_rhs(elem, f_e);
solver_data->assemble_jacobian(elem, K_e);
solver_data->assemble_rhs(elem, f_e);
}
}
solver_data->collect_matrices();
solver_data->collect_rhs_vector();
Assembly tables:
Cell<Vector<index_t>> mSystemTable;
Parallel assembly workflow:
Worker: Master:
┌─────────────┐ ┌─────────────┐
│ Assemble K_e│ │ Allocate J │
│ for owned │ │ │
│ elements │ │ │
└─────┬───────┘ └─────┬───────┘
│ │
│ MPI_Send(K_local) │
├───────────────────────────>│
│ │
│ ┌─────▼───────┐
│ │ Accumulate │
│ │ contributions│
│ │ into J │
│ └─────────────┘
Implementation: SolverData::collect_matrices() in cl_FEM_DofMgr_SolverData.cpp
8.5 Field Synchronization
Three synchronization modes:
field->collect_fields({"T", "q"});
field->distribute_fields({"T", "q"});
field->synchronize_fields({"T", "q"});
When to synchronize:
- After solve: Solution computed on master, must distribute to workers
- Before postprocessing: Workers need full solution for L2 projection
- Before output: All procs need consistent data for VTK/Exodus export
Implementation:
void DofManager::synchronize_fields(const Cell<string>& labels)
{
collect_fields(labels);
distribute_fields(labels);
}
⚠️ Common Pitfall: Forgetting to synchronize after solve → workers have stale data!
8.6 MPI-Safe Patterns
Safe pattern:
if (comm_rank() == 0) {
mesh->save(
"output.exo");
}
field->synchronize_fields({"T"});
if (comm_rank() > 0) {
}
Collective operations (all procs must call):
- field->initialize()
- field->solve()
- field->synchronize_fields()
- field->collect_fields()
- field->distribute_fields()
⚠️ Deadlock if only master calls collective operation!
9. Calculator and Element-Level Assembly
9.1 What Is the Calculator?
The Calculator is the element-level computation engine. It computes:
- N: Shape functions (interpolation)
- B: Gradient operator (B = inv(J_geom) * dN/dξ)
- J_geom: Geometric Jacobian (dX/dξ)
- dV: Differential volume (for integration)
- dS: Differential surface (for boundary integrals)
- Normals: Outward normal vectors
File: cl_FEM_Calculator.{hpp,cpp} (1742 lines - largest file in kernel!)
9.2 Calculator Usage Pattern
Typical assembly loop:
Block* block = field->block(1);
Calculator* calc = block->calculator();
for (Element* elem : block->elements()) {
calc->link(elem);
uint num_gauss = elem->number_of_integration_points();
for (uint g = 0; g < num_gauss; ++g) {
const Matrix<real>& N = calc->N(g);
const Matrix<real>& B = calc->B(g);
real dV = calc->dV(g);
K_e += trans(B) * material_D * B * dV;
}
solver_data->assemble_jacobian(elem, K_e);
}
9.3 Function Pointer Pattern (Performance Optimization)
Why function pointers instead of virtual functions?
The Calculator is called millions of times during assembly. Virtual function calls have ~5-10% overhead. Function pointers avoid this overhead while maintaining runtime polymorphism.
Pattern:
class Calculator {
const Matrix<real>& (Calculator::*mFunN)(uint);
const Matrix<real>& (Calculator::*mFunB)(uint);
real (Calculator::*mFundV)(uint);
void allocate() {
if (dimensionality == 2D) {
mFunN = &Calculator::N2D;
mFunB = &Calculator::Bscalar;
mFundV = &Calculator::dV_tri6_tet10;
} else {
mFunN = &Calculator::N3D;
mFunB = &Calculator::Bscalar;
mFundV = &Calculator::dV_hex;
}
}
public:
const Matrix<real>& N(uint g) { return (this->*mFunN)(g); }
const Matrix<real>& B(uint g) { return (this->*mFunB)(g); }
real dV(uint g) {
return (this->*mFundV)(g); }
};
double real
Definition typedefs.hpp:36
Specializations:
- N functions: Scalar, Vec2D, Vec3D (vector field interpolation)
- B functions: Gradient, PlaneStress, Voigt (strain-displacement operators)
- dV functions: Cartesian, AxiSymmX, AxiSymmY (2D), ThreeD
- Geometry: Straight vs. curved elements
9.4 Shape Functions (N) and Gradients (B)
Shape Function Matrix N:
For scalar field (e.g., temperature):
N = [ N1 N2 N3 ... Nn ] (1 × num_bases)
For 2D vector field (e.g., displacement):
N = [ N1 0 N2 0 N3 0 ... ] (2 × 2*num_bases)
[ 0 N1 0 N2 0 N3 ... ]
For 3D vector field:
N = [ N1 0 0 N2 0 0 ... ] (3 × 3*num_bases)
[ 0 N1 0 0 N2 0 ... ]
[ 0 0 N1 0 0 N2 ... ]
Gradient Operator B:
For diffusion (scalar field):
B = [ dN1/dx dN2/dx ... dNn/dx ] (ndim × num_bases)
[ dN1/dy dN2/dy ... dNn/dy ]
[ dN1/dz dN2/dz ... dNn/dz ] (3D only)
For elasticity (2D plane stress):
B = [ dN1/dx 0 dN2/dx 0 ... ] (3 × 2*num_bases)
[ 0 dN1/dy 0 dN2/dy ... ]
[ dN1/dy dN1/dx dN2/dy dN2/dx ... ]
For elasticity (3D Voigt notation):
B = [ dN1/dx 0 0 ... ] (6 × 3*num_bases)
[ 0 dN1/dy 0 ... ]
[ 0 0 dN1/dz ... ]
[ dN1/dy dN1/dx 0 ... ]
[ 0 dN1/dz dN1/dy ... ]
[ dN1/dz 0 dN1/dx ... ]
Computation:
Matrix<real> J_geom = dN_dxi * X_coords;
Matrix<real> inv_J = inv(J_geom);
Matrix<real> B = inv_J * dN_dxi;
9.5 Integration Point Data
Differential volume (dV):
dV = det(J_geom) * weight
dV = det(J_geom) * weight * 2*pi * y_coord
dV = det(J_geom) * weight * 2*pi * x_coord
Differential surface (dS):
dS = sqrt((dx/dξ)^2 + (dy/dξ)^2) * weight
dS = ||∂X/∂ξ × ∂X/∂η|| * weight
Outward normal:
normal = normalize([dy/dξ, -dx/dξ])
normal = normalize(∂X/∂ξ × ∂X/∂η)
9.6 Nédélec Edge Elements (Electromagnetics)
For electromagnetics (H-φ formulation), edge elements use curl-conforming basis functions:
Edge functions (E):
const Matrix<real>& E = calc->E(g);
Curl operator (C):
const Matrix<real>& C = calc->C(g);
Edge directions:
Bitset< 12 > tDirs;
elem->edge_directions( tDirs );
Implementation: cl_FEM_Calculator.cpp (specialized functions for Nédélec elements)
10. Boundary Conditions
10.1 BC Types
BELFEM supports three main BC types:
| Type | Name | Equation | Example |
| Dirichlet | Essential BC | u = g | T = 300K (fixed temperature) |
| Neumann | Natural BC | ∂u/∂n = q | q = 1000 W/m² (heat flux) |
| Robin | Mixed BC | α*(u - u_∞) = q | h*(T - T_∞) = q (convection) |
Implementation:
sideset->impose_dirichlet(300.0);
sideset->impose_neumann(1000.0);
sideset->impose_alpha(h, T_inf);
10.2 Dirichlet BCs (Essential)
Application:
SideSet* sideset = field->sideset(1);
sideset->impose_dirichlet(300.0);
Effect on DOF:
Dof* dof = ;
dof->is_fixed();
dof->value();
dof->index();
Effect on JEDI system:
- DOF moved from free to fixed container
- Contributes to D, E, I matrices (not J)
- RHS modified: f -= D*y where y[i] = 300.0
10.3 Neumann BCs (Natural)
Application:
SideSet* sideset = field->sideset(2);
sideset->impose_neumann(1000.0);
Weak form contribution:
for (Element* facet_elem : sideset->elements()) {
calc->link(facet_elem);
for (uint g = 0; g < num_gauss; ++g) {
const Matrix<real>& N = calc->N(g);
real dS = calc->dS(g);
real q = sideset->bc_value( 0 );
f_e += trans(N) * q * dS;
}
solver_data->assemble_rhs(facet_elem, f_e);
}
No modification to Jacobian - only RHS vector.
10.4 Robin BCs (Convection)
Application:
SideSet* sideset = field->sideset(3);
real h = 100.0;
real T_inf = 298.0;
sideset->impose_alpha(h, T_inf);
Weak form contribution:
for (Element* facet_elem : sideset->elements()) {
calc->link(facet_elem);
const Vector<real>& tTinf =
mesh->field_data(
"Tinf" );
for (uint g = 0; g < num_gauss; ++g) {
const Matrix<real>& N = calc->N(g);
real dS = calc->dS(g);
real h = sideset->bc_value( 0 );
real T_inf = tTinf( facet_elem->element()->node( 0 )->index() );
K_e += trans(N) * h * N * dS;
f_e += trans(N) * h * T_inf * dS;
}
solver_data->assemble_jacobian(facet_elem, K_e);
solver_data->assemble_rhs(facet_elem, f_e);
}
Modifies both Jacobian and RHS.
10.5 BC Imposing Modes
Enum: BoundaryConditionImposing
enum class BoundaryConditionImposing
{
Free,
Dirichlet,
Neumann,
Alpha,
Lambda,
Weak,
UNDEFINED
};
SideSet::bc_type( aDimension ) returns the entry a sideset carries for that dof dimension; Dirichlet values are imposed strongly through the JEDI split.
11. Hanging Nodes and T-Matrices
11.1 What Are Hanging Nodes?
Hanging nodes occur at mesh refinement boundaries where a node on a coarse element lies on an edge/face of refined elements.
Example (1D):
Coarse element: Refined element:
a-----------b a-----h-----b
^
Hanging node h
Constraint: h = 0.5*a + 0.5*b
Example (2D quad refinement):
Coarse: Refined:
a-------b a---e---b
| | | 1 | 2 |
| | f---h---g
d-------c | 3 | 4 |
d---i---c
Hanging nodes: e, f, g, h, i Constraints:
- e = 0.5*a + 0.5*b
- f = 0.5*a + 0.5*d
- g = 0.5*b + 0.5*c
- h = 0.25*a + 0.25*b + 0.25*c + 0.25*d
- i = 0.5*d + 0.5*c
11.2 Hanging DOF Data Structure
class Dof {
uint mNumberOfSources;
Dof** mSources;
real* mCoefficients;
};
Dof* h = ;
h->mNumberOfSources = 4;
h->mSources = [a, b, c, d];
h->mCoefficients = [0.25, 0.25, 0.25, 0.25];
if (h->is_hanging()) {
for (uint i = 0; i < h->number_of_sources(); ++i) {
Dof* parent = h->source(i);
real weight = h->weight(i);
}
}
11.3 T-Matrix Transformation
Element-level transformation:
Original element DOFs include hanging nodes:
u_elem = [u1, u2, u3, u_h, ...] // u_h is hanging
Transform to free DOFs only:
u_elem = T * u_free
where T is the transformation matrix:
T = [ 1 0 0 ... ] (row 1: u1 = u_free[1])
[ 0 1 0 ... ] (row 2: u2 = u_free[2])
[ 0 0 1 ... ] (row 3: u3 = u_free[3])
[ w1 w2 w3 ... ] (row 4: u_h = w1*u1 + w2*u2 + w3*u3)
Element matrix transformation:
K_elem = T^T * K_free * T
This eliminates hanging DOFs from the element matrix before assembly.
11.4 T-Matrix Workflow
DofData::collect_hanging_dofs()
├── Traverse all nodes/edges/faces
├── Check if entity is hanging (
mesh::Node::is_hanging())
├── Get parent entities and weights
└── Store in mHangingDOFs container
DofData::remove_hanging_dofs_from_container()
├── Remove hanging DOFs from mDOFs
└── Keep in separate mHangingDOFs container
DofData::create_dofwise_t_matrices_master()
├── For each element with hanging DOFs
├── Identify which DOFs are hanging
├── Create T-matrix transformation
└── Store in Element::mTmatrix
Element::relink_dofs()
├── Replace hanging DOF pointers with parent DOFs
└── Apply T-matrix during assembly
11.5 Assembly with T-Matrices
Standard assembly (no hanging nodes):
iwg->compute_jacobian(elem, K_e);
solver_data->assemble_jacobian(elem, K_e);
Assembly with T-matrix:
iwg->compute_jacobian(elem, K_e);
if (elem->has_t_matrix()) {
elem->t_matrix()->project(K_e, K_cond);
solver_data->assemble_jacobian(elem, K_cond);
} else {
solver_data->assemble_jacobian(elem, K_e);
}
Automatic in BELFEM: the projection is done by DofManager::compute_jacobian() / compute_jacobian_and_rhs() before SolverData::assemble_jacobian() is called.
11.6 Hanging-Node Counts vs. Elimination (historical note)
The number_of_hanging_dofs() accessor bug (§1.1) is fixed; both count accessors are safe. Even while it was live it never affected the hanging-node elimination — assembly, solving and solution accuracy all go through the T-matrix mechanism, which computes its maps independently of the count accessors. Only logging and user queries ever saw the wrong number.
12. Postprocessing (L2 Projection)
12.1 What Is L2 Projection?
Problem: IWG computes secondary fields (e.g., heat flux, stress) at integration points. These are discontinuous across elements.
Goal: Project discontinuous field to continuous nodal field for visualization.
Method: L2 projection (least-squares fit)
Formulation:
Minimize: ||q_gauss - N*q_nodes||²
Solve: M*q_nodes = ∫ N^T * q_gauss * dV
where M = ∫ N^T * N * dV (mass matrix)
12.2 Postprocessor Workflow
field->initialize_postprocessors();
field->postprocess();
field->postprocess()
├── For each postprocessor
│ ├── 1. Evaluate secondary field at integration points
│ │ └── iwg->compute_secondary_field(elem, gauss_point, value)
│ │
│ ├── 2. Assemble RHS: f = ∫ N^T * value * dV
│ │
│ ├── 3. Solve: M*q = f
│ │ (M is mass matrix, precomputed during initialize_postprocessors())
│ │
│ └── 4. Store nodal values in
mesh field
│ └──
mesh->field(
"q")->set_value(node_index, q_value)
│
└── Synchronize projected fields (MPI)
12.3 Creating Postprocessors
field->initialize_postprocessors();
IWG::create_fields(DofManager*)
{
mesh->create_field(
"heat_flux_x");
mesh->create_field(
"gradient_T_x");
}
12.4 Accessing Projected Fields
field->postprocess();
mesh::Field* q_field =
mesh->field(
"heat_flux_x");
real qx = q_field->value(node->index());
}
Vector<real>& q_data = q_field->data();
12.5 Postprocessing Performance
Cost: L2 projection requires solving M*q = f for each secondary field.
Optimization:
- Mass matrix M is SPD (Symmetric Positive Definite)
- Can use Cholesky factorization (faster than LU)
- M is constant (compute once, reuse for all timesteps)
- Can use lumped mass matrix for cheaper approximation
Lumped mass (faster, less accurate):
M_lumped[i,i] = sum_j(M[i,j]) (row sum)
M_lumped[i,j] = 0 (i ≠ j)
// Solve becomes trivial:
q[i] = f[i] / M_lumped[i,i]
BELFEM uses consistent mass by default (more accurate).
13. Ownership and Lifetime
13.1 Ownership Hierarchy
BELFEM uses explicit ownership via raw pointers.
Kernel (top-level owner)
├── borrows → Mesh* (the caller who did `new Mesh` deletes it; `mOwnMesh` is not set by any constructor)
├── owns → Materials
├── owns → BoundaryConditions
├── owns → Cell<IWG*>
└── owns → Cell<DofManager*>
DofManager
├── borrows → Kernel* (parent, NOT owned)
├── borrows → Mesh* (NOT owned)
├── borrows → IWG* (NOT owned)
├── owns → Parameters*
├── owns → DofData*
├── owns → BlockData*
├── owns → SideSetData*
├── owns → BearingData*
├── owns → FieldData*
├── owns → SolverData*
├── owns → EigenValues*
└── owns → Cell<Postprocessor*>
DofData
├── owns → Cell<Dof*>
├── owns → Cell<Dof*> (hanging DOFs)
└── owns → Map<id_t, Dof*> (map does NOT own Dof objects)
Key Rules:
- owns → means destructor calls delete
- borrows → means destructor does NOT call delete
- Cell<T*> is non-owning (does NOT call delete on elements)
- Ownership is documented in header comments
13.2 Lifetime Management Patterns
Pattern 1: Kernel Manages Everything (RECOMMENDED)
Mesh*
mesh =
new Mesh(
"mesh.exo");
KernelParameters params(
mesh);
Kernel kernel(¶ms);
IWG* iwg = kernel.create_equation(...);
DofManager* field = kernel.create_field(iwg);
return 0;
}
Pattern 2: Manual Management (ADVANCED)
Kernel* kernel = new Kernel(¶ms);
IwgFactory factory(
mesh);
IWG* iwg = factory.create_iwg(...);
DofManager* field = new DofManager(kernel, 0);
field->set_equation(iwg);
delete field;
delete iwg;
delete kernel;
Pattern 3: DofManager Owns IWG (ALTERNATIVE)
IwgFactory factory(
mesh);
IWG* iwg = factory.create_iwg(...);
DofManager* field = kernel.create_field(iwg);
delete field;
⚠️ CRITICAL: Always check header comments for ownership documentation!
13.3 Common Ownership Mistakes
Mistake 1: Double-Free
IWG* iwg = kernel.create_equation(...);
DofManager* field = kernel.create_field(iwg);
delete iwg;
Mistake 2: Dangling Pointer
IWG* iwg = factory.create_iwg(...);
DofManager* field = new DofManager(&kernel, 0);
field->set_equation(iwg);
delete iwg;
field->compute_jacobian_and_rhs();
Mistake 3: Memory Leak
for (int i = 0; i < 100; ++i) {
IWG* iwg = factory.create_iwg(...);
}
Mistake 4: Deleting Borrowed Pointer
DofManager* field = kernel.create_field(iwg);
Mesh*
mesh = field->mesh();
13.4 Smart Pointer Policy
BELFEM uses raw pointers on critical paths for performance reasons.
When smart pointers are OK:
- High-level utilities (I/O, post-processing)
- One-off allocations (setup, initialization)
- Outside hot paths (assembly, solve)
When raw pointers are required:
- Inside loops (assembly, element traversal)
- Interfacing with C libraries (MPI, BLAS, Fortran)
- Performance-critical code
See: doc/coding_philosophy.md for complete rationale.
13.5 Checking Ownership
Header comments:
class DofManager {
Kernel* mParent;
Mesh* mMesh;
IWG* mIWG;
DofData* mDofData;
};
Ownership documentation pattern:
- //! owned - Destructor calls delete
- //! borrowed - Destructor does NOT call delete
- //! the parent object - Usually borrowed (child does not outlive parent)
If unclear, check destructor:
DofManager::~DofManager()
{
delete mDofData;
delete mBlockData;
}
14. Performance Considerations
14.1 Computational Complexity
DOF Allocation:
- O(N_entities × N_dof_types) - Linear in number of mesh entities
- Dominated by: Graph extraction O(N_elements × N_dofs_per_elem)
Graph Reordering:
- RCM: O(N_dofs + E_graph + N_dofs × log(N_dofs)) for sorting
- METIS: O(N_dofs × log(N_dofs)) expected
Matrix Assembly:
- O(N_elements × N_dofs_per_elem² × N_gauss)
- Dominated by: Element matrix computation (IWG::compute_jacobian)
Sparse Solver:
- Direct (MUMPS, STRUMPACK): O(N_dofs^1.5 to N_dofs^2) depending on fill-in
- Iterative (PETSc): O(iterations × N_dofs × avg_connectivity)
Overall: Assembly is usually bottleneck for large problems.
14.2 Memory Usage
DOF Storage:
Per DOF: ~200 bytes (Dof object + graph::Vertex overhead + hanging node data)
Total: N_dofs × 200 bytes
Example: 1M DOFs → ~200 MB
Sparse Matrix Storage (CSR format):
J matrix: (N_free × N_free) with avg_connectivity entries per row
Storage: N_free × avg_connectivity × sizeof(real) + indexing overhead
Example: 1M DOFs, avg 50 connections per row
Values: 1M × 50 × 8 bytes = 400 MB
Indices: 1M × 50 × 4 bytes = 200 MB
Total: ~600 MB
JEDI Matrices:
J: ~600 MB (from above)
D: N_free × N_fixed × sparsity ~ 10-50 MB (typically small)
E: N_fixed × N_free × sparsity ~ 10-50 MB
I: N_fixed × N_fixed × sparsity ~ 1-10 MB (typically very sparse)
Total JEDI: ~700 MB (dominated by J)
Peak Memory:
- During assembly: Element matrices (small, reused)
- During solve: Factorization (can be 2-10× matrix storage for direct solvers)
Example: 1M DOF problem
- DOFs: 200 MB
- JEDI matrices: 700 MB
- Factorization: 2-5 GB (MUMPS with fill-in)
- Total: ~3-6 GB
14.3 Performance Optimization Tips
1. Reuse DofManager:
for (int step = 0; step < n_steps; ++step) {
DofManager* field = kernel.create_field(iwg);
field->initialize();
delete field;
}
DofManager* field = kernel.create_field(iwg);
field->initialize();
for (int step = 0; step < n_steps; ++step) {
field->compute_jacobian_and_rhs();
field->solve();
}
2. Use Optimal Reordering:
SolverParameters params( SolverType::MUMPS );
params.set_reordering_method( ReorderingMethod::METIS );
3. Choose Right Solver:
SolverParameters params( SolverType::UMFPACK );
SolverParameters params( SolverType::MUMPS );
SolverParameters params( SolverType::STRUMPACK );
SolverParameters params( SolverType::PETSc );
4. Minimize Field Synchronization:
for (int iter = 0; iter < max_iter; ++iter) {
field->compute_jacobian_and_rhs();
field->solve();
field->synchronize_fields({"T"});
}
for (int iter = 0; iter < max_iter; ++iter) {
field->compute_jacobian_and_rhs();
field->solve();
}
field->synchronize_fields({"T"});
5. Preallocate Work Arrays:
class IWG_Custom : public IWG {
Matrix<real> mWorkK;
Vector<real> mWorkF;
void compute_jacobian(Element* elem, Matrix<real>& K) {
mWorkK.fill(0.0);
K = mWorkK;
}
};
14.4 Profiling and Benchmarking
Use BELFEM profiling tools:
Timer timer;
timer.reset();
field->initialize();
message(InfoLevel::Verbose,
"Initialize: %u ms", (uint) timer.stop());
timer.reset();
field->compute_jacobian_and_rhs();
message(InfoLevel::Verbose,
"Assembly: %u ms", (uint) timer.stop());
timer.reset();
field->solve();
message(InfoLevel::Verbose,
"Solve: %u ms", (uint) timer.stop());
void message(const belfem::InfoLevel aInfoLevel, const std::string &aFormat, const Args ... aArgs)
Definition cl_Logger.hpp:144
Typical time breakdown:
- Initialize: 5-10% (one-time cost)
- Assembly: 30-50% (per Newton iteration)
- Solve: 40-60% (per Newton iteration)
- Postprocess: 5-10% (per output step)
Optimization Priority:
- Reduce Newton iterations (better initial guess, line search)
- Optimize assembly (vectorize element loops, reduce function calls)
- Optimize solver (choose best reordering, preconditioner)
15. Thread Safety and MPI
15.1 Thread Safety
BELFEM is deliberately NOT thread-safe internally.
Rationale:
- MPI for parallelism (distributed memory), not threading
- No internal mutexes (adds latency to every operation)
- Users needing OpenMP: protect calls externally
Example: OpenMP with BELFEM
#pragma omp parallel for
for (int i = 0; i < n_elements; ++i) {
field->compute_jacobian(elem[i]);
}
#pragma omp parallel for
for (int i = 0; i < n_elements; ++i) {
#pragma omp critical
{
field->compute_jacobian(elem[i]);
}
}
Safe for OpenMP:
- Read-only access to DOF values
- Independent element computations (no shared state)
NOT safe for OpenMP:
- Matrix assembly (writes to shared sparse matrix)
- DOF value modifications
- Graph operations (reordering, connectivity)
15.2 MPI Collective Operations
Collective operations require ALL procs to participate:
field->initialize();
field->solve();
field->synchronize_fields({"T"});
⚠️ DEADLOCK if only master calls:
if (comm_rank() == 0) {
field->initialize();
}
Master-only operations:
if (comm_rank() == 0) {
mesh->save(
"output.exo");
}
15.3 MPI Communication Patterns
Point-to-point:
MPI_Send(K_local, master_rank, ...);
MPI_Recv(x_local, worker_rank, ...);
Collective:
MPI_Gather(local_data, global_data, master_rank, ...);
MPI_Scatter(global_data, local_data, master_rank, ...);
MPI_Bcast(shared_data, master_rank, ...);
MPI_Reduce(local_value, global_value, MPI_SUM, master_rank, ...);
BELFEM abstracts MPI via Communicator class:
proc_t rank = comm_rank();
proc_t size = comm_size();
if (comm_rank() == 0) send(data, 1);
if (comm_rank() == 1) receive(data, 0);
broadcast(data, 0);
comm_barrier();
Two things worth knowing before you reach for something that is not there:
- There is no global-sum helper. allreduce() exists but is fixed to MPI_MAX (commtools.hpp:234-250). A global sum has to be written with MPI_Allreduce directly; if you need one more than once, add it to commtools.hpp rather than open-coding it at the call site.
- share/receive and distribute/collect are not collective. They are asymmetric halves that must be paired across ranks, and calling one without its counterpart deadlocks. See the communication module guide.
15.4 MPI-Safe Field Access
field->solve();
field->synchronize_fields({"T"});
real T =
mesh->field(
"T")->value(node->index());
}
real T =
mesh->field(
"T")->value(node->index());
}
16. Known Issues and Bugs
16.1 FIXED: number_of_hanging_dofs() (reported 2026-01-20)
The accessor once returned mNumberOfFixedDofs; it now returns mNumberOfHangingDofs (cl_FEM_DofMgr_DofData.hpp). No open issue remains. Kept as the record of the report; the standing lesson is in §1.1.
16.2 (retracted)
An earlier revision of this guide reported a typo in the assemble_rhs() method name; the report itself was garbled (it showed the same spelling on both sides) and the method exists as assemble_rhs() in cl_FEM_DofMgr_SolverData.hpp. Nothing to work around.
16.3 CBorg False Positives (Verified Not Bugs)
During AI-assisted code review, CBorg (having access only to .hpp header files, not .cpp implementations) flagged 7 issues that were verified to be false positives:
❌ FALSE POSITIVE #1: DOF offsets not initialized before use
- Claim: mEdgeDofOffset, mFaceDofOffset, etc. are gNoID and never set
- Reality: compute_dof_offsets(aIWG) is called at the START of create_dofs() (before any DOF ID calculations)
- Evidence: compute_dof_offsets() call at the top of DofData::create_dofs() in cl_FEM_DofMgr_DofData.cpp
❌ FALSE POSITIVE #2: create_dofwise_t_matrices_master() declared but not defined
- Claim: Function is declared in header but never implemented
- Reality: Full ~100-line implementation exists
- Evidence: DofData::create_dofwise_t_matrices_master() in cl_FEM_DofMgr_DofData.cpp
❌ FALSE POSITIVE #3: Destructor order issues
- Claim: DofManager destructor deletes in wrong order or misses objects
- Reality: Destructor correctly deletes all 8 owned data managers in proper order
- Evidence: DofManager::~DofManager() in cl_FEM_DofManager.cpp
❌ FALSE POSITIVE #4: node_dof_id() formula breaks for sparse meshes
- Claim: Formula nodeID * mNumDofTypes + dofType causes collisions with sparse node IDs
- Reality: Offset system ensures non-overlapping ranges (nodes, edges, faces, cells, lambdas each have separate ranges)
- Explanation: May waste ID space but cannot cause collisions. Even node ID 1,000,000 produces valid ID in node range.
❌ FALSE POSITIVE #5: EigenValues::mSolver not initialized
- Claim: mSolver pointer is never set before use
- Reality: Lazy initialization pattern using mFirstRun flag
- Evidence: EigenValues::mFirstRun in cl_FEM_DofMgr_EigenValues.cpp - mSolver created on first use
❌ FALSE POSITIVE #6: bearing(id) returns nullptr
- Claim: mEmptyBearing is nullptr, causing crash
- Reality: mEmptyBearing = new Bearing(mParent) is always allocated in create_bearings()
- Evidence: BearingData::create_bearings() in cl_FEM_DofMgr_BearingData.cpp - Returns valid (empty) Bearing, not nullptr
❌ FALSE POSITIVE #7: Calculator function pointers null before allocation
- Claim: Function pointers (mFunN, mFunB, etc.) can be null when accessed
- Reality: Guard exists in link() method: BELFEM_ERROR(mIsAllocated, "calculator is not allocated")
- Evidence: Calculator::link() in cl_FEM_Calculator.cpp - Must call link() before any accessor, link() checks allocation
⚠️ PARTIALLY VALID (Defensive Programming Recommendations):
- solve() has no explicit solver null check
- No explicit null check for mSolver before mSolver->solve() (line 17893)
- Normal usage always calls set_solver() first, so unlikely to occur
- Recommendation: Add BELFEM_ASSERT(mSolver != nullptr, "Solver not set") for safety
- Calculator accessors could have explicit allocation checks
- Guard exists in link() but not in individual accessors (N(), B(), etc.)
- Since link() must be called before any accessor, current design is safe
- Recommendation: Could add BELFEM_ASSERT(mIsAllocated, ...) to individual accessors for extra safety
Lesson Learned:
Header-only review (.hpp files) can identify:
- ✅ Interface design issues
- ✅ Declaration/definition mismatches (when both are in headers)
- ✅ Inconsistent naming
But may incorrectly assume missing functionality that actually exists in .cpp implementation files:
- ❌ Missing initializations (may be in constructors)
- ❌ Missing function definitions (may be in .cpp)
- ❌ Incorrect execution order (may be correct in implementation)
Verification Method: Always check .cpp implementation files before concluding that functionality is missing!
16.4 Debugging Tips
Enable verbose logging:
field->initialize();
void set_info_level(const uint aInfoLevel)
set the info level at runtime, e.g.
Definition cl_Logger.cpp:71
Print DOF information:
Check matrix dimensions:
SpMatrix* A = field->system_matrix();
index_t n_free = field->solver_data()->number_of_free_dofs();
message(InfoLevel::Info,
"J: %lu × %lu, nnz = %lu",
J->n_rows(), J->n_cols(), J->number_of_nonzeros());
Check DOF connectivity:
Dof* dof = field->dof(dof_id);
message(InfoLevel::Info,
"DOF %lu:", dof->id());
message(InfoLevel::Info,
" Index: %lu", dof->index());
message(InfoLevel::Info,
" Fixed: %s", dof->is_fixed() ?
"yes" :
"no");
message(InfoLevel::Info,
" Hanging: %s", dof->is_hanging() ?
"yes" :
"no");
message(InfoLevel::Info,
" Connectivity: %u neighbors", dof->number_of_vertices());
for (uint i = 0; i < dof->number_of_vertices(); ++i) {
Dof* neighbor = (Dof*) dof->vertex(i);
message(InfoLevel::Info,
" Neighbor %u: DOF %lu", i, neighbor->id());
}
Valgrind for memory issues:
valgrind --leak-check=full --track-origins=yes ./my_belfem_app
# Look for:
# - Invalid reads/writes (use-after-free, out-of-bounds)
# - Memory leaks (missing delete)
# - Uninitialized values
17. Development Notes
17.1 Adding New BC Types
When implementing new boundary condition types:
- Add to enum (en_FEM_BoundaryConditionImposing.hpp — the per-dimension kind a sideset stores):
enum class BoundaryConditionImposing {
Free,
Dirichlet,
Neumann,
Alpha,
Lambda,
Weak,
CustomBC,
UNDEFINED
};
- Update SideSet (cl_FEM_SideSet.{hpp,cpp}):
void impose_custom_bc(const real aValue, const uint aDofType = 0);
void SideSet::impose_custom_bc(const real aValue, const uint aDofType)
{
mBcValues(aDofType) = aValue;
mBcTypes(aDofType) = BoundaryConditionImposing::CustomBC;
}
- Implement assembly in IWG:
void IWG::compute_surface_loads(Element* elem, Vector<real>& f)
{
SideSet* sideset = (SideSet*) elem->parent();
if (sideset->bc_type(0) == BoundaryConditionImposing::CustomBC) {
real bc_value = sideset->bc_value(0);
}
}
- Test:
- Manufactured solution with known analytical result
- Mesh convergence study (h-refinement)
- MPI parallel consistency check
17.2 Adding New DOF Entity Types
Currently supported: Nodes, Edges, Faces, Cells, Lambdas (facets).
To add new entity type (e.g., "Volumes" for 3D interior DOFs):
- Update IWG interface (cl_IWG.hpp):
virtual uint number_of_dofs_per_volume( const id_t aBlockID ) const { return 0; }
- Update DofData (cl_FEM_DofMgr_DofData.{hpp,cpp}):
id_t mVolumeDofOffset = gNoID;
id_t volume_dof_id(const id_t aVolumeID, const uint aDofType) const;
index_t count_volume_dofs(IWG* aIWG, ...);
void compute_dof_offsets(IWG* aIWG)
{
mVolumeDofOffset = mCellDofOffset + max_cell_id * mNumDofTypes;
}
id_t DofData::volume_dof_id(const id_t aVolumeID, const uint aDofType) const
{
return mVolumeDofOffset + aVolumeID * mNumDofTypes + aDofType;
}
- Update Element (cl_FEM_Element.{hpp,cpp}):
- Test thoroughly:
- DOF ID uniqueness (no collisions)
- Mesh convergence (verify DOF count scaling)
- Parallel consistency (all procs agree on DOF IDs)
17.3 Debugging DOF Issues
Common DOF problems:
- DOF ID collisions:
Map<id_t, uint> dof_id_count;
for (Dof* dof : dofs) {
dof_id_count(dof->id())++;
}
for (auto& pair : dof_id_count) {
"DOF ID %lu has %u instances (should be 1)",
pair.first, pair.second);
}
- Missing DOFs:
index_t expected_node_dofs =
mesh->number_of_nodes() * num_dof_types;
index_t actual_node_dofs = ;
"Expected %lu node DOFs, found %lu",
expected_node_dofs, actual_node_dofs);
- Hanging node constraint errors:
for (Dof* h : hanging_dofs) {
real h_value = 0.0;
for (uint i = 0; i < h->number_of_sources(); ++i) {
h_value += h->weight(i) * h->source(i)->value();
}
real error = std::abs(h_value - h->value());
"Hanging node constraint violated: error = %e", error);
}
18. Literature References
18.1 Finite Element Theory
Core FEM Textbooks:
- Zienkiewicz & Taylor, "The Finite Element Method" Vol. 1-2
- Vol. 1: DOF management, assembly, Chapter 2-3
- Vol. 2: Advanced topics, Chapter 8-10
- Hughes, "The Finite Element Method" (2000)
- Chapter 1-3: FEM fundamentals
- Chapter 4: Locking, mixed methods
- Chapter 5: Element technology
- Bathe, "Finite Element Procedures" (2014)
- Chapter 3: Formulation of FEM
- Chapter 8: Solvers and algorithms
- Chapter 9: Nonlinear analysis
Hanging Nodes and Constraints:
- Brenner & Scott, "The Mathematical Theory of Finite Element Methods" (2008)
- Chapter 5: Hanging node constraints
- Chapter 9: Multi-level methods
18.2 Sparse Solvers
Direct Solvers:
Iterative Solvers:
- Saad, "Iterative Methods for Sparse Linear Systems" (2003)
- Chapter 6: Krylov subspace methods
- Chapter 10: Preconditioning
- PETSc User Manual - https://petsc.org/
- KSP (Krylov Subspace) solvers
- PC (Preconditioner) options
18.3 Graph Partitioning and Reordering
Algorithms:
- Cuthill & McKee (1969), "Reducing the Bandwidth of Sparse Symmetric Matrices"
- ACM Conference Proceedings
- Reverse Cuthill-McKee (RCM) algorithm
- George (1973), "Nested Dissection of a Regular Finite Element Mesh"
- SIAM J. Numer. Anal., Vol. 10, No. 2
- Fill-minimizing ordering
- Liu & Sherman (1976), "Comparative Analysis of the Cuthill-McKee and the Reversed Cuthill-McKee Ordering Algorithms"
- SIAM J. Numer. Anal., Vol. 13, No. 2
Software:
18.4 MPI and Parallel Computing
MPI Standards:
- MPI Forum, "MPI: A Message-Passing Interface Standard" (2021)
Parallel FEM:
- Gropp et al., "Using MPI" (3rd Edition, 2014)
- Practical MPI programming
- Collective operations, derived datatypes
- Bathe & Wilson, "Numerical Methods in Finite Element Analysis" (1976)
- Chapter 8: Parallel solution strategies
- Domain decomposition methods
18.5 BELFEM-Specific References
Related BELFEM Modules:
- IWG Module (src/fem/iwg/doc/): Physics layer, weak form assembly, time-stepping
- Interpolation Module (src/fem/interpolation/doc/): Shape functions, integration points, Nédélec elements
- Sparse Module (src/sparse/doc/): Sparse matrix storage, solver interfaces
- Graph Module (src/math/graph/doc/): Graph algorithms (BFS, DFS, RCM, METIS)
- Mesh Module (src/mesh/doc/): Mesh data structures, I/O formats
BELFEM Papers:
- messe2023.txt - BELFEM core paper (Messe) - Architecture, h-φ formulation
- arsenault2023.txt - Magnetodynamic h-φ coupling (Arsenault) - JEDI system usage
- alves2022b.txt - Thin-shell theory and cohomology cuts - Hanging nodes, constraints
See: doc/literature_references.md for complete reference list with DOIs.
19. H-Phi Mixed Formulation (Electromagnetics)
Note: This section covers Maxwell-specific applications of the DOF Manager. For general DOF management, see sections 1-18.
19.1 Overview
BELFEM implements a sophisticated H-Phi mixed formulation (maxwell::Formulation::HPhi) for electromagnetic simulations that combines:
- H-formulation in conductors: Magnetic field intensity H as primary variable (edge DOFs)
- Phi-formulation in air/ferro: Scalar potential φ as primary variable (node DOFs)
Why Mixed Formulation?
- H-formulation directly captures eddy currents in conductors (∇ × H = J)
- Phi-formulation is computationally efficient in non-conducting regions
- Thin shells naturally couple both formulations at interfaces
19.2 Governing Equations
| Domain Type | Primary Variable | Governing Equation | Weak Form |
| Conductor | H (edge DOF) | ∇ × (ρ ∇ × H) + μ₀ ∂**H**/∂t = 0 | ∫ (ρ ∇ × w) · (∇ × H) dV + ∫ μ₀ w · ∂**H**/∂t dV = 0 |
| Air/Ferro | φ (node DOF) | ∇ · (μ ∇φ) = 0, H = -∇φ | ∫ (μ ∇w) · (∇φ) dV = 0 |
Where:
- ρ = electrical resistivity [Ω·m]
- μ = magnetic permeability [H/m] (μ₀ in air, μ(H,T) in ferro)
- w = edge test functions (Nédélec)
- w = nodal test functions (Lagrange)
References:
- Messe et al. 2023 - BELFEM core paper (SUST) - Equations 6-8
- Arsenault et al. 2023 - Magnetodynamic h-φ coupling (IEEE TASC) - Section II, Eq. 5-7. Read together with the 2026 erratum (DOI:10.1109/TASC.2026.3686487), which corrects the air-domain strong and weak forms of that paper
19.3 Domain Types and DOF Assignments
Domain Type Enum: src/mesh/en_DomainType.hpp (excerpt)
enum class DomainType {
Air = 1,
Buffer = 2,
Ferro = 3,
Coil = 4,
Conductor = 5,
ThinShell = 9,
InterfaceCondAir = 25,
InterfaceCondFerro = 26,
InterfaceFerroAir = 27,
};
DOF Field Assignments:
| Domain | DOF Fields | Entity | Physics |
| Air | "phi" | Node | H = -∇φ |
| Ferro | "phi" | Node | H = -∇φ, μ = μ(H,T) |
| Conductor | "edge_h" | Edge | ∇ × H = J |
| (Higher order) | + "face_h" | Face | Second-order edge elements |
| ThinShell | "edge_h" + "phi" | Edge + Node | Both formulations! |
Facet Master Priority (for interface orientation):
Conductor > Ferro > Air > Coil
This ensures consistent normal vectors at interfaces.
19.4 Field List Construction for Thin Shells
Reference: cl_Maxwell_FieldList.cpp:116-156
Thin shells get DOFs from both conductor and air domains:
for (const string & tDof : Conductor) {
ThinShell.push(tDof);
}
for (const string & tDof : Air) {
ThinShell.push(tDof);
}
This is automatic - users don't need to manually specify thin shell DOFs.
19.5 Hanging DOFs in Electromagnetic Context: Cohomology Cuts
In electromagnetic problems, hanging DOFs arise from enforcing current boundary conditions via cohomology cuts.
Problem: Impose electric current I through a conductor in a multiply-connected non-conducting domain.
Solution: Create a "cut" surface where the scalar potential φ has a discontinuity:
Implementation:
- Duplicate nodes on one side of the cut surface
- Original nodes → independent DOFs (φ⁻)
- Duplicated nodes → hanging DOFs (φ⁺)
- T-matrix enforces: φ⁺ = φ⁻ + I
Creation: MaxwellFactory::create_hanging_edges_and_facets() at line 1008 in cl_MaxwellFactory.cpp
Collection: DofData::collect_hanging_dofs() in cl_FEM_DofMgr_DofData.cpp
T-Matrix Setup: DofData::create_dofwise_t_matrices_master() in cl_FEM_DofMgr_DofData.cpp
Workflow:
1. Mesh Creation
└─> MaxwellFactory::create_hanging_edges_and_facets()
└─> Creates hanging edges/facets at interfaces
2. DOF Initialization
└─> DofData::create_dofs()
└─> DofData::collect_hanging_dofs()
└─> Identifies all hanging DOFs
└─> DofData::create_dofwise_t_matrices_master()
└─> Computes T-matrices for each hanging DOF
3. Matrix Assembly
└─> DofManager::compute_jacobian()
└─> For each element:
└─> if has_t_matrix(): K_condensed = T^T * K_element * T
4. System Solution
└─> Solve reduced system (independent DOFs only)
└─> Hanging DOF values computed via: φ_hanging = Σ w_i * φ_source_i
References:
- Alves et al. 2022b - Thick cuts / cohomology basis (Equation 10)
- Riva et al. 2023 - Cohomology cuts for transport current (IEEE TASC)
- Schnaubelt et al. 2023 - Thick cuts interpretation (IEEE TASC)
See also: Section 11: Hanging Nodes and T-Matrices for general mesh refinement hanging nodes.
19.6 Static Condensation vs. Lagrange Multipliers
BELFEM uses static condensation exclusively for all interface coupling, including:
- Thin shell h-φ interfaces
- Cohomology cuts for transport current
- Hanging DOFs from duplicated nodes
Why Static Condensation? (following Messe et al. 2023's recommendation)
| Aspect | Static Condensation | Lagrange Multipliers |
| Diagonal zeros | ✅ Avoids zeros on main diagonal | ❌ Introduces zeros (saddle-point) |
| Solver robustness | ✅ Better for direct solvers (MUMPS/STRUMPACK) | ⚠️ Requires specialized saddle-point solvers |
| Global DOF count | ✅ Reduces DOF count | ❌ Increases DOF count (adds λ DOFs) |
| Matrix structure | ✅ Positive definite (or close) | ❌ Indefinite saddle-point |
| Implementation | ✅ Element-level (T-matrices) | ⚠️ Requires global constraint assembly |
| Parallelization | ✅ Better (local elimination) | ⚠️ More complex (global coupling) |
Code Evidence:
- Extensive T-matrix infrastructure (cl_FEM_Tmatrix.hpp/cpp) with CSR storage
- Hanging DOF mechanisms (is_hanging(), has_t_matrix())
- Assembly using T^T * K * T projection (element-level condensation before global assembly)
- No saddle-point matrix structure
Theoretical Foundation:
- Felippa §10.2 — Static condensation theory
- Bathe §8.5 — Implementation in production FEM codes
- Messe et al. 2023, Section 3 — BELFEM's application to h-φ interfaces
Historical Note: Early versions of Messe et al. 2023 mentioned Lagrange multipliers, but the production code has evolved to use static condensation exclusively, which is the "preferred method" cited in Messe et al. 2023.
19.7 Matrix Function Selection
Reference: IWG_Maxwell::link_to_group, cl_IWG_Maxwell.cpp (anchor: the switch ( aGroup->domain_type() ))
Matrix assembly functions are selected by domain type. For the conductor and thin-shell domains, the selection is by solver algorithm only; see §20.3 for why:
void IWG_Maxwell::link_to_group(Group * aGroup) {
switch (aGroup->domain_type()) {
case DomainType::Air:
mFunMKF = &maxwell::phi_tri3;
break;
case DomainType::Ferro:
mFunMKF = (this->algorithm() == SolverAlgorithm::NewtonRaphson) ?
&maxwell::phi_ferro_newton
: &maxwell::phi_ferro_picard;
break;
case DomainType::Conductor:
case DomainType::ThinShell:
if (this->algorithm() == SolverAlgorithm::NewtonRaphson) {
mFunMKF = tMat->is_constant(MaterialProperty::mu) ?
&maxwell::h_newton_mu0
: &maxwell::h_newton_mu;
} else {
mFunMKF = &maxwell::h_picard;
}
break;
case DomainType::InterfaceFerroAir:
BELFEM_ERROR(
false,
"Ferro-Air Interfaces must be disabled!" );
break;
}
}
Disabled Interfaces (currently throw errors):
- InterfaceCondAir: Conductor-Air direct interface
- InterfaceCondFerro: Conductor-Ferro direct interface
- InterfaceFerroAir: Ferro-Air interface — nodes are duplicated for visualization only; no weak form exists in 2d or 3d
- InterfaceAirCoil: Air-Coil interface
- InterfaceFerroCoil: Ferro-Coil interface
Reason: Thin shells now handle conductor-to-air/ferro transitions more accurately.
20. Thin Shell Formulation
20.1 Physics and Motivation
Problem: Thin conducting regions (tapes, films, shells) where:
- Thickness δ << λ (characteristic length scale)
- Normal field variation is negligible
- In-plane currents dominate
Solution: 2.5D formulation where:
- Tangential fields computed from edge DOFs (∇ × H = J)
- Normal fields imposed from the volume elements on either side (−∇φ on an air/ferro side, the conductor's own Nédélec trace on an h-conductor side, §20.4)
Benefits:
- Avoids meshing thin dimension (would require prohibitively fine mesh)
- Captures physics accurately (validated against 3D in Alves et al. 2022b, Alves et al. 2024)
- Computationally efficient (reduces problem dimensionality)
20.2 Mathematical Decomposition
The magnetic flux density is decomposed:
Where:
- B_tangent = f(edge_h) : In-plane component from curl of H
- B_normal = f(φ_master, φ_slave) : Through-thickness component from external φ
Key Insight: Normal field penetration is an external boundary condition, while tangential fields are determined by local current flow.
Thin-Shell Interface Conditions (Alves et al. 2022b, Appendix A, Eq. 19):
[n × h] = j_s (tangential jump)
[n · b] = 0 (normal continuity)
Magnetodynamic Coupling (Arsenault et al. 2023, Eq. 5-7; with the 2026 erratum for the air domain): Interface coupling derived from Faraday's law ensures tangential E continuity when E^CC = E^C.
20.3 Where the Thin-Shell Assembly Lives
There is no thin-shell kernel to call. A thin shell is assembled by the same H-formulation kernels as a bulk conductor, and everything that makes it a shell is resolved one level down, in the Calculator's material data object. The split is worth internalizing before reading any of the code:
| Layer | Anchor | Decides |
| Kernel selection | DomainType::ThinShell in IWG_Maxwell::link_to_group, cl_IWG_Maxwell.cpp | solver algorithm (Picard vs. Newton) and whether mu is constant |
| Per-point field and material math | tIsThinShell in the calculator::MaxwellData constructor, cl_FEM_Calculator.cpp | edge vs. node field assembly, the normal-field term, the rho family, the temperature source |
Kernel selection. Conductor and ThinShell share one case label, and the choice inside it never looks at the material's shape:
case DomainType::Conductor :
case DomainType::ThinShell :
{
Material * tMat = aGroup->material();
if ( this->algorithm() == SolverAlgorithm::NewtonRaphson )
{
mFunMKF = tMat->is_constant( MaterialProperty::mu ) ?
& maxwell::h_newton_mu0
: & maxwell::h_newton_mu ;
}
else
{
mFunMKF = & maxwell::h_picard ;
}
break ;
}
mt_maxwell_h.hpp exports exactly six kernels: the three above, the facet stabilization kernel h_ghost, and the edge-coating wall pair h_side_connector / h_side_connector_newton. The per-material h_ts_* family that earlier revisions of this guide tabulated no longer exists.
What a kernel actually looks like. h_picard is the short one, and it shows the pattern: ask aCalc->maxwell() for the point values, assemble, and never branch on the material:
void
h_picard( Calculator * aCalc, TimestepMatrices * aMatrices )
{
const Vector< real > & w = aCalc->integration()->weights();
calculator::MaxwellData * mx = aCalc->maxwell();
...
for ( uint k = 0; k < aCalc->num_intpoints(); ++k )
{
const Matrix< real > & E = aCalc->E( k );
const Matrix< real > & C = aCalc->C( k );
real mu = mx->compute_mu( k );
real rho = mx->compute_rho( k );
real wdV = w( k ) * aCalc->dV( k );
aMatrices->M() += trans( E ) * E * ( mu * wdV );
aMatrices->K() += trans( C ) * C * ( rho * wdV );
...
}
}
compute_mu and compute_rho are function-pointer calls into MaxwellData. On a thin-shell block those pointers were bound, at construction, to implementations that know about the normal field and about the tape normal. That binding is the whole thin-shell formulation.
The bindings that matter. From the tIsThinShell branch of the MaxwellData constructor:
mFunH = & MaxwellData::compute_h_ts_edge ;
mFunB = & MaxwellData::compute_b_ts ;
plus a rho family chosen from the material (§20.6), and a temperature source chosen from whether a thermal kernel exists (§20.7).
There is no edge-versus-node test in that branch, and there does not need to be: a ThinShell block shares the Conductor dof table, which is edge_h (FieldList::collect_block_dofs), so a shell is always edge-interpolated. Beware the near-miss: the FieldList::ThinShell member in the same file is the sideset table and does carry phi; block routing is the one that decides interpolation. Ferro and Buffer blocks take compute_h_bulk_node instead and do not run thin-shell facet recovery at all (Air never reaches MaxwellData).
20.4 The Normal Field
The tangential part comes from the block's own edge DOFs. The normal part does not: it is obtained from the volume elements on either side of the facet through the free function compute_hn in cl_FEM_Calculator.hpp, which delegates the per-side trace to compute_h_trace in the same header.
inline const Vector< real > &
compute_hn( Calculator * aCalc , const uint k )
{
Vector< real > & hn = aCalc->vector("hn");
BELFEM_ASSERT( aCalc->element_is_linear(),
"compute_hn doesn't work for ..." );
if ( k == 0 )
{
Vector< real > & phi_m = aCalc->vector("phi_m");
Vector< real > & phi_s = aCalc->vector("phi_s");
bool tMasterIsConductor ;
bool tSlaveIsConductor ;
Calculator * tCalc = aCalc->get_normal_calculator(
phi_m, phi_s, tMasterIsConductor, tSlaveIsConductor );
Vector< real > & hk = aCalc->vector("hk");
Vector< real > & hm = aCalc->vector("hm");
compute_h_trace( tCalc, true, tMasterIsConductor, phi_m, hk, hm );
Vector< real > & hs = aCalc->vector("hs");
compute_h_trace( tCalc, false, tSlaveIsConductor, phi_s, hk, hs );
hn = 0.5 * ( hm + hs ) ;
Vector< real > & n = aCalc->vector("normal");
n = tCalc->normal( k );
hn = dot( hn, n ) * n ;
}
return hn;
}
compute_h_trace dispatches on the kind of the volume block on that side:
| side | test ( Calculator::volume_is_conductor ) | trace |
| φ-region ( air, ferro, buffer ) | block DomainType is not Conductor | -B_σ(0) * phi_σ, constant on a linear element |
| h-conductor | block DomainType::Conductor | integration-weighted mean over the facet rule of E_σ(k) * q_σ, with q_σ read from the edge_h mesh field ( nedelec_data_master_h / nedelec_data_slave_h ) |
The conductor side has no potential: its nodal phi is bookkeeping ( zero on conductor-only nodes, whatever the neighboring air holds on shared nodes ) and must not be differentiated. Its own lowest-order Nédélec field is a + b × r, so the normal trace is linear over the facet and the weighted mean is the exact centroid value in 2D and 3D. The rule rests on [n · B] = 0 across the sheet with μ = μ₀ on the conductor side, which is why MaxwellFactory::assign_materials refuses a magnetic conductor as a volume neighbor of a shell.
Five properties of this routine are load-bearing:
1. The result is projected, not just averaged. The last line, hn = dot( hn, n ) * n, is what makes hn a normal field. Dropping it leaves an O(|H_t|) tangential contamination from the volume side, which is the same order as the tape's own field through [n x h] = j_s. Any code that recomputes this average for display, rather than calling compute_hn, has to repeat the projection or it will report a different |B| and a different angle than the solve used.
2. It is computed once per element, at k == 0. For flat linear shells the φ-side gradient is constant over the facet and the h-side mean is taken over the facet rule inside the k == 0 branch, so the guard is a cache, not an approximation. The BELFEM_ASSERT on element_is_linear() is the guardrail: for higher-order shells the volume integration points are not the surface points this routine assumes, and the caching is invalid as well.
3. get_normal_calculator is the sanctioned bridge from a shell block to the volume elements on either side. It links the tape sideset's calculator to the facet, reports per side whether the volume is an h-conductor, fills phi_m and phi_s on the φ-sides, and returns a calculator that can produce Bm, Bs, Em, Es, and the facet normal. Do not reach into the neighbor blocks by hand.
4. The mesh field is the live DOF storage. Calculator::q() reads mMesh->field( dof->field_index() ), rank 0 writes every DOF into its field after each linear solve, and DofManager::solve_from_residual distributes the fields to the workers before the next assembly; worker-rank Dof::value() is never updated. Reading edge_h by edge index is therefore identical to the master's q() and is the only accessor that exists for an aura master, which carries no DOF objects.
5. The normal direction has a sign, and it is a modeling input. n points from the master volume element to the slave one, which is also the layer-stack direction. A minus prefix on the thinshell sidesets key flips both together. §20.5 depends on this. The projection itself is sign-blind.
compute_b_ts then keeps the two parts apart, because the postprocessor and the anisotropy law both want them:
inline const Vector< real > &
calculator::MaxwellData::compute_b_ts( const uint aIndex )
{
this->compute_h( aIndex );
real mu = this->compute_mu( aIndex );
mBt = mu * mHt ;
mBn = mu * mHn ;
mB = mBt + mBn ;
return mB ;
}
The material laws are evaluated on the total b. That matters: it is what couples the surrounding field into the tape's critical current and into a metal's magnetoresistance.
20.5 Superconductor Anisotropy
An HTS tape's critical current depends on where the field points relative to the tape plane. BELFEM measures that with Calculator::bn_angle, the angle between b and the tape normal n:
inline real
Calculator::bn_angle( const Vector< real > & b, const Vector< real > & n, real & norm_b ) const
{
norm_b = norm( b ) ;
BELFEM_ASSERT( std::abs( norm( n ) - 1.0 ) < 1e-6,
"tape normal must be normalized" ) ;
return norm_b < 1e-6 ? constant::pi*0.5 :
std::acos( std::clamp( dot( n, b ) / norm_b, -1.0, 1.0 ) ) ;
}
The angle is unfolded onto [0, pi], deliberately. theta < pi/2 means b has a component along +n. Measured jc(theta) tables are asymmetric about pi/2, so folding the angle into [0, pi/2] would average two physically different lobes into one. Analytic laws that are even in theta (ModifiedKim) fold internally, by construction, so they are unaffected. Do not re-fold this angle; the sideset sign (§20.4, item 4) is what selects the lobe.
Physical reading:
- theta = 0 or pi: B perpendicular to the tape, giving the minimum Jc for a typical REBCO tape (easy vortex penetration through the ab-planes)
- theta = pi/2: B in the tape plane, giving the maximum Jc (intrinsic pinning between the CuO layers)
The lobes at 0 and pi are not required to be equal, and for a real tape they are not.
bn_angle is not bj_angle. The second angle, between b and j, is the Kohler angle a normal metal's magnetoresistance needs, and it is what the Newton tangent block add_rho_field_tangent in mt_maxwell_h.cpp differentiates. The two are bound separately in MaxwellData for exactly that reason: for an HTS block mFundRhodBeta stays return_zero, because binding it would apply the metal law's dbeta/dq rows to a tape angle they do not describe. In 2-D, bj_angle is pi/2 by convention (the current is perpendicular to the model plane), while bn_angle is a genuine geometric quantity in both 2-D and 3-D.
20.6 Material Models
The rho law is selected in the deck, not in C++, and it is bound once per block in the MaxwellData constructor. Nothing downstream branches on it.
Pure metals. compute_rho_metal is evaluated as rho(T, |B|, beta_bj). Field dependence follows the Kohler magnetoresistance, and this case binds the |B| and beta Newton channels (compute_drhodb_metal, compute_drhodbeta_metal).
HTS. Three laws, chosen with the resistivity type key, each in a plain and a defect flavor:
| resistivity type | Thin-shell binding | Notes |
| power-law (default), powerlaw | compute_rho_powerlaw_ts | E = ec (J/Jc)^n, in parallel with the normal-state channel (Duron 2004) |
| piecewise | compute_rho_piecewise_ts | Bezier flux-flow blend above roughly 1.4 Jc |
| riva | compute_rho_riva_ts | same parallel model as powerlaw, made total over the whole table: dead defects and spline over/underflow fall back to the fully normal branch, and n -> 1 near Tc takes a finite ohmic form instead of producing NaN |
A defect { file : ...; label : ...; } subsection swaps each of these for its _ts_defect twin, which additionally passes the integration point's coordinates and the current time, allowing a plugin to modulate Jc in space and time. The bulk conductor equivalents carry _bulk instead of _ts in the same naming scheme.
jc and n come either from an explicit jc + n pair or from a file holding Jc(B,theta,T) and n(B,theta,T) tables; the two are alternatives, not to be combined. ec defaults to 1e-4 V/m. The resistivity type value is case sensitive.
For the table contract, the interpolation scheme, and what the tables are allowed to do at their edges, see src/physics/materials/doc/.
20.7 Thermal Coupling
There is no thermally coupled kernel either. The temperature source is a third function pointer, bound according to whether the run has a thermal kernel at all:
if ( mThermalCalculator != nullptr )
{
mFunT = & MaxwellData::compute_T_fem ;
}
else
{
mFunT = & MaxwellData::compute_T_const ;
}
mThermalCalculator is resolved in the MaxwellData constructor by looking up this block's id in the thermal kernel's DofManager. The block_exists() guard there is not decoration: block() returns the EmptyBlock, which carries a live calculator, for an unknown id, so a block that is not part of the thermal problem (a side connector, for instance) would silently adopt the wrong peer.
compute_T_fem interpolates the thermal block's own solution at the integration point:
real T = dot( mThermalCalculator->Nvec( aIndex ), mThermalCalculator->q() ) ;
mTClamped = ( T < gTmin ) || ( T > mTmax ) ;
mT = mTClamped ? std::clamp( T, gTmin, mTmax ) : T ;
The clamp is part of the contract, and so is the flag. Transient nonlinear iterates routinely swing outside a material table's window, and clamping keeps the evaluation legal. While clamped, every dT derivative is forced to zero, so the tangent stays consistent with the value actually used. A converged solution sitting at a clamp is a modeling error rather than a numerical one, and the controller can ask for T_clamped() to detect it. compute_T_const honors the same contract for the uncoupled case, pinning a user-set gTbulk that falls outside the window.
Coupling direction: the electromagnetic assembly reads temperature, and the Joule heating that closes the loop is assembled on the thermal side (src/fem/thermal/matrices/mt_thermal_h.cpp).
21. Maxwell-Specific Implementation
21.1 Custom Vectors for Thin Shells
Thin shell functions request work vectors by name:
Reference: IWG_Maxwell::create_custom_vectors_and_matrices()
Vector<real> & bt = aCalc->vector("bt");
Vector<real> & bn = aCalc->vector("bn");
Vector<real> & b = aCalc->vector("b");
Vector<real> & j = aCalc->vector("j");
Vector<real> & phi_m = aCalc->vector("phi_m");
Vector<real> & phi_s = aCalc->vector("phi_s");
Vector<real> & hk = aCalc->vector("hk");
On a tape-sideset calculator, "nedelec_h" ( master-sized ) and "nedelec_h_s" ( slave-sized ) hold the edge DOFs of an h-conductor volume side, filled by nedelec_data_master_h() / nedelec_data_slave_h() from the edge_h field.
These are allocated once during IWG initialization, then reused during assembly (avoids repeated allocation in hot path).
21.2 Calculator Interface for Electromagnetics
Key Methods:
class Calculator {
const Matrix<real> & E(uint k);
const Matrix<real> & C(uint k);
const Matrix<real> & N(uint k);
Vector<real> Nvec(uint k);
const Matrix<real> & Bm(uint k);
const Matrix<real> & Bs(uint k);
const Vector<real> & integration()->weights();
uint num_intpoints();
real dV(uint k);
const Vector<real> & q();
const Vector<real> & normal();
const Matrix<real> & X();
Vector<real> & vector(const string & aName);
Calculator * get_normal_calculator(Vector<real> & phi_m, Vector<real> & phi_s,
bool & aMasterIsConductor, bool & aSlaveIsConductor);
const Vector<real> & nedelec_data_master_h();
const Vector<real> & nedelec_data_slave_h();
};
21.3 Mesh Entity Access for Interfaces
Elements can query their master/slave neighbors and facet information:
Reference: cl_FEM_Element.hpp:244-264
class Element {
Element * master();
Element * slave();
mesh::Facet * facet();
bool has_t_matrix() const;
const Tmatrix * t_matrix();
};
Usage in Thin Shell Functions: do not gather neighbor DOFs by hand — the per-side traces go through get_normal_calculator and compute_h_trace ( §20.4 ). In particular Dof::value() is not the live value off rank 0, and an aura neighbor has no DOF objects at all; the mesh fields are.
21.4 Matrix Function File Organization
The matrix kernels live in src/fem/maxwell/matrices/ (they moved there in the maxwell kernel split; the layout below is the current one, not the pre-split single-file layout this guide originally described):
Note: the old h_ts_* thin-shell kernel family and the mt_maxwell_thinshell.hpp/cpp stub are gone — thin shells are handled through the layer-block machinery, and the stub file was removed as this guide once recommended.
21.5 Key Line References for Maxwell
H-Phi Formulation
Thin Shells
Maxwell Factory
21.6 Literature References for H-Phi Formulation
Essential Reading Order:
- messe2023.txt → Main BELFEM document (Equations 6–8, Section 2.7)
- alves2022b.txt → Thin-shell theory (Appendix A, Eq. 19)
- arsenault2023.txt → Magnetodynamic coupling (Section II, Eq. 5-7)
- alves2024.txt → Thin cuts implementation
- dular2021.txt → Stability theory (inf-sup, hierarchical enrichment)
Quick Reference Table:
| Topic | Search Terms | Primary Papers |
| H-φ Interface Coupling | "Faraday", "phi", "interface", "static condensation" | Messe et al. 2023, Arsenault et al. 2023, Alves et al. 2024 |
| Thin-Shell 2D | "thin-shell", "auxiliary 1-D", "interface conditions" | Alves et al. 2022b, Messe et al. 2023, Alves et al. 2024 |
| Thin-Shell 3D | "3-D thin-shell", "surface representation" | Alves et al. 2022a, Schnaubelt et al. 2023 |
| Transport Current/Cuts | "thick cuts", "cohomology", "up(phi) - down(phi)" | Schnaubelt et al. 2023, Alves et al. 2024, Riva et al. 2023 |
| Stability | "inf-sup", "hierarchical", "oscillations" | Dular et al. 2021, Messe et al. 2023 |
| Solver Performance | "MUMPS", "STRUMPACK", "computation time" | Messe et al. 2023, Arsenault et al. 2021 |
Paper Locations: /home/christian/codes/belfem/literature/papers/fem as dular2021.txt through schnaubelt2023.txt
FEM Textbook References (for foundational theory):
| Topic | Primary Reference | BELFEM Application |
| Mixed Formulations | Brenner Ch. 8 (inf-sup/LBB), Bathe §4.4 | H-Phi coupling stability |
| Edge Elements (H(curl)) | Zienkiewicz Vol. 1, Hughes, Bathe | Nédélec basis for edge_h DOFs |
| Static Condensation | Felippa §10.2, Bathe §8.5 | T-matrix implementation |
| Newton-Raphson Methods | Bathe §8.6, Zienkiewicz Vol. 2 Ch. 3 | Nonlinear HTS solver |
See: Section 18: Literature References for complete list.
21.7 Recommended Study Workflow for Maxwell
For understanding BELFEM's H-Phi implementation:
- Start with general DOF management:
- Read Sections 1-18 of this guide
- Understand JEDI system, hanging nodes, T-matrices
- BELFEM-specific formulation:
- Read Messe et al. 2023 (main BELFEM document - Equations 6-8)
- Read Arsenault et al. 2023 (magnetodynamic coupling - Section II)
- Read Alves et al. 2022b (thin-shell theory - Appendix A)
- Cross-reference with Bathe/Hughes for general FEM context
- Deep dive into thin shells:
- Theory: Alves et al. 2022b (equations) → Code: mt_maxwell_h.cpp (h_picard, h_newton_mu0, h_newton_mu) plus the thin-shell branch of the calculator::MaxwellData constructor in cl_FEM_Calculator.cpp
- Material models: src/physics/materials/doc/ → Code: Material::rho(), Material::jc()
- Thermal coupling: Messe et al. 2023 → Code: MaxwellData::compute_T_fem() and the Joule-heating consumer in src/fem/thermal/matrices/mt_thermal_h.cpp
- Validation and debugging:
- Benchmarks: Messe et al. 2023, Alves et al. 2022b, Alves et al. 2024 (expected results)
- Common pitfalls: See Section 1 (Common Pitfalls)
- Convergence issues: Bathe Ch. 8, Messe et al. 2023, Section 2.7
Cross-referencing strategy:
- "Why does BELFEM do X?" → Start with papers, verify with books
- "How should I implement Y?" → Start with books (algorithms), cross-check with papers (BELFEM specifics)
- "What's the theory behind Z?" → Books for general theory, papers for HTS application
21.8 Summary of Maxwell Implementation
The BELFEM framework implements a sophisticated H-Phi mixed formulation for electromagnetic simulations:
- DOF Manager provides unified management of different DOF types (node, edge, face, lambda)
- Hanging DOFs enable static condensation for cohomology cuts and constraints via T-matrices
- H-Phi coupling combines edge-based (H) and node-based (φ) formulations in a single system
- Thin shells serve as interface elements, coupling both formulations through field decomposition:
- Tangential components from edge DOFs (in-plane currents)
- Normal components from neighboring φ DOFs (external field)
This design enables:
- Accurate modeling of thin conducting regions
- Efficient handling of conductor-insulator interfaces
- Support for complex material models (HTS, magnetoresistance, thermal coupling)
- Topologically correct current boundary conditions via cohomology
Production-ready with full support for:
- Pure metals, alloys, and high-temperature superconductors
- Thermal coupling with separate thermal kernel
- Defects and spatial variations
- Multiple constitutive laws (power-law, piecewise E-J)
Key Insight: Thin shells naturally couple H and φ formulations by recognizing that normal field penetration is an external boundary condition, while tangential fields are determined by local current flow.
End of DofManager Usage Guide
For additional information:
- Module README: README.md (Quick reference)
- Project Documentation: ../../../../doc/README.md
- Coding Philosophy: ../../../../doc/coding_philosophy.md
- CLAUDE.md: ../../../../CLAUDE.md (AI assistant instructions)
Report bugs to: chris.nosp@m.tian.nosp@m..mess.nosp@m.e@lb.nosp@m.l.gov
Last updated: 2026-08-28