forked from 1b-agent/fieldcraft-upstream
1D elements I: trusses (rod2, rod3), point masses, rotary inertia, springs and dashpots (#38)
* Model, fem and decks: point masses, rotary inertia, springs, dashpots and rod3 trusses Model: PointMass and Spring definitions (spring | dashpot; to ground or two-point; along a degree of freedom, a vector or the line of the ends) on a set or reference point, resolved per node; ElementKind::Rod3. fem: rod2/rod3 truss kernel on Basis::Line2/Line3 declaring ElementDofs (translations). Decks: Abaqus T3D3, MASS, ROTARYI, SPRING1/2/A, DASHPOT1/2/A; Nastran CONM1, CELAS2, CDAMP2; LS-DYNA ELEMENT_MASS/INERTIA and ELEMENT_DISCRETE; what a format cannot take is reported. Commands model.mass.* and model.spring.*. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Implicit solver: trusses, point masses, rotary inertia and springs on the DofLayout Trusses declare their translations like solids; point masses, inertia and springs ask the layout for the kinds they need at their nodes (translations, rotations), and reference points they use get six degrees of freedom. The matrix pattern takes rows for those points; dashpots are ignored with a warning. Existing results are bit-identical (hash-checked). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Explicit solver: trusses, point masses, springs and dashpots on the node-major layout Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: fem.md on trusses and discrete elements in the layout; regenerate features.md Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: trusses in plane models and corotational explicit trusses 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:
53 files changed
+3626
-110
No files matched your search
@@ -12,7 +12,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};
|
||||
use crate::system::{Mesh, inelastic_warnings, loads, prescribed, translation_block};
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
/// Size and effort of a solution.
|
||||
@@ -147,6 +147,24 @@ fn assemble(mesh: &Mesh, graph: &NodeGraph, mass: Option<MassKind>) -> Result<As
|
||||
}
|
||||
}
|
||||
}
|
||||
let kinds = mesh.translation_kinds();
|
||||
for t in &mesh.trusses {
|
||||
let truss = t.truss()?;
|
||||
let at = |err| Error::element(t.label, err);
|
||||
let stiffness = translation_block(&truss.stiffness(t.young, t.area).map_err(at)?.data, t.nodes.len(), kinds);
|
||||
add_block(&mut k, layout, &t.nodes, kinds, &stiffness)?;
|
||||
if let (Some(mm), Some(kind)) = (m.as_mut(), mass) {
|
||||
let rho = t.density.ok_or_else(|| Error::Invalid(format!("element {} has no density", t.label)))?;
|
||||
match kind {
|
||||
MassKind::Consistent => add_scalar(mm, layout, &t.nodes, kinds, &truss.mass(rho, t.area).map_err(at)?.data)?,
|
||||
MassKind::Lumped => add_diagonal(mm, layout, &t.nodes, kinds, &truss.lumped_mass(rho, t.area).map_err(at)?)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
mesh.discrete.add_stiffness(&mut k)?;
|
||||
if let Some(mm) = m.as_mut() {
|
||||
mesh.discrete.add_mass(mm)?;
|
||||
}
|
||||
Ok(Assembled { k, m })
|
||||
}
|
||||
|
||||
@@ -184,6 +202,32 @@ fn split(full: &[f64], mesh: &Mesh) -> (Vec<[f64; 3]>, Vec<[f64; 6]>) {
|
||||
((0..l.nodes()).map(|n| directions(full, l, n)).collect(), (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)
|
||||
/// and the order of its nodes for the factorization.
|
||||
fn pattern(mesh: &Mesh) -> (NodeGraph, Vec<usize>) {
|
||||
let graph = NodeGraph::new(mesh.block_count(), mesh.blocks());
|
||||
// The order covers the mesh nodes; reference point equations follow them.
|
||||
let n = mesh.labels.len();
|
||||
let order = if graph.adjacency.len() == n {
|
||||
nested_dissection(&mesh.xyz, &graph.adjacency)
|
||||
} else {
|
||||
let mesh_part: Vec<Vec<usize>> = graph.adjacency.iter().take(n).map(|row| row.iter().copied().filter(|b| *b < n).collect()).collect();
|
||||
nested_dissection(&mesh.xyz, &mesh_part)
|
||||
};
|
||||
(graph, order)
|
||||
}
|
||||
|
||||
/// Warnings for what a step without velocities ignores.
|
||||
fn ignored_dashpots(mesh: &Mesh, warnings: &mut Vec<String>) {
|
||||
let names = mesh.discrete.dashpots();
|
||||
if !names.is_empty() {
|
||||
warnings.push(format!(
|
||||
"dashpots {} act in dynamic steps only; this step ignores them",
|
||||
names.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Constraint rows, prescribed values and their elimination for a step.
|
||||
/// The model, its resolved view, the mesh and the step being solved.
|
||||
struct Setup<'a> {
|
||||
@@ -210,8 +254,8 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
|
||||
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
|
||||
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
|
||||
warnings.extend(inelastic_warnings(model));
|
||||
let graph = NodeGraph::new(mesh.labels.len(), mesh.elements.iter().map(|e| e.nodes.as_slice()));
|
||||
let order = nested_dissection(&mesh.xyz, &graph.adjacency);
|
||||
let (graph, order) = pattern(&mesh);
|
||||
ignored_dashpots(&mesh, &mut warnings);
|
||||
let setup = Setup { model, resolved: &resolved, mesh: &mesh, step };
|
||||
let red = reduction(&setup, true, &order, budget(limits, &mesh, &graph, 1), &mut warnings)?;
|
||||
let f = loads(model, &resolved, &mesh, step, &red.active)?;
|
||||
@@ -276,10 +320,28 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
|
||||
}
|
||||
elements.push(ElementStress { label: e.label, kind: e.kind, points, nodal });
|
||||
}
|
||||
for t in &mesh.trusses {
|
||||
let truss = t.truss()?;
|
||||
let at = |err| Error::element(t.label, err);
|
||||
let ue: Vec<[f64; 3]> = t.nodes.iter().map(|n| displacement.get(*n).copied().unwrap_or_default()).collect();
|
||||
let points = truss.stresses(t.young, &ue).map_err(at)?;
|
||||
let nodal = truss.extrapolate(&points.iter().map(|p| p.stress).collect::<Vec<_>>()).map_err(at)?;
|
||||
for (n, s) in t.nodes.iter().zip(&nodal) {
|
||||
if let (Some(acc), Some(c)) = (sum.get_mut(*n), count.get_mut(*n)) {
|
||||
for k in 0..6 {
|
||||
acc[k] += s[k];
|
||||
}
|
||||
*c += 1;
|
||||
}
|
||||
}
|
||||
elements.push(ElementStress { label: t.label, kind: t.kind, points, nodal });
|
||||
}
|
||||
elements.sort_by_key(|e| e.label);
|
||||
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()));
|
||||
let imbalance = (0..3).map(|c| (applied_total[c] + reaction_total[c]).abs()).fold(0.0, f64::max);
|
||||
let ground = mesh.discrete.ground_force(&u, &mesh);
|
||||
let imbalance = (0..3).map(|c| (applied_total[c] + reaction_total[c] + ground[c]).abs()).fold(0.0, f64::max);
|
||||
if scale > 0.0 && imbalance > 1e-6 * scale {
|
||||
warnings.push(format!("loads and reactions differ by {imbalance:.3e} (relative {:.1e})", imbalance / scale));
|
||||
}
|
||||
@@ -338,14 +400,23 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
|
||||
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
|
||||
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
|
||||
warnings.extend(inelastic_warnings(model));
|
||||
let graph = NodeGraph::new(mesh.labels.len(), mesh.elements.iter().map(|e| e.nodes.as_slice()));
|
||||
let order = nested_dissection(&mesh.xyz, &graph.adjacency);
|
||||
let (graph, order) = pattern(&mesh);
|
||||
ignored_dashpots(&mesh, &mut warnings);
|
||||
let setup = Setup { model, resolved: &resolved, mesh: &mesh, step };
|
||||
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.
|
||||
let total_mass = m.val.iter().sum::<f64>() / if mesh.theory.is_some() { 2.0 } else { 3.0 };
|
||||
// (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);
|
||||
let mut translational = 0.0;
|
||||
for d in (0..m.n).filter(|d| translation(*d)) {
|
||||
for v in m.row(d).1 {
|
||||
translational += v;
|
||||
}
|
||||
}
|
||||
let total_mass = translational / mesh.translation_kinds().len() as f64;
|
||||
let neq = red.neq();
|
||||
if neq == 0 {
|
||||
return Err(Error::Invalid("every degree of freedom is held; there is nothing to vibrate".into()));
|
||||
|
||||
@@ -191,10 +191,15 @@ pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) ->
|
||||
}
|
||||
|
||||
/// The degrees of freedom that exist: every mesh node's, and the reference point directions
|
||||
/// some coupling row involves.
|
||||
/// some coupling row or discrete element involves.
|
||||
fn active_dofs(mesh: &Mesh, rows: &[Row]) -> Vec<bool> {
|
||||
let mut active = vec![false; mesh.dofs()];
|
||||
active.iter_mut().take(mesh.mesh_dofs()).for_each(|a| *a = true);
|
||||
for dof in mesh.discrete.dofs() {
|
||||
if let Some(a) = active.get_mut(dof) {
|
||||
*a = true;
|
||||
}
|
||||
}
|
||||
for row in rows {
|
||||
for (dof, c) in &row.terms {
|
||||
if *c != 0.0
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Point masses, rotary inertia, springs and dashpots in the implicit system.
|
||||
//!
|
||||
//! The model resolves each definition to its nodes ([`modelcraft_model::Resolved::masses`] and
|
||||
//! `springs`); here each copy is turned into global degrees of freedom and its matrix:
|
||||
//!
|
||||
//! - a point mass adds m_x, m_y, m_z on the node's translations and its inertia tensor J (3 × 3,
|
||||
//! off-diagonal products included) on its rotations, the same in the consistent and the lumped
|
||||
//! mass matrix;
|
||||
//! - a spring with stiffness k and unit direction coefficients c on each end's six degrees of
|
||||
//! freedom adds k c cᵀ to each end and −k c cᵀ between them (Cook, Malkus, Plesha, Witt 2002,
|
||||
//! §2.2): the elongation is cᵀ(u_second − u_first);
|
||||
//! - a dashpot has the same form on velocities; static and frequency steps have no velocities, so
|
||||
//! they ignore dashpots with a warning (damping enters dynamic steps).
|
||||
//!
|
||||
//! What each copy needs at its nodes ([`needs`]) goes into the mesh's [`modelcraft_fem::DofLayout`]
|
||||
//! like an element's declared kinds; [`dof_of`] then finds its unknowns there.
|
||||
|
||||
use modelcraft_fem::{DofKind, DofSet};
|
||||
use modelcraft_model::{Resolved, ResolvedSpring};
|
||||
|
||||
use crate::sparse::{Csr, add_entry};
|
||||
use crate::system::Mesh;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A point mass on global degrees of freedom.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MassAt {
|
||||
pub name: String,
|
||||
/// Translations along X, Y, Z: (degree of freedom, mass).
|
||||
pub translations: Vec<(usize, f64)>,
|
||||
/// The rotations' degrees of freedom and the inertia tensor on them (empty without inertia).
|
||||
pub rotations: Vec<usize>,
|
||||
pub inertia: [[f64; 3]; 3],
|
||||
}
|
||||
|
||||
/// A spring or dashpot on global degrees of freedom: the elongation is Σ c u over `terms`, whose
|
||||
/// coefficients are the direction's on the second end and their negatives on the first.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SpringAt {
|
||||
pub name: String,
|
||||
pub dashpot: bool,
|
||||
/// Connected to the ground (one end only).
|
||||
pub grounded: bool,
|
||||
pub terms: Vec<(usize, f64)>,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Every discrete element of the model on the mesh's degrees of freedom.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Discrete {
|
||||
pub masses: Vec<MassAt>,
|
||||
pub springs: Vec<SpringAt>,
|
||||
}
|
||||
|
||||
/// The kinds each discrete copy needs at the labels it acts on: translations for a mass,
|
||||
/// rotations for an inertia, translations or rotations for a spring or dashpot.
|
||||
/// `translations` are the model's (X and Y in a 2D model).
|
||||
pub fn needs(resolved: &Resolved, translations: DofSet) -> impl Iterator<Item = (u32, DofSet)> + '_ {
|
||||
let masses = resolved.masses.iter().flat_map(move |m| {
|
||||
let mass = m.mass.iter().any(|c| *c != 0.0).then_some((m.node, translations));
|
||||
let inertia = m.inertia.iter().any(|c| *c != 0.0).then_some((m.node, DofSet::ROTATIONS));
|
||||
mass.into_iter().chain(inertia)
|
||||
});
|
||||
// A node of a 3D model moves in all three directions (a spring along X does not take Y and
|
||||
// Z away), so a spring asks for every translation, or every rotation.
|
||||
let springs = resolved.springs.iter().flat_map(move |s| {
|
||||
let kinds = if s.direction.is_rotational() { DofSet::ROTATIONS } else { translations };
|
||||
std::iter::once((s.first, kinds)).chain(s.second.map(|b| (b, kinds)))
|
||||
});
|
||||
masses.chain(springs)
|
||||
}
|
||||
|
||||
/// The global degree of freedom of direction `d` (0–2 translations, 3–5 rotations) of a node or
|
||||
/// reference point label, or `None` when the node has no such direction.
|
||||
pub fn dof_of(mesh: &Mesh, label: u32, d: usize) -> Option<usize> {
|
||||
mesh.layout.dof(mesh.layout.slot(label)?, DofKind::from_direction(d)?)
|
||||
}
|
||||
|
||||
fn missing(what: &str, name: &str, label: u32, d: usize) -> Error {
|
||||
let direction = DofKind::from_direction(d).map_or("?", DofKind::describe);
|
||||
Error::Invalid(format!("{what} `{name}`: node {label} has no degree of freedom in {direction}"))
|
||||
}
|
||||
|
||||
/// The spring's terms at one end, with the direction's coefficients times `sign`.
|
||||
fn ends(mesh: &Mesh, s: &ResolvedSpring, label: u32, sign: f64) -> Result<Vec<(usize, f64)>> {
|
||||
s.direction
|
||||
.coefficients()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| **c != 0.0)
|
||||
.map(|(d, c)| dof_of(mesh, label, d).map(|dof| (dof, sign * c)).ok_or_else(|| missing(s.kind(), &s.name, label, d)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Discrete {
|
||||
/// The resolved discrete elements on the mesh's degrees of freedom.
|
||||
pub fn new(mesh: &Mesh, resolved: &Resolved) -> Result<Discrete> {
|
||||
let mut out = Discrete::default();
|
||||
for m in &resolved.masses {
|
||||
// The model's translations only (a 2D model's masses act in its plane).
|
||||
let planar = mesh.translation_kinds();
|
||||
let translations: Vec<(usize, f64)> = (0..3)
|
||||
.filter(|d| m.mass[*d] != 0.0 && DofKind::translation(*d).is_some_and(|k| planar.contains(k)))
|
||||
.map(|d| dof_of(mesh, m.node, d).map(|dof| (dof, m.mass[d])).ok_or_else(|| missing("point mass", &m.name, m.node, d)))
|
||||
.collect::<Result<_>>()?;
|
||||
let inertia = modelcraft_model::discrete::inertia_matrix(m.inertia);
|
||||
let rotations = if m.inertia.iter().any(|c| *c != 0.0) {
|
||||
(3..6).map(|d| dof_of(mesh, m.node, d).ok_or_else(|| missing("point mass", &m.name, m.node, d))).collect::<Result<_>>()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
out.masses.push(MassAt { name: m.name.clone(), translations, rotations, inertia });
|
||||
}
|
||||
for s in &resolved.springs {
|
||||
let mut terms = ends(mesh, s, s.first, -1.0)?;
|
||||
if let Some(b) = s.second {
|
||||
terms.extend(ends(mesh, s, b, 1.0)?);
|
||||
}
|
||||
out.springs.push(SpringAt { name: s.name.clone(), dashpot: s.dashpot, grounded: s.second.is_none(), terms, value: s.value });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The groups of degrees of freedom each element couples, for the matrix pattern.
|
||||
pub fn couplings(&self) -> impl Iterator<Item = Vec<usize>> + '_ {
|
||||
let masses = self.masses.iter().map(|m| m.translations.iter().map(|t| t.0).chain(m.rotations.iter().copied()).collect());
|
||||
masses.chain(self.springs.iter().map(|s| s.terms.iter().map(|t| t.0).collect()))
|
||||
}
|
||||
|
||||
/// Every degree of freedom a discrete element acts on.
|
||||
pub fn dofs(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
self.couplings().flatten()
|
||||
}
|
||||
|
||||
/// Names of the dashpots, which static and frequency steps ignore.
|
||||
pub fn dashpots(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = self.springs.iter().filter(|s| s.dashpot).map(|s| s.name.as_str()).collect();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
/// Add the springs' stiffness.
|
||||
pub fn add_stiffness(&self, k: &mut Csr) -> Result<()> {
|
||||
for s in self.springs.iter().filter(|s| !s.dashpot) {
|
||||
for (i, ci) in &s.terms {
|
||||
for (j, cj) in &s.terms {
|
||||
add_entry(k, *i, *j, s.value * ci * cj)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add the point masses and rotary inertia.
|
||||
pub fn add_mass(&self, m: &mut Csr) -> Result<()> {
|
||||
for p in &self.masses {
|
||||
for (d, v) in &p.translations {
|
||||
add_entry(m, *d, *d, *v)?;
|
||||
}
|
||||
for (a, da) in p.rotations.iter().enumerate() {
|
||||
for (b, db) in p.rotations.iter().enumerate() {
|
||||
let v = p.inertia[a][b];
|
||||
if v != 0.0 {
|
||||
add_entry(m, *da, *db, v)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sum of the forces springs to the ground exert on the model, along X, Y and Z (the
|
||||
/// loads they carry to the ground, besides the boundary conditions' reactions).
|
||||
pub fn ground_force(&self, u: &[f64], mesh: &Mesh) -> [f64; 3] {
|
||||
let mut total = [0.0; 3];
|
||||
for s in self.springs.iter().filter(|s| !s.dashpot && s.grounded) {
|
||||
// The spring's force on the model: −k (cᵀu) c over its terms.
|
||||
let stretch: f64 = s.terms.iter().map(|(d, c)| c * u.get(*d).copied().unwrap_or(0.0)).sum();
|
||||
for (d, c) in &s.terms {
|
||||
if let Some(t) = mesh.kind(*d).and_then(DofKind::direction).filter(|d| *d < 3).and_then(|d| total.get_mut(d)) {
|
||||
*t -= s.value * stretch * c;
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Gravity on the point masses: m_d g_d on each translation.
|
||||
pub fn add_gravity(&self, f: &mut [f64], g: [f64; 3], mesh: &Mesh) {
|
||||
for p in &self.masses {
|
||||
for (dof, m) in &p.translations {
|
||||
if let (Some(x), Some(a)) = (f.get_mut(*dof), mesh.kind(*dof).and_then(DofKind::direction).and_then(|d| g.get(d))) {
|
||||
*x += m * a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
pub mod analysis;
|
||||
pub mod cholesky;
|
||||
pub mod constraints;
|
||||
pub mod discrete;
|
||||
pub mod eigen;
|
||||
pub mod ordering;
|
||||
pub mod sparse;
|
||||
|
||||
@@ -103,10 +103,10 @@ impl NodeGraph {
|
||||
self.adjacency.iter().enumerate().map(|(a, row)| layout.range(a).len() * row.iter().map(|b| layout.range(*b).len()).sum::<usize>()).sum()
|
||||
}
|
||||
|
||||
/// A zero matrix over the mesh nodes' degrees of freedom (layout slots `0..nodes`) on this
|
||||
/// pattern.
|
||||
/// A zero matrix over the degrees of freedom of the graph's layout slots (the mesh nodes
|
||||
/// `0..nodes`, and reference points when the graph has rows for them) on this pattern.
|
||||
pub fn zero_matrix(&self, layout: &DofLayout) -> Csr {
|
||||
let n = layout.node_dofs();
|
||||
let n = self.adjacency.len().checked_sub(1).map_or(0, |last| layout.range(last).end);
|
||||
let mut ptr = Vec::with_capacity(n + 1);
|
||||
ptr.push(0);
|
||||
let mut idx = Vec::with_capacity(self.entries(layout));
|
||||
@@ -210,6 +210,14 @@ pub fn add_diagonal(k: &mut Csr, layout: &DofLayout, nodes: &[usize], kinds: Dof
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add `v` to entry (`i`, `j`), which must be in the pattern.
|
||||
pub fn add_entry(k: &mut Csr, i: usize, j: usize, v: f64) -> Result<()> {
|
||||
let (start, end) = (k.ptr.get(i).copied().unwrap_or(0), k.ptr.get(i + 1).copied().unwrap_or(0));
|
||||
let at = k.idx.get(start..end).and_then(|cols| cols.binary_search(&j).ok()).ok_or_else(|| Error::Internal("entry outside the pattern".into()))?;
|
||||
add_at(k, start + at, v);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+165
-11
@@ -1,5 +1,6 @@
|
||||
//! From the resolved model to the discrete system: the solid elements with their materials and
|
||||
//! formulations, the coupled reference points, the degrees of freedom, prescribed values and the
|
||||
//! From the resolved model to the discrete system: the solid, 2D and truss elements with their
|
||||
//! materials and formulations, point masses, springs and dashpots ([`crate::discrete`]), the
|
||||
//! reference points couplings or discrete elements use, the degrees of freedom, prescribed values and the
|
||||
//! load vector of a step.
|
||||
//!
|
||||
//! Degrees of freedom ([`DofLayout`]): the kinds each mesh node's elements declare (three
|
||||
@@ -8,6 +9,7 @@
|
||||
|
||||
use modelcraft_fem::element::{Formulation, Solid};
|
||||
use modelcraft_fem::plane::{Plane, Theory};
|
||||
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;
|
||||
@@ -117,6 +119,42 @@ impl Elem {
|
||||
}
|
||||
}
|
||||
|
||||
/// A truss element (rod2, rod3) ready for the kernel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bar {
|
||||
pub label: u32,
|
||||
pub kind: ElementKind,
|
||||
/// Indices into [`Mesh::labels`] (layout slots).
|
||||
pub nodes: Vec<usize>,
|
||||
pub xyz: Vec<[f64; 3]>,
|
||||
pub young: f64,
|
||||
pub area: f64,
|
||||
pub density: Option<f64>,
|
||||
}
|
||||
|
||||
impl Bar {
|
||||
pub fn truss(&self) -> Result<Truss<'_>> {
|
||||
Truss::new(self.kind, &self.xyz).map_err(|e| Error::element(self.label, e))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if planar { DofSet::PLANE } else { DofSet::TRANSLATIONS }
|
||||
}
|
||||
|
||||
/// Keep the rows and columns of a matrix over three translations per node (node by node) that
|
||||
/// `kinds` has: the in-plane block of a truss in a 2D model, or the matrix itself.
|
||||
pub fn translation_block(data: &[f64], nodes: usize, kinds: DofSet) -> Vec<f64> {
|
||||
if kinds == DofSet::TRANSLATIONS {
|
||||
return data.to_vec();
|
||||
}
|
||||
let keep: Vec<usize> = (0..3 * nodes).filter(|i| DofKind::translation(i % 3).is_some_and(|k| kinds.contains(k))).collect();
|
||||
let width = 3 * nodes;
|
||||
keep.iter().flat_map(|i| keep.iter().map(move |j| data.get(i * width + j).copied().unwrap_or(0.0))).collect()
|
||||
}
|
||||
|
||||
/// A reference point some coupling ties to the mesh.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Point {
|
||||
@@ -138,6 +176,9 @@ pub struct Mesh {
|
||||
pub layout: DofLayout,
|
||||
/// The 2D theory when this is a 2D model (a model is 2D as a whole).
|
||||
pub theory: Option<Theory>,
|
||||
pub trusses: Vec<Bar>,
|
||||
/// Point masses, springs and dashpots ([`crate::discrete`]).
|
||||
pub discrete: crate::discrete::Discrete,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
@@ -145,6 +186,15 @@ impl Mesh {
|
||||
self.labels.binary_search(&label).ok()
|
||||
}
|
||||
|
||||
pub fn truss(&self, label: u32) -> Option<&Bar> {
|
||||
self.trusses.binary_search_by_key(&label, |e| e.label).ok().and_then(|i| self.trusses.get(i))
|
||||
}
|
||||
|
||||
/// The kinds trusses and translational discrete elements carry at their nodes.
|
||||
pub fn translation_kinds(&self) -> DofSet {
|
||||
translation_kinds(self.theory.is_some())
|
||||
}
|
||||
|
||||
pub fn element(&self, label: u32) -> Option<&Elem> {
|
||||
self.elements.binary_search_by_key(&label, |e| e.label).ok().and_then(|i| self.elements.get(i))
|
||||
}
|
||||
@@ -188,17 +238,32 @@ impl Mesh {
|
||||
/// Build it from the model and its resolved view, refusing what the solver cannot represent.
|
||||
pub fn new(model: &Model, resolved: &Resolved, max_dofs: usize) -> Result<Mesh> {
|
||||
let mut elements = Vec::new();
|
||||
let mut trusses = 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))
|
||||
&& let Some(what) = model
|
||||
.sections()
|
||||
.values()
|
||||
.any(|s| matches!(s.kind, SectionKind::Rod { .. }))
|
||||
.then_some("trusses")
|
||||
.or_else(|| (!model.masses().is_empty() || !model.springs().is_empty()).then_some("point masses, springs and dashpots"))
|
||||
{
|
||||
return Err(Error::NotSupported(format!("{what} in axisymmetric models")));
|
||||
}
|
||||
for (name, section) in model.sections() {
|
||||
let theory = Theory::from_section(§ion.kind);
|
||||
if !matches!(section.kind, SectionKind::Solid {}) && theory.is_none() {
|
||||
let rod = match section.kind {
|
||||
SectionKind::Rod { area } => Some(area),
|
||||
_ => None,
|
||||
};
|
||||
if !matches!(section.kind, SectionKind::Solid {}) && theory.is_none() && rod.is_none() {
|
||||
return Err(Error::NotSupported(format!(
|
||||
"{} sections (section `{name}`); the solver has solid and 2D (plane stress, plane strain, axisymmetric) elements so far",
|
||||
"{} sections (section `{name}`); the solver has solid, 2D (plane stress, plane strain, axisymmetric) and truss elements so far",
|
||||
section.kind.name()
|
||||
)));
|
||||
}
|
||||
if theory.is_some() != planar.is_some() {
|
||||
if theory.is_some() != planar.is_some() && rod.is_none() {
|
||||
return Err(Error::NotSupported("2D and solid sections in one model".into()));
|
||||
}
|
||||
let material = model
|
||||
@@ -206,8 +271,17 @@ impl Mesh {
|
||||
.ok_or_else(|| Error::Invalid(format!("section `{name}`: material `{}` is missing", section.material)))?;
|
||||
let elastic =
|
||||
material.elastic.as_ref().ok_or_else(|| Error::Invalid(format!("material `{}` has no elastic constants", section.material)))?;
|
||||
let d = elasticity(elastic).map_err(|e| Error::Invalid(format!("material `{}`: {e}", section.material)))?;
|
||||
let set = resolved.sets.get(§ion.set).ok_or_else(|| Error::Invalid(format!("set `{}` is not resolved", section.set)))?;
|
||||
if let Some(area) = rod {
|
||||
let young =
|
||||
young_modulus(elastic).map_err(|e| Error::Invalid(format!("material `{}` of section `{name}`: {e}", section.material)))?;
|
||||
for &label in &set.elements {
|
||||
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
|
||||
trusses.push((label, el.kind, el.nodes.clone(), young, area, material.density));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let d = elasticity(elastic).map_err(|e| Error::Invalid(format!("material `{}`: {e}", section.material)))?;
|
||||
for &label in &set.elements {
|
||||
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
|
||||
let (formulation, dofs) = match theory {
|
||||
@@ -222,10 +296,19 @@ impl Mesh {
|
||||
return Err(Error::NotSupported(format!("couplings in 2D models (coupling `{name}`)")));
|
||||
}
|
||||
elements.sort_by_key(|e| e.0);
|
||||
trusses.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(needs.iter().map(|n| n.0).filter(|l| model.node(*l).is_some()));
|
||||
labels.sort_unstable();
|
||||
labels.dedup();
|
||||
let coupled: BTreeSet<&str> = model.couplings().values().map(|c| c.reference_point.as_str()).collect();
|
||||
let mut coupled: BTreeSet<&str> = model.couplings().values().map(|c| c.reference_point.as_str()).collect();
|
||||
let discrete_labels: BTreeSet<u32> = needs.iter().map(|n| n.0).collect();
|
||||
coupled.extend(model.reference_points().iter().filter(|(_, p)| discrete_labels.contains(&p.label)).map(|(n, _)| n.as_str()));
|
||||
let points: Vec<Point> = coupled
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
@@ -237,6 +320,12 @@ impl Mesh {
|
||||
for e in &elements {
|
||||
layout.element(&e.2, e.7);
|
||||
}
|
||||
for e in &trusses {
|
||||
layout.element(&e.2, modelcraft_fem::ElementDofs::new(translations));
|
||||
}
|
||||
for (label, kinds) in needs.iter().filter(|n| model.node(n.0).is_some()) {
|
||||
layout.node(*label, *kinds);
|
||||
}
|
||||
for p in &points {
|
||||
layout.point(p.label, DofSet::STRUCTURAL);
|
||||
}
|
||||
@@ -249,14 +338,46 @@ impl Mesh {
|
||||
.iter()
|
||||
.map(|l| model.node(*l).map(|n| n.xyz).ok_or_else(|| Error::Invalid(format!("node {l} is missing"))))
|
||||
.collect::<Result<_>>()?;
|
||||
let mut mesh = Mesh { labels, xyz, elements: Vec::with_capacity(elements.len()), points, layout, theory: planar };
|
||||
let mut mesh = Mesh {
|
||||
labels,
|
||||
xyz,
|
||||
elements: Vec::with_capacity(elements.len()),
|
||||
points,
|
||||
layout,
|
||||
theory: planar,
|
||||
trusses: Vec::with_capacity(trusses.len()),
|
||||
discrete: crate::discrete::Discrete::default(),
|
||||
};
|
||||
for (label, kind, nodes, formulation, d, density, theory, _) in elements {
|
||||
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
|
||||
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
|
||||
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density, theory });
|
||||
}
|
||||
for (label, kind, nodes, young, area, density) in trusses {
|
||||
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.trusses.push(Bar { label, kind, nodes: idx, xyz: exyz, young, area, density });
|
||||
}
|
||||
mesh.discrete = crate::discrete::Discrete::new(&mesh, resolved)?;
|
||||
Ok(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()));
|
||||
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();
|
||||
std::borrow::Cow::Owned(slots)
|
||||
}))
|
||||
}
|
||||
|
||||
/// The slots of the matrix pattern: the mesh nodes, and the reference points too when a
|
||||
/// discrete element acts on them (couplings tie points through constraints, not the matrix).
|
||||
pub fn block_count(&self) -> usize {
|
||||
if self.discrete.couplings().flatten().any(|d| d >= self.mesh_dofs()) { self.layout.slots() } else { self.labels.len() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The prescribed value of every degree of freedom (`None` = free) under a step's boundary
|
||||
@@ -279,8 +400,9 @@ pub fn prescribed(
|
||||
if let Target::ReferencePoint(rp) = bc.target()
|
||||
&& mesh.point(rp).is_none()
|
||||
{
|
||||
warnings
|
||||
.push(format!("boundary condition `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh; it holds nothing"));
|
||||
warnings.push(format!(
|
||||
"boundary condition `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh and no spring or point mass uses; it holds nothing"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
for slot in resolved.target_nodes(bc.target()).iter().filter_map(|l| mesh.layout.slot(*l)) {
|
||||
@@ -365,7 +487,9 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
if let Some(rp) = load.reference_point()
|
||||
&& mesh.point(rp).is_none()
|
||||
{
|
||||
return Err(Error::Invalid(format!("load `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh")));
|
||||
return Err(Error::Invalid(format!(
|
||||
"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.
|
||||
let rotation = matches!(load, Load::Moment { .. });
|
||||
@@ -433,15 +557,36 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
let rho = e.density.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} has no density", e.label)))?;
|
||||
body(&mut f, mesh, e, acceleration.map(|a| rho * a))?;
|
||||
}
|
||||
for t in &mesh.trusses {
|
||||
if chosen.is_some_and(|c| c.binary_search(&t.label).is_err()) {
|
||||
continue;
|
||||
}
|
||||
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))?;
|
||||
}
|
||||
// Point masses take gravity on the whole model.
|
||||
if set.is_none() {
|
||||
mesh.discrete.add_gravity(&mut f, *acceleration, mesh);
|
||||
}
|
||||
}
|
||||
Load::BodyForce { set, vector } => {
|
||||
for label in resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default() {
|
||||
if let Some(t) = mesh.truss(*label) {
|
||||
bar_body(&mut f, mesh, t, *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)?;
|
||||
}
|
||||
}
|
||||
Load::Centrifugal { angular_velocity, point, axis, set } => {
|
||||
let chosen: Option<&[u32]> = set.as_ref().map(|s| resolved.sets.get(s).map(|r| r.elements.as_slice()).unwrap_or_default());
|
||||
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 set.is_none() && !mesh.discrete.masses.is_empty() {
|
||||
return Err(Error::NotSupported(format!("centrifugal loads on point masses (load `{name}`)")));
|
||||
}
|
||||
for e in &mesh.elements {
|
||||
if chosen.is_some_and(|c| c.binary_search(&e.label).is_err()) {
|
||||
continue;
|
||||
@@ -461,6 +606,15 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// A force `b` per volume on a truss.
|
||||
fn bar_body(f: &mut [f64], mesh: &Mesh, t: &Bar, b: [f64; 3]) -> Result<()> {
|
||||
let forces = t.truss()?.body_force(b, t.area).map_err(|err| Error::element(t.label, err))?;
|
||||
for (node, v) in t.nodes.iter().zip(forces) {
|
||||
add(f, &translations(mesh, *node)?, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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,652 @@
|
||||
//! Trusses, point masses, rotary inertia and springs against closed-form solutions. Run with
|
||||
//! `--nocapture` to see the errors.
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
//!
|
||||
//! - Statically determinate trusses (a plane Pratt truss and a space tripod): the member forces
|
||||
//! are those of the method of joints, whatever the members' stiffness.
|
||||
//! - The three-bar truss (statically indeterminate): N = P cos²θ / (1 + 2 cos³θ) in the inclined
|
||||
//! bars, P / (1 + 2 cos³θ) in the vertical one.
|
||||
//! - rod3: constant-stress patch with off-centre middle nodes; a bar hanging under its own
|
||||
//! weight (quadratic displacement, exact at the nodes); curved members are checked in
|
||||
//! `modelcraft_fem::truss` (a ring's radial expansion gives the hoop strain δ/R exactly).
|
||||
//! - Spring–mass ω = √(k/m), springs along three skew directions, the two-mass chain, a bar with
|
||||
//! a tip mass ω = √(EA/(LM)), the torsional pendulum ω = √(k_t/J).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use modelcraft_fem::truss::axial;
|
||||
use modelcraft_model::*;
|
||||
use modelcraft_solver::{FrequencyOptions, FrequencyResult, Limits, MassKind, StaticResult, solve_frequency, solve_static};
|
||||
|
||||
const ZERO_Z: [Option<f64>; 6] = [None, None, Some(0.0), None, None, None];
|
||||
|
||||
/// A model built point by point.
|
||||
struct Build {
|
||||
m: Model,
|
||||
step: &'static str,
|
||||
}
|
||||
|
||||
impl Build {
|
||||
fn new(procedure: Procedure) -> Build {
|
||||
let mut m = Model::default();
|
||||
m.add_step(Step::new("Step", procedure), None).unwrap();
|
||||
Build { m, step: "Step" }
|
||||
}
|
||||
|
||||
fn statics() -> Build {
|
||||
Build::new(Procedure::Static {})
|
||||
}
|
||||
|
||||
fn modes(n: u32) -> Build {
|
||||
Build::new(Procedure::Frequency { modes: n, min_hz: None, max_hz: None })
|
||||
}
|
||||
|
||||
fn node(&mut self, label: u32, xyz: [f64; 3]) {
|
||||
self.m.set_node(label, xyz).unwrap();
|
||||
}
|
||||
|
||||
fn nodes(&mut self, name: &str, nodes: &[u32]) -> Target {
|
||||
self.m.add_set(name, Set { nodes: nodes.to_vec(), ..Set::default() }).unwrap();
|
||||
Target::Set(name.into())
|
||||
}
|
||||
|
||||
/// Members with one area and material.
|
||||
fn members(&mut self, name: &str, kind: ElementKind, members: &[(u32, Vec<u32>)], area: f64, young: f64, density: Option<f64>) {
|
||||
for (label, nodes) in members {
|
||||
self.m.set_element(*label, kind, nodes.clone()).unwrap();
|
||||
}
|
||||
self.m.add_set(name, Set { elements: members.iter().map(|m| m.0).collect(), ..Set::default() }).unwrap();
|
||||
self.m.add_material(name, Material { elastic: Some(Elastic::Isotropic { young, poisson: 0.3 }), density, ..Material::default() }).unwrap();
|
||||
self.m
|
||||
.add_section(
|
||||
name,
|
||||
Section { set: name.into(), material: name.into(), kind: SectionKind::Rod { area }, formulation: Formulation::default() },
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn bc(&mut self, name: &str, target: Target, values: [Option<f64>; 6]) {
|
||||
self.m.add_boundary_condition(name, BoundaryCondition::Displacement { target, values }).unwrap();
|
||||
self.m.set_boundary_condition_active(self.step, name, true).unwrap();
|
||||
}
|
||||
|
||||
fn fix(&mut self, name: &str, target: Target) {
|
||||
self.m.add_boundary_condition(name, BoundaryCondition::Fixed { target }).unwrap();
|
||||
self.m.set_boundary_condition_active(self.step, name, true).unwrap();
|
||||
}
|
||||
|
||||
fn load(&mut self, name: &str, load: Load) {
|
||||
self.m.add_load(name, load).unwrap();
|
||||
self.m.set_load_active(self.step, name, true).unwrap();
|
||||
}
|
||||
|
||||
fn force(&mut self, name: &str, node: u32, vector: [f64; 3]) {
|
||||
let target = self.nodes(name, &[node]);
|
||||
self.load(name, Load::Force { target, vector, distribution: Distribution::Total });
|
||||
}
|
||||
|
||||
fn spring(&mut self, name: &str, first: Target, second: Option<Target>, direction: Direction, stiffness: f64) {
|
||||
self.m.add_spring(name, Spring::Spring { first, second, direction, stiffness }).unwrap();
|
||||
}
|
||||
|
||||
fn mass(&mut self, name: &str, target: Target, mass: Option<Mass>, inertia: Option<[f64; 6]>) {
|
||||
self.m.add_mass(name, PointMass { target, mass, inertia }).unwrap();
|
||||
}
|
||||
|
||||
fn solve(&self) -> StaticResult {
|
||||
solve_static(&self.m, None, &Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
fn modes_of(&self, mass: MassKind) -> FrequencyResult {
|
||||
solve_frequency(&self.m, None, &FrequencyOptions { mass, shift: None }, &Limits::default()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn unit(a: [f64; 3]) -> [f64; 3] {
|
||||
let l = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
|
||||
a.map(|c| c / l)
|
||||
}
|
||||
|
||||
/// The axial force of each member at its first integration point: N = A tᵀ σ t.
|
||||
fn member_forces(m: &Model, r: &StaticResult, area_of: impl Fn(u32) -> f64) -> Vec<(u32, f64)> {
|
||||
r.elements
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let el = m.element(e.label).unwrap();
|
||||
let t = unit(sub(m.node(el.nodes[1]).unwrap().xyz, m.node(el.nodes[0]).unwrap().xyz));
|
||||
(e.label, area_of(e.label) * axial(&e.points[0].stress, t, false))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Gaussian elimination with partial pivoting (the method of joints' equations).
|
||||
fn solve_dense(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Vec<f64> {
|
||||
let n = b.len();
|
||||
for c in 0..n {
|
||||
let p = (c..n).max_by(|i, j| a[*i][c].abs().total_cmp(&a[*j][c].abs())).unwrap();
|
||||
a.swap(c, p);
|
||||
b.swap(c, p);
|
||||
assert!(a[c][c].abs() > 1e-12, "the truss is not statically determinate");
|
||||
for r in c + 1..n {
|
||||
let f = a[r][c] / a[c][c];
|
||||
for k in c..n {
|
||||
a[r][k] -= f * a[c][k];
|
||||
}
|
||||
b[r] -= f * b[c];
|
||||
}
|
||||
}
|
||||
let mut x = vec![0.0; n];
|
||||
for r in (0..n).rev() {
|
||||
x[r] = (b[r] - (r + 1..n).map(|k| a[r][k] * x[k]).sum::<f64>()) / a[r][r];
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Member forces by the method of joints: at every joint and direction, Σ N t + F + R = 0, with
|
||||
/// the reactions `supports` (node, direction) as unknowns too.
|
||||
fn method_of_joints(
|
||||
xyz: &[(u32, [f64; 3])],
|
||||
members: &[(u32, [u32; 2])],
|
||||
supports: &[(u32, usize)],
|
||||
loads: &[(u32, [f64; 3])],
|
||||
dims: usize,
|
||||
) -> Vec<(u32, f64)> {
|
||||
let row = |node: u32, d: usize| xyz.iter().position(|(l, _)| *l == node).unwrap() * dims + d;
|
||||
let pos = |node: u32| xyz.iter().find(|(l, _)| *l == node).unwrap().1;
|
||||
let n = xyz.len() * dims;
|
||||
assert_eq!(members.len() + supports.len(), n, "determinate");
|
||||
let mut a = vec![vec![0.0; n]; n];
|
||||
let mut b = vec![0.0; n];
|
||||
for (k, (_, [i, j])) in members.iter().enumerate() {
|
||||
let t = unit(sub(pos(*j), pos(*i)));
|
||||
for d in 0..dims {
|
||||
// Tension pulls joint i towards j and j towards i.
|
||||
a[row(*i, d)][k] += t[d];
|
||||
a[row(*j, d)][k] -= t[d];
|
||||
}
|
||||
}
|
||||
for (k, (node, d)) in supports.iter().enumerate() {
|
||||
a[row(*node, *d)][members.len() + k] = 1.0;
|
||||
}
|
||||
for (node, f) in loads {
|
||||
for d in 0..dims {
|
||||
b[row(*node, d)] -= f[d];
|
||||
}
|
||||
}
|
||||
let x = solve_dense(a, b);
|
||||
members.iter().zip(x).map(|((l, _), n)| (*l, n)).collect()
|
||||
}
|
||||
|
||||
fn worst_relative(fe: &[(u32, f64)], exact: &[(u32, f64)]) -> f64 {
|
||||
let scale = exact.iter().fold(0.0f64, |m, (_, n)| m.max(n.abs()));
|
||||
exact.iter().map(|(l, n)| (fe.iter().find(|(m, _)| m == l).unwrap().1 - n).abs() / scale).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// A plane Pratt truss of four panels, pinned at one end and on a roller at the other, loaded at
|
||||
/// the bottom joints; chords and web members have different areas. The out-of-plane direction is
|
||||
/// held. Member forces equal the method of joints'.
|
||||
#[test]
|
||||
fn pratt_truss_member_forces_by_the_method_of_joints() {
|
||||
let (a, h, p) = (3.0, 4.0, 10.0);
|
||||
let mut b = Build::statics();
|
||||
let mut xyz = Vec::new();
|
||||
for i in 0..5u32 {
|
||||
xyz.push((1 + i, [a * f64::from(i), 0.0, 0.0]));
|
||||
}
|
||||
for i in 1..4u32 {
|
||||
xyz.push((10 + i, [a * f64::from(i), h, 0.0]));
|
||||
}
|
||||
for (l, x) in &xyz {
|
||||
b.node(*l, *x);
|
||||
}
|
||||
let chords: Vec<(u32, [u32; 2])> = vec![(1, [1, 2]), (2, [2, 3]), (3, [3, 4]), (4, [4, 5]), (5, [11, 12]), (6, [12, 13])];
|
||||
let web: Vec<(u32, [u32; 2])> = vec![(7, [2, 11]), (8, [3, 12]), (9, [4, 13]), (10, [1, 11]), (11, [13, 5]), (12, [11, 3]), (13, [13, 3])];
|
||||
let as_members = |list: &[(u32, [u32; 2])]| list.iter().map(|(l, n)| (*l, n.to_vec())).collect::<Vec<_>>();
|
||||
b.members("chords", ElementKind::Rod2, &as_members(&chords), 2.0, 2.0e5, None);
|
||||
b.members("web", ElementKind::Rod2, &as_members(&web), 0.7, 7.0e4, None);
|
||||
let all: Vec<u32> = xyz.iter().map(|x| x.0).collect();
|
||||
let plane = b.nodes("all_nodes", &all);
|
||||
b.bc("plane", plane, ZERO_Z);
|
||||
let pin = b.nodes("pin", &[1]);
|
||||
b.bc("pin", pin, [Some(0.0), Some(0.0), None, None, None, None]);
|
||||
let roller = b.nodes("roller", &[5]);
|
||||
b.bc("roller", roller, [None, Some(0.0), None, None, None, None]);
|
||||
let loads = [(2, [0.0, -p, 0.0]), (3, [0.0, -1.5 * p, 0.0]), (4, [0.2 * p, -p, 0.0])];
|
||||
for (node, f) in loads {
|
||||
b.force(&format!("P{node}"), node, f);
|
||||
}
|
||||
let r = b.solve();
|
||||
let fe = member_forces(&b.m, &r, |l| if l <= 6 { 2.0 } else { 0.7 });
|
||||
let members: Vec<(u32, [u32; 2])> = chords.iter().chain(&web).copied().collect();
|
||||
let exact = method_of_joints(&xyz, &members, &[(1, 0), (1, 1), (5, 1)], &loads, 2);
|
||||
let err = worst_relative(&fe, &exact);
|
||||
eprintln!("Pratt truss: worst member force error {err:.2e} of the largest");
|
||||
assert!(err < 1e-10, "{fe:?} vs {exact:?}");
|
||||
// Reactions balance the loads.
|
||||
for d in 0..2 {
|
||||
assert!((r.applied_total[d] + r.reaction_total[d]).abs() < 1e-9 * p);
|
||||
}
|
||||
}
|
||||
|
||||
/// A space tripod: three members from an apex to three fixed points, a skew load at the apex.
|
||||
#[test]
|
||||
fn space_tripod_member_forces() {
|
||||
let (rad, h) = (2.0, 3.0);
|
||||
let mut b = Build::statics();
|
||||
let apex = (4, [0.1, -0.2, h]);
|
||||
let feet: Vec<(u32, [f64; 3])> = (0..3)
|
||||
.map(|i| (1 + i as u32, [rad * (PI / 2.0 + 2.0 * PI * i as f64 / 3.0).cos(), rad * (PI / 2.0 + 2.0 * PI * i as f64 / 3.0).sin(), 0.0]))
|
||||
.collect();
|
||||
for (l, x) in feet.iter().chain([&apex]) {
|
||||
b.node(*l, *x);
|
||||
}
|
||||
let members: Vec<(u32, Vec<u32>)> = (1..=3).map(|i| (i, vec![i, 4])).collect();
|
||||
b.members("legs", ElementKind::Rod2, &members, 1.5, 1.0e5, None);
|
||||
let feet_set = b.nodes("feet", &[1, 2, 3]);
|
||||
b.fix("feet", feet_set);
|
||||
let load = [3.0, -2.0, -10.0];
|
||||
b.force("P", 4, load);
|
||||
let r = b.solve();
|
||||
let fe = member_forces(&b.m, &r, |_| 1.5);
|
||||
// Equilibrium of the apex: Σ N_i t_i + P = 0 with t_i from the apex to foot i (tension
|
||||
// pulls the apex towards the foot).
|
||||
let t: Vec<[f64; 3]> = feet.iter().map(|(_, x)| unit(sub(*x, apex.1))).collect();
|
||||
let a = (0..3).map(|d| (0..3).map(|i| t[i][d]).collect()).collect();
|
||||
let exact: Vec<(u32, f64)> = (1..=3).zip(solve_dense(a, load.map(|c| -c).to_vec())).collect();
|
||||
let err = worst_relative(&fe, &exact);
|
||||
eprintln!("space tripod: worst member force error {err:.2e}");
|
||||
assert!(err < 1e-10, "{fe:?} vs {exact:?}");
|
||||
}
|
||||
|
||||
/// The statically indeterminate three-bar truss: a vertical bar of length L and two at ±θ
|
||||
/// meeting at a loaded joint.
|
||||
#[test]
|
||||
fn three_bar_truss() {
|
||||
let (l, theta, p, ea) = (2.0, 35f64.to_radians(), 50.0, 3.0e4);
|
||||
for kind in [ElementKind::Rod2, ElementKind::Rod3] {
|
||||
let mut b = Build::statics();
|
||||
b.node(1, [-l * theta.tan(), l, 0.0]);
|
||||
b.node(2, [0.0, l, 0.0]);
|
||||
b.node(3, [l * theta.tan(), l, 0.0]);
|
||||
b.node(4, [0.0, 0.0, 0.0]);
|
||||
let mut members = Vec::new();
|
||||
for (i, top) in [1u32, 2, 3].into_iter().enumerate() {
|
||||
let mut nodes = vec![top, 4];
|
||||
if kind == ElementKind::Rod3 {
|
||||
// Middle nodes a third of the way along: still straight, not centred.
|
||||
let (x0, x1) = (b.m.node(top).unwrap().xyz, [0.0; 3]);
|
||||
let mid = 10 + i as u32;
|
||||
b.node(mid, [0, 1, 2].map(|k| x0[k] + (x1[k] - x0[k]) / 3.0));
|
||||
nodes.push(mid);
|
||||
// A straight member's middle node has no stiffness across it: hold it there.
|
||||
let across = unit([x0[1], -x0[0], 0.0]);
|
||||
let set = b.nodes(&format!("mid{mid}"), &[mid]);
|
||||
b.spring(&format!("mid{mid}"), set, None, Direction::Vector(across), 1.0);
|
||||
}
|
||||
members.push((1 + i as u32, nodes));
|
||||
}
|
||||
b.members("bars", kind, &members, 1.0, ea, None);
|
||||
let top = b.nodes("top", &[1, 2, 3]);
|
||||
b.fix("top", top);
|
||||
let all: Vec<u32> = b.m.nodes().keys().copied().collect();
|
||||
let plane = b.nodes("plane_nodes", &all);
|
||||
b.bc("plane", plane, ZERO_Z);
|
||||
b.force("P", 4, [0.0, -p, 0.0]);
|
||||
let r = b.solve();
|
||||
let c3 = theta.cos().powi(3);
|
||||
let vertical = p / (1.0 + 2.0 * c3);
|
||||
let inclined = p * theta.cos().powi(2) / (1.0 + 2.0 * c3);
|
||||
let fe = member_forces(&b.m, &r, |_| 1.0);
|
||||
let exact = [(1, inclined), (2, vertical), (3, inclined)];
|
||||
let err = worst_relative(&fe, &exact);
|
||||
let delta = r.displacement[r.nodes.binary_search(&4).unwrap()][1];
|
||||
let want = -vertical * l / ea;
|
||||
eprintln!("three-bar truss ({kind:?}): force error {err:.2e}, deflection {delta:.6e} vs {want:.6e}");
|
||||
assert!(err < 1e-10);
|
||||
assert!((delta - want).abs() < 1e-10 * want.abs());
|
||||
}
|
||||
}
|
||||
|
||||
/// A rod3 bar with off-centre middle nodes under end tension carries σ = F/A exactly, at every
|
||||
/// integration point and extrapolated to every node.
|
||||
#[test]
|
||||
fn rod3_constant_stress_patch() {
|
||||
let (area, young, f) = (2.0, 1000.0, 6.0);
|
||||
let mut b = Build::statics();
|
||||
let dir = unit([1.0, 2.0, 2.0]);
|
||||
let at = |s: f64| dir.map(|c| c * s);
|
||||
// Three elements of unequal length, middle nodes at 30%, 50% and 65% of each.
|
||||
let ends = [0.0, 1.0, 2.5, 3.2];
|
||||
let fractions = [0.3, 0.5, 0.65];
|
||||
for (i, s) in ends.iter().enumerate() {
|
||||
b.node(1 + i as u32, at(*s));
|
||||
}
|
||||
let mut members = Vec::new();
|
||||
for i in 0..3 {
|
||||
let s = ends[i] + fractions[i] * (ends[i + 1] - ends[i]);
|
||||
b.node(10 + i as u32, at(s));
|
||||
members.push((1 + i as u32, vec![1 + i as u32, 2 + i as u32, 10 + i as u32]));
|
||||
}
|
||||
b.members("bar", ElementKind::Rod3, &members, area, young, None);
|
||||
let start = b.nodes("start", &[1]);
|
||||
b.fix("start", start);
|
||||
// The other nodes slide along the bar only: hold them with springs across it.
|
||||
let others: Vec<u32> = b.m.nodes().keys().copied().filter(|l| *l != 1).collect();
|
||||
let rest = b.nodes("rest", &others);
|
||||
let across = unit([2.0, -1.0, 0.0]);
|
||||
let third = [dir[1] * across[2] - dir[2] * across[1], dir[2] * across[0] - dir[0] * across[2], dir[0] * across[1] - dir[1] * across[0]];
|
||||
b.spring("across1", rest.clone(), None, Direction::Vector(across), 1.0e6);
|
||||
b.spring("across2", rest, None, Direction::Vector(third), 1.0e6);
|
||||
b.force("pull", 4, dir.map(|c| c * f));
|
||||
let r = b.solve();
|
||||
let mut worst: f64 = 0.0;
|
||||
for e in &r.elements {
|
||||
for p in &e.points {
|
||||
worst = worst.max((axial(&p.stress, dir, false) - f / area).abs());
|
||||
}
|
||||
for s in &e.nodal {
|
||||
worst = worst.max((axial(s, dir, false) - f / area).abs());
|
||||
}
|
||||
}
|
||||
let tip = r.displacement[r.nodes.binary_search(&4).unwrap()];
|
||||
let stretch = tip.iter().zip(dir).map(|(u, d)| u * d).sum::<f64>();
|
||||
eprintln!("rod3 patch: stress error {worst:.2e}; tip stretch {stretch:.10} vs FL/EA {:.10}", f * 3.2 / (young * area));
|
||||
assert!(worst < 1e-10 * f / area);
|
||||
assert!((stretch - f * 3.2 / (young * area)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// A bar hanging under its own weight: u(z) = ρ g (L z − z²/2) / E from the support. Two-node
|
||||
/// bars are exact at the nodes in one dimension; three-node bars reproduce the quadratic exactly.
|
||||
#[test]
|
||||
fn hanging_bar_under_gravity() {
|
||||
let (l, rho, g, young, area) = (6.0, 2.0, 9.81, 1.0e4, 0.5);
|
||||
for (kind, cells) in [(ElementKind::Rod2, 6usize), (ElementKind::Rod3, 3)] {
|
||||
let mut b = Build::statics();
|
||||
let h = l / cells as f64;
|
||||
for i in 0..=cells {
|
||||
b.node(1 + i as u32, [0.0, 0.0, -(i as f64) * h]);
|
||||
}
|
||||
let mut members = Vec::new();
|
||||
for i in 0..cells {
|
||||
let mut nodes = vec![1 + i as u32, 2 + i as u32];
|
||||
if kind == ElementKind::Rod3 {
|
||||
b.node(100 + i as u32, [0.0, 0.0, -(i as f64 + 0.5) * h]);
|
||||
nodes.push(100 + i as u32);
|
||||
}
|
||||
members.push((1 + i as u32, nodes));
|
||||
}
|
||||
b.members("bar", kind, &members, area, young, Some(rho));
|
||||
let top = b.nodes("top", &[1]);
|
||||
b.fix("top", top);
|
||||
let all: Vec<u32> = b.m.nodes().keys().copied().collect();
|
||||
let line = b.nodes("line", &all);
|
||||
b.bc("line", line, [Some(0.0), Some(0.0), None, None, None, None]);
|
||||
b.load("weight", Load::Gravity { acceleration: [0.0, 0.0, -g], set: None });
|
||||
let r = b.solve();
|
||||
let mut worst: f64 = 0.0;
|
||||
for (label, u) in r.nodes.iter().zip(&r.displacement) {
|
||||
let z = -b.m.node(*label).unwrap().xyz[2];
|
||||
let exact = -rho * g * (l * z - z * z / 2.0) / young;
|
||||
worst = worst.max((u[2] - exact).abs());
|
||||
}
|
||||
let tip = rho * g * l * l / (2.0 * young);
|
||||
eprintln!("hanging bar ({kind:?}): nodal displacement error {:.2e} of the tip's", worst / tip);
|
||||
assert!(worst < 1e-12 * tip.max(1.0), "{worst}");
|
||||
assert!((r.reaction_total[2] - rho * g * area * l).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
/// One node, a spring to the ground in X and a mass: ω = √(k/m) (consistent and lumped mass
|
||||
/// alike); a static force gives u = F/k.
|
||||
#[test]
|
||||
fn spring_mass_natural_frequency_and_static_stretch() {
|
||||
let (k, m) = (400.0, 2.5);
|
||||
for mass in [MassKind::Consistent, MassKind::Lumped] {
|
||||
let mut b = Build::modes(1);
|
||||
b.node(1, [1.0, 2.0, 3.0]);
|
||||
let n = b.nodes("n", &[1]);
|
||||
b.spring("k", n.clone(), None, Direction::Dof(1), k);
|
||||
b.mass("m", n.clone(), Some(Mass::Isotropic(m)), None);
|
||||
b.bc("yz", n, [None, Some(0.0), Some(0.0), None, None, None]);
|
||||
let r = b.modes_of(mass);
|
||||
let f = (k / m).sqrt() / (2.0 * PI);
|
||||
let err = (r.modes[0].frequency - f).abs() / f;
|
||||
eprintln!("spring–mass ({mass:?}): f {:.12} vs √(k/m)/2π {f:.12}, error {err:.1e}", r.modes[0].frequency);
|
||||
assert!(err < 1e-12);
|
||||
assert!((r.total_mass - m).abs() < 1e-12);
|
||||
assert!((r.modes[0].effective_mass[0] - m).abs() < 1e-9);
|
||||
}
|
||||
let mut b = Build::statics();
|
||||
b.node(1, [0.0; 3]);
|
||||
let n = b.nodes("n", &[1]);
|
||||
b.spring("k", n.clone(), None, Direction::Dof(1), k);
|
||||
b.bc("yz", n, [None, Some(0.0), Some(0.0), None, None, None]);
|
||||
b.force("F", 1, [8.0, 0.0, 0.0]);
|
||||
let r = b.solve();
|
||||
assert!((r.displacement[0][0] - 8.0 / k).abs() < 1e-15);
|
||||
// The spring carries the load to the ground: no support reaction, and no imbalance.
|
||||
assert_eq!(r.reaction_total[0], 0.0);
|
||||
assert!(r.warnings.iter().all(|w| !w.contains("differ")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Springs to the ground along three orthogonal skew vectors: frequencies √(kᵢ/m) with the mode
|
||||
/// shapes along the vectors.
|
||||
#[test]
|
||||
fn springs_in_skew_directions() {
|
||||
let m = 3.0;
|
||||
let a = unit([1.0, 1.0, 0.5]);
|
||||
let b2 = unit([1.0, -1.0, 0.0]);
|
||||
let c = [a[1] * b2[2] - a[2] * b2[1], a[2] * b2[0] - a[0] * b2[2], a[0] * b2[1] - a[1] * b2[0]];
|
||||
let ks = [30.0, 120.0, 270.0];
|
||||
let mut b = Build::modes(3);
|
||||
b.node(7, [0.0; 3]);
|
||||
let n = b.nodes("n", &[7]);
|
||||
for (i, (v, k)) in [a, b2, c].iter().zip(ks).enumerate() {
|
||||
b.spring(&format!("k{i}"), n.clone(), None, Direction::Vector(v.map(|x| 2.0 * x)), k);
|
||||
}
|
||||
b.mass("m", n, Some(Mass::Isotropic(m)), None);
|
||||
let r = b.modes_of(MassKind::Consistent);
|
||||
for ((mode, k), v) in r.modes.iter().zip(ks).zip([a, b2, c]) {
|
||||
let f = (k / m).sqrt() / (2.0 * PI);
|
||||
let s = mode.shape[0];
|
||||
let along = (s[0] * v[0] + s[1] * v[1] + s[2] * v[2]).abs() / (s[0] * s[0] + s[1] * s[1] + s[2] * s[2]).sqrt();
|
||||
eprintln!("skew springs: f {:.10} vs {f:.10}; shape along the spring {along:.12}", mode.frequency);
|
||||
assert!((mode.frequency - f).abs() < 1e-10 * f);
|
||||
assert!((along - 1.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ground — k₁ — m₁ — k₂ — m₂, the two-point spring acting along the line of its nodes:
|
||||
/// ω² are the roots of m₁m₂ω⁴ − (k₁m₂ + k₂m₁ + k₂m₂)ω² + k₁k₂ = 0.
|
||||
#[test]
|
||||
fn two_mass_chain() {
|
||||
let (k1, k2, m1, m2) = (100.0, 60.0, 2.0, 0.5);
|
||||
let mut b = Build::modes(2);
|
||||
let dir = unit([3.0, 0.0, 4.0]);
|
||||
b.node(1, [0.0; 3]);
|
||||
b.node(2, dir.map(|c| 2.0 * c));
|
||||
let n1 = b.nodes("n1", &[1]);
|
||||
let n2 = b.nodes("n2", &[2]);
|
||||
b.spring("k1", n1.clone(), None, Direction::Vector(dir), k1);
|
||||
b.spring("k2", n1.clone(), Some(n2.clone()), Direction::Line, k2);
|
||||
b.mass("m1", n1, Some(Mass::Isotropic(m1)), None);
|
||||
b.mass("m2", n2, Some(Mass::Directional([m2, 7.0, m2])), None);
|
||||
// Only the motion along the line: hold the others with stiff springs across it.
|
||||
let both = b.nodes("both", &[1, 2]);
|
||||
b.spring("hold_y", both.clone(), None, Direction::Dof(2), 1.0e9);
|
||||
b.spring("hold_n", both, None, Direction::Vector([dir[2], 0.0, -dir[0]]), 1.0e9);
|
||||
let r = b.modes_of(MassKind::Lumped);
|
||||
let (pa, pb, pc) = (m1 * m2, -(k1 * m2 + k2 * m1 + k2 * m2), k1 * k2);
|
||||
let disc = (pb * pb - 4.0 * pa * pc).sqrt();
|
||||
let exact = [(-pb - disc) / (2.0 * pa), (-pb + disc) / (2.0 * pa)];
|
||||
for (mode, w2) in r.modes.iter().zip(exact) {
|
||||
let err = (mode.eigenvalue - w2).abs() / w2;
|
||||
eprintln!("two-mass chain: ω² {:.10} vs {w2:.10}, error {err:.1e}", mode.eigenvalue);
|
||||
assert!(err < 1e-8, "the holding springs perturb at 1e-9");
|
||||
}
|
||||
}
|
||||
|
||||
/// A massless bar with a tip mass M: ω = √(EA/(L M)); the bar's own mass ρAL adds the
|
||||
/// Rayleigh-quotient correction ω² ≈ EA/(L(M + ρAL/3)) for a light bar.
|
||||
#[test]
|
||||
fn bar_with_a_tip_mass() {
|
||||
let (l, young, area, tip) = (2.0, 2.0e5, 1e-2, 50.0);
|
||||
for kind in [ElementKind::Rod2, ElementKind::Rod3] {
|
||||
let mut b = Build::modes(1);
|
||||
b.node(1, [0.0; 3]);
|
||||
b.node(2, [l, 0.0, 0.0]);
|
||||
let mut nodes = vec![1, 2];
|
||||
if kind == ElementKind::Rod3 {
|
||||
b.node(3, [l / 2.0, 0.0, 0.0]);
|
||||
nodes.push(3);
|
||||
}
|
||||
b.members("bar", kind, &[(1, nodes)], area, young, Some(1e-9));
|
||||
let root = b.nodes("root", &[1]);
|
||||
b.fix("root", root);
|
||||
let all: Vec<u32> = b.m.nodes().keys().copied().collect();
|
||||
let line = b.nodes("line", &all);
|
||||
b.bc("line", line, [None, Some(0.0), Some(0.0), None, None, None]);
|
||||
let end = b.nodes("end", &[2]);
|
||||
b.mass("tip", end, Some(Mass::Isotropic(tip)), None);
|
||||
let r = b.modes_of(MassKind::Consistent);
|
||||
let f = (young * area / (l * tip)).sqrt() / (2.0 * PI);
|
||||
let err = (r.modes[0].frequency - f).abs() / f;
|
||||
eprintln!("bar with tip mass ({kind:?}): f {:.10} vs {f:.10}, error {err:.1e}", r.modes[0].frequency);
|
||||
assert!(err < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
/// A torsional pendulum: a reference point with polar inertia J on a torsional spring k_t to
|
||||
/// the ground; ω = √(k_t/J). The other directions are held.
|
||||
#[test]
|
||||
fn torsional_pendulum() {
|
||||
let (kt, j) = (5.0e3, 0.8);
|
||||
let mut b = Build::modes(1);
|
||||
b.m.add_reference_point("Disc", ReferencePoint { xyz: [1.0, 1.0, 0.0], label: 0 }).unwrap();
|
||||
let disc = Target::ReferencePoint("Disc".into());
|
||||
b.spring("shaft", disc.clone(), None, Direction::Dof(6), kt);
|
||||
// Products of inertia about the other axes do not couple into the held rotations.
|
||||
b.mass("disc", disc.clone(), Some(Mass::Isotropic(4.0)), Some([0.5, 0.5, j, 0.0, 0.1, 0.1]));
|
||||
b.bc("held", disc, [Some(0.0), Some(0.0), Some(0.0), Some(0.0), Some(0.0), None]);
|
||||
let r = b.modes_of(MassKind::Lumped);
|
||||
let f = (kt / j).sqrt() / (2.0 * PI);
|
||||
let err = (r.modes[0].frequency - f).abs() / f;
|
||||
eprintln!("torsional pendulum: f {:.12} vs √(k_t/J)/2π {f:.12}, error {err:.1e}", r.modes[0].frequency);
|
||||
assert!(err < 1e-12);
|
||||
assert_eq!(r.reference_points.len(), 1);
|
||||
assert!(r.modes[0].point_shape[0][5].abs() > 0.0);
|
||||
}
|
||||
|
||||
/// Gravity acts on point masses (whole-model gravity); dashpots are ignored with a warning in
|
||||
/// a static step; the runs are bit-for-bit repeatable.
|
||||
#[test]
|
||||
fn point_mass_gravity_dashpots_and_determinism() {
|
||||
let mut b = Build::statics();
|
||||
b.node(1, [0.0; 3]);
|
||||
b.node(2, [0.0, 0.0, -1.0]);
|
||||
b.members("rod", ElementKind::Rod2, &[(1, vec![1, 2])], 1.0, 1.0e3, Some(0.0 + 1e-6));
|
||||
let top = b.nodes("top", &[1]);
|
||||
b.fix("top", top);
|
||||
let bottom = b.nodes("bottom", &[2]);
|
||||
b.bc("line", bottom.clone(), [Some(0.0), Some(0.0), None, None, None, None]);
|
||||
b.mass("weight", bottom.clone(), Some(Mass::Isotropic(3.0)), None);
|
||||
b.m.add_spring("damper", Spring::Dashpot { first: bottom, second: None, direction: Direction::Dof(3), coefficient: 2.0 }).unwrap();
|
||||
b.load("g", Load::Gravity { acceleration: [0.0, 0.0, -10.0], set: None });
|
||||
let r = b.solve();
|
||||
let total = 3.0 * 10.0 + 1e-6 * 10.0;
|
||||
assert!((r.reaction_total[2] - total).abs() < 1e-10, "{:?}", r.reaction_total);
|
||||
assert!((r.displacement[r.nodes.binary_search(&2).unwrap()][2] + total * 1.0 / 1.0e3).abs() < 1e-6);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("dashpots `damper`")), "{:?}", r.warnings);
|
||||
assert_eq!(r, b.solve());
|
||||
}
|
||||
|
||||
/// What the solver cannot do is an error that says so.
|
||||
#[test]
|
||||
fn errors_name_the_problem() {
|
||||
// A point mass with nothing holding it.
|
||||
let mut b = Build::modes(1);
|
||||
b.node(1, [0.0; 3]);
|
||||
let n = b.nodes("n", &[1]);
|
||||
b.mass("m", n, Some(Mass::Isotropic(1.0)), None);
|
||||
// A free particle has no stiffness at all to shift against.
|
||||
assert!(solve_frequency(&b.m, None, &FrequencyOptions::default(), &Limits::default()).is_err());
|
||||
// A truss of an orthotropic material.
|
||||
let mut b = Build::statics();
|
||||
b.node(1, [0.0; 3]);
|
||||
b.node(2, [1.0, 0.0, 0.0]);
|
||||
b.m.set_element(1, ElementKind::Rod2, vec![1, 2]).unwrap();
|
||||
b.m.add_set("rod", Set { elements: vec![1], ..Set::default() }).unwrap();
|
||||
b.m.add_material(
|
||||
"Ortho",
|
||||
Material {
|
||||
elastic: Some(Elastic::EngineeringConstants { young: [1.0; 3], poisson: [0.0; 3], shear: [1.0; 3] }),
|
||||
density: None,
|
||||
..Material::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
b.m.add_section(
|
||||
"rod",
|
||||
Section { set: "rod".into(), material: "Ortho".into(), kind: SectionKind::Rod { area: 1.0 }, formulation: Formulation::default() },
|
||||
)
|
||||
.unwrap();
|
||||
let e = solve_static(&b.m, None, &Limits::default()).unwrap_err().to_string();
|
||||
assert!(e.contains("orthotropic"), "{e}");
|
||||
}
|
||||
|
||||
/// A 2D (plane stress) plate stiffened by trusses along its loaded edges: the truss takes the
|
||||
/// model's two in-plane unknowns per node, and the two act as parallel springs,
|
||||
/// u = F L / (E t w + E_b A) (exact: uniform tension, ν = 0).
|
||||
#[test]
|
||||
fn plane_truss_stiffens_a_plane_stress_plate() {
|
||||
let (l, w, t, young, area, young_bar, force) = (4.0, 1.0, 0.2, 1000.0, 0.05, 2.0e4, 30.0);
|
||||
for (kind, mid) in [(ElementKind::Rod2, false), (ElementKind::Rod3, true)] {
|
||||
let mut b = Build::statics();
|
||||
for (label, xyz) in [(1, [0.0, 0.0, 0.0]), (2, [l, 0.0, 0.0]), (3, [l, w, 0.0]), (4, [0.0, w, 0.0])] {
|
||||
b.node(label, xyz);
|
||||
}
|
||||
b.m.set_element(1, ElementKind::Quad4, vec![1, 2, 3, 4]).unwrap();
|
||||
b.m.add_set("plate", Set { elements: vec![1], ..Set::default() }).unwrap();
|
||||
b.m.add_material("Plate", Material { elastic: Some(Elastic::Isotropic { young, poisson: 0.0 }), density: None, ..Material::default() })
|
||||
.unwrap();
|
||||
b.m.add_section(
|
||||
"plate",
|
||||
Section {
|
||||
set: "plate".into(),
|
||||
material: "Plate".into(),
|
||||
kind: SectionKind::PlaneStress { thickness: t },
|
||||
formulation: Formulation::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Two bars of half the area, along the bottom and top edges, keep the tension uniform.
|
||||
let mut members = vec![(2, vec![1, 2]), (3, vec![4, 3])];
|
||||
if mid {
|
||||
b.node(5, [l / 2.0, 0.0, 0.0]);
|
||||
b.node(6, [l / 2.0, w, 0.0]);
|
||||
members[0].1.push(5);
|
||||
members[1].1.push(6);
|
||||
}
|
||||
b.members("bar", kind, &members, area / 2.0, young_bar, None);
|
||||
let left = b.nodes("left", &[1, 4]);
|
||||
b.bc("left", left, [Some(0.0), None, None, None, None, None]);
|
||||
let corner = b.nodes("corner", &[1]);
|
||||
b.bc("corner", corner, [None, Some(0.0), None, None, None, None]);
|
||||
if mid {
|
||||
let middle = b.nodes("middle", &[5, 6]);
|
||||
b.bc("middle", middle, [None, Some(0.0), None, None, None, None]);
|
||||
}
|
||||
let right = b.nodes("right", &[2, 3]);
|
||||
b.load("pull", Load::Force { target: right, vector: [force, 0.0, 0.0], distribution: Distribution::PerNode });
|
||||
let r = b.solve();
|
||||
let u = r.displacement[r.nodes.binary_search(&2).unwrap()][0];
|
||||
let exact = 2.0 * force * l / (young * t * w + young_bar * area);
|
||||
eprintln!("plane truss ({kind:?}): u {u:.12} vs {exact:.12}; {} unknowns", r.stats.dofs);
|
||||
assert!((u - exact).abs() < 1e-12 * exact);
|
||||
assert_eq!(r.stats.dofs, 2 * b.m.nodes().len(), "two unknowns per node in a 2D model");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user