forked from 1b-agent/fieldcraft-upstream
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family, docs and licences. No compatibility shim: no files of the earlier name exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
252 lines
13 KiB
Rust
252 lines
13 KiB
Rust
//! Element-level checks: rigid-body modes, constant-strain reproduction (the single-element form
|
||
//! of the patch test), mass and load totals, extrapolation.
|
||
|
||
use fieldcraft_model::ElementKind;
|
||
|
||
use crate::dense::{Cholesky, Matrix};
|
||
use crate::element::{Formulation, Solid};
|
||
use crate::material::{isotropic, orthotropic, stress};
|
||
|
||
/// A distorted but valid element of each kind, mid-side nodes off the straight edges for the
|
||
/// quadratic kinds when `curved`.
|
||
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],
|
||
[2.3, 1.2, 0.05],
|
||
[-0.1, 0.9, 0.1],
|
||
[0.1, -0.1, 0.8],
|
||
[2.0, 0.05, 1.1],
|
||
[2.2, 1.1, 0.9],
|
||
[0.05, 1.05, 1.2],
|
||
],
|
||
};
|
||
let mut xyz = corners.clone();
|
||
for (i, e) in kind.edges().iter().enumerate() {
|
||
if let [a, b, _] = **e {
|
||
let bump = if curved { 0.03 * ((i % 3) as f64 - 1.0) } else { 0.0 };
|
||
xyz.push([0, 1, 2].map(|k| (corners[a][k] + corners[b][k]) / 2.0 + bump));
|
||
}
|
||
}
|
||
xyz
|
||
}
|
||
|
||
const CASES: [(ElementKind, Formulation); 10] = [
|
||
(ElementKind::Tet4, Formulation::Full),
|
||
(ElementKind::Tet10, Formulation::Full),
|
||
(ElementKind::Wedge6, Formulation::Full),
|
||
(ElementKind::Hex8, Formulation::Full),
|
||
(ElementKind::Hex8, Formulation::IncompatibleModes),
|
||
(ElementKind::Hex20, Formulation::Full),
|
||
(ElementKind::Hex20, Formulation::Reduced),
|
||
(ElementKind::Tet10, Formulation::Hybrid { pressures: 1 }),
|
||
(ElementKind::Hex8, Formulation::Hybrid { pressures: 1 }),
|
||
(ElementKind::Hex20, Formulation::Hybrid { pressures: 4 }),
|
||
];
|
||
|
||
fn rigid_modes(xyz: &[[f64; 3]]) -> Vec<Vec<f64>> {
|
||
let mut modes = Vec::new();
|
||
for k in 0..3 {
|
||
modes.push(xyz.iter().flat_map(|_| [0, 1, 2].map(|c| if c == k { 1.0 } else { 0.0 })).collect());
|
||
}
|
||
for axis in 0..3 {
|
||
// ω × x for ω along `axis`.
|
||
let w = [0, 1, 2].map(|c| if c == axis { 1.0 } else { 0.0 });
|
||
modes.push(xyz.iter().flat_map(|x| [w[1] * x[2] - w[2] * x[1], w[2] * x[0] - w[0] * x[2], w[0] * x[1] - w[1] * x[0]]).collect());
|
||
}
|
||
modes
|
||
}
|
||
|
||
#[test]
|
||
fn stiffness_is_symmetric_with_exactly_six_rigid_body_modes() {
|
||
let d = isotropic(1000.0, 0.3).unwrap();
|
||
for (kind, f) in CASES {
|
||
let xyz = distorted(kind, true);
|
||
let k = Solid::new(kind, &xyz, f).unwrap().stiffness(&d).unwrap();
|
||
let scale = k.data.iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||
for i in 0..k.n {
|
||
for j in 0..k.n {
|
||
assert_eq!(k.get(i, j), k.get(j, i), "{kind:?} {f:?} symmetric");
|
||
}
|
||
}
|
||
for m in rigid_modes(&xyz) {
|
||
let r = k.mul_vec(&m);
|
||
assert!(r.iter().all(|v| v.abs() < 1e-10 * scale), "{kind:?} {f:?}: rigid motion strains the element");
|
||
}
|
||
// Holding six degrees of freedom (node 1 fully, node 2 in y and z, node 3 in z) leaves a
|
||
// positive definite matrix: no other zero-energy mode. Reduced hex20 has spurious modes
|
||
// on a single element, and so has hybrid tet10: its deviatoric stiffness is blind to the
|
||
// four dilatations with a linear volumetric strain, and its constant pressure controls
|
||
// one; neighbouring elements restrain the other three (P2–P0, crate::hybrid). Both are
|
||
// skipped.
|
||
if f != Formulation::Reduced && !(kind == ElementKind::Tet10 && matches!(f, Formulation::Hybrid { .. })) {
|
||
let mut held = k.clone();
|
||
for dof in [0, 1, 2, 4, 5, 8] {
|
||
held.add(dof, dof, 1e3 * scale);
|
||
}
|
||
assert!(Cholesky::new(&held).is_ok(), "{kind:?} {f:?} has a spurious zero-energy mode");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn constant_strain_is_reproduced_exactly() {
|
||
// u = A x for a general A: every integration point sees ε(A), the stiffness's internal
|
||
// forces balance (K u is the boundary load of a uniform stress), and the incompatible modes
|
||
// stay idle.
|
||
let a = [[1e-3, 2e-4, -3e-4], [5e-4, -2e-3, 1e-4], [-1e-4, 3e-4, 7e-4]];
|
||
let strain = [a[0][0], a[1][1], a[2][2], a[0][1] + a[1][0], a[0][2] + a[2][0], a[1][2] + a[2][1]];
|
||
for d in [isotropic(210e3, 0.3).unwrap(), orthotropic([140e3, 10e3, 12e3], [0.3, 0.25, 0.4], [5e3, 4e3, 3.8e3]).unwrap()] {
|
||
let want = stress(&d, &strain);
|
||
// Hybrid elements need an isotropic material.
|
||
let hybrid_ok = crate::hybrid::split(&d).is_ok();
|
||
for (kind, f) in CASES.into_iter().filter(|(_, f)| hybrid_ok || !matches!(f, Formulation::Hybrid { .. })) {
|
||
// Curved mid-sides keep the field in the isoparametric span only for straight edges.
|
||
let xyz = distorted(kind, false);
|
||
let u: Vec<[f64; 3]> = xyz.iter().map(|x| [0, 1, 2].map(|i| (0..3).map(|j| a[i][j] * x[j]).sum())).collect();
|
||
let e = Solid::new(kind, &xyz, f).unwrap();
|
||
for p in e.stresses(&d, &u).unwrap() {
|
||
for k in 0..6 {
|
||
assert!((p.strain[k] - strain[k]).abs() < 1e-14, "{kind:?} {f:?} strain {k}");
|
||
assert!((p.stress[k] - want[k]).abs() < 1e-9 * want[0].abs().max(1.0), "{kind:?} {f:?} stress {k}");
|
||
}
|
||
}
|
||
let nodal = e.extrapolate(&e.stresses(&d, &u).unwrap().iter().map(|p| p.stress).collect::<Vec<_>>()).unwrap();
|
||
assert_eq!(nodal.len(), xyz.len());
|
||
assert!(nodal.iter().all(|s| (0..6).all(|k| (s[k] - want[k]).abs() < 1e-9 * want[0].abs())), "{kind:?} {f:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mass_volume_and_body_force_totals() {
|
||
let rho = 7.85e-9;
|
||
for (kind, f) in CASES {
|
||
let xyz = distorted(kind, true);
|
||
let e = Solid::new(kind, &xyz, f).unwrap();
|
||
let v = e.volume().unwrap();
|
||
assert!(v > 0.0);
|
||
let m = e.mass(rho).unwrap();
|
||
let total: f64 = m.data.iter().sum();
|
||
assert!((total - rho * v).abs() < 1e-12 * rho * v, "{kind:?}: {total} vs {}", rho * v);
|
||
let lumped = e.lumped_mass(rho).unwrap();
|
||
assert!(lumped.iter().all(|x| *x > 0.0), "{kind:?}: lumped masses are positive");
|
||
assert!((lumped.iter().sum::<f64>() - rho * v).abs() < 1e-12 * rho * v);
|
||
let b = e.body_force([0.0, 0.0, -9.81]).unwrap();
|
||
assert!((b.iter().map(|f| f[2]).sum::<f64>() + 9.81 * v).abs() < 1e-12 * v);
|
||
}
|
||
// A tetrahedron's volume is a sixth of its edge triple product.
|
||
let xyz = distorted(ElementKind::Tet4, false);
|
||
let e = |i: usize| [0, 1, 2].map(|k| xyz[i][k] - xyz[0][k]);
|
||
let (a, b, c) = (e(1), e(2), e(3));
|
||
let exact = (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])) / 6.0;
|
||
let v = Solid::new(ElementKind::Tet4, &xyz, Formulation::Full).unwrap().volume().unwrap();
|
||
assert!((v - exact).abs() < 1e-15);
|
||
}
|
||
|
||
#[test]
|
||
fn extrapolation_reproduces_linear_fields_on_affine_elements() {
|
||
// Values linear in x at the integration points come back exactly at the nodes (tet4 has
|
||
// one point and gives the mean).
|
||
let f = |x: [f64; 3]| 1.0 + 2.0 * x[0] - 0.5 * x[1] + 3.0 * x[2];
|
||
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();
|
||
for e in kind.edges() {
|
||
if let [a, b, _] = **e {
|
||
xyz.push([0, 1, 2].map(|k| (c[a][k] + c[b][k]) / 2.0));
|
||
}
|
||
}
|
||
xyz
|
||
};
|
||
for (kind, form) in CASES.into_iter().filter(|(k, _)| *k != ElementKind::Tet4) {
|
||
let xyz = affine(kind);
|
||
let e = Solid::new(kind, &xyz, form).unwrap();
|
||
let values: Vec<[f64; 6]> = e.integration_points().iter().map(|p| [f(e.position(p.xi)); 6]).collect();
|
||
let nodal = e.extrapolate(&values).unwrap();
|
||
for (x, v) in xyz.iter().zip(&nodal) {
|
||
assert!((v[0] - f(*x)).abs() < 1e-12, "{kind:?} {form:?} at {x:?}: {} vs {}", v[0], f(*x));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bad_input_is_an_error() {
|
||
let xyz = distorted(ElementKind::Hex8, false);
|
||
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::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());
|
||
// An inverted element (top and bottom swapped) and a flat one.
|
||
let mut inverted = xyz.clone();
|
||
inverted.rotate_left(4);
|
||
let d = isotropic(1.0, 0.3).unwrap();
|
||
assert!(Solid::new(ElementKind::Hex8, &inverted, Formulation::Full).unwrap().stiffness(&d).is_err());
|
||
let flat: Vec<[f64; 3]> = xyz.iter().map(|p| [p[0], p[1], 0.0]).collect();
|
||
assert!(Solid::new(ElementKind::Hex8, &flat, Formulation::IncompatibleModes).unwrap().stiffness(&d).is_err());
|
||
let e = Solid::new(ElementKind::Hex8, &xyz, Formulation::Full).unwrap();
|
||
assert!(e.stresses(&d, &[[0.0; 3]; 3]).is_err());
|
||
assert!(e.extrapolate(&[[0.0; 6]; 3]).is_err());
|
||
let _ = Matrix::zeros(0).mul_vec(&[]);
|
||
}
|
||
|
||
#[test]
|
||
fn section_formulations() {
|
||
use fieldcraft_model::{Formulation as F, Integration};
|
||
let full = F::default();
|
||
let reduced = F { integration: Integration::Reduced, ..F::default() };
|
||
let im = F { incompatible_modes: true, ..F::default() };
|
||
let hybrid = F { hybrid: true, ..F::default() };
|
||
assert_eq!(Formulation::from_section(ElementKind::Hex8, &full), Ok(Formulation::Full));
|
||
assert_eq!(Formulation::from_section(ElementKind::Hex8, &im), Ok(Formulation::IncompatibleModes));
|
||
assert_eq!(Formulation::from_section(ElementKind::Hex20, &reduced), Ok(Formulation::Reduced));
|
||
assert!(Formulation::from_section(ElementKind::Hex8, &reduced).unwrap_err().to_string().contains("not supported yet"));
|
||
assert!(Formulation::from_section(ElementKind::Tet10, &im).is_err());
|
||
assert!(Formulation::from_section(ElementKind::Tet4, &hybrid).is_err());
|
||
assert!(Formulation::from_section(ElementKind::Quad4, &full).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn uniform_thermal_strain_is_free_expansion_and_its_load_balances_it() {
|
||
// A uniform ΔT with orthotropic α: the free expansion u = diag(α ΔT) x is stress-free, and
|
||
// the thermal load equals K u for it (the element is in equilibrium with no other load).
|
||
let ortho = orthotropic([1000.0, 800.0, 600.0], [0.3, 0.25, 0.2], [400.0, 300.0, 350.0]).unwrap();
|
||
let iso = isotropic(1000.0, 0.4999).unwrap();
|
||
let alpha = [1.2e-5, 2.0e-5, 0.7e-5];
|
||
for (kind, f) in CASES {
|
||
// Hybrid elements take isotropic (here nearly incompressible) materials.
|
||
let d = if matches!(f, Formulation::Hybrid { .. }) { iso } else { ortho };
|
||
let xyz = distorted(kind, true);
|
||
let solid = Solid::new(kind, &xyz, f).unwrap();
|
||
let th = crate::ThermalStrain { alpha, delta: vec![80.0; xyz.len()] };
|
||
let u: Vec<[f64; 3]> = xyz.iter().map(|x| [0, 1, 2].map(|k| alpha[k] * 80.0 * x[k])).collect();
|
||
for p in solid.stresses_with(&d, &u, Some(&th)).unwrap() {
|
||
assert!(p.stress.iter().all(|s| s.abs() < 1e-9), "{kind:?} {f:?}: {:?}", p.stress);
|
||
for k in 0..3 {
|
||
assert!((p.strain[k] - alpha[k] * 80.0).abs() < 1e-12);
|
||
}
|
||
}
|
||
let load = solid.thermal_load(&d, &th).unwrap();
|
||
let ku = solid.stiffness(&d).unwrap().mul_vec(&u.iter().flatten().copied().collect::<Vec<_>>());
|
||
let scale = ku.iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||
for (a, b) in load.iter().flatten().zip(&ku) {
|
||
assert!((a - b).abs() < 1e-9 * scale, "{kind:?} {f:?}: {a} vs {b}");
|
||
}
|
||
// Without a thermal strain the two stress paths agree bit for bit.
|
||
assert_eq!(solid.stresses_with(&d, &u, None).unwrap(), solid.stresses(&d, &u).unwrap());
|
||
assert!(solid.thermal_load(&d, &crate::ThermalStrain { alpha, delta: vec![1.0; 2] }).is_err());
|
||
assert!(solid.thermal_load(&d, &crate::ThermalStrain { alpha: [f64::NAN; 3], delta: vec![1.0; xyz.len()] }).is_err());
|
||
}
|
||
}
|