forked from 1b-agent/fieldcraft-upstream
Shells and beams: MITC4/MITC3 shells, Timoshenko beams, six-DOF nodes (M4) (#47)
* fem: MITC4/MITC3 flat shells and Timoshenko beams with profile properties (ElementDofs STRUCTURAL) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * solver: shells and beams on the DofLayout beside solids, 2D elements and trusses; analytic verification and tagged benchmarks Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * engine: probe rotations, reaction moments and shell and beam element results; docs for shells and beams 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:
18 files changed
+2411
-48
No files matched your search
@@ -1,8 +1,10 @@
|
||||
//! Linear static and frequency analysis of a model's step, and their results.
|
||||
|
||||
use modelcraft_fem::ElementType;
|
||||
use modelcraft_fem::beam::BeamForces;
|
||||
use modelcraft_fem::element::{Extrapolation, extrapolation};
|
||||
use modelcraft_fem::quadrature::Rule;
|
||||
use modelcraft_fem::shell::ShellStress;
|
||||
use modelcraft_fem::{DofKind, DofLayout, PointStress, von_mises};
|
||||
use modelcraft_model::{ElementKind, Model, Procedure, Resolved, Step};
|
||||
use serde::Serialize;
|
||||
@@ -12,7 +14,7 @@ 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, inelastic_warnings, loads, prescribed, translation_block};
|
||||
use crate::system::{Mesh, Structural, StructuralKernel, inelastic_warnings, loads, prescribed, structural_body_forces, translation_block};
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
/// Size and effort of a solution.
|
||||
@@ -29,6 +31,22 @@ pub struct Stats {
|
||||
pub lanczos_vectors: usize,
|
||||
}
|
||||
|
||||
/// Section forces and surface stresses of one shell element.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShellResult {
|
||||
pub label: u32,
|
||||
pub kind: ElementKind,
|
||||
pub stress: ShellStress,
|
||||
}
|
||||
|
||||
/// End section forces of one beam element.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BeamResult {
|
||||
pub label: u32,
|
||||
pub kind: ElementKind,
|
||||
pub forces: BeamForces,
|
||||
}
|
||||
|
||||
/// Stresses of one element.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ElementStress {
|
||||
@@ -57,13 +75,23 @@ pub struct StaticResult {
|
||||
/// Node labels with degrees of freedom, sorted; the per-node vectors follow this order.
|
||||
pub nodes: Vec<u32>,
|
||||
pub displacement: Vec<[f64; 3]>,
|
||||
/// Rotations about X, Y, Z (zero at nodes without rotations: those of solids and trusses
|
||||
/// only).
|
||||
pub rotation: Vec<[f64; 3]>,
|
||||
/// Reaction forces (zero where nothing is prescribed).
|
||||
pub reaction: Vec<[f64; 3]>,
|
||||
/// Reaction moments at held rotations.
|
||||
pub reaction_moment: Vec<[f64; 3]>,
|
||||
/// Stress averaged at the nodes over the elements that share them (xx, yy, zz, xy, xz, yz).
|
||||
pub stress: Vec<[f64; 6]>,
|
||||
/// Von Mises stress of the averaged nodal stress.
|
||||
pub von_mises: Vec<f64>,
|
||||
pub elements: Vec<ElementStress>,
|
||||
/// Shell section forces and surface stresses, by label (their top surface, the side their
|
||||
/// normal points to, joins the nodal stress average).
|
||||
pub shells: Vec<ShellResult>,
|
||||
/// Beam end section forces, by label.
|
||||
pub beams: Vec<BeamResult>,
|
||||
/// 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).
|
||||
@@ -82,6 +110,8 @@ pub struct Mode {
|
||||
pub frequency: f64,
|
||||
/// Mass-normalized (φᵀ M φ = 1), per node in [`FrequencyResult::nodes`] order.
|
||||
pub shape: Vec<[f64; 3]>,
|
||||
/// The rotations of the shape (zero at nodes without rotations).
|
||||
pub rotation: Vec<[f64; 3]>,
|
||||
/// Participation factors Γ = φᵀ M r for unit translations along X, Y, Z.
|
||||
pub participation: [f64; 3],
|
||||
/// Effective mass Γ² along X, Y, Z.
|
||||
@@ -161,6 +191,15 @@ fn assemble(mesh: &Mesh, graph: &NodeGraph, mass: Option<MassKind>) -> Result<As
|
||||
}
|
||||
}
|
||||
}
|
||||
for s in &mesh.structural {
|
||||
let kernel = s.kernel()?;
|
||||
let at = |err| Error::element(s.label, err);
|
||||
add_block(&mut k, layout, &s.nodes, Structural::KINDS, &kernel.stiffness().map_err(at)?.data)?;
|
||||
if let (Some(mm), Some(kind)) = (m.as_mut(), mass) {
|
||||
let rho = s.density.ok_or_else(|| Error::Invalid(format!("element {} has no density", s.label)))?;
|
||||
add_block(mm, layout, &s.nodes, Structural::KINDS, &kernel.mass(rho, kind).map_err(at)?.data)?;
|
||||
}
|
||||
}
|
||||
mesh.discrete.add_stiffness(&mut k)?;
|
||||
if let Some(mm) = m.as_mut() {
|
||||
mesh.discrete.add_mass(mm)?;
|
||||
@@ -195,11 +234,17 @@ fn directions<const N: usize>(full: &[f64], layout: &DofLayout, slot: usize) ->
|
||||
std::array::from_fn(|c| DofKind::from_direction(c).and_then(|k| layout.dof(slot, k)).and_then(|d| full.get(d)).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// The mesh nodes' translations in a full vector, per node, and the reference points'
|
||||
/// translations and rotations, per point.
|
||||
fn split(full: &[f64], mesh: &Mesh) -> (Vec<[f64; 3]>, Vec<[f64; 6]>) {
|
||||
/// A full vector split into the mesh nodes' translations and rotations (zero where a node has
|
||||
/// none), per node, and the reference points' translations and rotations, per point.
|
||||
fn split(full: &[f64], mesh: &Mesh) -> (Vec<[f64; 3]>, Vec<[f64; 3]>, Vec<[f64; 6]>) {
|
||||
let l = &mesh.layout;
|
||||
((0..l.nodes()).map(|n| directions(full, l, n)).collect(), (0..mesh.points.len()).map(|k| directions(full, l, l.point_slot(k))).collect())
|
||||
let (translation, rotation) = (0..l.nodes())
|
||||
.map(|n| {
|
||||
let [a, b, c, d, e, f] = directions::<6>(full, l, n);
|
||||
([a, b, c], [d, e, f])
|
||||
})
|
||||
.unzip();
|
||||
(translation, rotation, (0..mesh.points.len()).map(|k| directions(full, l, l.point_slot(k))).collect())
|
||||
}
|
||||
|
||||
/// The matrix pattern of the mesh (node blocks of its elements, trusses and discrete elements)
|
||||
@@ -252,7 +297,7 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
|
||||
let resolved = model.resolve()?;
|
||||
let step = model.select_step(step, |p| matches!(p, Procedure::Static {}), "static")?;
|
||||
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
|
||||
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
|
||||
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).chain(mesh.warnings.iter().cloned()).collect();
|
||||
warnings.extend(inelastic_warnings(model));
|
||||
let (graph, order) = pattern(&mesh);
|
||||
ignored_dashpots(&mesh, &mut warnings);
|
||||
@@ -272,9 +317,9 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
|
||||
if u.iter().any(|x| !x.is_finite()) {
|
||||
return Err(Error::Singular("the solution is not finite; the model is ill-conditioned".into()));
|
||||
}
|
||||
let (displacement, point_motion) = split(&u, &mesh);
|
||||
let (displacement, rotation, point_motion) = split(&u, &mesh);
|
||||
let reactions = red.reactions(&k, &u, &f);
|
||||
let (reaction, point_reaction) = split(&reactions, &mesh);
|
||||
let (reaction, reaction_moment, point_reaction) = split(&reactions, &mesh);
|
||||
let (mut applied_total, mut reaction_total) = ([0.0; 3], [0.0; 3]);
|
||||
for (dof, (fv, rv)) in f.iter().zip(&reactions).enumerate() {
|
||||
let Some(d) = mesh.kind(dof).and_then(DofKind::direction) else { continue };
|
||||
@@ -337,6 +382,31 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
|
||||
elements.push(ElementStress { label: t.label, kind: t.kind, points, nodal });
|
||||
}
|
||||
elements.sort_by_key(|e| e.label);
|
||||
let (mut shells, mut beams) = (Vec::new(), Vec::new());
|
||||
let per_volume = structural_body_forces(model, &resolved, &mesh, step)?;
|
||||
for (s, body) in mesh.structural.iter().zip(&per_volume) {
|
||||
let at = |err| Error::element(s.label, err);
|
||||
let dofs = mesh
|
||||
.layout
|
||||
.element_dofs(&s.nodes, Structural::KINDS)
|
||||
.ok_or_else(|| Error::Internal(format!("element {} degrees of freedom", s.label)))?;
|
||||
let ue: Vec<f64> = dofs.iter().map(|d| u.get(*d).copied().unwrap_or(0.0)).collect();
|
||||
match s.kernel()? {
|
||||
StructuralKernel::Shell(shell) => {
|
||||
let stress = shell.stresses(&ue).map_err(at)?;
|
||||
for (n, v) in s.nodes.iter().zip(&stress.top) {
|
||||
if let (Some(acc), Some(c)) = (sum.get_mut(*n), count.get_mut(*n)) {
|
||||
for k in 0..6 {
|
||||
acc[k] += v[k];
|
||||
}
|
||||
*c += 1;
|
||||
}
|
||||
}
|
||||
shells.push(ShellResult { label: s.label, kind: s.kind, stress });
|
||||
}
|
||||
StructuralKernel::Beam(beam) => beams.push(BeamResult { label: s.label, kind: s.kind, forces: beam.forces(&ue, *body).map_err(at)? }),
|
||||
}
|
||||
}
|
||||
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 scale = applied_total.iter().chain(&reaction_total).fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
@@ -349,10 +419,14 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
|
||||
step: step.name.clone(),
|
||||
nodes: mesh.labels.clone(),
|
||||
displacement,
|
||||
rotation,
|
||||
reaction,
|
||||
reaction_moment,
|
||||
stress,
|
||||
von_mises,
|
||||
elements,
|
||||
shells,
|
||||
beams,
|
||||
reference_points,
|
||||
applied_total,
|
||||
reaction_total,
|
||||
@@ -398,7 +472,7 @@ 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 mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
|
||||
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).chain(mesh.warnings.iter().cloned()).collect();
|
||||
warnings.extend(inelastic_warnings(model));
|
||||
let (graph, order) = pattern(&mesh);
|
||||
ignored_dashpots(&mesh, &mut warnings);
|
||||
@@ -406,14 +480,18 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
|
||||
let red = reduction(&setup, false, &order, budget(limits, &mesh, &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.
|
||||
// (the mean over the directions for a point mass given per direction; rotary inertia is left
|
||||
// out), added in storage order.
|
||||
let translation = |d: usize| mesh.kind(d).and_then(DofKind::direction).is_some_and(|c| c < 3);
|
||||
// Every element's mass entries between translations sum to its mass, once per direction
|
||||
// (the mean over the directions for a point mass given per direction; rotary inertia, and
|
||||
// the translation–rotation coupling of shells and beams, are left out), added in storage
|
||||
// order.
|
||||
let translation: Vec<bool> = (0..m.n).map(|d| mesh.kind(d).and_then(DofKind::direction).is_some_and(|c| c < 3)).collect();
|
||||
let mut translational = 0.0;
|
||||
for d in (0..m.n).filter(|d| translation(*d)) {
|
||||
for v in m.row(d).1 {
|
||||
translational += v;
|
||||
for d in (0..m.n).filter(|d| translation.get(*d).copied().unwrap_or(false)) {
|
||||
let (cols, vals) = m.row(d);
|
||||
for (c, v) in cols.iter().zip(vals) {
|
||||
if translation.get(*c).copied().unwrap_or(false) {
|
||||
translational += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
let total_mass = translational / mesh.translation_kinds().len() as f64;
|
||||
@@ -468,11 +546,12 @@ 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);
|
||||
let (shape, rotation, point_shape) = split(&red.expand(phi, false), &mesh);
|
||||
out.push(Mode {
|
||||
eigenvalue: *lambda,
|
||||
frequency: lambda.max(0.0).sqrt() / two_pi,
|
||||
shape,
|
||||
rotation,
|
||||
participation,
|
||||
effective_mass: participation.map(|g| g * g),
|
||||
point_shape,
|
||||
@@ -501,6 +580,8 @@ pub enum Location {
|
||||
|
||||
/// The result of one step, as a session keeps it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
// One per solved step; boxing the larger variant would buy nothing.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum StepResult {
|
||||
Static(StaticResult),
|
||||
Frequency(FrequencyResult),
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//! 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.
|
||||
//! coupled. Coupled rotations tie the rotations of shell and beam nodes, θ_s = θ_r; solid
|
||||
//! nodes 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
|
||||
@@ -50,8 +51,8 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use modelcraft_fem::DofKind;
|
||||
use modelcraft_fem::dense::inverse3;
|
||||
use modelcraft_fem::{DofKind, DofSet};
|
||||
use modelcraft_model::{CouplingKind, Model, Resolved};
|
||||
|
||||
use crate::sparse::Csr;
|
||||
@@ -113,7 +114,8 @@ pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) ->
|
||||
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() {
|
||||
let rotations = |n: usize| mesh.layout.kinds(n).includes(DofSet::ROTATIONS);
|
||||
if coupled(0..3).next().is_none() && !(nodes.iter().any(|n| rotations(*n)) && coupled(3..6).next().is_some()) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"coupling `{name}` couples only rotations, which the nodes of solid elements do not have; couple translations too"
|
||||
)));
|
||||
@@ -133,6 +135,14 @@ pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) ->
|
||||
}
|
||||
rows.push(Row { terms, pivot, coupling: name });
|
||||
}
|
||||
// Shell and beam nodes also turn with the point: θ_s = θ_r.
|
||||
if rotations(n) {
|
||||
for k in coupled(3..6) {
|
||||
let pivot =
|
||||
DofKind::from_direction(k).ok_or_else(|| Error::Internal("direction".into())).and_then(|kind| mesh.dof(n, kind))?;
|
||||
rows.push(Row { terms: vec![(pivot, 1.0), (rp(k)?, -1.0)], pivot, coupling: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CouplingKind::Distributing => {
|
||||
|
||||
+199
-5
@@ -7,15 +7,19 @@
|
||||
//! translations for solids, Ux and Uy for 2D elements), nodes by label, then six per coupled reference point (points by
|
||||
//! name): translations along X, Y, Z and rotations about them.
|
||||
|
||||
use modelcraft_fem::beam::{Beam, BeamSection};
|
||||
use modelcraft_fem::dense::Matrix;
|
||||
use modelcraft_fem::element::{Formulation, Solid};
|
||||
use modelcraft_fem::plane::{Plane, Theory};
|
||||
use modelcraft_fem::shell::{Shell, ShellSection};
|
||||
use modelcraft_fem::truss::{Truss, young_modulus};
|
||||
use modelcraft_fem::{DofKind, DofLayout, DofSet, Elasticity, ElementDofs, PointStress, elasticity, face, plane};
|
||||
use modelcraft_model::nodal::edge_pressure;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use modelcraft_model::{ElementKind, Load, Model, Resolved, SectionKind, Step, Target, centrifugal_acceleration};
|
||||
use modelcraft_model::{ElementKind, Integration, Load, Model, Resolved, SectionKind, Step, Target, centrifugal_acceleration};
|
||||
|
||||
use crate::analysis::MassKind;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// An element ready for the kernels: a solid, or a 2D element (`theory`).
|
||||
@@ -138,6 +142,79 @@ impl Bar {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a shell or beam element is made of.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Section {
|
||||
Shell(ShellSection),
|
||||
Beam { section: BeamSection, orientation: [f64; 3] },
|
||||
}
|
||||
|
||||
/// A shell or beam element ready for its kernel: six degrees of freedom per node.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Structural {
|
||||
pub label: u32,
|
||||
pub kind: ElementKind,
|
||||
/// Indices into [`Mesh::labels`] (layout slots).
|
||||
pub nodes: Vec<usize>,
|
||||
pub xyz: Vec<[f64; 3]>,
|
||||
pub density: Option<f64>,
|
||||
pub section: Section,
|
||||
}
|
||||
|
||||
/// The kernel of a shell or beam element.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StructuralKernel {
|
||||
Shell(Shell),
|
||||
Beam(Beam),
|
||||
}
|
||||
|
||||
impl Structural {
|
||||
/// The unknowns every node of a shell or beam carries.
|
||||
pub const KINDS: DofSet = DofSet::STRUCTURAL;
|
||||
|
||||
pub fn kernel(&self) -> Result<StructuralKernel> {
|
||||
let k = match self.section {
|
||||
Section::Shell(s) => Shell::new(self.kind, &self.xyz, s).map(StructuralKernel::Shell),
|
||||
Section::Beam { section, orientation } => Beam::new(&self.xyz, orientation, section).map(StructuralKernel::Beam),
|
||||
};
|
||||
k.map_err(|e| Error::element(self.label, e))
|
||||
}
|
||||
}
|
||||
|
||||
impl StructuralKernel {
|
||||
pub fn stiffness(&self) -> modelcraft_fem::Result<Matrix> {
|
||||
match self {
|
||||
StructuralKernel::Shell(s) => s.stiffness(),
|
||||
StructuralKernel::Beam(b) => Ok(b.stiffness()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The consistent or lumped mass over every element degree of freedom.
|
||||
pub fn mass(&self, density: f64, kind: MassKind) -> modelcraft_fem::Result<Matrix> {
|
||||
Ok(match (self, kind) {
|
||||
(StructuralKernel::Shell(s), MassKind::Consistent) => s.mass(density)?,
|
||||
(StructuralKernel::Shell(s), MassKind::Lumped) => {
|
||||
let diagonal = s.lumped_mass(density)?;
|
||||
let mut m = Matrix::zeros(diagonal.len());
|
||||
for (i, v) in diagonal.iter().enumerate() {
|
||||
m.add(i, i, *v);
|
||||
}
|
||||
m
|
||||
}
|
||||
(StructuralKernel::Beam(b), MassKind::Consistent) => b.mass(density),
|
||||
(StructuralKernel::Beam(b), MassKind::Lumped) => b.lumped_mass(density),
|
||||
})
|
||||
}
|
||||
|
||||
/// Consistent nodal forces and moments of a uniform force per volume, six per node.
|
||||
pub fn body_force(&self, b: [f64; 3]) -> modelcraft_fem::Result<Vec<f64>> {
|
||||
match self {
|
||||
StructuralKernel::Shell(s) => s.body_force(b),
|
||||
StructuralKernel::Beam(beam) => Ok(beam.body_force(b)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations of a model's nodes: X and Y in a 2D model, else X, Y and Z. Trusses and
|
||||
/// translational discrete elements declare these.
|
||||
pub fn translation_kinds(planar: bool) -> DofSet {
|
||||
@@ -177,6 +254,10 @@ pub struct Mesh {
|
||||
/// The 2D theory when this is a 2D model (a model is 2D as a whole).
|
||||
pub theory: Option<Theory>,
|
||||
pub trusses: Vec<Bar>,
|
||||
/// Shells and beams, by label.
|
||||
pub structural: Vec<Structural>,
|
||||
/// What the solver changed or ignored in the model's sections.
|
||||
pub warnings: Vec<String>,
|
||||
/// Point masses, springs and dashpots ([`crate::discrete`]).
|
||||
pub discrete: crate::discrete::Discrete,
|
||||
}
|
||||
@@ -190,6 +271,11 @@ impl Mesh {
|
||||
self.trusses.binary_search_by_key(&label, |e| e.label).ok().and_then(|i| self.trusses.get(i))
|
||||
}
|
||||
|
||||
/// The index of a shell or beam element in [`Self::structural`].
|
||||
pub fn structural_index(&self, label: u32) -> Option<usize> {
|
||||
self.structural.binary_search_by_key(&label, |e| e.label).ok()
|
||||
}
|
||||
|
||||
/// The kinds trusses and translational discrete elements carry at their nodes.
|
||||
pub fn translation_kinds(&self) -> DofSet {
|
||||
translation_kinds(self.theory.is_some())
|
||||
@@ -239,6 +325,8 @@ impl Mesh {
|
||||
pub fn new(model: &Model, resolved: &Resolved, max_dofs: usize) -> Result<Mesh> {
|
||||
let mut elements = Vec::new();
|
||||
let mut trusses = Vec::new();
|
||||
let mut structural = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
// A model is 2D as a whole (the model check refuses a mix).
|
||||
let planar = model.sections().values().find_map(|s| Theory::from_section(&s.kind));
|
||||
if matches!(planar, Some(Theory::Axisymmetric))
|
||||
@@ -257,7 +345,11 @@ impl Mesh {
|
||||
SectionKind::Rod { area } => Some(area),
|
||||
_ => None,
|
||||
};
|
||||
if !matches!(section.kind, SectionKind::Solid {}) && theory.is_none() && rod.is_none() {
|
||||
let shell_or_beam = matches!(section.kind, SectionKind::Shell { .. } | SectionKind::Beam { .. });
|
||||
if shell_or_beam && planar.is_some() {
|
||||
return Err(Error::NotSupported(format!("{} sections in 2D models (section `{name}`)", section.kind.name())));
|
||||
}
|
||||
if !matches!(section.kind, SectionKind::Solid {}) && theory.is_none() && rod.is_none() && !shell_or_beam {
|
||||
return Err(Error::NotSupported(format!(
|
||||
"{} sections (section `{name}`); the solver has solid, 2D (plane stress, plane strain, axisymmetric) and truss elements so far",
|
||||
section.kind.name()
|
||||
@@ -272,6 +364,31 @@ impl Mesh {
|
||||
let elastic =
|
||||
material.elastic.as_ref().ok_or_else(|| Error::Invalid(format!("material `{}` has no elastic constants", section.material)))?;
|
||||
let set = resolved.sets.get(§ion.set).ok_or_else(|| Error::Invalid(format!("set `{}` is not resolved", section.set)))?;
|
||||
let bad = |e: modelcraft_fem::Error| match e {
|
||||
modelcraft_fem::Error::NotSupported(m) => Error::NotSupported(format!("section `{name}`: {m}")),
|
||||
e => Error::Invalid(format!("section `{name}`, material `{}`: {e}", section.material)),
|
||||
};
|
||||
let shared = match §ion.kind {
|
||||
SectionKind::Shell { thickness, offset, .. } => {
|
||||
if section.formulation.integration == Integration::Reduced {
|
||||
warnings.push(format!(
|
||||
"section `{name}`: shells are MITC elements with full integration; reduced integration is solved the same way"
|
||||
));
|
||||
}
|
||||
Some(Section::Shell(ShellSection::new(*thickness, *offset, elastic).map_err(bad)?))
|
||||
}
|
||||
SectionKind::Beam { profile, orientation } => {
|
||||
Some(Section::Beam { section: BeamSection::new(profile, elastic).map_err(bad)?, orientation: *orientation })
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(body) = shared {
|
||||
for &label in &set.elements {
|
||||
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
|
||||
structural.push((label, el.kind, el.nodes.clone(), material.density, body));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(area) = rod {
|
||||
let young =
|
||||
young_modulus(elastic).map_err(|e| Error::Invalid(format!("material `{}` of section `{name}`: {e}", section.material)))?;
|
||||
@@ -297,12 +414,14 @@ impl Mesh {
|
||||
}
|
||||
elements.sort_by_key(|e| e.0);
|
||||
trusses.sort_by_key(|e| e.0);
|
||||
structural.sort_by_key(|e| e.0);
|
||||
let translations = translation_kinds(planar.is_some());
|
||||
// What discrete elements need: at mesh nodes (some only they use), and at reference
|
||||
// points, which then get their six degrees of freedom like coupled ones.
|
||||
let needs: Vec<(u32, DofSet)> = crate::discrete::needs(resolved, translations).collect();
|
||||
let mut labels: Vec<u32> = elements.iter().flat_map(|e| e.2.iter().copied()).collect();
|
||||
labels.extend(trusses.iter().flat_map(|e| e.2.iter().copied()));
|
||||
labels.extend(structural.iter().flat_map(|e| e.2.iter().copied()));
|
||||
labels.extend(needs.iter().map(|n| n.0).filter(|l| model.node(*l).is_some()));
|
||||
labels.sort_unstable();
|
||||
labels.dedup();
|
||||
@@ -323,6 +442,9 @@ impl Mesh {
|
||||
for e in &trusses {
|
||||
layout.element(&e.2, modelcraft_fem::ElementDofs::new(translations));
|
||||
}
|
||||
for e in &structural {
|
||||
layout.element(&e.2, ElementDofs::new(Structural::KINDS));
|
||||
}
|
||||
for (label, kinds) in needs.iter().filter(|n| model.node(n.0).is_some()) {
|
||||
layout.node(*label, *kinds);
|
||||
}
|
||||
@@ -346,6 +468,8 @@ impl Mesh {
|
||||
layout,
|
||||
theory: planar,
|
||||
trusses: Vec::with_capacity(trusses.len()),
|
||||
structural: Vec::with_capacity(structural.len()),
|
||||
warnings,
|
||||
discrete: crate::discrete::Discrete::default(),
|
||||
};
|
||||
for (label, kind, nodes, formulation, d, density, theory, _) in elements {
|
||||
@@ -358,6 +482,11 @@ impl Mesh {
|
||||
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
|
||||
mesh.trusses.push(Bar { label, kind, nodes: idx, xyz: exyz, young, area, density });
|
||||
}
|
||||
for (label, kind, nodes, density, section) in structural {
|
||||
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();
|
||||
mesh.structural.push(Structural { label, kind, nodes: idx, xyz: exyz, density, section });
|
||||
}
|
||||
mesh.discrete = crate::discrete::Discrete::new(&mesh, resolved)?;
|
||||
Ok(mesh)
|
||||
}
|
||||
@@ -365,7 +494,12 @@ impl Mesh {
|
||||
/// The layout slots (mesh nodes, reference points) every element and discrete element
|
||||
/// couples, for the matrix pattern.
|
||||
pub fn blocks(&self) -> impl Iterator<Item = std::borrow::Cow<'_, [usize]>> {
|
||||
let elements = self.elements.iter().map(|e| e.nodes.as_slice()).chain(self.trusses.iter().map(|t| t.nodes.as_slice()));
|
||||
let elements = self
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| e.nodes.as_slice())
|
||||
.chain(self.trusses.iter().map(|t| t.nodes.as_slice()))
|
||||
.chain(self.structural.iter().map(|s| s.nodes.as_slice()));
|
||||
elements.map(std::borrow::Cow::Borrowed).chain(self.discrete.couplings().map(|dofs| {
|
||||
let mut slots: Vec<usize> = dofs.iter().filter_map(|d| self.layout.locate(*d).map(|(slot, _)| slot)).collect();
|
||||
slots.dedup();
|
||||
@@ -491,13 +625,16 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
"load `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh and no spring or point mass uses"
|
||||
)));
|
||||
}
|
||||
// Moments act on the rotations, which only reference points have.
|
||||
// Moments act on the rotations, which reference points and the nodes of shells
|
||||
// and beams have.
|
||||
let rotation = matches!(load, Load::Moment { .. });
|
||||
for (label, v) in resolved.nodal_loads.get(name).map(Vec::as_slice).unwrap_or_default() {
|
||||
let slot =
|
||||
mesh.layout.slot(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: node {label} belongs to no solid element")))?;
|
||||
let dofs = vector_dofs(mesh, slot, rotation).ok_or_else(|| {
|
||||
Error::Invalid(format!("moment `{name}`: solid nodes have no rotations; apply it to a coupled reference point"))
|
||||
Error::Invalid(format!(
|
||||
"moment `{name}`: node {label} has no rotations (only shell and beam nodes do); apply it to a coupled reference point"
|
||||
))
|
||||
})?;
|
||||
for (&dof, x) in dofs.iter().zip(v) {
|
||||
if *x != 0.0 && !active.get(dof).copied().unwrap_or(false) {
|
||||
@@ -564,6 +701,13 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
let rho = t.density.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} has no density", t.label)))?;
|
||||
bar_body(&mut f, mesh, t, acceleration.map(|a| rho * a))?;
|
||||
}
|
||||
for e in &mesh.structural {
|
||||
if chosen.is_some_and(|c| c.binary_search(&e.label).is_err()) {
|
||||
continue;
|
||||
}
|
||||
let rho = e.density.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} has no density", e.label)))?;
|
||||
structural_body(&mut f, mesh, e, acceleration.map(|a| rho * a))?;
|
||||
}
|
||||
// Point masses take gravity on the whole model.
|
||||
if set.is_none() {
|
||||
mesh.discrete.add_gravity(&mut f, *acceleration, mesh);
|
||||
@@ -575,6 +719,10 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
bar_body(&mut f, mesh, t, *vector)?;
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = mesh.structural_index(*label).and_then(|i| mesh.structural.get(i)) {
|
||||
structural_body(&mut f, mesh, e, *vector)?;
|
||||
continue;
|
||||
}
|
||||
let e = mesh.element(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: element {label} is not a solid element")))?;
|
||||
body(&mut f, mesh, e, *vector)?;
|
||||
}
|
||||
@@ -584,6 +732,9 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
if let Some(t) = mesh.trusses.iter().find(|t| chosen.is_none_or(|c| c.binary_search(&t.label).is_ok())) {
|
||||
return Err(Error::NotSupported(format!("centrifugal loads on trusses (load `{name}`, element {})", t.label)));
|
||||
}
|
||||
if let Some(e) = mesh.structural.iter().find(|e| chosen.is_none_or(|c| c.binary_search(&e.label).is_ok())) {
|
||||
return Err(Error::NotSupported(format!("centrifugal loads on shells and beams (load `{name}`, element {})", e.label)));
|
||||
}
|
||||
if set.is_none() && !mesh.discrete.masses.is_empty() {
|
||||
return Err(Error::NotSupported(format!("centrifugal loads on point masses (load `{name}`)")));
|
||||
}
|
||||
@@ -615,6 +766,49 @@ fn bar_body(f: &mut [f64], mesh: &Mesh, t: &Bar, b: [f64; 3]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consistent forces and moments of a uniform force per volume on a shell or beam.
|
||||
fn structural_body(f: &mut [f64], mesh: &Mesh, e: &Structural, b: [f64; 3]) -> Result<()> {
|
||||
let forces = e.kernel()?.body_force(b).map_err(|err| Error::element(e.label, err))?;
|
||||
let dofs =
|
||||
mesh.layout.element_dofs(&e.nodes, Structural::KINDS).ok_or_else(|| Error::Internal(format!("element {} degrees of freedom", e.label)))?;
|
||||
for (d, v) in dofs.iter().zip(&forces) {
|
||||
if let Some(x) = f.get_mut(*d) {
|
||||
*x += v;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The uniform force per volume a step's gravity and body forces put on each shell and beam
|
||||
/// element ([`Mesh::structural`] order): beam end forces are recovered with it.
|
||||
pub fn structural_body_forces(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Result<Vec<[f64; 3]>> {
|
||||
let mut out = vec![[0.0; 3]; mesh.structural.len()];
|
||||
for name in &step.loads {
|
||||
let load = model.load(name).ok_or_else(|| Error::Invalid(format!("load `{name}` is missing")))?;
|
||||
match load {
|
||||
Load::Gravity { acceleration, set } => {
|
||||
let chosen: Option<&[u32]> = set.as_ref().map(|s| resolved.sets.get(s).map(|r| r.elements.as_slice()).unwrap_or_default());
|
||||
for (e, b) in mesh.structural.iter().zip(out.iter_mut()) {
|
||||
if chosen.is_some_and(|c| c.binary_search(&e.label).is_err()) {
|
||||
continue;
|
||||
}
|
||||
let rho = e.density.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} has no density", e.label)))?;
|
||||
*b = [0, 1, 2].map(|k| b[k] + rho * acceleration[k]);
|
||||
}
|
||||
}
|
||||
Load::BodyForce { set, vector } => {
|
||||
for label in resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default() {
|
||||
if let Some(b) = mesh.structural_index(*label).and_then(|i| out.get_mut(i)) {
|
||||
*b = [0, 1, 2].map(|k| b[k] + vector[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn body(f: &mut [f64], mesh: &Mesh, e: &Elem, b: [f64; 3]) -> Result<()> {
|
||||
let forces = e.kernel()?.body_force(b).map_err(|err| Error::element(e.label, err))?;
|
||||
for (node, v) in e.nodes.iter().zip(forces) {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Benchmark suite: shell problems compared with reference values published in papers, each
|
||||
//! tagged with its source. These are not closed forms, so they are kept apart from the analytic
|
||||
//! verification suite (`structural.rs`, `verification.rs`), which is the gate; a benchmark checks
|
||||
//! convergence towards the published value within a stated margin.
|
||||
//!
|
||||
//! - **Scordelis–Lo roof** [benchmark: MacNeal & Harder 1985]: a cylindrical roof, R = 25,
|
||||
//! L = 50, t = 0.25, 80° arc, E = 4.32 · 10⁸, ν = 0, self-weight 90 per area, rigid
|
||||
//! diaphragms at the curved ends, straight edges free. Vertical deflection at the middle of a
|
||||
//! free edge 0.3024 (R. H. MacNeal, R. L. Harder, *A proposed standard set of problems to test
|
||||
//! finite element accuracy*, Finite Elements in Analysis and Design 1(1):3–20, 1985).
|
||||
//! - **Pinched cylinder** [benchmark: Belytschko et al. 1985]: R = 300, L = 600, t = 3,
|
||||
//! E = 3 · 10⁶, ν = 0.3, rigid diaphragms at both ends, two opposite unit forces at the middle.
|
||||
//! Deflection under a load 1.8248 · 10⁻⁵ (T. Belytschko, H. Stolarski, W. K. Liu, N. Carpenter,
|
||||
//! J. S.-J. Ong, *Stress projection for membrane and shear locking in shell finite elements*,
|
||||
//! CMAME 51(1–3):221–258, 1985).
|
||||
//!
|
||||
//! Both are modelled on one symmetric part (a quarter of the roof, an eighth of the cylinder)
|
||||
//! with quad4 (MITC4) shells on a structured mesh whose quadrilaterals are exactly flat.
|
||||
|
||||
use modelcraft_model::{
|
||||
BoundaryCondition, Distribution, Elastic, ElementKind, Formulation, Load, Material, Model, Procedure, Section, SectionKind, Set, Step, Symmetry,
|
||||
Target,
|
||||
};
|
||||
use modelcraft_solver::{Limits, solve_static};
|
||||
|
||||
/// A cylindrical shell patch: x from 0 to `length`, angle φ from 0 (top, z = R) to `arc`,
|
||||
/// y = R sin φ, z = R cos φ; n × n quad4 cells. Returns the model and the node at (x, φ).
|
||||
struct Cylinder {
|
||||
model: Model,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl Cylinder {
|
||||
fn new(n: usize, radius: f64, length: f64, arc: f64, thickness: f64, elastic: Elastic) -> Cylinder {
|
||||
let mut model = Model::default();
|
||||
let label = |i: usize, j: usize| (1 + i + (n + 1) * j) as u32;
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let (x, phi) = (length * i as f64 / n as f64, arc * j as f64 / n as f64);
|
||||
model.set_node(label(i, j), [x, radius * phi.sin(), radius * phi.cos()]).unwrap();
|
||||
}
|
||||
}
|
||||
let mut elements = Vec::new();
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let e = (1 + i + n * j) as u32;
|
||||
// Counter-clockwise seen from outside: the normal points away from the axis.
|
||||
model.set_element(e, ElementKind::Quad4, vec![label(i, j), label(i + 1, j), label(i + 1, j + 1), label(i, j + 1)]).unwrap();
|
||||
elements.push(e);
|
||||
}
|
||||
}
|
||||
model.add_set("shell", Set { elements, ..Set::default() }).unwrap();
|
||||
model.add_material("Mat", Material { elastic: Some(elastic), density: None, ..Material::default() }).unwrap();
|
||||
model
|
||||
.add_section(
|
||||
"shell",
|
||||
Section {
|
||||
set: "shell".into(),
|
||||
material: "Mat".into(),
|
||||
kind: SectionKind::Shell { thickness, offset: 0.0, points: 5 },
|
||||
formulation: Formulation::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
model.add_step(Step::new("Step", Procedure::Static {}), None).unwrap();
|
||||
Cylinder { model, n }
|
||||
}
|
||||
|
||||
fn label(&self, i: usize, j: usize) -> u32 {
|
||||
(1 + i + (self.n + 1) * j) as u32
|
||||
}
|
||||
|
||||
fn nodes(&self, f: impl Fn(usize, usize) -> bool) -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
for j in 0..=self.n {
|
||||
for i in 0..=self.n {
|
||||
if f(i, j) {
|
||||
out.push(self.label(i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
fn bc(&mut self, name: &str, nodes: Vec<u32>, bc: impl Fn(Target) -> BoundaryCondition) {
|
||||
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
|
||||
self.model.add_boundary_condition(name, bc(Target::Set(name.into()))).unwrap();
|
||||
self.model.set_boundary_condition_active("Step", name, true).unwrap();
|
||||
}
|
||||
|
||||
/// A rigid diaphragm at x = length: no motion in its plane (y, z) and no rotation about x.
|
||||
fn diaphragm(&mut self) {
|
||||
let n = self.n;
|
||||
let values = [None, Some(0.0), Some(0.0), Some(0.0), None, None];
|
||||
self.bc("diaphragm", self.nodes(|i, _| i == n), |target| BoundaryCondition::Displacement { target, values });
|
||||
}
|
||||
|
||||
fn symmetry(&mut self, name: &str, plane: Symmetry, f: impl Fn(usize, usize) -> bool) {
|
||||
let nodes = self.nodes(f);
|
||||
self.bc(name, nodes, |target| BoundaryCondition::Symmetry { target, plane });
|
||||
}
|
||||
|
||||
fn load(&mut self, name: &str, load: Load) {
|
||||
self.model.add_load(name, load).unwrap();
|
||||
self.model.set_load_active("Step", name, true).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The Scordelis–Lo roof's free-edge mid-span deflection on an n × n quarter mesh.
|
||||
fn scordelis_lo(n: usize) -> f64 {
|
||||
// A quarter: x from the mid-span (x = 0) to the diaphragm (x = 25), φ from the crown to the
|
||||
// free edge at 40°.
|
||||
let mut c = Cylinder::new(n, 25.0, 25.0, 40f64.to_radians(), 0.25, Elastic::Isotropic { young: 4.32e8, poisson: 0.0 });
|
||||
c.diaphragm();
|
||||
c.symmetry("midspan", Symmetry::X, |i, _| i == 0);
|
||||
c.symmetry("crown", Symmetry::Y, |_, j| j == 0);
|
||||
// Self-weight 90 per area: 360 per volume over t = 0.25.
|
||||
c.load("weight", Load::BodyForce { set: "shell".into(), vector: [0.0, 0.0, -360.0] });
|
||||
let r = solve_static(&c.model, None, &Limits::default()).unwrap();
|
||||
assert!((r.reaction_total[2] + r.applied_total[2]).abs() < 1e-9 * r.applied_total[2].abs());
|
||||
let k = r.nodes.binary_search(&c.label(0, n)).unwrap();
|
||||
-r.displacement[k][2]
|
||||
}
|
||||
|
||||
/// Scordelis–Lo reference deflection (MacNeal & Harder 1985).
|
||||
const SCORDELIS_LO: f64 = 0.3024;
|
||||
|
||||
/// [benchmark: MacNeal & Harder 1985] Scordelis–Lo roof on a 16 × 16 quarter mesh: within 2 %.
|
||||
#[test]
|
||||
fn benchmark_scordelis_lo_roof_coarse() {
|
||||
let ratio = scordelis_lo(16) / SCORDELIS_LO;
|
||||
assert!((ratio - 1.0).abs() < 0.02, "{ratio}");
|
||||
}
|
||||
|
||||
/// [benchmark: MacNeal & Harder 1985] Scordelis–Lo roof, free-edge mid-span deflection 0.3024:
|
||||
/// monotone convergence to within 1 % at 64 × 64.
|
||||
#[test]
|
||||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||||
fn benchmark_scordelis_lo_roof() {
|
||||
let results: Vec<(usize, f64, f64)> = [8, 16, 32, 64]
|
||||
.iter()
|
||||
.map(|&n| {
|
||||
let w = scordelis_lo(n);
|
||||
(n, w, w / SCORDELIS_LO)
|
||||
})
|
||||
.collect();
|
||||
eprintln!("Scordelis–Lo roof (reference {SCORDELIS_LO}): (cells per side, deflection, ratio) {results:.4?}");
|
||||
let (_, _, finest) = results[results.len() - 1];
|
||||
assert!((finest - 1.0).abs() < 0.01, "{results:?}");
|
||||
assert!(results.windows(2).all(|w| w[1].2 > w[0].2), "converges from below: {results:?}");
|
||||
}
|
||||
|
||||
/// [benchmark: Belytschko et al. 1985] Pinched cylinder with diaphragms, deflection under the
|
||||
/// load 1.8248 · 10⁻⁵.
|
||||
#[test]
|
||||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||||
fn benchmark_pinched_cylinder() {
|
||||
const REFERENCE: f64 = 1.8248e-5;
|
||||
let mut results = Vec::new();
|
||||
for n in [8, 16, 32] {
|
||||
// An eighth: x from the loaded section (x = 0) to the diaphragm (x = 300), φ from the
|
||||
// loaded top to the side (z = 0).
|
||||
let mut c = Cylinder::new(n, 300.0, 300.0, std::f64::consts::FRAC_PI_2, 3.0, Elastic::Isotropic { young: 3e6, poisson: 0.3 });
|
||||
c.diaphragm();
|
||||
c.symmetry("middle", Symmetry::X, |i, _| i == 0);
|
||||
c.symmetry("top", Symmetry::Y, |_, j| j == 0);
|
||||
c.symmetry("side", Symmetry::Z, |_, j| j == n);
|
||||
let top = c.label(0, 0);
|
||||
c.model.add_set("pinch", Set { nodes: vec![top], ..Set::default() }).unwrap();
|
||||
// A quarter of the unit force acts on the eighth.
|
||||
c.load("pinch", Load::Force { target: Target::Set("pinch".into()), vector: [0.0, 0.0, -0.25], distribution: Distribution::Total });
|
||||
let r = solve_static(&c.model, None, &Limits::default()).unwrap();
|
||||
let k = r.nodes.binary_search(&top).unwrap();
|
||||
let w = -r.displacement[k][2];
|
||||
results.push((n, w, w / REFERENCE));
|
||||
}
|
||||
eprintln!("pinched cylinder (reference {REFERENCE:e}): (cells per side, deflection, ratio) {results:.4?}");
|
||||
let (_, _, finest) = results[results.len() - 1];
|
||||
assert!((finest - 1.0).abs() < 0.02, "{results:?}");
|
||||
assert!(results.windows(2).all(|w| w[1].2 > w[0].2), "converges from below: {results:?}");
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
//! Analytic verification of the structural elements: shell patch tests (membrane and bending,
|
||||
//! quad4 and tri3, in a tilted plane), the simply supported plate under uniform pressure against
|
||||
//! Navier's series (deflection and first frequency), cantilever beams against Timoshenko and
|
||||
//! Euler–Bernoulli theory (deflection and frequencies), a portal frame against the
|
||||
//! slope-deflection hand calculation, and mixed meshes of solids with shells and rods.
|
||||
//!
|
||||
//! Every expected value is a closed form or a converged series; nothing comes from another
|
||||
//! program.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use modelcraft_model::{
|
||||
BeamProfile, BoundaryCondition, Distribution, Elastic, ElementFace, ElementKind, Formulation, Load, Material, Model, Procedure, Section,
|
||||
SectionKind, Set, Step, Target,
|
||||
};
|
||||
use modelcraft_solver::{FrequencyOptions, Limits, MassKind, StaticResult, solve_frequency, solve_static};
|
||||
|
||||
/// A model under construction: nodes and elements numbered from 1, one material, one step.
|
||||
struct Build {
|
||||
model: Model,
|
||||
xyz: BTreeMap<u32, [f64; 3]>,
|
||||
elements: u32,
|
||||
}
|
||||
|
||||
impl Build {
|
||||
fn new(elastic: Elastic, density: f64, procedure: Procedure) -> Build {
|
||||
let mut model = Model::default();
|
||||
model.add_material("Mat", Material { elastic: Some(elastic), density: Some(density), ..Material::default() }).unwrap();
|
||||
model.add_step(Step::new("Step", procedure), None).unwrap();
|
||||
Build { model, xyz: BTreeMap::new(), elements: 0 }
|
||||
}
|
||||
|
||||
fn node(&mut self, label: u32, xyz: [f64; 3]) {
|
||||
self.model.set_node(label, xyz).unwrap();
|
||||
self.xyz.insert(label, xyz);
|
||||
}
|
||||
|
||||
fn element(&mut self, kind: ElementKind, nodes: Vec<u32>) -> u32 {
|
||||
self.elements += 1;
|
||||
self.model.set_element(self.elements, kind, nodes).unwrap();
|
||||
self.elements
|
||||
}
|
||||
|
||||
fn section(&mut self, name: &str, elements: Vec<u32>, kind: SectionKind) {
|
||||
self.model.add_set(name, Set { elements, ..Set::default() }).unwrap();
|
||||
self.model.add_section(name, Section { set: name.into(), material: "Mat".into(), kind, formulation: Formulation::default() }).unwrap();
|
||||
}
|
||||
|
||||
fn nodes_where(&self, f: impl Fn([f64; 3]) -> bool) -> Vec<u32> {
|
||||
self.xyz.iter().filter(|(_, x)| f(**x)).map(|(l, _)| *l).collect()
|
||||
}
|
||||
|
||||
/// A boundary condition on the nodes where `f` holds: `values` per direction (None = free).
|
||||
fn hold(&mut self, name: &str, values: [Option<f64>; 6], f: impl Fn([f64; 3]) -> bool) {
|
||||
let nodes = self.nodes_where(f);
|
||||
self.hold_nodes(name, values, nodes);
|
||||
}
|
||||
|
||||
fn hold_nodes(&mut self, name: &str, values: [Option<f64>; 6], nodes: Vec<u32>) {
|
||||
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
|
||||
self.model.add_boundary_condition(name, BoundaryCondition::Displacement { target: Target::Set(name.into()), values }).unwrap();
|
||||
self.model.set_boundary_condition_active("Step", name, true).unwrap();
|
||||
}
|
||||
|
||||
fn load(&mut self, name: &str, load: Load) {
|
||||
self.model.add_load(name, load).unwrap();
|
||||
self.model.set_load_active("Step", name, true).unwrap();
|
||||
}
|
||||
|
||||
fn force(&mut self, name: &str, nodes: Vec<u32>, vector: [f64; 3]) {
|
||||
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
|
||||
self.load(name, Load::Force { target: Target::Set(name.into()), vector, distribution: Distribution::Total });
|
||||
}
|
||||
|
||||
/// A structured surface mesh of n[0] × n[1] cells on the map of [0, 1]², quadrilaterals or
|
||||
/// two triangles per cell, starting at node label `base`; returns the elements.
|
||||
fn surface(&mut self, n: [usize; 2], kind: ElementKind, base: u32, map: impl Fn(f64, f64) -> [f64; 3]) -> Vec<u32> {
|
||||
let label = |i: usize, j: usize| base + (i + (n[0] + 1) * j) as u32;
|
||||
for j in 0..=n[1] {
|
||||
for i in 0..=n[0] {
|
||||
self.node(label(i, j), map(i as f64 / n[0] as f64, j as f64 / n[1] as f64));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for j in 0..n[1] {
|
||||
for i in 0..n[0] {
|
||||
let (a, b, c, d) = (label(i, j), label(i + 1, j), label(i + 1, j + 1), label(i, j + 1));
|
||||
if kind == ElementKind::Quad4 {
|
||||
out.push(self.element(kind, vec![a, b, c, d]));
|
||||
} else {
|
||||
out.push(self.element(kind, vec![a, b, c]));
|
||||
out.push(self.element(kind, vec![a, c, d]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn solve(&self) -> StaticResult {
|
||||
solve_static(&self.model, None, &Limits::default()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn at(r: &StaticResult, label: u32) -> usize {
|
||||
r.nodes.binary_search(&label).unwrap()
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
const FREE: Option<f64> = None;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shell patch tests
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The distorted patch of five quadrilaterals in a 0.24 × 0.12 rectangle (the irregular patch of
|
||||
/// R. H. MacNeal, R. L. Harder, *A proposed standard set of problems to test finite element
|
||||
/// accuracy*, Finite Elements in Analysis and Design 1(1):3–20, 1985), in local plane
|
||||
/// coordinates; the four inner nodes are 5–8.
|
||||
const PATCH: [[f64; 2]; 8] = [[0.0, 0.0], [0.24, 0.0], [0.24, 0.12], [0.0, 0.12], [0.04, 0.02], [0.18, 0.03], [0.16, 0.08], [0.08, 0.08]];
|
||||
/// Thick enough that membrane and bending stiffness are within a few orders of magnitude, so the
|
||||
/// comparison measures the elements and not the round-off of an ill-conditioned system.
|
||||
const PATCH_THICKNESS: f64 = 0.02;
|
||||
const PATCH_QUADS: [[u32; 4]; 5] = [[1, 2, 6, 5], [2, 3, 7, 6], [3, 4, 8, 7], [4, 1, 5, 8], [5, 6, 7, 8]];
|
||||
|
||||
/// A tilted plane: local (x, y) → global, with its axes.
|
||||
fn tilted(p: [f64; 2]) -> [f64; 3] {
|
||||
let (e1, e2) = axes();
|
||||
[0, 1, 2].map(|k| 1.0 + p[0] * e1[k] + p[1] * e2[k])
|
||||
}
|
||||
|
||||
fn axes() -> ([f64; 3], [f64; 3]) {
|
||||
let e1 = [0.6, 0.8, 0.0];
|
||||
let (c, s) = (0.5f64.sqrt(), 0.5f64.sqrt());
|
||||
// e2 = cos · (−0.8, 0.6, 0) + sin · z.
|
||||
let e2 = [-0.8 * c, 0.6 * c, s];
|
||||
(e1, e2)
|
||||
}
|
||||
|
||||
fn normal() -> [f64; 3] {
|
||||
let (a, b) = axes();
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
/// Local displacement (u, v, w) and rotations (θx, θy, θz) to global 6-vector.
|
||||
fn to_global(local: [f64; 6]) -> [f64; 6] {
|
||||
let (e1, e2) = axes();
|
||||
let e3 = normal();
|
||||
let g = |a: [f64; 3]| [0, 1, 2].map(|k| a[0] * e1[k] + a[1] * e2[k] + a[2] * e3[k]);
|
||||
let t = g([local[0], local[1], local[2]]);
|
||||
let r = g([local[3], local[4], local[5]]);
|
||||
[t[0], t[1], t[2], r[0], r[1], r[2]]
|
||||
}
|
||||
|
||||
/// The patch with a prescribed exact field on its boundary nodes 1–4: returns the worst nodal
|
||||
/// error of the inner nodes (relative to the field's size) and the solution.
|
||||
fn patch(kind: ElementKind, field: impl Fn([f64; 2]) -> [f64; 6]) -> (f64, StaticResult, Build) {
|
||||
let mut b = Build::new(Elastic::Isotropic { young: 1e6, poisson: 0.25 }, 1.0, Procedure::Static {});
|
||||
for (i, p) in PATCH.iter().enumerate() {
|
||||
b.node(i as u32 + 1, tilted(*p));
|
||||
}
|
||||
let mut elements = Vec::new();
|
||||
for q in PATCH_QUADS {
|
||||
if kind == ElementKind::Quad4 {
|
||||
elements.push(b.element(kind, q.to_vec()));
|
||||
} else {
|
||||
elements.push(b.element(kind, vec![q[0], q[1], q[2]]));
|
||||
elements.push(b.element(kind, vec![q[0], q[2], q[3]]));
|
||||
}
|
||||
}
|
||||
b.section("shell", elements, SectionKind::Shell { thickness: PATCH_THICKNESS, offset: 0.0, points: 5 });
|
||||
for (i, p) in PATCH.iter().enumerate().take(4) {
|
||||
let g = to_global(field(*p));
|
||||
b.hold_nodes(&format!("edge{i}"), g.map(Some), vec![i as u32 + 1]);
|
||||
}
|
||||
let r = b.solve();
|
||||
let size = PATCH.iter().flat_map(|p| field(*p)).fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
let mut worst = 0.0f64;
|
||||
for (i, p) in PATCH.iter().enumerate().skip(4) {
|
||||
let want = to_global(field(*p));
|
||||
let k = at(&r, i as u32 + 1);
|
||||
let got = [r.displacement[k], r.rotation[k]].concat();
|
||||
for c in 0..6 {
|
||||
worst = worst.max((got[c] - want[c]).abs() / size);
|
||||
}
|
||||
}
|
||||
(worst, r, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_membrane_patch_test() {
|
||||
// u = 10⁻³ (x + y/2), v = 10⁻³ (y + x/2): constant strain, no in-plane rotation.
|
||||
let field = |p: [f64; 2]| [1e-3 * (p[0] + p[1] / 2.0), 1e-3 * (p[1] + p[0] / 2.0), 0.0, 0.0, 0.0, 0.0];
|
||||
for kind in [ElementKind::Quad4, ElementKind::Tri3] {
|
||||
let (err, r, _) = patch(kind, field);
|
||||
// σ in local axes: E/(1 − ν²) [[1, ν], [ν, 1]] ε, τ = G γ.
|
||||
let (e, nu) = (1e6, 0.25);
|
||||
let c = e / (1.0 - nu * nu);
|
||||
let (sx, sy, txy) = (c * (1e-3 + nu * 1e-3), c * (1e-3 + nu * 1e-3), e / (2.0 * (1.0 + nu)) * 1e-3);
|
||||
let mut stress_err = 0.0f64;
|
||||
for s in &r.shells {
|
||||
let [a1, a2, _] = s.stress.axes;
|
||||
for layer in [&s.stress.bottom, &s.stress.middle, &s.stress.top] {
|
||||
for g in layer {
|
||||
// Back to the patch's local axes: σ_ab = a·S·b.
|
||||
let t = [[g[0], g[3], g[4]], [g[3], g[1], g[5]], [g[4], g[5], g[2]]];
|
||||
let (e1, e2) = axes();
|
||||
let p = |a: [f64; 3], b: [f64; 3]| (0..3).map(|i| (0..3).map(|j| a[i] * t[i][j] * b[j]).sum::<f64>()).sum::<f64>();
|
||||
stress_err = stress_err.max(((p(e1, e1) - sx).abs()).max((p(e2, e2) - sy).abs()).max((p(e1, e2) - txy).abs()) / sx);
|
||||
}
|
||||
}
|
||||
assert!(a1.iter().chain(&a2).all(|v| v.is_finite()));
|
||||
}
|
||||
eprintln!("membrane patch {kind:?}: displacement error {err:.1e}, stress error {stress_err:.1e}");
|
||||
assert!(err < 1e-9 && stress_err < 1e-9, "{kind:?}: {err:e} {stress_err:e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_bending_patch_test() {
|
||||
// w = 10⁻³ (x² + x y + y²)/2 with θx = w,y and θy = −w,x: constant curvature, no shear.
|
||||
let k = 1e-3;
|
||||
let field = |p: [f64; 2]| {
|
||||
let [x, y] = p;
|
||||
[0.0, 0.0, k * (x * x + x * y + y * y) / 2.0, k * (x / 2.0 + y), -k * (x + y / 2.0), 0.0]
|
||||
};
|
||||
for kind in [ElementKind::Quad4, ElementKind::Tri3] {
|
||||
let (err, r, _) = patch(kind, field);
|
||||
// κx = θy,x = −k, κy = −θx,y = −k, κxy = θy,y − θx,x = −k.
|
||||
let (e, nu, t) = (1e6, 0.25, PATCH_THICKNESS);
|
||||
let d = e * t * t * t / (12.0 * (1.0 - nu * nu));
|
||||
let want = [d * (-k - nu * k), d * (-k - nu * k), d * (1.0 - nu) / 2.0 * -k];
|
||||
let mut moment_err = 0.0f64;
|
||||
for s in &r.shells {
|
||||
// Local element axes may differ from the patch's in-plane axes by a rotation in the
|
||||
// plane; the invariants Mx + My and Mx My − Mxy² do not.
|
||||
for f in &s.stress.forces {
|
||||
let (tr, det) = (f[3] + f[4], f[3] * f[4] - f[5] * f[5]);
|
||||
let (wtr, wdet) = (want[0] + want[1], want[0] * want[1] - want[2] * want[2]);
|
||||
moment_err = moment_err.max(rel(tr, wtr)).max(rel(det, wdet)).max(f[6].abs().max(f[7].abs()) / wtr.abs());
|
||||
}
|
||||
}
|
||||
eprintln!("bending patch {kind:?}: displacement error {err:.1e}, moment error {moment_err:.1e}");
|
||||
assert!(err < 1e-9 && moment_err < 1e-8, "{kind:?}: {err:e} {moment_err:e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Simply supported plate (Navier)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Navier's series for the centre deflection of a simply supported square plate under uniform
|
||||
/// pressure: w = 16 q a⁴/(π⁶ D) Σ_{m, n odd} (−1)^((m+n)/2 − 1) / (m n (m² + n²)²)
|
||||
/// (S. Timoshenko, S. Woinowsky-Krieger, *Theory of Plates and Shells*, 2nd ed., McGraw-Hill
|
||||
/// 1959, §30).
|
||||
fn navier(q: f64, a: f64, d: f64) -> f64 {
|
||||
let mut s = 0.0;
|
||||
for m in (1..400).step_by(2) {
|
||||
for n in (1..400).step_by(2) {
|
||||
let (mf, nf) = (f64::from(m), f64::from(n));
|
||||
let sign = if ((m + n) / 2 - 1) % 2 == 0 { 1.0 } else { -1.0 };
|
||||
s += sign / (mf * nf * (mf * mf + nf * nf).powi(2));
|
||||
}
|
||||
}
|
||||
16.0 * q * a.powi(4) / (std::f64::consts::PI.powi(6) * d) * s
|
||||
}
|
||||
|
||||
/// A quarter of a simply supported square plate (side a, edges x = 0 and y = 0 held, symmetry
|
||||
/// on x = y = a/2), n × n cells, under pressure q or for its modes.
|
||||
fn plate(kind: ElementKind, n: usize, procedure: Procedure) -> Build {
|
||||
let (a, t, e, nu, rho) = (1.0, 0.01, 1e7, 0.3, 1.0);
|
||||
let mut b = Build::new(Elastic::Isotropic { young: e, poisson: nu }, rho, procedure);
|
||||
let elements = b.surface([n, n], kind, 1, |u, v| [u * a / 2.0, v * a / 2.0, 0.0]);
|
||||
b.section("plate", elements.clone(), SectionKind::Shell { thickness: t, offset: 0.0, points: 5 });
|
||||
let on = |x: f64, c: f64| (x - c).abs() < 1e-9;
|
||||
// Hard simple support: w and the twist along the edge held.
|
||||
b.hold("x0", [FREE, FREE, Some(0.0), Some(0.0), FREE, FREE], |p| on(p[0], 0.0));
|
||||
b.hold("y0", [FREE, FREE, Some(0.0), FREE, Some(0.0), FREE], |p| on(p[1], 0.0));
|
||||
b.hold("xs", [Some(0.0), FREE, FREE, FREE, Some(0.0), Some(0.0)], |p| on(p[0], a / 2.0));
|
||||
b.hold("ys", [FREE, Some(0.0), FREE, Some(0.0), FREE, Some(0.0)], |p| on(p[1], a / 2.0));
|
||||
if matches!(b.model.steps()[0].procedure, Procedure::Static {}) {
|
||||
let faces = elements.iter().map(|e| ElementFace { element: *e, face: 1 }).collect();
|
||||
b.model.add_set("top", Set { faces, ..Set::default() }).unwrap();
|
||||
b.load("q", Load::Pressure { set: "top".into(), magnitude: 1.0 });
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simply_supported_plate_deflection_and_frequency() {
|
||||
let (a, t, e, nu, rho) = (1.0, 0.01, 1e7, 0.3, 1.0);
|
||||
let d = e * t * t * t / (12.0 * (1.0 - nu * nu));
|
||||
let w = navier(1.0, a, d);
|
||||
let f11 = std::f64::consts::PI * 2.0 / (a * a) * (d / (rho * t)).sqrt() / 2.0;
|
||||
for kind in [ElementKind::Quad4, ElementKind::Tri3] {
|
||||
let mut errors = Vec::new();
|
||||
for n in [4, 8, 16] {
|
||||
let b = plate(kind, n, Procedure::Static {});
|
||||
let r = b.solve();
|
||||
let centre = b.nodes_where(|p| (p[0] - 0.5).abs() < 1e-9 && (p[1] - 0.5).abs() < 1e-9)[0];
|
||||
let got = -r.displacement[at(&r, centre)][2];
|
||||
assert!((r.reaction_total[2] + r.applied_total[2]).abs() < 1e-9 * r.applied_total[2].abs());
|
||||
let b = plate(kind, n, Procedure::Frequency { modes: 1, min_hz: None, max_hz: None });
|
||||
let fr = solve_frequency(&b.model, None, &FrequencyOptions::default(), &Limits::default()).unwrap();
|
||||
errors.push((n, rel(got, w), rel(fr.modes[0].frequency, f11)));
|
||||
}
|
||||
eprintln!("plate {kind:?} vs Navier w = {w:.6e}, f11 = {f11:.6}: (cells, deflection error, frequency error) {errors:.5?}");
|
||||
let (_, dw, df) = errors[2];
|
||||
assert!(dw < 0.01 && df < 0.01, "{kind:?} {errors:?}");
|
||||
assert!(errors[0].1 > errors[2].1, "converges: {errors:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Beams
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A straight cantilever along a skew direction, `n` beam elements, clamped at node 1.
|
||||
fn cantilever(n: usize, length: f64, profile: BeamProfile, procedure: Procedure) -> (Build, [f64; 3], [f64; 3]) {
|
||||
let dir = [2.0 / 3.0, 2.0 / 3.0, 1.0 / 3.0];
|
||||
let orientation = [0.0, 0.0, 1.0];
|
||||
let mut b = Build::new(Elastic::Isotropic { young: 210e3, poisson: 0.3 }, 7.85e-9, procedure);
|
||||
for i in 0..=n {
|
||||
let s = length * i as f64 / n as f64;
|
||||
b.node(i as u32 + 1, dir.map(|c| c * s));
|
||||
}
|
||||
let elements: Vec<u32> = (1..=n as u32).map(|i| b.element(ElementKind::Beam2, vec![i, i + 1])).collect();
|
||||
b.section("beam", elements, SectionKind::Beam { profile, orientation });
|
||||
b.hold_nodes("clamp", [Some(0.0); 6], vec![1]);
|
||||
// Local y: the orientation made normal to the axis; local z = x × y.
|
||||
let dot = dir[2];
|
||||
let y = [0, 1, 2].map(|k| orientation[k] - dot * dir[k]);
|
||||
let ny = (y[0] * y[0] + y[1] * y[1] + y[2] * y[2]).sqrt();
|
||||
let y = y.map(|c| c / ny);
|
||||
let z = [dir[1] * y[2] - dir[2] * y[1], dir[2] * y[0] - dir[0] * y[2], dir[0] * y[1] - dir[1] * y[0]];
|
||||
(b, y, z)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cantilever_beam_deflection_is_exact() {
|
||||
// A stocky I beam, so shear deformation matters: δ = P L³/(3 E I) + P L/(G A_s) in each plane.
|
||||
let profile = BeamProfile::I { height: 200.0, width: 100.0, web: 6.0, flange: 10.0 };
|
||||
let length: f64 = 1000.0;
|
||||
let p = modelcraft_fem::beam::properties(&profile, 0.3);
|
||||
let (e, g) = (210e3, 210e3 / 2.6);
|
||||
for n in [1, 4] {
|
||||
for (along_y, name) in [(true, "weak"), (false, "strong")] {
|
||||
let (mut b, y, z) = cantilever(n, length, profile, Procedure::Static {});
|
||||
let d = if along_y { y } else { z };
|
||||
b.force("tip", vec![n as u32 + 1], d.map(|c| 1000.0 * c));
|
||||
let r = b.solve();
|
||||
let u = r.displacement[at(&r, n as u32 + 1)];
|
||||
let got = u[0] * d[0] + u[1] * d[1] + u[2] * d[2];
|
||||
let (i, a_s) = if along_y { (p.iz, p.shear_y) } else { (p.iy, p.shear_z) };
|
||||
let want = 1000.0 * length.powi(3) / (3.0 * e * i) + 1000.0 * length / (g * a_s);
|
||||
eprintln!(
|
||||
"cantilever {n} element(s), {name} axis: tip {got:.6e} vs {want:.6e} (shear share {:.1}%)",
|
||||
100.0 * 1000.0 * length / (g * a_s) / want
|
||||
);
|
||||
assert!(rel(got, want) < 1e-9, "{got} {want}");
|
||||
// The clamp's moment and the root section force.
|
||||
let m = r.reaction_moment[at(&r, 1)];
|
||||
let mm = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2]).sqrt();
|
||||
assert!(rel(mm, 1000.0 * length) < 1e-9, "{m:?}");
|
||||
let root = &r.beams[0].forces.ends[0];
|
||||
let (shear, moment) = if along_y { (root[1], root[5]) } else { (root[2], root[4]) };
|
||||
assert!(rel(shear.abs(), 1000.0) < 1e-9 && rel(moment.abs(), 1000.0 * length) < 1e-9, "{root:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cantilever_beam_frequencies() {
|
||||
// A slender pipe, L/r = 1000 (shear and rotary inertia below 10⁻⁴ in the third mode):
|
||||
// Euler–Bernoulli ω_k = (β_k L)² √(E I / (ρ A L⁴)).
|
||||
let profile = BeamProfile::Pipe { radius: 10.0, thickness: 1.0 };
|
||||
let length: f64 = 10000.0;
|
||||
let p = modelcraft_fem::beam::properties(&profile, 0.3);
|
||||
let base = (210e3 * p.iy / (7.85e-9 * p.area * length.powi(4))).sqrt() / (2.0 * std::f64::consts::PI);
|
||||
let beta = [1.875_104_068_711_961, 4.694_091_132_974_175, 7.854_757_438_237_613];
|
||||
for mass in [MassKind::Consistent, MassKind::Lumped] {
|
||||
let (b, _, _) = cantilever(40, length, profile, Procedure::Frequency { modes: 6, min_hz: None, max_hz: None });
|
||||
let r = solve_frequency(&b.model, None, &FrequencyOptions { mass, shift: None }, &Limits::default()).unwrap();
|
||||
// The pipe bends alike in both planes: modes come in pairs.
|
||||
let errors: Vec<f64> = beta.iter().enumerate().map(|(k, bl)| r.modes[2 * k].frequency / (bl * bl * base) - 1.0).collect();
|
||||
eprintln!("cantilever frequencies ({mass:?}, 40 elements) vs Euler–Bernoulli: {errors:.5?}");
|
||||
let mass_total = 7.85e-9 * p.area * length;
|
||||
assert!(rel(r.total_mass, mass_total) < 1e-12, "{} {mass_total}", r.total_mass);
|
||||
for (k, e) in errors.iter().enumerate() {
|
||||
// Lumped mass converges from below, a few 10⁻³ low at 40 elements.
|
||||
let tolerance = if mass == MassKind::Consistent { 5e-4 } else { 5e-3 };
|
||||
assert!(e.abs() < tolerance, "mode {k}: {e}");
|
||||
assert!(rel(r.modes[2 * k].frequency, r.modes[2 * k + 1].frequency) < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_frame_matches_the_slope_deflection_solution() {
|
||||
// Columns of height h, a beam of span l, fixed bases, a horizontal load H at the top.
|
||||
// Slope-deflection with Timoshenko members (Przemieniecki 1968, §5.6): with
|
||||
// a = E I_c / (h (1 + Φ_c)) and b = E I_b / (l (1 + Φ_b)), Φ = 12 E I / (G A_s L²), the end
|
||||
// moments are M_ij = k [(4 + Φ) θ_i + (2 − Φ) θ_j − 6 Δ/L]; antisymmetric sway gives the joint
|
||||
// rotation θ = 6 a Δ / (h (a (4 + Φ_c) + 6 b)), the storey shear
|
||||
// a (6 θ − 12 Δ/h) = −H h / 2, and the base moment M = a ((2 − Φ_c) θ − 6 Δ/h). The columns'
|
||||
// axial deformation is left out of the hand calculation: their shortening and lengthening
|
||||
// under the overturning moment tilts the girder by about 2 H h² / (l² E A), about 2.6 · 10⁻³
|
||||
// of θ here; sway and base moment feel it far less.
|
||||
let (h, l, load) = (8000.0, 10000.0, 1000.0);
|
||||
let column = BeamProfile::Rectangle { width: 100.0, height: 100.0 };
|
||||
let girder = BeamProfile::Rectangle { width: 100.0, height: 200.0 };
|
||||
let e = 210e3;
|
||||
let mut b = Build::new(Elastic::Isotropic { young: e, poisson: 0.3 }, 7.85e-9, Procedure::Static {});
|
||||
let n = 8;
|
||||
// Nodes: left column 1..=n+1 (base to top), beam to the right column top, right column down.
|
||||
let mut path = Vec::new();
|
||||
for i in 0..=n {
|
||||
path.push([0.0, 0.0, h * i as f64 / n as f64]);
|
||||
}
|
||||
for i in 1..=n {
|
||||
path.push([l * i as f64 / n as f64, 0.0, h]);
|
||||
}
|
||||
for i in 1..=n {
|
||||
path.push([l, 0.0, h - h * i as f64 / n as f64]);
|
||||
}
|
||||
for (i, x) in path.iter().enumerate() {
|
||||
b.node(i as u32 + 1, *x);
|
||||
}
|
||||
let count = path.len() as u32;
|
||||
let mut columns = Vec::new();
|
||||
let mut beam = Vec::new();
|
||||
for i in 1..count {
|
||||
let el = b.element(ElementKind::Beam2, vec![i, i + 1]);
|
||||
if (n as u32 + 1..2 * n as u32 + 1).contains(&i) { beam.push(el) } else { columns.push(el) }
|
||||
}
|
||||
// Both members bend in the x–z plane: their profile height (local z) must lie in it, so
|
||||
// the orientation (local y, the width) is along global y.
|
||||
b.section("columns", columns, SectionKind::Beam { profile: column, orientation: [0.0, 1.0, 0.0] });
|
||||
b.section("girder", beam, SectionKind::Beam { profile: girder, orientation: [0.0, 1.0, 0.0] });
|
||||
b.hold_nodes("bases", [Some(0.0); 6], vec![1, count]);
|
||||
b.force("push", vec![n as u32 + 1], [load, 0.0, 0.0]);
|
||||
let r = b.solve();
|
||||
let g = e / 2.6;
|
||||
let (pc, pb) = (modelcraft_fem::beam::properties(&column, 0.3), modelcraft_fem::beam::properties(&girder, 0.3));
|
||||
let (phi_c, phi_b) = (12.0 * e * pc.iy / (g * pc.shear_z * h * h), 12.0 * e * pb.iy / (g * pb.shear_z * l * l));
|
||||
let (a, bb) = (e * pc.iy / (h * (1.0 + phi_c)), e * pb.iy / (l * (1.0 + phi_b)));
|
||||
let c = 6.0 * a / (h * (a * (4.0 + phi_c) + 6.0 * bb));
|
||||
let sway = -load * h / 2.0 / (a * (6.0 * c - 12.0 / h));
|
||||
let theta = c * sway;
|
||||
let base = a * ((2.0 - phi_c) * theta - 6.0 * sway / h);
|
||||
let top = r.displacement[at(&r, n as u32 + 1)][0];
|
||||
let m = r.reaction_moment[at(&r, 1)][1];
|
||||
let rot = r.rotation[at(&r, n as u32 + 1)][1];
|
||||
eprintln!(
|
||||
"portal frame: sway {top:.6e} vs {sway:.6e} ({:.1e}), joint rotation {rot:.6e} vs {theta:.6e} ({:.1e}), base moment {m:.6e} vs {:.6e} ({:.1e})",
|
||||
rel(top, sway),
|
||||
rel(rot.abs(), theta.abs()),
|
||||
base.abs(),
|
||||
rel(m.abs(), base.abs())
|
||||
);
|
||||
assert!(rel(top, sway) < 5e-4 && rel(rot.abs(), theta.abs()) < 3e-3 && rel(m.abs(), base.abs()) < 5e-4);
|
||||
assert!((r.reaction_total[0] + load).abs() < 1e-9 * load);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Mixed meshes
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A hex8 column 1 × 1 × 2 (two elements) clamped at z = 0, with four rods of area 1/4 from its
|
||||
/// top corners up to four loaded nodes: the elongation is the sum of both parts' (ν = 0).
|
||||
#[test]
|
||||
fn rods_continue_a_solid_column() {
|
||||
let e = 1000.0;
|
||||
let mut b = Build::new(Elastic::Isotropic { young: e, poisson: 0.0 }, 1.0, Procedure::Static {});
|
||||
let corners = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
for z in 0..3 {
|
||||
for (i, c) in corners.iter().enumerate() {
|
||||
b.node(1 + i as u32 + 4 * z, [c[0], c[1], f64::from(z)]);
|
||||
}
|
||||
}
|
||||
for (i, c) in corners.iter().enumerate() {
|
||||
b.node(13 + i as u32, [c[0], c[1], 5.0]);
|
||||
}
|
||||
let hexes: Vec<u32> = (0..2).map(|k| b.element(ElementKind::Hex8, (1..=8).map(|n| n + 4 * k).collect())).collect();
|
||||
let rods: Vec<u32> = (0..4).map(|i| b.element(ElementKind::Rod2, vec![9 + i, 13 + i])).collect();
|
||||
b.section("column", hexes, SectionKind::Solid {});
|
||||
b.section("rods", rods, SectionKind::Rod { area: 0.25 });
|
||||
b.hold_nodes("base", [Some(0.0), Some(0.0), Some(0.0), FREE, FREE, FREE], vec![1, 2, 3, 4]);
|
||||
// Rods carry no lateral stiffness: hold their ends sideways.
|
||||
b.hold_nodes("guides", [Some(0.0), Some(0.0), FREE, FREE, FREE, FREE], vec![13, 14, 15, 16]);
|
||||
b.force("pull", vec![13, 14, 15, 16], [0.0, 0.0, 4.0]);
|
||||
let r = b.solve();
|
||||
let want = 4.0 * 2.0 / (e * 1.0) + 4.0 * 3.0 / (e * 1.0);
|
||||
for n in 13..=16 {
|
||||
let got = r.displacement[at(&r, n)][2];
|
||||
assert!(rel(got, want) < 1e-12, "{got} {want}");
|
||||
}
|
||||
let rods: Vec<_> = r.elements.iter().filter(|e| e.kind == ElementKind::Rod2).collect();
|
||||
assert_eq!(rods.len(), 4);
|
||||
assert!(rods.iter().flat_map(|e| &e.nodal).all(|s| rel(modelcraft_fem::von_mises(s), 4.0) < 1e-12), "{rods:?}");
|
||||
// Solid nodes have no rotations.
|
||||
assert!(r.rotation.iter().all(|x| *x == [0.0; 3]));
|
||||
}
|
||||
|
||||
/// A hex8 slab with a shell skin on its top face sharing the nodes, stretched by prescribed end
|
||||
/// displacements: the reaction is E (A_solid + A_shell) ε (ν = 0, uniform strain in both).
|
||||
#[test]
|
||||
fn shell_skin_on_a_solid_slab() {
|
||||
let (e, length, width, depth, skin) = (1000.0, 4.0, 2.0, 1.0, 0.1);
|
||||
let mut b = Build::new(Elastic::Isotropic { young: e, poisson: 0.0 }, 1.0, Procedure::Static {});
|
||||
let (nx, ny) = (4usize, 2usize);
|
||||
let label = |i: usize, j: usize, k: usize| (1 + i + (nx + 1) * (j + (ny + 1) * k)) as u32;
|
||||
for k in 0..2 {
|
||||
for j in 0..=ny {
|
||||
for i in 0..=nx {
|
||||
b.node(label(i, j, k), [length * i as f64 / nx as f64, width * j as f64 / ny as f64, depth * k as f64]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut hexes = Vec::new();
|
||||
let mut quads = Vec::new();
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let q = |k| vec![label(i, j, k), label(i + 1, j, k), label(i + 1, j + 1, k), label(i, j + 1, k)];
|
||||
hexes.push(b.element(ElementKind::Hex8, [q(0), q(1)].concat()));
|
||||
quads.push(b.element(ElementKind::Quad4, q(1)));
|
||||
}
|
||||
}
|
||||
b.section("slab", hexes, SectionKind::Solid {});
|
||||
b.section("skin", quads, SectionKind::Shell { thickness: skin, offset: 0.0, points: 5 });
|
||||
let strain = 1e-3;
|
||||
b.hold("left", [Some(0.0), Some(0.0), Some(0.0), FREE, FREE, FREE], |p| p[0] == 0.0);
|
||||
b.hold("right", [Some(strain * length), Some(0.0), Some(0.0), FREE, FREE, FREE], |p| p[0] == length);
|
||||
let r = b.solve();
|
||||
let want = e * (width * depth + width * skin) * strain;
|
||||
let pull: f64 = b.nodes_where(|p| p[0] == length).iter().map(|n| r.reaction[at(&r, *n)][0]).sum();
|
||||
eprintln!("solid with shell skin: end reaction {pull:.9e} vs {want:.9e}");
|
||||
assert!(rel(pull, want) < 1e-10, "{pull}");
|
||||
let top = r.shells[0].stress.middle[0][0];
|
||||
assert!(rel(top, e * strain) < 1e-10, "{top}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Errors, not crashes
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn structural_models_that_cannot_be_solved_are_errors() {
|
||||
// A beam whose orientation is along it.
|
||||
let (mut b, _, _) = cantilever(2, 100.0, BeamProfile::Circle { radius: 1.0 }, Procedure::Static {});
|
||||
b.model
|
||||
.replace_section(
|
||||
"beam",
|
||||
Section {
|
||||
set: "beam".into(),
|
||||
material: "Mat".into(),
|
||||
kind: SectionKind::Beam { profile: BeamProfile::Circle { radius: 1.0 }, orientation: [2.0, 2.0, 1.0] },
|
||||
formulation: Formulation::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
b.force("tip", vec![3], [1.0, 0.0, 0.0]);
|
||||
let err = solve_static(&b.model, None, &Limits::default()).unwrap_err();
|
||||
assert!(err.to_string().contains("parallel"), "{err}");
|
||||
|
||||
// A quad8 shell is not supported yet; a degenerate quad4 is an error naming the element.
|
||||
for (kind, nodes, want) in [(ElementKind::Quad8, 8, "not supported"), (ElementKind::Quad4, 4, "degenerate")] {
|
||||
let mut b = Build::new(Elastic::Isotropic { young: 1.0, poisson: 0.0 }, 1.0, Procedure::Static {});
|
||||
for i in 0..nodes {
|
||||
b.node(i as u32 + 1, [f64::from(i as u8 % 2), 0.0, 0.0]);
|
||||
}
|
||||
let el = b.element(kind, (1..=nodes as u32).collect());
|
||||
b.section("s", vec![el], SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 });
|
||||
let err = solve_static(&b.model, None, &Limits::default()).unwrap_err();
|
||||
assert!(err.to_string().contains(want), "{kind:?}: {err}");
|
||||
}
|
||||
|
||||
// A moment on the nodes of a rod: they have no rotations.
|
||||
let mut b = Build::new(Elastic::Isotropic { young: 1.0, poisson: 0.0 }, 1.0, Procedure::Static {});
|
||||
b.node(1, [0.0; 3]);
|
||||
b.node(2, [1.0, 0.0, 0.0]);
|
||||
let el = b.element(ElementKind::Rod2, vec![1, 2]);
|
||||
b.section("r", vec![el], SectionKind::Rod { area: 1.0 });
|
||||
b.hold_nodes("all", [Some(0.0); 6], vec![1]);
|
||||
b.model.add_set("end", Set { nodes: vec![2], ..Set::default() }).unwrap();
|
||||
b.load("twist", Load::Moment { target: Target::Set("end".into()), vector: [1.0, 0.0, 0.0], distribution: Distribution::Total });
|
||||
let err = solve_static(&b.model, None, &Limits::default()).unwrap_err();
|
||||
assert!(err.to_string().contains("rotations"), "{err}");
|
||||
}
|
||||
Reference in new issue
Block a user