forked from 1b-agent/fieldcraft-upstream
Implicit solver: fem kernels (tet4/10, hex8, hex20), sparse Cholesky, block Lanczos, solve.* commands (M4) (#23)
* Renumber the layer table for solver crates: fem at L1, solver at L2, fsi at L3 L0 model -> L1 geometry, mesh, fem, decks, render -> L2 solver (explicit, lbm later) -> L3 fsi (later) -> L4 doc -> L5 io -> L6 engine -> L7 ui-egui, mcp -> apps. UI_MIN_LAYER moves to 7, the testkit sits above it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-fem: element kernels for tet4, tet10, hex8 (full, incompatible modes), hex20 (full, reduced) Shape functions, Gauss and conical-product quadrature, stiffness, consistent and HRZ-lumped mass, body forces, consistent pressure loads on faces, integration-point stresses and their least-squares extrapolation to nodes, isotropic and orthotropic elasticity. The element table is one row per kind, so wedges, shells and beams are added locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-solver: linear static and frequency analysis with an analytic verification suite Assembly from the resolved model on the node graph, boundary conditions by elimination, consistent pressure, gravity and body-force loads, nested-dissection ordering, sparse up-looking Cholesky with a factor-size cap, reactions, integration-point and nodal-averaged stresses, von Mises; shift-invert block Lanczos with full reorthogonalization for natural modes, participation and effective mass. ADR 0001 records the own-linear-algebra decision (determinism, wasm). Verified against closed forms: displacement and traction patch tests for every element, cantilever tip deflection (Timoshenko) with convergence rates, Lamé cylinder, Kirsch hole (Kt -> 3), cantilever and free-free beam frequencies with six rigid-body modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Engine: solve.static, solve.frequency and solve.probe; results kept in the session Results are stored per step, restored on a failed command and cleared when the model changes. Caps (degrees of freedom, factor entries, Lanczos vectors) may only be lowered; hostile parameters are tested against a solvable session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: implicit solver architecture, roadmap M4, parity and references Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Simplify the solver: shared face integration, block assembly, cached extrapolation - model: one face integrator (nodal::face_normal_weights) for tractions and pressures - fem: mid-side tables from ElementKind::edges, orthotropic via inverse3, symmetric accumulation without a symmetrize pass, incompatible-mode recovery without K_uu, model's coordinate check, dead table rows removed - solver: one pattern search per node pair (block, scalar and diagonal adds), extrapolation built once per element type, reactions from the prescribed rows only, matrices dropped after restriction, the free-body shift keyed on the factorization error, step selection in the solver, Error::Element naming the element - engine: the solver owns the default step and the shift check; the model comparison runs once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: results are stamped with the session revision Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
33 files changed
+4559
-53
No files matched your search
+51
-20
@@ -95,6 +95,48 @@ fn rule(corners: usize) -> Vec<(f64, f64, f64)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visit the quadrature points of a face (node coordinates in the face's node order): the shape
|
||||
/// function values and the weighted normal (∂x/∂ξ × ∂x/∂η) w, whose length is the point's share of
|
||||
/// the area and whose direction is the face's right-hand normal.
|
||||
fn integrate_face(xyz: &[[f64; 3]], mut visit: impl FnMut(&[f64], [f64; 3])) -> Result<()> {
|
||||
if xyz.iter().flatten().any(|c| !c.is_finite()) {
|
||||
return Err(Error::Invalid("face coordinates must be finite".into()));
|
||||
}
|
||||
let (corners, shape): (usize, fn(usize, f64, f64) -> Shape) = match xyz.len() {
|
||||
3 | 6 => (3, triangle),
|
||||
4 | 8 => (4, quadrilateral),
|
||||
n => return Err(Error::Invalid(format!("a face has 3, 4, 6 or 8 nodes, not {n}"))),
|
||||
};
|
||||
for (xi, eta, w) in rule(corners) {
|
||||
let s = shape(xyz.len(), xi, eta);
|
||||
let mut tx = [0.0; 3];
|
||||
let mut te = [0.0; 3];
|
||||
for ((p, dx), de) in xyz.iter().zip(&s.dxi).zip(&s.deta) {
|
||||
for k in 0..3 {
|
||||
tx[k] += dx * p[k];
|
||||
te[k] += de * p[k];
|
||||
}
|
||||
}
|
||||
let c = [tx[1] * te[2] - tx[2] * te[1], tx[2] * te[0] - tx[0] * te[2], tx[0] * te[1] - tx[1] * te[0]];
|
||||
visit(&s.n, c.map(|v| v * w));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ∫ Nᵢ n dA for every node of a face: the consistent nodal forces of a unit pressure pulling
|
||||
/// along the face's right-hand (outward, for solids) normal.
|
||||
pub fn face_normal_weights(xyz: &[[f64; 3]]) -> Result<Vec<[f64; 3]>> {
|
||||
let mut out = vec![[0.0; 3]; xyz.len()];
|
||||
integrate_face(xyz, |n, nda| {
|
||||
for (o, ni) in out.iter_mut().zip(n) {
|
||||
for k in 0..3 {
|
||||
o[k] += ni * nda[k];
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// The nodal weights `∫ N_i dA` of a set of element faces, summed per node. Their sum is the
|
||||
/// faces' area.
|
||||
@@ -104,27 +146,16 @@ impl Model {
|
||||
let labels = self.face_nodes(f).ok_or_else(|| Error::NotFound(format!("element {} face {}", f.element, f.face)))?;
|
||||
let xyz: Vec<[f64; 3]> =
|
||||
labels.iter().map(|n| self.node(*n).map(|p| p.xyz).ok_or_else(|| Error::NotFound(format!("node {n}")))).collect::<Result<_>>()?;
|
||||
let (corners, shape): (usize, fn(usize, f64, f64) -> Shape) = match labels.len() {
|
||||
3 | 6 => (3, triangle),
|
||||
4 | 8 => (4, quadrilateral),
|
||||
n => return Err(Error::Invalid(format!("element {} face {} has {n} nodes, not a surface face", f.element, f.face))),
|
||||
};
|
||||
for (xi, eta, w) in rule(corners) {
|
||||
let s = shape(labels.len(), xi, eta);
|
||||
let mut tx = [0.0; 3];
|
||||
let mut te = [0.0; 3];
|
||||
for ((p, dx), de) in xyz.iter().zip(&s.dxi).zip(&s.deta) {
|
||||
for k in 0..3 {
|
||||
tx[k] += dx * p[k];
|
||||
te[k] += de * p[k];
|
||||
}
|
||||
integrate_face(&xyz, |n, nda| {
|
||||
let da = (nda[0] * nda[0] + nda[1] * nda[1] + nda[2] * nda[2]).sqrt();
|
||||
for (label, ni) in labels.iter().zip(n) {
|
||||
*out.entry(*label).or_default() += ni * da;
|
||||
}
|
||||
let c = [tx[1] * te[2] - tx[2] * te[1], tx[2] * te[0] - tx[0] * te[2], tx[0] * te[1] - tx[1] * te[0]];
|
||||
let da = (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt() * w;
|
||||
for (label, n) in labels.iter().zip(&s.n) {
|
||||
*out.entry(*label).or_default() += n * da;
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| match e {
|
||||
Error::Invalid(m) => Error::Invalid(format!("element {} face {}: {m}", f.element, f.face)),
|
||||
e => e,
|
||||
})?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user