Files
OneCAD-Simulation/crates/solver/src/sparse.rs
T
15a9ac9157 1D elements I: trusses (rod2, rod3), point masses, rotary inertia, springs and dashpots (#38)
* Model, fem and decks: point masses, rotary inertia, springs, dashpots and rod3 trusses

Model: PointMass and Spring definitions (spring | dashpot; to ground or
two-point; along a degree of freedom, a vector or the line of the ends) on a
set or reference point, resolved per node; ElementKind::Rod3. fem: rod2/rod3
truss kernel on Basis::Line2/Line3 declaring ElementDofs (translations).
Decks: Abaqus T3D3, MASS, ROTARYI, SPRING1/2/A, DASHPOT1/2/A; Nastran CONM1,
CELAS2, CDAMP2; LS-DYNA ELEMENT_MASS/INERTIA and ELEMENT_DISCRETE; what a
format cannot take is reported. Commands model.mass.* and model.spring.*.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Implicit solver: trusses, point masses, rotary inertia and springs on the DofLayout

Trusses declare their translations like solids; point masses, inertia and
springs ask the layout for the kinds they need at their nodes (translations,
rotations), and reference points they use get six degrees of freedom. The
matrix pattern takes rows for those points; dashpots are ignored with a
warning. Existing results are bit-identical (hash-checked).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Explicit solver: trusses, point masses, springs and dashpots on the node-major layout

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: fem.md on trusses and discrete elements in the layout; regenerate features.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: trusses in plane models and corotational explicit trusses

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 21:36:46 -04:00

283 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sparse symmetric matrices in compressed rows, both triangles stored.
//!
//! Assembly works on the node graph: a node's row holds every node it shares an element with,
//! and each node pair is a dense block of their degrees of freedom ([`DofLayout`]: a node's are
//! contiguous), so the pattern is built once from the element connectivity and the stiffness and
//! mass share it. Sums run in a fixed order (elements by
//! label, then the element's own node order), so assembled values are the same bit for bit on
//! every run and platform.
use modelcraft_fem::{DofLayout, DofSet};
use crate::{Error, Result};
/// A square sparse matrix in compressed rows with sorted column indices.
#[derive(Debug, Clone, PartialEq)]
pub struct Csr {
pub n: usize,
pub ptr: Vec<usize>,
pub idx: Vec<usize>,
pub val: Vec<f64>,
}
impl Csr {
/// The row's column indices and values.
pub fn row(&self, r: usize) -> (&[usize], &[f64]) {
let (a, b) = (self.ptr.get(r).copied().unwrap_or(0), self.ptr.get(r + 1).copied().unwrap_or(0));
(self.idx.get(a..b).unwrap_or_default(), self.val.get(a..b).unwrap_or_default())
}
/// y = A x.
pub fn mul(&self, x: &[f64], y: &mut [f64]) {
for (r, yr) in y.iter_mut().enumerate().take(self.n) {
let (cols, vals) = self.row(r);
*yr = cols.iter().zip(vals).map(|(c, v)| v * x.get(*c).copied().unwrap_or(0.0)).sum();
}
}
pub fn diagonal(&self) -> Vec<f64> {
(0..self.n)
.map(|r| {
let (cols, vals) = self.row(r);
cols.binary_search(&r).ok().and_then(|i| vals.get(i)).copied().unwrap_or(0.0)
})
.collect()
}
/// Make the matrix exactly symmetric by copying the lower triangle (what the factorization
/// reads) onto the upper one; the pattern must be structurally symmetric.
pub fn mirror_lower(&mut self) {
for r in 0..self.n {
let (start, end) = (self.ptr.get(r).copied().unwrap_or(0), self.ptr.get(r + 1).copied().unwrap_or(0));
for at in start..end {
let Some(&c) = self.idx.get(at) else { continue };
if c <= r {
continue;
}
let (cs, ce) = (self.ptr.get(c).copied().unwrap_or(0), self.ptr.get(c + 1).copied().unwrap_or(0));
let mirror = self.idx.get(cs..ce).and_then(|cols| cols.binary_search(&r).ok()).and_then(|i| self.val.get(cs + i)).copied();
if let (Some(v), Some(x)) = (mirror, self.val.get_mut(at)) {
*x = v;
}
}
}
}
/// `self + a·other` on the same pattern.
pub fn add_scaled(&self, a: f64, other: &Csr) -> Result<Csr> {
if self.ptr != other.ptr || self.idx != other.idx {
return Err(Error::Internal("matrices with different patterns".into()));
}
let val = self.val.iter().zip(&other.val).map(|(x, y)| x + a * y).collect();
Ok(Csr { n: self.n, ptr: self.ptr.clone(), idx: self.idx.clone(), val })
}
}
/// The node-block pattern of a mesh: for each node, the sorted nodes it shares an element with
/// (itself included).
#[derive(Debug, Clone)]
pub struct NodeGraph {
pub adjacency: Vec<Vec<usize>>,
}
impl NodeGraph {
pub fn new(nodes: usize, elements: impl Iterator<Item = impl AsRef<[usize]>>) -> NodeGraph {
let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); nodes];
for e in elements {
let e = e.as_ref();
for &a in e {
if let Some(row) = adjacency.get_mut(a) {
row.extend_from_slice(e);
}
}
}
for row in &mut adjacency {
row.sort_unstable();
row.dedup();
}
NodeGraph { adjacency }
}
/// Nonzeros of the dof-level matrix: a dense block per node pair.
pub fn entries(&self, layout: &DofLayout) -> usize {
self.adjacency.iter().enumerate().map(|(a, row)| layout.range(a).len() * row.iter().map(|b| layout.range(*b).len()).sum::<usize>()).sum()
}
/// A zero matrix over the degrees of freedom of the graph's layout slots (the mesh nodes
/// `0..nodes`, and reference points when the graph has rows for them) on this pattern.
pub fn zero_matrix(&self, layout: &DofLayout) -> Csr {
let n = self.adjacency.len().checked_sub(1).map_or(0, |last| layout.range(last).end);
let mut ptr = Vec::with_capacity(n + 1);
ptr.push(0);
let mut idx = Vec::with_capacity(self.entries(layout));
for (a, row) in self.adjacency.iter().enumerate() {
for _ in layout.range(a) {
for &b in row {
idx.extend(layout.range(b));
}
ptr.push(idx.len());
}
}
let val = vec![0.0; idx.len()];
Csr { n, ptr, idx, val }
}
}
/// Most kinds a node carries ([`modelcraft_fem::DofKind::ALL`]).
const MAX_KINDS: usize = 8;
/// Where node b's block lies in node a's rows of a matrix built by [`NodeGraph::zero_matrix`],
/// for the degrees of freedom `kinds` of both: the value index of (row r, column c) is
/// `rows[r] + cols[c]`. The rows of a node share one pattern, so this is one search per node
/// pair.
struct Block {
rows: [usize; MAX_KINDS],
cols: [usize; MAX_KINDS],
}
impl Block {
fn new(k: &Csr, layout: &DofLayout, a: usize, b: usize, kinds: DofSet) -> Result<Block> {
let first = layout.range(a).start;
let (start, end) = (k.ptr.get(first).copied().unwrap_or(0), k.ptr.get(first + 1).copied().unwrap_or(0));
let pattern = k.idx.get(start..end).ok_or_else(|| Error::Internal("pattern".into()))?;
let at = pattern.binary_search(&layout.range(b).start).map_err(|_| Error::Internal("entry outside the pattern".into()))?;
let missing = |kind| Error::Internal(format!("an element degree of freedom ({kind:?}) its node does not have"));
let mut block = Block { rows: [0; MAX_KINDS], cols: [0; MAX_KINDS] };
for ((row, col), kind) in block.rows.iter_mut().zip(&mut block.cols).zip(kinds.iter()) {
let r = layout.dof(a, kind).ok_or_else(|| missing(kind))?;
*row = k.ptr.get(r).copied().unwrap_or(0) + at;
*col = layout.kinds(b).position(kind).ok_or_else(|| missing(kind))?;
}
Ok(block)
}
}
fn add_at(k: &mut Csr, at: usize, v: f64) {
if let Some(x) = k.val.get_mut(at) {
*x += v;
}
}
/// Add an element matrix into a matrix built by [`NodeGraph::zero_matrix`]: row-major over the
/// element's degrees of freedom, node by node and `kinds` in order within a node; `nodes` are
/// the element's layout slots.
pub fn add_block(k: &mut Csr, layout: &DofLayout, nodes: &[usize], kinds: DofSet, data: &[f64]) -> Result<()> {
let per = kinds.len();
let width = per * nodes.len();
if data.len() != width * width {
return Err(Error::Internal("element matrix size".into()));
}
for (i, &a) in nodes.iter().enumerate() {
for (j, &b) in nodes.iter().enumerate() {
let block = Block::new(k, layout, a, b, kinds)?;
for r in 0..per {
for c in 0..per {
add_at(k, block.rows[r] + block.cols[c], data.get((per * i + r) * width + per * j + c).copied().unwrap_or(0.0));
}
}
}
}
Ok(())
}
/// Add an n × n element matrix to the block of each of `kinds` (a solid's consistent mass on its
/// translations).
pub fn add_scalar(k: &mut Csr, layout: &DofLayout, nodes: &[usize], kinds: DofSet, data: &[f64]) -> Result<()> {
let n = nodes.len();
if data.len() != n * n {
return Err(Error::Internal("element matrix size".into()));
}
for (i, &a) in nodes.iter().enumerate() {
for (j, &b) in nodes.iter().enumerate() {
let block = Block::new(k, layout, a, b, kinds)?;
let v = data.get(i * n + j).copied().unwrap_or(0.0);
for c in 0..kinds.len() {
add_at(k, block.rows[c] + block.cols[c], v);
}
}
}
Ok(())
}
/// Add a per-node diagonal to each of `kinds` (a solid's lumped mass on its translations).
pub fn add_diagonal(k: &mut Csr, layout: &DofLayout, nodes: &[usize], kinds: DofSet, diagonal: &[f64]) -> Result<()> {
for (&a, v) in nodes.iter().zip(diagonal) {
let block = Block::new(k, layout, a, a, kinds)?;
for c in 0..kinds.len() {
add_at(k, block.rows[c] + block.cols[c], *v);
}
}
Ok(())
}
/// Add `v` to entry (`i`, `j`), which must be in the pattern.
pub fn add_entry(k: &mut Csr, i: usize, j: usize, v: f64) -> Result<()> {
let (start, end) = (k.ptr.get(i).copied().unwrap_or(0), k.ptr.get(i + 1).copied().unwrap_or(0));
let at = k.idx.get(start..end).and_then(|cols| cols.binary_search(&j).ok()).ok_or_else(|| Error::Internal("entry outside the pattern".into()))?;
add_at(k, start + at, v);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn assemble_mirror_multiply() {
// Two "elements" sharing node 1.
let g = NodeGraph::new(3, [vec![0usize, 1], vec![1, 2]].into_iter());
assert_eq!(g.adjacency, vec![vec![0, 1], vec![0, 1, 2], vec![1, 2]]);
let layout = DofLayout::uniform(0..3, DofSet::TRANSLATIONS);
let mut k = g.zero_matrix(&layout);
let mut e = vec![0.0; 36];
for i in 0..6 {
e[i * 6 + i] = 2.0;
e[i * 6 + (i + 3) % 6] = -1.0;
}
add_block(&mut k, &layout, &[0, 1], DofSet::TRANSLATIONS, &e).unwrap();
add_block(&mut k, &layout, &[1, 2], DofSet::TRANSLATIONS, &e).unwrap();
assert_eq!(k.diagonal(), vec![2.0, 2.0, 2.0, 4.0, 4.0, 4.0, 2.0, 2.0, 2.0]);
let x = vec![1.0; 9];
let mut y = vec![0.0; 9];
k.mul(&x, &mut y);
assert_eq!(y, vec![1.0; 3].into_iter().chain([2.0; 3]).chain([1.0; 3]).collect::<Vec<_>>());
// Mirroring keeps a symmetric matrix as it is.
let mut mirrored = k.clone();
mirrored.mirror_lower();
assert_eq!(mirrored, k);
assert!(add_block(&mut k, &layout, &[0, 2], DofSet::TRANSLATIONS, &e).is_err(), "nodes 0 and 2 share no element");
// Scalar (per component) and diagonal additions land on each component.
let mut m = g.zero_matrix(&layout);
add_scalar(&mut m, &layout, &[0, 1], DofSet::TRANSLATIONS, &[2.0, 1.0, 1.0, 2.0]).unwrap();
add_diagonal(&mut m, &layout, &[2], DofSet::TRANSLATIONS, &[5.0]).unwrap();
assert_eq!(m.diagonal(), vec![2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 5.0, 5.0, 5.0]);
assert_eq!(m.row(0).1, &[2.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
let s = k.add_scaled(-1.0, &k).unwrap();
assert!(s.val.iter().all(|v| *v == 0.0));
}
#[test]
fn mixed_kinds_per_node_assemble_into_their_blocks() {
// A solid on nodes 0 and 1 (translations) and a beam-like element on 1 and 2 (six each):
// node 1 carries six, and the solid's block lands on its translations.
let mut b = DofLayout::builder();
b.node(10, DofSet::TRANSLATIONS).node(11, DofSet::STRUCTURAL).node(12, DofSet::STRUCTURAL);
let layout = b.build();
let g = NodeGraph::new(3, [vec![0usize, 1], vec![1, 2]].into_iter());
let mut k = g.zero_matrix(&layout);
assert_eq!((k.n, k.idx.len(), g.entries(&layout)), (15, 3 * 9 + 6 * 15 + 6 * 12, 189));
let solid: Vec<f64> = (0..36).map(|i| i as f64).collect();
add_block(&mut k, &layout, &[0, 1], DofSet::TRANSLATIONS, &solid).unwrap();
let beam = vec![1.0; 144];
add_block(&mut k, &layout, &[1, 2], DofSet::STRUCTURAL, &beam).unwrap();
// Row Uy of node 1 (global 4): the solid's row 4 on node 0's translations and node 1's.
let (cols, vals) = k.row(4);
let at = |c: usize| vals[cols.binary_search(&c).unwrap()];
assert_eq!([at(0), at(1), at(2)], [24.0, 25.0, 26.0]);
assert_eq!([at(3), at(4), at(5)], [27.0 + 1.0, 28.0 + 1.0, 29.0 + 1.0]);
assert_eq!(at(6), 1.0, "rotation Rx of node 1 from the beam only");
// A translations element cannot claim rotations its node lacks.
assert!(add_block(&mut k, &layout, &[0, 1], DofSet::STRUCTURAL, &beam).is_err());
}
}