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) {
|
||||
|
||||
Reference in new issue
Block a user