Files
OneCAD-Simulation/crates/fem/src/dof.rs
T
bd55abea7f FEM foundation A: DofKind/DofLayout, shape-function library with Hessians, solvers on DofLayout (#35)
* fem: DofKind/DofLayout and the shape-function library with second derivatives

DofKind (Ux..Rz, T, P), DofSet, ElementDofs (per-node kinds and condensed
internal unknowns) and DofLayout: mesh nodes by label, kinds in order, then
reference points. basis::Basis covers line2/3, tri3/6, quad4/8/9, tet4/10,
wedge6/15, pyramid5, hex8/20/27 with values, first and second natural
derivatives (second-order forward AD, fem::jet) and the physical gradients,
Hessians and Laplacians with the map's second-derivative term. Tested for
Kronecker delta, partition of unity, polynomial completeness, finite
differences, affine and curved maps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* solver, explicit: number degrees of freedom with DofLayout

The implicit mesh numbers its unknowns with a DofLayout (mesh nodes by label
with the kinds their elements declare, then six per coupled reference point);
assembly adds element blocks by (node, DofKind), so a node may carry any mix
of kinds; couplings, boundary conditions, loads and results address
(node, kind). The explicit system keeps node-major translations, now checked
against its layout (NODE_DOFS). Solid formulations declare their ElementDofs.

Every static, frequency and explicit result of the solver, explicit, engine
and io test suites hashes bit-identically before and after (957 results).
docs/fem.md describes the DOF model and the shape-function conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* fem, solver: simplify after review

Block offsets resolved once per node pair; add_scalar/add_diagonal take
the kinds like add_block; one checked Jacobian inverse (dense::positive_inverse)
for the solid kernels and the basis library; static natural-node tables; the
Hessian path inverts the Jacobian once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:54:32 -04:00

459 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Degrees of freedom: what a node carries and how the global unknowns are numbered.
//!
//! Every unknown of a discrete system is a ([`DofKind`], node) pair. An element declares the
//! kinds each of its nodes uses ([`ElementDofs`]): three translations for solids, six
//! (translations and rotations) for shells and beams, two for plane elements, a temperature for
//! heat transfer, a pressure for mixed u–p elements, and the internal unknowns it condenses out
//! itself (incompatible modes, a discontinuous pressure), which never reach the global system.
//! A node carries the union of what its elements, constraints and boundary conditions need.
//!
//! [`DofLayout`] numbers them: mesh nodes in label order, each node's kinds in [`DofKind`] order,
//! then extra points (reference points) in the order they are added, so the same model always
//! gives the same numbering (the convention of T. J. R. Hughes, *The Finite Element Method*,
//! Dover 2000, §2.8–2.10, with the ID array replaced by per-node offsets). Assembly reads a
//! node's unknowns as one contiguous block, so a node pair is a dense block of the global matrix.
//!
//! Boundary conditions and loads address a node and a [`DofKind`]; the model's 1-based degree
//! of freedom numbers (1–3 translations, 4–6 rotations, 11 temperature, 8 pressure, the common
//! convention of the deck formats) convert with [`DofKind::from_number`].
use std::collections::BTreeMap;
use std::ops::Range;
/// One kind of nodal unknown. The order is the numbering order within a node.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DofKind {
/// Translation along X.
Ux,
/// Translation along Y.
Uy,
/// Translation along Z.
Uz,
/// Rotation about X.
Rx,
/// Rotation about Y.
Ry,
/// Rotation about Z.
Rz,
/// Temperature.
T,
/// Pressure (hydrostatic, of a mixed u–p element).
P,
}
impl DofKind {
pub const ALL: [DofKind; 8] = [DofKind::Ux, DofKind::Uy, DofKind::Uz, DofKind::Rx, DofKind::Ry, DofKind::Rz, DofKind::T, DofKind::P];
/// Position in [`DofKind::ALL`].
pub const fn index(self) -> usize {
self as usize
}
/// The translation along axis `axis` (0–2).
pub fn translation(axis: usize) -> Option<DofKind> {
[DofKind::Ux, DofKind::Uy, DofKind::Uz].get(axis).copied()
}
/// The rotation about axis `axis` (0–2).
pub fn rotation(axis: usize) -> Option<DofKind> {
[DofKind::Rx, DofKind::Ry, DofKind::Rz].get(axis).copied()
}
/// The structural direction 0–5 (translations, then rotations), `None` for T and P.
pub fn direction(self) -> Option<usize> {
(self.index() < 6).then_some(self.index())
}
/// The kind of a structural direction 0–5.
pub fn from_direction(direction: usize) -> Option<DofKind> {
DofKind::ALL.get(direction).copied().filter(|k| k.index() < 6)
}
/// The model's and decks' 1-based number: 1–3 translations, 4–6 rotations, 8 pressure,
/// 11 temperature.
pub fn number(self) -> u8 {
match self {
DofKind::T => 11,
DofKind::P => 8,
k => k.index() as u8 + 1,
}
}
/// The kind of a model degree of freedom number ([`DofKind::number`]).
pub fn from_number(number: u8) -> Option<DofKind> {
DofKind::ALL.into_iter().find(|k| k.number() == number)
}
/// What it is, in the user's terms.
pub fn describe(self) -> &'static str {
match self {
DofKind::Ux => "X",
DofKind::Uy => "Y",
DofKind::Uz => "Z",
DofKind::Rx => "rotation about X",
DofKind::Ry => "rotation about Y",
DofKind::Rz => "rotation about Z",
DofKind::T => "temperature",
DofKind::P => "pressure",
}
}
const fn bit(self) -> u8 {
1 << self as u8
}
}
/// A set of [`DofKind`]s, iterated in numbering order.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct DofSet(u8);
impl DofSet {
pub const EMPTY: DofSet = DofSet(0);
/// Ux, Uy, Uz: solids, trusses, membranes in 3D.
pub const TRANSLATIONS: DofSet = DofSet(0b0000_0111);
/// Ux, Uy: plane elements.
pub const PLANE: DofSet = DofSet(0b0000_0011);
/// Rx, Ry, Rz.
pub const ROTATIONS: DofSet = DofSet(0b0011_1000);
/// Translations and rotations: shells, beams, reference points.
pub const STRUCTURAL: DofSet = DofSet(0b0011_1111);
pub const TEMPERATURE: DofSet = DofSet(DofKind::T.bit());
pub const PRESSURE: DofSet = DofSet(DofKind::P.bit());
pub const fn of(kinds: &[DofKind]) -> DofSet {
let mut bits = 0;
let mut i = 0;
while i < kinds.len() {
bits |= kinds[i].bit();
i += 1;
}
DofSet(bits)
}
pub const fn contains(self, kind: DofKind) -> bool {
self.0 & kind.bit() != 0
}
/// Whether every kind of `other` is in this set.
pub const fn includes(self, other: DofSet) -> bool {
self.0 & other.0 == other.0
}
pub const fn union(self, other: DofSet) -> DofSet {
DofSet(self.0 | other.0)
}
pub const fn len(self) -> usize {
self.0.count_ones() as usize
}
pub const fn is_empty(self) -> bool {
self.0 == 0
}
/// The position of `kind` among this set's kinds (its offset in a node's block).
pub const fn position(self, kind: DofKind) -> Option<usize> {
if self.contains(kind) { Some((self.0 & (kind.bit() - 1)).count_ones() as usize) } else { None }
}
/// The kinds, in numbering order.
pub fn iter(self) -> impl Iterator<Item = DofKind> {
DofKind::ALL.into_iter().filter(move |k| self.contains(*k))
}
}
impl FromIterator<DofKind> for DofSet {
fn from_iter<I: IntoIterator<Item = DofKind>>(iter: I) -> DofSet {
DofSet(iter.into_iter().fold(0, |m, k| m | k.bit()))
}
}
/// What an element type contributes to the unknowns: the kinds every one of its nodes carries
/// (its element matrices are ordered node by node, these kinds in order within a node) and the
/// internal unknowns it condenses out before assembly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ElementDofs {
pub node: DofSet,
pub internal: Internal,
}
/// Element-internal unknowns, condensed at the element level (static condensation; Hughes
/// 2000, §4.7 for incompatible modes, §4.4 for discontinuous pressure).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Internal {
/// Incompatible (bubble) displacement modes.
pub incompatible_modes: usize,
/// Discontinuous pressure unknowns of a mixed element.
pub pressure: usize,
}
impl Internal {
pub const NONE: Internal = Internal { incompatible_modes: 0, pressure: 0 };
pub const fn count(self) -> usize {
self.incompatible_modes + self.pressure
}
}
impl ElementDofs {
/// Three translations per node, nothing internal.
pub const SOLID: ElementDofs = ElementDofs { node: DofSet::TRANSLATIONS, internal: Internal::NONE };
pub const fn new(node: DofSet) -> ElementDofs {
ElementDofs { node, internal: Internal::NONE }
}
/// Unknowns of an element with `nodes` nodes, before condensation.
pub const fn total(self, nodes: usize) -> usize {
nodes * self.node.len() + self.internal.count()
}
}
/// One numbered node: its label, its kinds and its first global unknown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Slot {
label: u32,
kinds: DofSet,
first: usize,
}
/// The global numbering of the unknowns.
///
/// Slots (positions) `0..nodes()` are the mesh nodes in label order; `nodes()..slots()` are the
/// extra points in the order they were added. A slot's unknowns are contiguous, in
/// [`DofKind`] order.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DofLayout {
slots: Vec<Slot>,
nodes: usize,
total: usize,
}
/// Collects what every node needs before numbering: the union of the kinds asked for.
#[derive(Debug, Clone, Default)]
pub struct LayoutBuilder {
nodes: BTreeMap<u32, DofSet>,
points: Vec<(u32, DofSet)>,
}
impl LayoutBuilder {
/// Node `label` needs `kinds` (added to what it needs already).
pub fn node(&mut self, label: u32, kinds: DofSet) -> &mut Self {
let entry = self.nodes.entry(label).or_default();
*entry = entry.union(kinds);
self
}
/// Every node of an element of this kind needs the element's kinds.
pub fn element(&mut self, labels: &[u32], dofs: ElementDofs) -> &mut Self {
for &l in labels {
self.node(l, dofs.node);
}
self
}
/// An extra point (a reference point) numbered after every mesh node, in the order added;
/// each call adds one.
pub fn point(&mut self, label: u32, kinds: DofSet) -> &mut Self {
self.points.push((label, kinds));
self
}
pub fn build(&self) -> DofLayout {
let mut slots = Vec::with_capacity(self.nodes.len() + self.points.len());
let mut total = 0;
for (&label, &kinds) in self.nodes.iter().chain(self.points.iter().map(|p| (&p.0, &p.1))) {
slots.push(Slot { label, kinds, first: total });
total += kinds.len();
}
DofLayout { slots, nodes: self.nodes.len(), total }
}
}
impl DofLayout {
pub fn builder() -> LayoutBuilder {
LayoutBuilder::default()
}
/// Every label in `labels` (sorted or not) with the same kinds.
pub fn uniform(labels: impl IntoIterator<Item = u32>, kinds: DofSet) -> DofLayout {
let mut b = LayoutBuilder::default();
for l in labels {
b.node(l, kinds);
}
b.build()
}
/// Total unknowns.
pub fn dofs(&self) -> usize {
self.total
}
/// Mesh nodes (slots `0..nodes()`).
pub fn nodes(&self) -> usize {
self.nodes
}
/// Mesh nodes and extra points.
pub fn slots(&self) -> usize {
self.slots.len()
}
/// Unknowns of the mesh nodes; the extra points' follow them.
pub fn node_dofs(&self) -> usize {
self.slots.get(self.nodes).map_or(self.total, |s| s.first)
}
/// The slot of extra point `k`.
pub fn point_slot(&self, k: usize) -> usize {
self.nodes + k
}
/// The slot of a mesh node label.
pub fn node(&self, label: u32) -> Option<usize> {
self.slots.get(..self.nodes)?.binary_search_by_key(&label, |s| s.label).ok()
}
/// The slot of a label: a mesh node, else an extra point.
pub fn slot(&self, label: u32) -> Option<usize> {
self.node(label).or_else(|| self.slots.get(self.nodes..)?.iter().position(|s| s.label == label).map(|k| self.nodes + k))
}
pub fn label(&self, slot: usize) -> Option<u32> {
self.slots.get(slot).map(|s| s.label)
}
/// The kinds a slot carries.
pub fn kinds(&self, slot: usize) -> DofSet {
self.slots.get(slot).map_or(DofSet::EMPTY, |s| s.kinds)
}
/// A slot's unknowns (empty for a slot that does not exist).
pub fn range(&self, slot: usize) -> Range<usize> {
self.slots.get(slot).map_or(0..0, |s| s.first..s.first + s.kinds.len())
}
/// The global unknown of `kind` at `slot`, if the slot carries it.
pub fn dof(&self, slot: usize, kind: DofKind) -> Option<usize> {
let s = self.slots.get(slot)?;
s.kinds.position(kind).map(|p| s.first + p)
}
/// The slot and kind of a global unknown.
pub fn locate(&self, dof: usize) -> Option<(usize, DofKind)> {
if dof >= self.total {
return None;
}
let slot = self.slots.partition_point(|s| s.first <= dof).checked_sub(1)?;
let s = self.slots.get(slot)?;
let kind = s.kinds.iter().nth(dof - s.first)?;
Some((slot, kind))
}
/// The global unknowns of an element: its slots' `kinds`, node by node, `None` when a slot
/// lacks one of them.
pub fn element_dofs(&self, slots: &[usize], kinds: DofSet) -> Option<Vec<usize>> {
let mut out = Vec::with_capacity(slots.len() * kinds.len());
for &slot in slots {
for k in kinds.iter() {
out.push(self.dof(slot, k)?);
}
}
Some(out)
}
/// `Some(s)` when every slot carries the same `s` kinds, so unknown `s · slot + offset` is
/// the `offset`-th kind of `slot` (node-major vectors of `s`-vectors).
pub fn stride(&self) -> Option<usize> {
let first = self.slots.first()?.kinds;
self.slots.iter().all(|s| s.kinds == first).then_some(first.len())
}
/// What a global unknown is, in the user's terms.
pub fn describe(&self, dof: usize) -> String {
match self.locate(dof) {
Some((slot, kind)) => {
let what = if slot < self.nodes { "node" } else { "point" };
format!("{what} {} in {}", self.label(slot).unwrap_or(0), kind.describe())
}
None => format!("unknown degree of freedom {dof}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinds_numbers_and_sets() {
for k in DofKind::ALL {
assert_eq!(DofKind::from_number(k.number()), Some(k));
}
assert_eq!(DofKind::from_number(1), Some(DofKind::Ux));
assert_eq!(DofKind::from_number(6), Some(DofKind::Rz));
assert_eq!(DofKind::from_number(11), Some(DofKind::T));
assert_eq!(DofKind::from_number(0), None);
assert_eq!(DofKind::from_number(7), None);
assert_eq!(DofKind::from_direction(4), Some(DofKind::Ry));
assert_eq!(DofKind::from_direction(6), None);
let s = DofSet::of(&[DofKind::T, DofKind::Ux, DofKind::Uy, DofKind::Uz]);
assert_eq!(s.len(), 4);
assert_eq!(s.iter().collect::<Vec<_>>(), vec![DofKind::Ux, DofKind::Uy, DofKind::Uz, DofKind::T]);
assert_eq!(s.position(DofKind::T), Some(3));
assert_eq!(s.position(DofKind::Rx), None);
assert!(DofSet::STRUCTURAL.includes(DofSet::TRANSLATIONS));
assert!(!DofSet::TRANSLATIONS.includes(DofSet::STRUCTURAL));
assert_eq!(DofSet::TRANSLATIONS.union(DofSet::ROTATIONS), DofSet::STRUCTURAL);
assert_eq!(ElementDofs::SOLID.total(8), 24);
let hybrid = ElementDofs { node: DofSet::TRANSLATIONS, internal: Internal { incompatible_modes: 0, pressure: 1 } };
assert_eq!(hybrid.total(8), 25);
}
#[test]
fn layout_numbers_nodes_by_label_then_kind_then_points() {
let mut b = DofLayout::builder();
// A shell on 7 and 3, a solid on 3, 5 and 7, a heat element on 5; a reference point.
b.element(&[7, 3], ElementDofs::new(DofSet::STRUCTURAL));
b.element(&[3, 5, 7], ElementDofs::SOLID);
b.node(5, DofSet::TEMPERATURE);
b.point(100, DofSet::STRUCTURAL);
let l = b.build();
assert_eq!((l.nodes(), l.slots(), l.dofs(), l.node_dofs()), (3, 4, 22, 16));
assert_eq!(l.node(3), Some(0));
assert_eq!(l.node(5), Some(1));
assert_eq!(l.node(100), None);
assert_eq!(l.slot(100), Some(3));
assert_eq!(l.range(1), 6..10);
assert_eq!(l.dof(1, DofKind::T), Some(9));
assert_eq!(l.dof(1, DofKind::Rx), None);
assert_eq!(l.dof(2, DofKind::Rz), Some(15));
assert_eq!(l.dof(3, DofKind::Ux), Some(16));
assert_eq!(l.locate(9), Some((1, DofKind::T)));
assert_eq!(l.locate(16), Some((3, DofKind::Ux)));
assert_eq!(l.locate(22), None);
for d in 0..l.dofs() {
let (slot, kind) = l.locate(d).unwrap();
assert_eq!(l.dof(slot, kind), Some(d));
}
assert_eq!(l.element_dofs(&[0, 1], DofSet::TRANSLATIONS), Some(vec![0, 1, 2, 6, 7, 8]));
assert_eq!(l.element_dofs(&[0, 1], DofSet::STRUCTURAL), None);
assert_eq!(l.describe(9), "node 5 in temperature");
assert_eq!(l.describe(19), "point 100 in rotation about X");
// The same needs in another order give the same layout.
let mut c = DofLayout::builder();
c.point(100, DofSet::STRUCTURAL).node(5, DofSet::TEMPERATURE).element(&[5, 7, 3], ElementDofs::SOLID);
c.element(&[3, 7], ElementDofs::new(DofSet::STRUCTURAL));
assert_eq!(c.build(), l);
// A uniform translational layout is the classic 3 × node numbering.
let u = DofLayout::uniform([4, 2, 9], DofSet::TRANSLATIONS);
assert_eq!(u.dof(2, DofKind::Uy), Some(7));
assert_eq!(u.dofs(), 9);
assert_eq!(u.range(7), 0..0);
assert_eq!(u.locate(usize::MAX), None);
assert_eq!(u.stride(), Some(3));
assert_eq!(l.stride(), None);
assert_eq!(DofLayout::default().stride(), None);
}
}