Implicit solver: reference points, couplings, moments, wedge6 (M4) (#26)

* fem: wedge6 element (triangle × interval, 3 × 2 points), patch and cantilever tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* solver: reference points, kinematic and distributing couplings, moments by constraint elimination

Coupled reference points get six degrees of freedom; kinematic couplings are rigid
links, distributing couplings least-squares interpolation constraints. Every
constraint row and prescribed value is eliminated master-slave (Abel-Shephard
pivot choice), so the reduced matrix stays SPD; no coupling gives the old
elimination bit for bit. Verified: rigid motion of a coupled block, clamping
through a point, distributing tension vs pressure, bending and tube torsion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: reference point results in solve.static and solve.probe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: couplings, moments and wedge6 in the solver docs, literature and roadmap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* solver: simplify constraint elimination and reference point plumbing

Rows combine by one sort; T_f transposed once; K and M reduced in one pass on a
shared pattern; prescribed motion computed once; reference point loads and BCs go
through Resolved like node ones (Mesh::label_dofs); one dof-direction rule;
StepResult::locate for probes; dead Csr::restrict removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* solver: refuse oversized reductions before allocating them

A held distributing coupling point makes a dense (3N)^2 block; estimate it from
the constraint rows and refuse it against the factorization's memory budget
with a clear message, before anything is built. Other reductions with
dependents count their entries first (stopping at the budget) and reserve
fallibly.

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 18:21:52 -04:00
1 parent a292212b67
commit 48039639cb
18 files changed
+1502 -199

No files matched your search

+113 -70
View File
@@ -7,16 +7,17 @@ use modelcraft_model::{ElementKind, Model, Procedure, Resolved, Step};
use serde::Serialize;
use crate::cholesky::{Factor, FactorError, UpperColumns};
use crate::constraints::{Budget, Reduction, coupling_rows};
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::system::{Mesh, 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).
/// Degrees of freedom: 3 per mesh node and 6 per coupled reference point.
pub dofs: usize,
/// Free degrees of freedom solved for.
pub equations: usize,
@@ -37,6 +38,16 @@ pub struct ElementStress {
pub nodal: Vec<[f64; 6]>,
}
/// The motion and reaction of a coupled reference point: translations along X, Y, Z, then
/// rotations about them (reactions: forces, then moments). Directions no coupling ties are zero.
#[derive(Debug, Clone, PartialEq)]
pub struct PointResult {
pub name: String,
pub label: u32,
pub displacement: [f64; 6],
pub reaction: [f64; 6],
}
/// The result of a static step.
#[derive(Debug, Clone, PartialEq)]
pub struct StaticResult {
@@ -51,7 +62,9 @@ pub struct StaticResult {
/// 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.
/// The coupled reference points, by name.
pub reference_points: Vec<PointResult>,
/// Sum of the applied forces and of the reaction forces (moments are not included).
pub applied_total: [f64; 3],
pub reaction_total: [f64; 3],
pub stats: Stats,
@@ -71,6 +84,9 @@ pub struct Mode {
pub participation: [f64; 3],
/// Effective mass Γ² along X, Y, Z.
pub effective_mass: [f64; 3],
/// The shape at the coupled reference points ([`FrequencyResult::reference_points`] order):
/// translations, then rotations.
pub point_shape: Vec<[f64; 6]>,
}
/// The result of a frequency step.
@@ -78,6 +94,8 @@ pub struct Mode {
pub struct FrequencyResult {
pub step: String,
pub nodes: Vec<u32>,
/// The coupled reference points' names and labels.
pub reference_points: Vec<(String, u32)>,
pub modes: Vec<Mode>,
/// Mass of the model.
pub total_mass: f64,
@@ -156,33 +174,45 @@ fn factor_raw(a: &Csr, limits: &Limits) -> std::result::Result<Factor, FactorErr
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 {
/// A factorization failure in the user's terms: the node (or reference point) and direction of
/// a singular pivot.
fn factor_error(e: FactorError, mesh: &Mesh, reduction: &Reduction) -> 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("?");
let at = reduction.eq_dof.get(column).map_or_else(|| "an unknown degree of freedom".to_string(), |d| mesh.describe_dof(*d));
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)"
"the stiffness is singular at {at}: the model can move without straining (missing or insufficient boundary conditions, a part not connected to the rest, or a reference point direction nothing holds)"
))
}
}
}
/// 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()
/// The mesh nodes' part of a full vector, per node, and the reference points' part, per point.
fn split(full: &[f64], mesh: &Mesh) -> (Vec<[f64; 3]>, Vec<[f64; 6]>) {
let (nodes, points) = full.split_at(mesh.mesh_dofs().min(full.len()));
(nodes.as_chunks::<3>().0.to_vec(), points.as_chunks::<6>().0.to_vec())
}
/// Constraint rows, prescribed values and their elimination for a step.
/// The model, its resolved view, the mesh and the step being solved.
struct Setup<'a> {
model: &'a Model,
resolved: &'a Resolved,
mesh: &'a Mesh,
step: &'a Step,
}
fn reduction(s: &Setup, values: bool, order: &[usize], budget: Budget, warnings: &mut Vec<String>) -> Result<Reduction> {
let rows = coupling_rows(s.model, s.resolved, s.mesh)?;
Reduction::new(s.mesh, order, &rows, |active| prescribed(s.model, s.resolved, s.mesh, s.step, values, active, warnings), budget)
}
/// The reduced matrices' memory budget: the factorization's.
fn budget(limits: &Limits, graph: &NodeGraph, matrices: usize) -> Budget {
Budget { max_bytes: limits.max_factor_entries.saturating_mul(8), pattern_entries: graph.entries(), matrices }
}
/// Solve a linear static step: the step named `step`, or the model's only static step.
@@ -190,49 +220,42 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
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 mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
let graph = NodeGraph::new(mesh.labels.len(), mesh.elements.iter().map(|e| e.nodes.as_slice()));
let order = nested_dissection(&mesh.xyz, &graph.adjacency);
let setup = Setup { model, resolved: &resolved, mesh: &mesh, step };
let red = reduction(&setup, true, &order, budget(limits, &graph, 1), &mut warnings)?;
let f = loads(model, &resolved, &mesh, step, &red.active)?;
let k = assemble(&mesh, &graph, None)?.k;
let (map, neq) = equations(&nested_dissection(&mesh.xyz, &graph.adjacency), &fixed);
if neq == 0 {
if red.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()) {
let [kff] = red.reduce([&k])?;
let chol = factor_raw(&kff, limits).map_err(|e| factor_error(e, &mesh, &red))?;
drop(kff);
let mut q = red.rhs(&f, &k);
chol.solve(&mut q)?;
let u = red.expand(&q, true);
if u.iter().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 (displacement, point_motion) = split(&u, &mesh);
let reactions = red.reactions(&k, &u, &f);
let (reaction, point_reaction) = split(&reactions, &mesh);
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;
for (dof, (fv, rv)) in f.iter().zip(&reactions).enumerate() {
let d = mesh.direction(dof);
if let (Some(a), Some(r)) = (applied_total.get_mut(d), reaction_total.get_mut(d)) {
*a += fv;
*r += rv;
}
}
let reference_points = mesh
.points
.iter()
.zip(point_motion.iter().zip(&point_reaction))
.map(|(p, (d, r))| PointResult { name: p.name.clone(), label: p.label, displacement: *d, reaction: *r })
.collect();
// 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());
@@ -266,7 +289,6 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
}
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 {
@@ -280,9 +302,10 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
stress,
von_mises,
elements,
reference_points,
applied_total,
reaction_total,
stats: Stats { dofs: mesh.dofs(), equations: neq, factor_entries: chol.entries(), lanczos_vectors: 0 },
stats: Stats { dofs: mesh.dofs(), equations: red.neq(), factor_entries: chol.entries(), lanczos_vectors: 0 },
warnings,
})
}
@@ -324,21 +347,22 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
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 mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
let graph = NodeGraph::new(mesh.labels.len(), mesh.elements.iter().map(|e| e.nodes.as_slice()));
let order = nested_dissection(&mesh.xyz, &graph.adjacency);
let setup = Setup { model, resolved: &resolved, mesh: &mesh, step };
let red = reduction(&setup, false, &order, budget(limits, &graph, 2), &mut warnings)?;
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);
let neq = red.neq();
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);
let [kff, mff] = red.reduce([&k, &m])?;
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 shifted = |s: f64| -> Result<Factor> { factor_raw(&kff.add_scaled(-s, &mff)?, limits).map_err(|e| factor_error(e, &mesh, &red)) };
let (shift, chol) = match options.shift {
Some(s) => (s, shifted(s)?),
None => match factor_raw(&kff, limits) {
@@ -353,7 +377,7 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
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)),
Err(e) => return Err(factor_error(e, &mesh, &red)),
},
};
drop(kff);
@@ -369,15 +393,11 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
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.
// Participation: Γ_d = φᵀ M r_d with r_d the unit translation along d on the free equations
// (mesh and reference point translations).
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 r: Vec<f64> = red.eq_dof.iter().map(|dof| if mesh.direction(*dof) == d { 1.0 } else { 0.0 }).collect();
let mut y = vec![0.0; neq];
mff.mul(&r, &mut y);
y
@@ -386,12 +406,14 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
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()));
let (shape, point_shape) = split(&red.expand(phi, false), &mesh);
out.push(Mode {
eigenvalue: *lambda,
frequency: lambda.max(0.0).sqrt() / two_pi,
shape: per_node(phi, &map, vec![0.0; mesh.dofs()]),
shape,
participation,
effective_mass: participation.map(|g| g * g),
point_shape,
});
}
if out.len() < wanted.count && max_hz.is_none() {
@@ -400,6 +422,7 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
Ok(FrequencyResult {
step: step.name.clone(),
nodes: mesh.labels.clone(),
reference_points: mesh.points.iter().map(|p| (p.name.clone(), p.label)).collect(),
modes: out,
total_mass,
stats: Stats { dofs: mesh.dofs(), equations: neq, factor_entries: chol.entries(), lanczos_vectors: pairs.basis },
@@ -407,6 +430,13 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
})
}
/// Where a label's result is in a [`StepResult`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
Node(usize),
Point(usize),
}
/// The result of one step, as a session keeps it.
#[derive(Debug, Clone, PartialEq)]
pub enum StepResult {
@@ -428,4 +458,17 @@ impl StepResult {
StepResult::Frequency(r) => &r.nodes,
}
}
/// Where a label's result is: a mesh node (index into [`Self::nodes`]) or a coupled
/// reference point (index into the result's reference points).
pub fn locate(&self, label: u32) -> Option<Location> {
if let Ok(i) = self.nodes().binary_search(&label) {
return Some(Location::Node(i));
}
match self {
StepResult::Static(r) => r.reference_points.iter().position(|p| p.label == label),
StepResult::Frequency(r) => r.reference_points.iter().position(|p| p.1 == label),
}
.map(Location::Point)
}
}