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:
authored and GitHub committed 2026-10-13 21:36:46 -04:00
1 parent 5e5a2ae251
commit 15a9ac9157
53 files changed
+3626 -110

No files matched your search

+144 -7
View File
@@ -71,6 +71,11 @@ pub struct Resolved {
/// apply, so every one of them distributes a load the same way. A force or traction gives
/// force components, a moment moment components.
pub nodal_loads: BTreeMap<String, Vec<(u32, [f64; 3])>>,
/// Every point mass at every node of its target, by definition name, then node.
pub masses: Vec<crate::ResolvedMass>,
/// Every spring and dashpot at every node of its target (or between its two ends), by
/// definition name, then node.
pub springs: Vec<crate::ResolvedSpring>,
/// The check's warnings (a resolved model has no errors).
pub warnings: Vec<Diagnostic>,
/// How each element of a 2D section (plane stress, plane strain, axisymmetric) extends out
@@ -97,6 +102,11 @@ impl Resolved {
self.sets.get(set).map(|s| s.edges.iter().filter(|e| self.planar.contains_key(&e.element)).copied().collect()).unwrap_or_default()
}
/// The node labels point masses, springs and dashpots act on (repeats possible).
pub fn discrete_labels(&self) -> impl Iterator<Item = u32> + '_ {
self.masses.iter().map(|m| m.node).chain(self.springs.iter().flat_map(|s| std::iter::once(s.first).chain(s.second)))
}
/// The nodes a boundary condition, force or moment acts on.
pub fn target_nodes(&self, target: &Target) -> &[u32] {
match target {
@@ -160,9 +170,10 @@ impl Model {
let mut resolved = Resolved::default();
let units = self.units;
// A model of fluid steps alone needs no mesh: its domains carry their own grid.
// A model of fluid steps alone needs no mesh: its domains carry their own grid; one of
// point masses, springs and dashpots alone is its own mesh.
let fluid_only = !self.steps.is_empty() && self.steps.iter().all(|s| s.procedure.fluid_domain().is_some());
if self.elements.is_empty() && !fluid_only {
if self.elements.is_empty() && !fluid_only && self.masses.is_empty() && self.springs.is_empty() {
d.error("model", "the mesh has no elements");
}
if units.length().is_none() {
@@ -229,7 +240,7 @@ impl Model {
k.dimension() == 2
}
SectionKind::Beam { .. } => k == ElementKind::Beam2,
SectionKind::Rod { .. } => k == ElementKind::Rod2,
SectionKind::Rod { .. } => matches!(k, ElementKind::Rod2 | ElementKind::Rod3),
};
// The linear element with an incompatible-modes formulation for this kind of section.
let incompatible_kind = if sec.kind.is_planar() { ElementKind::Quad4 } else { ElementKind::Hex8 };
@@ -362,6 +373,9 @@ impl Model {
}
}
// Point masses, springs and dashpots.
let (masses, springs) = self.resolve_discrete(&resolved, &mut d, &has_rotations);
// Loads.
let mut nodal_loads = BTreeMap::new();
for (name, load) in &self.loads {
@@ -449,11 +463,123 @@ impl Model {
}
resolved.nodal_loads = nodal_loads;
resolved.masses = masses;
resolved.springs = springs;
let mut out = d.0;
out.sort_by_key(|a| a.severity);
(resolved, out)
}
/// Point masses, springs and dashpots expanded to their nodes, reporting what cannot be
/// expanded.
fn resolve_discrete(
&self,
resolved: &Resolved,
d: &mut Diagnostics,
has_rotations: &dyn Fn(&Target) -> bool,
) -> (Vec<crate::ResolvedMass>, Vec<crate::ResolvedSpring>) {
use crate::{Direction, ResolvedDirection, ResolvedMass, ResolvedSpring};
let mut masses = Vec::new();
let mut springs = Vec::new();
let mut count = 0usize;
let mut over = false;
let mut take = |n: usize| {
count = count.saturating_add(n);
over |= count > crate::discrete::MAX_DISCRETE;
!over
};
for (name, m) in &self.masses {
let subject = format!("point mass `{name}`");
let nodes = resolved.target_nodes(&m.target);
if nodes.is_empty() {
d.error(&subject, format!("{} has no nodes", m.target));
continue;
}
if m.inertia.is_some_and(|i| i.iter().any(|c| *c != 0.0)) && !has_rotations(&m.target) {
d.error(&subject, format!("{} has no nodes with rotations for its inertia; put it on a reference point", m.target));
}
if !take(nodes.len()) {
break;
}
let mass = m.mass.map_or([0.0; 3], crate::Mass::components);
let inertia = m.inertia.unwrap_or_default();
masses.extend(nodes.iter().map(|n| ResolvedMass { name: name.clone(), node: *n, mass, inertia }));
}
for (name, s) in &self.springs {
let subject = format!("{} `{name}`", s.kind());
let first = resolved.target_nodes(s.first());
let rotational = s.direction().is_rotational();
if first.is_empty() {
d.error(&subject, format!("{} has no nodes", s.first()));
continue;
}
if rotational && s.targets().any(|t| !has_rotations(t)) {
d.error(&subject, "acts on a rotation, and its nodes have none; connect reference points (or shell and beam nodes)");
}
let unit = |v: [f64; 3]| {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
(len > 0.0 && len.is_finite()).then(|| v.map(|c| c / len))
};
let pairs: Vec<(u32, Option<u32>)> = match s.second() {
None => first.iter().map(|n| (*n, None)).collect(),
Some(t) => {
let second = resolved.target_nodes(t);
if first.len() != 1 || second.len() != 1 {
d.error(
&subject,
format!(
"a two-point {} connects one node to one node; {} has {} and {t} has {} (use a vertex set or a reference point at each end)",
s.kind(),
s.first(),
first.len(),
second.len()
),
);
continue;
}
match (first.first(), second.first()) {
(Some(a), Some(b)) if a == b => {
d.error(&subject, format!("both ends are node {a}"));
continue;
}
(Some(a), Some(b)) => vec![(*a, Some(*b))],
_ => continue,
}
}
};
if !take(pairs.len()) {
break;
}
for (a, b) in pairs {
let direction = match s.direction() {
Direction::Dof(k) => ResolvedDirection::Dof(k),
Direction::Vector(v) => match unit(v) {
Some(n) => ResolvedDirection::Vector(n),
None => {
d.error(&subject, "its direction vector has no length");
break;
}
},
Direction::Line => {
let ends = b.and_then(|b| Some((self.label_xyz(a)?, self.label_xyz(b)?)));
match ends.and_then(|(x, y)| unit([y[0] - x[0], y[1] - x[1], y[2] - x[2]])) {
Some(n) => ResolvedDirection::Line(n),
None => {
d.error(&subject, "its two ends are at the same point, so it has no line to act along");
break;
}
}
}
};
springs.push(ResolvedSpring { name: name.clone(), dashpot: s.is_dashpot(), first: a, second: b, direction, value: s.value() });
}
}
if over {
d.error("model", format!("point masses, springs and dashpots resolve to more than {} copies", crate::discrete::MAX_DISCRETE));
}
(masses, springs)
}
/// A force, moment or traction as nodal values (`None` for other loads), or why it cannot be
/// distributed.
fn nodal_values(&self, load: &Load, r: &Resolved) -> std::result::Result<Option<Vec<(u32, [f64; 3])>>, String> {
@@ -505,13 +631,17 @@ impl Model {
}))
}
/// The rules of a 2D model: one kind of 2D model (plane or axisymmetric) and nothing else, in
/// the XY plane (x ≥ 0 when axisymmetric), elements numbered counter-clockwise.
/// The rules of a 2D model: one kind of 2D model (plane or axisymmetric) and nothing else but
/// trusses (plane models only), in the XY plane (x ≥ 0 when axisymmetric), elements numbered
/// counter-clockwise.
fn check_planar(&self, r: &Resolved, d: &mut Diagnostics) {
if !r.is_planar() {
return;
}
let others: Vec<&str> = self.sections.iter().filter(|(_, s)| !s.kind.is_planar()).map(|(n, _)| n.as_str()).collect();
let axisymmetric = r.extent() == Some(Extent::Revolved);
// Trusses join a plane model with its two in-plane unknowns per node.
let truss = |s: &crate::Section| matches!(s.kind, SectionKind::Rod { .. }) && !axisymmetric;
let others: Vec<&str> = self.sections.iter().filter(|(_, s)| !s.kind.is_planar() && !truss(s)).map(|(n, _)| n.as_str()).collect();
if !others.is_empty() {
d.error(
"model",
@@ -521,7 +651,6 @@ impl Model {
),
);
}
let axisymmetric = r.extent() == Some(Extent::Revolved);
if axisymmetric && r.planar.values().any(|e| *e != Extent::Revolved) {
d.error("model", "axisymmetric and plane (stress or strain) sections cannot be mixed in one model");
}
@@ -542,6 +671,14 @@ impl Model {
clockwise.push(label);
}
}
for sec in self.sections.values().filter(|s| truss(s)) {
for label in r.sets.get(&sec.set).map(|s| s.elements.as_slice()).unwrap_or_default() {
let Some(e) = self.elements.get(label) else { continue };
if e.nodes.iter().filter_map(|n| self.nodes.get(n)).any(|n| n.xyz[2] != 0.0) {
off_plane.push(*label);
}
}
}
if !off_plane.is_empty() {
d.error("model", format!("2D elements {} have nodes off the XY plane (z ≠ 0); 2D models lie in the XY plane", first_labels(&off_plane)));
}
+1
View File
@@ -104,6 +104,7 @@ impl Model {
.map(|(n, _)| format!("boundary condition `{n}`")),
);
users.extend(self.loads.iter().filter(|(_, l)| l.reference_point() == Some(name)).map(|(n, _)| format!("load `{n}`")));
users.extend(self.discrete_users(|t| t.reference_point() == Some(name)));
not_in_use("reference point", name, users)?;
remove_existing(&mut self.reference_points, "reference point", name).map(|_| ())
}
+501
View File
@@ -0,0 +1,501 @@
//! Discrete elements: point masses with rotary inertia, springs and dashpots.
//!
//! They are definitions, not mesh elements: each acts on a target (the nodes of a set, or a
//! reference point) and survives remeshing like a load does. Every node of the target gets its
//! own copy, with the full value: a point mass of 2 on a set of five nodes adds 2 at each of them
//! (a mass shared over a face is a point mass on a reference point coupled to the face).
//!
//! - A **point mass** adds a translational mass, the same in every direction or one per global
//! direction, and a rotary inertia tensor about the node (which needs rotational degrees of
//! freedom: a reference point, or a shell or beam node).
//! - A **spring** resists the relative displacement of its two ends along a direction with a
//! force `stiffness × relative displacement`; a **dashpot** resists the relative velocity with
//! `coefficient × relative velocity`. With no second end, the spring or dashpot connects each
//! node of the target to the ground. The direction is a global degree of freedom (1–3
//! translations, 4–6 rotations: then a torsional spring or dashpot), a fixed vector, or the
//! line through the two ends (two-point only, from the undeformed positions).
//!
//! The linear (small displacement) form is the textbook spring element: for a unit direction n,
//! the element matrix is k [n nᵀ, −n nᵀ; −n nᵀ, n nᵀ] on the two ends' translations (R. D. Cook,
//! D. S. Malkus, M. E. Plesha, R. J. Witt, *Concepts and Applications of Finite Element
//! Analysis*, 4th ed., Wiley 2002, §2.2 and §2.10), and k n nᵀ for a grounded one. Dashpots
//! have the same form on velocities; static and frequency steps ignore them.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::bcs::Target;
use crate::{Error, Model, Result, context, finite, insert_new, remove_existing, replace_existing};
/// Most copies of discrete elements a model resolves to (definitions × target nodes).
pub const MAX_DISCRETE: usize = 10_000_000;
/// A translational mass: the same in every direction, or one per global direction.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Mass {
Isotropic(f64),
Directional([f64; 3]),
}
impl Mass {
/// The mass in X, Y and Z.
pub fn components(self) -> [f64; 3] {
match self {
Mass::Isotropic(m) => [m; 3],
Mass::Directional(m) => m,
}
}
}
/// A point mass and rotary inertia at every node of a target.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PointMass {
pub target: Target,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mass: Option<Mass>,
/// The inertia tensor about the node in global axes, `[I11, I22, I33, I12, I13, I23]`: the
/// components of the symmetric tensor J with angular momentum H = J ω (products of inertia
/// carry their sign: I12 = −∫ x y dm).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inertia: Option<[f64; 6]>,
}
impl PointMass {
pub fn validate(&self) -> Result<()> {
if self.mass.is_none() && self.inertia.is_none() {
return Err(Error::Invalid("a point mass needs a mass, an inertia or both".into()));
}
if let Some(m) = self.mass {
for c in m.components() {
if finite("mass", c)? < 0.0 {
return Err(Error::Invalid(format!("a mass is not negative, got {c}")));
}
}
if m.components().iter().all(|c| *c == 0.0) && self.inertia.is_none() {
return Err(Error::Invalid("a point mass of zero adds nothing".into()));
}
}
if let Some(i) = self.inertia {
for c in i {
finite("inertia", c)?;
}
// Positive semidefinite: every principal minor is non-negative (to round-off).
let j = inertia_matrix(i);
let scale = i.iter().fold(0.0f64, |m, c| m.max(c.abs()));
let tolerance = 1e-12 * scale;
let minors = [
j[0][0],
j[1][1],
j[2][2],
j[0][0] * j[1][1] - j[0][1] * j[0][1],
j[0][0] * j[2][2] - j[0][2] * j[0][2],
j[1][1] * j[2][2] - j[1][2] * j[1][2],
j[0][0] * (j[1][1] * j[2][2] - j[1][2] * j[2][1]) - j[0][1] * (j[1][0] * j[2][2] - j[1][2] * j[2][0])
+ j[0][2] * (j[1][0] * j[2][1] - j[1][1] * j[2][0]),
];
let powers = [1, 1, 1, 2, 2, 2, 3];
if minors.iter().zip(powers).any(|(m, p)| *m < -tolerance * scale.powi(p - 1)) {
return Err(Error::Invalid(
"an inertia tensor must be positive semidefinite (non-negative moments, products no larger than the moments allow)".into(),
));
}
if i.iter().all(|c| *c == 0.0) && self.mass.is_none_or(|m| m.components().iter().all(|c| *c == 0.0)) {
return Err(Error::Invalid("a point mass of zero adds nothing".into()));
}
}
Ok(())
}
}
/// `[I11, I22, I33, I12, I13, I23]` as a symmetric matrix.
pub fn inertia_matrix(i: [f64; 6]) -> [[f64; 3]; 3] {
let [i11, i22, i33, i12, i13, i23] = i;
[[i11, i12, i13], [i12, i22, i23], [i13, i23, i33]]
}
/// The direction a spring or dashpot acts in.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
/// A global degree of freedom: 1–3 translations along X, Y, Z, 4–6 rotations about them.
Dof(u8),
/// A fixed direction in global axes (translations; normalized when used).
Vector([f64; 3]),
/// The line through the two ends' undeformed positions (two-point only).
Line,
}
impl Direction {
/// Whether it acts on rotations.
pub fn is_rotational(self) -> bool {
matches!(self, Direction::Dof(4..=6))
}
fn validate(self, two_point: bool) -> Result<()> {
match self {
Direction::Dof(d) if !(1..=6).contains(&d) => Err(Error::Invalid(format!("degree of freedom {d} is not in 1..=6"))),
Direction::Dof(_) => Ok(()),
Direction::Vector(v) => {
for c in v {
finite("direction", c)?;
}
if v.iter().all(|c| *c == 0.0) {
return Err(Error::Invalid("a direction vector must not be zero".into()));
}
Ok(())
}
Direction::Line if !two_point => Err(Error::Invalid("the direction `line` needs a second end (it runs between the two ends)".into())),
Direction::Line => Ok(()),
}
}
}
/// A linear spring or dashpot between the nodes of `first` and the ground, or between `first`
/// and `second` (each one node: a vertex set or a reference point).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Spring {
/// Force (or moment) = stiffness × relative displacement (or rotation).
Spring {
first: Target,
#[serde(default, skip_serializing_if = "Option::is_none")]
second: Option<Target>,
direction: Direction,
stiffness: f64,
},
/// Force (or moment) = coefficient × relative velocity (or angular velocity); dynamic steps
/// only.
Dashpot {
first: Target,
#[serde(default, skip_serializing_if = "Option::is_none")]
second: Option<Target>,
direction: Direction,
coefficient: f64,
},
}
impl Spring {
pub fn first(&self) -> &Target {
match self {
Spring::Spring { first, .. } | Spring::Dashpot { first, .. } => first,
}
}
pub fn second(&self) -> Option<&Target> {
match self {
Spring::Spring { second, .. } | Spring::Dashpot { second, .. } => second.as_ref(),
}
}
pub fn direction(&self) -> Direction {
match self {
Spring::Spring { direction, .. } | Spring::Dashpot { direction, .. } => *direction,
}
}
/// The stiffness or the damping coefficient.
pub fn value(&self) -> f64 {
match self {
Spring::Spring { stiffness, .. } => *stiffness,
Spring::Dashpot { coefficient, .. } => *coefficient,
}
}
pub fn is_dashpot(&self) -> bool {
matches!(self, Spring::Dashpot { .. })
}
/// `spring` or `dashpot`.
pub fn kind(&self) -> &'static str {
if self.is_dashpot() { "dashpot" } else { "spring" }
}
/// The targets it acts on.
pub fn targets(&self) -> impl Iterator<Item = &Target> {
std::iter::once(self.first()).chain(self.second())
}
pub fn validate(&self) -> Result<()> {
let what = if self.is_dashpot() { "damping coefficient" } else { "stiffness" };
let v = finite(what, self.value())?;
if v <= 0.0 {
return Err(Error::Invalid(format!("a {} needs a positive {what}, got {v}", self.kind())));
}
self.direction().validate(self.second().is_some())?;
if self.second() == Some(self.first()) {
return Err(Error::Invalid(format!("a {} cannot connect a target to itself", self.kind())));
}
Ok(())
}
}
/// A resolved direction: a global degree of freedom (1–6), or a unit vector acting on the
/// translations (from a fixed vector, or along the line of the ends).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ResolvedDirection {
Dof(u8),
Vector([f64; 3]),
Line([f64; 3]),
}
impl ResolvedDirection {
/// The coefficients of the relative motion on each end's six degrees of freedom: the
/// spring's elongation is Σ c (u_second − u_first).
pub fn coefficients(self) -> [f64; 6] {
let mut c = [0.0; 6];
match self {
ResolvedDirection::Dof(d) => {
if let Some(x) = usize::from(d).checked_sub(1).and_then(|i| c.get_mut(i)) {
*x = 1.0;
}
}
ResolvedDirection::Vector(n) | ResolvedDirection::Line(n) => c[..3].copy_from_slice(&n),
}
c
}
pub fn is_rotational(self) -> bool {
matches!(self, ResolvedDirection::Dof(4..=6))
}
/// The unit vector it acts along (for a rotation, the axis it turns about).
pub fn axis(self) -> [f64; 3] {
let c = self.coefficients();
if self.is_rotational() { [c[3], c[4], c[5]] } else { [c[0], c[1], c[2]] }
}
}
/// One point mass at one node.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedMass {
/// The definition it comes from.
pub name: String,
pub node: u32,
/// Mass in X, Y, Z.
pub mass: [f64; 3],
/// `[I11, I22, I33, I12, I13, I23]` (zero when there is none).
pub inertia: [f64; 6],
}
impl ResolvedSpring {
/// `spring` or `dashpot`.
pub fn kind(&self) -> &'static str {
if self.dashpot { "dashpot" } else { "spring" }
}
}
/// One spring or dashpot at one node (grounded) or between two nodes.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedSpring {
pub name: String,
pub dashpot: bool,
pub first: u32,
/// `None`: to the ground.
pub second: Option<u32>,
pub direction: ResolvedDirection,
/// Stiffness, or damping coefficient.
pub value: f64,
}
impl Model {
pub fn masses(&self) -> &BTreeMap<String, PointMass> {
&self.masses
}
pub fn mass(&self, name: &str) -> Option<&PointMass> {
self.masses.get(name)
}
pub fn springs(&self) -> &BTreeMap<String, Spring> {
&self.springs
}
pub fn spring(&self, name: &str) -> Option<&Spring> {
self.springs.get(name)
}
fn check_mass(&self, name: &str, m: &PointMass) -> Result<()> {
m.validate().map_err(|e| context("point mass", name, e))?;
m.target.check_exists(self).map_err(|e| context("point mass", name, e))
}
pub fn add_mass(&mut self, name: &str, mass: PointMass) -> Result<()> {
self.check_mass(name, &mass)?;
insert_new(&mut self.masses, "point mass", name, mass)
}
pub fn replace_mass(&mut self, name: &str, mass: PointMass) -> Result<()> {
self.check_mass(name, &mass)?;
replace_existing(&mut self.masses, "point mass", name, mass)
}
pub fn remove_mass(&mut self, name: &str) -> Result<()> {
remove_existing(&mut self.masses, "point mass", name).map(|_| ())
}
fn check_spring(&self, name: &str, s: &Spring) -> Result<()> {
let what = s.kind();
s.validate().map_err(|e| context(what, name, e))?;
for t in s.targets() {
t.check_exists(self).map_err(|e| context(what, name, e))?;
}
Ok(())
}
pub fn add_spring(&mut self, name: &str, spring: Spring) -> Result<()> {
self.check_spring(name, &spring)?;
insert_new(&mut self.springs, "spring", name, spring)
}
pub fn replace_spring(&mut self, name: &str, spring: Spring) -> Result<()> {
self.check_spring(name, &spring)?;
replace_existing(&mut self.springs, "spring", name, spring)
}
pub fn remove_spring(&mut self, name: &str) -> Result<()> {
remove_existing(&mut self.springs, "spring", name).map(|_| ())
}
/// The definitions among point masses, springs and dashpots that act on a target.
pub(crate) fn discrete_users(&self, uses: impl Fn(&Target) -> bool) -> Vec<String> {
let mut users: Vec<String> = self.masses.iter().filter(|(_, m)| uses(&m.target)).map(|(n, _)| format!("point mass `{n}`")).collect();
users.extend(self.springs.iter().filter(|(_, s)| s.targets().any(&uses)).map(|(n, s)| format!("{} `{n}`", s.kind())));
users
}
/// The coordinates of a mesh node or a reference point's node label.
pub fn label_xyz(&self, label: u32) -> Option<[f64; 3]> {
self.nodes.get(&label).map(|n| n.xyz).or_else(|| self.reference_points.values().find(|p| p.label == label).map(|p| p.xyz))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Set;
#[test]
fn definitions_validate_and_round_trip() {
let mut m = Model::default();
m.add_set("tip", Set { nodes: vec![1], ..Set::default() }).unwrap();
let json = serde_json::json!({"target": {"set": "tip"}, "mass": [1.0, 2.0, 0.0], "inertia": [1.0, 2.0, 3.0, 0.5, 0.0, 0.0]});
let pm: PointMass = serde_json::from_value(json.clone()).unwrap();
assert_eq!(pm.mass, Some(Mass::Directional([1.0, 2.0, 0.0])));
assert_eq!(serde_json::to_value(&pm).unwrap(), json);
m.add_mass("pm", pm).unwrap();
let iso: PointMass = serde_json::from_value(serde_json::json!({"target": {"set": "tip"}, "mass": 3})).unwrap();
assert_eq!(iso.mass.map(Mass::components), Some([3.0; 3]));
for bad in [
serde_json::json!({"target": {"set": "tip"}}),
serde_json::json!({"target": {"set": "tip"}, "mass": -1}),
serde_json::json!({"target": {"set": "tip"}, "mass": 0}),
serde_json::json!({"target": {"set": "tip"}, "inertia": [1, 1, 1, 2, 0, 0]}),
serde_json::json!({"target": {"set": "tip"}, "inertia": [-1, 1, 1, 0, 0, 0]}),
serde_json::json!({"target": {"set": "tip"}, "inertia": [0, 0, 0, 0, 0, 0]}),
] {
let pm: PointMass = serde_json::from_value(bad.clone()).unwrap();
assert!(m.add_mass("x", pm).is_err(), "{bad}");
}
assert!(serde_json::from_value::<PointMass>(serde_json::json!({"target": {"set": "tip"}, "mass": 1, "masss": 2})).is_err());
let missing: PointMass = serde_json::from_value(serde_json::json!({"target": {"set": "nope"}, "mass": 1})).unwrap();
assert!(matches!(m.add_mass("x", missing), Err(Error::NotFound(_))));
let s: Spring =
serde_json::from_value(serde_json::json!({"type": "spring", "first": {"set": "tip"}, "direction": {"dof": 3}, "stiffness": 100}))
.unwrap();
m.add_spring("k", s).unwrap();
let d: Spring = serde_json::from_value(
serde_json::json!({"type": "dashpot", "first": {"set": "tip"}, "direction": {"vector": [1, 1, 0]}, "coefficient": 2}),
)
.unwrap();
assert!(d.is_dashpot());
m.add_spring("c", d).unwrap();
for bad in [
serde_json::json!({"type": "spring", "first": {"set": "tip"}, "direction": "line", "stiffness": 1}),
serde_json::json!({"type": "spring", "first": {"set": "tip"}, "direction": {"dof": 7}, "stiffness": 1}),
serde_json::json!({"type": "spring", "first": {"set": "tip"}, "direction": {"vector": [0, 0, 0]}, "stiffness": 1}),
serde_json::json!({"type": "spring", "first": {"set": "tip"}, "direction": {"dof": 1}, "stiffness": 0}),
serde_json::json!({"type": "spring", "first": {"set": "tip"}, "direction": {"dof": 1}, "stiffness": f64::NAN}),
serde_json::json!({"type": "dashpot", "first": {"set": "tip"}, "second": {"set": "tip"}, "direction": {"dof": 1}, "coefficient": 1}),
] {
let Ok(s) = serde_json::from_value::<Spring>(bad.clone()) else { continue };
assert!(m.add_spring("x", s).is_err(), "{bad}");
}
assert!(
serde_json::from_value::<Spring>(serde_json::json!({"type": "dashpot", "first": {"set": "tip"}, "direction": "line", "stiffness": 1}))
.is_err()
);
// The set is in use.
assert!(matches!(m.remove_set("tip"), Err(Error::InUse(_))));
m.remove_mass("pm").unwrap();
m.remove_spring("k").unwrap();
m.remove_spring("c").unwrap();
m.remove_set("tip").unwrap();
}
#[test]
fn directions_give_unit_coefficients() {
assert_eq!(ResolvedDirection::Dof(5).coefficients(), [0.0, 0.0, 0.0, 0.0, 1.0, 0.0]);
assert_eq!(ResolvedDirection::Line([0.6, 0.8, 0.0]).coefficients(), [0.6, 0.8, 0.0, 0.0, 0.0, 0.0]);
assert!(ResolvedDirection::Dof(4).is_rotational() && !ResolvedDirection::Dof(3).is_rotational());
}
#[test]
fn resolution_expands_and_checks_targets() {
use crate::{Procedure, ReferencePoint, Step};
let mut m = Model::default();
m.set_node(1, [0.0; 3]).unwrap();
m.set_node(2, [3.0, 4.0, 0.0]).unwrap();
m.add_set("a", Set { nodes: vec![1], ..Set::default() }).unwrap();
m.add_set("b", Set { nodes: vec![2], ..Set::default() }).unwrap();
m.add_set("ab", Set { nodes: vec![1, 2], ..Set::default() }).unwrap();
m.add_reference_point("RP", ReferencePoint { xyz: [0.0, 0.0, 1.0], label: 0 }).unwrap();
m.add_step(Step::new("Modes", Procedure::Frequency { modes: 1, min_hz: None, max_hz: None }), None).unwrap();
let spring = |first: &str, second: Option<&str>, direction| Spring::Spring {
first: Target::Set(first.into()),
second: second.map(|s| Target::Set(s.into())),
direction,
stiffness: 10.0,
};
m.add_spring("line", spring("a", Some("b"), Direction::Line)).unwrap();
m.add_spring("ground", spring("ab", None, Direction::Vector([0.0, 0.0, 2.0]))).unwrap();
m.add_mass("m", PointMass { target: Target::Set("ab".into()), mass: Some(Mass::Isotropic(2.0)), inertia: None }).unwrap();
m.add_mass("j", PointMass { target: Target::ReferencePoint("RP".into()), mass: None, inertia: Some([1.0, 1.0, 1.0, 0.0, 0.0, 0.0]) })
.unwrap();
// No mesh elements: the discrete elements are the model.
let r = m.resolve().unwrap();
assert_eq!(r.springs.len(), 3);
assert_eq!(r.springs[0].direction, ResolvedDirection::Vector([0.0, 0.0, 1.0]));
assert_eq!((r.springs[2].first, r.springs[2].second), (1, Some(2)));
assert_eq!(r.springs[2].direction, ResolvedDirection::Line([0.6, 0.8, 0.0]));
assert_eq!(r.masses.len(), 3);
assert_eq!(r.masses.iter().map(|x| x.node).collect::<Vec<_>>(), vec![99_999_999, 1, 2]);
// Errors: two-point ends with several nodes, rotations at solid nodes, inertia at mesh nodes.
m.add_spring("wide", spring("ab", Some("b"), Direction::Dof(1))).unwrap();
m.add_spring("twist", spring("a", None, Direction::Dof(6))).unwrap();
m.add_mass("bad", PointMass { target: Target::Set("a".into()), mass: None, inertia: Some([1.0, 1.0, 1.0, 0.0, 0.0, 0.0]) }).unwrap();
let errors = m.resolve().unwrap_err().to_string();
for want in ["spring `wide`: a two-point spring connects one node to one node", "spring `twist`: acts on a rotation", "point mass `bad`"] {
assert!(errors.contains(want), "{errors}");
}
// A reference point used by a discrete element cannot be deleted.
assert!(matches!(m.remove_reference_point("RP"), Err(Error::InUse(_))));
// Torsion at a reference point is fine.
m.remove_spring("twist").unwrap();
m.remove_spring("wide").unwrap();
m.remove_mass("bad").unwrap();
m.add_spring(
"twist",
Spring::Spring { first: Target::ReferencePoint("RP".into()), second: None, direction: Direction::Dof(6), stiffness: 1.0 },
)
.unwrap();
m.resolve().unwrap();
// The definitions survive a file round trip.
let back: Model = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(back, m);
}
}
+23 -2
View File
@@ -8,7 +8,8 @@
//! - named sets ([`sets`]) defined by geometry names or explicit labels, resolved to nodes,
//! elements and element faces against the current mesh, so they survive remeshing;
//! - materials and the unit system ([`materials`], [`units`]), sections with their element
//! formulation ([`sections`]), reference points and couplings ([`constraints`]);
//! formulation ([`sections`]), reference points and couplings ([`constraints`]), point masses,
//! springs and dashpots ([`discrete`]);
//! - boundary conditions ([`bcs`]), loads ([`loads`]) and steps with an explicit table of which
//! boundary conditions and loads are active in each step ([`steps`]);
//! - the check that names every missing or bad item, and the resolved view the deck writers and
@@ -33,6 +34,7 @@
pub mod bcs;
pub mod check;
pub mod constraints;
pub mod discrete;
pub mod fluids;
pub mod geometry;
pub mod loads;
@@ -51,6 +53,7 @@ use serde::{Deserialize, Serialize};
pub use bcs::{BoundaryCondition, Dofs, Symmetry, Target};
pub use check::{Diagnostic, Resolved, ResolvedSet, Severity};
pub use constraints::{Coupling, CouplingKind, ReferencePoint};
pub use discrete::{Direction, Mass, PointMass, ResolvedDirection, ResolvedMass, ResolvedSpring, Spring};
pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition};
pub use geometry::{Classification, Entity, GeometryRef};
pub use loads::{Distribution, Load, centrifugal_acceleration};
@@ -113,6 +116,10 @@ pub struct Model {
sections: BTreeMap<String, Section>,
reference_points: BTreeMap<String, ReferencePoint>,
couplings: BTreeMap<String, Coupling>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
masses: BTreeMap<String, PointMass>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
springs: BTreeMap<String, Spring>,
boundary_conditions: BTreeMap<String, BoundaryCondition>,
loads: BTreeMap<String, Load>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
@@ -144,6 +151,10 @@ struct ModelData {
#[serde(default)]
couplings: BTreeMap<String, Coupling>,
#[serde(default)]
masses: BTreeMap<String, PointMass>,
#[serde(default)]
springs: BTreeMap<String, Spring>,
#[serde(default)]
boundary_conditions: BTreeMap<String, BoundaryCondition>,
#[serde(default)]
loads: BTreeMap<String, Load>,
@@ -198,6 +209,12 @@ impl TryFrom<ModelData> for Model {
for (n, v) in d.couplings {
m.add_coupling(&n, v)?;
}
for (n, v) in d.masses {
m.add_mass(&n, v)?;
}
for (n, v) in d.springs {
m.add_spring(&n, v)?;
}
for (n, v) in d.boundary_conditions {
m.add_boundary_condition(&n, v)?;
}
@@ -307,6 +324,8 @@ impl Model {
sections: self.sections.clone(),
reference_points: self.reference_points.clone(),
couplings: self.couplings.clone(),
masses: self.masses.clone(),
springs: self.springs.clone(),
boundary_conditions: self.boundary_conditions.clone(),
loads: self.loads.clone(),
fluid_domains: self.fluid_domains.clone(),
@@ -315,13 +334,15 @@ impl Model {
}
/// Counts of each kind of definition, for summaries.
pub fn definition_counts(&self) -> [(&'static str, usize); 9] {
pub fn definition_counts(&self) -> [(&'static str, usize); 11] {
[
("sets", self.sets.len()),
("materials", self.materials.len()),
("sections", self.sections.len()),
("reference_points", self.reference_points.len()),
("couplings", self.couplings.len()),
("masses", self.masses.len()),
("springs", self.springs.len()),
("boundary_conditions", self.boundary_conditions.len()),
("loads", self.loads.len()),
("fluid_domains", self.fluid_domains.len()),
+13 -7
View File
@@ -14,6 +14,7 @@ pub struct Node {
/// Element topology. Node order is corners first, then mid-side nodes, with positive volume for
/// a right-handed ordering:
/// - rod3: the two ends, then the middle node;
/// - tet10: mid-side nodes 5–10 on edges 1-2, 2-3, 3-1, 1-4, 2-4, 3-4;
/// - wedge6: triangle 1-2-3, then 4-5-6 on the side its right-hand normal points to;
/// - hex8/hex20: face 1-2-3-4, then 5-6-7-8 above it; hex20's mid-side nodes 9–12 lie on the
@@ -25,8 +26,10 @@ pub struct Node {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ElementKind {
/// Two-node axial member.
/// Two-node axial member (truss).
Rod2,
/// Three-node axial member (quadratic truss, straight or curved).
Rod3,
/// Two-node beam.
Beam2,
/// Three-node triangle (shell or membrane).
@@ -50,8 +53,9 @@ pub enum ElementKind {
}
impl ElementKind {
pub const ALL: [ElementKind; 11] = [
pub const ALL: [ElementKind; 12] = [
ElementKind::Rod2,
ElementKind::Rod3,
ElementKind::Beam2,
ElementKind::Tri3,
ElementKind::Tri6,
@@ -67,7 +71,7 @@ impl ElementKind {
pub fn node_count(self) -> usize {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => 2,
ElementKind::Tri3 => 3,
ElementKind::Rod3 | ElementKind::Tri3 => 3,
ElementKind::Quad4 | ElementKind::Tet4 => 4,
ElementKind::Tri6 | ElementKind::Wedge6 => 6,
ElementKind::Quad8 | ElementKind::Hex8 => 8,
@@ -79,7 +83,7 @@ impl ElementKind {
/// Corner nodes: the first ones in the node list (the rest are mid-side nodes).
pub fn corner_count(self) -> usize {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => 2,
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => 2,
ElementKind::Tri3 | ElementKind::Tri6 => 3,
ElementKind::Quad4 | ElementKind::Quad8 | ElementKind::Tet4 | ElementKind::Tet10 => 4,
ElementKind::Wedge6 => 6,
@@ -95,7 +99,7 @@ impl ElementKind {
/// Topological dimension: 1 for line elements, 2 for shells, 3 for solids.
pub fn dimension(self) -> u8 {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => 1,
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => 1,
ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => 2,
ElementKind::Tet4 | ElementKind::Tet10 | ElementKind::Wedge6 | ElementKind::Hex8 | ElementKind::Hex20 => 3,
}
@@ -105,6 +109,7 @@ impl ElementKind {
pub fn name(self) -> &'static str {
match self {
ElementKind::Rod2 => "rod2",
ElementKind::Rod3 => "rod3",
ElementKind::Beam2 => "beam2",
ElementKind::Tri3 => "tri3",
ElementKind::Tri6 => "tri6",
@@ -134,7 +139,7 @@ impl ElementKind {
/// - Line elements have none.
pub fn faces(self) -> &'static [&'static [usize]] {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => &[],
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => &[],
ElementKind::Tri3 => &[&[0, 1, 2], &[0, 2, 1]],
ElementKind::Tri6 => &[&[0, 1, 2, 3, 4, 5], &[0, 2, 1, 5, 4, 3]],
ElementKind::Quad4 => &[&[0, 1, 2, 3], &[0, 3, 2, 1]],
@@ -160,6 +165,7 @@ impl ElementKind {
pub fn edges(self) -> &'static [&'static [usize]] {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => &[&[0, 1]],
ElementKind::Rod3 => &[&[0, 1, 2]],
ElementKind::Tri3 => &[&[0, 1], &[1, 2], &[2, 0]],
ElementKind::Tri6 => &[&[0, 1, 3], &[1, 2, 4], &[2, 0, 5]],
ElementKind::Quad4 => &[&[0, 1], &[1, 2], &[2, 3], &[3, 0]],
@@ -357,7 +363,7 @@ mod tests {
/// Reference coordinates with positive volume for each kind (mid-side nodes at edge midpoints).
fn reference(kind: ElementKind) -> Vec<[f64; 3]> {
let corners: Vec<[f64; 3]> = match kind {
ElementKind::Rod2 | ElementKind::Beam2 => vec![[0., 0., 0.], [1., 0., 0.]],
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => vec![[0., 0., 0.], [1., 0., 0.]],
ElementKind::Tri3 | ElementKind::Tri6 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
ElementKind::Quad4 | ElementKind::Quad8 => vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]],
ElementKind::Tet4 | ElementKind::Tet10 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]],
+1
View File
@@ -122,6 +122,7 @@ impl Model {
let mut users = Vec::new();
users.extend(self.sections.iter().filter(|(_, s)| s.set == set).map(|(n, _)| format!("section `{n}`")));
users.extend(self.couplings.iter().filter(|(_, c)| c.set == set).map(|(n, _)| format!("coupling `{n}`")));
users.extend(self.discrete_users(|t| t.set() == Some(set)));
users
.extend(self.boundary_conditions.iter().filter(|(_, b)| b.target().set() == Some(set)).map(|(n, _)| format!("boundary condition `{n}`")));
users.extend(self.loads.iter().filter(|(_, l)| l.set() == Some(set)).map(|(n, _)| format!("load `{n}`")));