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

+19 -30
View File
@@ -41,33 +41,23 @@ impl Csr {
.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));
/// Make the matrix exactly symmetric by copying the lower triangle (what the factorization
/// reads) onto the upper one; the pattern must be structurally symmetric.
pub fn mirror_lower(&mut self) {
for r in 0..self.n {
let (start, end) = (self.ptr.get(r).copied().unwrap_or(0), self.ptr.get(r + 1).copied().unwrap_or(0));
for at in start..end {
let Some(&c) = self.idx.get(at) else { continue };
if c <= r {
continue;
}
let (cs, ce) = (self.ptr.get(c).copied().unwrap_or(0), self.ptr.get(c + 1).copied().unwrap_or(0));
let mirror = self.idx.get(cs..ce).and_then(|cols| cols.binary_search(&r).ok()).and_then(|i| self.val.get(cs + i)).copied();
if let (Some(v), Some(x)) = (mirror, self.val.get_mut(at)) {
*x = v;
}
}
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.
@@ -198,7 +188,7 @@ mod tests {
use super::*;
#[test]
fn assemble_restrict_multiply() {
fn assemble_mirror_multiply() {
// Two "elements" sharing node 1.
let g = NodeGraph::new(3, [vec![0usize, 1], vec![1, 2]].into_iter());
assert_eq!(g.adjacency, vec![vec![0, 1], vec![0, 1, 2], vec![1, 2]]);
@@ -215,11 +205,10 @@ mod tests {
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]);
// Mirroring keeps a symmetric matrix as it is.
let mut mirrored = k.clone();
mirrored.mirror_lower();
assert_eq!(mirrored, k);
assert!(add_block(&mut k, &[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();