forked from 1b-agent/fieldcraft-upstream
Implicit solver: fem kernels (tet4/10, hex8, hex20), sparse Cholesky, block Lanczos, solve.* commands (M4) (#23)
* Renumber the layer table for solver crates: fem at L1, solver at L2, fsi at L3 L0 model -> L1 geometry, mesh, fem, decks, render -> L2 solver (explicit, lbm later) -> L3 fsi (later) -> L4 doc -> L5 io -> L6 engine -> L7 ui-egui, mcp -> apps. UI_MIN_LAYER moves to 7, the testkit sits above it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-fem: element kernels for tet4, tet10, hex8 (full, incompatible modes), hex20 (full, reduced) Shape functions, Gauss and conical-product quadrature, stiffness, consistent and HRZ-lumped mass, body forces, consistent pressure loads on faces, integration-point stresses and their least-squares extrapolation to nodes, isotropic and orthotropic elasticity. The element table is one row per kind, so wedges, shells and beams are added locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-solver: linear static and frequency analysis with an analytic verification suite Assembly from the resolved model on the node graph, boundary conditions by elimination, consistent pressure, gravity and body-force loads, nested-dissection ordering, sparse up-looking Cholesky with a factor-size cap, reactions, integration-point and nodal-averaged stresses, von Mises; shift-invert block Lanczos with full reorthogonalization for natural modes, participation and effective mass. ADR 0001 records the own-linear-algebra decision (determinism, wasm). Verified against closed forms: displacement and traction patch tests for every element, cantilever tip deflection (Timoshenko) with convergence rates, Lamé cylinder, Kirsch hole (Kt -> 3), cantilever and free-free beam frequencies with six rigid-body modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Engine: solve.static, solve.frequency and solve.probe; results kept in the session Results are stored per step, restored on a failed command and cleared when the model changes. Caps (degrees of freedom, factor entries, Lanczos vectors) may only be lowered; hostile parameters are tested against a solvable session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: implicit solver architecture, roadmap M4, parity and references Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Simplify the solver: shared face integration, block assembly, cached extrapolation - model: one face integrator (nodal::face_normal_weights) for tractions and pressures - fem: mid-side tables from ElementKind::edges, orthotropic via inverse3, symmetric accumulation without a symmetrize pass, incompatible-mode recovery without K_uu, model's coordinate check, dead table rows removed - solver: one pattern search per node pair (block, scalar and diagonal adds), extrapolation built once per element type, reactions from the prescribed rows only, matrices dropped after restriction, the free-body shift keyed on the factorization error, step selection in the solver, Error::Element naming the element - engine: the solver owns the default step and the shift check; the model comparison runs once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: results are stamped with the session revision 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>
This commit is contained in:
33 files changed
+4559
-53
No files matched your search
@@ -0,0 +1,138 @@
|
||||
//! Small dense matrices: element matrices and the little systems inside an element (static
|
||||
//! condensation, extrapolation fits).
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A square matrix, row-major.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Matrix {
|
||||
pub n: usize,
|
||||
pub data: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Matrix {
|
||||
pub fn zeros(n: usize) -> Matrix {
|
||||
Matrix { n, data: vec![0.0; n * n] }
|
||||
}
|
||||
|
||||
pub fn get(&self, i: usize, j: usize) -> f64 {
|
||||
self.data.get(i * self.n + j).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
pub fn add(&mut self, i: usize, j: usize, v: f64) {
|
||||
if let Some(x) = self.data.get_mut(i * self.n + j) {
|
||||
*x += v;
|
||||
}
|
||||
}
|
||||
|
||||
/// y = A x (x shorter than n is padded with zeros).
|
||||
pub fn mul_vec(&self, x: &[f64]) -> Vec<f64> {
|
||||
self.data.chunks_exact(self.n.max(1)).map(|row| row.iter().zip(x).map(|(a, b)| a * b).sum()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The Cholesky factor of a small symmetric positive definite matrix (lower triangle, row-major).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cholesky {
|
||||
n: usize,
|
||||
l: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Cholesky {
|
||||
/// Factor `a` (only its lower triangle is read). Fails when a pivot is not positive relative
|
||||
/// to the matrix's diagonal.
|
||||
pub fn new(a: &Matrix) -> Result<Cholesky> {
|
||||
let n = a.n;
|
||||
let mut l = vec![0.0; n * n];
|
||||
let scale = (0..n).map(|i| a.get(i, i).abs()).fold(0.0, f64::max);
|
||||
for j in 0..n {
|
||||
let mut d = a.get(j, j);
|
||||
for k in 0..j {
|
||||
let v = l[j * n + k];
|
||||
d -= v * v;
|
||||
}
|
||||
if !(d > 1e-13 * scale) {
|
||||
return Err(Error::Singular(format!("a {n} × {n} element matrix is not positive definite (pivot {j})")));
|
||||
}
|
||||
let djj = d.sqrt();
|
||||
l[j * n + j] = djj;
|
||||
for i in j + 1..n {
|
||||
let mut s = a.get(i, j);
|
||||
for k in 0..j {
|
||||
s -= l[i * n + k] * l[j * n + k];
|
||||
}
|
||||
l[i * n + j] = s / djj;
|
||||
}
|
||||
}
|
||||
Ok(Cholesky { n, l })
|
||||
}
|
||||
|
||||
/// Solve A x = b in place.
|
||||
pub fn solve(&self, b: &mut [f64]) {
|
||||
let n = self.n.min(b.len());
|
||||
for i in 0..n {
|
||||
let mut s = b[i];
|
||||
for k in 0..i {
|
||||
s -= self.l[i * self.n + k] * b[k];
|
||||
}
|
||||
b[i] = s / self.l[i * self.n + i];
|
||||
}
|
||||
for i in (0..n).rev() {
|
||||
let mut s = b[i];
|
||||
for k in i + 1..n {
|
||||
s -= self.l[k * self.n + i] * b[k];
|
||||
}
|
||||
b[i] = s / self.l[i * self.n + i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse and determinant of a 3 × 3 matrix; `None` when it is singular or not finite.
|
||||
pub fn inverse3(m: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> {
|
||||
let det = det3(m);
|
||||
if !(det.abs() > 0.0 && det.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut inv = [[0.0; 3]; 3];
|
||||
for (i, row) in inv.iter_mut().enumerate() {
|
||||
for (j, v) in row.iter_mut().enumerate() {
|
||||
let (j1, j2) = ((j + 1) % 3, (j + 2) % 3);
|
||||
let (i1, i2) = ((i + 1) % 3, (i + 2) % 3);
|
||||
// inv[i][j] = cofactor[j][i] / det.
|
||||
*v = (m[j1][i1] * m[j2][i2] - m[j1][i2] * m[j2][i1]) / det;
|
||||
}
|
||||
}
|
||||
Some((inv, det))
|
||||
}
|
||||
|
||||
pub fn det3(m: &[[f64; 3]; 3]) -> f64 {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cholesky_solves_and_rejects_indefinite() {
|
||||
let mut a = Matrix::zeros(3);
|
||||
a.data = vec![4.0, 2.0, 0.6, 2.0, 5.0, 1.0, 0.6, 1.0, 3.0];
|
||||
let x = [1.0, -2.0, 0.5];
|
||||
let mut b = a.mul_vec(&x);
|
||||
Cholesky::new(&a).unwrap().solve(&mut b);
|
||||
assert!(b.iter().zip(x).all(|(p, q)| (p - q).abs() < 1e-14), "{b:?}");
|
||||
a.data[8] = -1.0;
|
||||
assert!(Cholesky::new(&a).is_err());
|
||||
let m = [[2.0, 0.5, 0.0], [0.1, 3.0, 0.2], [0.0, 0.4, 1.0]];
|
||||
let (inv, det) = inverse3(&m).unwrap();
|
||||
assert!((det - det3(&m)).abs() < 1e-15);
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
let p: f64 = (0..3).map(|k| m[i][k] * inv[k][j]).sum();
|
||||
assert!((p - if i == j { 1.0 } else { 0.0 }).abs() < 1e-14);
|
||||
}
|
||||
}
|
||||
assert!(inverse3(&[[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [0.0, 0.0, 1.0]]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
//! Solid continuum elements: the element table, stiffness, mass, body forces, stresses at the
|
||||
//! integration points and their extrapolation to the nodes.
|
||||
//!
|
||||
//! The isoparametric displacement formulation (Zienkiewicz, Taylor & Zhu 2013, ch. 6; Hughes
|
||||
//! 2000, ch. 3): K = ∫ Bᵀ D B dV and M = ∫ ρ Nᵀ N dV, integrated by Gauss quadrature on the
|
||||
//! reference element through the Jacobian of the map from natural to physical coordinates.
|
||||
//!
|
||||
//! Formulations:
|
||||
//! - **full integration**: the rule that integrates the stiffness of an undistorted element
|
||||
//! exactly (1 point for tet4, 4 for tet10, 2 × 2 × 2 for hex8, 3 × 3 × 3 for hex20);
|
||||
//! - **reduced integration** for hex20 (2 × 2 × 2), which avoids locking at the cost of one
|
||||
//! spurious mode that a mesh of more than one element restrains. Reduced hex8 needs hourglass
|
||||
//! control and is not offered here yet (it arrives with the explicit solver, Flanagan &
|
||||
//! Belytschko 1981);
|
||||
//! - **incompatible modes** for hex8 (E. L. Wilson, R. L. Taylor, W. P. Doherty, J. Ghaboussi,
|
||||
//! *Incompatible displacement models*, in Numerical and Computer Methods in Structural
|
||||
//! Mechanics, Academic Press 1973, 43–57): nine internal displacement modes (1 − ξ²),
|
||||
//! (1 − η²), (1 − ζ²) per direction, condensed out of the element. Their derivatives use the
|
||||
//! Jacobian at the element centre scaled by det J₀ / det J, the correction of R. L. Taylor,
|
||||
//! P. J. Beresford, E. L. Wilson, *A non-conforming element for stress analysis*, IJNME
|
||||
//! 10(6):1211–1219, 1976, so the element passes the patch test when distorted. It bends
|
||||
//! correctly with one element through the thickness.
|
||||
//!
|
||||
//! Lumped mass is the diagonal scaling of Hinton, Rock and Zienkiewicz (E. Hinton, T. Rock,
|
||||
//! O. C. Zienkiewicz, *A note on mass lumping and related processes in the finite element
|
||||
//! method*, Earthquake Eng. Struct. Dyn. 4(3):245–249, 1976): the consistent diagonal scaled to
|
||||
//! the element's mass, positive for quadratic elements too.
|
||||
//!
|
||||
//! Nodal stresses extrapolate the integration-point values with the element's linear corner
|
||||
//! functions fitted by least squares (E. Hinton, J. S. Campbell, *Local and global smoothing of
|
||||
//! discontinuous finite element functions using a least squares method*, IJNME 8(3):461–480,
|
||||
//! 1974); mid-side nodes take the mean of their edge's corners.
|
||||
|
||||
use modelcraft_model::ElementKind;
|
||||
|
||||
use crate::dense::{Cholesky, Matrix, inverse3};
|
||||
use crate::material::{Elasticity, stress};
|
||||
use crate::quadrature::{Point, Rule};
|
||||
use crate::shape::{self, MAX_NODES, Shape};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The reference shape of an element type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Family {
|
||||
Tetrahedron,
|
||||
Hexahedron,
|
||||
}
|
||||
|
||||
/// One row of the element table: everything the kernels need to know about an element kind.
|
||||
/// Adding an element (wedge6, shells, beams) is adding a row and its shape functions.
|
||||
#[derive(Debug)]
|
||||
pub struct ElementType {
|
||||
pub kind: ElementKind,
|
||||
pub family: Family,
|
||||
pub nodes: usize,
|
||||
/// The corner nodes come first in the node list.
|
||||
pub corners: usize,
|
||||
pub shape: fn([f64; 3]) -> Shape,
|
||||
/// Full integration of the stiffness.
|
||||
pub full: Rule,
|
||||
/// Reduced integration, where the element is stable with it.
|
||||
pub reduced: Option<Rule>,
|
||||
/// Exact for the consistent mass of an undistorted element.
|
||||
pub mass: Rule,
|
||||
/// Whether the element has an incompatible-modes formulation.
|
||||
pub incompatible_modes: bool,
|
||||
}
|
||||
|
||||
/// The element table.
|
||||
pub static ELEMENT_TYPES: [ElementType; 4] = [
|
||||
ElementType {
|
||||
kind: ElementKind::Tet4,
|
||||
family: Family::Tetrahedron,
|
||||
nodes: 4,
|
||||
corners: 4,
|
||||
shape: shape::tet4,
|
||||
full: Rule::TetCentroid,
|
||||
reduced: None,
|
||||
mass: Rule::Tet4,
|
||||
incompatible_modes: false,
|
||||
},
|
||||
ElementType {
|
||||
kind: ElementKind::Tet10,
|
||||
family: Family::Tetrahedron,
|
||||
nodes: 10,
|
||||
corners: 4,
|
||||
shape: shape::tet10,
|
||||
full: Rule::Tet4,
|
||||
reduced: None,
|
||||
mass: Rule::TetConical(4),
|
||||
incompatible_modes: false,
|
||||
},
|
||||
ElementType {
|
||||
kind: ElementKind::Hex8,
|
||||
family: Family::Hexahedron,
|
||||
nodes: 8,
|
||||
corners: 8,
|
||||
shape: shape::hex8,
|
||||
full: Rule::Hex(2),
|
||||
reduced: None,
|
||||
mass: Rule::Hex(2),
|
||||
incompatible_modes: true,
|
||||
},
|
||||
ElementType {
|
||||
kind: ElementKind::Hex20,
|
||||
family: Family::Hexahedron,
|
||||
nodes: 20,
|
||||
corners: 8,
|
||||
shape: shape::hex20,
|
||||
full: Rule::Hex(3),
|
||||
reduced: Some(Rule::Hex(2)),
|
||||
mass: Rule::Hex(3),
|
||||
incompatible_modes: false,
|
||||
},
|
||||
];
|
||||
|
||||
/// The table row of an element kind.
|
||||
pub fn element_type(kind: ElementKind) -> Result<&'static ElementType> {
|
||||
ELEMENT_TYPES
|
||||
.iter()
|
||||
.find(|t| t.kind == kind)
|
||||
.ok_or_else(|| Error::NotSupported(format!("{} elements (the solver has tet4, tet10, hex8 and hex20 so far)", kind.name())))
|
||||
}
|
||||
|
||||
/// How a solid element is formulated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Formulation {
|
||||
Full,
|
||||
Reduced,
|
||||
IncompatibleModes,
|
||||
}
|
||||
|
||||
impl Formulation {
|
||||
/// The formulation a model section asks for, if this element type has it.
|
||||
pub fn from_section(kind: ElementKind, f: &modelcraft_model::Formulation) -> Result<Formulation> {
|
||||
let ty = element_type(kind)?;
|
||||
if f.hybrid {
|
||||
return Err(Error::NotSupported("hybrid (mixed displacement–pressure) elements".into()));
|
||||
}
|
||||
if f.incompatible_modes {
|
||||
return if ty.incompatible_modes {
|
||||
Ok(Formulation::IncompatibleModes)
|
||||
} else {
|
||||
Err(Error::Invalid(format!("incompatible modes apply to hex8 elements, not {}", kind.name())))
|
||||
};
|
||||
}
|
||||
match f.integration {
|
||||
modelcraft_model::Integration::Full => Ok(Formulation::Full),
|
||||
modelcraft_model::Integration::Reduced if ty.reduced.is_some() => Ok(Formulation::Reduced),
|
||||
modelcraft_model::Integration::Reduced => Err(Error::NotSupported(format!(
|
||||
"reduced integration of {} elements (hex8 needs hourglass control; use full integration or incompatible modes)",
|
||||
kind.name()
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strain, stress and position at one integration point.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PointStress {
|
||||
pub xyz: [f64; 3],
|
||||
pub strain: [f64; 6],
|
||||
pub stress: [f64; 6],
|
||||
}
|
||||
|
||||
/// Shape function gradients in physical coordinates at one point.
|
||||
struct Gradients {
|
||||
shape: Shape,
|
||||
dx: [[f64; 3]; MAX_NODES],
|
||||
det: f64,
|
||||
}
|
||||
|
||||
/// A solid element: its type, its node coordinates and its formulation.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Solid<'a> {
|
||||
pub ty: &'static ElementType,
|
||||
xyz: &'a [[f64; 3]],
|
||||
formulation: Formulation,
|
||||
}
|
||||
|
||||
/// The stiffness blocks of the incompatible-modes element.
|
||||
struct IncompatibleParts {
|
||||
kuu: Matrix,
|
||||
kua: Vec<f64>,
|
||||
kaa: Matrix,
|
||||
j0: ([[f64; 3]; 3], f64),
|
||||
}
|
||||
|
||||
/// The incompatible modes' count of internal degrees of freedom.
|
||||
const INCOMPATIBLE_DOFS: usize = 9;
|
||||
|
||||
impl<'a> Solid<'a> {
|
||||
pub fn new(kind: ElementKind, xyz: &'a [[f64; 3]], formulation: Formulation) -> Result<Solid<'a>> {
|
||||
let ty = element_type(kind)?;
|
||||
if xyz.len() != ty.nodes {
|
||||
return Err(Error::Invalid(format!("a {} element has {} nodes, got {}", kind.name(), ty.nodes, xyz.len())));
|
||||
}
|
||||
modelcraft_model::check_coordinates(xyz.as_flattened()).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
let ok = match formulation {
|
||||
Formulation::Full => true,
|
||||
Formulation::Reduced => ty.reduced.is_some(),
|
||||
Formulation::IncompatibleModes => ty.incompatible_modes,
|
||||
};
|
||||
if !ok {
|
||||
return Err(Error::NotSupported(format!("{formulation:?} formulation of {} elements", kind.name())));
|
||||
}
|
||||
Ok(Solid { ty, xyz, formulation })
|
||||
}
|
||||
|
||||
/// The stiffness integration rule.
|
||||
pub fn rule(&self) -> Rule {
|
||||
match (self.formulation, self.ty.reduced) {
|
||||
(Formulation::Reduced, Some(r)) => r,
|
||||
_ => self.ty.full,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stiffness integration points (where stresses are reported).
|
||||
pub fn integration_points(&self) -> Vec<Point> {
|
||||
self.rule().points()
|
||||
}
|
||||
|
||||
/// The Jacobian matrix J[a][b] = ∂x_b/∂ξ_a.
|
||||
fn jacobian(&self, s: &Shape) -> [[f64; 3]; 3] {
|
||||
let mut j = [[0.0; 3]; 3];
|
||||
for (d, x) in s.derivatives().iter().zip(self.xyz) {
|
||||
for a in 0..3 {
|
||||
for b in 0..3 {
|
||||
j[a][b] += d[a] * x[b];
|
||||
}
|
||||
}
|
||||
}
|
||||
j
|
||||
}
|
||||
|
||||
fn gradients(&self, xi: [f64; 3]) -> Result<Gradients> {
|
||||
let shape = (self.ty.shape)(xi);
|
||||
let j = self.jacobian(&shape);
|
||||
let (inv, det) = inverse3(&j).ok_or_else(|| self.degenerate())?;
|
||||
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
|
||||
if !(det > 1e-12 * norms) {
|
||||
return Err(self.degenerate());
|
||||
}
|
||||
let mut dx = [[0.0; 3]; MAX_NODES];
|
||||
for (g, d) in dx.iter_mut().zip(shape.derivatives()) {
|
||||
for b in 0..3 {
|
||||
g[b] = inv[b][0] * d[0] + inv[b][1] * d[1] + inv[b][2] * d[2];
|
||||
}
|
||||
}
|
||||
Ok(Gradients { shape, dx, det })
|
||||
}
|
||||
|
||||
fn degenerate(&self) -> Error {
|
||||
Error::Degenerate(format!("a {} element is inverted or degenerate (its Jacobian is not positive)", self.ty.kind.name()))
|
||||
}
|
||||
|
||||
/// Position of a natural point.
|
||||
pub fn position(&self, xi: [f64; 3]) -> [f64; 3] {
|
||||
let s = (self.ty.shape)(xi);
|
||||
let mut p = [0.0; 3];
|
||||
for (n, x) in s.values().iter().zip(self.xyz) {
|
||||
for k in 0..3 {
|
||||
p[k] += n * x[k];
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// The element's volume.
|
||||
pub fn volume(&self) -> Result<f64> {
|
||||
let mut v = 0.0;
|
||||
for p in self.ty.mass.points() {
|
||||
v += self.gradients(p.xi)?.det * p.weight;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The incompatible modes' gradients at a point: det J₀/det J · J₀⁻¹ ∂P/∂ξ.
|
||||
fn incompatible_gradients(&self, xi: [f64; 3], det: f64, j0: &([[f64; 3]; 3], f64)) -> [[f64; 3]; 3] {
|
||||
let (inv0, det0) = j0;
|
||||
let mut g = [[0.0; 3]; 3];
|
||||
for (k, gk) in g.iter_mut().enumerate() {
|
||||
// ∂(1 − ξ_k²)/∂ξ_k = −2 ξ_k, other derivatives zero.
|
||||
let dp = -2.0 * xi[k];
|
||||
for b in 0..3 {
|
||||
gk[b] = det0 / det * inv0[b][k] * dp;
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
fn centre_jacobian(&self) -> Result<([[f64; 3]; 3], f64)> {
|
||||
let s = (self.ty.shape)([0.0; 3]);
|
||||
inverse3(&self.jacobian(&s)).filter(|(_, d)| *d > 0.0).ok_or_else(|| self.degenerate())
|
||||
}
|
||||
|
||||
/// The parts of the stiffness with the incompatible modes: K_uu (when `with_uu`), K_ua
|
||||
/// (3n × 9), K_aa, and the centre Jacobian they were built with.
|
||||
fn incompatible_parts(&self, d: &Elasticity, with_uu: bool) -> Result<IncompatibleParts> {
|
||||
let n = 3 * self.ty.nodes;
|
||||
let j0 = self.centre_jacobian()?;
|
||||
let mut kuu = Matrix::zeros(if with_uu { n } else { 0 });
|
||||
let mut kua = vec![0.0; n * INCOMPATIBLE_DOFS];
|
||||
let mut kaa = Matrix::zeros(INCOMPATIBLE_DOFS);
|
||||
for p in self.rule().points() {
|
||||
let g = self.gradients(p.xi)?;
|
||||
let w = g.det * p.weight;
|
||||
let ga = self.incompatible_gradients(p.xi, g.det, &j0);
|
||||
let gu = g.dx.get(..self.ty.nodes).unwrap_or_default();
|
||||
if with_uu {
|
||||
accumulate_symmetric(&mut kuu.data, gu, d, w);
|
||||
}
|
||||
accumulate(&mut kua, INCOMPATIBLE_DOFS, gu, &ga, d, w);
|
||||
accumulate_symmetric(&mut kaa.data, &ga, d, w);
|
||||
}
|
||||
Ok(IncompatibleParts { kuu, kua, kaa, j0 })
|
||||
}
|
||||
|
||||
/// The stiffness matrix, 3n × 3n, degrees of freedom ordered node by node (x, y, z).
|
||||
pub fn stiffness(&self, d: &Elasticity) -> Result<Matrix> {
|
||||
let n = 3 * self.ty.nodes;
|
||||
if self.formulation == Formulation::IncompatibleModes {
|
||||
let IncompatibleParts { kuu: mut k, kua, kaa, .. } = self.incompatible_parts(d, true)?;
|
||||
let chol = Cholesky::new(&kaa)?;
|
||||
// K = K_uu − K_ua K_aa⁻¹ K_au, column by column of K_au.
|
||||
for i in 0..n {
|
||||
let mut col: Vec<f64> = kua.get(i * INCOMPATIBLE_DOFS..(i + 1) * INCOMPATIBLE_DOFS).unwrap_or_default().to_vec();
|
||||
chol.solve(&mut col);
|
||||
for j in 0..n {
|
||||
let row = kua.get(j * INCOMPATIBLE_DOFS..(j + 1) * INCOMPATIBLE_DOFS).unwrap_or_default();
|
||||
let v: f64 = row.iter().zip(&col).map(|(a, b)| a * b).sum();
|
||||
k.add(j, i, -v);
|
||||
}
|
||||
}
|
||||
symmetrize(&mut k);
|
||||
return Ok(k);
|
||||
}
|
||||
let mut k = Matrix::zeros(n);
|
||||
for p in self.rule().points() {
|
||||
let g = self.gradients(p.xi)?;
|
||||
let gu = g.dx.get(..self.ty.nodes).unwrap_or_default();
|
||||
accumulate_symmetric(&mut k.data, gu, d, g.det * p.weight);
|
||||
}
|
||||
Ok(k)
|
||||
}
|
||||
|
||||
/// The consistent mass matrix per displacement component, n × n: m_ij = ∫ ρ Nᵢ Nⱼ dV (the
|
||||
/// 3n × 3n matrix has it on each component's block).
|
||||
pub fn mass(&self, density: f64) -> Result<Matrix> {
|
||||
let n = self.ty.nodes;
|
||||
let mut m = Matrix::zeros(n);
|
||||
for p in self.ty.mass.points() {
|
||||
let g = self.gradients(p.xi)?;
|
||||
let w = density * g.det * p.weight;
|
||||
let v = g.shape.values();
|
||||
for (i, a) in v.iter().enumerate() {
|
||||
for (j, b) in v.iter().enumerate() {
|
||||
m.add(i, j, w * a * b);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// The lumped (diagonal) mass per node: the consistent diagonal scaled to the element's mass.
|
||||
pub fn lumped_mass(&self, density: f64) -> Result<Vec<f64>> {
|
||||
let m = self.mass(density)?;
|
||||
// The consistent entries sum to the element's mass (the shape functions sum to one).
|
||||
let total: f64 = m.data.iter().sum();
|
||||
let diag: Vec<f64> = (0..m.n).map(|i| m.get(i, i)).collect();
|
||||
let sum: f64 = diag.iter().sum();
|
||||
if !(sum > 0.0) {
|
||||
return Err(self.degenerate());
|
||||
}
|
||||
Ok(diag.iter().map(|d| d * total / sum).collect())
|
||||
}
|
||||
|
||||
/// Consistent nodal forces of a uniform force per volume: fᵢ = ∫ Nᵢ b dV.
|
||||
pub fn body_force(&self, b: [f64; 3]) -> Result<Vec<[f64; 3]>> {
|
||||
let mut f = vec![[0.0; 3]; self.ty.nodes];
|
||||
for p in self.ty.mass.points() {
|
||||
let g = self.gradients(p.xi)?;
|
||||
for (fi, n) in f.iter_mut().zip(g.shape.values()) {
|
||||
for k in 0..3 {
|
||||
fi[k] += n * b[k] * g.det * p.weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// Strain and stress at every integration point for the nodal displacements `u`.
|
||||
pub fn stresses(&self, d: &Elasticity, u: &[[f64; 3]]) -> Result<Vec<PointStress>> {
|
||||
if u.len() != self.ty.nodes {
|
||||
return Err(Error::Invalid(format!("{} displacements for a {}-node element", u.len(), self.ty.nodes)));
|
||||
}
|
||||
// Incompatible modes: recover the internal amplitudes α = −K_aa⁻¹ K_au u.
|
||||
let alpha = if self.formulation == Formulation::IncompatibleModes {
|
||||
let IncompatibleParts { kua, kaa, j0, .. } = self.incompatible_parts(d, false)?;
|
||||
let flat: Vec<f64> = u.iter().flatten().copied().collect();
|
||||
let mut rhs = [0.0; INCOMPATIBLE_DOFS];
|
||||
for (i, ui) in flat.iter().enumerate() {
|
||||
for (a, r) in rhs.iter_mut().enumerate() {
|
||||
*r -= kua.get(i * INCOMPATIBLE_DOFS + a).copied().unwrap_or(0.0) * ui;
|
||||
}
|
||||
}
|
||||
Cholesky::new(&kaa)?.solve(&mut rhs);
|
||||
Some((rhs, j0))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for p in self.rule().points() {
|
||||
let g = self.gradients(p.xi)?;
|
||||
let mut grad = [[0.0; 3]; 3]; // ∂u_c/∂x_b
|
||||
for (dn, ui) in g.dx.iter().zip(u) {
|
||||
for c in 0..3 {
|
||||
for b in 0..3 {
|
||||
grad[c][b] += ui[c] * dn[b];
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((a, j0)) = &alpha {
|
||||
let ga = self.incompatible_gradients(p.xi, g.det, j0);
|
||||
for (k, gk) in ga.iter().enumerate() {
|
||||
for c in 0..3 {
|
||||
for b in 0..3 {
|
||||
grad[c][b] += a[3 * k + c] * gk[b];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let strain = [grad[0][0], grad[1][1], grad[2][2], grad[0][1] + grad[1][0], grad[0][2] + grad[2][0], grad[1][2] + grad[2][1]];
|
||||
let mut xyz = [0.0; 3];
|
||||
for (n, x) in g.shape.values().iter().zip(self.xyz) {
|
||||
for k in 0..3 {
|
||||
xyz[k] += n * x[k];
|
||||
}
|
||||
}
|
||||
out.push(PointStress { xyz, strain, stress: stress(d, &strain) });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Extrapolate values at the integration points to the element's nodes.
|
||||
pub fn extrapolate(&self, values: &[[f64; 6]]) -> Result<Vec<[f64; 6]>> {
|
||||
let e = extrapolation(self.ty, self.rule())?;
|
||||
if values.len() != e.points {
|
||||
return Err(Error::Invalid(format!("{} values for {} integration points", values.len(), e.points)));
|
||||
}
|
||||
Ok(e.apply(values))
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a matrix exactly symmetric (the mean of it and its transpose), so assembly can rely on it.
|
||||
fn symmetrize(k: &mut Matrix) {
|
||||
let n = k.n;
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
let v = 0.5 * (k.get(i, j) + k.get(j, i));
|
||||
if let Some(x) = k.data.get_mut(i * n + j) {
|
||||
*x = v;
|
||||
}
|
||||
if let Some(x) = k.data.get_mut(j * n + i) {
|
||||
*x = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The strain-displacement block of one gradient g: ε = B u for a single node's (x, y, z).
|
||||
fn b_block(g: &[f64; 3]) -> [[f64; 3]; 6] {
|
||||
[[g[0], 0.0, 0.0], [0.0, g[1], 0.0], [0.0, 0.0, g[2]], [g[1], g[0], 0.0], [g[2], 0.0, g[0]], [0.0, g[2], g[1]]]
|
||||
}
|
||||
|
||||
/// out[3i + r][3j + c] += w (Bᵢᵀ D Bⱼ)[r][c] for gradients `ga` (rows) and `gb` (columns), with
|
||||
/// `cols` columns in the row-major `out`.
|
||||
fn accumulate(out: &mut [f64], cols: usize, ga: &[[f64; 3]], gb: &[[f64; 3]], d: &Elasticity, w: f64) {
|
||||
for (j, gj) in gb.iter().enumerate() {
|
||||
let bj = b_block(gj);
|
||||
// D Bⱼ: 6 × 3.
|
||||
let mut db = [[0.0; 3]; 6];
|
||||
for s in 0..6 {
|
||||
for c in 0..3 {
|
||||
db[s][c] = (0..6).map(|t| d[s][t] * bj[t][c]).sum();
|
||||
}
|
||||
}
|
||||
for (i, gi) in ga.iter().enumerate() {
|
||||
let bi = b_block(gi);
|
||||
for r in 0..3 {
|
||||
for c in 0..3 {
|
||||
let v: f64 = (0..6).map(|s| bi[s][r] * db[s][c]).sum();
|
||||
if let Some(x) = out.get_mut((3 * i + r) * cols + 3 * j + c) {
|
||||
*x += w * v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The symmetric case of [`accumulate`] (rows and columns from the same gradients, `out` square):
|
||||
/// computes the blocks with j ≥ i and mirrors them, so the result is exactly symmetric.
|
||||
fn accumulate_symmetric(out: &mut [f64], g: &[[f64; 3]], d: &Elasticity, w: f64) {
|
||||
let cols = 3 * g.len();
|
||||
for (j, gj) in g.iter().enumerate() {
|
||||
let bj = b_block(gj);
|
||||
let mut db = [[0.0; 3]; 6];
|
||||
for s in 0..6 {
|
||||
for c in 0..3 {
|
||||
db[s][c] = (0..6).map(|t| d[s][t] * bj[t][c]).sum();
|
||||
}
|
||||
}
|
||||
for (i, gi) in g.iter().enumerate().take(j + 1) {
|
||||
let bi = b_block(gi);
|
||||
for r in 0..3 {
|
||||
// On a diagonal block only the upper triangle is computed, then mirrored.
|
||||
for c in if i == j { r } else { 0 }..3 {
|
||||
let v = w * (0..6).map(|s| bi[s][r] * db[s][c]).sum::<f64>();
|
||||
let (row, col) = (3 * i + r, 3 * j + c);
|
||||
if let Some(x) = out.get_mut(row * cols + col) {
|
||||
*x += v;
|
||||
}
|
||||
if row != col
|
||||
&& let Some(x) = out.get_mut(col * cols + row)
|
||||
{
|
||||
*x += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The map from integration-point values to nodal values of an element type and rule.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Extrapolation {
|
||||
pub points: usize,
|
||||
/// nodes × points, row-major.
|
||||
pub weights: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Extrapolation {
|
||||
pub fn apply(&self, values: &[[f64; 6]]) -> Vec<[f64; 6]> {
|
||||
self.weights
|
||||
.chunks_exact(self.points.max(1))
|
||||
.map(|row| {
|
||||
let mut v = [0.0; 6];
|
||||
for (w, x) in row.iter().zip(values) {
|
||||
for k in 0..6 {
|
||||
v[k] += w * x[k];
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The least-squares extrapolation of a rule's points to the nodes with the linear corner
|
||||
/// functions (Hinton & Campbell 1974); the mean when the rule has fewer points than corners.
|
||||
pub fn extrapolation(ty: &ElementType, rule: Rule) -> Result<Extrapolation> {
|
||||
let pts = rule.points();
|
||||
let g = pts.len();
|
||||
let c = ty.corners;
|
||||
let corner_basis = |xi: [f64; 3]| match ty.family {
|
||||
Family::Tetrahedron => shape::tet4(xi),
|
||||
Family::Hexahedron => shape::hex8(xi),
|
||||
};
|
||||
let mut corner_rows = vec![0.0; c * g];
|
||||
if g < c {
|
||||
corner_rows.iter_mut().for_each(|w| *w = 1.0 / g.max(1) as f64);
|
||||
} else {
|
||||
// (AᵀA)⁻¹ Aᵀ with A[p][j] = corner function j at point p.
|
||||
let a: Vec<Vec<f64>> = pts.iter().map(|p| corner_basis(p.xi).values().to_vec()).collect();
|
||||
let mut ata = Matrix::zeros(c);
|
||||
for row in &a {
|
||||
for i in 0..c {
|
||||
for j in 0..c {
|
||||
ata.add(i, j, row.get(i).copied().unwrap_or(0.0) * row.get(j).copied().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
let chol = Cholesky::new(&ata)?;
|
||||
for (p, row) in a.iter().enumerate() {
|
||||
let mut col = row.clone();
|
||||
chol.solve(&mut col);
|
||||
for (i, v) in col.iter().enumerate() {
|
||||
if let Some(x) = corner_rows.get_mut(i * g + p) {
|
||||
*x = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut weights = vec![0.0; ty.nodes * g];
|
||||
for (w, v) in weights.iter_mut().zip(&corner_rows) {
|
||||
*w = *v;
|
||||
}
|
||||
for edge in ty.kind.edges() {
|
||||
if let [a, b, m] = **edge {
|
||||
for p in 0..g {
|
||||
let v = 0.5 * (corner_rows.get(a * g + p).copied().unwrap_or(0.0) + corner_rows.get(b * g + p).copied().unwrap_or(0.0));
|
||||
if let Some(x) = weights.get_mut(m * g + p) {
|
||||
*x = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Extrapolation { points: g, weights })
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Element faces: consistent nodal forces of surface loads.
|
||||
//!
|
||||
//! A pressure p on a face gives node i the force fᵢ = −p ∫ Nᵢ n dA, where n dA =
|
||||
//! (∂x/∂ξ × ∂x/∂η) dξ dη on the face's own, possibly curved, geometry; faces are numbered so this
|
||||
//! normal points out of the element ([`modelcraft_model::ElementKind::faces`]), and a positive
|
||||
//! pressure pushes in. This is the consistent load vector of the virtual work of the surface load
|
||||
//! (Zienkiewicz, Taylor & Zhu 2013, §6.6), integrated with the model's face rules (exact for a
|
||||
//! uniform pressure on a flat face).
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// ∫ Nᵢ n dA for every node of a face (node coordinates in the face's node order): the model's
|
||||
/// face integration ([`modelcraft_model::nodal::face_normal_weights`]), so pressures and the
|
||||
/// tractions the model distributes use the same rule.
|
||||
pub fn normal_weights(xyz: &[[f64; 3]]) -> Result<Vec<[f64; 3]>> {
|
||||
modelcraft_model::nodal::face_normal_weights(xyz).map_err(|e| Error::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
/// Consistent nodal forces of a uniform pressure (positive pushes into the element).
|
||||
pub fn pressure(xyz: &[[f64; 3]], magnitude: f64) -> Result<Vec<[f64; 3]>> {
|
||||
Ok(normal_weights(xyz)?.into_iter().map(|w| w.map(|c| -magnitude * c)).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pressure_on_flat_faces_is_the_textbook_split() {
|
||||
// A 2 × 3 quadrilateral in z = 0, normal +z: quad4 shares p A equally, quad8 gives the
|
||||
// corners −A/12 and the mid-sides A/3.
|
||||
let quad = [[0., 0., 0.], [2., 0., 0.], [2., 3., 0.], [0., 3., 0.]];
|
||||
let f = pressure(&quad, 1.0).unwrap();
|
||||
assert!(f.iter().all(|v| (v[2] + 1.5).abs() < 1e-14 && v[0] == 0.0 && v[1] == 0.0), "{f:?}");
|
||||
let mut q8 = quad.to_vec();
|
||||
for (a, b) in [(0, 1), (1, 2), (2, 3), (3, 0)] {
|
||||
q8.push([0, 1, 2].map(|k| (quad[a][k] + quad[b][k]) / 2.0));
|
||||
}
|
||||
let f = pressure(&q8, 1.0).unwrap();
|
||||
for (i, v) in f.iter().enumerate() {
|
||||
let want = if i < 4 { 0.5 } else { -2.0 };
|
||||
assert!((v[2] - want).abs() < 1e-13, "{i}: {v:?}");
|
||||
}
|
||||
// Triangles: tri3 A/3 each; tri6 corners 0, mid-sides A/3.
|
||||
let tri = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
|
||||
let f = pressure(&tri, 6.0).unwrap();
|
||||
assert!(f.iter().all(|v| (v[2] + 1.0).abs() < 1e-14), "{f:?}");
|
||||
let mut t6 = tri.to_vec();
|
||||
for (a, b) in [(0, 1), (1, 2), (2, 0)] {
|
||||
t6.push([0, 1, 2].map(|k| (tri[a][k] + tri[b][k]) / 2.0));
|
||||
}
|
||||
let f = pressure(&t6, 6.0).unwrap();
|
||||
for (i, v) in f.iter().enumerate() {
|
||||
let want = if i < 3 { 0.0 } else { -1.0 };
|
||||
assert!((v[2] - want).abs() < 1e-13, "{i}: {v:?}");
|
||||
}
|
||||
assert!(pressure(&tri[..2], 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_on_a_curved_face_sums_to_the_projected_area() {
|
||||
// A quadratic face on a quarter cylinder r = 2, z in 0..1, normal outward: the total
|
||||
// pressure force is −p times the projected areas (2 in x and 2 in y).
|
||||
let r: f64 = 2.0;
|
||||
let at = |t: f64, z: f64| [r * t.cos(), r * t.sin(), z];
|
||||
let h = std::f64::consts::FRAC_PI_2;
|
||||
// Corners counter-clockwise seen from outside: (0,0), (0,1)... with the normal outward.
|
||||
let xyz = vec![at(0.0, 0.0), at(h, 0.0), at(h, 1.0), at(0.0, 1.0), at(h / 2.0, 0.0), at(h, 0.5), at(h / 2.0, 1.0), at(0.0, 0.5)];
|
||||
let w = normal_weights(&xyz).unwrap();
|
||||
let total = w.iter().fold([0.0; 3], |a, v| [a[0] + v[0], a[1] + v[1], a[2] + v[2]]);
|
||||
// A quadratic arc interpolates the circle closely; the projected area is exact for the
|
||||
// interpolated surface (its end points are exact).
|
||||
assert!((total[0].abs() - 2.0).abs() < 1e-12 && (total[1].abs() - 2.0).abs() < 1e-12 && total[2].abs() < 1e-12, "{total:?}");
|
||||
assert!(total[0] > 0.0 && total[1] > 0.0, "this ordering points outwards: {total:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Finite element kernels shared by ModelCraft's structural solvers.
|
||||
//!
|
||||
//! Pure functions over element geometry and material constants, with no solver loop and no
|
||||
//! global state, so the implicit solver (`modelcraft-solver`) and the explicit solver that follows
|
||||
//! it use the same element technology:
|
||||
//! - [`quadrature`]: Gauss–Legendre and tetrahedron rules;
|
||||
//! - [`shape`]: shape functions of tet4, tet10, hex8, hex20 and of their faces;
|
||||
//! - [`material`]: isotropic and orthotropic elasticity matrices, von Mises stress;
|
||||
//! - [`element`]: the element table, stiffness (full, reduced, incompatible modes), consistent
|
||||
//! and lumped mass, body forces, integration-point stresses and their extrapolation to nodes;
|
||||
//! - [`face`]: consistent nodal forces of pressure on element faces;
|
||||
//! - [`dense`]: the small dense linear algebra inside an element.
|
||||
//!
|
||||
//! Conventions: three translational degrees of freedom per node, ordered node by node; stress
|
||||
//! and strain 6-vectors in the order xx, yy, zz, xy, xz, yz with engineering shear strains.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
// Matrix kernels over fixed small dimensions read as the textbook formulas with index loops.
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
pub mod dense;
|
||||
pub mod element;
|
||||
pub mod face;
|
||||
pub mod material;
|
||||
pub mod quadrature;
|
||||
pub mod shape;
|
||||
|
||||
pub use element::{ELEMENT_TYPES, ElementType, Family, Formulation, PointStress, Solid, element_type};
|
||||
pub use material::{Elasticity, elasticity, von_mises};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
#[error("not supported yet: {0}")]
|
||||
NotSupported(String),
|
||||
/// An element's geometry is inverted or collapsed.
|
||||
#[error("{0}")]
|
||||
Degenerate(String),
|
||||
/// A matrix that must be positive definite is not.
|
||||
#[error("{0}")]
|
||||
Singular(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Linear elastic constitutive matrices.
|
||||
//!
|
||||
//! Stress and strain are 6-vectors in the order xx, yy, zz, xy, xz, yz, with engineering shear
|
||||
//! strains (γ = 2ε), so σ = D ε. The order puts the orthotropic shear moduli G₁₂, G₁₃, G₂₃ on
|
||||
//! the diagonal in the order the model lists them. Material axes are the global axes until the
|
||||
//! model has material orientations.
|
||||
//!
|
||||
//! Orthotropic stiffness is the inverse of the compliance written with engineering constants
|
||||
//! (R. M. Jones, *Mechanics of Composite Materials*, 2nd ed., Taylor & Francis 1999, §2.4):
|
||||
//! S₁₁ = 1/E₁, S₁₂ = −ν₁₂/E₁, S₁₃ = −ν₁₃/E₁, S₂₃ = −ν₂₃/E₂ (ν_ji/E_j = ν_ij/E_i), and
|
||||
//! 1/G on the shear diagonal.
|
||||
|
||||
use modelcraft_model::Elastic;
|
||||
|
||||
use crate::dense::inverse3;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A 6 × 6 elasticity matrix, row-major.
|
||||
pub type Elasticity = [[f64; 6]; 6];
|
||||
|
||||
/// The isotropic elasticity matrix.
|
||||
pub fn isotropic(young: f64, poisson: f64) -> Result<Elasticity> {
|
||||
if !(young > 0.0 && young.is_finite() && poisson > -1.0 && poisson < 0.5) {
|
||||
return Err(Error::Invalid(format!("isotropic constants E = {young}, ν = {poisson} are not a stable material")));
|
||||
}
|
||||
let lambda = young * poisson / ((1.0 + poisson) * (1.0 - 2.0 * poisson));
|
||||
let mu = young / (2.0 * (1.0 + poisson));
|
||||
let mut d = [[0.0; 6]; 6];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
d[i][j] = lambda;
|
||||
}
|
||||
d[i][i] = lambda + 2.0 * mu;
|
||||
d[i + 3][i + 3] = mu;
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The orthotropic elasticity matrix from engineering constants.
|
||||
pub fn orthotropic(young: [f64; 3], poisson: [f64; 3], shear: [f64; 3]) -> Result<Elasticity> {
|
||||
let [e1, e2, e3] = young;
|
||||
let [n12, n13, n23] = poisson;
|
||||
let s = [[1.0 / e1, -n12 / e1, -n13 / e1], [-n12 / e1, 1.0 / e2, -n23 / e2], [-n13 / e1, -n23 / e2, 1.0 / e3]];
|
||||
let scale = (1.0 / e1) * (1.0 / e2) * (1.0 / e3);
|
||||
let inverse = inverse3(&s).filter(|(_, det)| *det > 1e-12 * scale);
|
||||
let Some((normal, _)) = inverse.filter(|_| shear.iter().all(|g| *g > 0.0 && g.is_finite())) else {
|
||||
return Err(Error::Invalid("the orthotropic constants do not describe a stable material".into()));
|
||||
};
|
||||
let mut d = [[0.0; 6]; 6];
|
||||
for i in 0..3 {
|
||||
d[i][..3].copy_from_slice(&normal[i]);
|
||||
}
|
||||
for (k, g) in shear.iter().enumerate() {
|
||||
d[3 + k][3 + k] = *g;
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The elasticity matrix of a model material's elastic behaviour.
|
||||
pub fn elasticity(e: &Elastic) -> Result<Elasticity> {
|
||||
match *e {
|
||||
Elastic::Isotropic { young, poisson } => isotropic(young, poisson),
|
||||
Elastic::EngineeringConstants { young, poisson, shear } => orthotropic(young, poisson, shear),
|
||||
}
|
||||
}
|
||||
|
||||
/// The von Mises equivalent stress of a stress 6-vector.
|
||||
pub fn von_mises(s: &[f64; 6]) -> f64 {
|
||||
let [xx, yy, zz, xy, xz, yz] = *s;
|
||||
let normal = (xx - yy) * (xx - yy) + (yy - zz) * (yy - zz) + (zz - xx) * (zz - xx);
|
||||
(0.5 * normal + 3.0 * (xy * xy + xz * xz + yz * yz)).sqrt()
|
||||
}
|
||||
|
||||
/// σ = D ε.
|
||||
pub fn stress(d: &Elasticity, strain: &[f64; 6]) -> [f64; 6] {
|
||||
let mut s = [0.0; 6];
|
||||
for (si, row) in s.iter_mut().zip(d) {
|
||||
*si = row.iter().zip(strain).map(|(a, b)| a * b).sum();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn orthotropic_with_isotropic_constants_is_isotropic() {
|
||||
let (e, nu) = (210e3, 0.3);
|
||||
let g = e / (2.0 * (1.0 + nu));
|
||||
let a = isotropic(e, nu).unwrap();
|
||||
let b = orthotropic([e; 3], [nu; 3], [g; 3]).unwrap();
|
||||
for i in 0..6 {
|
||||
for j in 0..6 {
|
||||
assert!((a[i][j] - b[i][j]).abs() < 1e-9 * e, "{i} {j}: {} vs {}", a[i][j], b[i][j]);
|
||||
}
|
||||
}
|
||||
assert!(isotropic(1.0, 0.5).is_err());
|
||||
assert!(orthotropic([1.0; 3], [0.9; 3], [1.0; 3]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orthotropic_uniaxial_response() {
|
||||
// Stress along 1 alone gives strains σ/E1, −ν12 σ/E1, −ν13 σ/E1.
|
||||
let (young, poisson) = ([140e3, 10e3, 12e3], [0.3, 0.25, 0.4]);
|
||||
let d = orthotropic(young, poisson, [5e3, 4e3, 3.8e3]).unwrap();
|
||||
let strain = [1.0 / young[0], -poisson[0] / young[0], -poisson[1] / young[0], 0.0, 0.0, 0.0];
|
||||
let s = stress(&d, &strain);
|
||||
assert!((s[0] - 1.0).abs() < 1e-12 && s[1].abs() < 1e-12 && s[2].abs() < 1e-12, "{s:?}");
|
||||
assert!((von_mises(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]) - 1.0).abs() < 1e-15);
|
||||
assert!((von_mises(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0]) - 3f64.sqrt()).abs() < 1e-15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Numerical integration rules on the reference elements.
|
||||
//!
|
||||
//! - Gauss–Legendre rules on [−1, 1] with 1 to 4 points (exact for polynomials of degree
|
||||
//! 2n − 1), and their tensor products on the reference hexahedron [−1, 1]³.
|
||||
//! - On the reference tetrahedron {ξ, η, ζ ≥ 0, ξ + η + ζ ≤ 1} (volume 1/6): the centroid rule
|
||||
//! (degree 1), the symmetric four-point rule (degree 2), and the conical product rule of Stroud
|
||||
//! (A. H. Stroud, *Approximate Calculation of Multiple Integrals*, Prentice-Hall 1971, §2.7):
|
||||
//! the collapsed map ξ = u, η = v(1 − u), ζ = w(1 − u)(1 − v) from the unit cube, with
|
||||
//! Jacobian (1 − u)²(1 − v), integrated by Gauss–Legendre in u, v and w. With n points per
|
||||
//! direction it is exact for polynomials of degree 2n − 3 on the tetrahedron, with positive
|
||||
//! weights only.
|
||||
//! - On the reference triangle {ξ, η ≥ 0, ξ + η ≤ 1}: the same collapsed product in two
|
||||
//! dimensions (Jacobian 1 − u), exact for degree 2n − 2.
|
||||
//! - On the reference square [−1, 1]²: tensor Gauss–Legendre.
|
||||
|
||||
/// A quadrature point: natural coordinates and weight.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Point {
|
||||
pub xi: [f64; 3],
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
/// Gauss–Legendre abscissae and weights on [−1, 1], `n` in 1..=4 (clamped).
|
||||
pub fn gauss_legendre(n: usize) -> &'static [(f64, f64)] {
|
||||
const G1: [(f64, f64); 1] = [(0.0, 2.0)];
|
||||
const G2: [(f64, f64); 2] = [(-0.577_350_269_189_625_8, 1.0), (0.577_350_269_189_625_8, 1.0)];
|
||||
const G3: [(f64, f64); 3] = [(-0.774_596_669_241_483_4, 5.0 / 9.0), (0.0, 8.0 / 9.0), (0.774_596_669_241_483_4, 5.0 / 9.0)];
|
||||
const G4: [(f64, f64); 4] = [
|
||||
(-0.861_136_311_594_052_6, 0.347_854_845_137_453_9),
|
||||
(-0.339_981_043_584_856_3, 0.652_145_154_862_546_1),
|
||||
(0.339_981_043_584_856_3, 0.652_145_154_862_546_1),
|
||||
(0.861_136_311_594_052_6, 0.347_854_845_137_453_9),
|
||||
];
|
||||
match n {
|
||||
0 | 1 => &G1,
|
||||
2 => &G2,
|
||||
3 => &G3,
|
||||
_ => &G4,
|
||||
}
|
||||
}
|
||||
|
||||
/// An integration rule on a reference element.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Rule {
|
||||
/// n × n × n Gauss–Legendre on the reference hexahedron.
|
||||
Hex(u8),
|
||||
/// The tetrahedron's centroid, weight 1/6 (degree 1).
|
||||
TetCentroid,
|
||||
/// Four symmetric points on the tetrahedron, weight 1/24 each (degree 2).
|
||||
Tet4,
|
||||
/// Stroud's conical product on the tetrahedron, n points per direction.
|
||||
TetConical(u8),
|
||||
/// n × n Gauss–Legendre on the reference square (z = 0).
|
||||
Quad(u8),
|
||||
/// The collapsed product on the reference triangle, n points per direction (z = 0).
|
||||
TriConical(u8),
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
pub fn points(self) -> Vec<Point> {
|
||||
match self {
|
||||
Rule::Hex(n) => {
|
||||
let g = gauss_legendre(usize::from(n));
|
||||
let mut out = Vec::with_capacity(g.len().pow(3));
|
||||
// ξ fastest, then η, then ζ: the corner order of the reference hexahedron for 2 × 2 × 2.
|
||||
for &(z, wz) in g {
|
||||
for &(y, wy) in g {
|
||||
for &(x, wx) in g {
|
||||
out.push(Point { xi: [x, y, z], weight: wx * wy * wz });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Rule::TetCentroid => vec![Point { xi: [0.25; 3], weight: 1.0 / 6.0 }],
|
||||
Rule::Tet4 => {
|
||||
// (5 ∓ √5)/20: the barycentric coordinates of the degree-2 rule.
|
||||
let a = 0.585_410_196_624_968_5;
|
||||
let b = 0.138_196_601_125_010_5;
|
||||
// Point k sits near corner k (barycentric a there, b elsewhere).
|
||||
[[b, b, b], [a, b, b], [b, a, b], [b, b, a]].into_iter().map(|xi| Point { xi, weight: 1.0 / 24.0 }).collect()
|
||||
}
|
||||
Rule::TetConical(n) => {
|
||||
let g: Vec<(f64, f64)> = gauss_legendre(usize::from(n)).iter().map(|&(x, w)| ((x + 1.0) / 2.0, w / 2.0)).collect();
|
||||
let mut out = Vec::with_capacity(g.len().pow(3));
|
||||
for &(u, wu) in &g {
|
||||
for &(v, wv) in &g {
|
||||
for &(w, ww) in &g {
|
||||
let xi = [u, v * (1.0 - u), w * (1.0 - u) * (1.0 - v)];
|
||||
out.push(Point { xi, weight: wu * wv * ww * (1.0 - u) * (1.0 - u) * (1.0 - v) });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Rule::Quad(n) => {
|
||||
let g = gauss_legendre(usize::from(n));
|
||||
g.iter().flat_map(|&(y, wy)| g.iter().map(move |&(x, wx)| Point { xi: [x, y, 0.0], weight: wx * wy })).collect()
|
||||
}
|
||||
Rule::TriConical(n) => {
|
||||
let g: Vec<(f64, f64)> = gauss_legendre(usize::from(n)).iter().map(|&(x, w)| ((x + 1.0) / 2.0, w / 2.0)).collect();
|
||||
g.iter()
|
||||
.flat_map(|&(u, wu)| g.iter().map(move |&(v, wv)| Point { xi: [u, v * (1.0 - u), 0.0], weight: wu * wv * (1.0 - u) }))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// ∫ ξ^a η^b ζ^c over the reference tetrahedron = a! b! c! / (a + b + c + 3)!.
|
||||
fn tet_monomial(a: u32, b: u32, c: u32) -> f64 {
|
||||
let f = |n: u32| (1..=n).map(f64::from).product::<f64>();
|
||||
f(a) * f(b) * f(c) / f(a + b + c + 3)
|
||||
}
|
||||
|
||||
fn integrate(rule: Rule, a: i32, b: i32, c: i32) -> f64 {
|
||||
rule.points().iter().map(|p| p.weight * p.xi[0].powi(a) * p.xi[1].powi(b) * p.xi[2].powi(c)).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tetrahedron_rules_are_exact_to_their_degree() {
|
||||
for (rule, degree) in [(Rule::TetCentroid, 1), (Rule::Tet4, 2), (Rule::TetConical(3), 3), (Rule::TetConical(4), 5)] {
|
||||
for a in 0..=degree {
|
||||
for b in 0..=degree - a {
|
||||
for c in 0..=degree - a - b {
|
||||
let exact = tet_monomial(a, b, c);
|
||||
let got = integrate(rule, a as i32, b as i32, c as i32);
|
||||
assert!((got - exact).abs() < 1e-14, "{rule:?} ξ^{a} η^{b} ζ^{c}: {got} vs {exact}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hexahedron_and_face_rules() {
|
||||
for n in 1..=4u8 {
|
||||
let d = 2 * i32::from(n) - 1;
|
||||
for a in 0..=d {
|
||||
// ∫_{-1}^{1} x^a dx.
|
||||
let exact_1d = if a % 2 == 1 { 0.0 } else { 2.0 / f64::from(a + 1) };
|
||||
let got = integrate(Rule::Hex(n), a, 0, 0);
|
||||
assert!((got - exact_1d * 4.0).abs() < 1e-14, "hex {n} x^{a}");
|
||||
let got = integrate(Rule::Quad(n), 0, a, 0);
|
||||
assert!((got - exact_1d * 2.0).abs() < 1e-14, "quad {n} y^{a}");
|
||||
}
|
||||
}
|
||||
// Triangle: ∫ ξ^a η^b = a! b! / (a + b + 2)!.
|
||||
let f = |n: i32| (1..=n).map(f64::from).product::<f64>();
|
||||
for a in 0..=4 {
|
||||
for b in 0..=4 - a {
|
||||
let got = integrate(Rule::TriConical(3), a, b, 0);
|
||||
assert!((got - f(a) * f(b) / f(a + b + 2)).abs() < 1e-14, "tri ξ^{a} η^{b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Shape functions and their derivatives in natural coordinates.
|
||||
//!
|
||||
//! Node order is the model's (Abaqus/VTK, [`modelcraft_model::ElementKind`]):
|
||||
//! - tetrahedra on the reference element with corners (0,0,0), (1,0,0), (0,1,0), (0,0,1) and the
|
||||
//! volume coordinates L₁ = 1 − ξ − η − ζ, L₂ = ξ, L₃ = η, L₄ = ζ; tet10 mid-side nodes on edges
|
||||
//! 1-2, 2-3, 3-1, 1-4, 2-4, 3-4 with N = 4 Lₐ L_b, corners N = L(2L − 1);
|
||||
//! - hexahedra on [−1, 1]³, corners 1–4 at ζ = −1 counter-clockwise from (−1,−1), 5–8 above
|
||||
//! them; hex20 is the serendipity element (corners
|
||||
//! N = ⅛(1 + ξξᵢ)(1 + ηηᵢ)(1 + ζζᵢ)(ξξᵢ + ηηᵢ + ζζᵢ − 2), a mid-side node with ξᵢ = 0
|
||||
//! N = ¼(1 − ξ²)(1 + ηηᵢ)(1 + ζζᵢ), and likewise), mid-side nodes 9–12 on the bottom edges,
|
||||
//! 13–16 on the top edges, 17–20 on the vertical edges (mid-side positions come from
|
||||
//! [`modelcraft_model::ElementKind::edges`], the one place the node order is written down).
|
||||
//!
|
||||
//! The functions are the textbook ones (O. C. Zienkiewicz, R. L. Taylor, J. Z. Zhu, *The Finite
|
||||
//! Element Method: Its Basis and Fundamentals*, 7th ed., Butterworth-Heinemann 2013, ch. 6;
|
||||
//! T. J. R. Hughes, *The Finite Element Method*, Dover 2000, ch. 3).
|
||||
|
||||
use modelcraft_model::ElementKind;
|
||||
|
||||
/// Most nodes an element in this crate has (hex20).
|
||||
pub const MAX_NODES: usize = 20;
|
||||
|
||||
/// Shape function values and natural derivatives at one point.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Shape {
|
||||
pub count: usize,
|
||||
pub n: [f64; MAX_NODES],
|
||||
/// ∂Nᵢ/∂ξ, ∂Nᵢ/∂η, ∂Nᵢ/∂ζ.
|
||||
pub dn: [[f64; 3]; MAX_NODES],
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
fn new(count: usize) -> Shape {
|
||||
Shape { count, n: [0.0; MAX_NODES], dn: [[0.0; 3]; MAX_NODES] }
|
||||
}
|
||||
|
||||
pub fn values(&self) -> &[f64] {
|
||||
self.n.get(..self.count).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn derivatives(&self) -> &[[f64; 3]] {
|
||||
self.dn.get(..self.count).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Natural coordinates of the hexahedron's corners.
|
||||
pub const HEX_CORNERS: [[f64; 3]; 8] =
|
||||
[[-1., -1., -1.], [1., -1., -1.], [1., 1., -1.], [-1., 1., -1.], [-1., -1., 1.], [1., -1., 1.], [1., 1., 1.], [-1., 1., 1.]];
|
||||
|
||||
/// Natural coordinates of the tetrahedron's corners.
|
||||
pub const TET_CORNERS: [[f64; 3]; 4] = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]];
|
||||
|
||||
/// Four-node tetrahedron.
|
||||
pub fn tet4(xi: [f64; 3]) -> Shape {
|
||||
let mut s = Shape::new(4);
|
||||
s.n[0] = 1.0 - xi[0] - xi[1] - xi[2];
|
||||
s.n[1] = xi[0];
|
||||
s.n[2] = xi[1];
|
||||
s.n[3] = xi[2];
|
||||
s.dn[0] = [-1.0, -1.0, -1.0];
|
||||
s.dn[1] = [1.0, 0.0, 0.0];
|
||||
s.dn[2] = [0.0, 1.0, 0.0];
|
||||
s.dn[3] = [0.0, 0.0, 1.0];
|
||||
s
|
||||
}
|
||||
|
||||
/// Ten-node tetrahedron.
|
||||
pub fn tet10(xi: [f64; 3]) -> Shape {
|
||||
let lin = tet4(xi);
|
||||
let l = [lin.n[0], lin.n[1], lin.n[2], lin.n[3]];
|
||||
let dl = [lin.dn[0], lin.dn[1], lin.dn[2], lin.dn[3]];
|
||||
let mut s = Shape::new(10);
|
||||
for i in 0..4 {
|
||||
s.n[i] = l[i] * (2.0 * l[i] - 1.0);
|
||||
s.dn[i] = dl[i].map(|d| (4.0 * l[i] - 1.0) * d);
|
||||
}
|
||||
// Mid-side nodes on the model's tet10 edges.
|
||||
for edge in ElementKind::Tet10.edges() {
|
||||
if let [a, b, m] = **edge {
|
||||
s.n[m] = 4.0 * l[a] * l[b];
|
||||
s.dn[m] = [0, 1, 2].map(|c| 4.0 * (dl[a][c] * l[b] + l[a] * dl[b][c]));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Eight-node hexahedron (trilinear).
|
||||
pub fn hex8(xi: [f64; 3]) -> Shape {
|
||||
let mut s = Shape::new(8);
|
||||
for (i, c) in HEX_CORNERS.iter().enumerate() {
|
||||
let f = [0, 1, 2].map(|k| 1.0 + xi[k] * c[k]);
|
||||
s.n[i] = f[0] * f[1] * f[2] / 8.0;
|
||||
s.dn[i] = [c[0] * f[1] * f[2] / 8.0, c[1] * f[0] * f[2] / 8.0, c[2] * f[0] * f[1] / 8.0];
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Twenty-node serendipity hexahedron.
|
||||
pub fn hex20(xi: [f64; 3]) -> Shape {
|
||||
let mut s = Shape::new(20);
|
||||
let [x, y, z] = xi;
|
||||
for (i, c) in HEX_CORNERS.iter().enumerate() {
|
||||
let f = [0, 1, 2].map(|k| 1.0 + xi[k] * c[k]);
|
||||
let g = x * c[0] + y * c[1] + z * c[2] - 2.0;
|
||||
s.n[i] = f[0] * f[1] * f[2] * g / 8.0;
|
||||
// ∂/∂ξ_k: c_k f_l f_m g / 8 + f_0 f_1 f_2 c_k / 8.
|
||||
s.dn[i] = [c[0] * f[1] * f[2] * (g + f[0]) / 8.0, c[1] * f[0] * f[2] * (g + f[1]) / 8.0, c[2] * f[0] * f[1] * (g + f[2]) / 8.0];
|
||||
}
|
||||
// Mid-side nodes on the model's hex20 edges, at the midpoints of their corners.
|
||||
for edge in ElementKind::Hex20.edges() {
|
||||
let [a, b, i] = **edge else { continue };
|
||||
let c = [0, 1, 2].map(|k| (HEX_CORNERS[a][k] + HEX_CORNERS[b][k]) / 2.0);
|
||||
// The direction along the edge has cᵢ = 0: N = ¼(1 − t²)(1 + a aᵢ)(1 + b bᵢ).
|
||||
let along = if c[0] == 0.0 {
|
||||
0
|
||||
} else if c[1] == 0.0 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let (p, q) = ((along + 1) % 3, (along + 2) % 3);
|
||||
let t = xi[along];
|
||||
let fp = 1.0 + xi[p] * c[p];
|
||||
let fq = 1.0 + xi[q] * c[q];
|
||||
s.n[i] = (1.0 - t * t) * fp * fq / 4.0;
|
||||
let mut d = [0.0; 3];
|
||||
d[along] = -2.0 * t * fp * fq / 4.0;
|
||||
d[p] = (1.0 - t * t) * c[p] * fq / 4.0;
|
||||
d[q] = (1.0 - t * t) * fp * c[q] / 4.0;
|
||||
s.dn[i] = d;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn nodes_of(kind: &str) -> Vec<[f64; 3]> {
|
||||
match kind {
|
||||
"tet4" => TET_CORNERS.to_vec(),
|
||||
"tet10" => {
|
||||
let mut v = TET_CORNERS.to_vec();
|
||||
for e in ElementKind::Tet10.edges() {
|
||||
v.push([0, 1, 2].map(|k| (TET_CORNERS[e[0]][k] + TET_CORNERS[e[1]][k]) / 2.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"hex8" => HEX_CORNERS.to_vec(),
|
||||
_ => {
|
||||
let mut v = HEX_CORNERS.to_vec();
|
||||
for e in ElementKind::Hex20.edges() {
|
||||
v.push([0, 1, 2].map(|k| (HEX_CORNERS[e[0]][k] + HEX_CORNERS[e[1]][k]) / 2.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kronecker_delta_partition_of_unity_and_derivatives() {
|
||||
let kinds: [(&str, fn([f64; 3]) -> Shape); 4] = [("tet4", tet4), ("tet10", tet10), ("hex8", hex8), ("hex20", hex20)];
|
||||
for (name, f) in kinds {
|
||||
let nodes = nodes_of(name);
|
||||
for (i, p) in nodes.iter().enumerate() {
|
||||
let s = f(*p);
|
||||
for (j, v) in s.values().iter().enumerate() {
|
||||
assert!((v - if i == j { 1.0 } else { 0.0 }).abs() < 1e-14, "{name} N{j} at node {i}: {v}");
|
||||
}
|
||||
}
|
||||
// At an arbitrary point: ΣN = 1, ΣdN = 0, Σ N x = x (linear completeness), and the
|
||||
// derivatives match central differences.
|
||||
let p = [0.21, 0.17, 0.33];
|
||||
let s = f(p);
|
||||
assert!((s.values().iter().sum::<f64>() - 1.0).abs() < 1e-14);
|
||||
for k in 0..3 {
|
||||
assert!(s.derivatives().iter().map(|d| d[k]).sum::<f64>().abs() < 1e-13);
|
||||
let x: f64 = s.values().iter().zip(&nodes).map(|(n, x)| n * x[k]).sum();
|
||||
assert!((x - p[k]).abs() < 1e-14, "{name}");
|
||||
let h = 1e-6;
|
||||
let (mut a, mut b) = (p, p);
|
||||
a[k] += h;
|
||||
b[k] -= h;
|
||||
let (sa, sb) = (f(a), f(b));
|
||||
for j in 0..s.count {
|
||||
let fd = (sa.n[j] - sb.n[j]) / (2.0 * h);
|
||||
assert!((fd - s.dn[j][k]).abs() < 1e-8, "{name} dN{j}/dξ{k}: {fd} vs {}", s.dn[j][k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Element-level checks: rigid-body modes, constant-strain reproduction (the single-element form
|
||||
//! of the patch test), mass and load totals, extrapolation.
|
||||
|
||||
use modelcraft_model::ElementKind;
|
||||
|
||||
use crate::dense::{Cholesky, Matrix};
|
||||
use crate::element::{Formulation, Solid};
|
||||
use crate::material::{isotropic, orthotropic, stress};
|
||||
|
||||
/// A distorted but valid element of each kind, mid-side nodes off the straight edges for the
|
||||
/// quadratic kinds when `curved`.
|
||||
fn distorted(kind: ElementKind, curved: bool) -> Vec<[f64; 3]> {
|
||||
let corners: Vec<[f64; 3]> = match kind {
|
||||
ElementKind::Tet4 | ElementKind::Tet10 => vec![[0.1, -0.05, 0.0], [1.2, 0.1, 0.05], [0.2, 0.9, -0.1], [0.05, 0.15, 1.1]],
|
||||
_ => vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.1, 0.1, -0.1],
|
||||
[2.3, 1.2, 0.05],
|
||||
[-0.1, 0.9, 0.1],
|
||||
[0.1, -0.1, 0.8],
|
||||
[2.0, 0.05, 1.1],
|
||||
[2.2, 1.1, 0.9],
|
||||
[0.05, 1.05, 1.2],
|
||||
],
|
||||
};
|
||||
let mut xyz = corners.clone();
|
||||
for (i, e) in kind.edges().iter().enumerate() {
|
||||
if let [a, b, _] = **e {
|
||||
let bump = if curved { 0.03 * ((i % 3) as f64 - 1.0) } else { 0.0 };
|
||||
xyz.push([0, 1, 2].map(|k| (corners[a][k] + corners[b][k]) / 2.0 + bump));
|
||||
}
|
||||
}
|
||||
xyz
|
||||
}
|
||||
|
||||
const CASES: [(ElementKind, Formulation); 6] = [
|
||||
(ElementKind::Tet4, Formulation::Full),
|
||||
(ElementKind::Tet10, Formulation::Full),
|
||||
(ElementKind::Hex8, Formulation::Full),
|
||||
(ElementKind::Hex8, Formulation::IncompatibleModes),
|
||||
(ElementKind::Hex20, Formulation::Full),
|
||||
(ElementKind::Hex20, Formulation::Reduced),
|
||||
];
|
||||
|
||||
fn rigid_modes(xyz: &[[f64; 3]]) -> Vec<Vec<f64>> {
|
||||
let mut modes = Vec::new();
|
||||
for k in 0..3 {
|
||||
modes.push(xyz.iter().flat_map(|_| [0, 1, 2].map(|c| if c == k { 1.0 } else { 0.0 })).collect());
|
||||
}
|
||||
for axis in 0..3 {
|
||||
// ω × x for ω along `axis`.
|
||||
let w = [0, 1, 2].map(|c| if c == axis { 1.0 } else { 0.0 });
|
||||
modes.push(xyz.iter().flat_map(|x| [w[1] * x[2] - w[2] * x[1], w[2] * x[0] - w[0] * x[2], w[0] * x[1] - w[1] * x[0]]).collect());
|
||||
}
|
||||
modes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stiffness_is_symmetric_with_exactly_six_rigid_body_modes() {
|
||||
let d = isotropic(1000.0, 0.3).unwrap();
|
||||
for (kind, f) in CASES {
|
||||
let xyz = distorted(kind, true);
|
||||
let k = Solid::new(kind, &xyz, f).unwrap().stiffness(&d).unwrap();
|
||||
let scale = k.data.iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
for i in 0..k.n {
|
||||
for j in 0..k.n {
|
||||
assert_eq!(k.get(i, j), k.get(j, i), "{kind:?} {f:?} symmetric");
|
||||
}
|
||||
}
|
||||
for m in rigid_modes(&xyz) {
|
||||
let r = k.mul_vec(&m);
|
||||
assert!(r.iter().all(|v| v.abs() < 1e-10 * scale), "{kind:?} {f:?}: rigid motion strains the element");
|
||||
}
|
||||
// Holding six degrees of freedom (node 1 fully, node 2 in y and z, node 3 in z) leaves a
|
||||
// positive definite matrix: no other zero-energy mode. Reduced hex20 has spurious modes
|
||||
// on a single element, so it is skipped.
|
||||
if f != Formulation::Reduced {
|
||||
let mut held = k.clone();
|
||||
for dof in [0, 1, 2, 4, 5, 8] {
|
||||
held.add(dof, dof, 1e3 * scale);
|
||||
}
|
||||
assert!(Cholesky::new(&held).is_ok(), "{kind:?} {f:?} has a spurious zero-energy mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_strain_is_reproduced_exactly() {
|
||||
// u = A x for a general A: every integration point sees ε(A), the stiffness's internal
|
||||
// forces balance (K u is the boundary load of a uniform stress), and the incompatible modes
|
||||
// stay idle.
|
||||
let a = [[1e-3, 2e-4, -3e-4], [5e-4, -2e-3, 1e-4], [-1e-4, 3e-4, 7e-4]];
|
||||
let strain = [a[0][0], a[1][1], a[2][2], a[0][1] + a[1][0], a[0][2] + a[2][0], a[1][2] + a[2][1]];
|
||||
for d in [isotropic(210e3, 0.3).unwrap(), orthotropic([140e3, 10e3, 12e3], [0.3, 0.25, 0.4], [5e3, 4e3, 3.8e3]).unwrap()] {
|
||||
let want = stress(&d, &strain);
|
||||
for (kind, f) in CASES {
|
||||
// Curved mid-sides keep the field in the isoparametric span only for straight edges.
|
||||
let xyz = distorted(kind, false);
|
||||
let u: Vec<[f64; 3]> = xyz.iter().map(|x| [0, 1, 2].map(|i| (0..3).map(|j| a[i][j] * x[j]).sum())).collect();
|
||||
let e = Solid::new(kind, &xyz, f).unwrap();
|
||||
for p in e.stresses(&d, &u).unwrap() {
|
||||
for k in 0..6 {
|
||||
assert!((p.strain[k] - strain[k]).abs() < 1e-14, "{kind:?} {f:?} strain {k}");
|
||||
assert!((p.stress[k] - want[k]).abs() < 1e-9 * want[0].abs().max(1.0), "{kind:?} {f:?} stress {k}");
|
||||
}
|
||||
}
|
||||
let nodal = e.extrapolate(&e.stresses(&d, &u).unwrap().iter().map(|p| p.stress).collect::<Vec<_>>()).unwrap();
|
||||
assert_eq!(nodal.len(), xyz.len());
|
||||
assert!(nodal.iter().all(|s| (0..6).all(|k| (s[k] - want[k]).abs() < 1e-9 * want[0].abs())), "{kind:?} {f:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_volume_and_body_force_totals() {
|
||||
let rho = 7.85e-9;
|
||||
for (kind, f) in CASES {
|
||||
let xyz = distorted(kind, true);
|
||||
let e = Solid::new(kind, &xyz, f).unwrap();
|
||||
let v = e.volume().unwrap();
|
||||
assert!(v > 0.0);
|
||||
let m = e.mass(rho).unwrap();
|
||||
let total: f64 = m.data.iter().sum();
|
||||
assert!((total - rho * v).abs() < 1e-12 * rho * v, "{kind:?}: {total} vs {}", rho * v);
|
||||
let lumped = e.lumped_mass(rho).unwrap();
|
||||
assert!(lumped.iter().all(|x| *x > 0.0), "{kind:?}: lumped masses are positive");
|
||||
assert!((lumped.iter().sum::<f64>() - rho * v).abs() < 1e-12 * rho * v);
|
||||
let b = e.body_force([0.0, 0.0, -9.81]).unwrap();
|
||||
assert!((b.iter().map(|f| f[2]).sum::<f64>() + 9.81 * v).abs() < 1e-12 * v);
|
||||
}
|
||||
// A tetrahedron's volume is a sixth of its edge triple product.
|
||||
let xyz = distorted(ElementKind::Tet4, false);
|
||||
let e = |i: usize| [0, 1, 2].map(|k| xyz[i][k] - xyz[0][k]);
|
||||
let (a, b, c) = (e(1), e(2), e(3));
|
||||
let exact = (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])) / 6.0;
|
||||
let v = Solid::new(ElementKind::Tet4, &xyz, Formulation::Full).unwrap().volume().unwrap();
|
||||
assert!((v - exact).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrapolation_reproduces_linear_fields_on_affine_elements() {
|
||||
// Values linear in x at the integration points come back exactly at the nodes (tet4 has
|
||||
// one point and gives the mean).
|
||||
let f = |x: [f64; 3]| 1.0 + 2.0 * x[0] - 0.5 * x[1] + 3.0 * x[2];
|
||||
let affine = |kind: ElementKind| -> Vec<[f64; 3]> {
|
||||
let mut xyz: Vec<[f64; 3]> = match kind {
|
||||
ElementKind::Tet10 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]],
|
||||
_ => vec![[0., 0., 0.], [2., 0., 0.], [2.5, 1., 0.], [0.5, 1., 0.], [0., 0., 1.], [2., 0., 1.], [2.5, 1., 1.], [0.5, 1., 1.]],
|
||||
};
|
||||
let c = xyz.clone();
|
||||
for e in kind.edges() {
|
||||
if let [a, b, _] = **e {
|
||||
xyz.push([0, 1, 2].map(|k| (c[a][k] + c[b][k]) / 2.0));
|
||||
}
|
||||
}
|
||||
xyz
|
||||
};
|
||||
for (kind, form) in CASES.into_iter().filter(|(k, _)| *k != ElementKind::Tet4) {
|
||||
let xyz = affine(kind);
|
||||
let e = Solid::new(kind, &xyz, form).unwrap();
|
||||
let values: Vec<[f64; 6]> = e.integration_points().iter().map(|p| [f(e.position(p.xi)); 6]).collect();
|
||||
let nodal = e.extrapolate(&values).unwrap();
|
||||
for (x, v) in xyz.iter().zip(&nodal) {
|
||||
assert!((v[0] - f(*x)).abs() < 1e-12, "{kind:?} {form:?} at {x:?}: {} vs {}", v[0], f(*x));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_is_an_error() {
|
||||
let xyz = distorted(ElementKind::Hex8, false);
|
||||
assert!(Solid::new(ElementKind::Hex8, &xyz[..7], Formulation::Full).is_err());
|
||||
assert!(Solid::new(ElementKind::Hex8, &xyz, Formulation::Reduced).is_err());
|
||||
assert!(Solid::new(ElementKind::Tet4, &xyz[..4], Formulation::IncompatibleModes).is_err());
|
||||
assert!(Solid::new(ElementKind::Wedge6, &xyz[..6], Formulation::Full).is_err());
|
||||
let mut nan = xyz.clone();
|
||||
nan[3][1] = f64::NAN;
|
||||
assert!(Solid::new(ElementKind::Hex8, &nan, Formulation::Full).is_err());
|
||||
// An inverted element (top and bottom swapped) and a flat one.
|
||||
let mut inverted = xyz.clone();
|
||||
inverted.rotate_left(4);
|
||||
let d = isotropic(1.0, 0.3).unwrap();
|
||||
assert!(Solid::new(ElementKind::Hex8, &inverted, Formulation::Full).unwrap().stiffness(&d).is_err());
|
||||
let flat: Vec<[f64; 3]> = xyz.iter().map(|p| [p[0], p[1], 0.0]).collect();
|
||||
assert!(Solid::new(ElementKind::Hex8, &flat, Formulation::IncompatibleModes).unwrap().stiffness(&d).is_err());
|
||||
let e = Solid::new(ElementKind::Hex8, &xyz, Formulation::Full).unwrap();
|
||||
assert!(e.stresses(&d, &[[0.0; 3]; 3]).is_err());
|
||||
assert!(e.extrapolate(&[[0.0; 6]; 3]).is_err());
|
||||
let _ = Matrix::zeros(0).mul_vec(&[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_formulations() {
|
||||
use modelcraft_model::{Formulation as F, Integration};
|
||||
let full = F::default();
|
||||
let reduced = F { integration: Integration::Reduced, ..F::default() };
|
||||
let im = F { incompatible_modes: true, ..F::default() };
|
||||
let hybrid = F { hybrid: true, ..F::default() };
|
||||
assert_eq!(Formulation::from_section(ElementKind::Hex8, &full), Ok(Formulation::Full));
|
||||
assert_eq!(Formulation::from_section(ElementKind::Hex8, &im), Ok(Formulation::IncompatibleModes));
|
||||
assert_eq!(Formulation::from_section(ElementKind::Hex20, &reduced), Ok(Formulation::Reduced));
|
||||
assert!(Formulation::from_section(ElementKind::Hex8, &reduced).unwrap_err().to_string().contains("not supported yet"));
|
||||
assert!(Formulation::from_section(ElementKind::Tet10, &im).is_err());
|
||||
assert!(Formulation::from_section(ElementKind::Tet4, &hybrid).is_err());
|
||||
assert!(Formulation::from_section(ElementKind::Quad4, &full).is_err());
|
||||
}
|
||||
Reference in new issue
Block a user