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

+1 -1
View File
@@ -22,7 +22,7 @@ hex meshes today and it gives the best elements where stresses are read.
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`modelcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `modelcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `modelcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023); Delaunay refinement, ODT smoothing, sliver removal; tet4 and tet10 with mid-side nodes on the B-rep; mesh verify per set | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.modelcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Linear static and modal solver (tet4/10, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `modelcraft-fem` kernels, `modelcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests); deformed shape, contours, mode shapes, probes, reaction sums | started |
| M4 Own solver and first results | Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `modelcraft-fem` kernels, `modelcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion); deformed shape, contours, mode shapes, probes, reaction sums | started |
| M5 Structured hex | 2D quad meshing of faces (`modelcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions; sweep meshing; conforming interfaces and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M6 Automatic hex | Octree/grid-dual all-hex for any part; hex-dominant by tet recombination; polycube for blocky parts; smoothing, untangling, boundary padding, layers through thin walls; mesh editing and orphan-mesh import | planned |
| M7 Assemblies and contact | Part instances from SolidCraft occurrences; tie between non-matching meshes; general and pairwise contact with automatic pair detection, friction; bolt pretension, springs, point masses; amplitudes, multiple steps; nonlinear static steps in decks | planned |
+59 -18
View File
@@ -1,7 +1,7 @@
//! `solve.*`: run the model's steps with the implicit solver, keep the results in the session,
//! and read them back.
use modelcraft_solver::{FrequencyOptions, Limits, MassKind, StepResult, solve_frequency, solve_static};
use modelcraft_solver::{FrequencyOptions, Limits, Location, MassKind, StepResult, solve_frequency, solve_static};
use serde_json::{Value, json};
use super::{labels, opt};
@@ -26,7 +26,7 @@ pub const FREQUENCY: CommandSpec = CommandSpec {
pub const PROBE: CommandSpec = CommandSpec {
id: "solve.probe",
label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1} — the stored result at nodes: displacement, reaction, stress and von Mises (static), or the shape of mode `mode` (frequency, from 1)"#,
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1} — the stored result at nodes: displacement, reaction, stress and von Mises (static), or the shape of mode `mode` (frequency, from 1); a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments)"#,
kind: CommandKind::Model,
run: probe,
};
@@ -75,6 +75,12 @@ fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
"max_von_mises": largest(r.nodes.iter().zip(&r.von_mises).map(|(l, v)| (*l, *v))),
"applied_total": r.applied_total,
"reaction_total": r.reaction_total,
"reference_points": r.reference_points.iter().map(|p| json!({
"name": p.name,
"label": p.label,
"displacement": p.displacement,
"reaction": p.reaction,
})).collect::<Vec<_>>(),
"warnings": r.warnings,
});
s.store_result(StepResult::Static(r));
@@ -109,21 +115,28 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
let step = opt::<String>(p, "step")?.ok_or_else(|| EngineError::Params("`step` is required".into()))?;
let result = s.result(&step).ok_or_else(|| EngineError::Params(format!("step `{step}` has no result; solve it first")))?;
let nodes = labels(p, "nodes", MAX_PROBE)?;
let index = |label: u32| {
result.nodes().binary_search(&label).map_err(|_| EngineError::Params(format!("node {label} has no result (it belongs to no solved element)")))
let locate = |label: u32| {
result
.locate(label)
.ok_or_else(|| EngineError::Params(format!("node {label} has no result (it belongs to no solved element or coupled reference point)")))
};
let out: Vec<Value> = match result.as_ref() {
StepResult::Static(r) => nodes
.iter()
.map(|&l| {
let i = index(l)?;
Ok(json!({
"node": l,
"displacement": r.displacement.get(i),
"reaction": r.reaction.get(i),
"stress": r.stress.get(i),
"von_mises": r.von_mises.get(i),
}))
Ok(match locate(l)? {
Location::Point(k) => {
let p = r.reference_points.get(k);
json!({"node": l, "reference_point": p.map(|p| &p.name), "displacement": p.map(|p| p.displacement), "reaction": p.map(|p| p.reaction)})
}
Location::Node(i) => json!({
"node": l,
"displacement": r.displacement.get(i),
"reaction": r.reaction.get(i),
"stress": r.stress.get(i),
"von_mises": r.von_mises.get(i),
}),
})
})
.collect::<Result<_>>()?,
StepResult::Frequency(r) => {
@@ -132,7 +145,17 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
.checked_sub(1)
.and_then(|k| r.modes.get(k))
.ok_or_else(|| EngineError::Params(format!("`mode` must be from 1 to {}", r.modes.len())))?;
nodes.iter().map(|&l| Ok(json!({"node": l, "shape": m.shape.get(index(l)?)}))).collect::<Result<_>>()?
nodes
.iter()
.map(|&l| {
Ok(match locate(l)? {
Location::Point(k) => {
json!({"node": l, "reference_point": r.reference_points.get(k).map(|p| &p.0), "shape": m.point_shape.get(k)})
}
Location::Node(i) => json!({"node": l, "shape": m.shape.get(i)}),
})
})
.collect::<Result<_>>()?
}
};
Ok(json!({"step": step, "nodes": out}))
@@ -259,10 +282,28 @@ pub(crate) mod tests {
}
}
}
// An unsupported model (a coupling, a reference point load) is an error, not a panic.
let mut defined = Session::default();
crate::cmd::define::tests::defined_bar(&mut defined);
let e = defined.execute("solve.static", &json!({"step": "Bend"})).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
}
#[test]
fn solve_and_probe_a_coupled_reference_point() {
// The defined bar: a force on a reference point kinematically coupled to the free end.
let mut s = Session::default();
crate::cmd::define::tests::defined_bar(&mut s);
let r = s.execute("solve.static", &json!({"step": "Bend"})).unwrap();
let rp = &r["reference_points"][0];
assert_eq!(rp["name"], "RP", "{r}");
assert!(rp["displacement"][2].as_f64().unwrap() < 0.0, "{r}");
assert!((r["reaction_total"][2].as_f64().unwrap() - 100.0).abs() < 1e-6, "{r}");
let label = rp["label"].as_u64().unwrap();
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [label, 1]})).unwrap();
assert_eq!(probe["nodes"][0]["reference_point"], "RP", "{probe}");
assert_eq!(probe["nodes"][0]["displacement"].as_array().unwrap().len(), 6, "{probe}");
assert!(probe["nodes"][1]["reaction"].is_array(), "{probe}");
s.execute("solve.frequency", &json!({"step": "Modes"})).unwrap();
let shape = s.execute("solve.probe", &json!({"step": "Modes", "nodes": [label], "mode": 1})).unwrap();
assert_eq!(shape["nodes"][0]["shape"].as_array().unwrap().len(), 6, "{shape}");
for p in [json!({"step": "Bend", "nodes": [label, label, 0]}), json!({"step": "Modes", "nodes": [label], "mode": 99})] {
assert!(s.execute("solve.probe", &p).is_err(), "{p}");
}
}
}
+17 -3
View File
@@ -7,7 +7,8 @@
//!
//! Formulations:
//! - **full integration**: the rule that integrates the stiffness of an undistorted element
//! exactly (1 point for tet4, 4 for tet10, 2 × 2 × 2 for hex8, 3 × 3 × 3 for hex20);
//! exactly (1 point for tet4, 4 for tet10, 3 triangle points × 2 for wedge6, 2 × 2 × 2 for
//! hex8, 3 × 3 × 3 for hex20);
//! - **reduced integration** for hex20 (2 × 2 × 2), which avoids locking at the cost of one
//! spurious mode that a mesh of more than one element restrains. Reduced hex8 needs hourglass
//! control and is not offered here yet (it arrives with the explicit solver, Flanagan &
@@ -43,6 +44,7 @@ use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Family {
Tetrahedron,
Wedge,
Hexahedron,
}
@@ -67,7 +69,7 @@ pub struct ElementType {
}
/// The element table.
pub static ELEMENT_TYPES: [ElementType; 4] = [
pub static ELEMENT_TYPES: [ElementType; 5] = [
ElementType {
kind: ElementKind::Tet4,
family: Family::Tetrahedron,
@@ -90,6 +92,17 @@ pub static ELEMENT_TYPES: [ElementType; 4] = [
mass: Rule::TetConical(4),
incompatible_modes: false,
},
ElementType {
kind: ElementKind::Wedge6,
family: Family::Wedge,
nodes: 6,
corners: 6,
shape: shape::wedge6,
full: Rule::Wedge,
reduced: None,
mass: Rule::Wedge,
incompatible_modes: false,
},
ElementType {
kind: ElementKind::Hex8,
family: Family::Hexahedron,
@@ -119,7 +132,7 @@ pub fn element_type(kind: ElementKind) -> Result<&'static ElementType> {
ELEMENT_TYPES
.iter()
.find(|t| t.kind == kind)
.ok_or_else(|| Error::NotSupported(format!("{} elements (the solver has tet4, tet10, hex8 and hex20 so far)", kind.name())))
.ok_or_else(|| Error::NotSupported(format!("{} elements (the solver has tet4, tet10, wedge6, hex8 and hex20 so far)", kind.name())))
}
/// How a solid element is formulated.
@@ -565,6 +578,7 @@ pub fn extrapolation(ty: &ElementType, rule: Rule) -> Result<Extrapolation> {
let c = ty.corners;
let corner_basis = |xi: [f64; 3]| match ty.family {
Family::Tetrahedron => shape::tet4(xi),
Family::Wedge => shape::wedge6(xi),
Family::Hexahedron => shape::hex8(xi),
};
let mut corner_rows = vec![0.0; c * g];
+1 -1
View File
@@ -4,7 +4,7 @@
//! global state, so the implicit solver (`modelcraft-solver`) and the explicit solver that follows
//! it use the same element technology:
//! - [`quadrature`]: Gauss–Legendre and tetrahedron rules;
//! - [`shape`]: shape functions of tet4, tet10, hex8, hex20 and of their faces;
//! - [`shape`]: shape functions of tet4, tet10, wedge6, hex8, hex20;
//! - [`material`]: isotropic and orthotropic elasticity matrices, von Mises stress;
//! - [`element`]: the element table, stiffness (full, reduced, incompatible modes), consistent
//! and lumped mass, body forces, integration-point stresses and their extrapolation to nodes;
+22 -1
View File
@@ -12,6 +12,9 @@
//! - On the reference triangle {ξ, η ≥ 0, ξ + η ≤ 1}: the same collapsed product in two
//! dimensions (Jacobian 1 − u), exact for degree 2n − 2.
//! - On the reference square [−1, 1]²: tensor Gauss–Legendre.
//! - On the reference wedge (triangle × [−1, 1]): the symmetric three-point triangle rule
//! (points at area coordinates (⅔, ⅙, ⅙) and permutations, degree 2; Zienkiewicz, Taylor &
//! Zhu 2013, table 6.3) times two-point Gauss–Legendre in ζ (degree 3).
/// A quadrature point: natural coordinates and weight.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -54,6 +57,9 @@ pub enum Rule {
Quad(u8),
/// The collapsed product on the reference triangle, n points per direction (z = 0).
TriConical(u8),
/// Three triangle points times two Gauss points in ζ on the reference wedge: point k of each
/// layer sits near corner k, the lower layer first.
Wedge,
}
impl Rule {
@@ -103,6 +109,11 @@ impl Rule {
.flat_map(|&(u, wu)| g.iter().map(move |&(v, wv)| Point { xi: [u, v * (1.0 - u), 0.0], weight: wu * wv * (1.0 - u) }))
.collect()
}
Rule::Wedge => {
let (a, b) = (2.0 / 3.0, 1.0 / 6.0);
let tri = [[b, b], [a, b], [b, a]];
gauss_legendre(2).iter().flat_map(|&(z, wz)| tri.iter().map(move |t| Point { xi: [t[0], t[1], z], weight: wz / 6.0 })).collect()
}
}
}
}
@@ -149,8 +160,18 @@ mod tests {
assert!((got - exact_1d * 2.0).abs() < 1e-14, "quad {n} y^{a}");
}
}
// Triangle: ∫ ξ^a η^b = a! b! / (a + b + 2)!.
// Wedge: ∫ ξ^a η^b ζ^c = a! b! / (a + b + 2)! · ∫ ζ^c, for a + b ≤ 2 and c ≤ 3.
let f = |n: i32| (1..=n).map(f64::from).product::<f64>();
for a in 0..=2 {
for b in 0..=2 - a {
for c in 0..=3 {
let line = if c % 2 == 1 { 0.0 } else { 2.0 / f64::from(c + 1) };
let got = integrate(Rule::Wedge, a, b, c);
assert!((got - f(a) * f(b) / f(a + b + 2) * line).abs() < 1e-15, "wedge ξ^{a} η^{b} ζ^{c}");
}
}
}
// Triangle: ∫ ξ^a η^b = a! b! / (a + b + 2)!.
for a in 0..=4 {
for b in 0..=4 - a {
let got = integrate(Rule::TriConical(3), a, b, 0);
+24 -2
View File
@@ -9,7 +9,11 @@
//! N = ⅛(1 + ξξᵢ)(1 + ηηᵢ)(1 + ζζᵢ)(ξξᵢ + ηηᵢ + ζζᵢ − 2), a mid-side node with ξᵢ = 0
//! N = ¼(1 − ξ²)(1 + ηηᵢ)(1 + ζζᵢ), and likewise), mid-side nodes 9–12 on the bottom edges,
//! 13–16 on the top edges, 17–20 on the vertical edges (mid-side positions come from
//! [`modelcraft_model::ElementKind::edges`], the one place the node order is written down).
//! [`modelcraft_model::ElementKind::edges`], the one place the node order is written down);
//! - the six-node wedge (pentahedron) as the product of the linear triangle {ξ, η ≥ 0,
//! ξ + η ≤ 1} (area coordinates L₁ = 1 − ξ − η, L₂ = ξ, L₃ = η) and the linear interval
//! ζ ∈ [−1, 1]: nodes 1–3 at ζ = −1 counter-clockwise seen from above, nodes 4–6 above them,
//! Nᵢ = Lᵢ (1 − ζ)/2 and Nᵢ₊₃ = Lᵢ (1 + ζ)/2.
//!
//! The functions are the textbook ones (O. C. Zienkiewicz, R. L. Taylor, J. Z. Zhu, *The Finite
//! Element Method: Its Basis and Fundamentals*, 7th ed., Butterworth-Heinemann 2013, ch. 6;
@@ -47,6 +51,9 @@ impl Shape {
pub const HEX_CORNERS: [[f64; 3]; 8] =
[[-1., -1., -1.], [1., -1., -1.], [1., 1., -1.], [-1., 1., -1.], [-1., -1., 1.], [1., -1., 1.], [1., 1., 1.], [-1., 1., 1.]];
/// Natural coordinates of the wedge's corners.
pub const WEDGE_CORNERS: [[f64; 3]; 6] = [[0., 0., -1.], [1., 0., -1.], [0., 1., -1.], [0., 0., 1.], [1., 0., 1.], [0., 1., 1.]];
/// Natural coordinates of the tetrahedron's corners.
pub const TET_CORNERS: [[f64; 3]; 4] = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]];
@@ -95,6 +102,20 @@ pub fn hex8(xi: [f64; 3]) -> Shape {
s
}
/// Six-node wedge: the linear triangle times the linear interval.
pub fn wedge6(xi: [f64; 3]) -> Shape {
let mut s = Shape::new(6);
let l = [1.0 - xi[0] - xi[1], xi[0], xi[1]];
let dl = [[-1.0, -1.0], [1.0, 0.0], [0.0, 1.0]];
for (h, f, df) in [(0, (1.0 - xi[2]) / 2.0, -0.5), (3, (1.0 + xi[2]) / 2.0, 0.5)] {
for i in 0..3 {
s.n[h + i] = l[i] * f;
s.dn[h + i] = [dl[i][0] * f, dl[i][1] * f, l[i] * df];
}
}
s
}
/// Twenty-node serendipity hexahedron.
pub fn hex20(xi: [f64; 3]) -> Shape {
let mut s = Shape::new(20);
@@ -147,6 +168,7 @@ mod tests {
v
}
"hex8" => HEX_CORNERS.to_vec(),
"wedge6" => WEDGE_CORNERS.to_vec(),
_ => {
let mut v = HEX_CORNERS.to_vec();
for e in ElementKind::Hex20.edges() {
@@ -159,7 +181,7 @@ mod tests {
#[test]
fn kronecker_delta_partition_of_unity_and_derivatives() {
let kinds: [(&str, fn([f64; 3]) -> Shape); 4] = [("tet4", tet4), ("tet10", tet10), ("hex8", hex8), ("hex20", hex20)];
let kinds: [(&str, fn([f64; 3]) -> Shape); 5] = [("tet4", tet4), ("tet10", tet10), ("wedge6", wedge6), ("hex8", hex8), ("hex20", hex20)];
for (name, f) in kinds {
let nodes = nodes_of(name);
for (i, p) in nodes.iter().enumerate() {
+6 -2
View File
@@ -12,6 +12,7 @@ use crate::material::{isotropic, orthotropic, stress};
fn distorted(kind: ElementKind, curved: bool) -> Vec<[f64; 3]> {
let corners: Vec<[f64; 3]> = match kind {
ElementKind::Tet4 | ElementKind::Tet10 => vec![[0.1, -0.05, 0.0], [1.2, 0.1, 0.05], [0.2, 0.9, -0.1], [0.05, 0.15, 1.1]],
ElementKind::Wedge6 => vec![[0.0, 0.0, 0.0], [2.1, 0.1, -0.1], [0.2, 1.3, 0.05], [0.1, -0.1, 0.9], [2.0, 0.05, 1.1], [0.15, 1.2, 1.0]],
_ => vec![
[0.0, 0.0, 0.0],
[2.1, 0.1, -0.1],
@@ -33,9 +34,10 @@ fn distorted(kind: ElementKind, curved: bool) -> Vec<[f64; 3]> {
xyz
}
const CASES: [(ElementKind, Formulation); 6] = [
const CASES: [(ElementKind, Formulation); 7] = [
(ElementKind::Tet4, Formulation::Full),
(ElementKind::Tet10, Formulation::Full),
(ElementKind::Wedge6, Formulation::Full),
(ElementKind::Hex8, Formulation::Full),
(ElementKind::Hex8, Formulation::IncompatibleModes),
(ElementKind::Hex20, Formulation::Full),
@@ -145,6 +147,7 @@ fn extrapolation_reproduces_linear_fields_on_affine_elements() {
let affine = |kind: ElementKind| -> Vec<[f64; 3]> {
let mut xyz: Vec<[f64; 3]> = match kind {
ElementKind::Tet10 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]],
ElementKind::Wedge6 => vec![[0., 0., 0.], [2., 0., 0.], [0.5, 1., 0.], [0., 0.2, 1.], [2., 0.2, 1.], [0.5, 1.2, 1.]],
_ => vec![[0., 0., 0.], [2., 0., 0.], [2.5, 1., 0.], [0.5, 1., 0.], [0., 0., 1.], [2., 0., 1.], [2.5, 1., 1.], [0.5, 1., 1.]],
};
let c = xyz.clone();
@@ -172,7 +175,8 @@ fn bad_input_is_an_error() {
assert!(Solid::new(ElementKind::Hex8, &xyz[..7], Formulation::Full).is_err());
assert!(Solid::new(ElementKind::Hex8, &xyz, Formulation::Reduced).is_err());
assert!(Solid::new(ElementKind::Tet4, &xyz[..4], Formulation::IncompatibleModes).is_err());
assert!(Solid::new(ElementKind::Wedge6, &xyz[..6], Formulation::Full).is_err());
assert!(Solid::new(ElementKind::Quad4, &xyz[..4], Formulation::Full).is_err());
assert!(Solid::new(ElementKind::Wedge6, &xyz[..6], Formulation::IncompatibleModes).is_err());
let mut nan = xyz.clone();
nan[3][1] = f64::NAN;
assert!(Solid::new(ElementKind::Hex8, &nan, Formulation::Full).is_err());
+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)
}
}
+651
View File
@@ -0,0 +1,651 @@
//! Couplings of reference points to the mesh, and the elimination of every linear constraint
//! (couplings and prescribed values) from the system.
//!
//! A coupled reference point is a node with six degrees of freedom (three translations, three
//! rotations) after the mesh's translational ones. Each coupling becomes linear constraint rows
//! Σ cⱼ uⱼ = 0:
//!
//! - **Kinematic** (a rigid link): every node s of the set follows the reference point r rigidly
//! in the coupled translations, u_s = u_r + θ_r × (x_s − x_r) (small rotations; R. D. Cook,
//! D. S. Malkus, M. E. Plesha, R. J. Witt, *Concepts and Applications of Finite Element
//! Analysis*, 4th ed., Wiley 2002, §9.2). The reference point's rotations take part through the
//! lever arms, so a moment on it reaches the face as a couple even when only translations are
//! coupled; coupled rotations have nothing to act on at solid nodes, which have none.
//! - **Distributing** (an interpolation constraint): the reference point moves with the rigid
//! motion that fits the set's nodal translations best in the least-squares sense, with equal
//! weights (as the model and the deck writers define it). About the nodes' centroid c, with
//! rᵢ = xᵢ − c, the fit is u_c = Σ uᵢ / N and θ = J⁻¹ Σ rᵢ × uᵢ with
//! J = Σ (|rᵢ|² I − rᵢ rᵢᵀ); the reference point's motion is θ_r = θ and
//! u_r = u_c + θ × (x_r − c). Only the coupled degrees of freedom are tied. By virtual work a
//! force or moment on the point is spread over the nodes as the forces whose resultant and
//! moment about the point equal it, weighted like the fit, and the region stays free to deform
//! (G. H. K. Heirman, W. Desmet, *Interface reduction of flexible bodies for efficient modeling
//! of body flexibility in multibody dynamics*, Multibody Syst. Dyn. 24(2):219–234, 2010, on
//! interpolation constraints). Nodes on one line cannot carry a rotation about that line: that
//! is an error when rotations are coupled, and their mean motion is used otherwise.
//!
//! **Elimination** (master–slave by transformation, Cook et al. 2002 §9.2; C. A. Felippa,
//! *Introduction to Finite Element Methods*, ch. 8, University of Colorado lecture notes): every
//! degree of freedom is free, prescribed (a boundary condition), dependent on others, or inactive
//! (a reference point direction no coupling ties). Rows are taken one at a time in a fixed order
//! (boundary conditions first, then couplings by name, nodes by label, directions in order). A
//! row is first rewritten in terms of the current free and prescribed degrees of freedom; one
//! free degree of freedom is then chosen to depend on the rest: the row's intended one (the
//! coupled node, or the distributing coupling's reference point) while it is free, otherwise the
//! largest coefficient, lowest index first (the automatic choice of J. F. Abel, M. S. Shephard,
//! *An algorithm for multipoint constraints in finite element analysis*, IJNME 14(3):464–467,
//! 1979). A row with no free degree of freedom left is redundant when the prescribed values
//! satisfy it and contradictory otherwise. Every dependent degree of freedom ends up as a
//! combination of free and prescribed ones, u = T_f q + T_p u_p, so the reduced system is
//! T_fᵀ K T_f q = T_fᵀ (f − K T_p u_p) and the reactions at prescribed degrees of freedom are
//! T_pᵀ (K u − f): no penalty numbers and no Lagrange multipliers, so the matrix stays symmetric
//! positive definite for the Cholesky factorization. With no coupling, T is the identity on the
//! free degrees of freedom and the result is the plain elimination of prescribed values, bit for
//! bit.
//!
//! **Memory.** A held distributing coupling point makes one coupled node depend on all the others,
//! a dense (3N)² block in the reduced matrix; its size is estimated from the rows and refused
//! against the factorization's memory budget before anything is built, and every other reduction
//! counts its entries (stopping at the budget) before it allocates them.
use std::collections::BTreeMap;
use modelcraft_fem::dense::inverse3;
use modelcraft_model::{CouplingKind, Model, Resolved};
use crate::sparse::Csr;
use crate::system::Mesh;
use crate::{Error, Result};
/// One linear constraint Σ cⱼ uⱼ = 0 and the degree of freedom meant to be eliminated with it.
#[derive(Debug, Clone)]
pub struct Row<'m> {
pub terms: Vec<(usize, f64)>,
pub pivot: usize,
/// The name of the coupling it comes from (for messages).
pub coupling: &'m str,
}
/// A coefficient below this fraction of a row's largest one is round-off, not a constraint.
const NEGLIGIBLE: f64 = 1e-10;
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
/// [a]×, the matrix of a × ·.
fn cross_matrix(a: [f64; 3]) -> [[f64; 3]; 3] {
[[0.0, -a[2], a[1]], [a[2], 0.0, -a[0]], [-a[1], a[0], 0.0]]
}
fn mat_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut c = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
c[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
c
}
/// The constraint rows of every coupling: couplings by name, nodes by label, directions in order.
pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) -> Result<Vec<Row<'m>>> {
let mut rows = Vec::new();
for (name, c) in model.couplings() {
let point = mesh.point(&c.reference_point).ok_or_else(|| Error::Internal(format!("reference point `{}`", c.reference_point)))?;
let xr = mesh.points.get(point).map(|p| p.xyz).ok_or_else(|| Error::Internal("reference point index".into()))?;
let rp = mesh.point_dof(point, 0);
let set = resolved.sets.get(&c.set).ok_or_else(|| Error::Invalid(format!("coupling `{name}`: set `{}` is not resolved", c.set)))?;
if set.nodes.is_empty() {
return Err(Error::Invalid(format!("coupling `{name}`: set `{}` has no nodes", c.set)));
}
let nodes: Vec<usize> = set
.nodes
.iter()
.map(|l| mesh.node(*l).ok_or_else(|| Error::Invalid(format!("coupling `{name}`: node {l} belongs to no solid element"))))
.collect::<Result<_>>()?;
let xyz = |n: usize| mesh.xyz.get(n).copied().unwrap_or_default();
// The coupled directions of the reference point, 0–5.
let coupled = |range: std::ops::Range<usize>| range.filter(|k| c.dofs.contains(*k as u8 + 1));
match c.kind {
CouplingKind::Kinematic => {
if coupled(0..3).next().is_none() {
return Err(Error::Invalid(format!(
"coupling `{name}` couples only rotations, which the nodes of solid elements do not have; couple translations too"
)));
}
for &n in &nodes {
let r = sub(xyz(n), xr);
for k in coupled(0..3) {
// u_s,k − u_r,k − (θ × r)_k = 0, (θ × r)_k = θ_a r_b − θ_b r_a.
let (a, b) = ((k + 1) % 3, (k + 2) % 3);
let mut terms = vec![(3 * n + k, 1.0), (rp + k, -1.0)];
if r[b] != 0.0 {
terms.push((rp + 3 + a, -r[b]));
}
if r[a] != 0.0 {
terms.push((rp + 3 + b, r[a]));
}
rows.push(Row { terms, pivot: 3 * n + k, coupling: name });
}
}
}
CouplingKind::Distributing => {
let count = nodes.len() as f64;
let mut centroid = [0.0; 3];
for &n in &nodes {
let x = xyz(n);
for k in 0..3 {
centroid[k] += x[k] / count;
}
}
let arms: Vec<[f64; 3]> = nodes.iter().map(|n| sub(xyz(*n), centroid)).collect();
let mut j = [[0.0; 3]; 3];
for r in &arms {
let rr = r[0] * r[0] + r[1] * r[1] + r[2] * r[2];
for a in 0..3 {
for b in 0..3 {
j[a][b] += if a == b { rr } else { 0.0 } - r[a] * r[b];
}
}
}
let trace = j[0][0] + j[1][1] + j[2][2];
let inverse = inverse3(&j).filter(|(_, det)| *det > 1e-10 * trace.powi(3) / 27.0).map(|(inv, _)| inv);
if inverse.is_none() && coupled(3..6).next().is_some() {
return Err(Error::Invalid(format!(
"coupling `{name}`: the nodes of set `{}` lie on one line (or at one point), so a distributing coupling cannot carry rotations through them; couple translations only or use a kinematic coupling",
c.set
)));
}
// θ = Σ Gᵢ uᵢ with Gᵢ = J⁻¹ [rᵢ]×; u_r = Σ (I/N − [d]× Gᵢ) uᵢ with d = x_r − c.
let d = cross_matrix(sub(xr, centroid));
let g: Vec<[[f64; 3]; 3]> = arms.iter().map(|r| inverse.map_or([[0.0; 3]; 3], |inv| mat_mul(&inv, &cross_matrix(*r)))).collect();
for k in coupled(0..6) {
let mut terms = Vec::with_capacity(1 + 3 * nodes.len());
terms.push((rp + k, 1.0));
for (&n, gi) in nodes.iter().zip(&g) {
for comp in 0..3 {
let a = if k < 3 {
let dg: f64 = (0..3).map(|m| d[k][m] * gi[m][comp]).sum();
if k == comp { 1.0 / count - dg } else { -dg }
} else {
gi[k - 3][comp]
};
if a != 0.0 {
terms.push((3 * n + comp, -a));
}
}
}
rows.push(Row { terms, pivot: rp + k, coupling: name });
}
}
}
}
Ok(rows)
}
/// The degrees of freedom that exist: every mesh translation, and the reference point
/// directions some coupling row involves.
fn active_dofs(mesh: &Mesh, rows: &[Row]) -> Vec<bool> {
let mut active = vec![false; mesh.dofs()];
active.iter_mut().take(mesh.mesh_dofs()).for_each(|a| *a = true);
for row in rows {
for (dof, c) in &row.terms {
if *c != 0.0
&& let Some(a) = active.get_mut(*dof)
{
*a = true;
}
}
}
active
}
/// A sparse list of (index, coefficient) per row.
#[derive(Debug, Clone, Default)]
struct Terms {
ptr: Vec<usize>,
idx: Vec<usize>,
val: Vec<f64>,
}
impl Terms {
fn row(&self, r: usize) -> impl Iterator<Item = (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().iter().copied().zip(self.val.get(a..b).unwrap_or_default().iter().copied())
}
fn is_empty_row(&self, r: usize) -> bool {
self.ptr.get(r) == self.ptr.get(r + 1)
}
fn push_row(&mut self, terms: impl Iterator<Item = (usize, f64)>) {
if self.ptr.is_empty() {
self.ptr.push(0);
}
for (i, v) in terms {
self.idx.push(i);
self.val.push(v);
}
self.ptr.push(self.idx.len());
}
/// The transpose, with `n` rows.
fn transpose(&self, n: usize) -> Terms {
let rows = self.ptr.len().saturating_sub(1);
let mut count = vec![0usize; n + 1];
for i in &self.idx {
if let Some(c) = count.get_mut(i + 1) {
*c += 1;
}
}
for i in 0..n {
count[i + 1] += count[i];
}
let mut next = count.clone();
let mut idx = vec![0; self.idx.len()];
let mut val = vec![0.0; self.idx.len()];
for r in 0..rows {
for (i, v) in self.row(r) {
if let Some(slot) = next.get_mut(i)
&& let (Some(x), Some(y)) = (idx.get_mut(*slot), val.get_mut(*slot))
{
*x = r;
*y = v;
*slot += 1;
}
}
}
Terms { ptr: count, idx, val }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
Free,
Fixed,
Dependent(usize),
Inactive,
}
/// Sort terms by degree of freedom (stably, so equal ones add in the order given) and add the
/// equal ones, dropping exact zeros.
fn combine(mut terms: Vec<(usize, f64)>) -> Vec<(usize, f64)> {
terms.sort_by_key(|t| t.0);
let mut out: Vec<(usize, f64)> = Vec::with_capacity(terms.len());
for (dof, c) in terms {
match out.last_mut() {
Some(last) if last.0 == dof => last.1 += c,
_ => out.push((dof, c)),
}
}
out.retain(|t| t.1 != 0.0);
out
}
/// The map from the free equations and prescribed values to every degree of freedom.
#[derive(Debug, Clone)]
pub struct Reduction {
/// The degree of freedom of each free equation.
pub eq_dof: Vec<usize>,
/// Whether a degree of freedom exists (mesh translations, and the reference point
/// directions some coupling ties).
pub active: Vec<bool>,
/// T_f: per degree of freedom, (equation, coefficient).
free: Terms,
/// T_fᵀ: per equation, (degree of freedom, coefficient).
columns: Terms,
/// T_p: per degree of freedom, (prescribed degree of freedom, coefficient).
fixed: Terms,
/// T_p u_p, the motion of the prescribed values alone.
prescribed_motion: Vec<f64>,
/// Whether any degree of freedom depends on others (T is not a plain selection).
has_dependents: bool,
budget: Budget,
}
/// The memory the reduced matrices may take, checked before they are built: the same budget as
/// the factorization's (`Limits::max_factor_entries` doubles of 8 bytes).
#[derive(Debug, Clone, Copy)]
pub struct Budget {
pub max_bytes: usize,
/// Entries of the assembled mesh pattern.
pub pattern_entries: usize,
/// Matrices reduced together (stiffness, and mass for frequency steps).
pub matrices: usize,
}
impl Budget {
/// Bytes of `entries` reduced entries: one column index and one value per matrix.
fn bytes(&self, entries: usize) -> usize {
entries.saturating_mul(8 * (1 + self.matrices))
}
fn exceeded(&self, entries: usize) -> bool {
self.bytes(entries) > self.max_bytes
}
}
/// An empty vector with room for `n` elements, or a limit error when the memory is not there.
fn reserved<T>(n: usize) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(n).map_err(|_| Error::Limit(format!("cannot allocate {} for the reduced matrices", gigabytes(n.saturating_mul(8)))))?;
Ok(v)
}
fn gigabytes(bytes: usize) -> String {
format!("{:.2} GB", bytes as f64 / 1e9)
}
impl Reduction {
/// Eliminate the prescribed values (`prescribed(active)` gives them) and the constraint rows.
/// `order` is the mesh nodes' order for numbering the free equations; reference point
/// equations follow them.
///
/// A distributing coupling whose reference point is held makes one of its nodes depend on
/// all the others, which couples every coupled degree of freedom with every other: a dense
/// (3N)² block in the reduced matrix. Its size is checked against `budget` before anything is
/// built.
pub fn new(
mesh: &Mesh,
order: &[usize],
rows: &[Row],
prescribed: impl FnOnce(&[bool]) -> Result<Vec<Option<f64>>>,
budget: Budget,
) -> Result<Reduction> {
let n = mesh.dofs();
let active = active_dofs(mesh, rows);
let prescribed = prescribed(&active)?;
let mut state: Vec<State> = (0..n)
.map(|d| match (active.get(d).copied().unwrap_or(false), prescribed.get(d).copied().flatten()) {
(false, _) => State::Inactive,
(true, Some(_)) => State::Fixed,
(true, None) => State::Free,
})
.collect();
let values: Vec<f64> = (0..n).map(|d| prescribed.get(d).copied().flatten().unwrap_or(0.0)).collect();
// Dense blocks: the mesh degrees of freedom of the rows meant to eliminate a held
// reference point, one block per coupling.
let mut coupled: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
for row in rows {
if row.pivot >= mesh.mesh_dofs() && state.get(row.pivot) == Some(&State::Fixed) {
coupled.entry(row.coupling).or_default().extend(row.terms.iter().map(|t| t.0).filter(|d| *d < mesh.mesh_dofs()));
}
}
let mut blocks: Vec<(&str, usize, usize)> = Vec::new();
for (name, mut dofs) in coupled {
dofs.sort_unstable();
dofs.dedup();
let mut nodes: Vec<usize> = dofs.iter().map(|d| d / 3).collect();
nodes.dedup();
blocks.push((name, nodes.len(), dofs.len().saturating_mul(dofs.len())));
}
let dense: usize = blocks.iter().fold(0, |a, b| a.saturating_add(b.2));
if dense > 0 && budget.exceeded(budget.pattern_entries.saturating_add(dense)) {
let (name, nodes, _) = blocks.iter().max_by_key(|b| b.2).copied().unwrap_or(("?", 0, 0));
return Err(Error::Limit(format!(
"distributing coupling `{name}` couples {nodes} nodes and its reference point is held, which ties every coupled node to every other: the reduced stiffness needs about {} (the limit is {}); split the surface into smaller couplings, hold the surface itself, or use a kinematic coupling",
gigabytes(budget.bytes(budget.pattern_entries.saturating_add(dense))),
gigabytes(budget.max_bytes)
)));
}
// Dependent expressions in free and prescribed degrees of freedom, and for each free
// degree of freedom the expressions that use it (possibly listed twice).
let mut exprs: Vec<Vec<(usize, f64)>> = Vec::new();
let mut users: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for row in rows {
let scale = row.terms.iter().fold(0.0f64, |m, t| m.max(t.1.abs()));
if !(scale > 0.0 && scale.is_finite()) {
return Err(Error::Invalid(format!("coupling `{}`: a constraint with no finite coefficient", row.coupling)));
}
// The row in current free and prescribed degrees of freedom.
let mut expanded = Vec::with_capacity(row.terms.len());
for &(dof, c) in &row.terms {
match state.get(dof).copied() {
Some(State::Free | State::Fixed) => expanded.push((dof, c)),
Some(State::Dependent(e)) => expanded.extend(exprs.get(e).into_iter().flatten().map(|t| (t.0, c * t.1))),
Some(State::Inactive) | None => return Err(Error::Internal("a constraint on a missing degree of freedom".into())),
}
}
let terms = combine(expanded);
let candidate = |t: &&(usize, f64)| state.get(t.0) == Some(&State::Free) && t.1.abs() > NEGLIGIBLE * scale;
let intended = terms.iter().filter(candidate).find(|t| t.0 == row.pivot);
let largest = terms.iter().filter(candidate).fold(None, |best: Option<&(usize, f64)>, t| match best {
Some(b) if b.1.abs() >= t.1.abs() => Some(b),
_ => Some(t),
});
let Some(&(pivot, cp)) = intended.or(largest) else {
// Only prescribed degrees of freedom (or round-off) are left.
let value = |t: &(usize, f64)| t.1 * values.get(t.0).copied().unwrap_or(0.0);
let residual: f64 = terms.iter().filter(|t| state.get(t.0) == Some(&State::Fixed)).map(value).sum();
let size: f64 = terms.iter().map(|t| value(t).abs()).sum();
if residual.abs() > 1e-9 * size.max(f64::MIN_POSITIVE) && residual.abs() > 0.0 {
return Err(Error::Invalid(format!(
"coupling `{}` contradicts the prescribed values (boundary conditions or other couplings hold its nodes in a way it cannot follow)",
row.coupling
)));
}
continue;
};
let expr: Vec<(usize, f64)> = terms.iter().filter(|t| t.0 != pivot).map(|t| (t.0, -t.1 / cp)).collect();
let free_terms: Vec<usize> = expr.iter().filter(|t| state.get(t.0) == Some(&State::Free)).map(|t| t.0).collect();
// Substitute the pivot in the expressions that use it.
if let Some(mut using) = users.remove(&pivot) {
using.sort_unstable();
using.dedup();
for u in using {
let Some(e) = exprs.get_mut(u) else { continue };
let Ok(at) = e.binary_search_by_key(&pivot, |t| t.0) else { continue };
let a = e.remove(at).1;
let merged = combine(e.drain(..).chain(expr.iter().map(|t| (t.0, a * t.1))).collect());
*e = merged;
for &dof in &free_terms {
users.entry(dof).or_default().push(u);
}
}
}
let index = exprs.len();
for &dof in &free_terms {
users.entry(dof).or_default().push(index);
}
exprs.push(expr);
if let Some(s) = state.get_mut(pivot) {
*s = State::Dependent(index);
}
}
// Equations: free mesh degrees of freedom in node order, then reference point ones.
let mut eq = vec![usize::MAX; n];
let mut eq_dof = Vec::new();
let mesh_order = order.iter().flat_map(|node| (0..3).map(move |c| 3 * node + c));
for dof in mesh_order.chain(mesh.mesh_dofs()..n) {
if state.get(dof) == Some(&State::Free)
&& let Some(e) = eq.get_mut(dof)
&& *e == usize::MAX
{
*e = eq_dof.len();
eq_dof.push(dof);
}
}
if state.iter().enumerate().any(|(d, s)| *s == State::Free && eq.get(d) == Some(&usize::MAX)) {
return Err(Error::Internal("the node order misses a degree of freedom".into()));
}
let (mut free, mut fixed) = (Terms::default(), Terms::default());
for dof in 0..n {
match state.get(dof).copied().unwrap_or(State::Inactive) {
State::Free => {
free.push_row(std::iter::once((eq.get(dof).copied().unwrap_or(0), 1.0)));
fixed.push_row(std::iter::empty());
}
State::Fixed => {
free.push_row(std::iter::empty());
fixed.push_row(std::iter::once((dof, 1.0)));
}
State::Dependent(e) => {
let expr = exprs.get(e).map(Vec::as_slice).unwrap_or_default();
let state = &state;
let on = |want: State| expr.iter().filter(move |t| state.get(t.0) == Some(&want));
free.push_row(on(State::Free).map(|t| (eq.get(t.0).copied().unwrap_or(0), t.1)));
fixed.push_row(on(State::Fixed).copied());
}
State::Inactive => {
free.push_row(std::iter::empty());
fixed.push_row(std::iter::empty());
}
}
}
let prescribed_motion = (0..n).map(|d| fixed.row(d).map(|(p, c)| c * values.get(p).copied().unwrap_or(0.0)).sum()).collect();
let columns = free.transpose(eq_dof.len());
Ok(Reduction { eq_dof, active, free, columns, fixed, prescribed_motion, has_dependents: !exprs.is_empty(), budget })
}
/// Free equations.
pub fn neq(&self) -> usize {
self.eq_dof.len()
}
/// T_fᵀ A T_f for each matrix, all over the mesh's degrees of freedom and on one pattern
/// (reference points carry no stiffness or mass of their own). Row by row with a dense
/// accumulator (F. G. Gustavson, *Two fast algorithms for sparse matrices*, ACM TOMS
/// 4(3):250–269, 1978), in a fixed order, sharing the symbolic work between the matrices;
/// structural zeros are kept, so the results share one pattern too.
pub fn reduce<const M: usize>(&self, mats: [&Csr; M]) -> Result<[Csr; M]> {
if let Some(first) = mats.first()
&& mats.iter().any(|m| m.ptr != first.ptr || m.idx != first.idx)
{
return Err(Error::Internal("matrices with different patterns".into()));
}
let pattern = mats.first().ok_or_else(|| Error::Internal("no matrix to reduce".into()))?;
let n = self.neq();
let mut mark = vec![usize::MAX; n];
// A plain selection has at most the mesh pattern's entries; otherwise count them first
// (stopping at the budget) so nothing larger than the budget is ever allocated.
let entries = if self.has_dependents {
let budget = Budget { matrices: M, ..self.budget };
let mut count = 0usize;
for e in 0..n {
for (dof, _) in self.columns.row(e) {
for b in pattern.row(dof).0 {
for (f, _) in self.free.row(*b) {
if let Some(m) = mark.get_mut(f)
&& *m != e
{
*m = e;
count += 1;
}
}
}
}
if budget.exceeded(count) {
return Err(Error::Limit(format!(
"the reduced matrices need more than {} (the limit is {}); couplings tie too many nodes together",
gigabytes(budget.bytes(count)),
gigabytes(budget.max_bytes)
)));
}
}
mark.iter_mut().for_each(|m| *m = usize::MAX);
count
} else {
pattern.idx.len()
};
let mut acc = vec![[0.0; M]; n];
let mut touched: Vec<usize> = Vec::new();
let mut ptr = Vec::with_capacity(n + 1);
ptr.push(0);
let mut idx: Vec<usize> = reserved(entries)?;
let mut vals: [Vec<f64>; M] = std::array::from_fn(|_| Vec::new());
for v in &mut vals {
*v = reserved(entries)?;
}
for e in 0..n {
touched.clear();
for (dof, ca) in self.columns.row(e) {
let (start, end) = (pattern.ptr.get(dof).copied().unwrap_or(0), pattern.ptr.get(dof + 1).copied().unwrap_or(0));
for at in start..end {
let Some(&b) = pattern.idx.get(at) else { continue };
let v: [f64; M] = std::array::from_fn(|i| mats.get(i).and_then(|m| m.val.get(at)).copied().unwrap_or(0.0));
for (f, cb) in self.free.row(b) {
let (Some(m), Some(x)) = (mark.get_mut(f), acc.get_mut(f)) else { continue };
if *m != e {
*m = e;
*x = [0.0; M];
touched.push(f);
}
for i in 0..M {
x[i] += ca * v[i] * cb;
}
}
}
}
touched.sort_unstable();
for &f in &touched {
idx.push(f);
let x = acc.get(f).copied().unwrap_or([0.0; M]);
for i in 0..M {
vals[i].push(x[i]);
}
}
ptr.push(idx.len());
}
Ok(vals.map(|val| {
let mut out = Csr { n, ptr: ptr.clone(), idx: idx.clone(), val };
if self.has_dependents {
out.mirror_lower();
}
out
}))
}
/// The reduced right-hand side T_fᵀ (f − K T_p u_p); `k` is over the mesh's degrees of freedom.
pub fn rhs(&self, f: &[f64], k: &Csr) -> Vec<f64> {
let mut ku = Vec::new();
if self.prescribed_motion.iter().any(|v| *v != 0.0) {
ku = vec![0.0; k.n];
k.mul(self.prescribed_motion.get(..k.n).unwrap_or_default(), &mut ku);
}
let mut rhs = vec![0.0; self.neq()];
for (dof, fv) in f.iter().enumerate() {
let g = fv - ku.get(dof).copied().unwrap_or(0.0);
for (e, c) in self.free.row(dof) {
if let Some(r) = rhs.get_mut(e) {
*r += c * g;
}
}
}
rhs
}
/// Every degree of freedom from the free equations' values `q` (and the prescribed values
/// when `with_values`).
pub fn expand(&self, q: &[f64], with_values: bool) -> Vec<f64> {
(0..self.prescribed_motion.len())
.map(|d| {
let free: f64 = self.free.row(d).map(|(e, c)| c * q.get(e).copied().unwrap_or(0.0)).sum();
match self.prescribed_motion.get(d) {
Some(base) if with_values && !self.fixed.is_empty_row(d) => base + free,
_ => free,
}
})
.collect()
}
/// The reactions at the prescribed degrees of freedom, T_pᵀ (K u − f), over every degree of
/// freedom (zero where nothing is prescribed).
pub fn reactions(&self, k: &Csr, u: &[f64], f: &[f64]) -> Vec<f64> {
let mut out = vec![0.0; self.prescribed_motion.len()];
for (dof, fv) in f.iter().enumerate() {
if self.fixed.is_empty_row(dof) {
continue;
}
let (cols, vals) = k.row(dof);
let ku: f64 = cols.iter().zip(vals).map(|(j, v)| v * u.get(*j).copied().unwrap_or(0.0)).sum();
let r = ku - fv;
for (p, c) in self.fixed.row(dof) {
if let Some(x) = out.get_mut(p) {
*x += c * r;
}
}
}
out
}
}
+3 -1
View File
@@ -24,13 +24,15 @@
pub mod analysis;
pub mod cholesky;
pub mod constraints;
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,
ElementStress, FrequencyOptions, FrequencyResult, Location, MassKind, Mode, PointResult, StaticResult, Stats, StepResult, solve_frequency,
solve_static,
};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
+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();
+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)
}
+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
+25 -10
View File
@@ -21,7 +21,7 @@ crates in strictly lower layers.
| L0 | `modelcraft-model` | Solver-neutral FE model: nodes and elements by label, their geometry tags, sets, materials, sections, couplings, BCs, loads and steps, the model check and the resolved view the writers read. Knows no file format and no geometry type. | started |
| L1 | `modelcraft-geometry` | The only door to SolidCraft: opens `.solidcraft` designs (rebuilt from their timeline, bodies with persistent face names), B-rep bodies, measures, tessellation into a face-tagged `SurfaceMesh`; face-tagged 3MF files read into the same type without the kernel (docs/formats/tagged-mesh.md). No SolidCraft type in its API. | started |
| L1 | `modelcraft-mesh` | Surface preparation (weld, watertightness, feature edges), robust predicates, Delaunay tetrahedralization and refinement, Voronoi-based optimization, hex meshing, quality metrics. | started |
| L1 | `modelcraft-fem` | Element kernels shared by every structural solver: the element table (tet4, tet10, hex8 full and incompatible modes, hex20 full and reduced), shape functions, quadrature, element stiffness, consistent and lumped mass, body and pressure loads, stresses and their extrapolation, isotropic and orthotropic elasticity. Pure functions, no solver loop. | started |
| L1 | `modelcraft-fem` | Element kernels shared by every structural solver: the element table (tet4, tet10, wedge6, hex8 full and incompatible modes, hex20 full and reduced), shape functions, quadrature, element stiffness, consistent and lumped mass, body and pressure loads, stresses and their extrapolation, isotropic and orthotropic elasticity. Pure functions, no solver loop. | started |
| L1 | `modelcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. Text out, no file system; `deck.write` saves it. | started |
| L1 | `modelcraft-render` | Display data without a GPU dependency: the visible faces, element edges and nodes of a model, the orbit camera, a CPU rasterizer for headless snapshots; contour maps later. | started |
| L2 | `modelcraft-solver` | Implicit solver: assembly from the resolved model, boundary conditions, sparse direct solution, linear static and modal analysis, stresses and reactions. The explicit (`modelcraft-explicit`) and lattice Boltzmann (`modelcraft-lbm`) solvers join this layer. | started |
@@ -130,19 +130,29 @@ module docs; ADR 0001 records why the linear algebra is our own.
1. **System.** `Model::resolve()`; each solid section's elements take their material's
elasticity matrix and the section's formulation (`modelcraft_fem::element::ELEMENT_TYPES`, one
row per element kind). Couplings, reference points, moments, shells and beams return "not
supported yet". The step's boundary conditions prescribe translations (rotations do not exist
on solid nodes); its loads become a load vector: forces and tractions from
`Resolved::nodal_loads`, pressure as consistent face loads, gravity and body forces through the
row per element kind: tet4, tet10, wedge6, hex8, hex20). Every reference point a coupling
ties to the mesh gets six degrees of freedom (translations, rotations) after the mesh's. Shells
and beams return "not supported yet". The step's boundary conditions prescribe translations
(rotations do not exist on solid nodes) and a coupled point's six directions; its loads become
a load vector: forces and tractions from `Resolved::nodal_loads`, forces and moments on coupled
reference points, pressure as consistent face loads, gravity and body forces through the
element shape functions.
2. **Assembly** on the node graph (3 × 3 blocks, elements in label order, so sums are
reproducible bit for bit).
3. **Ordering and elimination.** Nested dissection of the nodes by coordinate bisection; free
degrees of freedom numbered in that order; prescribed values moved to the right-hand side (no
penalty).
3. **Ordering and elimination** (`constraints`). Nested dissection of the nodes by coordinate
bisection. Couplings become linear constraint rows: kinematic = rigid link of each coupled
translation to the point's translation and rotation; distributing = the point follows the
least-squares rigid fit of the set's translations (equal weights, the deck writers' choice).
Prescribed values and rows are eliminated master–slave in a fixed order, one dependent degree
of freedom per row (the coupled node or the distributing point while it is free, else the
largest coefficient), giving u = T_f q + T_p u_p; the reduced matrix T_fᵀ K T_f stays
symmetric positive definite (no penalty, no multipliers). Free mesh equations follow the
node order, reference point equations come last. Without couplings T is a selection and the
numbers are those of plain elimination, bit for bit.
4. **Static solution.** Up-looking sparse Cholesky (the factor size is computed and capped before
any value), reactions K u − f, integration-point stresses extrapolated to the nodes and
averaged, von Mises. A zero pivot names the node and direction that can move freely.
any value), reactions T_pᵀ (K u − f) (at nodes and at prescribed reference points, forces and
moments), integration-point stresses extrapolated to the nodes and averaged, von Mises. A zero
pivot names the node or reference point and direction that can move freely.
5. **Frequency.** Shift-invert block Lanczos (block 6, full reorthogonalization) on
K − σM, σ = 0 for a supported structure and slightly negative for a free one, so rigid-body
modes come out at zero; mass-normalized shapes, participation factors and effective masses.
@@ -154,6 +164,11 @@ Verification (`crates/solver/tests/verification.rs`, analytic only): displacemen
patch tests for every element and formulation (exact to round-off), cantilever tip deflection
against Timoshenko with convergence rates of the linear elements, Lamé's cylinder, Kirsch's
plate with a hole (Kt → 3), cantilever and free–free beam frequencies with six rigid-body modes.
`crates/solver/tests/couplings.rs`: a block given a rigid motion through a coupled point
(kinematic and distributing), clamping through a coupled point equals clamping (static and
modes), tension through a distributing coupling against the equivalent pressure away from the
end, pure bending by a moment through a distributing coupling, torsion of a tube by a moment on
a kinematically coupled point (twist and shear stress).
## Commands
+1 -1
View File
@@ -60,7 +60,7 @@ products are run, no documentation text is copied). Status: **done**, **started*
| Decks | Nastran bulk data writer (`deck.write`, SOL 101 or 103 per deck; reports what Nastran cannot express) | M3 | | done |
| Decks | LS-DYNA keyword writer (implicit; explicit in M8) | M3 | | done (implicit static and eigenvalue; beam sections planned) |
| Decks | Readers keeping unknown keywords | M9 | | planned |
| Solve | Own linear static and modal solver, verified against analytic solutions | M4 | yes | started (solid elements: tet4, tet10, hex8, hex20) |
| Solve | Own linear static and modal solver, verified against analytic solutions | M4 | yes | started (solid elements: tet4, tet10, wedge6, hex8, hex20; reference points, kinematic and distributing couplings, moments) |
| Results | Deformed shape, contours, mode shapes, probes, reaction sums | M4 | | started (results in the session, `solve.probe`, reaction sums; display planned) |
| Results | XY and path plots, cuts, display groups, free-body forces, animation | M9 | | planned |
| Results | Readers for public result formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`) | M9 | | planned |
+12 -1
View File
@@ -977,6 +977,17 @@ the module docs):
Lewis, H. D. Simon, SIAM J. Matrix Anal. Appl. 15(1):228–272, 1994; B. N. Parlett, *The
Symmetric Eigenvalue Problem*, SIAM 1998; G. H. Golub, C. F. Van Loan, *Matrix Computations*,
4th ed., 2013.
- Wedge6: the linear triangle times the linear interval; the symmetric three-point triangle rule
(Zienkiewicz, Taylor & Zhu 2013, table 6.3) times two-point Gauss–Legendre.
- Couplings and constraint elimination: rigid links and master–slave elimination by
transformation, R. D. Cook, D. S. Malkus, M. E. Plesha, R. J. Witt, *Concepts and Applications
of Finite Element Analysis*, 4th ed., Wiley 2002, §9.2, and C. A. Felippa, *Introduction to
Finite Element Methods*, ch. 8 (University of Colorado lecture notes); automatic choice of the
dependent degree of freedom, J. F. Abel, M. S. Shephard, IJNME 14(3):464–467, 1979; the
distributing (interpolation) constraint as a least-squares rigid fit with loads spread by
virtual work, G. H. K. Heirman, W. Desmet, Multibody Syst. Dyn. 24(2):219–234, 2010; the
reduced matrix product T_fᵀ K T_f row by row, F. G. Gustavson, ACM TOMS 4(3):250–269, 1978.
- Verification: R. H. MacNeal, R. L. Harder, *A proposed standard set of problems to test finite
element accuracy*, Finite Elem. Anal. Des. 1(1):3–20, 1985 (patch tests); Lamé, Kirsch,
Timoshenko and Euler–Bernoulli closed forms.
Timoshenko and Euler–Bernoulli closed forms; Saint-Venant's principle (tension through a
coupling), pure bending σ = M z / I, torsion of a circular tube φ = T L / (G J), τ = T r / J.