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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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

+22 -3
View File
@@ -1,13 +1,13 @@
//! Structured meshes generated in code for the analytic verification suite: a block of
//! hexahedra (hex8, hex20) or Kuhn-split tetrahedra (tet4, tet10) on [0, 1]³, mapped to the part
//! hexahedra (hex8, hex20), of wedges (two per cell) or of Kuhn-split tetrahedra (tet4, tet10) on [0, 1]³, mapped to the part
//! by a function, and helpers to define the model on it.
#![allow(dead_code)]
use std::collections::BTreeMap;
use modelcraft_model::{
BoundaryCondition, Elastic, ElementFace, ElementKind, Formulation, Integration, Load, Material, Model, Procedure, Section, SectionKind, Set,
Step, Symmetry, Target,
BoundaryCondition, Coupling, CouplingKind, Dofs, Elastic, ElementFace, ElementKind, Formulation, Integration, Load, Material, Model, Procedure,
ReferencePoint, Section, SectionKind, Set, Step, Symmetry, Target,
};
pub struct Grid {
@@ -54,6 +54,15 @@ pub fn grid(n: [usize; 3], kind: ElementKind, map: impl Fn([f64; 3]) -> [f64; 3]
model.set_element(next, kind, nodes).unwrap();
next += 1;
}
ElementKind::Wedge6 => {
// Two prisms along z, split by the cell's (x, y) diagonal, both counter-clockwise from above.
for tri in [[[0, 0], [2, 0], [2, 2]], [[0, 0], [2, 2], [0, 2]]] {
let nodes: Vec<u32> =
[0, 2].iter().flat_map(|&z| tri.map(|t| [t[0], t[1], z])).map(|c| node(&mut model, at(c))).collect();
model.set_element(next, kind, nodes).unwrap();
next += 1;
}
}
_ => {
// Six tetrahedra around the cell's main diagonal (Kuhn), conforming across cells.
for perm in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
@@ -144,6 +153,15 @@ impl Grid {
self.bc(name, BoundaryCondition::Symmetry { target: Target::Set(name.into()), plane });
}
/// A reference point `name` at `xyz`, coupled to the nodes where `f` holds.
pub fn couple(&mut self, name: &str, xyz: [f64; 3], kind: CouplingKind, dofs: &[u8], f: impl Fn([f64; 3]) -> bool) {
let set = format!("{name}_nodes");
self.node_set(&set, f);
self.model.add_reference_point(name, ReferencePoint { xyz, label: 0 }).unwrap();
let dofs = Dofs::try_from(dofs.to_vec()).unwrap();
self.model.add_coupling(&format!("{name}_coupling"), Coupling { reference_point: name.into(), set, kind, dofs }).unwrap();
}
pub fn fixed(&mut self, name: &str, f: impl Fn([f64; 3]) -> bool) {
self.node_set(name, f);
self.bc(name, BoundaryCondition::Fixed { target: Target::Set(name.into()) });
@@ -171,6 +189,7 @@ pub fn variants() -> Vec<(ElementKind, Formulation, &'static str)> {
vec![
(ElementKind::Tet4, full(), "tet4"),
(ElementKind::Tet10, full(), "tet10"),
(ElementKind::Wedge6, full(), "wedge6"),
(ElementKind::Hex8, full(), "hex8"),
(ElementKind::Hex8, incompatible(), "hex8 incompatible"),
(ElementKind::Hex20, full(), "hex20"),
+392
View File
@@ -0,0 +1,392 @@
//! Reference points, couplings and moments against closed-form solutions. Run with
//! `--nocapture` to see the errors.
mod common;
use std::f64::consts::PI;
use common::*;
use modelcraft_model::{BoundaryCondition, CouplingKind, Distribution, ElementKind, Load, Procedure, Symmetry, Target};
use modelcraft_solver::{FrequencyOptions, Limits, StaticResult, solve_frequency, solve_static};
const ALL: [u8; 6] = [1, 2, 3, 4, 5, 6];
fn solve(g: &Grid) -> StaticResult {
solve_static(&g.model, Some("Step"), &Limits::default()).unwrap()
}
fn at(r: &StaticResult, label: u32) -> usize {
r.nodes.binary_search(&label).unwrap()
}
fn rp(name: &str) -> Target {
Target::ReferencePoint(name.into())
}
fn force(target: Target, vector: [f64; 3]) -> Load {
Load::Force { target, vector, distribution: Distribution::default() }
}
fn moment(target: Target, vector: [f64; 3]) -> Load {
Load::Moment { target, vector, distribution: Distribution::default() }
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// A block held only through a reference point coupled to one face, the point given a small
/// rigid motion: the whole block follows it rigidly, u = u_r + θ × (x − x_r), with no stress.
/// Kinematic couplings eliminate the face nodes; a distributing coupling with a prescribed point
/// eliminates face nodes chosen by the constraint itself (the point is dependent).
#[test]
fn prescribed_reference_point_moves_the_block_rigidly() {
let motion = [2e-3, -1e-3, 3e-3, 1e-3, -2e-3, 1.5e-3];
let xr = [0.0, 0.6, 0.4];
for (kind, formulation, name) in [
(ElementKind::Hex8, incompatible(), "hex8 incompatible"),
(ElementKind::Hex20, full(), "hex20"),
(ElementKind::Tet10, full(), "tet10"),
(ElementKind::Wedge6, full(), "wedge6"),
] {
for coupling in [CouplingKind::Kinematic, CouplingKind::Distributing] {
let mut g = grid([3, 2, 2], kind, |q| [3.0 * q[0], 1.2 * q[1], 0.8 * q[2]]);
g.define(iso(1000.0, 0.3), None, formulation, Procedure::Static {});
g.couple("RP", xr, coupling, &ALL, |q| near(q[0], 0.0));
let values = motion.map(Some);
g.bc("move", BoundaryCondition::Displacement { target: rp("RP"), values });
let r = solve(&g);
let (t, w) = ([motion[0], motion[1], motion[2]], [motion[3], motion[4], motion[5]]);
let mut worst: f64 = 0.0;
for (label, u) in r.nodes.iter().zip(&r.displacement) {
let x = g.model.node(*label).unwrap().xyz;
let rot = cross(w, [x[0] - xr[0], x[1] - xr[1], x[2] - xr[2]]);
worst = worst.max((0..3).map(|k| (u[k] - t[k] - rot[k]).abs()).fold(0.0, f64::max));
}
let stress = r.stress.iter().flatten().fold(0.0f64, |m, s| m.max(s.abs()));
let p = &r.reference_points[0];
eprintln!("rigid {name} {coupling:?}: displacement error {worst:.1e}, largest stress {stress:.1e}");
assert!(worst < 1e-13, "{name} {coupling:?}: {worst}");
assert!(stress < 1e-9, "{name} {coupling:?}: {stress}");
assert_eq!(p.displacement, motion);
assert!(p.reaction.iter().all(|x| x.abs() < 1e-9), "{:?}", p.reaction);
}
}
}
/// A cantilever clamped through a fixed reference point kinematically coupled to its root face
/// is the cantilever clamped directly: same displacements and natural frequencies, and the
/// point's reaction carries the whole load (force and moment about the point).
#[test]
fn clamping_through_a_coupled_reference_point_is_clamping() {
let build = |through_point: bool, procedure: Procedure| {
let mut g = grid([8, 1, 1], ElementKind::Hex20, |q| [8.0 * q[0], q[1], q[2]]);
g.define(iso(1000.0, 0.3), Some(1.0), full(), procedure);
if through_point {
g.couple("Root", [0.0, 0.5, 0.5], CouplingKind::Kinematic, &[1, 2, 3], |q| near(q[0], 0.0));
g.bc("clamp", BoundaryCondition::Fixed { target: rp("Root") });
} else {
g.fixed("clamp", |q| near(q[0], 0.0));
}
g
};
let (mut direct, mut coupled) = (build(false, Procedure::Static {}), build(true, Procedure::Static {}));
for g in [&mut direct, &mut coupled] {
g.face_set("end", |q| near(q[0], 1.0));
g.load("shear", Load::Traction { set: "end".into(), vector: [0.0, 0.0, -1.0] });
}
let (a, b) = (solve(&direct), solve(&coupled));
let scale = a.displacement.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()));
let diff = a.displacement.iter().flatten().zip(b.displacement.iter().flatten()).fold(0.0f64, |m, (x, y)| m.max((x - y).abs()));
let p = &b.reference_points[0];
eprintln!("clamp through a point: displacement difference {:.1e} (relative), reaction {:?}", diff / scale, p.reaction);
assert!(diff < 1e-10 * scale, "{diff}");
// The tip load −1 in z at x = 8 (centroid line y = z = 0.5): reaction +1 in z and the moment
// about the point M_y = −8 (r × F with r = (8, 0, 0), F = (0, 0, 1) → (0, −8, 0)).
assert!((p.reaction[2] - 1.0).abs() < 1e-9 && p.reaction[0].abs() < 1e-9 && p.reaction[1].abs() < 1e-9, "{:?}", p.reaction);
assert!((p.reaction[4] + 8.0).abs() < 1e-8 && p.reaction[3].abs() < 1e-8 && p.reaction[5].abs() < 1e-8, "{:?}", p.reaction);
assert!((b.reaction_total[2] - 1.0).abs() < 1e-9, "{:?}", b.reaction_total);
let modes = Procedure::Frequency { modes: 3, min_hz: None, max_hz: None };
let (direct, coupled) = (build(false, modes), build(true, modes));
let f = |g: &Grid| solve_frequency(&g.model, Some("Step"), &FrequencyOptions::default(), &Limits::default()).unwrap();
let (fa, fb) = (f(&direct), f(&coupled));
for (x, y) in fa.modes.iter().zip(&fb.modes) {
assert!((x.frequency - y.frequency).abs() < 1e-9 * x.frequency, "{} vs {}", x.frequency, y.frequency);
}
assert_eq!(fb.reference_points.len(), 1);
assert!(fb.modes.iter().all(|m| m.point_shape[0] == [0.0; 6]), "the fixed point does not move");
}
/// A bar in tension loaded through a distributing coupling at the centroid of its end face
/// against the same total force as a uniform pressure: away from the end (Saint-Venant) both give
/// σxx = F/A and u_x = F x / (E A); the coupled load reaches the face as equal nodal forces.
#[test]
fn distributing_force_equals_the_equivalent_pressure_away_from_the_end() {
let (l, b, h, young, nu, total) = (20.0, 1.0, 1.0, 1000.0, 0.3, 4.0);
let area = b * h;
let mut errors = Vec::new();
for (kind, formulation, name) in [(ElementKind::Hex20, full(), "hex20"), (ElementKind::Hex8, incompatible(), "hex8 incompatible")] {
let mut results = Vec::new();
for coupled in [false, true] {
let mut g = grid([20, 2, 2], kind, |q| [l * q[0], b * q[1], h * q[2]]);
g.define(iso(young, nu), None, formulation, Procedure::Static {});
g.symmetry("x0", Symmetry::X, |q| near(q[0], 0.0));
g.symmetry("y0", Symmetry::Y, |q| near(q[1], 0.0));
g.symmetry("z0", Symmetry::Z, |q| near(q[2], 0.0));
if coupled {
// The quarter model's end face: its centroid is at (l, b/2, h/2).
g.couple("End", [l, b / 2.0, h / 2.0], CouplingKind::Distributing, &ALL, |q| near(q[0], 1.0));
g.load("pull", force(rp("End"), [total, 0.0, 0.0]));
} else {
g.face_set("end", |q| near(q[0], 1.0));
g.load("pull", Load::Pressure { set: "end".into(), magnitude: -total / area });
}
let r = solve(&g);
// At least eight widths of the quarter (four of the whole bar) from the loaded end.
let middle: Vec<u32> = g.nodes_where(|q| q[0] > 0.25 && q[0] < 0.6);
let es = middle.iter().map(|n| (r.stress[at(&r, *n)][0] - total / area).abs() / (total / area)).fold(0.0, f64::max);
let eu = middle
.iter()
.map(|n| {
let x = g.model.node(*n).unwrap().xyz[0];
let want = total * x / (young * area);
(r.displacement[at(&r, *n)][0] - want).abs() / want
})
.fold(0.0, f64::max);
eprintln!("tension {name} {}: mid-length σxx error {:.2e}, u_x error {:.2e}", if coupled { "coupled" } else { "pressure" }, es, eu);
assert!((r.applied_total[0] - total).abs() < 1e-12 && (r.reaction_total[0] + total).abs() < 1e-9, "{name}");
errors.push((es, eu));
results.push(r);
}
// The point moves with the face's mean: between the pressure-loaded face's extremes.
let end = x_range(&results[0]);
let ux = results[1].reference_points[0].displacement[0];
assert!(ux > 0.9 * end.0 && ux < 1.1 * end.1, "{ux} {end:?}");
}
assert!(errors.iter().all(|(es, eu)| *es < 1e-4 && *eu < 1e-4), "{errors:?}");
}
/// The smallest and largest x displacement.
fn x_range(r: &StaticResult) -> (f64, f64) {
r.displacement.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), u| (lo.min(u[0]), hi.max(u[0])))
}
/// Pure bending by a moment on a reference point with a distributing coupling on the end face:
/// away from the end σxx = M z / I and the curvature κ = M / (E I) (u_z = κ x² / 2 on the axis).
#[test]
fn bending_moment_through_a_distributing_coupling() {
let (l, b, h, young, nu, m) = (10.0, 1.0, 2.0, 1000.0, 0.3, 3.0);
let i = b * h * h * h / 12.0;
let mut g = grid([10, 2, 4], ElementKind::Hex20, |q| [l * q[0], b * (q[1] - 0.5), h * (q[2] - 0.5)]);
g.define(iso(young, nu), None, full(), Procedure::Static {});
g.symmetry("x0", Symmetry::X, |q| near(q[0], 0.0));
// The pure-bending field has u_y = −ν κ y z and u_z = κ (x² + ν (z² − y²)) / 2: zero u_y on
// y = 0 at x = 0, and zero u_z at the origin.
g.node_set("axis", |q| near(q[0], 0.0) && near(q[1], 0.5) && near(q[2], 0.5));
g.bc("axis", BoundaryCondition::Displacement { target: Target::Set("axis".into()), values: [None, Some(0.0), Some(0.0), None, None, None] });
g.node_set("edge", |q| near(q[0], 0.0) && near(q[1], 0.5) && near(q[2], 0.0));
g.bc("edge", BoundaryCondition::Displacement { target: Target::Set("edge".into()), values: [None, Some(0.0), None, None, None, None] });
g.couple("End", [l, 0.0, 0.0], CouplingKind::Distributing, &ALL, |q| near(q[0], 1.0));
g.load("bend", moment(rp("End"), [0.0, m, 0.0]));
let r = solve(&g);
let mut es: f64 = 0.0;
for n in g.nodes_where(|q| q[0] > 0.35 && q[0] < 0.65) {
let z = g.model.node(n).unwrap().xyz[2];
es = es.max((r.stress[at(&r, n)][0] - m * z / i).abs() / (m * h / 2.0 / i));
}
let kappa = m / (young * i);
let mid = g.nodes_where(|q| near(q[0], 0.5) && near(q[1], 0.5) && near(q[2], 0.5))[0];
let x = g.model.node(mid).unwrap().xyz[0];
// Positive M_y stretches +z: the axis bends toward −z.
let want = -kappa * x * x / 2.0;
let eu = (r.displacement[at(&r, mid)][2] - want).abs() / want.abs();
eprintln!("bending through a distributing coupling: σxx error {es:.2e} (of the peak), mid-span deflection error {eu:.2e}");
assert!(es < 5e-3 && eu < 5e-3, "{es} {eu}");
for k in 0..3 {
assert!(r.applied_total[k].abs() < 1e-12 && r.reaction_total[k].abs() < 1e-9, "a moment adds no net force");
}
}
/// A tube of hex20 elements in (r, θ, z), the seam at θ = 0 shared.
fn tube(nr: usize, nt: usize, nz: usize, ri: f64, ro: f64, length: f64) -> Grid {
let mut g = grid([nr, nt, nz], ElementKind::Hex20, |q| {
let (r, t) = (ri + (ro - ri) * q[0], 2.0 * PI * q[1]);
[r * t.cos(), r * t.sin(), length * q[2]]
});
// Merge the seam: nodes at θ = 2π take the labels of those at θ = 0.
let seam: Vec<(u32, u32)> = g
.param
.iter()
.filter(|(_, p)| near(p[1], 1.0))
.map(|(l, p)| (*l, *g.param.iter().find(|(_, o)| near(o[1], 0.0) && near(o[0], p[0]) && near(o[2], p[2])).unwrap().0))
.collect();
let elements: Vec<(u32, modelcraft_model::Element)> = g.model.elements().iter().map(|(l, e)| (*l, e.clone())).collect();
for (label, e) in elements {
let nodes: Vec<u32> = e.nodes.iter().map(|n| seam.iter().find(|(a, _)| a == n).map_or(*n, |(_, b)| *b)).collect();
g.model.set_element(label, e.kind, nodes).unwrap();
}
for (old, _) in &seam {
g.model.remove_node(*old).unwrap();
g.param.remove(old);
}
g
}
/// Torsion of a tube by a moment on a reference point kinematically coupled to its end face, the
/// other end clamped: twist φ = T L / (G J), shear stress τ = T r / J, J = π (r_o⁴ − r_i⁴) / 2
/// (circular sections do not warp, so the clamp and the rigid end are exact).
#[test]
fn torsion_of_a_tube_through_a_reference_point() {
let (ri, ro, length, young, nu, torque) = (0.6f64, 1.0f64, 4.0, 1000.0, 0.3, 5.0);
let shear = young / (2.0 * (1.0 + nu));
let j = PI / 2.0 * (ro.powi(4) - ri.powi(4));
let mut g = tube(2, 16, 4, ri, ro, length);
g.define(iso(young, nu), None, full(), Procedure::Static {});
g.fixed("root", |q| near(q[2], 0.0));
g.couple("Tip", [0.0, 0.0, length], CouplingKind::Kinematic, &ALL, |q| near(q[2], 1.0));
g.load("twist", moment(rp("Tip"), [0.0, 0.0, torque]));
let r = solve(&g);
let phi = r.reference_points[0].displacement[5];
let want = torque * length / (shear * j);
let e_phi = (phi - want).abs() / want;
// Shear stress on the outer surface at mid-length, τ_θz = −sin θ σxz + cos θ σyz.
let outer = g.nodes_where(|q| near(q[0], 1.0) && near(q[2], 0.5));
let tau = outer
.iter()
.map(|n| {
let x = g.model.node(*n).unwrap().xyz;
let t = x[1].atan2(x[0]);
let s = r.stress[at(&r, *n)];
-t.sin() * s[4] + t.cos() * s[5]
})
.sum::<f64>()
/ outer.len() as f64;
let e_tau = (tau - torque * ro / j).abs() / (torque * ro / j);
eprintln!("torsion: twist {phi:.6e} vs {want:.6e} ({:.3}%), τ {tau:.5} vs {:.5} ({:.3}%)", 100.0 * e_phi, torque * ro / j, 100.0 * e_tau);
assert!(e_phi < 1e-3 && e_tau < 5e-3, "{e_phi} {e_tau}");
// The rotation is about the axis only; the reaction moment at the root balances the torque
// (reaction forces sum to zero).
let p = &r.reference_points[0];
assert!(p.displacement[..5].iter().all(|v| v.abs() < 1e-9 * phi), "{:?}", p.displacement);
assert!(r.reaction_total.iter().all(|v| v.abs() < 1e-9));
}
#[test]
fn coupled_results_are_deterministic() {
let build = || {
let mut g = grid([4, 2, 2], ElementKind::Tet10, |q| [4.0 * q[0], q[1], q[2]]);
g.define(iso(1000.0, 0.3), None, full(), Procedure::Static {});
g.couple("Root", [0.0, 0.5, 0.5], CouplingKind::Kinematic, &ALL, |q| near(q[0], 0.0));
g.bc("clamp", BoundaryCondition::Fixed { target: rp("Root") });
g.couple("Tip", [4.5, 0.5, 0.5], CouplingKind::Distributing, &ALL, |q| near(q[0], 1.0));
g.load("f", force(rp("Tip"), [0.0, 1.0, -2.0]));
g.load("m", moment(rp("Tip"), [0.5, 0.0, 0.0]));
g
};
let (a, b) = (solve(&build()), solve(&build()));
assert_eq!(a, b);
// Balance: forces and the reactions at the clamped point.
for k in 0..3 {
assert!((a.applied_total[k] + a.reaction_total[k]).abs() < 1e-9, "{:?} {:?}", a.applied_total, a.reaction_total);
}
}
#[test]
fn coupling_errors_name_the_problem() {
let base = || {
let mut g = grid([2, 1, 1], ElementKind::Hex8, |q| [2.0 * q[0], q[1], q[2]]);
g.define(iso(1000.0, 0.3), None, full(), Procedure::Static {});
g.fixed("root", |q| near(q[0], 0.0));
g
};
let err = |g: &Grid| solve_static(&g.model, Some("Step"), &Limits::default()).unwrap_err().to_string();
// A moment on a point whose distributing coupling ties translations only.
let mut g = base();
g.couple("Tip", [2.0, 0.5, 0.5], CouplingKind::Distributing, &[1, 2, 3], |q| near(q[0], 1.0));
g.load("m", moment(rp("Tip"), [1.0, 0.0, 0.0]));
assert!(err(&g).contains("rotation about X is not coupled"), "{}", err(&g));
let loose = |g: &mut Grid| g.model.add_reference_point("Loose", modelcraft_model::ReferencePoint { xyz: [3.0, 0.0, 0.0], label: 0 }).unwrap();
// A force on a reference point nothing couples.
let mut g = base();
loose(&mut g);
g.couple("Tip", [2.0, 0.5, 0.5], CouplingKind::Kinematic, &ALL, |q| near(q[0], 1.0));
g.load("f", force(rp("Loose"), [1.0, 0.0, 0.0]));
assert!(err(&g).contains("no coupling ties"), "{}", err(&g));
// Rotations through nodes on one line.
let mut g = base();
g.couple("Edge", [2.0, 0.0, 0.5], CouplingKind::Distributing, &ALL, |q| near(q[0], 1.0) && near(q[1], 0.0));
g.load("f", force(rp("Edge"), [1.0, 0.0, 0.0]));
assert!(err(&g).contains("one line"), "{}", err(&g));
// Only rotations coupled kinematically.
let mut g = base();
g.couple("Tip", [2.0, 0.5, 0.5], CouplingKind::Kinematic, &[4, 5, 6], |q| near(q[0], 1.0));
assert!(err(&g).contains("only rotations"), "{}", err(&g));
// A rigid face whose nodes boundary conditions pull apart.
let mut g = grid([2, 1, 1], ElementKind::Hex8, |q| [2.0 * q[0], q[1], q[2]]);
g.define(iso(1000.0, 0.3), None, full(), Procedure::Static {});
g.couple("Root", [0.0, 0.5, 0.5], CouplingKind::Kinematic, &ALL, |q| near(q[0], 0.0));
for (name, y, v) in [("a", 0.0, 0.1), ("b", 1.0, 0.0)] {
g.node_set(name, |q| near(q[0], 0.0) && near(q[1], y) && near(q[2], 0.0));
g.bc(name, BoundaryCondition::Displacement { target: Target::Set(name.into()), values: [None, Some(v), None, None, None, None] });
}
assert!(err(&g).contains("contradicts"), "{}", err(&g));
// A free reference point rotation: one coupled node gives the point's rotations no stiffness.
let mut g = base();
g.couple("One", [2.5, 0.5, 0.5], CouplingKind::Kinematic, &[1, 2, 3], |q| near(q[0], 1.0) && near(q[1], 1.0) && near(q[2], 1.0));
g.load("f", force(rp("One"), [1.0, 0.0, 0.0]));
assert!(err(&g).contains("reference point `One` in rotation"), "{}", err(&g));
// A boundary condition on an uncoupled point holds nothing and says so.
let mut g = base();
loose(&mut g);
g.bc("hold", BoundaryCondition::Fixed { target: rp("Loose") });
g.face_set("end", |q| near(q[0], 1.0));
g.load("p", Load::Pressure { set: "end".into(), magnitude: 1.0 });
let r = solve(&g);
assert!(r.warnings.iter().any(|w| w.contains("holds nothing")), "{:?}", r.warnings);
// Hostile geometry: a reference point far away still solves or fails cleanly.
for kind in [CouplingKind::Kinematic, CouplingKind::Distributing] {
let mut g = base();
g.couple("Far", [1e9, -1e9, 1e9], kind, &ALL, |q| near(q[0], 1.0));
g.load("m", moment(rp("Far"), [1.0, 1.0, 1.0]));
let _ = solve_static(&g.model, Some("Step"), &Limits::default());
}
}
/// A distributing coupling on a large face whose reference point is held ties every coupled
/// node to every other (a dense block of (3N)² entries): refused up front, before the reduced
/// matrix is built, with the size it would need. Any other reduction that would outgrow the
/// budget stops while counting, before allocating.
#[test]
fn oversized_reductions_are_refused_before_allocating() {
let mut g = grid([83, 83, 1], ElementKind::Hex8, |q| [83.0 * q[0], 83.0 * q[1], q[2]]);
g.define(iso(1000.0, 0.3), None, full(), Procedure::Static {});
g.couple("Base", [41.5, 41.5, 0.0], CouplingKind::Distributing, &ALL, |q| near(q[2], 0.0));
g.bc("hold", BoundaryCondition::Fixed { target: rp("Base") });
g.face_set("top", |q| near(q[2], 1.0));
g.load("p", Load::Pressure { set: "top".into(), magnitude: 1.0 });
let start = std::time::Instant::now();
let e = solve_static(&g.model, Some("Step"), &Limits::default()).unwrap_err().to_string();
eprintln!("refused in {:?}: {e}", start.elapsed());
assert!(e.contains("couples 7056 nodes") && e.contains("GB") && e.contains("kinematic coupling"), "{e}");
let mut g = grid([2, 1, 1], ElementKind::Hex8, |q| [2.0 * q[0], q[1], q[2]]);
g.define(iso(1000.0, 0.3), None, full(), Procedure::Static {});
g.couple("Root", [0.0, 0.5, 0.5], CouplingKind::Kinematic, &ALL, |q| near(q[0], 0.0));
g.bc("clamp", BoundaryCondition::Fixed { target: rp("Root") });
g.face_set("end", |q| near(q[0], 1.0));
g.load("p", Load::Pressure { set: "end".into(), magnitude: 1.0 });
let tight = Limits { max_factor_entries: 4, ..Limits::default() };
let e = solve_static(&g.model, Some("Step"), &tight).unwrap_err().to_string();
assert!(e.contains("reduced matrices need more than"), "{e}");
assert!(solve_static(&g.model, Some("Step"), &Limits::default()).is_ok());
}
+3 -2
View File
@@ -179,13 +179,14 @@ fn cantilever_tip_deflection_and_convergence() {
let meshes = [[5, 1, 1], [10, 2, 2], [20, 4, 4], [40, 8, 8]];
let hex8 = study(ElementKind::Hex8, full(), &meshes, "hex8");
let tet4 = study(ElementKind::Tet4, full(), &meshes, "tet4");
for (name, e) in [("hex8", &hex8), ("tet4", &tet4)] {
let wedge6 = study(ElementKind::Wedge6, full(), &meshes, "wedge6");
for (name, e) in [("hex8", &hex8), ("tet4", &tet4), ("wedge6", &wedge6)] {
let rates: Vec<f64> = e.windows(2).map(|w| (w[0] / w[1]).log2()).collect();
eprintln!(" {name} convergence rates {rates:.2?}");
assert!(e.windows(2).all(|w| w[1] < w[0]), "{name} converges monotonically");
assert!(*rates.last().unwrap() > 1.5, "{name} approaches second order: {rates:?}");
}
assert!(hex8[3] < 0.04 && tet4[3] < 0.12, "{hex8:?} {tet4:?}");
assert!(hex8[3] < 0.04 && tet4[3] < 0.12 && wedge6[3] < 0.04, "{hex8:?} {tet4:?} {wedge6:?}");
}
/// Lamé's thick cylinder under internal pressure, plane strain (u_z = 0 on both ends), quarter