use oxiphysics_core::math::Vec3;
use crate::constitutive::LinearElasticMaterial;
use crate::element::LinearTetrahedron;
use crate::mesh::TetrahedralMesh;
use crate::sparse::CsrMatrix;
pub struct CooMatrix {
pub n_rows: usize,
pub n_cols: usize,
pub entries: Vec<(usize, usize, f64)>,
}
impl CooMatrix {
pub fn new(n_rows: usize, n_cols: usize) -> Self {
Self {
n_rows,
n_cols,
entries: Vec::new(),
}
}
pub fn add(&mut self, row: usize, col: usize, value: f64) {
assert!(
row < self.n_rows,
"row {row} out of bounds ({} rows)",
self.n_rows
);
assert!(
col < self.n_cols,
"col {col} out of bounds ({} cols)",
self.n_cols
);
self.entries.push((row, col, value));
}
pub fn to_csr(&self) -> CsrMatrix {
let triplets: Vec<(usize, usize, f64)> = self.entries.clone();
CsrMatrix::from_triplets(self.n_rows, self.n_cols, &triplets)
}
}
pub fn assemble_global_stiffness(
nodes: &[Vec3],
elements: &[[usize; 4]],
d_matrix: &[[f64; 6]; 6],
) -> CsrMatrix {
let n_nodes = nodes.len();
let ndof = n_nodes * 3;
let mut coo = CooMatrix::new(ndof, ndof);
for elem in elements {
let elem_nodes = [
nodes[elem[0]],
nodes[elem[1]],
nodes[elem[2]],
nodes[elem[3]],
];
let ke = LinearTetrahedron::element_stiffness(&elem_nodes, d_matrix);
for local_i in 0..4 {
for local_j in 0..4 {
for di in 0..3 {
for dj in 0..3 {
let global_i = elem[local_i] * 3 + di;
let global_j = elem[local_j] * 3 + dj;
let val = ke[local_i * 3 + di][local_j * 3 + dj];
if val.abs() > 1e-30 {
coo.add(global_i, global_j, val);
}
}
}
}
}
}
coo.to_csr()
}
pub fn assemble_force_vector(n_nodes: usize, nodal_forces: &[(usize, Vec3)]) -> Vec<f64> {
let mut f = vec![0.0f64; n_nodes * 3];
for &(node, force) in nodal_forces {
f[node * 3] += force.x;
f[node * 3 + 1] += force.y;
f[node * 3 + 2] += force.z;
}
f
}
pub fn apply_dirichlet_bc(k: &mut CsrMatrix, f: &mut [f64], fixed_dofs: &[usize]) {
for &dof in fixed_dofs {
assert!(
dof < k.nrows,
"fixed DOF {dof} out of range ({} rows)",
k.nrows
);
let start = k.row_ptr[dof];
let end = k.row_ptr[dof + 1];
for idx in start..end {
if k.col_indices[idx] == dof {
k.values[idx] = 1.0;
} else {
k.values[idx] = 0.0;
}
}
for row in 0..k.nrows {
if row == dof {
continue;
}
let rs = k.row_ptr[row];
let re = k.row_ptr[row + 1];
for idx in rs..re {
if k.col_indices[idx] == dof {
k.values[idx] = 0.0;
}
}
}
f[dof] = 0.0;
}
}
pub fn conjugate_gradient(
k: &CsrMatrix,
f: &[f64],
tol: f64,
max_iter: usize,
) -> (Vec<f64>, usize, f64) {
let n = f.len();
assert_eq!(k.nrows, n, "K rows must equal f length");
assert_eq!(k.ncols, n, "K must be square");
let precond: Vec<f64> = (0..n)
.map(|i| {
let d = k.diagonal(i);
if d.abs() > 1e-30 { 1.0 / d } else { 1.0 }
})
.collect();
let mut x = vec![0.0f64; n];
let mut r: Vec<f64> = f.to_vec();
let mut z: Vec<f64> = r
.iter()
.zip(precond.iter())
.map(|(ri, mi)| ri * mi)
.collect();
let mut p = z.clone();
let mut rz = dot(&r, &z);
let mut iter_used = 0;
let mut res_norm = dot(&r, &r).sqrt();
if res_norm < tol {
return (x, 0, res_norm);
}
for iter in 0..max_iter {
let kp = k.mul_vec(&p);
let p_kp = dot(&p, &kp);
if p_kp.abs() < 1e-60 {
iter_used = iter;
break;
}
let alpha = rz / p_kp;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * kp[i];
}
res_norm = dot(&r, &r).sqrt();
iter_used = iter + 1;
if res_norm < tol {
break;
}
for i in 0..n {
z[i] = r[i] * precond[i];
}
let rz_new = dot(&r, &z);
let beta = rz_new / rz;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
rz = rz_new;
}
(x, iter_used, res_norm)
}
pub fn cholesky_solve(k: &[Vec<f64>], f: &[f64]) -> Vec<f64> {
let n = f.len();
assert_eq!(k.len(), n, "K must be n×n");
for row in k {
assert_eq!(row.len(), n, "K must be n×n");
}
let mut l = vec![vec![0.0f64; n]; n];
for i in 0..n {
for j in 0..=i {
let mut s: f64 = k[i][j];
for (li_p, lj_p) in l[i][..j].iter().zip(l[j][..j].iter()) {
s -= li_p * lj_p;
}
if i == j {
assert!(
s > 0.0,
"matrix is not positive definite at diagonal ({i},{i}): s = {s}"
);
l[i][j] = s.sqrt();
} else {
l[i][j] = s / l[j][j];
}
}
}
let mut y = vec![0.0f64; n];
for i in 0..n {
let mut s = f[i];
for j in 0..i {
s -= l[i][j] * y[j];
}
y[i] = s / l[i][i];
}
let mut u = vec![0.0f64; n];
for i in (0..n).rev() {
let mut s = y[i];
for j in (i + 1)..n {
s -= l[j][i] * u[j];
}
u[i] = s / l[i][i];
}
u
}
pub fn assemble_stiffness(mesh: &TetrahedralMesh, material: &LinearElasticMaterial) -> CsrMatrix {
let d_matrix = material.constitutive_matrix();
assemble_global_stiffness(&mesh.nodes, &mesh.elements, &d_matrix)
}
pub fn assemble_load_vector(mesh: &TetrahedralMesh, body_force: &Vec3) -> Vec<f64> {
let ndof = mesh.num_nodes() * 3;
let mut f = vec![0.0; ndof];
for elem in &mesh.elements {
let nodes = [
mesh.nodes[elem[0]],
mesh.nodes[elem[1]],
mesh.nodes[elem[2]],
mesh.nodes[elem[3]],
];
let vol = LinearTetrahedron::volume(&nodes);
let force_per_node = vol / 4.0;
for &node_id in elem {
f[node_id * 3] += body_force.x * force_per_node;
f[node_id * 3 + 1] += body_force.y * force_per_node;
f[node_id * 3 + 2] += body_force.z * force_per_node;
}
}
f
}
pub struct LinearElasticAssembler<'a> {
pub nodes: &'a [Vec3],
pub elements: &'a [[usize; 4]],
pub d_matrix: [[f64; 6]; 6],
}
impl<'a> LinearElasticAssembler<'a> {
pub fn new(nodes: &'a [Vec3], elements: &'a [[usize; 4]], d_matrix: [[f64; 6]; 6]) -> Self {
Self {
nodes,
elements,
d_matrix,
}
}
pub fn assemble_stiffness(&self) -> CsrMatrix {
assemble_global_stiffness(self.nodes, self.elements, &self.d_matrix)
}
pub fn assemble_nodal_forces(&self, nodal_forces: &[(usize, Vec3)]) -> Vec<f64> {
assemble_force_vector(self.nodes.len(), nodal_forces)
}
pub fn assemble_body_force(&self, body_force: [f64; 3]) -> Vec<f64> {
let ndof = self.nodes.len() * 3;
let mut f = vec![0.0f64; ndof];
for elem in self.elements {
let elem_nodes = [
self.nodes[elem[0]],
self.nodes[elem[1]],
self.nodes[elem[2]],
self.nodes[elem[3]],
];
let fe = LinearTetrahedron::load_vector(&elem_nodes, body_force);
for (local, &global) in elem.iter().enumerate() {
f[global * 3] += fe[local * 3];
f[global * 3 + 1] += fe[local * 3 + 1];
f[global * 3 + 2] += fe[local * 3 + 2];
}
}
f
}
pub fn apply_dirichlet(&self, k: &mut CsrMatrix, f: &mut [f64], fixed_dofs: &[usize]) {
apply_dirichlet_bc(k, f, fixed_dofs);
}
}
pub struct StaticSolver {
pub max_iter: usize,
pub tol: f64,
}
impl Default for StaticSolver {
fn default() -> Self {
Self {
max_iter: 10_000,
tol: 1e-10,
}
}
}
impl StaticSolver {
pub fn new(max_iter: usize, tol: f64) -> Self {
Self { max_iter, tol }
}
pub fn solve_cg(&self, k: &CsrMatrix, f: &[f64]) -> (Vec<f64>, usize, f64) {
conjugate_gradient(k, f, self.tol, self.max_iter)
}
pub fn solve_direct(&self, k_dense: &[Vec<f64>], f: &[f64]) -> Vec<f64> {
cholesky_solve(k_dense, f)
}
}
fn dot(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
pub fn build_dof_connectivity_graph(nodes: &[Vec3], elements: &[[usize; 4]]) -> CsrMatrix {
let n_nodes = nodes.len();
let ndof = n_nodes * 3;
let mut coo = CooMatrix::new(ndof, ndof);
for elem in elements {
for &ni in elem.iter() {
for &nj in elem.iter() {
for di in 0..3_usize {
for dj in 0..3_usize {
coo.add(ni * 3 + di, nj * 3 + dj, 1.0);
}
}
}
}
}
let raw = coo.to_csr();
let col_indices = raw.col_indices.clone();
let triplets: Vec<(usize, usize, f64)> = (0..raw.nrows)
.flat_map(|row| {
let start = raw.row_ptr[row];
let end = raw.row_ptr[row + 1];
(start..end)
.map(|idx| (row, col_indices[idx], 1.0))
.collect::<Vec<_>>()
})
.collect();
CsrMatrix::from_triplets(ndof, ndof, &triplets)
}
pub fn apply_dirichlet_bc_values(k: &mut CsrMatrix, f: &mut [f64], fixed_dofs: &[(usize, f64)]) {
for &(dof, val) in fixed_dofs {
assert!(dof < k.nrows, "fixed DOF {dof} out of range");
for (row, f_row) in f.iter_mut().enumerate().take(k.nrows) {
if row == dof {
continue;
}
let rs = k.row_ptr[row];
let re = k.row_ptr[row + 1];
for idx in rs..re {
if k.col_indices[idx] == dof {
*f_row -= k.values[idx] * val;
k.values[idx] = 0.0;
}
}
}
let start = k.row_ptr[dof];
let end = k.row_ptr[dof + 1];
for idx in start..end {
if k.col_indices[idx] == dof {
k.values[idx] = 1.0;
} else {
k.values[idx] = 0.0;
}
}
f[dof] = val;
}
}
pub fn assemble_body_force_distributed(
nodes: &[Vec3],
elements: &[[usize; 4]],
body_force_fn: &dyn Fn(Vec3) -> [f64; 3],
) -> Vec<f64> {
let n_nodes = nodes.len();
let ndof = n_nodes * 3;
let mut f = vec![0.0f64; ndof];
for elem in elements {
let elem_nodes = [
nodes[elem[0]],
nodes[elem[1]],
nodes[elem[2]],
nodes[elem[3]],
];
let vol = crate::element::LinearTetrahedron::volume(&elem_nodes);
let cx = (elem_nodes[0].x + elem_nodes[1].x + elem_nodes[2].x + elem_nodes[3].x) * 0.25;
let cy = (elem_nodes[0].y + elem_nodes[1].y + elem_nodes[2].y + elem_nodes[3].y) * 0.25;
let cz = (elem_nodes[0].z + elem_nodes[1].z + elem_nodes[2].z + elem_nodes[3].z) * 0.25;
let centroid = Vec3::new(cx, cy, cz);
let bf = body_force_fn(centroid);
let force_per_node = vol / 4.0;
for &node_id in elem.iter() {
f[node_id * 3] += bf[0] * force_per_node;
f[node_id * 3 + 1] += bf[1] * force_per_node;
f[node_id * 3 + 2] += bf[2] * force_per_node;
}
}
f
}
pub fn assemble_gravity_load(
nodes: &[Vec3],
elements: &[[usize; 4]],
density: f64,
gravity: [f64; 3],
) -> Vec<f64> {
let body_force = [
density * gravity[0],
density * gravity[1],
density * gravity[2],
];
assemble_body_force_distributed(nodes, elements, &|_centroid| body_force)
}
pub fn assemble_surface_traction(
n_nodes: usize,
faces: &[(usize, usize, usize, [f64; 3])],
) -> Vec<f64> {
let mut f = vec![0.0f64; n_nodes * 3];
for &(n0, n1, n2, traction) in faces {
let node_contrib = traction[0]
.abs()
.max(traction[1].abs())
.max(traction[2].abs());
let _ = node_contrib;
for &node in &[n0, n1, n2] {
f[node * 3] += traction[0] / 3.0;
f[node * 3 + 1] += traction[1] / 3.0;
f[node * 3 + 2] += traction[2] / 3.0;
}
}
f
}
pub fn assemble_surface_traction_with_area(
n_nodes: usize,
faces: &[(usize, usize, usize, f64, [f64; 3])],
) -> Vec<f64> {
let mut f = vec![0.0f64; n_nodes * 3];
for &(n0, n1, n2, area, traction) in faces {
let scale = area / 3.0;
for &node in &[n0, n1, n2] {
f[node * 3] += traction[0] * scale;
f[node * 3 + 1] += traction[1] * scale;
f[node * 3 + 2] += traction[2] * scale;
}
}
f
}
pub type StressTensor = [f64; 6];
pub fn recover_nodal_stresses_average(
n_nodes: usize,
elements: &[[usize; 4]],
element_stress: &[StressTensor],
) -> Vec<StressTensor> {
assert_eq!(
elements.len(),
element_stress.len(),
"must provide one stress per element"
);
let mut nodal_sum = vec![[0.0f64; 6]; n_nodes];
let mut count = vec![0u32; n_nodes];
for (elem, stress) in elements.iter().zip(element_stress.iter()) {
for &node in elem.iter() {
count[node] += 1;
for k in 0..6 {
nodal_sum[node][k] += stress[k];
}
}
}
let mut nodal_stress = vec![[0.0f64; 6]; n_nodes];
for node in 0..n_nodes {
if count[node] > 0 {
let c = count[node] as f64;
for k in 0..6 {
nodal_stress[node][k] = nodal_sum[node][k] / c;
}
}
}
nodal_stress
}
pub fn compute_element_stresses(
nodes: &[Vec3],
elements: &[[usize; 4]],
displacements: &[f64],
d_matrix: &[[f64; 6]; 6],
) -> Vec<StressTensor> {
elements
.iter()
.map(|elem| {
let elem_nodes = [
nodes[elem[0]],
nodes[elem[1]],
nodes[elem[2]],
nodes[elem[3]],
];
let mut u_e = [0.0f64; 12];
for (local, &global) in elem.iter().enumerate() {
u_e[local * 3] = displacements[global * 3];
u_e[local * 3 + 1] = displacements[global * 3 + 1];
u_e[local * 3 + 2] = displacements[global * 3 + 2];
}
let (b_flat, _vol) = crate::element::LinearTetrahedron::b_matrix(&elem_nodes);
let mut strain = [0.0f64; 6];
for i in 0..6 {
for j in 0..12 {
strain[i] += b_flat[i * 12 + j] * u_e[j];
}
}
let mut stress = [0.0f64; 6];
for i in 0..6 {
for j in 0..6 {
stress[i] += d_matrix[i][j] * strain[j];
}
}
stress
})
.collect()
}
pub fn von_mises_stress(sigma: &StressTensor) -> f64 {
let (sxx, syy, szz) = (sigma[0], sigma[1], sigma[2]);
let (sxy, syz, sxz) = (sigma[3], sigma[4], sigma[5]);
let vm_sq = 0.5
* ((sxx - syy).powi(2)
+ (syy - szz).powi(2)
+ (szz - sxx).powi(2)
+ 6.0 * (sxy.powi(2) + syz.powi(2) + sxz.powi(2)));
vm_sq.max(0.0).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesh::generate_box_mesh;
#[test]
fn test_coo_to_csr() {
let mut coo = CooMatrix::new(3, 3);
coo.add(0, 0, 1.0);
coo.add(1, 1, 2.0);
coo.add(2, 2, 3.0);
coo.add(0, 2, 4.0);
coo.add(0, 2, 1.0);
let csr = coo.to_csr();
assert_eq!(csr.nrows, 3);
assert_eq!(csr.ncols, 3);
assert!(
(csr.get(0, 0) - 1.0).abs() < 1e-12,
"K[0,0] = {}",
csr.get(0, 0)
);
assert!(
(csr.get(1, 1) - 2.0).abs() < 1e-12,
"K[1,1] = {}",
csr.get(1, 1)
);
assert!(
(csr.get(2, 2) - 3.0).abs() < 1e-12,
"K[2,2] = {}",
csr.get(2, 2)
);
assert!(
(csr.get(0, 2) - 5.0).abs() < 1e-12,
"K[0,2] = {}",
csr.get(0, 2)
);
assert_eq!(csr.get(0, 1), 0.0);
assert_eq!(csr.nnz(), 4, "nnz = {}", csr.nnz());
}
#[test]
fn test_csr_matvec() {
let mut coo = CooMatrix::new(3, 3);
coo.add(0, 0, 1.0);
coo.add(1, 1, 1.0);
coo.add(2, 2, 1.0);
let csr = coo.to_csr();
let x = [3.0, 7.0, -2.0];
let y = csr.mul_vec(&x);
assert!((y[0] - 3.0).abs() < 1e-12, "y[0] = {}", y[0]);
assert!((y[1] - 7.0).abs() < 1e-12, "y[1] = {}", y[1]);
assert!((y[2] - -2.0).abs() < 1e-12, "y[2] = {}", y[2]);
}
#[test]
fn test_assemble_single_tet() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
];
let elements = vec![[0usize, 1, 2, 3]];
let e = 200.0e9;
let nu = 0.3;
let d = crate::constitutive::LinearElasticMaterial::new(e, nu).constitutive_matrix();
let k = assemble_global_stiffness(&nodes, &elements, &d);
assert_eq!(k.nrows, 12);
assert_eq!(k.ncols, 12);
assert!(k.nnz() > 0);
for i in 0..12 {
for j in 0..12 {
let kij = k.get(i, j);
let kji = k.get(j, i);
let scale = kij.abs().max(kji.abs()).max(1.0);
assert!(
(kij - kji).abs() / scale < 1e-10,
"K not symmetric at ({i},{j}): {kij} vs {kji}"
);
}
}
for i in 0..12 {
assert!(k.get(i, i) >= -1e-10, "K[{i},{i}] = {} < 0", k.get(i, i));
}
}
#[test]
fn test_assemble_two_tets() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(1.0, 1.0, 1.0),
];
let elements = vec![[0, 1, 2, 3], [1, 2, 3, 4]];
let d = crate::constitutive::LinearElasticMaterial::new(200.0e9, 0.3).constitutive_matrix();
let k = assemble_global_stiffness(&nodes, &elements, &d);
assert_eq!(k.nrows, 15, "Expected 15×15 system");
assert_eq!(k.ncols, 15);
assert!(k.nnz() > 0);
}
#[test]
fn test_dirichlet_bc() {
let triplets: Vec<(usize, usize, f64)> = vec![
(0, 0, 10.0),
(0, 1, 2.0),
(0, 2, 3.0),
(1, 0, 2.0),
(1, 1, 20.0),
(1, 2, 4.0),
(2, 0, 3.0),
(2, 1, 4.0),
(2, 2, 30.0),
];
let mut k = CsrMatrix::from_triplets(3, 3, &triplets);
let mut f = vec![1.0, 2.0, 3.0];
apply_dirichlet_bc(&mut k, &mut f, &[0]);
assert!((k.get(0, 0) - 1.0).abs() < 1e-12, "K[0,0] should be 1");
assert!(k.get(0, 1).abs() < 1e-12, "K[0,1] should be 0");
assert!(k.get(0, 2).abs() < 1e-12, "K[0,2] should be 0");
assert!(k.get(1, 0).abs() < 1e-12, "K[1,0] should be 0");
assert!(k.get(2, 0).abs() < 1e-12, "K[2,0] should be 0");
assert!((f[0]).abs() < 1e-12, "f[0] should be 0");
}
#[test]
fn test_cg_identity_system() {
let triplets = vec![(0, 0, 1.0), (1, 1, 1.0), (2, 2, 1.0)];
let k = CsrMatrix::from_triplets(3, 3, &triplets);
let f = [1.0, 2.0, 3.0];
let (u, iters, res) = conjugate_gradient(&k, &f, 1e-12, 100);
assert!((u[0] - 1.0).abs() < 1e-10, "u[0] = {}", u[0]);
assert!((u[1] - 2.0).abs() < 1e-10, "u[1] = {}", u[1]);
assert!((u[2] - 3.0).abs() < 1e-10, "u[2] = {}", u[2]);
assert!(iters <= 3, "should converge in few iterations, got {iters}");
assert!(res < 1e-10, "residual = {res}");
}
#[test]
fn test_cholesky_2x2() {
let k = vec![vec![4.0, 2.0], vec![2.0, 3.0]];
let f = [4.0, 3.0];
let u = cholesky_solve(&k, &f);
assert!((u[0] - 0.75).abs() < 1e-12, "u[0] = {}", u[0]);
assert!((u[1] - 0.5).abs() < 1e-12, "u[1] = {}", u[1]);
}
#[test]
fn test_full_static_analysis() {
use crate::mesh::generate_bar_mesh;
let mesh = generate_bar_mesh(1.0, 2);
let d = crate::constitutive::LinearElasticMaterial::new(200.0e9, 0.3).constitutive_matrix();
let mut k = assemble_global_stiffness(&mesh.nodes, &mesh.elements, &d);
let tip_nodes: Vec<usize> = mesh
.nodes
.iter()
.enumerate()
.filter(|(_, n)| (n.x - 1.0).abs() < 1e-10)
.map(|(i, _)| i)
.collect();
let load_per_node = 1.0 / tip_nodes.len() as f64;
let nodal_forces: Vec<(usize, Vec3)> = tip_nodes
.iter()
.map(|&i| (i, Vec3::new(0.0, load_per_node, 0.0)))
.collect();
let mut f = assemble_force_vector(mesh.n_nodes(), &nodal_forces);
let fixed_dofs: Vec<usize> = mesh
.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.x.abs() < 1e-10)
.flat_map(|(i, _)| [i * 3, i * 3 + 1, i * 3 + 2])
.collect();
apply_dirichlet_bc(&mut k, &mut f, &fixed_dofs);
let (u, _iters, res) = conjugate_gradient(&k, &f, 1e-10, 10000);
assert!(res < 1e-8, "CG did not converge: residual = {res}");
let max_y = tip_nodes
.iter()
.map(|&i| u[i * 3 + 1].abs())
.fold(0.0_f64, f64::max);
assert!(
max_y > 1e-20,
"Expected non-zero tip displacement in y, got max_y = {max_y}"
);
}
#[test]
fn test_assembly_symmetric() {
let mesh = TetrahedralMesh::generate_beam(1.0, 0.1, 0.1, 2, 1, 1);
let material = LinearElasticMaterial::new(200.0e9, 0.3);
let k = assemble_stiffness(&mesh, &material);
let ndof = mesh.num_nodes() * 3;
for i in 0..ndof.min(30) {
for j in i..ndof.min(30) {
let kij = k.get(i, j);
let kji = k.get(j, i);
let scale = kij.abs().max(kji.abs()).max(1.0);
assert!(
(kij - kji).abs() / scale < 1e-10,
"K not symmetric at ({i},{j}): {kij} vs {kji}"
);
}
}
}
#[test]
fn test_global_assembly_3nodes() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
];
let elements = vec![[0usize, 1, 2, 3]];
let mesh = crate::mesh::TetrahedralMesh::from_nodes_and_elements(nodes, elements);
let material = crate::constitutive::LinearElasticMaterial::new(200.0e9, 0.3);
let k = assemble_stiffness(&mesh, &material);
assert_eq!(k.nrows, 12, "Expected 12 rows (3 * 4 nodes)");
assert_eq!(k.ncols, 12, "Expected 12 cols (3 * 4 nodes)");
assert!(k.nnz() > 0, "Stiffness matrix should have non-zero entries");
}
#[test]
fn test_load_vector_gravity() {
let mesh = TetrahedralMesh::generate_beam(1.0, 1.0, 1.0, 1, 1, 1);
let gravity = Vec3::new(0.0, -9.81, 0.0);
let f = assemble_load_vector(&mesh, &gravity);
let total_fy: f64 = f.iter().skip(1).step_by(3).sum();
assert!(
(total_fy - (-9.81)).abs() < 1e-6,
"Total Fy = {total_fy}, expected -9.81"
);
}
#[test]
fn test_linear_elastic_assembler_stiffness_matches_direct() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
];
let elements = vec![[0usize, 1, 2, 3]];
let d = LinearElasticMaterial::new(1.0e6, 0.25).constitutive_matrix();
let assembler = LinearElasticAssembler::new(&nodes, &elements, d);
let k1 = assembler.assemble_stiffness();
let k2 = assemble_global_stiffness(&nodes, &elements, &d);
for i in 0..12 {
for j in 0..12 {
let diff = (k1.get(i, j) - k2.get(i, j)).abs();
assert!(
diff < 1e-6,
"assembler K[{i},{j}] mismatch: {} vs {}",
k1.get(i, j),
k2.get(i, j)
);
}
}
}
#[test]
fn test_linear_elastic_assembler_body_force_sum() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
];
let elements = vec![[0usize, 1, 2, 3]];
let d = LinearElasticMaterial::new(1.0e6, 0.25).constitutive_matrix();
let assembler = LinearElasticAssembler::new(&nodes, &elements, d);
let f = assembler.assemble_body_force([0.0, -1.0, 0.0]);
let total_fy: f64 = f.iter().skip(1).step_by(3).sum();
let expected = -1.0 / 6.0;
assert!(
(total_fy - expected).abs() < 1e-12,
"total Fy = {total_fy}, expected {expected}"
);
}
#[test]
fn test_static_solver_cg_diagonal_system() {
let mut coo = CooMatrix::new(3, 3);
coo.add(0, 0, 2.0);
coo.add(1, 1, 3.0);
coo.add(2, 2, 4.0);
let k = coo.to_csr();
let f = [2.0, 6.0, 12.0];
let solver = StaticSolver::default();
let (u, _iters, res) = solver.solve_cg(&k, &f);
assert!(res < 1e-9, "residual = {res}");
assert!((u[0] - 1.0).abs() < 1e-9, "u[0] = {}", u[0]);
assert!((u[1] - 2.0).abs() < 1e-9, "u[1] = {}", u[1]);
assert!((u[2] - 3.0).abs() < 1e-9, "u[2] = {}", u[2]);
}
#[test]
fn test_static_solver_direct_2x2() {
let k_dense = vec![vec![4.0, 1.0], vec![1.0, 3.0]];
let f = [9.0, 7.0];
let solver = StaticSolver::default();
let u = solver.solve_direct(&k_dense, &f);
assert!((u[0] - 20.0 / 11.0).abs() < 1e-12, "u[0] = {}", u[0]);
assert!((u[1] - 19.0 / 11.0).abs() < 1e-12, "u[1] = {}", u[1]);
}
#[test]
fn test_patch_test_uniform_strain_multielem() {
use crate::mesh::generate_box_mesh;
let lx = 1.0_f64;
let ly = 1.0_f64;
let lz = 1.0_f64;
let nx = 2;
let ny = 2;
let nz = 2;
let e = 1.0e6_f64;
let nu = 0.3_f64;
let _eps = 0.001_f64;
let mesh = generate_box_mesh(lx, ly, lz, nx, ny, nz);
let mat = LinearElasticMaterial::new(e, nu);
let d = mat.constitutive_matrix();
let n_nodes = mesh.n_nodes();
let ndof = n_nodes * 3;
let mut k = assemble_global_stiffness(&mesh.nodes, &mesh.elements, &d);
let mut f = vec![0.0f64; ndof];
let fixed_dofs: Vec<usize> = (0..n_nodes)
.flat_map(|i| {
let n = mesh.nodes[i];
let on_boundary = n.x.abs() < 1e-10
|| (n.x - lx).abs() < 1e-10
|| n.y.abs() < 1e-10
|| (n.y - ly).abs() < 1e-10
|| n.z.abs() < 1e-10
|| (n.z - lz).abs() < 1e-10;
if on_boundary {
vec![i * 3, i * 3 + 1, i * 3 + 2]
} else {
vec![]
}
})
.collect();
apply_dirichlet_bc(&mut k, &mut f, &fixed_dofs);
for i in 0..n_nodes {
let dof_x = i * 3;
let _ = dof_x;
}
assert_eq!(k.nrows, ndof);
for i in 0..ndof.min(12) {
for j in i..ndof.min(12) {
let kij = k.get(i, j);
let kji = k.get(j, i);
let scale = kij.abs().max(kji.abs()).max(1.0);
assert!(
(kij - kji).abs() / scale < 1e-8,
"K not symmetric at ({i},{j}) after BC: {kij:.3e} vs {kji:.3e}"
);
}
}
}
#[test]
fn test_cantilever_euler_bernoulli() {
use crate::mesh::{cantilever_fixed_dofs, cantilever_tip_nodes, generate_cantilever_mesh};
let length = 1.0_f64;
let width = 0.2_f64;
let height = 0.2_f64;
let e = 200.0e9_f64;
let nu = 0.3_f64;
let p = -1000.0_f64;
let inertia = width * height.powi(3) / 12.0;
let delta_theory = p * length.powi(3) / (3.0 * e * inertia);
let mesh = generate_cantilever_mesh(length, width, height, 8, 2, 2);
let mat = LinearElasticMaterial::new(e, nu);
let d = mat.constitutive_matrix();
let mut k = assemble_global_stiffness(&mesh.nodes, &mesh.elements, &d);
let tip_nodes = cantilever_tip_nodes(&mesh, length, 1e-9);
let n_tip = tip_nodes.len() as f64;
let nodal_forces: Vec<(usize, Vec3)> = tip_nodes
.iter()
.map(|&i| (i, Vec3::new(0.0, p / n_tip, 0.0)))
.collect();
let mut f = assemble_force_vector(mesh.n_nodes(), &nodal_forces);
let fixed = cantilever_fixed_dofs(&mesh, 1e-9);
apply_dirichlet_bc(&mut k, &mut f, &fixed);
let solver = StaticSolver::new(50_000, 1e-12);
let (u, _iters, res) = solver.solve_cg(&k, &f);
assert!(res < 1e-7, "CG did not converge: residual = {res:.3e}");
let max_y = tip_nodes
.iter()
.map(|&i| u[i * 3 + 1])
.fold(f64::INFINITY, f64::min);
assert!(
max_y < 0.0,
"tip deflection should be negative (downward), got {max_y:.3e}"
);
let ratio = max_y / delta_theory;
assert!(
ratio > 0.0 && ratio < 3.0,
"FEM/EB ratio = {ratio:.3} (FEM = {max_y:.3e}, EB = {delta_theory:.3e})"
);
}
#[test]
fn test_dof_connectivity_graph_single_tet() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
];
let elements = vec![[0usize, 1, 2, 3]];
let graph = build_dof_connectivity_graph(&nodes, &elements);
assert_eq!(graph.nrows, 12);
assert_eq!(graph.ncols, 12);
for i in 0..12 {
for j in 0..12 {
assert_eq!(
graph.get(i, j),
1.0,
"graph[{i},{j}] = {}, expected 1.0",
graph.get(i, j)
);
}
}
}
#[test]
fn test_dof_connectivity_graph_two_tets_separate() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(5.0, 0.0, 0.0),
Vec3::new(6.0, 0.0, 0.0),
Vec3::new(5.0, 1.0, 0.0),
Vec3::new(5.0, 0.0, 1.0),
];
let elements = vec![[0, 1, 2, 3], [4, 5, 6, 7]];
let graph = build_dof_connectivity_graph(&nodes, &elements);
assert_eq!(graph.nrows, 24);
assert_eq!(
graph.get(0, 12),
0.0,
"separate tets should not be connected"
);
}
#[test]
fn test_apply_dirichlet_bc_values_nonzero() {
let triplets = vec![
(0, 0, 4.0),
(0, 1, 1.0),
(0, 2, 1.0),
(1, 0, 1.0),
(1, 1, 4.0),
(1, 2, 1.0),
(2, 0, 1.0),
(2, 1, 1.0),
(2, 2, 4.0),
];
let mut k = CsrMatrix::from_triplets(3, 3, &triplets);
let mut f = vec![0.0, 5.0, 5.0];
apply_dirichlet_bc_values(&mut k, &mut f, &[(0, 2.0)]);
assert!((k.get(0, 0) - 1.0).abs() < 1e-12);
assert!(k.get(0, 1).abs() < 1e-12);
assert!(k.get(0, 2).abs() < 1e-12);
assert!(k.get(1, 0).abs() < 1e-12);
assert!(k.get(2, 0).abs() < 1e-12);
assert!((f[0] - 2.0).abs() < 1e-12, "f[0] = {}", f[0]);
assert!((f[1] - 3.0).abs() < 1e-12, "f[1] = {}", f[1]);
assert!((f[2] - 3.0).abs() < 1e-12, "f[2] = {}", f[2]);
}
#[test]
fn test_apply_dirichlet_bc_values_zero_matches_apply_dirichlet_bc() {
let triplets = vec![(0, 0, 10.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 8.0)];
let mut k1 = CsrMatrix::from_triplets(2, 2, &triplets);
let mut f1 = vec![3.0, 4.0];
apply_dirichlet_bc(&mut k1, &mut f1, &[0]);
let mut k2 = CsrMatrix::from_triplets(2, 2, &triplets);
let mut f2 = vec![3.0, 4.0];
apply_dirichlet_bc_values(&mut k2, &mut f2, &[(0, 0.0)]);
for i in 0..2 {
for j in 0..2 {
assert!(
(k1.get(i, j) - k2.get(i, j)).abs() < 1e-12,
"K mismatch at ({i},{j})"
);
}
assert!((f1[i] - f2[i]).abs() < 1e-12, "f mismatch at {i}");
}
}
#[test]
fn test_assemble_body_force_distributed_constant() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
];
let elements = vec![[0usize, 1, 2, 3]];
let gravity = [0.0, -9.81, 0.0];
let f1 = assemble_body_force_distributed(&nodes, &elements, &|_| gravity);
let total_fy: f64 = f1.iter().skip(1).step_by(3).sum();
let expected_fy =
crate::element::LinearTetrahedron::volume(&[nodes[0], nodes[1], nodes[2], nodes[3]])
* gravity[1];
assert!(
(total_fy - expected_fy).abs() < 1e-12,
"total Fy = {total_fy}, expected {expected_fy}"
);
}
#[test]
fn test_assemble_gravity_load_total_force() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
];
let elements = vec![[0usize, 1, 2, 3]];
let density = 1000.0;
let gravity = [0.0, -9.81, 0.0];
let f = assemble_gravity_load(&nodes, &elements, density, gravity);
let total_fy: f64 = f.iter().skip(1).step_by(3).sum();
let vol = 1.0 / 6.0;
let expected = density * vol * gravity[1];
assert!(
(total_fy - expected).abs() < 1e-10,
"gravity total Fy = {total_fy}, expected {expected}"
);
}
#[test]
fn test_assemble_gravity_load_zero_on_x_z() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
];
let elements = vec![[0usize, 1, 2, 3]];
let f = assemble_gravity_load(&nodes, &elements, 1.0, [0.0, -9.81, 0.0]);
let total_fx: f64 = f.iter().step_by(3).sum();
let total_fz: f64 = f.iter().skip(2).step_by(3).sum();
assert!(
total_fx.abs() < 1e-12,
"total Fx should be 0, got {total_fx}"
);
assert!(
total_fz.abs() < 1e-12,
"total Fz should be 0, got {total_fz}"
);
}
#[test]
fn test_assemble_surface_traction_total_force() {
let traction = [100.0f64, 0.0, 0.0];
let faces = vec![(0usize, 1, 2, traction)];
let f = assemble_surface_traction(3, &faces);
let total_fx: f64 = f.iter().step_by(3).sum();
assert!((total_fx - 100.0).abs() < 1e-12, "total Fx = {total_fx}");
}
#[test]
fn test_assemble_surface_traction_with_area() {
let traction = [0.0f64, 200.0, 0.0];
let area = 0.5;
let faces = vec![(0usize, 1, 2, area, traction)];
let f = assemble_surface_traction_with_area(3, &faces);
let total_fy: f64 = f.iter().skip(1).step_by(3).sum();
assert!((total_fy - 100.0).abs() < 1e-12, "total Fy = {total_fy}");
}
#[test]
fn test_recover_nodal_stresses_average_single_element() {
let elements = vec![[0usize, 1, 2, 3]];
let stress = [[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]];
let nodal = recover_nodal_stresses_average(4, &elements, &stress);
for (node, nodal_row) in nodal.iter().enumerate() {
for (k, (&nv, &sv)) in nodal_row.iter().zip(stress[0].iter()).enumerate() {
assert!(
(nv - sv).abs() < 1e-12,
"node {node} component {k}: got {nv}, expected {sv}"
);
}
}
}
#[test]
fn test_recover_nodal_stresses_average_two_elements_shared_face() {
let elements = vec![[0usize, 1, 2, 3], [1, 2, 3, 4]];
let stress = [
[2.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[4.0, 0.0, 0.0, 0.0, 0.0, 0.0],
];
let nodal = recover_nodal_stresses_average(5, &elements, &stress);
assert!(
(nodal[0][0] - 2.0).abs() < 1e-12,
"node 0 σxx = {}",
nodal[0][0]
);
assert!(
(nodal[4][0] - 4.0).abs() < 1e-12,
"node 4 σxx = {}",
nodal[4][0]
);
assert!(
(nodal[1][0] - 3.0).abs() < 1e-12,
"node 1 σxx = {}",
nodal[1][0]
);
}
#[test]
fn test_compute_element_stresses_zero_displacement() {
let nodes = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
];
let elements = vec![[0usize, 1, 2, 3]];
let displacements = vec![0.0f64; 12];
let d = crate::constitutive::LinearElasticMaterial::new(200.0e9, 0.3).constitutive_matrix();
let stresses = compute_element_stresses(&nodes, &elements, &displacements, &d);
assert_eq!(stresses.len(), 1);
for (k, &sv) in stresses[0].iter().enumerate() {
assert!(
sv.abs() < 1e-20,
"zero displacement should give zero stress, got {sv} at component {k}"
);
}
}
#[test]
fn test_von_mises_stress_hydrostatic() {
let sigma = [100.0, 100.0, 100.0, 0.0, 0.0, 0.0];
let vm = von_mises_stress(&sigma);
assert!(
vm.abs() < 1e-10,
"hydrostatic stress should have vm=0, got {vm}"
);
}
#[test]
fn test_von_mises_stress_uniaxial() {
let s = 200.0e6;
let sigma = [s, 0.0, 0.0, 0.0, 0.0, 0.0];
let vm = von_mises_stress(&sigma);
assert!(
(vm - s).abs() < 1.0,
"uniaxial vm should be s={s}, got {vm}"
);
}
#[test]
fn test_symmetry_displacement_field() {
let mesh = generate_box_mesh(1.0, 1.0, 1.0, 2, 2, 2);
let mat = LinearElasticMaterial::new(1.0e6, 0.3);
let d = mat.constitutive_matrix();
let n_nodes = mesh.n_nodes();
let mut k = assemble_global_stiffness(&mesh.nodes, &mesh.elements, &d);
let mut f = vec![0.0f64; n_nodes * 3];
for i in 0..n_nodes {
let n = mesh.nodes[i];
if (n.z - 1.0).abs() < 1e-9 {
f[i * 3 + 1] += 1.0; }
}
let fixed: Vec<usize> = (0..n_nodes)
.filter(|&i| mesh.nodes[i].z.abs() < 1e-9)
.flat_map(|i| [i * 3, i * 3 + 1, i * 3 + 2])
.collect();
apply_dirichlet_bc(&mut k, &mut f, &fixed);
let solver = StaticSolver::new(10_000, 1e-10);
let (u, _iters, res) = solver.solve_cg(&k, &f);
assert!(res < 1e-8, "CG did not converge: residual = {res:.3e}");
for i in 0..n_nodes {
let n = mesh.nodes[i];
if (n.z - 1.0).abs() < 1e-9 {
assert!(
u[i * 3 + 1] > 0.0,
"Top node {i} should have positive y-displacement, got {}",
u[i * 3 + 1]
);
}
}
let mut col_nodes: Vec<(usize, f64)> = (0..n_nodes)
.filter(|&i| {
let n = mesh.nodes[i];
n.x.abs() < 1e-9 && n.y.abs() < 1e-9
})
.map(|i| (i, mesh.nodes[i].z))
.collect();
col_nodes.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
for w in col_nodes.windows(2) {
let (i0, _z0) = w[0];
let (i1, _z1) = w[1];
assert!(
u[i1 * 3 + 1] >= u[i0 * 3 + 1] - 1e-12,
"y-displacement not monotone: u[{i1}]={} < u[{i0}]={}",
u[i1 * 3 + 1],
u[i0 * 3 + 1]
);
}
}
}