forked from 1b-agent/fieldcraft-upstream
Implicit solver: fem kernels (tet4/10, hex8, hex20), sparse Cholesky, block Lanczos, solve.* commands (M4) (#23)
* Renumber the layer table for solver crates: fem at L1, solver at L2, fsi at L3 L0 model -> L1 geometry, mesh, fem, decks, render -> L2 solver (explicit, lbm later) -> L3 fsi (later) -> L4 doc -> L5 io -> L6 engine -> L7 ui-egui, mcp -> apps. UI_MIN_LAYER moves to 7, the testkit sits above it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-fem: element kernels for tet4, tet10, hex8 (full, incompatible modes), hex20 (full, reduced) Shape functions, Gauss and conical-product quadrature, stiffness, consistent and HRZ-lumped mass, body forces, consistent pressure loads on faces, integration-point stresses and their least-squares extrapolation to nodes, isotropic and orthotropic elasticity. The element table is one row per kind, so wedges, shells and beams are added locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-solver: linear static and frequency analysis with an analytic verification suite Assembly from the resolved model on the node graph, boundary conditions by elimination, consistent pressure, gravity and body-force loads, nested-dissection ordering, sparse up-looking Cholesky with a factor-size cap, reactions, integration-point and nodal-averaged stresses, von Mises; shift-invert block Lanczos with full reorthogonalization for natural modes, participation and effective mass. ADR 0001 records the own-linear-algebra decision (determinism, wasm). Verified against closed forms: displacement and traction patch tests for every element, cantilever tip deflection (Timoshenko) with convergence rates, Lamé cylinder, Kirsch hole (Kt -> 3), cantilever and free-free beam frequencies with six rigid-body modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Engine: solve.static, solve.frequency and solve.probe; results kept in the session Results are stored per step, restored on a failed command and cleared when the model changes. Caps (degrees of freedom, factor entries, Lanczos vectors) may only be lowered; hostile parameters are tested against a solvable session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: implicit solver architecture, roadmap M4, parity and references Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Simplify the solver: shared face integration, block assembly, cached extrapolation - model: one face integrator (nodal::face_normal_weights) for tractions and pressures - fem: mid-side tables from ElementKind::edges, orthotropic via inverse3, symmetric accumulation without a symmetrize pass, incompatible-mode recovery without K_uu, model's coordinate check, dead table rows removed - solver: one pattern search per node pair (block, scalar and diagonal adds), extrapolation built once per element type, reactions from the prescribed rows only, matrices dropped after restriction, the free-body shift keyed on the factorization error, step selection in the solver, Error::Element naming the element - engine: the solver owns the default step and the shift check; the model comparison runs once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: results are stamped with the session revision Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
33 files changed
+4559
-53
No files matched your search
@@ -0,0 +1,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(§ion.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(§ion.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, §ion.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)
|
||||
}
|
||||
Reference in new issue
Block a user