Element-kernel authoring#
How to add a new element kernel — shape functions, stiffness /
mass assembly, real-constant layout, registration. Companion to
Interop reader authoring (the reader-side of “a deck mentions
CBUSH, our parser doesn’t know it”). This page is the
kernel-side: “the parser can route to BUSH6, but
BUSH6 doesn’t exist yet.”
When you’re authoring a new kernel#
Two driving paths get you here.
A VM hits a card whose target element type isn’t in the kernel registry. The reader gap is filed under the
[interop]label, the kernel gap under[kernel]; both carryverify-blockeduntil they land. See the Coordinated card+kernel landings section of Interop reader authoring for the joint workflow.A new element formulation (EAS, MITC, hourglass-stabilised reduced integration, …) is needed to unlock a benchmark family that the existing kernel can’t reach. Author as a sibling module under
src/femorph_solver/elements/and register under a distinct neutral name; route the foreign-deck KEYOPT / SECTYPE to it via the spec class.
Whichever path got you here, the kernel itself is independent of how the reader routes to it. The contract below is what makes a kernel pluggable.
The contract — what a kernel must provide#
Every kernel subclasses femorph_solver.elements._base.ElementBase
and provides:
@register # registers under cls.name
class Spring(ElementBase):
name = "SPRING" # neutral kernel name
n_nodes = 2 # connectivity arity
n_dof_per_node = 3 # 3 = trans-only, 6 = trans+rot
vtk_cell_type = 3 # VTK_LINE / VTK_QUAD / VTK_HEXAHEDRON / ...
# dof_labels defaults to CANONICAL_DOFS[:n_dof_per_node]
@staticmethod
def ke(coords, material, real): # element stiffness, dense (n_dof, n_dof)
...
@staticmethod
def me(coords, material, real, lumped=False): # element mass
...
The base class fills dof_labels from CANONICAL_DOFS = ("UX",
"UY", "UZ", "ROTX", "ROTY", "ROTZ")[:n_dof_per_node] unless the
subclass overrides it (e.g. shells with explicit drilling DOFs).
The neutral-name convention#
Kernels register under their topology-first neutral name:
Neutral |
MAPDL alias |
NASTRAN |
Abaqus |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Foreign-deck name → neutral name happens at the interop boundary
(see Interop reader authoring’s _ELEMENT_MAP /
_ELEMENT_TYPE_MAP). The kernel never sees a vendor name.
Real-constant layout#
The real tuple’s slot meaning is per-element-family and the
reader’s responsibility. Document the layout at the top of
the kernel module so future readers can audit cross-vendor
parity:
"""Spring — 3D 2-node spring-damper (longitudinal mode only).
Real constants:
real[0] : K — spring stiffness (force / length)
real[1] : CV1 — linear damping (force · time / length, unused for K_e)
real[2] : CV2 — non-linear (cubic) damping (unused here)
real[3] : IL — initial length (unused)
"""
For beam-family elements specifically, the layout is fixed at
(A, IZZ, IYY, J) — see Interop reader authoring “The
materialisation contract” for the reader-side I1/I2 swap that
gets you there.
Material dictionary#
material is a dict keyed by canonical property names:
EX— Young’s modulus.PRXY— Poisson’s ratio.DENS— density.ALPX— thermal expansion coefficient.GXY(optional) — shear modulus override; defaultE / (2(1 + ν)).
Per-formulation hints (e.g. plane stress vs plane strain, EAS vs
B-bar) flow in via _QUAD4_PLANE_MODE, _QUAD4_PLANE_TECH
underscore-prefixed keys — the spec instance writes them.
DOF ordering#
The element returns ke and me in node-major,
DOF-minor order:
[u₁ⁿ⁰, u₂ⁿ⁰, …, u_{n_dof}ⁿ⁰, u₁ⁿ¹, u₂ⁿ¹, …, u_{n_dof}ⁿ¹, …]
i.e. the first n_dof_per_node rows are node 0’s DOFs in
dof_labels order, then node 1’s, etc. The assembly path
relies on this; do not ship a kernel with arbitrary
ordering.
The sequence — adding a new kernel#
Worked example: a hypothetical BUSH6 element — 2-node 6-DOF
spring/damper connector with three translational and three
rotational stiffnesses.
Step 1 — write the kernel module#
Drop src/femorph_solver/elements/bush6.py. Header docstring
cites the published source for the formulation (this is
Provenance inventory’s job — every non-trivial numerical kernel
must trace to a paper / textbook).
"""BUSH6 — 2-node 6-DOF translational+rotational connector.
Real constants:
real[0..5] : K_t1, K_t2, K_t3, K_r1, K_r2, K_r3
(per-DOF stiffnesses in element local frame)
References
----------
* Cook, Malkus, Plesha, Witt, *Concepts and Applications of
Finite Element Analysis*, 4th ed., Wiley, 2002, §2.10
(multi-DOF spring-damper between two nodes).
"""
from __future__ import annotations
import numpy as np
from femorph_solver.elements._base import ElementBase
from femorph_solver.elements._registry import register
@register
class Bush6(ElementBase):
name = "BUSH6"
n_nodes = 2
n_dof_per_node = 6
vtk_cell_type = 3 # VTK_LINE
@staticmethod
def ke(coords, material, real):
r = np.asarray(real, dtype=np.float64)
if r.size < 6:
raise ValueError("Bush6 requires REAL[0..5] = (K_t1, K_t2, K_t3, K_r1, K_r2, K_r3)")
# Local stiffness — diagonal 6×6 then sandwiched between two nodes.
k_local = np.diag(r[:6])
# Block layout: [[ K, -K], [-K, K]] in 12×12 element frame.
K = np.block([[k_local, -k_local], [-k_local, k_local]])
return np.ascontiguousarray(K)
@staticmethod
def me(coords, material, real, lumped=False):
return np.zeros((12, 12), dtype=np.float64)
Conventions:
keandmeare static methods. Kernels carry no instance state; everything they need comes through the three arguments.Validation lives at the kernel boundary — raise with a clear message naming the missing field. The interop layer should have already populated
realcorrectly, but the kernel shouldn’t trust it.Return
np.ascontiguousarray(...)for stiffness so the assembly path doesn’t pay for a copy.
Step 2 — register the spec#
Append to src/femorph_solver/elements/_specs.py:
@dataclass(frozen=True)
class Bush6Spec(ElementSpec):
name: str = "BUSH6"
# Add formulation kwargs here if the element has them
# (e.g. tech="enhanced" on QUAD4_PLANE).
This lets Model.assign(Bush6Spec, ...) and the spec-by-name
lookup work. For the simplest cases (no formulation kwargs) the
spec is just the name + class binding.
Step 3 — wire the importer#
Add to src/femorph_solver/elements/__init__.py so the kernel
is registered at import time:
from femorph_solver.elements.bush6 import Bush6 # noqa: F401
Without the import, @register never runs and elements.get("BUSH6")
raises.
Step 4 — element-kernel unit tests#
Drop tests/elements/bush6/test_bush6_kernel.py with the
element-only invariants:
Stiffness symmetry:
np.allclose(K, K.T).Rigid-body modes:
K @ rbm == 0for the 6 rigid-body modes expected for the topology.Patch-test or single-element analytical solution: a 2-node bush under prescribed displacement returns the analytical reaction.
Real-constant layout: each slot routes to the right physical effect.
Don’t run the assembled solver here — that’s the cross-solver
harness’s job. Element tests live in
tests/elements/<element>/ per Test-suite layout.
Step 5 — extend the interop maps#
If the new kernel has a vendor analogue, route the foreign-deck
name to it. See Interop reader authoring’s “Adding a card to
from_bdf” Step 4 for the _ELEMENT_MAP / _ELEMENT_TYPE_MAP
edits.
Step 6 — wire a verification row#
The kernel earns its keep against a published benchmark. Land the registry row + fixture pair in the same PR (or as a gating-on-each-other follow-up) — see Verification-manual spec for the registry entry shape.
Patterns by element family#
The reference kernels in src/femorph_solver/elements/ cover
the standard families. When you author a new one, mirror the
closest existing kernel’s idioms — the pattern below should
land:
Family |
Reference module |
Idiomatic shape |
|---|---|---|
3D solid |
|
Gauss-quadrature loop over the canonical reference
element; |
2D plane / shell membrane |
|
2×2 Gauss for membrane; thickness multiplies the
constitutive |
Shell (Mindlin-Reissner) |
|
Membrane + bending + transverse shear. Reduced integration on the shear term (1×1 Gauss) is standard; drilling stabilisation (Hughes-Brezzi penalty) keeps ROTZ from being a free DOF. |
Beam (Hermite cubic) |
|
Local frame from orientation vector → 12×12 local K / M → transform to 6-DOF-per-node global frame via block rotation matrix. |
Rod / truss |
|
1-D constant-strain element; |
Spring / point connector |
|
Same direction-cosine sandwich pattern as truss with a
scalar real instead of |
Lumped mass |
|
|
Common pitfalls#
Forgetting the import in
elements/__init__.py.@registeronly runs when the module is imported. A kernel that’s never imported is invisible to the registry.Returning a non-symmetric
ke. The assembly path addskedirectly into a CSRK; non-symmetry surfaces only at modal-solve time as complex eigenvalues. Test for symmetry in the kernel unit test, every time.Wrong DOF ordering. The “node-major, DOF-minor” rule isn’t enforced by the type system; eyeballing the 12×12 (or 24×24) matrix is the only safety net. Cross-check against a 1-element analytical solution before trusting it.
Hard-coded shape-function constants instead of building them from the canonical reference element.
hex20has three families of nodes (corner / mid-edge / mid-face) with different shape-function forms — copy the existing kernel’s pattern, don’t re-derive.Mutating
coords/material/real. Kernels are pure functions; the assembly loop reuses these arrays across many elements. In-place edits cascade silently.Mass matrix formulation. Default is consistent mass;
lumped=Trueswitches to row-sum lumping. Modal solves (Model.solve_modal) use consistent unless the problem explicitly requests lumped — the closed-form references usually assume consistent.Missing the
Referencessection in the docstring. Every non-trivial kernel must trace to a paper / textbook. See Provenance inventory;auditrows in that table are flagged for follow-up.
Where things live#
Concern |
Path |
|---|---|
Element kernel module |
|
Element registry |
|
Element specs (formulation kwargs, KEYOPT routing) |
|
Base class |
|
Per-element unit tests |
|
Beam-section helpers |
|
Stress-recovery helpers |
|
Provenance entry |
|
Companion pages: Interop reader authoring (reader-side of the “new card → new kernel” pair), Verification-manual spec (landing the kernel against a published benchmark), Provenance inventory (kernel-citation inventory).