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

+131 -53
View File
@@ -1,8 +1,14 @@
//! 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.
//! formulations, the coupled reference points, the degrees of freedom, prescribed values and the
//! load vector of a step.
//!
//! Degrees of freedom: three translations per mesh node (nodes by label), then six per coupled
//! reference point (points by name): translations along X, Y, Z and rotations about them.
use modelcraft_fem::element::{Formulation, Solid};
use modelcraft_fem::{Elasticity, elasticity, face};
use std::collections::BTreeSet;
use modelcraft_model::{ElementKind, Load, Model, Resolved, SectionKind, Step, Target};
use crate::{Error, Result};
@@ -26,15 +32,27 @@ impl Elem {
}
}
/// A reference point some coupling ties to the mesh.
#[derive(Debug, Clone, PartialEq)]
pub struct Point {
pub name: String,
pub label: u32,
pub xyz: [f64; 3],
}
/// The analysed mesh: the nodes that carry degrees of freedom (those of the solid elements),
/// sorted by label, and the elements sorted by label.
/// sorted by label, the elements sorted by label, and the coupled reference points by name.
#[derive(Debug, Clone)]
pub struct Mesh {
pub labels: Vec<u32>,
pub xyz: Vec<[f64; 3]>,
pub elements: Vec<Elem>,
pub points: Vec<Point>,
}
/// The directions of a node's degrees of freedom, 0–5.
const DIRECTIONS: [&str; 6] = ["X", "Y", "Z", "rotation about X", "rotation about Y", "rotation about Z"];
impl Mesh {
pub fn node(&self, label: u32) -> Option<usize> {
self.labels.binary_search(&label).ok()
@@ -44,17 +62,52 @@ impl Mesh {
self.elements.binary_search_by_key(&label, |e| e.label).ok().and_then(|i| self.elements.get(i))
}
pub fn dofs(&self) -> usize {
pub fn point(&self, name: &str) -> Option<usize> {
self.points.binary_search_by(|p| p.name.as_str().cmp(name)).ok()
}
/// The translational degrees of freedom of the mesh nodes.
pub fn mesh_dofs(&self) -> usize {
3 * self.labels.len()
}
/// Every degree of freedom: the mesh's, then six per reference point.
pub fn dofs(&self) -> usize {
self.mesh_dofs() + 6 * self.points.len()
}
/// Degree of freedom `c` (0–5) of reference point `k`.
pub fn point_dof(&self, k: usize, c: usize) -> usize {
self.mesh_dofs() + 6 * k + c
}
/// The direction of a degree of freedom: 0–2 translations along X, Y, Z, 3–5 rotations.
pub fn direction(&self, dof: usize) -> usize {
if dof < self.mesh_dofs() { dof % 3 } else { (dof - self.mesh_dofs()) % 6 }
}
/// The first degree of freedom and their count (3 or 6) of a node or coupled reference
/// point label.
pub fn label_dofs(&self, label: u32) -> Option<(usize, usize)> {
match self.node(label) {
Some(n) => Some((3 * n, 3)),
None => self.points.iter().position(|p| p.label == label).map(|k| (self.point_dof(k, 0), 6)),
}
}
/// What a degree of freedom is, in the user's terms.
pub fn describe_dof(&self, dof: usize) -> String {
let direction = DIRECTIONS.get(self.direction(dof)).copied().unwrap_or("?");
if dof < self.mesh_dofs() {
format!("node {} in {direction}", self.labels.get(dof / 3).copied().unwrap_or(0))
} else {
let name = self.points.get((dof - self.mesh_dofs()) / 6).map_or("?", |p| p.name.as_str());
format!("reference point `{name}` in {direction}")
}
}
/// 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 {}) {
@@ -80,14 +133,23 @@ impl Mesh {
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 coupled: BTreeSet<&str> = model.couplings().values().map(|c| c.reference_point.as_str()).collect();
let points: Vec<Point> = coupled
.into_iter()
.map(|name| {
let p = model.reference_point(name).ok_or_else(|| Error::Invalid(format!("reference point `{name}` is missing")))?;
Ok(Point { name: name.to_string(), label: p.label, xyz: p.xyz })
})
.collect::<Result<_>>()?;
let dofs = 3 * labels.len() + 6 * points.len();
if dofs > max_dofs {
return Err(Error::Limit(format!("the model has {dofs} degrees of freedom; the limit is {max_dofs}")));
}
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()) };
let mut mesh = Mesh { labels, xyz, elements: Vec::with_capacity(elements.len()), points };
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();
@@ -98,24 +160,43 @@ impl 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>>> {
/// conditions; `values: false` holds them at zero (frequency steps). Rotations of solid nodes do
/// not exist and are ignored, like the directions of a reference point no coupling ties
/// (`active`); a boundary condition on a reference point that no coupling ties to the mesh holds
/// nothing and is reported in `warnings`.
pub fn prescribed(
model: &Model,
resolved: &Resolved,
mesh: &Mesh,
step: &Step,
values: bool,
active: &[bool],
warnings: &mut Vec<String>,
) -> 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}`)")));
if let Target::ReferencePoint(rp) = bc.target()
&& mesh.point(rp).is_none()
{
warnings
.push(format!("boundary condition `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh; it holds nothing"));
continue;
}
for &label in resolved.target_nodes(bc.target()) {
let Some(node) = mesh.node(label) else { continue };
for dof in 1..=3u8 {
for (first, count) in resolved.target_nodes(bc.target()).iter().filter_map(|l| mesh.label_dofs(*l)) {
for dof in 1..=count as u8 {
let Some(v) = bc.value(dof) else { continue };
let index = first + usize::from(dof - 1);
if !active.get(index).copied().unwrap_or(false) {
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()))?;
let slot = out.get_mut(index).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)"
"{} is prescribed both {old} and {v} (boundary condition `{name}` and another)",
mesh.describe_dof(index)
)));
}
_ => *slot = Some(v),
@@ -126,32 +207,47 @@ pub fn prescribed(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step,
Ok(out)
}
fn add(f: &mut [f64], node: usize, v: [f64; 3]) {
fn add(f: &mut [f64], first: usize, v: [f64; 3]) {
for (k, c) in v.iter().enumerate() {
if let Some(x) = f.get_mut(3 * node + k) {
if let Some(x) = f.get_mut(first + 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>> {
/// forces and moments on coupled reference points, consistent pressure loads, gravity and body
/// forces.
pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, active: &[bool]) -> 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 { .. } => {
Load::Force { .. } | Load::Moment { .. } | Load::Traction { .. } => {
if let Some(rp) = load.reference_point()
&& mesh.point(rp).is_none()
{
return Err(Error::Invalid(format!("load `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh")));
}
// Moments act on the rotations, which only reference points have.
let offset = if matches!(load, Load::Moment { .. }) { 3 } else { 0 };
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);
let (first, count) =
mesh.label_dofs(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: node {label} belongs to no solid element")))?;
if offset + 3 > count {
return Err(Error::Invalid(format!("moment `{name}`: solid nodes have no rotations; apply it to a coupled reference point")));
}
for (c, x) in v.iter().enumerate() {
let dof = first + offset + c;
if *x != 0.0 && !active.get(dof).copied().unwrap_or(false) {
return Err(Error::Invalid(format!(
"load `{name}`: {} is not coupled to the mesh (its coupling leaves that direction free)",
mesh.describe_dof(dof)
)));
}
}
add(&mut f, first + offset, *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();
@@ -166,7 +262,7 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Re
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);
add(&mut f, 3 * i, v);
}
}
}
@@ -194,25 +290,7 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Re
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);
add(f, 3 * 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)
}