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:
authored and GitHub committed 2026-10-13 17:44:21 -04:00
1 parent 81fa8d7220
commit 40f98f0af9
33 files changed
+4559 -53

No files matched your search

+431
View File
@@ -0,0 +1,431 @@
//! Linear static and frequency analysis of a model's step, and their results.
use modelcraft_fem::element::{Extrapolation, extrapolation};
use modelcraft_fem::quadrature::Rule;
use modelcraft_fem::{PointStress, von_mises};
use modelcraft_model::{ElementKind, Model, Procedure, Resolved, Step};
use serde::Serialize;
use crate::cholesky::{Factor, FactorError, UpperColumns};
use crate::eigen::{Operator, Wanted, lanczos};
use crate::ordering::nested_dissection;
use crate::sparse::{Csr, NodeGraph, add_block, add_diagonal, add_scalar};
use crate::system::{Mesh, equations, loads, prescribed};
use crate::{Error, Limits, Result};
/// Size and effort of a solution.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct Stats {
/// Degrees of freedom of the mesh (3 per node).
pub dofs: usize,
/// Free degrees of freedom solved for.
pub equations: usize,
/// Entries in the Cholesky factor.
pub factor_entries: usize,
/// Lanczos vectors used (frequency analysis).
pub lanczos_vectors: usize,
}
/// Stresses of one element.
#[derive(Debug, Clone, PartialEq)]
pub struct ElementStress {
pub label: u32,
pub kind: ElementKind,
/// At the integration points.
pub points: Vec<PointStress>,
/// Extrapolated to the element's nodes, in its node order.
pub nodal: Vec<[f64; 6]>,
}
/// The result of a static step.
#[derive(Debug, Clone, PartialEq)]
pub struct StaticResult {
pub step: String,
/// Node labels with degrees of freedom, sorted; the per-node vectors follow this order.
pub nodes: Vec<u32>,
pub displacement: Vec<[f64; 3]>,
/// Reaction forces (zero where nothing is prescribed).
pub reaction: Vec<[f64; 3]>,
/// Stress averaged at the nodes over the elements that share them (xx, yy, zz, xy, xz, yz).
pub stress: Vec<[f64; 6]>,
/// Von Mises stress of the averaged nodal stress.
pub von_mises: Vec<f64>,
pub elements: Vec<ElementStress>,
/// Sum of the applied loads and of the reactions.
pub applied_total: [f64; 3],
pub reaction_total: [f64; 3],
pub stats: Stats,
pub warnings: Vec<String>,
}
/// One natural mode.
#[derive(Debug, Clone, PartialEq)]
pub struct Mode {
/// λ = ω², in (rad per time unit)².
pub eigenvalue: f64,
/// Cycles per time unit (0 for a rigid-body mode).
pub frequency: f64,
/// Mass-normalized (φᵀ M φ = 1), per node in [`FrequencyResult::nodes`] order.
pub shape: Vec<[f64; 3]>,
/// Participation factors Γ = φᵀ M r for unit translations along X, Y, Z.
pub participation: [f64; 3],
/// Effective mass Γ² along X, Y, Z.
pub effective_mass: [f64; 3],
}
/// The result of a frequency step.
#[derive(Debug, Clone, PartialEq)]
pub struct FrequencyResult {
pub step: String,
pub nodes: Vec<u32>,
pub modes: Vec<Mode>,
/// Mass of the model.
pub total_mass: f64,
pub stats: Stats,
pub warnings: Vec<String>,
}
/// Mass matrix choice for a frequency analysis.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MassKind {
#[default]
Consistent,
Lumped,
}
/// Options of a frequency analysis.
#[derive(Debug, Clone, Copy, Default)]
pub struct FrequencyOptions {
pub mass: MassKind,
/// The spectral shift σ (eigenvalue units); by default 0 for a supported structure and a
/// small negative value for a free one.
pub shift: Option<f64>,
}
/// The step named `name`, or the only step of the procedure when no name is given; it must be
/// of that procedure.
fn select_step<'m>(model: &'m Model, name: Option<&str>, wanted: fn(&Procedure) -> bool, what: &str) -> Result<&'m Step> {
let step = match name {
Some(n) => model.step(n).ok_or_else(|| Error::Invalid(format!("step `{n}` does not exist")))?,
None => {
let matching: Vec<&Step> = model.steps().iter().filter(|s| wanted(&s.procedure)).collect();
match matching.as_slice() {
[one] => *one,
[] => return Err(Error::Invalid(format!("the model has no {what} step"))),
many => {
let names: Vec<&str> = many.iter().map(|s| s.name.as_str()).collect();
return Err(Error::Invalid(format!("name the step: the model has {what} steps {}", names.join(", "))));
}
}
}
};
if !wanted(&step.procedure) {
return Err(Error::Invalid(format!("step `{}` is a {} step, not a {what} one", step.name, step.procedure.name())));
}
Ok(step)
}
/// The assembled matrices and the model's mass.
struct Assembled {
k: Csr,
m: Option<Csr>,
}
/// Assemble the stiffness (and the mass when `mass` is set) on the mesh's node graph.
fn assemble(mesh: &Mesh, graph: &NodeGraph, mass: Option<MassKind>) -> Result<Assembled> {
let mut k = graph.zero_matrix();
let mut m = mass.map(|_| graph.zero_matrix());
for e in &mesh.elements {
let solid = e.solid()?;
let at = |err| Error::element(e.label, err);
add_block(&mut k, &e.nodes, &solid.stiffness(&e.d).map_err(at)?.data)?;
if let (Some(mm), Some(kind)) = (m.as_mut(), mass) {
let rho = e.density.ok_or_else(|| Error::Invalid(format!("element {} has no density", e.label)))?;
match kind {
MassKind::Consistent => add_scalar(mm, &e.nodes, &solid.mass(rho).map_err(at)?.data)?,
MassKind::Lumped => add_diagonal(mm, &e.nodes, &solid.lumped_mass(rho).map_err(at)?)?,
}
}
}
Ok(Assembled { k, m })
}
/// Factor a free-dof matrix; a singular pivot is reported as the free-dof column it hit.
fn factor_raw(a: &Csr, limits: &Limits) -> std::result::Result<Factor, FactorError> {
Factor::new(&UpperColumns::from_symmetric(a), limits.max_factor_entries)
}
/// A factorization failure in the user's terms: the node and direction of a singular pivot.
fn factor_error(e: FactorError, mesh: &Mesh, map: &[Option<usize>]) -> Error {
match e {
FactorError::TooLarge { entries, limit } => {
Error::Limit(format!("the factorization needs {entries} entries; the limit is {limit} (refine less or raise the limit)"))
}
FactorError::NotPositiveDefinite { column } => {
let dof = map.iter().position(|m| *m == Some(column)).unwrap_or(0);
let label = mesh.labels.get(dof / 3).copied().unwrap_or(0);
let axis = ["X", "Y", "Z"].get(dof % 3).copied().unwrap_or("?");
Error::Singular(format!(
"the stiffness is singular at node {label} in {axis}: the model can move without straining (missing or insufficient boundary conditions, or a part not connected to the rest)"
))
}
}
}
/// Spread a free-equation vector over every degree of freedom (`base` holds the prescribed
/// values) and group it per node.
fn per_node(x: &[f64], map: &[Option<usize>], base: Vec<f64>) -> Vec<[f64; 3]> {
let mut full = base;
for (dof, m) in map.iter().enumerate() {
if let (Some(eq), Some(v)) = (m, full.get_mut(dof)) {
*v = x.get(*eq).copied().unwrap_or(0.0);
}
}
full.as_chunks::<3>().0.to_vec()
}
/// Solve a linear static step: the step named `step`, or the model's only static step.
pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Result<StaticResult> {
let resolved = model.resolve()?;
let step = select_step(model, step, |p| matches!(p, Procedure::Static {}), "static")?;
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
let fixed = prescribed(model, &resolved, &mesh, step, true)?;
let f = loads(model, &resolved, &mesh, step)?;
let graph = NodeGraph::new(mesh.labels.len(), mesh.elements.iter().map(|e| e.nodes.as_slice()));
let k = assemble(&mesh, &graph, None)?.k;
let (map, neq) = equations(&nested_dissection(&mesh.xyz, &graph.adjacency), &fixed);
if neq == 0 {
return Err(Error::Invalid("every degree of freedom is prescribed; there is nothing to solve".into()));
}
let chol = factor_raw(&k.restrict(&map, neq), limits).map_err(|e| factor_error(e, &mesh, &map))?;
// Right-hand side: f_free − K_fp u_p (K u_p only when something moves).
let up: Vec<f64> = fixed.iter().map(|v| v.unwrap_or(0.0)).collect();
let mut kup = vec![0.0; mesh.dofs()];
if up.iter().any(|v| *v != 0.0) {
k.mul(&up, &mut kup);
}
let mut rhs = vec![0.0; neq];
for (dof, m) in map.iter().enumerate() {
if let (Some(eq), Some(fv), Some(kv)) = (m, f.get(dof), kup.get(dof))
&& let Some(r) = rhs.get_mut(*eq)
{
*r = fv - kv;
}
}
chol.solve(&mut rhs)?;
let displacement = per_node(&rhs, &map, up);
if displacement.iter().flatten().any(|x| !x.is_finite()) {
return Err(Error::Singular("the solution is not finite; the model is ill-conditioned".into()));
}
// Reactions R = K u − f at the prescribed degrees of freedom (only their rows of K).
let mut reaction = vec![[0.0; 3]; mesh.labels.len()];
let (mut applied_total, mut reaction_total) = ([0.0; 3], [0.0; 3]);
for (dof, fv) in f.iter().enumerate() {
let (node, c) = (dof / 3, dof % 3);
applied_total[c] += fv;
if fixed.get(dof).is_some_and(Option::is_some) {
let (cols, vals) = k.row(dof);
let ku: f64 = cols.iter().zip(vals).map(|(j, v)| v * displacement.get(j / 3).map_or(0.0, |u| u[j % 3])).sum();
if let Some(rn) = reaction.get_mut(node) {
rn[c] = ku - fv;
}
reaction_total[c] += ku - fv;
}
}
// Stresses: one extrapolation per element type and rule.
let mut extrapolations: Vec<(ElementKind, Rule, Extrapolation)> = Vec::new();
let mut elements = Vec::with_capacity(mesh.elements.len());
let mut sum = vec![[0.0; 6]; mesh.labels.len()];
let mut count = vec![0u32; mesh.labels.len()];
for e in &mesh.elements {
let solid = e.solid()?;
let at = |err| Error::element(e.label, err);
let ue: Vec<[f64; 3]> = e.nodes.iter().map(|n| displacement.get(*n).copied().unwrap_or_default()).collect();
let points = solid.stresses(&e.d, &ue).map_err(at)?;
let values: Vec<[f64; 6]> = points.iter().map(|p| p.stress).collect();
let rule = solid.rule();
let cached = extrapolations.iter().position(|(k, r, _)| *k == e.kind && *r == rule);
let index = match cached {
Some(i) => i,
None => {
extrapolations.push((e.kind, rule, extrapolation(solid.ty, rule).map_err(at)?));
extrapolations.len() - 1
}
};
let nodal = extrapolations.get(index).map(|(_, _, x)| x.apply(&values)).unwrap_or_default();
for (n, s) in e.nodes.iter().zip(&nodal) {
if let (Some(acc), Some(c)) = (sum.get_mut(*n), count.get_mut(*n)) {
for k in 0..6 {
acc[k] += s[k];
}
*c += 1;
}
}
elements.push(ElementStress { label: e.label, kind: e.kind, points, nodal });
}
let stress: Vec<[f64; 6]> = sum.iter().zip(&count).map(|(s, c)| s.map(|v| if *c > 0 { v / f64::from(*c) } else { 0.0 })).collect();
let von_mises = stress.iter().map(von_mises).collect();
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
let scale = applied_total.iter().chain(&reaction_total).fold(0.0f64, |m, v| m.max(v.abs()));
let imbalance = (0..3).map(|c| (applied_total[c] + reaction_total[c]).abs()).fold(0.0, f64::max);
if scale > 0.0 && imbalance > 1e-6 * scale {
warnings.push(format!("loads and reactions differ by {imbalance:.3e} (relative {:.1e})", imbalance / scale));
}
Ok(StaticResult {
step: step.name.clone(),
nodes: mesh.labels.clone(),
displacement,
reaction,
stress,
von_mises,
elements,
applied_total,
reaction_total,
stats: Stats { dofs: mesh.dofs(), equations: neq, factor_entries: chol.entries(), lanczos_vectors: 0 },
warnings,
})
}
struct ShiftInvert<'a> {
m: &'a Csr,
factor: &'a Factor,
}
impl Operator for ShiftInvert<'_> {
fn n(&self) -> usize {
self.m.n
}
fn mass(&self, x: &[f64], y: &mut [f64]) {
self.m.mul(x, y);
}
fn solve(&self, x: &mut [f64]) -> Result<()> {
self.factor.solve(x)
}
}
/// Block size of the Lanczos iteration: enough for the six rigid-body modes of a free body.
const BLOCK: usize = 6;
/// Relative residual of an accepted Ritz pair.
const TOLERANCE: f64 = 1e-9;
/// Solve a frequency step (the step named `step`, or the model's only frequency step): the
/// lowest natural modes within the step's frequency range.
pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOptions, limits: &Limits) -> Result<FrequencyResult> {
let resolved: Resolved = model.resolve()?;
let step = select_step(model, step, |p| matches!(p, Procedure::Frequency { .. }), "frequency")?;
let Procedure::Frequency { modes, min_hz, max_hz } = step.procedure else {
return Err(Error::Internal("frequency step".into()));
};
if let Some(s) = options.shift
&& !s.is_finite()
{
return Err(Error::Invalid(format!("the shift must be a finite number, got {s}")));
}
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
let fixed = prescribed(model, &resolved, &mesh, step, false)?;
let graph = NodeGraph::new(mesh.labels.len(), mesh.elements.iter().map(|e| e.nodes.as_slice()));
let Assembled { k, m } = assemble(&mesh, &graph, Some(options.mass))?;
let m = m.ok_or_else(|| Error::Internal("mass matrix".into()))?;
// Every element's mass entries sum to its mass, once per direction.
let total_mass = m.val.iter().sum::<f64>() / 3.0;
let (map, neq) = equations(&nested_dissection(&mesh.xyz, &graph.adjacency), &fixed);
if neq == 0 {
return Err(Error::Invalid("every degree of freedom is held; there is nothing to vibrate".into()));
}
let kff = k.restrict(&map, neq);
let mff = m.restrict(&map, neq);
drop((k, m));
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
let shifted = |s: f64| -> Result<Factor> { factor_raw(&kff.add_scaled(-s, &mff)?, limits).map_err(|e| factor_error(e, &mesh, &map)) };
let (shift, chol) = match options.shift {
Some(s) => (s, shifted(s)?),
None => match factor_raw(&kff, limits) {
Ok(f) => (0.0, f),
Err(FactorError::NotPositiveDefinite { .. }) => {
// A free (or partly free) body: shift below zero so the rigid-body modes come out.
let (tk, tm): (f64, f64) = (kff.diagonal().iter().sum(), mff.diagonal().iter().sum());
if !(tk > 0.0 && tm > 0.0) {
return Err(Error::Singular("the stiffness or mass has no positive diagonal".into()));
}
let s = -1e-6 * tk / tm;
warnings.push("the structure is not fully supported; rigid-body modes appear at (near) zero frequency".into());
(s, shifted(s)?)
}
Err(e) => return Err(factor_error(e, &mesh, &map)),
},
};
drop(kff);
let two_pi = 2.0 * std::f64::consts::PI;
let wanted = Wanted {
count: usize::try_from(modes).unwrap_or(usize::MAX).min(neq),
min: min_hz.map_or(f64::NEG_INFINITY, |f| (two_pi * f).powi(2)),
max: max_hz.map_or(f64::INFINITY, |f| (two_pi * f).powi(2)),
};
// The basis (vectors and their mass products) costs 2 n × vectors; cap it with the factor's
// entry limit.
let by_memory = limits.max_factor_entries / (2 * neq).max(1);
let max_basis = limits.max_lanczos_vectors.min(by_memory).max(BLOCK.min(neq));
let op = ShiftInvert { m: &mff, factor: &chol };
let pairs = lanczos(&op, shift, wanted, BLOCK, max_basis, TOLERANCE)?;
// Participation: Γ_d = φᵀ M r_d with r_d the unit translation along d on the free dofs.
let mr: Vec<Vec<f64>> = (0..3)
.map(|d| {
let mut r = vec![0.0; neq];
for (dof, mm) in map.iter().enumerate() {
if let Some(x) = mm.filter(|_| dof % 3 == d).and_then(|eq| r.get_mut(eq)) {
*x = 1.0;
}
}
let mut y = vec![0.0; neq];
mff.mul(&r, &mut y);
y
})
.collect();
let mut out = Vec::with_capacity(pairs.values.len());
for (lambda, phi) in pairs.values.iter().zip(&pairs.vectors) {
let participation = [0, 1, 2].map(|d| mr.get(d).map_or(0.0, |y| y.iter().zip(phi).map(|(a, b)| a * b).sum()));
out.push(Mode {
eigenvalue: *lambda,
frequency: lambda.max(0.0).sqrt() / two_pi,
shape: per_node(phi, &map, vec![0.0; mesh.dofs()]),
participation,
effective_mass: participation.map(|g| g * g),
});
}
if out.len() < wanted.count && max_hz.is_none() {
warnings.push(format!("found {} of the {modes} modes asked for", out.len()));
}
Ok(FrequencyResult {
step: step.name.clone(),
nodes: mesh.labels.clone(),
modes: out,
total_mass,
stats: Stats { dofs: mesh.dofs(), equations: neq, factor_entries: chol.entries(), lanczos_vectors: pairs.basis },
warnings,
})
}
/// The result of one step, as a session keeps it.
#[derive(Debug, Clone, PartialEq)]
pub enum StepResult {
Static(StaticResult),
Frequency(FrequencyResult),
}
impl StepResult {
pub fn step(&self) -> &str {
match self {
StepResult::Static(r) => &r.step,
StepResult::Frequency(r) => &r.step,
}
}
pub fn nodes(&self) -> &[u32] {
match self {
StepResult::Static(r) => &r.nodes,
StepResult::Frequency(r) => &r.nodes,
}
}
}
+326
View File
@@ -0,0 +1,326 @@
//! Sparse Cholesky factorization A = L Lᵀ of a symmetric positive definite matrix.
//!
//! The up-looking method of T. A. Davis, *Direct Methods for Sparse Linear Systems*, SIAM 2006,
//! §4.1–4.8: the elimination tree (J. W. H. Liu, *The role of elimination trees in sparse
//! factorization*, SIAM J. Matrix Anal. Appl. 11(1):134–172, 1990) gives the pattern of each row
//! of L as a reach in the tree, the symbolic pass counts every column before any value is
//! computed (so the factor's size is checked against a cap before memory is committed), and each
//! row of L is a sparse triangular solve. Operations run in a fixed order, with no threads and no
//! data-dependent reduction order, so the factor is the same bit for bit on every platform.
//!
//! A pivot at or below `PIVOT_TOLERANCE` times the matrix's own diagonal entry means the matrix
//! is singular to working precision (an unconstrained rigid-body motion, a disconnected part, a
//! zero-stiffness direction); the factorization stops and names the column.
use crate::{Error, Result};
/// Relative pivot size below which the matrix counts as singular.
pub const PIVOT_TOLERANCE: f64 = 1e-10;
/// The upper triangle of a symmetric matrix by columns: column k holds A(i, k) for i ≤ k, rows
/// sorted.
#[derive(Debug, Clone)]
pub struct UpperColumns {
pub n: usize,
pub ptr: Vec<usize>,
pub idx: Vec<usize>,
pub val: Vec<f64>,
}
impl UpperColumns {
/// From a symmetric matrix stored by full rows (row k's entries with column ≤ k are column k
/// of the upper triangle).
pub fn from_symmetric(a: &crate::sparse::Csr) -> UpperColumns {
let mut ptr = Vec::with_capacity(a.n + 1);
ptr.push(0);
let mut idx = Vec::new();
let mut val = Vec::new();
for k in 0..a.n {
let (cols, vals) = a.row(k);
for (c, v) in cols.iter().zip(vals) {
if *c <= k {
idx.push(*c);
val.push(*v);
}
}
ptr.push(idx.len());
}
UpperColumns { n: a.n, ptr, idx, val }
}
fn column(&self, k: usize) -> (&[usize], &[f64]) {
let (a, b) = (self.ptr.get(k).copied().unwrap_or(0), self.ptr.get(k + 1).copied().unwrap_or(0));
(self.idx.get(a..b).unwrap_or_default(), self.val.get(a..b).unwrap_or_default())
}
}
const NONE: usize = usize::MAX;
/// The elimination tree: parent of each column, `NONE` at roots.
fn etree(a: &UpperColumns) -> Vec<usize> {
let mut parent = vec![NONE; a.n];
let mut ancestor = vec![NONE; a.n];
for k in 0..a.n {
let (rows, _) = a.column(k);
for &i0 in rows {
let mut i = i0;
// Walk from i up to the root of its current subtree, compressing the path to k.
while i != NONE && i < k {
let next = ancestor.get(i).copied().unwrap_or(NONE);
if let Some(x) = ancestor.get_mut(i) {
*x = k;
}
if next == NONE
&& let Some(p) = parent.get_mut(i)
{
*p = k;
}
i = next;
}
}
}
parent
}
/// The pattern of row k of L (excluding the diagonal): the reach of column k's entries in the
/// elimination tree. Fills `stack[top..]` in topological order and returns `top`; `mark` holds
/// `k` for visited columns.
fn ereach(a: &UpperColumns, k: usize, parent: &[usize], stack: &mut [usize], mark: &mut [usize], path: &mut Vec<usize>) -> usize {
let n = a.n;
let mut top = n;
if let Some(m) = mark.get_mut(k) {
*m = k;
}
let (rows, _) = a.column(k);
for &i0 in rows {
let mut i = i0;
if i > k {
continue;
}
path.clear();
while i != NONE && mark.get(i).copied() != Some(k) {
path.push(i);
if let Some(m) = mark.get_mut(i) {
*m = k;
}
i = parent.get(i).copied().unwrap_or(NONE);
}
while let Some(v) = path.pop() {
top -= 1;
if let Some(s) = stack.get_mut(top) {
*s = v;
}
}
}
top
}
/// A Cholesky factor, L by columns with the diagonal first in each column.
#[derive(Debug, Clone)]
pub struct Factor {
pub n: usize,
ptr: Vec<usize>,
idx: Vec<usize>,
val: Vec<f64>,
}
/// Why a factorization stopped.
#[derive(Debug, Clone, PartialEq)]
pub enum FactorError {
/// The pivot of this column is not positive (relative to the diagonal).
NotPositiveDefinite { column: usize },
/// The factor would hold more entries than allowed.
TooLarge { entries: usize, limit: usize },
}
impl Factor {
/// The elimination tree and the entry count of each column of L, without computing values.
fn symbolic(a: &UpperColumns) -> (Vec<usize>, Vec<usize>) {
let parent = etree(a);
let mut counts = vec![1usize; a.n];
let mut stack = vec![0; a.n];
let mut mark = vec![NONE; a.n];
let mut path = Vec::new();
for k in 0..a.n {
let top = ereach(a, k, &parent, &mut stack, &mut mark, &mut path);
for &j in stack.get(top..).unwrap_or_default() {
if let Some(c) = counts.get_mut(j) {
*c += 1;
}
}
}
(parent, counts)
}
pub fn new(a: &UpperColumns, max_entries: usize) -> std::result::Result<Factor, FactorError> {
let n = a.n;
let (parent, counts) = Factor::symbolic(a);
let total: usize = counts.iter().sum();
if total > max_entries {
return Err(FactorError::TooLarge { entries: total, limit: max_entries });
}
let mut ptr = Vec::with_capacity(n + 1);
ptr.push(0);
for c in &counts {
ptr.push(ptr.last().copied().unwrap_or(0) + c);
}
let mut idx = vec![0usize; total];
let mut val = vec![0.0f64; total];
// Next free slot in each column.
let mut next: Vec<usize> = ptr.get(..n).unwrap_or_default().to_vec();
let mut x = vec![0.0f64; n];
let mut stack = vec![0usize; n];
let mut mark = vec![NONE; n];
let mut path = Vec::new();
for k in 0..n {
let top = ereach(a, k, &parent, &mut stack, &mut mark, &mut path);
let (rows, vals) = a.column(k);
let mut diag = 0.0;
for (i, v) in rows.iter().zip(vals) {
if *i < k {
if let Some(xi) = x.get_mut(*i) {
*xi = *v;
}
} else if *i == k {
diag = *v;
}
}
let mut d = diag;
for &i in stack.get(top..).unwrap_or_default() {
let start = ptr.get(i).copied().unwrap_or(0);
let lii = val.get(start).copied().unwrap_or(1.0);
let lki = x.get(i).copied().unwrap_or(0.0) / lii;
if let Some(xi) = x.get_mut(i) {
*xi = 0.0;
}
let end = next.get(i).copied().unwrap_or(start);
for p in start + 1..end {
let (r, l) = (idx.get(p).copied().unwrap_or(0), val.get(p).copied().unwrap_or(0.0));
if let Some(xr) = x.get_mut(r) {
*xr -= l * lki;
}
}
d -= lki * lki;
if let Some(slot) = next.get_mut(i) {
if let (Some(ri), Some(vi)) = (idx.get_mut(*slot), val.get_mut(*slot)) {
*ri = k;
*vi = lki;
}
*slot += 1;
}
}
if !(d > PIVOT_TOLERANCE * diag.abs()) || !d.is_finite() {
return Err(FactorError::NotPositiveDefinite { column: k });
}
if let Some(slot) = next.get_mut(k) {
if let (Some(ri), Some(vi)) = (idx.get_mut(*slot), val.get_mut(*slot)) {
*ri = k;
*vi = d.sqrt();
}
*slot += 1;
}
}
Ok(Factor { n, ptr, idx, val })
}
/// Entries in L.
pub fn entries(&self) -> usize {
self.val.len()
}
/// Solve A x = b in place.
pub fn solve(&self, b: &mut [f64]) -> Result<()> {
if b.len() != self.n {
return Err(Error::Internal("right-hand side length".into()));
}
// L y = b, by columns.
for j in 0..self.n {
let (start, end) = (self.ptr.get(j).copied().unwrap_or(0), self.ptr.get(j + 1).copied().unwrap_or(0));
let yj = b.get(j).copied().unwrap_or(0.0) / self.val.get(start).copied().unwrap_or(1.0);
if let Some(x) = b.get_mut(j) {
*x = yj;
}
for p in start + 1..end {
let (r, l) = (self.idx.get(p).copied().unwrap_or(0), self.val.get(p).copied().unwrap_or(0.0));
if let Some(x) = b.get_mut(r) {
*x -= l * yj;
}
}
}
// Lᵀ x = y.
for j in (0..self.n).rev() {
let (start, end) = (self.ptr.get(j).copied().unwrap_or(0), self.ptr.get(j + 1).copied().unwrap_or(0));
let mut s = b.get(j).copied().unwrap_or(0.0);
for p in start + 1..end {
let (r, l) = (self.idx.get(p).copied().unwrap_or(0), self.val.get(p).copied().unwrap_or(0.0));
s -= l * b.get(r).copied().unwrap_or(0.0);
}
if let Some(x) = b.get_mut(j) {
*x = s / self.val.get(start).copied().unwrap_or(1.0);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sparse::Csr;
fn dense_to_csr(a: &[Vec<f64>]) -> Csr {
let n = a.len();
let mut ptr = vec![0];
let mut idx = Vec::new();
let mut val = Vec::new();
for row in a {
for (j, v) in row.iter().enumerate() {
if *v != 0.0 {
idx.push(j);
val.push(*v);
}
}
ptr.push(idx.len());
}
Csr { n, ptr, idx, val }
}
#[test]
fn factor_and_solve_a_sparse_spd_matrix() {
// A 2D Laplacian-like matrix on a 7 × 5 grid plus a diagonal shift: sparse with fill.
let (w, h) = (7, 5);
let n = w * h;
let mut a = vec![vec![0.0; n]; n];
for i in 0..w {
for j in 0..h {
let k = i * h + j;
a[k][k] = 4.5;
if i + 1 < w {
a[k][k + h] = -1.0;
a[k + h][k] = -1.0;
}
if j + 1 < h {
a[k][k + 1] = -1.0;
a[k + 1][k] = -1.0;
}
}
}
let csr = dense_to_csr(&a);
let f = Factor::new(&UpperColumns::from_symmetric(&csr), usize::MAX).unwrap();
let x: Vec<f64> = (0..n).map(|i| (i as f64 * 0.37).sin()).collect();
let mut b = vec![0.0; n];
csr.mul(&x, &mut b);
f.solve(&mut b).unwrap();
assert!(b.iter().zip(&x).all(|(p, q)| (p - q).abs() < 1e-13), "{b:?}");
// The cap is checked before the numeric factorization.
assert!(matches!(Factor::new(&UpperColumns::from_symmetric(&csr), 10), Err(FactorError::TooLarge { .. })));
// A singular matrix (a pure Laplacian has the constant vector in its null space).
let mut sing = a.clone();
for (k, row) in sing.iter_mut().enumerate() {
let off: f64 = row.iter().enumerate().filter(|(j, _)| *j != k).map(|(_, v)| -v).sum();
row[k] = off;
}
let e = Factor::new(&UpperColumns::from_symmetric(&dense_to_csr(&sing)), usize::MAX).unwrap_err();
assert_eq!(e, FactorError::NotPositiveDefinite { column: n - 1 });
}
}
+521
View File
@@ -0,0 +1,521 @@
//! Eigenvalues: the dense symmetric eigenproblem of the Lanczos projection, and the shifted
//! block Lanczos method for the lowest modes of K φ = λ M φ.
//!
//! **Dense.** Householder reduction to tridiagonal form, then the implicit symmetric QR
//! algorithm with Wilkinson shifts, accumulating the transformations (G. H. Golub, C. F. Van
//! Loan, *Matrix Computations*, 4th ed., Johns Hopkins 2013, §8.3, algorithms 8.3.1 and 8.3.3).
//!
//! **Sparse.** The spectral transformation (T. Ericsson, A. Ruhe, *The spectral transformation
//! Lanczos method for the numerical solution of large sparse generalized symmetric eigenvalue
//! problems*, Math. Comp. 35(152):1251–1268, 1980): with a shift σ below the wanted
//! eigenvalues, the operator (K − σM)⁻¹M is symmetric in the M inner product and has eigenvalues
//! θ = 1/(λ − σ), so the lowest λ become the largest, well separated θ. A block of vectors
//! (R. G. Grimes, J. G. Lewis, H. D. Simon, *A shifted block Lanczos algorithm for solving
//! sparse symmetric generalized eigenproblems*, SIAM J. Matrix Anal. Appl. 15(1):228–272, 1994)
//! finds repeated eigenvalues up to the block size, such as the six rigid-body modes of a free
//! body. Every new block is reorthogonalized against all earlier ones, twice (full
//! reorthogonalization, B. N. Parlett, *The Symmetric Eigenvalue Problem*, SIAM 1998, ch. 13),
//! so no spurious copies appear. A Ritz pair has converged when its residual bound
//! ‖B_{j+1} s‖ is below a relative tolerance. The start block is pseudo-random from a fixed seed,
//! so results are reproducible.
use crate::{Error, Result};
/// Eigenvalues (ascending) and eigenvectors (column k of the row-major `vectors`) of a dense
/// symmetric matrix (row-major, n × n).
pub fn symmetric_eigen(a: &[f64], n: usize) -> Result<(Vec<f64>, Vec<f64>)> {
if a.len() != n * n {
return Err(Error::Internal("dense eigenproblem size".into()));
}
let mut t = a.to_vec();
let mut q = vec![0.0; n * n];
for i in 0..n {
q[i * n + i] = 1.0;
}
// Householder tridiagonalization: for each column k, reflect A[k+1.., k] onto e1.
let mut v = vec![0.0; n];
let mut p = vec![0.0; n];
for k in 0..n.saturating_sub(2) {
let m = k + 1;
let norm: f64 = (m..n).map(|i| t[i * n + k] * t[i * n + k]).sum::<f64>().sqrt();
if norm == 0.0 {
continue;
}
let x0 = t[m * n + k];
let alpha = if x0 >= 0.0 { -norm } else { norm };
for i in m..n {
v[i] = t[i * n + k];
}
v[m] -= alpha;
let vnorm: f64 = (m..n).map(|i| v[i] * v[i]).sum::<f64>().sqrt();
if vnorm == 0.0 {
continue;
}
for i in m..n {
v[i] /= vnorm;
}
// p = A22 v, w = p − (vᵀp) v, A22 −= 2 (v wᵀ + w vᵀ).
for i in m..n {
p[i] = (m..n).map(|j| t[i * n + j] * v[j]).sum();
}
let kk: f64 = (m..n).map(|i| v[i] * p[i]).sum();
for i in m..n {
p[i] -= kk * v[i];
}
for i in m..n {
for j in m..n {
t[i * n + j] -= 2.0 * (v[i] * p[j] + p[i] * v[j]);
}
}
t[m * n + k] = alpha;
t[k * n + m] = alpha;
for i in m + 1..n {
t[i * n + k] = 0.0;
t[k * n + i] = 0.0;
}
// Q = Q (I − 2 v vᵀ).
for r in 0..n {
let s: f64 = (m..n).map(|j| q[r * n + j] * v[j]).sum();
for j in m..n {
q[r * n + j] -= 2.0 * s * v[j];
}
}
}
let mut d: Vec<f64> = (0..n).map(|i| t[i * n + i]).collect();
let mut e: Vec<f64> = (0..n.saturating_sub(1)).map(|i| t[(i + 1) * n + i]).collect();
tridiagonal_qr(&mut d, &mut e, &mut q, n)?;
// Sort ascending, carrying the vectors.
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| d[a].total_cmp(&d[b]).then(a.cmp(&b)));
let values = order.iter().map(|&i| d[i]).collect();
let mut vectors = vec![0.0; n * n];
for (new, &old) in order.iter().enumerate() {
for r in 0..n {
vectors[r * n + new] = q[r * n + old];
}
}
Ok((values, vectors))
}
/// Diagonalize a symmetric tridiagonal matrix (diagonal d, sub-diagonal e) by implicit QR steps
/// with Wilkinson shifts, applying the rotations to the columns of q.
fn tridiagonal_qr(d: &mut [f64], e: &mut [f64], q: &mut [f64], n: usize) -> Result<()> {
let small = |e: f64, a: f64, b: f64| e.abs() <= f64::EPSILON * (a.abs() + b.abs());
let mut h = n.saturating_sub(1);
let mut iterations = 0usize;
let cap = 30 * n.max(1) + 30;
while h > 0 {
if small(e[h - 1], d[h - 1], d[h]) {
e[h - 1] = 0.0;
h -= 1;
continue;
}
let mut l = h - 1;
while l > 0 && !small(e[l - 1], d[l - 1], d[l]) {
l -= 1;
}
if l > 0 {
e[l - 1] = 0.0;
}
iterations += 1;
if iterations > cap {
return Err(Error::NotConverged("the tridiagonal QR iteration did not converge".into()));
}
// Wilkinson shift from the trailing 2 × 2 block.
let dd = (d[h - 1] - d[h]) / 2.0;
let eh = e[h - 1];
let sign = if dd >= 0.0 { 1.0 } else { -1.0 };
let mu = d[h] - eh * eh / (dd + sign * dd.hypot(eh));
let mut x = d[l] - mu;
let mut z = e[l];
for k in l..h {
let r = x.hypot(z);
let (c, s) = if r == 0.0 { (1.0, 0.0) } else { (x / r, -z / r) };
if k > l {
e[k - 1] = c * e[k - 1] - s * z;
}
let (a, b, dq) = (d[k], e[k], d[k + 1]);
d[k] = c * c * a - 2.0 * c * s * b + s * s * dq;
d[k + 1] = s * s * a + 2.0 * c * s * b + c * c * dq;
e[k] = c * s * (a - dq) + (c * c - s * s) * b;
if k + 1 < h {
z = -s * e[k + 1];
e[k + 1] *= c;
x = e[k];
}
for row in 0..n {
let (qp, qq) = (q[row * n + k], q[row * n + k + 1]);
q[row * n + k] = c * qp - s * qq;
q[row * n + k + 1] = s * qp + c * qq;
}
}
}
Ok(())
}
/// A small deterministic pseudo-random generator (xorshift64*, S. Vigna, *An experimental
/// exploration of Marsaglia's xorshift generators, scrambled*, ACM TOMS 42(4), 2016) for
/// Lanczos start vectors.
pub struct Random(u64);
impl Random {
pub fn new(seed: u64) -> Random {
Random(seed.max(1))
}
/// Uniform in [−1, 1).
pub fn uniform(&mut self) -> f64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
let v = self.0.wrapping_mul(0x2545_f491_4f6c_dd1d);
(v >> 11) as f64 / (1u64 << 52) as f64 - 1.0
}
}
/// What the Lanczos iteration looks for, in terms of λ = σ + 1/θ.
#[derive(Debug, Clone, Copy)]
pub struct Wanted {
/// Modes to return within the range.
pub count: usize,
/// Lower and upper bounds on λ (eigenvalues outside are skipped).
pub min: f64,
pub max: f64,
}
/// The operators the Lanczos iteration needs.
pub trait Operator {
fn n(&self) -> usize;
/// y = M x.
fn mass(&self, x: &[f64], y: &mut [f64]);
/// x ← (K − σM)⁻¹ x.
fn solve(&self, x: &mut [f64]) -> Result<()>;
}
/// Converged eigenpairs: λ ascending, vectors M-orthonormal.
#[derive(Debug, Clone)]
pub struct Eigenpairs {
pub values: Vec<f64>,
pub vectors: Vec<Vec<f64>>,
/// Lanczos vectors used.
pub basis: usize,
}
fn dot(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
/// Orthonormalize a block against the basis and itself in the M inner product (classical
/// Gram–Schmidt, twice). A column that vanishes is replaced by a fresh random direction and its
/// diagonal coefficient stays zero. Returns the new vectors, their M products and the
/// upper-triangular coefficients `r[j][i]` of column i on new vector j.
fn orthonormalize(
op: &impl Operator,
w: Vec<Vec<f64>>,
q: &[Vec<f64>],
mq: &[Vec<f64>],
rng: &mut Random,
scratch: &mut [f64],
) -> (Vec<Vec<f64>>, Vec<Vec<f64>>, Vec<Vec<f64>>) {
let n = op.n();
let k = w.len();
let mut r = vec![vec![0.0; k]; k];
let mut out: Vec<Vec<f64>> = Vec::with_capacity(k);
let mut mout: Vec<Vec<f64>> = Vec::with_capacity(k);
for (i, mut v) in w.into_iter().enumerate() {
let mut fresh = false;
for _attempt in 0..4 {
op.mass(&v, scratch);
let before = dot(&v, scratch).max(0.0).sqrt();
for _ in 0..2 {
for (qj, mqj) in q.iter().zip(mq) {
let c = dot(mqj, &v);
v.iter_mut().zip(qj).for_each(|(x, y)| *x -= c * y);
}
for (j, (qj, mqj)) in out.iter().zip(&mout).enumerate() {
let c = dot(mqj, &v);
v.iter_mut().zip(qj).for_each(|(x, y)| *x -= c * y);
if !fresh && let Some(x) = r.get_mut(j).and_then(|row| row.get_mut(i)) {
*x += c;
}
}
}
op.mass(&v, scratch);
let norm = dot(&v, scratch).max(0.0).sqrt();
if norm > 1e-8 * before && norm > 0.0 {
if !fresh && let Some(x) = r.get_mut(i).and_then(|row| row.get_mut(i)) {
*x = norm;
}
v.iter_mut().for_each(|x| *x /= norm);
mout.push(scratch.iter().map(|x| x / norm).collect());
out.push(v);
break;
}
fresh = true;
v = (0..n).map(|_| rng.uniform()).collect();
}
}
(out, mout, r)
}
/// Shift-invert block Lanczos with full reorthogonalization.
pub fn lanczos(op: &impl Operator, shift: f64, wanted: Wanted, block: usize, max_basis: usize, tolerance: f64) -> Result<Eigenpairs> {
let n = op.n();
if n == 0 {
return Err(Error::Invalid("there are no free degrees of freedom".into()));
}
let b = block.clamp(1, n);
let max_basis = max_basis.min(n).max(b);
let mut rng = Random::new(0x9e37_79b9_7f4a_7c15);
let mut q: Vec<Vec<f64>> = Vec::new();
let mut mq: Vec<Vec<f64>> = Vec::new();
// The projected matrix T, grown block by block (row-major in a growing square).
let mut t: Vec<Vec<f64>> = Vec::new();
let mut scratch = vec![0.0; n];
// Start block.
let start: Vec<Vec<f64>> = (0..b).map(|_| (0..n).map(|_| rng.uniform()).collect()).collect();
let (mut cur, mut mcur, _) = orthonormalize(op, start, &q, &mq, &mut rng, &mut scratch);
if cur.len() < b {
return Err(Error::NotConverged("could not build a Lanczos start block".into()));
}
let mut prev_b: Option<Vec<Vec<f64>>> = None;
let mut next_check = (wanted.count + b).max(2 * b);
loop {
let j0 = q.len();
// W = Op Q_j.
let mut w: Vec<Vec<f64>> = Vec::with_capacity(b);
for mx in &mcur {
let mut y = mx.clone();
op.solve(&mut y)?;
w.push(y);
}
// A_j = Q_jᵀ M W (symmetrized), W −= Q_j A_j (the earlier blocks go in the full
// reorthogonalization below).
let mut a = vec![vec![0.0; b]; b];
for (i, mqi) in mcur.iter().enumerate() {
for (j, wj) in w.iter().enumerate() {
a[i][j] = dot(mqi, wj);
}
}
for i in 0..b {
for j in i + 1..b {
let s = 0.5 * (a[i][j] + a[j][i]);
a[i][j] = s;
a[j][i] = s;
}
}
q.append(&mut cur);
mq.append(&mut mcur);
let m = q.len();
for row in &mut t {
row.resize(m, 0.0);
}
t.resize(m, vec![0.0; m]);
for i in 0..b {
for j in 0..b {
t[j0 + i][j0 + j] = a[i][j];
}
}
if let Some(bp) = &prev_b {
// T[block j][block j−1] = B_j, T[block j−1][block j] = B_jᵀ.
for i in 0..b {
for j in 0..b {
t[j0 + i][j0 - b + j] = bp[i][j];
t[j0 - b + j][j0 + i] = bp[i][j];
}
}
}
// Full reorthogonalization happens inside the QR against the whole basis.
let full = m >= max_basis || m + b > n;
let (next, mnext, bnext) =
if full { (Vec::new(), Vec::new(), vec![vec![0.0; b]; b]) } else { orthonormalize(op, w, &q, &mq, &mut rng, &mut scratch) };
// Convergence check on a schedule (the dense eigenproblem costs m³).
if m >= next_check || full || next.len() < b {
next_check = m + (m / 4).max(b);
let dense: Vec<f64> = t.iter().flatten().copied().collect();
let (theta, s) = symmetric_eigen(&dense, m)?;
// Order by λ ascending = θ descending (θ > 0 below the spectrum).
let mut idx: Vec<usize> = (0..m).collect();
idx.sort_by(|&x, &y| theta[y].total_cmp(&theta[x]).then(x.cmp(&y)));
let mut done = false;
let mut accepted = Vec::new();
let mut found = 0;
for &i in &idx {
let th = theta[i];
if !(th > 0.0) {
break;
}
// Residual bound ‖B_{j+1} s_last‖ with s_last the last block of the Ritz vector.
let mut res2 = 0.0;
if !full {
for r in 0..b {
let v: f64 = (0..b).map(|c| bnext[r][c] * s[(m - b + c) * m + i]).sum();
res2 += v * v;
}
}
if res2.sqrt() > tolerance * th {
break;
}
let lambda = shift + 1.0 / th;
if lambda > wanted.max {
done = true;
break;
}
if lambda >= wanted.min {
accepted.push(i);
found += 1;
if found == wanted.count {
done = true;
break;
}
}
}
if full && !done {
if m >= n {
done = true;
} else {
return Err(Error::NotConverged(format!(
"found {found} of {} modes after {m} Lanczos vectors (the cap); raise the iteration limit or ask for fewer modes",
wanted.count
)));
}
}
if done {
let mut values = Vec::new();
let mut vectors = Vec::new();
for &i in &accepted {
values.push(shift + 1.0 / theta[i]);
let mut x = vec![0.0; n];
for (k, qk) in q.iter().enumerate() {
let c = s[k * m + i];
x.iter_mut().zip(qk).for_each(|(a, b)| *a += c * b);
}
op.mass(&x, &mut scratch);
let norm = dot(&x, &scratch).max(0.0).sqrt();
if norm > 0.0 {
x.iter_mut().for_each(|a| *a /= norm);
}
// Deterministic sign: the largest component positive.
let big = x.iter().copied().fold(0.0f64, |m, v| if v.abs() > m.abs() { v } else { m });
if big < 0.0 {
x.iter_mut().for_each(|a| *a = -*a);
}
vectors.push(x);
}
return Ok(Eigenpairs { values, vectors, basis: m });
}
}
if full {
return Err(Error::NotConverged("the Lanczos basis is exhausted".into()));
}
cur = next;
mcur = mnext;
if cur.len() < b {
return Err(Error::NotConverged("the Lanczos block lost rank".into()));
}
prev_b = Some(bnext);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dense_symmetric_eigen() {
let n = 9;
let mut rng = Random::new(7);
let mut a = vec![0.0; n * n];
for i in 0..n {
for j in 0..=i {
let v = rng.uniform();
a[i * n + j] = v;
a[j * n + i] = v;
}
}
let (values, vectors) = symmetric_eigen(&a, n).unwrap();
assert!(values.windows(2).all(|w| w[0] <= w[1]));
for k in 0..n {
for i in 0..n {
let av: f64 = (0..n).map(|j| a[i * n + j] * vectors[j * n + k]).sum();
assert!((av - values[k] * vectors[i * n + k]).abs() < 1e-12, "pair {k}");
}
let norm: f64 = (0..n).map(|i| vectors[i * n + k].powi(2)).sum();
assert!((norm - 1.0).abs() < 1e-12);
}
assert_eq!(symmetric_eigen(&[3.0], 1).unwrap().0, vec![3.0]);
}
/// K and M diagonal.
struct Diagonal {
k: Vec<f64>,
m: Vec<f64>,
shift: f64,
}
impl Operator for Diagonal {
fn n(&self) -> usize {
self.k.len()
}
fn mass(&self, x: &[f64], y: &mut [f64]) {
for i in 0..x.len() {
y[i] = self.m[i] * x[i];
}
}
fn solve(&self, x: &mut [f64]) -> Result<()> {
for i in 0..x.len() {
x[i] /= self.k[i] - self.shift * self.m[i];
}
Ok(())
}
}
#[test]
fn lanczos_finds_repeated_and_zero_eigenvalues() {
// Six zero eigenvalues, then 1 twice, then distinct values; M not the identity.
let n = 200;
let lambdas: Vec<f64> = (0..n)
.map(|i| {
if i < 6 {
0.0
} else if i < 8 {
1.0
} else {
1.0 + (i - 7) as f64 * 0.5
}
})
.collect();
let m: Vec<f64> = (0..n).map(|i| 1.0 + (i % 7) as f64 * 0.1).collect();
let k: Vec<f64> = lambdas.iter().zip(&m).map(|(l, m)| l * m).collect();
let shift = -1e-3;
let op = Diagonal { k, m, shift };
let wanted = Wanted { count: 10, min: f64::NEG_INFINITY, max: f64::INFINITY };
let pairs = lanczos(&op, shift, wanted, 6, n, 1e-9).unwrap();
assert_eq!(pairs.values.len(), 10);
let want = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 2.0];
for (got, w) in pairs.values.iter().zip(want) {
assert!((got - w).abs() < 1e-8, "{:?}", pairs.values);
}
// M-orthonormal vectors.
let mut y = vec![0.0; n];
for a in &pairs.vectors {
for b in &pairs.vectors {
op.mass(b, &mut y);
let d = dot(a, &y);
assert!((d - if std::ptr::eq(a, b) { 1.0 } else { 0.0 }).abs() < 1e-8);
}
}
// A range: values in [1.2, 3.2] (λ), at most 100.
let ranged = lanczos(&op, shift, Wanted { count: 100, min: 1.2, max: 3.2 }, 6, n, 1e-9).unwrap();
assert_eq!(ranged.values.len(), 4, "{:?}", ranged.values);
assert!(ranged.values.iter().zip([1.5, 2.0, 2.5, 3.0]).all(|(g, w)| (g - w).abs() < 1e-8), "{:?}", ranged.values);
// Caps: a basis too small to converge is an error, not a hang.
assert!(lanczos(&op, shift, Wanted { count: 50, min: f64::NEG_INFINITY, max: f64::INFINITY }, 6, 24, 1e-9).is_err());
// Tiny problems: the whole space.
let small = Diagonal { k: vec![2.0, 1.0, 3.0], m: vec![1.0; 3], shift: 0.0 };
let all = lanczos(&small, 0.0, Wanted { count: 5, min: f64::NEG_INFINITY, max: f64::INFINITY }, 6, 100, 1e-9).unwrap();
assert_eq!(all.values.len(), 3);
assert!((all.values[0] - 1.0).abs() < 1e-12 && (all.values[2] - 3.0).abs() < 1e-12, "{:?}", all.values);
}
}
+89
View File
@@ -0,0 +1,89 @@
//! ModelCraft's implicit solver: linear static and frequency analysis of a model's steps.
//!
//! The pipeline, for one step of a [`modelcraft_model::Model`]:
//! 1. **System** ([`system`]): the model is resolved; every section's elements become solid
//! elements with their material's elasticity and the section's formulation
//! ([`modelcraft_fem`] kernels); the step's boundary conditions give the prescribed degrees of
//! freedom, its loads the load vector (forces and tractions as the model distributes them,
//! consistent pressure loads, gravity and body forces).
//! 2. **Assembly** ([`sparse`]) on the node graph, elements in label order.
//! 3. **Ordering** ([`ordering`]): nested dissection of the nodes; the free degrees of freedom
//! are numbered in that order and the prescribed ones eliminated (u_p moved to the right-hand
//! side), with no penalty numbers.
//! 4. **Solution** ([`cholesky`]): sparse Cholesky; reactions K u − f at the prescribed degrees
//! of freedom; stresses at the integration points, extrapolated and averaged at the nodes, and
//! von Mises. Frequency analysis ([`eigen`]) runs shift-invert block Lanczos on the same
//! kind of factorization.
//!
//! Everything is sequential and in a fixed order, so the same model gives the same numbers bit
//! for bit; sizes and iterations are capped by [`Limits`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
// Matrix kernels read as the textbook formulas with index loops.
#![allow(clippy::needless_range_loop)]
pub mod analysis;
pub mod cholesky;
pub mod eigen;
pub mod ordering;
pub mod sparse;
pub mod system;
pub use analysis::{
ElementStress, FrequencyOptions, FrequencyResult, MassKind, Mode, StaticResult, Stats, StepResult, solve_frequency, solve_static,
};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Model(#[from] modelcraft_model::Error),
#[error("invalid input: {0}")]
Invalid(String),
#[error("not supported yet: {0}")]
NotSupported(String),
/// An element kernel failed (inverted geometry, a formulation it lacks, …).
#[error("element {label}: {source}")]
Element { label: u32, source: modelcraft_fem::Error },
/// The stiffness (or shifted matrix) is singular.
#[error("{0}")]
Singular(String),
#[error("limit reached: {0}")]
Limit(String),
#[error("did not converge: {0}")]
NotConverged(String),
/// A broken internal invariant (a bug), reported instead of a panic.
#[error("internal error: {0}")]
Internal(String),
}
pub type Result<T> = std::result::Result<T, Error>;
impl Error {
/// A kernel error, naming the element.
pub fn element(label: u32, source: modelcraft_fem::Error) -> Error {
Error::Element { label, source }
}
}
/// Caps on the size of a solution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Degrees of freedom of the mesh.
pub max_dofs: usize,
/// Entries of the Cholesky factor (8 bytes each); also bounds the Lanczos basis memory.
pub max_factor_entries: usize,
/// Lanczos vectors (iterations × block size).
pub max_lanczos_vectors: usize,
}
impl Limits {
pub const MAX_DOFS: usize = 6_000_000;
pub const MAX_FACTOR_ENTRIES: usize = 400_000_000;
pub const MAX_LANCZOS_VECTORS: usize = 3_000;
}
impl Default for Limits {
fn default() -> Limits {
Limits { max_dofs: Limits::MAX_DOFS, max_factor_entries: Limits::MAX_FACTOR_ENTRIES, max_lanczos_vectors: Limits::MAX_LANCZOS_VECTORS }
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Fill-reducing ordering: nested dissection by coordinate bisection.
//!
//! Nested dissection (A. George, *Nested dissection of a regular finite element mesh*, SIAM J.
//! Numer. Anal. 10(2):345–363, 1973) orders a separator after the two parts it splits, so the
//! Cholesky factor of a 3D mesh with n nodes fills O(n^{4/3}) instead of the O(n^{5/3}) of a band
//! or envelope ordering. The separator here is geometric: the nodes are split at the median of
//! the longest axis of their bounding box, and the nodes of the smaller side that touch the other
//! side form the separator (the coordinate nested dissection of H. D. Simon, *Partitioning of
//! unstructured problems for parallel processing*, Computing Systems in Engineering 2:135–148,
//! 1991, with vertex separators). Ties are broken by node index, so the ordering is
//! deterministic.
/// Parts this small are ordered as they come.
const LEAF: usize = 16;
/// A permutation of the nodes: `order[k]` is the node eliminated k-th.
pub fn nested_dissection(xyz: &[[f64; 3]], adjacency: &[Vec<usize>]) -> Vec<usize> {
let n = xyz.len();
let mut order = Vec::with_capacity(n);
// 0 = not in the current part, 1 = left, 2 = right.
let mut side = vec![0u8; n];
// An explicit stack instead of recursion: (part, separator to emit after it).
enum Task {
Split(Vec<usize>),
Emit(Vec<usize>),
}
let mut stack = vec![Task::Split((0..n).collect())];
while let Some(task) = stack.pop() {
let mut part = match task {
Task::Emit(sep) => {
order.extend(sep);
continue;
}
Task::Split(p) => p,
};
if part.len() <= LEAF {
order.extend(part);
continue;
}
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for &v in &part {
let p = xyz.get(v).copied().unwrap_or_default();
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
let axis = (0..3).fold(0, |best, k| if hi[k] - lo[k] > hi[best] - lo[best] { k } else { best });
let key = |v: usize| xyz.get(v).map_or(0.0, |p| p[axis]);
part.sort_by(|&a, &b| key(a).total_cmp(&key(b)).then(a.cmp(&b)));
let half = part.len() / 2;
for (i, &v) in part.iter().enumerate() {
if let Some(s) = side.get_mut(v) {
*s = if i < half { 1 } else { 2 };
}
}
let touches = |v: usize, other: u8| adjacency.get(v).is_some_and(|row| row.iter().any(|w| side.get(*w) == Some(&other)));
let (left, right) = part.split_at(half);
let left_boundary: Vec<usize> = left.iter().copied().filter(|&v| touches(v, 2)).collect();
let right_boundary: Vec<usize> = right.iter().copied().filter(|&v| touches(v, 1)).collect();
let separator = if left_boundary.len() <= right_boundary.len() { left_boundary } else { right_boundary };
for &v in &part {
if let Some(s) = side.get_mut(v) {
*s = 0;
}
}
// Mark the separator so the parts leave it out.
for &v in &separator {
if let Some(s) = side.get_mut(v) {
*s = 3;
}
}
let a: Vec<usize> = left.iter().copied().filter(|v| side.get(*v) != Some(&3)).collect();
let b: Vec<usize> = right.iter().copied().filter(|v| side.get(*v) != Some(&3)).collect();
for &v in &separator {
if let Some(s) = side.get_mut(v) {
*s = 0;
}
}
// Order: a, then b, then the separator (the stack runs in reverse).
stack.push(Task::Emit(separator));
stack.push(Task::Split(b));
stack.push(Task::Split(a));
}
order
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_permutation_with_separators_last() {
// A 12 × 12 grid graph.
let m = 12;
let id = |i: usize, j: usize| i * m + j;
let xyz: Vec<[f64; 3]> = (0..m * m).map(|v| [(v / m) as f64, (v % m) as f64, 0.0]).collect();
let mut adj = vec![Vec::new(); m * m];
for i in 0..m {
for j in 0..m {
for (di, dj) in [(0i64, 1i64), (1, 0), (0, -1), (-1, 0), (0, 0)] {
let (a, b) = (i as i64 + di, j as i64 + dj);
if (0..m as i64).contains(&a) && (0..m as i64).contains(&b) {
adj[id(i, j)].push(id(a as usize, b as usize));
}
}
}
}
let order = nested_dissection(&xyz, &adj);
let mut sorted = order.clone();
sorted.sort_unstable();
assert_eq!(sorted, (0..m * m).collect::<Vec<_>>());
// The top separator (last 12 entries) is one grid line.
let last: Vec<usize> = order[m * m - m..].to_vec();
assert!(last.iter().all(|v| v / m == last[0] / m) || last.iter().all(|v| v % m == last[0] % m), "{last:?}");
assert_eq!(order, nested_dissection(&xyz, &adj), "deterministic");
}
}
+233
View File
@@ -0,0 +1,233 @@
//! 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 3 × 3 block, 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 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()
}
/// The matrix on a subset of rows and columns, renumbered: `map[i]` is the new index of old
/// index i, or `None` to drop it. The new rows keep sorted columns.
pub fn restrict(&self, map: &[Option<usize>], n: usize) -> Csr {
let mut rows: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
for (r, m) in map.iter().enumerate() {
let Some(nr) = *m else { continue };
let (cols, vals) = self.row(r);
let Some(out) = rows.get_mut(nr) else { continue };
for (c, v) in cols.iter().zip(vals) {
if let Some(Some(nc)) = map.get(*c) {
out.push((*nc, *v));
}
}
out.sort_by_key(|e| e.0);
}
let mut ptr = Vec::with_capacity(n + 1);
ptr.push(0);
let mut idx = Vec::new();
let mut val = Vec::new();
for row in rows {
for (c, v) in row {
idx.push(c);
val.push(v);
}
ptr.push(idx.len());
}
Csr { n, ptr, idx, val }
}
/// `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 (3 × 3 per node pair).
pub fn entries(&self) -> usize {
self.adjacency.iter().map(|r| 9 * r.len()).sum()
}
/// A zero matrix over every degree of freedom (3 per node) on this pattern.
pub fn zero_matrix(&self) -> Csr {
let n = 3 * self.adjacency.len();
let mut ptr = Vec::with_capacity(n + 1);
ptr.push(0);
let mut idx = Vec::with_capacity(self.entries());
for row in &self.adjacency {
for _ in 0..3 {
for &b in row {
idx.extend([3 * b, 3 * b + 1, 3 * b + 2]);
}
ptr.push(idx.len());
}
}
let val = vec![0.0; idx.len()];
Csr { n, ptr, idx, val }
}
}
/// Where node b's 3 × 3 block starts in each of node a's three rows (the rows of a node share
/// one pattern, built by [`NodeGraph::zero_matrix`]): one search per node pair.
fn block_offsets(k: &Csr, a: usize, b: usize) -> Result<[usize; 3]> {
let (start, end) = (k.ptr.get(3 * a).copied().unwrap_or(0), k.ptr.get(3 * a + 1).copied().unwrap_or(0));
let cols = k.idx.get(start..end).ok_or_else(|| Error::Internal("pattern".into()))?;
let at = cols.binary_search(&(3 * b)).map_err(|_| Error::Internal("entry outside the pattern".into()))?;
Ok([0, 1, 2].map(|r| k.ptr.get(3 * a + r).copied().unwrap_or(0) + at))
}
/// Add an element matrix (3n × 3n, row-major, degrees of freedom node by node) into a matrix
/// built by [`NodeGraph::zero_matrix`]; `nodes` are the element's global node indices.
pub fn add_block(k: &mut Csr, nodes: &[usize], data: &[f64]) -> Result<()> {
let width = 3 * 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 rows = block_offsets(k, a, b)?;
for (r, start) in rows.iter().enumerate() {
for c in 0..3 {
if let (Some(x), Some(v)) = (k.val.get_mut(start + c), data.get((3 * i + r) * width + 3 * j + c)) {
*x += v;
}
}
}
}
}
Ok(())
}
/// Add an n × n element matrix to each displacement component's block (consistent mass).
pub fn add_scalar(k: &mut Csr, nodes: &[usize], 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 rows = block_offsets(k, a, b)?;
let v = data.get(i * n + j).copied().unwrap_or(0.0);
for (c, start) in rows.iter().enumerate() {
if let Some(x) = k.val.get_mut(start + c) {
*x += v;
}
}
}
}
Ok(())
}
/// Add a per-node diagonal to each displacement component (lumped mass).
pub fn add_diagonal(k: &mut Csr, nodes: &[usize], diagonal: &[f64]) -> Result<()> {
for (&a, v) in nodes.iter().zip(diagonal) {
let rows = block_offsets(k, a, a)?;
for (c, start) in rows.iter().enumerate() {
if let Some(x) = k.val.get_mut(start + c) {
*x += v;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn assemble_restrict_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 mut k = g.zero_matrix();
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, &[0, 1], &e).unwrap();
add_block(&mut k, &[1, 2], &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<_>>());
// Keep the x components only, in reverse order.
let map: Vec<Option<usize>> = (0..9).map(|i| (i % 3 == 0).then_some(2 - i / 3)).collect();
let r = k.restrict(&map, 3);
assert_eq!(r.diagonal(), vec![2.0, 4.0, 2.0]);
assert_eq!(r.row(1).0, &[0, 1, 2]);
assert!(add_block(&mut k, &[0, 2], &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();
add_scalar(&mut m, &[0, 1], &[2.0, 1.0, 1.0, 2.0]).unwrap();
add_diagonal(&mut m, &[2], &[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));
}
}
+218
View File
@@ -0,0 +1,218 @@
//! From the resolved model to the discrete system: the solid elements with their materials and
//! formulations, the degrees of freedom, prescribed values and the load vector of a step.
use modelcraft_fem::element::{Formulation, Solid};
use modelcraft_fem::{Elasticity, elasticity, face};
use modelcraft_model::{ElementKind, Load, Model, Resolved, SectionKind, Step, Target};
use crate::{Error, Result};
/// A solid element ready for the kernels.
#[derive(Debug, Clone)]
pub struct Elem {
pub label: u32,
pub kind: ElementKind,
/// Indices into [`Mesh::labels`].
pub nodes: Vec<usize>,
pub xyz: Vec<[f64; 3]>,
pub formulation: Formulation,
pub d: Elasticity,
pub density: Option<f64>,
}
impl Elem {
pub fn solid(&self) -> Result<Solid<'_>> {
Solid::new(self.kind, &self.xyz, self.formulation).map_err(|e| Error::element(self.label, e))
}
}
/// The analysed mesh: the nodes that carry degrees of freedom (those of the solid elements),
/// sorted by label, and the elements sorted by label.
#[derive(Debug, Clone)]
pub struct Mesh {
pub labels: Vec<u32>,
pub xyz: Vec<[f64; 3]>,
pub elements: Vec<Elem>,
}
impl Mesh {
pub fn node(&self, label: u32) -> Option<usize> {
self.labels.binary_search(&label).ok()
}
pub fn element(&self, label: u32) -> Option<&Elem> {
self.elements.binary_search_by_key(&label, |e| e.label).ok().and_then(|i| self.elements.get(i))
}
pub fn dofs(&self) -> usize {
3 * self.labels.len()
}
/// Build it from the model and its resolved view, refusing what the solver cannot represent.
pub fn new(model: &Model, resolved: &Resolved, max_dofs: usize) -> Result<Mesh> {
if let Some(name) = model.couplings().keys().next() {
return Err(Error::NotSupported(format!(
"couplings (coupling `{name}`); the solver handles solid elements without reference points so far"
)));
}
let mut elements = Vec::new();
for (name, section) in model.sections() {
if !matches!(section.kind, SectionKind::Solid {}) {
return Err(Error::NotSupported(format!(
"{} sections (section `{name}`); the solver has solid elements so far",
section.kind.name()
)));
}
let material = model
.material(&section.material)
.ok_or_else(|| Error::Invalid(format!("section `{name}`: material `{}` is missing", section.material)))?;
let elastic =
material.elastic.as_ref().ok_or_else(|| Error::Invalid(format!("material `{}` has no elastic constants", section.material)))?;
let d = elasticity(elastic).map_err(|e| Error::Invalid(format!("material `{}`: {e}", section.material)))?;
let set = resolved.sets.get(&section.set).ok_or_else(|| Error::Invalid(format!("set `{}` is not resolved", section.set)))?;
for &label in &set.elements {
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
let formulation = Formulation::from_section(el.kind, &section.formulation).map_err(|e| Error::element(label, e))?;
elements.push((label, el.kind, el.nodes.clone(), formulation, d, material.density));
}
}
elements.sort_by_key(|e| e.0);
let mut labels: Vec<u32> = elements.iter().flat_map(|e| e.2.iter().copied()).collect();
labels.sort_unstable();
labels.dedup();
if 3 * labels.len() > max_dofs {
return Err(Error::Limit(format!("the model has {} degrees of freedom; the limit is {max_dofs}", 3 * labels.len())));
}
let xyz: Vec<[f64; 3]> = labels
.iter()
.map(|l| model.node(*l).map(|n| n.xyz).ok_or_else(|| Error::Invalid(format!("node {l} is missing"))))
.collect::<Result<_>>()?;
let mut mesh = Mesh { labels, xyz, elements: Vec::with_capacity(elements.len()) };
for (label, kind, nodes, formulation, d, density) in elements {
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density });
}
Ok(mesh)
}
}
/// The prescribed value of every degree of freedom (`None` = free) under a step's boundary
/// conditions. Rotational degrees of freedom do not exist on solid nodes and are ignored.
pub fn prescribed(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, values: bool) -> Result<Vec<Option<f64>>> {
let mut out: Vec<Option<f64>> = vec![None; mesh.dofs()];
for name in &step.boundary_conditions {
let bc = model.boundary_condition(name).ok_or_else(|| Error::Invalid(format!("boundary condition `{name}` is missing")))?;
if let Target::ReferencePoint(rp) = bc.target() {
return Err(Error::NotSupported(format!("boundary conditions on reference points (`{name}` on `{rp}`)")));
}
for &label in resolved.target_nodes(bc.target()) {
let Some(node) = mesh.node(label) else { continue };
for dof in 1..=3u8 {
let Some(v) = bc.value(dof) else { continue };
let v = if values { v } else { 0.0 };
let slot = out.get_mut(3 * node + usize::from(dof - 1)).ok_or_else(|| Error::Internal("dof index".into()))?;
match *slot {
Some(old) if old != v => {
return Err(Error::Invalid(format!(
"node {label} direction {dof} is prescribed both {old} and {v} (boundary condition `{name}` and another)"
)));
}
_ => *slot = Some(v),
}
}
}
}
Ok(out)
}
fn add(f: &mut [f64], node: usize, v: [f64; 3]) {
for (k, c) in v.iter().enumerate() {
if let Some(x) = f.get_mut(3 * node + k) {
*x += c;
}
}
}
/// The load vector of a static step: nodal forces and tractions as the model distributes them,
/// consistent pressure loads, gravity and body forces.
pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Result<Vec<f64>> {
let mut f = vec![0.0; mesh.dofs()];
for name in &step.loads {
let load = model.load(name).ok_or_else(|| Error::Invalid(format!("load `{name}` is missing")))?;
match load {
Load::Force { target: Target::ReferencePoint(rp), .. } => {
return Err(Error::NotSupported(format!("loads on reference points (`{name}` on `{rp}`)")));
}
Load::Force { .. } | Load::Traction { .. } => {
for (label, v) in resolved.nodal_loads.get(name).map(Vec::as_slice).unwrap_or_default() {
let node = mesh.node(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: node {label} belongs to no solid element")))?;
add(&mut f, node, *v);
}
}
Load::Moment { .. } => {
return Err(Error::NotSupported(format!("moments (`{name}`): solid nodes have no rotations; a coupled reference point is needed")));
}
Load::Pressure { set, magnitude } => {
let faces = resolved.sets.get(set).map(|s| s.faces.as_slice()).unwrap_or_default();
for fc in faces {
let labels = model
.face_nodes(*fc)
.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} face {} is missing", fc.element, fc.face)))?;
let idx: Vec<usize> = labels
.iter()
.map(|l| mesh.node(*l).ok_or_else(|| Error::Invalid(format!("load `{name}`: node {l} belongs to no solid element"))))
.collect::<Result<_>>()?;
let xyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
let forces = face::pressure(&xyz, *magnitude).map_err(|e| Error::element(fc.element, e))?;
for (i, v) in idx.iter().zip(forces) {
add(&mut f, *i, v);
}
}
}
Load::Gravity { acceleration, set } => {
let chosen: Option<&[u32]> = set.as_ref().map(|s| resolved.sets.get(s).map(|r| r.elements.as_slice()).unwrap_or_default());
for e in &mesh.elements {
if chosen.is_some_and(|c| c.binary_search(&e.label).is_err()) {
continue;
}
let rho = e.density.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} has no density", e.label)))?;
body(&mut f, e, acceleration.map(|a| rho * a))?;
}
}
Load::BodyForce { set, vector } => {
for label in resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default() {
let e = mesh.element(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: element {label} is not a solid element")))?;
body(&mut f, e, *vector)?;
}
}
}
}
Ok(f)
}
fn body(f: &mut [f64], e: &Elem, b: [f64; 3]) -> Result<()> {
let forces = e.solid()?.body_force(b).map_err(|err| Error::element(e.label, err))?;
for (node, v) in e.nodes.iter().zip(forces) {
add(f, *node, v);
}
Ok(())
}
/// Equation numbers of the free degrees of freedom, following a node order.
pub fn equations(order: &[usize], prescribed: &[Option<f64>]) -> (Vec<Option<usize>>, usize) {
let mut map = vec![None; prescribed.len()];
let mut next = 0;
for &node in order {
for c in 0..3 {
let dof = 3 * node + c;
if prescribed.get(dof).is_some_and(Option::is_none) {
if let Some(m) = map.get_mut(dof) {
*m = Some(next);
}
next += 1;
}
}
}
(map, next)
}