forked from 1b-agent/fieldcraft-upstream
Explicit dynamics core: central differences, one-point hex8 with hourglass control, nodal-pressure tet4, solve.explicit (M10 X1) (#27)
* Model: explicit dynamic steps (end time, increment scale and cap, output intervals, initial velocities, rigid walls, hourglass and bulk viscosity controls); deck writers refuse them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * fem: uniform-strain hex8 and tet4 kinematics, Flanagan–Belytschko hourglass shape vectors, element frequency bound Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Explicit solver crate (L2): central differences with lumped mass, one-point hex8 with Flanagan–Belytschko hourglass control, Bonet–Burton tet4, bulk viscosity, rigid walls, energy account; wave and impact verification Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Engine: solve.explicit and solve.history commands, explicit results in the session and solve.probe; cantilever period against the modal solver Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Docs: explicit solver in the architecture, roadmap (M10 started), parity and literature (Part H) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Simplify: Model::select_step shared by both solvers, Procedure::sets/is_dynamic, fused per-increment passes without allocation, shared element tail, generic command caps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Catalogue: solve.explicit is ready; solve.history beside solve.probe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Rebase onto the implicit couplings work: Procedure is no longer Copy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
33 files changed
+2757
-65
No files matched your search
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "modelcraft-explicit"
|
||||
description = "ModelCraft's explicit dynamics solver: central-difference time integration with lumped mass, one-point hexahedra with hourglass control, nodal-pressure tetrahedra, bulk viscosity and energy accounting"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
modelcraft-model = { workspace = true }
|
||||
modelcraft-fem = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Element internal forces and critical time increments.
|
||||
//!
|
||||
//! **One-point hexahedron** (D. P. Flanagan, T. Belytschko, IJNME 17(5):679–706, 1981): the
|
||||
//! stress of the mean strain gives the forces f_iI = σ_ij b_jI ([`modelcraft_fem::uniform`]).
|
||||
//! The hourglass modes are resisted through the shape vectors γ_α: with the hourglass rates
|
||||
//! q̇_iα = Σ_I v_iI γ_αI (or displacements q_iα = Σ_I u_iI γ_αI for the stiffness form), the
|
||||
//! generalized forces Q_iα = C q̇_iα (viscous) or k q_iα (stiffness) give the nodal forces
|
||||
//! f_iI = Σ_α Q_iα γ_αI. Because the γ_α are orthogonal to linear fields, these forces do no work
|
||||
//! in a constant strain state. The coefficients are scaled from the dimensionless hourglass
|
||||
//! coefficient κ with the element's size h = V / √(2 λ_max(Σ b bᵀ)) (the edge, for a cube),
|
||||
//! density ρ and wave speed c: C = κ ρ c h² / 16 damps a cube's hourglass modes at a fraction κ
|
||||
//! of critical at the frequency 2c/h, and k = κ ρ c² h / 16 puts their frequency at √κ · 2c/h.
|
||||
//! (These normalizations are ours; the paper leaves the scale of the coefficient to the user.)
|
||||
//!
|
||||
//! **Average nodal pressure tetrahedron** (J. Bonet, A. J. Burton, *A simple average nodal
|
||||
//! pressure tetrahedral element for incompressible and nearly incompressible dynamic explicit
|
||||
//! applications*, Commun. Numer. Methods Eng. 14(5):437–449, 1998): the volumetric strain of each
|
||||
//! node is the volume-weighted mean of its tetrahedra's, and each tetrahedron takes the mean of
|
||||
//! its nodes' as its own: σ = 2μ dev ε + K θ̄ I. The forces are those of the strain energy
|
||||
//! Σ_e V_e μ ‖dev ε_e‖² + ½ K Σ_a V_a θ_a², so the element conserves energy, and it does not lock
|
||||
//! when the material is nearly incompressible.
|
||||
//!
|
||||
//! **Bulk viscosity** (J. von Neumann, R. D. Richtmyer, J. Appl. Phys. 21(3):232–237, 1950; M. L.
|
||||
//! Wilkins, J. Comput. Phys. 36(3):281–303, 1980): in compression (volumetric strain rate
|
||||
//! ε̇ᵥ < 0) a pressure q = ρ L (C_q L ε̇ᵥ² − C_l c ε̇ᵥ) spreads shocks over a few elements and
|
||||
//! damps the ringing behind wave fronts. It lowers the stable increment to
|
||||
//! Δt = L / (Q + √(Q² + c²)), Q = C_l c + C_q L |ε̇ᵥ| (Wilkins 1980).
|
||||
//!
|
||||
//! **Critical increment.** Each element's stable length L = 2c/ω_max comes from the bound on its
|
||||
//! highest frequency ([`modelcraft_fem::uniform::UniformStrain::frequency_bound`], plus the
|
||||
//! hourglass stiffness when it is used), so without bulk viscosity its critical increment is
|
||||
//! 2/ω_max (T. Belytschko, W. K. Liu, B. Moran, K. Elkhodary, 2014, §6.3; the element eigenvalue
|
||||
//! inequality makes the smallest of them stable for the mesh). For the nodal-pressure
|
||||
//! tetrahedron the averaging only lowers the volumetric energy (the mean of squares bounds the
|
||||
//! square of the mean), so the plain tetrahedron's bound holds.
|
||||
|
||||
use modelcraft_fem::material::stress;
|
||||
use modelcraft_fem::uniform::UniformStrain;
|
||||
use modelcraft_model::{BulkViscosity, HourglassKind};
|
||||
|
||||
use crate::system::{Material, System};
|
||||
|
||||
/// Scratch vectors and the element state of the latest evaluation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Workspace {
|
||||
/// Internal forces per degree of freedom, by origin.
|
||||
pub stress_force: Vec<f64>,
|
||||
pub hourglass_force: Vec<f64>,
|
||||
pub viscous_force: Vec<f64>,
|
||||
/// Per element (in [`System::elements`] order): the material stress and the strain.
|
||||
pub stress: Vec<[f64; 6]>,
|
||||
pub strain: Vec<[f64; 6]>,
|
||||
/// Per node: the mean volumetric strain of its tetrahedra (a volume-weighted sum until it is
|
||||
/// divided by `tet_volume`).
|
||||
theta: Vec<f64>,
|
||||
tet_volume: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn new(sys: &System) -> Workspace {
|
||||
let n = sys.labels.len();
|
||||
let mut tet_volume = vec![0.0; n];
|
||||
for t in &sys.tets {
|
||||
for node in t.nodes {
|
||||
if let Some(v) = tet_volume.get_mut(node) {
|
||||
*v += t.kinematics.volume;
|
||||
}
|
||||
}
|
||||
}
|
||||
let dofs = sys.dofs();
|
||||
let elements = sys.elements.len();
|
||||
Workspace {
|
||||
stress_force: vec![0.0; dofs],
|
||||
hourglass_force: vec![0.0; dofs],
|
||||
viscous_force: vec![0.0; dofs],
|
||||
stress: vec![[0.0; 6]; elements],
|
||||
strain: vec![[0.0; 6]; elements],
|
||||
theta: vec![0.0; n],
|
||||
tet_volume,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gather<const N: usize>(x: &[f64], nodes: &[usize; N]) -> [[f64; 3]; N] {
|
||||
nodes.map(|n| [0, 1, 2].map(|k| x.get(3 * n + k).copied().unwrap_or(0.0)))
|
||||
}
|
||||
|
||||
/// Add a node's vector into a per-degree-of-freedom vector.
|
||||
pub(crate) fn add_at(f: &mut [f64], node: usize, v: &[f64; 3]) {
|
||||
for (k, c) in v.iter().enumerate() {
|
||||
if let Some(x) = f.get_mut(3 * node + k) {
|
||||
*x += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scatter<const N: usize>(f: &mut [f64], nodes: &[usize; N], v: &[[f64; 3]; N]) {
|
||||
for (n, vi) in nodes.iter().zip(v) {
|
||||
add_at(f, *n, vi);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bulk viscosity pressure (positive in compression) for a volumetric strain rate.
|
||||
pub fn bulk_pressure(rate: f64, density: f64, length: f64, wave_speed: f64, b: &BulkViscosity) -> f64 {
|
||||
if rate < 0.0 { density * length * (b.quadratic * length * rate * rate - b.linear * wave_speed * rate) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The critical increment of an element of stable length `length` at a volumetric strain rate
|
||||
/// (Wilkins 1980).
|
||||
pub fn critical_increment(rate: f64, length: f64, wave_speed: f64, b: &BulkViscosity) -> f64 {
|
||||
let q = if rate < 0.0 { b.linear * wave_speed + b.quadratic * length * rate.abs() } else { 0.0 };
|
||||
length / (q + (q * q + wave_speed * wave_speed).sqrt())
|
||||
}
|
||||
|
||||
/// What the hexahedron and the tetrahedron share once their stress is known: the stress and bulk
|
||||
/// viscosity forces, the element state and the critical increment.
|
||||
struct Finish<'a, const N: usize> {
|
||||
kinematics: &'a UniformStrain<N>,
|
||||
nodes: &'a [usize; N],
|
||||
index: usize,
|
||||
length: f64,
|
||||
}
|
||||
|
||||
impl<const N: usize> Finish<'_, N> {
|
||||
fn apply(&self, ws: &mut Workspace, mat: &Material, v: &[f64], sigma: [f64; 6], strain: [f64; 6], bulk: &BulkViscosity) -> f64 {
|
||||
let k = self.kinematics;
|
||||
let rate = k.divergence(&gather(v, self.nodes));
|
||||
let q = bulk_pressure(rate, mat.density, self.length, mat.wave_speed, bulk);
|
||||
scatter(&mut ws.stress_force, self.nodes, &k.forces(&sigma));
|
||||
if q != 0.0 {
|
||||
scatter(&mut ws.viscous_force, self.nodes, &k.pressure_forces(q));
|
||||
}
|
||||
if let Some(s) = ws.stress.get_mut(self.index) {
|
||||
*s = sigma;
|
||||
}
|
||||
if let Some(s) = ws.strain.get_mut(self.index) {
|
||||
*s = strain;
|
||||
}
|
||||
critical_increment(rate, self.length, mat.wave_speed, bulk)
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the internal forces at displacements `u` and velocities `v` into `ws` and return the
|
||||
/// smallest critical increment of the elements.
|
||||
pub fn internal_forces(sys: &System, u: &[f64], v: &[f64], bulk: &BulkViscosity, ws: &mut Workspace) -> f64 {
|
||||
for f in [&mut ws.stress_force, &mut ws.hourglass_force, &mut ws.viscous_force] {
|
||||
f.fill(0.0);
|
||||
}
|
||||
let mut critical = f64::INFINITY;
|
||||
for h in &sys.hexes {
|
||||
let Some(mat) = sys.materials.get(h.material) else { continue };
|
||||
let ue = gather(u, &h.nodes);
|
||||
let strain = h.kinematics.strain(&ue);
|
||||
let sigma = stress(&mat.elasticity, &strain);
|
||||
// Hourglass forces on the displacement (stiffness) or velocity (viscous) modes.
|
||||
let modes = match sys.hourglass {
|
||||
HourglassKind::Viscous => gather(v, &h.nodes),
|
||||
HourglassKind::Stiffness => ue,
|
||||
};
|
||||
let mut f = [[0.0; 3]; 8];
|
||||
for g in &h.gamma {
|
||||
let mut generalized = [0.0; 3];
|
||||
for (x, gk) in modes.iter().zip(g) {
|
||||
for i in 0..3 {
|
||||
generalized[i] += x[i] * gk;
|
||||
}
|
||||
}
|
||||
for (fk, gk) in f.iter_mut().zip(g) {
|
||||
for i in 0..3 {
|
||||
fk[i] += h.hourglass * generalized[i] * gk;
|
||||
}
|
||||
}
|
||||
}
|
||||
scatter(&mut ws.hourglass_force, &h.nodes, &f);
|
||||
let finish = Finish { kinematics: &h.kinematics, nodes: &h.nodes, index: h.index, length: h.length };
|
||||
critical = critical.min(finish.apply(ws, mat, v, sigma, strain, bulk));
|
||||
}
|
||||
if !sys.tets.is_empty() {
|
||||
// Pass 1: each tetrahedron's strain, and each node's mean volumetric strain.
|
||||
ws.theta.fill(0.0);
|
||||
for t in &sys.tets {
|
||||
let strain = t.kinematics.strain(&gather(u, &t.nodes));
|
||||
for node in t.nodes {
|
||||
if let Some(x) = ws.theta.get_mut(node) {
|
||||
*x += t.kinematics.volume * (strain[0] + strain[1] + strain[2]);
|
||||
}
|
||||
}
|
||||
if let Some(s) = ws.strain.get_mut(t.index) {
|
||||
*s = strain;
|
||||
}
|
||||
}
|
||||
for (x, v) in ws.theta.iter_mut().zip(&ws.tet_volume) {
|
||||
if *v > 0.0 {
|
||||
*x /= v;
|
||||
}
|
||||
}
|
||||
// Pass 2: each tetrahedron's pressure from the mean of its nodes' volumetric strains.
|
||||
for t in &sys.tets {
|
||||
let Some(mat) = sys.materials.get(t.material) else { continue };
|
||||
let strain = ws.strain.get(t.index).copied().unwrap_or_default();
|
||||
let averaged = t.nodes.iter().map(|n| ws.theta.get(*n).copied().unwrap_or(0.0)).sum::<f64>() / 4.0;
|
||||
let own = strain[0] + strain[1] + strain[2];
|
||||
let mut sigma = stress(&mat.elasticity, &strain);
|
||||
let shift = mat.bulk.unwrap_or(0.0) * (averaged - own);
|
||||
for s in sigma.iter_mut().take(3) {
|
||||
*s += shift;
|
||||
}
|
||||
let finish = Finish { kinematics: &t.kinematics, nodes: &t.nodes, index: t.index, length: t.length };
|
||||
critical = critical.min(finish.apply(ws, mat, v, sigma, strain, bulk));
|
||||
}
|
||||
}
|
||||
critical
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! The time loop: central differences with variable increments, kinematic constraints, the
|
||||
//! energy account and output.
|
||||
//!
|
||||
//! One increment (Belytschko, Liu, Moran, Elkhodary 2014, Box 6.1, with Δt^n = ½(Δt^{n−½} +
|
||||
//! Δt^{n+½})):
|
||||
//! 1. u^n = u^{n−1} + Δt^{n−½} v^{n−½}; t^n = t^{n−1} + Δt^{n−½};
|
||||
//! 2. internal forces f^n from u^n (stresses, hourglass stiffness) and v^{n−½} (hourglass and
|
||||
//! bulk viscosity), and the next increment Δt^{n+½} = scale · min_e Δt_e;
|
||||
//! 3. v^{n+½} = v^{n−½} + Δt^n M⁻¹(f_ext − f^n), then the constraints: rigid walls set the normal
|
||||
//! velocity of a node that would cross its wall so it lands on it (a frictionless kinematic
|
||||
//! contact condition: T. J. R. Hughes, R. L. Taylor, J. L. Sackman, A. Curnier, W.
|
||||
//! Kanoknukulchai, *A finite element method for a class of contact-impact problems*, CMAME
|
||||
//! 8(3):249–276, 1976), and prescribed degrees of freedom take their velocity. The change a
|
||||
//! constraint makes is the impulse of its reaction, r^n = M Δv / Δt^n;
|
||||
//! 4. energies: the works of the internal and external forces by the trapezoidal rule,
|
||||
//! W += ½ Δu · (f^{n−1} + f^n) (exactly the strain energy for a linear elastic material),
|
||||
//! the constraints' work Δt^n r^n · ½(v^{n−½} + v^{n+½}) (exactly the kinetic energy their
|
||||
//! impulses add or remove), and the kinetic energy of v^n = v^{n−½} + ½Δt^{n−½} a^n.
|
||||
//!
|
||||
//! The run starts from u = 0 and the initial velocities with the half increment
|
||||
//! v^{½} = v^0 + ½Δt^{½} a^0.
|
||||
|
||||
use modelcraft_model::{ExplicitDynamic, Model, OutputVariable, Procedure, Step};
|
||||
|
||||
use crate::elements::{Workspace, internal_forces};
|
||||
use crate::result::{ControllingElement, Energies, ExplicitResult, Frame, History};
|
||||
use crate::system::System;
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
/// How many elements the result names as controlling the time increment.
|
||||
pub const CONTROLLING: usize = 10;
|
||||
|
||||
/// Relative energy balance error above which a run warns.
|
||||
pub const BALANCE_TOLERANCE: f64 = 0.01;
|
||||
|
||||
/// Hourglass energy as a fraction of the largest internal energy of the run above which it warns
|
||||
/// (the internal energy may fall back to zero, as in a rebound).
|
||||
pub const HOURGLASS_TOLERANCE: f64 = 0.1;
|
||||
|
||||
/// The step named `name`, or the model's only explicit dynamic step.
|
||||
fn select_step<'m>(model: &'m Model, name: Option<&str>) -> Result<(&'m Step, &'m ExplicitDynamic)> {
|
||||
let step = model.select_step(name, Procedure::is_dynamic, "explicit dynamic")?;
|
||||
match &step.procedure {
|
||||
Procedure::ExplicitDynamic(e) => Ok((step, e)),
|
||||
_ => Err(Error::Internal("an explicit dynamic step".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Times at a fixed interval: whether `t` has reached the next one.
|
||||
struct Schedule {
|
||||
interval: f64,
|
||||
next: f64,
|
||||
}
|
||||
|
||||
impl Schedule {
|
||||
fn due(&mut self, t: f64) -> bool {
|
||||
if t < self.next * (1.0 - 1e-12) {
|
||||
return false;
|
||||
}
|
||||
self.next = ((t / self.interval).floor() + 1.0) * self.interval;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// What the run writes.
|
||||
struct Output {
|
||||
displacement: bool,
|
||||
stress: bool,
|
||||
strain: bool,
|
||||
}
|
||||
|
||||
/// Velocity constraints over an increment of length `dt` (rigid walls when dt > 0, then
|
||||
/// prescribed degrees of freedom).
|
||||
fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64) {
|
||||
if dt > 0.0 {
|
||||
for w in &sys.walls {
|
||||
for &node in &w.nodes {
|
||||
let x = sys.xyz.get(node).copied().unwrap_or_default();
|
||||
let at = |k: usize| 3 * node + k;
|
||||
let gap: f64 = (0..3).map(|k| (x[k] + u.get(at(k)).copied().unwrap_or(0.0) - w.point[k]) * w.normal[k]).sum();
|
||||
let normal: f64 = (0..3).map(|k| v.get(at(k)).copied().unwrap_or(0.0) * w.normal[k]).sum();
|
||||
if gap + dt * normal < 0.0 {
|
||||
let change = -gap / dt - normal;
|
||||
for k in 0..3 {
|
||||
if let Some(c) = v.get_mut(at(k)) {
|
||||
*c += change * w.normal[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (c, p) in v.iter_mut().zip(&sys.prescribed) {
|
||||
if let Some(p) = p {
|
||||
*c = *p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// v_out = v_in + dt M⁻¹ (f_ext − f_int).
|
||||
fn accelerate(sys: &System, ws: &Workspace, mass: &[f64], v_in: &[f64], dt: f64, v_out: &mut [f64]) {
|
||||
let internal = ws.stress_force.iter().zip(&ws.hourglass_force).zip(&ws.viscous_force);
|
||||
for ((((out, v), m), e), ((s, h), q)) in v_out.iter_mut().zip(v_in).zip(mass).zip(&sys.external).zip(internal) {
|
||||
*out = v + dt * ((e - (s + h + q)) / m);
|
||||
}
|
||||
}
|
||||
|
||||
/// The constraint reactions M (v_constrained − v_free) / Δt, into `r`.
|
||||
fn impulses(mass: &[f64], free: &[f64], constrained: &[f64], dt: f64, r: &mut [f64]) {
|
||||
for (r, (m, (f, c))) in r.iter_mut().zip(mass.iter().zip(free.iter().zip(constrained))) {
|
||||
*r = if c == f { 0.0 } else { m * (c - f) / dt };
|
||||
}
|
||||
}
|
||||
|
||||
fn per_node(x: &[f64]) -> Vec<[f64; 3]> {
|
||||
x.as_chunks::<3>().0.to_vec()
|
||||
}
|
||||
|
||||
/// Solve an explicit dynamic step: the step named `step`, or the model's only one.
|
||||
pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Result<ExplicitResult> {
|
||||
let resolved = model.resolve()?;
|
||||
let (step, settings) = select_step(model, step)?;
|
||||
let sys = System::new(model, &resolved, step, settings, limits.max_dofs)?;
|
||||
let end = settings.end_time;
|
||||
let scale = settings.dt_scale;
|
||||
let bulk = settings.bulk_viscosity;
|
||||
let max_increments = settings.max_increments.min(limits.max_increments);
|
||||
let mut warnings = sys.warnings.clone();
|
||||
|
||||
// The controlling elements: the smallest critical increments at rest.
|
||||
let mut critical: Vec<ControllingElement> = sys
|
||||
.hexes
|
||||
.iter()
|
||||
.map(|h| (h.label, h.length, h.material))
|
||||
.chain(sys.tets.iter().map(|t| (t.label, t.length, t.material)))
|
||||
.map(|(label, length, m)| ControllingElement { label, increment: length / sys.materials.get(m).map_or(f64::INFINITY, |m| m.wave_speed) })
|
||||
.collect();
|
||||
critical.sort_by(|a, b| a.increment.total_cmp(&b.increment).then(a.label.cmp(&b.label)));
|
||||
critical.truncate(CONTROLLING);
|
||||
let smallest = critical
|
||||
.first()
|
||||
.map(|c| c.increment)
|
||||
.filter(|d| *d > 0.0 && d.is_finite())
|
||||
.ok_or_else(|| Error::Invalid("no element has a finite, positive critical time increment (check the material constants)".into()))?;
|
||||
let estimate = end / (scale * smallest);
|
||||
if estimate > max_increments as f64 {
|
||||
let first = critical.first().map_or(0, |c| c.label);
|
||||
return Err(Error::Limit(format!(
|
||||
"the step needs about {estimate:.3e} increments of {:.3e} (element {first} controls it), more than the cap of {max_increments}",
|
||||
scale * smallest
|
||||
)));
|
||||
}
|
||||
|
||||
// Output sizes.
|
||||
let output = Output {
|
||||
displacement: step.output.contains(&OutputVariable::Displacement),
|
||||
stress: step.output.contains(&OutputVariable::Stress),
|
||||
strain: step.output.contains(&OutputVariable::Strain),
|
||||
};
|
||||
let (nodes, elements) = (sys.labels.len(), sys.elements.len());
|
||||
let frames = (end / settings.output_interval()).ceil() + 2.0;
|
||||
let samples = (end / settings.history_interval()).ceil() + 2.0;
|
||||
let per_frame = (if output.displacement { 6 * nodes } else { 0 }) + 6 * elements * (usize::from(output.stress) + usize::from(output.strain));
|
||||
let per_sample = 16 + 6 * sys.history_nodes.len();
|
||||
let values = frames * per_frame as f64 + samples * per_sample as f64;
|
||||
if values > limits.max_output_values as f64 {
|
||||
return Err(Error::Limit(format!(
|
||||
"the output would hold {values:.3e} numbers; the limit is {} (lengthen the output or history interval, or request less output)",
|
||||
limits.max_output_values
|
||||
)));
|
||||
}
|
||||
|
||||
let n = sys.dofs();
|
||||
let mass: Vec<f64> = (0..n).map(|d| sys.mass.get(d / 3).copied().unwrap_or(0.0)).collect();
|
||||
if mass.iter().any(|m| !(*m > 0.0 && m.is_finite())) {
|
||||
return Err(Error::Internal("a node has no positive mass".into()));
|
||||
}
|
||||
let total_mass: f64 = sys.mass.iter().sum();
|
||||
let mut ws = Workspace::new(&sys);
|
||||
let mut previous = Workspace::new(&sys);
|
||||
let mut u = vec![0.0; n];
|
||||
let mut du = vec![0.0; n];
|
||||
let v0 = sys.initial_velocity.clone();
|
||||
let initial_kinetic = 0.5 * v0.iter().zip(&mass).map(|(v, m)| m * v * v).sum::<f64>();
|
||||
|
||||
// Start: forces at rest with the initial velocities, then the half increment.
|
||||
let crit = internal_forces(&sys, &u, &v0, &bulk, &mut ws);
|
||||
let mut dt_half = (scale * crit).min(end);
|
||||
let initial_increment = dt_half;
|
||||
let mut v_half = vec![0.0; n];
|
||||
accelerate(&sys, &ws, &mass, &v0, 0.5 * dt_half, &mut v_half);
|
||||
let mut free = v_half.clone();
|
||||
constrain(&sys, &u, &mut v_half, dt_half);
|
||||
let mut reaction = vec![0.0; n];
|
||||
impulses(&mass, &free, &v_half, 0.5 * dt_half, &mut reaction);
|
||||
// The constraints' work is that of their impulses on the mean of the velocities before
|
||||
// and after the increment, Δt r · ½(v^{n−½} + v^{n+½}): exact for the kinetic energy a
|
||||
// kinematic constraint removes or adds (a node stopped by a wall loses ½ m v²).
|
||||
let impulse_work = |r: &[f64], before: &[f64], after: &[f64], dt: f64| -> f64 {
|
||||
dt * r.iter().zip(before.iter().zip(after)).map(|(r, (a, b))| r * 0.5 * (a + b)).sum::<f64>()
|
||||
};
|
||||
let mut energy = Energies { kinetic: initial_kinetic, constraint: impulse_work(&reaction, &v0, &v_half, 0.5 * dt_half), ..Energies::default() };
|
||||
|
||||
let mut history = History { nodes: sys.history_nodes.iter().filter_map(|i| sys.labels.get(*i).copied()).collect(), ..History::default() };
|
||||
let mut out_frames = Vec::new();
|
||||
let sample = |history: &mut History, t: f64, dt: f64, e: Energies, u: &[f64], v: &[f64], r: &[f64]| {
|
||||
history.time.push(t);
|
||||
history.increment.push(dt);
|
||||
history.energies.push(e);
|
||||
let sum = |x: &[f64], weight: &dyn Fn(usize) -> f64| {
|
||||
let mut s = [0.0; 3];
|
||||
for (node, c) in x.as_chunks::<3>().0.iter().enumerate() {
|
||||
for k in 0..3 {
|
||||
s[k] += weight(node) * c[k];
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
history.momentum.push(sum(v, &|node| sys.mass.get(node).copied().unwrap_or(0.0)));
|
||||
history.constraint_force.push(sum(r, &|_| 1.0));
|
||||
let pick = |x: &[f64]| sys.history_nodes.iter().map(|i| [0, 1, 2].map(|k| x.get(3 * i + k).copied().unwrap_or(0.0))).collect();
|
||||
history.displacement.push(pick(u));
|
||||
history.velocity.push(pick(v));
|
||||
};
|
||||
let frame = |t: f64, u: &[f64], v: &[f64], ws: &Workspace| Frame {
|
||||
time: t,
|
||||
displacement: if output.displacement { per_node(u) } else { Vec::new() },
|
||||
velocity: if output.displacement { per_node(v) } else { Vec::new() },
|
||||
stress: if output.stress { ws.stress.clone() } else { Vec::new() },
|
||||
strain: if output.strain { ws.strain.clone() } else { Vec::new() },
|
||||
};
|
||||
let mut histories = Schedule { interval: settings.history_interval(), next: settings.history_interval() };
|
||||
let mut frames_due = Schedule { interval: settings.output_interval(), next: settings.output_interval() };
|
||||
sample(&mut history, 0.0, dt_half, energy, &u, &v0, &reaction);
|
||||
out_frames.push(frame(0.0, &u, &v0, &ws));
|
||||
|
||||
let mut t = 0.0;
|
||||
let mut increments = 0u64;
|
||||
let mut peak_internal: f64 = 0.0;
|
||||
let mut v_now = v0;
|
||||
let mut completed = true;
|
||||
let mut v_new = vec![0.0; n];
|
||||
loop {
|
||||
if increments >= max_increments {
|
||||
completed = false;
|
||||
warnings.push(format!("stopped at time {t:.6e} after {increments} increments (the cap); the step ends at {end:.6e}"));
|
||||
break;
|
||||
}
|
||||
for ((x, dx), v) in u.iter_mut().zip(du.iter_mut()).zip(&v_half) {
|
||||
*dx = dt_half * v;
|
||||
*x += *dx;
|
||||
}
|
||||
t += dt_half;
|
||||
increments += 1;
|
||||
std::mem::swap(&mut ws, &mut previous);
|
||||
let crit = internal_forces(&sys, &u, &v_half, &bulk, &mut ws);
|
||||
let remaining = end - t;
|
||||
let last = remaining <= 1e-12 * end;
|
||||
let dt_next = if last { 0.0 } else { (scale * crit).min(remaining) };
|
||||
let dt_n = 0.5 * (dt_half + dt_next);
|
||||
accelerate(&sys, &ws, &mass, &v_half, dt_n, &mut v_new);
|
||||
free.clone_from(&v_new);
|
||||
constrain(&sys, &u, &mut v_new, dt_next);
|
||||
impulses(&mass, &free, &v_new, dt_n, &mut reaction);
|
||||
// One pass over the degrees of freedom: v^n = v^{n−½} + ½Δt^{n−½} a^n with the constrained
|
||||
// a^n = (v^{n+½} − v^{n−½}) / Δt^n, and the works (trapezoidal over the displacement for
|
||||
// the internal and external forces).
|
||||
let weight = 0.5 * dt_half / dt_n;
|
||||
let [mut internal, mut hourglass, mut viscous, mut external, mut constraint, mut kinetic] = [0.0; 6];
|
||||
let forces = ws.stress_force.iter().zip(&ws.hourglass_force).zip(&ws.viscous_force);
|
||||
let before = previous.stress_force.iter().zip(&previous.hourglass_force).zip(&previous.viscous_force);
|
||||
let state = v_now.iter_mut().zip(&v_half).zip(&v_new).zip(&mass).zip(&du).zip(&sys.external).zip(&reaction);
|
||||
for ((((((((now, vh), vn), m), dx), e), r), ((s, h), q)), ((s0, h0), q0)) in state.zip(forces).zip(before) {
|
||||
*now = vh + weight * (vn - vh);
|
||||
internal += dx * (s0 + s);
|
||||
hourglass += dx * (h0 + h);
|
||||
viscous += dx * (q0 + q);
|
||||
external += dx * e;
|
||||
constraint += r * 0.5 * (vh + vn);
|
||||
kinetic += m * *now * *now;
|
||||
}
|
||||
energy.internal += 0.5 * internal;
|
||||
energy.hourglass += 0.5 * hourglass;
|
||||
energy.viscous += 0.5 * viscous;
|
||||
energy.external += external;
|
||||
energy.constraint += dt_n * constraint;
|
||||
energy.kinetic = 0.5 * kinetic;
|
||||
peak_internal = peak_internal.max(energy.internal.abs());
|
||||
energy.balance = energy.kinetic + energy.internal + energy.hourglass + energy.viscous - energy.external - energy.constraint - initial_kinetic;
|
||||
if !(energy.balance.is_finite() && crit.is_finite()) {
|
||||
return Err(Error::Unstable(format!("the solution is not finite at time {t:.6e} (increment {increments})")));
|
||||
}
|
||||
if histories.due(t) || last {
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction);
|
||||
}
|
||||
if frames_due.due(t) || last {
|
||||
out_frames.push(frame(t, &u, &v_now, &ws));
|
||||
}
|
||||
std::mem::swap(&mut v_half, &mut v_new);
|
||||
dt_half = dt_next;
|
||||
if last {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !completed {
|
||||
if history.time.last() != Some(&t) {
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction);
|
||||
}
|
||||
if out_frames.last().map(|f| f.time) != Some(t) {
|
||||
out_frames.push(frame(t, &u, &v_now, &ws));
|
||||
}
|
||||
}
|
||||
|
||||
let error = energy.relative_error(initial_kinetic);
|
||||
if error > BALANCE_TOLERANCE {
|
||||
warnings.push(format!("the energy balance is off by {:.2}% of the largest energy (kinetic, internal or work)", 100.0 * error));
|
||||
}
|
||||
// Round-off hourglass energy in a model at rest or in rigid motion is not worth a warning.
|
||||
let floor =
|
||||
1e-9 * [initial_kinetic, energy.kinetic, peak_internal, energy.external.abs(), energy.constraint.abs()].into_iter().fold(0.0, f64::max);
|
||||
if energy.hourglass.abs() > HOURGLASS_TOLERANCE * peak_internal && energy.hourglass.abs() > floor {
|
||||
warnings.push(format!(
|
||||
"hourglass energy is {:.1}% of the largest internal energy; refine the mesh or change the hourglass control",
|
||||
100.0 * energy.hourglass.abs() / peak_internal.max(f64::MIN_POSITIVE)
|
||||
));
|
||||
}
|
||||
Ok(ExplicitResult {
|
||||
step: step.name.clone(),
|
||||
nodes: sys.labels.clone(),
|
||||
elements: sys.elements.clone(),
|
||||
frames: out_frames,
|
||||
history,
|
||||
controlling: critical,
|
||||
initial_increment,
|
||||
increments,
|
||||
time: t,
|
||||
completed,
|
||||
total_mass,
|
||||
energy,
|
||||
initial_kinetic,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! ModelCraft's explicit dynamics solver: transient response of a model's explicit dynamic step
|
||||
//! by central-difference time integration.
|
||||
//!
|
||||
//! The method is the explicit central-difference scheme of T. Belytschko, W. K. Liu, B. Moran,
|
||||
//! K. Elkhodary, *Nonlinear Finite Elements for Continua and Structures*, 2nd ed., Wiley 2014
|
||||
//! (ch. 6, the flowchart of Box 6.1): with a diagonal (lumped) mass M,
|
||||
//! a = M⁻¹(f_ext − f_int), v^{n+½} = v^{n−½} + Δt^n a^n, u^{n+1} = u^n + Δt^{n+½} v^{n+½},
|
||||
//! so a time increment is one pass over the elements and a division per degree of freedom.
|
||||
//!
|
||||
//! - **System** ([`system`]): the model is resolved; one-point hexahedra and nodal-pressure
|
||||
//! tetrahedra with their materials, row-sum lumped masses, prescribed velocities, rigid walls,
|
||||
//! initial velocities and the constant load vector of the step.
|
||||
//! - **Elements** ([`elements`]): the uniform-strain hexahedron with Flanagan–Belytschko
|
||||
//! hourglass control (viscous or stiffness), the Bonet–Burton average nodal pressure
|
||||
//! tetrahedron, von Neumann–Richtmyer/Wilkins bulk viscosity, and the element critical time
|
||||
//! increments.
|
||||
//! - **Integration** ([`integrate`]): the time loop, kinematic constraints, energy accounting
|
||||
//! with a balance check, history samples and field frames ([`result`]).
|
||||
//!
|
||||
//! This first version is geometrically linear (small displacements and strains; the elements'
|
||||
//! gradients are those of the undeformed mesh) with linear elastic materials, which is what the
|
||||
//! analytic verification problems need. Large deformation kinematics with objective stress rates
|
||||
//! and inelastic materials come next (docs/solvers/literature.md, Part F, X2).
|
||||
//!
|
||||
//! Everything is sequential, with elements in label order and no parallel reductions, so the
|
||||
//! same model gives the same numbers bit for bit; sizes, increments and output are capped by
|
||||
//! [`Limits`].
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
// Small fixed-size vector kernels read as the textbook formulas with index loops.
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
pub mod elements;
|
||||
pub mod integrate;
|
||||
pub mod result;
|
||||
pub mod system;
|
||||
|
||||
pub use integrate::solve_explicit;
|
||||
pub use result::{ControllingElement, Energies, ExplicitResult, Frame, History};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Model(#[from] modelcraft_model::Error),
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
#[error("not supported yet: {0}")]
|
||||
NotSupported(String),
|
||||
/// An element kernel failed (inverted geometry, …).
|
||||
#[error("element {label}: {source}")]
|
||||
Element { label: u32, source: modelcraft_fem::Error },
|
||||
#[error("limit reached: {0}")]
|
||||
Limit(String),
|
||||
/// The solution stopped being finite.
|
||||
#[error("the solution became unstable: {0}")]
|
||||
Unstable(String),
|
||||
/// A broken internal invariant (a bug), reported instead of a panic.
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
impl Error {
|
||||
/// A kernel error, naming the element.
|
||||
pub fn element(label: u32, source: modelcraft_fem::Error) -> Error {
|
||||
Error::Element { label, source }
|
||||
}
|
||||
}
|
||||
|
||||
/// Caps on the size and length of a run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Degrees of freedom of the mesh.
|
||||
pub max_dofs: usize,
|
||||
/// Time increments; the step's own cap applies too, whichever is lower.
|
||||
pub max_increments: u64,
|
||||
/// Numbers kept in field frames and histories together (8 bytes each).
|
||||
pub max_output_values: usize,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub const MAX_DOFS: usize = 6_000_000;
|
||||
pub const MAX_INCREMENTS: u64 = modelcraft_model::steps::MAX_INCREMENTS;
|
||||
pub const MAX_OUTPUT_VALUES: usize = 50_000_000;
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Limits {
|
||||
Limits { max_dofs: Limits::MAX_DOFS, max_increments: Limits::MAX_INCREMENTS, max_output_values: Limits::MAX_OUTPUT_VALUES }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! What an explicit dynamic run keeps: histories sampled at the history interval and field
|
||||
//! frames at the output interval.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// The energy account at one time (Belytschko, Liu, Moran, Elkhodary 2014, §6.2.3). The works of
|
||||
/// the internal and external forces are integrated with the trapezoidal rule over each
|
||||
/// increment's displacement; the constraints' with their impulses (see [`crate::integrate`]).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
|
||||
pub struct Energies {
|
||||
/// ½ vᵀ M v.
|
||||
pub kinetic: f64,
|
||||
/// Work of the material stresses: the strain energy for an elastic material.
|
||||
pub internal: f64,
|
||||
/// Work of the hourglass forces (stored in the stiffness form, dissipated in the viscous one).
|
||||
pub hourglass: f64,
|
||||
/// Work of the bulk viscosity (dissipated).
|
||||
pub viscous: f64,
|
||||
/// Work of the loads.
|
||||
pub external: f64,
|
||||
/// Work of the boundary conditions and rigid walls on the model.
|
||||
pub constraint: f64,
|
||||
/// kinetic + internal + hourglass + viscous − external − constraint − the initial kinetic
|
||||
/// energy: zero when energy is conserved.
|
||||
pub balance: f64,
|
||||
}
|
||||
|
||||
impl Energies {
|
||||
/// The balance relative to the largest energy in the account.
|
||||
pub fn relative_error(&self, initial_kinetic: f64) -> f64 {
|
||||
let scale = [initial_kinetic, self.kinetic, self.internal + self.hourglass + self.viscous, self.external.abs(), self.constraint.abs()]
|
||||
.into_iter()
|
||||
.fold(0.0, f64::max);
|
||||
if scale > 0.0 { self.balance.abs() / scale } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields at one time. Vectors that the step's output does not request are empty.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Frame {
|
||||
pub time: f64,
|
||||
/// Per node in [`ExplicitResult::nodes`] order.
|
||||
pub displacement: Vec<[f64; 3]>,
|
||||
pub velocity: Vec<[f64; 3]>,
|
||||
/// Per element in [`ExplicitResult::elements`] order (one-point elements: one value each):
|
||||
/// xx, yy, zz, xy, xz, yz, engineering shear strains.
|
||||
pub stress: Vec<[f64; 6]>,
|
||||
pub strain: Vec<[f64; 6]>,
|
||||
}
|
||||
|
||||
/// Quantities sampled through the run.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct History {
|
||||
pub time: Vec<f64>,
|
||||
/// The time increment taken at each sample.
|
||||
pub increment: Vec<f64>,
|
||||
pub energies: Vec<Energies>,
|
||||
/// Total momentum M v.
|
||||
pub momentum: Vec<[f64; 3]>,
|
||||
/// Sum of the forces the boundary conditions and rigid walls exert on the model.
|
||||
pub constraint_force: Vec<[f64; 3]>,
|
||||
/// The nodes with histories, and per sample their displacement and velocity (in this order).
|
||||
pub nodes: Vec<u32>,
|
||||
pub displacement: Vec<Vec<[f64; 3]>>,
|
||||
pub velocity: Vec<Vec<[f64; 3]>>,
|
||||
}
|
||||
|
||||
/// An element that limits the stable time increment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||
pub struct ControllingElement {
|
||||
pub label: u32,
|
||||
/// Its critical increment at rest (no bulk viscosity), before the scale factor.
|
||||
pub increment: f64,
|
||||
}
|
||||
|
||||
/// The result of an explicit dynamic step.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ExplicitResult {
|
||||
pub step: String,
|
||||
/// Node labels with degrees of freedom, sorted.
|
||||
pub nodes: Vec<u32>,
|
||||
/// Element labels, sorted.
|
||||
pub elements: Vec<u32>,
|
||||
pub frames: Vec<Frame>,
|
||||
pub history: History,
|
||||
/// The elements with the smallest critical increments (at most ten), smallest first.
|
||||
pub controlling: Vec<ControllingElement>,
|
||||
/// The first time increment taken (the scale times the smallest critical increment).
|
||||
pub initial_increment: f64,
|
||||
pub increments: u64,
|
||||
/// The time reached: the step's end time unless the run stopped at the increment cap.
|
||||
pub time: f64,
|
||||
pub completed: bool,
|
||||
pub total_mass: f64,
|
||||
/// The energy account at the end.
|
||||
pub energy: Energies,
|
||||
pub initial_kinetic: f64,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! From the resolved model to the explicit system: elements with their materials and stable
|
||||
//! lengths, lumped masses, prescribed velocities, rigid walls, initial velocities and the load
|
||||
//! vector of an explicit dynamic step.
|
||||
|
||||
use modelcraft_fem::face;
|
||||
use modelcraft_fem::material::Elasticity;
|
||||
use modelcraft_fem::uniform::{Hex8, Tet4, modulus_bound};
|
||||
use modelcraft_model::{Elastic, ElementKind, ExplicitDynamic, HourglassKind, Integration, Load, Model, Resolved, SectionKind, Step, Target};
|
||||
|
||||
use crate::elements::add_at;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Most nodes whose histories a step records.
|
||||
pub const MAX_HISTORY_NODES: usize = 1000;
|
||||
|
||||
/// A material as the elements use it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Material {
|
||||
pub name: String,
|
||||
pub elasticity: Elasticity,
|
||||
pub density: f64,
|
||||
/// Dilatational wave speed √(M/ρ), with M the largest normal stiffness (λ + 2μ when
|
||||
/// isotropic).
|
||||
pub wave_speed: f64,
|
||||
/// The bound of [`modelcraft_fem::uniform::modulus_bound`].
|
||||
pub modulus: f64,
|
||||
/// Bulk modulus, for the nodal-pressure tetrahedron (isotropic materials).
|
||||
pub bulk: Option<f64>,
|
||||
}
|
||||
|
||||
/// A one-point hexahedron.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hex {
|
||||
pub label: u32,
|
||||
/// Position in [`System::elements`].
|
||||
pub index: usize,
|
||||
pub nodes: [usize; 8],
|
||||
pub material: usize,
|
||||
pub kinematics: Hex8,
|
||||
pub gamma: [[f64; 8]; 4],
|
||||
/// The hourglass coefficient: a damping (viscous) or a stiffness (stiffness form), see
|
||||
/// [`crate::elements`].
|
||||
pub hourglass: f64,
|
||||
/// The element's stable length: its critical increment without bulk viscosity is
|
||||
/// `length / wave_speed`.
|
||||
pub length: f64,
|
||||
}
|
||||
|
||||
/// A nodal-pressure tetrahedron.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tet {
|
||||
pub label: u32,
|
||||
pub index: usize,
|
||||
pub nodes: [usize; 4],
|
||||
pub material: usize,
|
||||
pub kinematics: Tet4,
|
||||
pub length: f64,
|
||||
}
|
||||
|
||||
/// A fixed, frictionless rigid plane.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Wall {
|
||||
pub point: [f64; 3],
|
||||
/// Unit normal, towards the side the nodes stay on.
|
||||
pub normal: [f64; 3],
|
||||
pub nodes: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The discrete system of an explicit dynamic step.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct System {
|
||||
/// Node labels with degrees of freedom (those of the elements), sorted.
|
||||
pub labels: Vec<u32>,
|
||||
pub xyz: Vec<[f64; 3]>,
|
||||
/// Element labels, sorted.
|
||||
pub elements: Vec<u32>,
|
||||
pub materials: Vec<Material>,
|
||||
pub hexes: Vec<Hex>,
|
||||
pub tets: Vec<Tet>,
|
||||
/// Row-sum lumped mass per node.
|
||||
pub mass: Vec<f64>,
|
||||
/// Prescribed velocity per degree of freedom (`None` = free).
|
||||
pub prescribed: Vec<Option<f64>>,
|
||||
pub walls: Vec<Wall>,
|
||||
/// Velocity per degree of freedom at the start (prescribed velocities included).
|
||||
pub initial_velocity: Vec<f64>,
|
||||
/// Constant external force per degree of freedom.
|
||||
pub external: Vec<f64>,
|
||||
/// Nodes whose histories are recorded.
|
||||
pub history_nodes: Vec<usize>,
|
||||
pub hourglass: HourglassKind,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn invalid(m: impl Into<String>) -> Error {
|
||||
Error::Invalid(m.into())
|
||||
}
|
||||
|
||||
/// A material's constants for the explicit elements.
|
||||
fn material(model: &Model, name: &str) -> Result<Material> {
|
||||
let m = model.material(name).ok_or_else(|| invalid(format!("material `{name}` is missing")))?;
|
||||
let elastic = m.elastic.as_ref().ok_or_else(|| invalid(format!("material `{name}` has no elastic constants")))?;
|
||||
let density = m
|
||||
.density
|
||||
.filter(|d| *d > 0.0 && d.is_finite())
|
||||
.ok_or_else(|| invalid(format!("material `{name}` has no density, and explicit dynamics needs one")))?;
|
||||
let d = modelcraft_fem::elasticity(elastic).map_err(|e| invalid(format!("material `{name}`: {e}")))?;
|
||||
let stiffest = d[0][0].max(d[1][1]).max(d[2][2]);
|
||||
let bulk = match *elastic {
|
||||
Elastic::Isotropic { young, poisson } => Some(young / (3.0 * (1.0 - 2.0 * poisson))),
|
||||
Elastic::EngineeringConstants { .. } => None,
|
||||
};
|
||||
Ok(Material {
|
||||
name: name.to_string(),
|
||||
elasticity: d,
|
||||
density,
|
||||
wave_speed: (stiffest / density).sqrt(),
|
||||
modulus: modulus_bound(elastic, &d),
|
||||
bulk,
|
||||
})
|
||||
}
|
||||
|
||||
/// The largest absolute row sum of the hourglass vectors' Gram matrix Σ_I γ_αI γ_βI / v_I (an
|
||||
/// upper bound of its largest eigenvalue, exact for parallelepipeds where the γ_α are
|
||||
/// orthogonal).
|
||||
fn hourglass_gram_bound(gamma: &[[f64; 8]; 4], nodal_volume: &[f64; 8]) -> f64 {
|
||||
let mut worst: f64 = 0.0;
|
||||
for a in gamma {
|
||||
let row: f64 = gamma.iter().map(|b| a.iter().zip(b).zip(nodal_volume).map(|((x, y), v)| x * y / v).sum::<f64>().abs()).sum();
|
||||
worst = worst.max(row);
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// The section's elements of a kind the explicit solver has, or why not.
|
||||
fn check_formulation(kind: ElementKind, section_name: &str, f: &modelcraft_model::Formulation) -> Result<()> {
|
||||
let refuse = |why: &str| Err(Error::NotSupported(format!("section `{section_name}`: {why}")));
|
||||
if f.hybrid {
|
||||
return refuse("hybrid elements in explicit dynamics");
|
||||
}
|
||||
match kind {
|
||||
ElementKind::Hex8 if f.incompatible_modes || f.integration == Integration::Full => refuse(
|
||||
"fully integrated or incompatible-mode hex8 in explicit dynamics (explicit hex8 elements are one-point with hourglass control: set the section's integration to reduced)",
|
||||
),
|
||||
ElementKind::Hex8 => Ok(()),
|
||||
ElementKind::Tet4 if f.integration == Integration::Reduced => refuse("reduced integration of tet4 elements"),
|
||||
ElementKind::Tet4 => Ok(()),
|
||||
other => refuse(&format!("{} elements in explicit dynamics (it has one-point hex8 and nodal-pressure tet4 so far)", other.name())),
|
||||
}
|
||||
}
|
||||
|
||||
impl System {
|
||||
pub fn new(model: &Model, resolved: &Resolved, step: &Step, settings: &ExplicitDynamic, max_dofs: usize) -> Result<System> {
|
||||
if let Some(name) = model.couplings().keys().next() {
|
||||
return Err(Error::NotSupported(format!("couplings (coupling `{name}`) in explicit dynamics")));
|
||||
}
|
||||
// Elements by label, with their section's material.
|
||||
let mut materials: Vec<Material> = Vec::new();
|
||||
let mut chosen: Vec<(u32, ElementKind, Vec<u32>, usize)> = Vec::new();
|
||||
for (name, section) in model.sections() {
|
||||
if !matches!(section.kind, SectionKind::Solid {}) {
|
||||
return Err(Error::NotSupported(format!(
|
||||
"{} sections (section `{name}`); explicit dynamics has solid elements so far",
|
||||
section.kind.name()
|
||||
)));
|
||||
}
|
||||
let mi = match materials.iter().position(|m| m.name == section.material) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
materials.push(material(model, §ion.material)?);
|
||||
materials.len() - 1
|
||||
}
|
||||
};
|
||||
let set = resolved.sets.get(§ion.set).ok_or_else(|| invalid(format!("set `{}` is not resolved", section.set)))?;
|
||||
for &label in &set.elements {
|
||||
let el = model.element(label).ok_or_else(|| invalid(format!("element {label} is missing")))?;
|
||||
check_formulation(el.kind, name, §ion.formulation)?;
|
||||
if el.kind == ElementKind::Tet4 && materials.get(mi).is_some_and(|m| m.bulk.is_none()) {
|
||||
return Err(Error::NotSupported(format!(
|
||||
"nodal-pressure tet4 elements of the orthotropic material `{}` (section `{name}`)",
|
||||
section.material
|
||||
)));
|
||||
}
|
||||
chosen.push((label, el.kind, el.nodes.clone(), mi));
|
||||
}
|
||||
}
|
||||
chosen.sort_by_key(|e| e.0);
|
||||
let mut labels: Vec<u32> = chosen.iter().flat_map(|e| e.2.iter().copied()).collect();
|
||||
labels.sort_unstable();
|
||||
labels.dedup();
|
||||
if 3 * labels.len() > max_dofs {
|
||||
return Err(Error::Limit(format!("the model has {} degrees of freedom; the limit is {max_dofs}", 3 * labels.len())));
|
||||
}
|
||||
if labels.is_empty() {
|
||||
return Err(invalid("the model has no elements to analyse"));
|
||||
}
|
||||
let xyz: Vec<[f64; 3]> =
|
||||
labels.iter().map(|l| model.node(*l).map(|n| n.xyz).ok_or_else(|| invalid(format!("node {l} is missing")))).collect::<Result<_>>()?;
|
||||
let n = labels.len();
|
||||
let mut sys = System {
|
||||
elements: chosen.iter().map(|e| e.0).collect(),
|
||||
materials,
|
||||
hexes: Vec::new(),
|
||||
tets: Vec::new(),
|
||||
mass: vec![0.0; n],
|
||||
prescribed: vec![None; 3 * n],
|
||||
walls: Vec::new(),
|
||||
initial_velocity: vec![0.0; 3 * n],
|
||||
external: vec![0.0; 3 * n],
|
||||
history_nodes: Vec::new(),
|
||||
hourglass: settings.hourglass.kind,
|
||||
warnings: resolved.warnings.iter().map(ToString::to_string).collect(),
|
||||
labels,
|
||||
xyz,
|
||||
};
|
||||
for (index, (label, kind, nodes, mi)) in chosen.into_iter().enumerate() {
|
||||
let idx: Vec<usize> = nodes.iter().map(|l| sys.node(*l).ok_or_else(|| Error::Internal(format!("node {l}")))).collect::<Result<_>>()?;
|
||||
let mat = sys.materials.get(mi).ok_or_else(|| Error::Internal("material index".into()))?;
|
||||
let (rho, c) = (mat.density, mat.wave_speed);
|
||||
let at = |e| Error::element(label, e);
|
||||
match kind {
|
||||
ElementKind::Hex8 => {
|
||||
let nodes: [usize; 8] = idx.try_into().map_err(|_| invalid(format!("element {label} does not have 8 nodes")))?;
|
||||
let x = nodes.map(|i| sys.xyz.get(i).copied().unwrap_or_default());
|
||||
let k = Hex8::hex8(&x).map_err(at)?;
|
||||
let gamma = k.hourglass_vectors(&x);
|
||||
// h: the edge of the cube with the element's volume and gradient norm.
|
||||
let h = k.volume / (2.0 * k.gradient_norm()).sqrt();
|
||||
let kappa = settings.hourglass.coefficient;
|
||||
let mut omega2 = k.frequency_bound(mat.modulus, rho);
|
||||
let hourglass = match settings.hourglass.kind {
|
||||
HourglassKind::Viscous => kappa * rho * c * h * h / 16.0,
|
||||
HourglassKind::Stiffness => {
|
||||
let stiffness = kappa * rho * c * c * h / 16.0;
|
||||
omega2 += stiffness * hourglass_gram_bound(&gamma, &k.nodal_volume) / rho;
|
||||
stiffness
|
||||
}
|
||||
};
|
||||
sys.lump(&nodes, &k.nodal_volume, rho);
|
||||
sys.hexes.push(Hex { label, index, nodes, material: mi, kinematics: k, gamma, hourglass, length: 2.0 * c / omega2.sqrt() });
|
||||
}
|
||||
ElementKind::Tet4 => {
|
||||
let nodes: [usize; 4] = idx.try_into().map_err(|_| invalid(format!("element {label} does not have 4 nodes")))?;
|
||||
let x = nodes.map(|i| sys.xyz.get(i).copied().unwrap_or_default());
|
||||
let k = Tet4::tet4(&x).map_err(at)?;
|
||||
let omega2 = k.frequency_bound(mat.modulus, rho);
|
||||
sys.lump(&nodes, &k.nodal_volume, rho);
|
||||
sys.tets.push(Tet { label, index, nodes, material: mi, kinematics: k, length: 2.0 * c / omega2.sqrt() });
|
||||
}
|
||||
other => return Err(Error::Internal(format!("{} element kind", other.name()))),
|
||||
}
|
||||
}
|
||||
sys.prescribe(model, resolved, step, settings.end_time)?;
|
||||
sys.load(model, resolved, step)?;
|
||||
sys.conditions(resolved, settings)?;
|
||||
Ok(sys)
|
||||
}
|
||||
|
||||
/// Add an element's row-sum lumped masses.
|
||||
fn lump(&mut self, nodes: &[usize], nodal_volume: &[f64], density: f64) {
|
||||
for (node, v) in nodes.iter().zip(nodal_volume) {
|
||||
if let Some(m) = self.mass.get_mut(*node) {
|
||||
*m += density * v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn node(&self, label: u32) -> Option<usize> {
|
||||
self.labels.binary_search(&label).ok()
|
||||
}
|
||||
|
||||
pub fn dofs(&self) -> usize {
|
||||
3 * self.labels.len()
|
||||
}
|
||||
|
||||
/// Prescribed velocities from the step's boundary conditions: zero for held degrees of
|
||||
/// freedom, value / end time for a displacement reached over the step.
|
||||
fn prescribe(&mut self, model: &Model, resolved: &Resolved, step: &Step, end_time: f64) -> Result<()> {
|
||||
for name in &step.boundary_conditions {
|
||||
let bc = model.boundary_condition(name).ok_or_else(|| invalid(format!("boundary condition `{name}` is missing")))?;
|
||||
if let Target::ReferencePoint(rp) = bc.target() {
|
||||
return Err(Error::NotSupported(format!("boundary conditions on reference points (`{name}` on `{rp}`)")));
|
||||
}
|
||||
for &label in resolved.target_nodes(bc.target()) {
|
||||
let Some(node) = self.node(label) else { continue };
|
||||
for dof in 1..=3u8 {
|
||||
let Some(value) = bc.value(dof) else { continue };
|
||||
let velocity = value / end_time;
|
||||
if !velocity.is_finite() {
|
||||
return Err(invalid(format!("boundary condition `{name}`: the velocity {value} / {end_time} is not finite")));
|
||||
}
|
||||
let slot = self.prescribed.get_mut(3 * node + usize::from(dof - 1)).ok_or_else(|| Error::Internal("dof index".into()))?;
|
||||
match *slot {
|
||||
Some(old) if old != velocity => {
|
||||
return Err(invalid(format!(
|
||||
"node {label} direction {dof} moves at both {old} and {velocity} (boundary condition `{name}` and another)"
|
||||
)));
|
||||
}
|
||||
_ => *slot = Some(velocity),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The constant load vector: forces and tractions as the model distributes them, consistent
|
||||
/// pressure loads, gravity and body forces with the lumped (row-sum) weights.
|
||||
fn load(&mut self, model: &Model, resolved: &Resolved, step: &Step) -> Result<()> {
|
||||
let mut f = std::mem::take(&mut self.external);
|
||||
for name in &step.loads {
|
||||
let load = model.load(name).ok_or_else(|| invalid(format!("load `{name}` is missing")))?;
|
||||
let off_mesh = |l: u32| invalid(format!("load `{name}`: node {l} belongs to no element"));
|
||||
match load {
|
||||
Load::Force { target: Target::ReferencePoint(rp), .. } => {
|
||||
return Err(Error::NotSupported(format!("loads on reference points (`{name}` on `{rp}`)")));
|
||||
}
|
||||
Load::Moment { .. } => {
|
||||
return Err(Error::NotSupported(format!("moments (`{name}`): solid nodes have no rotations")));
|
||||
}
|
||||
Load::Force { .. } | Load::Traction { .. } => {
|
||||
for (label, v) in resolved.nodal_loads.get(name).map(Vec::as_slice).unwrap_or_default() {
|
||||
let node = self.node(*label).ok_or_else(|| off_mesh(*label))?;
|
||||
add_at(&mut f, node, v);
|
||||
}
|
||||
}
|
||||
Load::Pressure { set, magnitude } => {
|
||||
for fc in resolved.sets.get(set).map(|s| s.faces.as_slice()).unwrap_or_default() {
|
||||
let labels = model
|
||||
.face_nodes(*fc)
|
||||
.ok_or_else(|| invalid(format!("load `{name}`: element {} face {} is missing", fc.element, fc.face)))?;
|
||||
let idx: Vec<usize> = labels.iter().map(|l| self.node(*l).ok_or_else(|| off_mesh(*l))).collect::<Result<_>>()?;
|
||||
let xyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| self.xyz.get(*i).copied()).collect();
|
||||
let forces = face::pressure(&xyz, *magnitude).map_err(|e| Error::element(fc.element, e))?;
|
||||
for (i, v) in idx.iter().zip(forces) {
|
||||
add_at(&mut f, *i, &v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Load::Gravity { acceleration, set } => {
|
||||
let chosen: Option<&[u32]> = set.as_ref().map(|s| resolved.sets.get(s).map(|r| r.elements.as_slice()).unwrap_or_default());
|
||||
self.each_element(chosen, |nodes, weights, rho| {
|
||||
for (node, w) in nodes.iter().zip(weights) {
|
||||
add_at(&mut f, *node, &acceleration.map(|a| rho * w * a));
|
||||
}
|
||||
});
|
||||
}
|
||||
Load::BodyForce { set, vector } => {
|
||||
let chosen = resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default();
|
||||
self.each_element(Some(chosen), |nodes, weights, _| {
|
||||
for (node, w) in nodes.iter().zip(weights) {
|
||||
add_at(&mut f, *node, &vector.map(|b| w * b));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.external = f;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Visit the elements (all, or those listed): their nodes, nodal volumes and density.
|
||||
fn each_element(&self, only: Option<&[u32]>, mut visit: impl FnMut(&[usize], &[f64], f64)) {
|
||||
let wanted = |label: u32| only.is_none_or(|l| l.binary_search(&label).is_ok());
|
||||
let density = |m: usize| self.materials.get(m).map_or(0.0, |m| m.density);
|
||||
for h in self.hexes.iter().filter(|h| wanted(h.label)) {
|
||||
visit(&h.nodes, &h.kinematics.nodal_volume, density(h.material));
|
||||
}
|
||||
for t in self.tets.iter().filter(|t| wanted(t.label)) {
|
||||
visit(&t.nodes, &t.kinematics.nodal_volume, density(t.material));
|
||||
}
|
||||
}
|
||||
|
||||
/// The analysed nodes of a resolved set.
|
||||
fn nodes_of(&self, resolved: &Resolved, set: &str) -> Vec<usize> {
|
||||
resolved.sets.get(set).map(|s| s.nodes.as_slice()).unwrap_or_default().iter().filter_map(|l| self.node(*l)).collect()
|
||||
}
|
||||
|
||||
/// Initial velocities, rigid walls and history nodes of the step.
|
||||
fn conditions(&mut self, resolved: &Resolved, settings: &ExplicitDynamic) -> Result<()> {
|
||||
for iv in &settings.initial_velocities {
|
||||
for node in self.nodes_of(resolved, &iv.set) {
|
||||
for (k, c) in iv.velocity.iter().enumerate() {
|
||||
if let Some(v) = self.initial_velocity.get_mut(3 * node + k) {
|
||||
*v = *c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A prescribed velocity holds from the start.
|
||||
for (v, p) in self.initial_velocity.iter_mut().zip(&self.prescribed) {
|
||||
if let Some(p) = p {
|
||||
*v = *p;
|
||||
}
|
||||
}
|
||||
let size = self.xyz.iter().flatten().fold(0.0f64, |m, c| m.max(c.abs())).max(1.0);
|
||||
for w in &settings.rigid_walls {
|
||||
let len = w.normal.iter().map(|c| c * c).sum::<f64>().sqrt();
|
||||
let normal = w.normal.map(|c| c / len);
|
||||
let nodes = self.nodes_of(resolved, &w.set);
|
||||
for &i in &nodes {
|
||||
let x = self.xyz.get(i).copied().unwrap_or_default();
|
||||
let gap: f64 = (0..3).map(|k| (x[k] - w.point[k]) * normal[k]).sum();
|
||||
if gap < -1e-9 * size {
|
||||
let label = self.labels.get(i).copied().unwrap_or(0);
|
||||
return Err(invalid(format!("node {label} of set `{}` starts behind its rigid wall (gap {gap:e})", w.set)));
|
||||
}
|
||||
}
|
||||
self.walls.push(Wall { point: w.point, normal, nodes });
|
||||
}
|
||||
if let Some(set) = &settings.history_nodes {
|
||||
let nodes = self.nodes_of(resolved, set);
|
||||
if nodes.len() > MAX_HISTORY_NODES {
|
||||
return Err(Error::Limit(format!("set `{set}` has {} nodes; histories are recorded for at most {MAX_HISTORY_NODES}", nodes.len())));
|
||||
}
|
||||
self.history_nodes = nodes;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Analytic verification of the explicit solver (docs/solvers/literature.md, A10): elastic waves
|
||||
//! in a bar (front at c·t, stress σ = ρ c v), a bar striking a rigid wall (contact for 2L/c,
|
||||
//! rebound at the impact speed), the energy balance and bit-for-bit repeatability.
|
||||
//!
|
||||
//! The bar has Poisson's ratio 0, so a uniaxial stress wave travels at the rod speed c = √(E/ρ)
|
||||
//! without lateral inertia effects (d'Alembert's solution of the one-dimensional wave equation).
|
||||
|
||||
use modelcraft_explicit::{ExplicitResult, Limits, solve_explicit};
|
||||
use modelcraft_model::*;
|
||||
|
||||
const YOUNG: f64 = 1.0e4;
|
||||
const DENSITY: f64 = 1.0;
|
||||
const LENGTH: f64 = 10.0;
|
||||
const WIDTH: f64 = 0.1;
|
||||
|
||||
fn wave_speed() -> f64 {
|
||||
(YOUNG / DENSITY).sqrt()
|
||||
}
|
||||
|
||||
/// A bar along x from 0 to LENGTH, WIDTH × WIDTH, with `cells` cells along it: one hex8 per
|
||||
/// cell, or six tet4 (Kuhn's split of the cube), with ν = 0; its sets, and an explicit step.
|
||||
struct Bar {
|
||||
model: Model,
|
||||
kind: ElementKind,
|
||||
cells: usize,
|
||||
}
|
||||
|
||||
impl Bar {
|
||||
fn new(kind: ElementKind, cells: usize, step: ExplicitDynamic) -> Bar {
|
||||
let mut m = Model::default();
|
||||
let h = LENGTH / cells as f64;
|
||||
let label = |i: usize, j: usize, k: usize| (1 + i + (cells + 1) * (j + 2 * k)) as u32;
|
||||
for k in 0..2 {
|
||||
for j in 0..2 {
|
||||
for i in 0..=cells {
|
||||
m.set_node(label(i, j, k), [i as f64 * h, j as f64 * WIDTH, k as f64 * WIDTH]).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
|
||||
let mut next = 1;
|
||||
for i in 0..cells {
|
||||
let at = |c: [usize; 3]| label(i + c[0], c[1], c[2]);
|
||||
if kind == ElementKind::Hex8 {
|
||||
m.set_element(next, kind, corners.iter().map(|c| at(*c)).collect()).unwrap();
|
||||
next += 1;
|
||||
continue;
|
||||
}
|
||||
for perm in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
|
||||
let mut v = [[0usize; 3]; 4];
|
||||
for s in 0..3 {
|
||||
v[s + 1] = v[s];
|
||||
v[s + 1][perm[s]] = 1;
|
||||
}
|
||||
let x = v.map(|c| [c[0] as f64 * h, c[1] as f64 * WIDTH, c[2] as f64 * WIDTH]);
|
||||
let e = |a: usize| [0, 1, 2].map(|k| x[a][k] - x[0][k]);
|
||||
let (a, b, c) = (e(1), e(2), e(3));
|
||||
let vol = a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]);
|
||||
if vol < 0.0 {
|
||||
v.swap(1, 2);
|
||||
}
|
||||
m.set_element(next, kind, v.iter().map(|c| at(*c)).collect()).unwrap();
|
||||
next += 1;
|
||||
}
|
||||
}
|
||||
let all: Vec<u32> = m.elements().keys().copied().collect();
|
||||
m.add_set("all", Set { elements: all, ..Set::default() }).unwrap();
|
||||
let end_nodes = |i: usize| (0..4).map(|n| label(i, n % 2, n / 2)).collect::<Vec<_>>();
|
||||
m.add_set("start", Set { nodes: end_nodes(0), ..Set::default() }).unwrap();
|
||||
m.add_set("end", Set { nodes: end_nodes(cells), ..Set::default() }).unwrap();
|
||||
let mut faces = Vec::new();
|
||||
for (l, e) in m.elements() {
|
||||
for (f, local) in e.kind.faces().iter().enumerate() {
|
||||
if local.iter().all(|n| m.node(e.nodes[*n]).unwrap().xyz[0] == LENGTH) {
|
||||
faces.push(ElementFace { element: *l, face: f as u8 + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
m.add_set("end_face", Set { faces, ..Set::default() }).unwrap();
|
||||
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }), density: Some(DENSITY) }).unwrap();
|
||||
let integration = if kind == ElementKind::Hex8 { Integration::Reduced } else { Integration::Full };
|
||||
let formulation = Formulation { integration, ..Formulation::default() };
|
||||
m.add_section("bar", Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
|
||||
m.add_step(Step::new("Dynamic", Procedure::ExplicitDynamic(step)), None).unwrap();
|
||||
Bar { model: m, kind, cells }
|
||||
}
|
||||
|
||||
fn per_cell(&self) -> usize {
|
||||
if self.kind == ElementKind::Hex8 { 1 } else { 6 }
|
||||
}
|
||||
|
||||
/// σ_xx per cell (the mean over its elements) and the cell's centre.
|
||||
fn axial_stress(&self, r: &ExplicitResult, frame: usize) -> Vec<(f64, f64)> {
|
||||
let f = &r.frames[frame];
|
||||
let h = LENGTH / self.cells as f64;
|
||||
(0..self.cells)
|
||||
.map(|i| {
|
||||
let s: f64 = f.stress[i * self.per_cell()..(i + 1) * self.per_cell()].iter().map(|s| s[0]).sum();
|
||||
((i as f64 + 0.5) * h, s / self.per_cell() as f64)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The axial velocity of the nodes on the bar's axis line y = z = 0, by x.
|
||||
fn axial_velocity(&self, r: &ExplicitResult, frame: usize) -> Vec<(f64, f64)> {
|
||||
let f = &r.frames[frame];
|
||||
let h = LENGTH / self.cells as f64;
|
||||
(0..=self.cells).map(|i| (i as f64 * h, f.velocity[r.nodes.binary_search(&(1 + i as u32)).unwrap()][0])).collect()
|
||||
}
|
||||
|
||||
fn activate(&mut self, bc: Option<(&str, BoundaryCondition)>, load: Option<(&str, Load)>) {
|
||||
if let Some((name, bc)) = bc {
|
||||
self.model.add_boundary_condition(name, bc).unwrap();
|
||||
self.model.set_boundary_condition_active("Dynamic", name, true).unwrap();
|
||||
}
|
||||
if let Some((name, load)) = load {
|
||||
self.model.add_load(name, load).unwrap();
|
||||
self.model.set_load_active("Dynamic", name, true).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a profile first falls below half of `level` going along x (linear interpolation).
|
||||
fn front(profile: &[(f64, f64)], level: f64) -> f64 {
|
||||
for w in profile.windows(2) {
|
||||
let (a, b) = (w[0].1 / level, w[1].1 / level);
|
||||
if a >= 0.5 && b < 0.5 {
|
||||
return w[0].0 + (a - 0.5) / (a - b) * (w[1].0 - w[0].0);
|
||||
}
|
||||
}
|
||||
f64::NAN
|
||||
}
|
||||
|
||||
fn mean_between(profile: &[(f64, f64)], from: f64, to: f64) -> f64 {
|
||||
let inside: Vec<f64> = profile.iter().filter(|(x, _)| *x >= from && *x <= to).map(|(_, v)| *v).collect();
|
||||
inside.iter().sum::<f64>() / inside.len() as f64
|
||||
}
|
||||
|
||||
/// A pressure step on the free end of a bar sends a compressive wave in −x: front at L − c t,
|
||||
/// stress −p, particle velocity −p/(ρc), so σ = ρ c v.
|
||||
#[test]
|
||||
fn pressure_wave_in_a_hex_bar() {
|
||||
let c = wave_speed();
|
||||
let t = 0.6 * LENGTH / c;
|
||||
let mut step = ExplicitDynamic::new(t);
|
||||
step.output_interval = Some(t / 4.0);
|
||||
let mut bar = Bar::new(ElementKind::Hex8, 200, step);
|
||||
let p = 1.0;
|
||||
bar.activate(None, Some(("push", Load::Pressure { set: "end_face".into(), magnitude: p })));
|
||||
let r = solve_explicit(&bar.model, None, &Limits::default()).unwrap();
|
||||
assert!(r.completed, "{:?}", r.warnings);
|
||||
let last = r.frames.len() - 1;
|
||||
assert!((r.frames[last].time - t).abs() < 1e-12 * t);
|
||||
// Measured from the loaded end, the profiles are those of a wave travelling in +x.
|
||||
let flip = |v: Vec<(f64, f64)>| -> Vec<(f64, f64)> { v.into_iter().rev().map(|(x, s)| (LENGTH - x, s)).collect() };
|
||||
let stress = flip(bar.axial_stress(&r, last));
|
||||
let velocity = flip(bar.axial_velocity(&r, last));
|
||||
let h = LENGTH / 200.0;
|
||||
let x_front = front(&stress, -p);
|
||||
let x_vfront = front(&velocity, -p / (DENSITY * c));
|
||||
let sigma = mean_between(&stress, 0.1 * c * t, 0.8 * c * t);
|
||||
let v = mean_between(&velocity, 0.1 * c * t, 0.8 * c * t);
|
||||
eprintln!(
|
||||
"hex8 pressure wave: front {x_front:.4} and {x_vfront:.4} vs c t = {:.4} (h = {h}); σ {sigma:.5} vs −p; v {v:.6} vs {:.6}; ρ c v = {:.5}; energy error {:.2e}; Δt {:.3e}, {} increments",
|
||||
c * t,
|
||||
-p / (DENSITY * c),
|
||||
DENSITY * c * v,
|
||||
r.energy.relative_error(r.initial_kinetic),
|
||||
r.initial_increment,
|
||||
r.increments
|
||||
);
|
||||
assert!((x_front - c * t).abs() < 2.0 * h, "stress front {x_front} vs {}", c * t);
|
||||
assert!((x_vfront - c * t).abs() < 2.0 * h, "velocity front {x_vfront} vs {}", c * t);
|
||||
assert!((sigma + p).abs() < 0.01 * p, "σ = {sigma}");
|
||||
assert!((DENSITY * c * v - sigma).abs() < 0.01 * p, "ρ c v = {} vs σ = {sigma}", DENSITY * c * v);
|
||||
// Work of the pressure = kinetic + strain energy (+ the viscous dissipation at the front).
|
||||
assert!(r.energy.external > 0.0 && r.energy.relative_error(r.initial_kinetic) < 0.01, "{:?}", r.energy);
|
||||
assert!(r.warnings.iter().all(|w| w.contains("unit system")), "{:?}", r.warnings);
|
||||
// The exact energy input is p A (c t)(p / E) = p² A c t / E; half is kinetic, half strain.
|
||||
let input = p * p * WIDTH * WIDTH * c * t / YOUNG;
|
||||
eprintln!("hex8 pressure wave: work {:.6e} vs p² A c t / E = {input:.6e}", r.energy.external);
|
||||
assert!((r.energy.external - input).abs() < 0.005 * input, "{} vs {input}", r.energy.external);
|
||||
}
|
||||
|
||||
/// A velocity v prescribed at one end of a tetrahedral bar (nodal-pressure tet4): front at c t,
|
||||
/// stress −ρ c v behind it.
|
||||
#[test]
|
||||
fn velocity_wave_in_a_tet_bar() {
|
||||
let c = wave_speed();
|
||||
let t = 0.6 * LENGTH / c;
|
||||
let v = 0.01;
|
||||
let mut step = ExplicitDynamic::new(t);
|
||||
step.output_interval = Some(t / 2.0);
|
||||
let mut bar = Bar::new(ElementKind::Tet4, 200, step);
|
||||
let push = BoundaryCondition::Displacement { target: Target::Set("start".into()), values: [Some(v * t), None, None, None, None, None] };
|
||||
bar.activate(Some(("push", push)), None);
|
||||
let r = solve_explicit(&bar.model, None, &Limits::default()).unwrap();
|
||||
let last = r.frames.len() - 1;
|
||||
let stress = bar.axial_stress(&r, last);
|
||||
let h = LENGTH / 200.0;
|
||||
let sigma_exact = -DENSITY * c * v;
|
||||
let x_front = front(&stress, sigma_exact);
|
||||
let sigma = mean_between(&stress, 0.1 * c * t, 0.8 * c * t);
|
||||
eprintln!(
|
||||
"tet4 velocity wave: front {x_front:.4} vs c t = {:.4} (h = {h}); σ {sigma:.6} vs −ρcv = {sigma_exact:.6}; energy error {:.2e}; Δt {:.3e}",
|
||||
c * t,
|
||||
r.energy.relative_error(r.initial_kinetic),
|
||||
r.initial_increment
|
||||
);
|
||||
assert!((x_front - c * t).abs() < 3.0 * h, "front {x_front} vs {}", c * t);
|
||||
assert!((sigma - sigma_exact).abs() < 0.02 * sigma_exact.abs(), "σ = {sigma}");
|
||||
assert!(r.energy.relative_error(r.initial_kinetic) < 0.01, "{:?}", r.energy);
|
||||
// The boundary condition does the work: ρ c v² A t (kinetic and strain energy).
|
||||
let input = DENSITY * c * v * v * WIDTH * WIDTH * t;
|
||||
eprintln!("tet4 velocity wave: work {:.6e} vs ρ c v² A t = {input:.6e}", r.energy.constraint);
|
||||
assert!((r.energy.constraint - input).abs() < 0.01 * input, "{} vs {input}", r.energy.constraint);
|
||||
}
|
||||
|
||||
/// A bar moving at v₀ strikes a rigid wall: it stays in contact for 2L/c, then leaves at v₀ in
|
||||
/// the opposite direction with its kinetic energy (elastic impact). The comparison is with the
|
||||
/// elastic solution, so the linear bulk viscosity (which dissipates a few percent of the energy
|
||||
/// at the wave fronts) is off; the kinematic wall stops the first layer of nodes dead, an O(h)
|
||||
/// loss that the constraint work accounts for.
|
||||
#[test]
|
||||
fn bar_rebounds_from_a_rigid_wall() {
|
||||
let c = wave_speed();
|
||||
let v0 = 0.01;
|
||||
let end = 3.0 * LENGTH / c;
|
||||
for (kind, linear) in [(ElementKind::Hex8, 0.0), (ElementKind::Tet4, 0.0), (ElementKind::Hex8, 0.06)] {
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.history_interval = Some(end / 3000.0);
|
||||
step.output_interval = Some(end);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "all_nodes".into(), velocity: [-v0, 0.0, 0.0] }];
|
||||
step.rigid_walls = vec![RigidWall { set: "start".into(), point: [0.0; 3], normal: [1.0, 0.0, 0.0] }];
|
||||
step.bulk_viscosity.linear = linear;
|
||||
let mut bar = Bar::new(kind, 200, ExplicitDynamic::new(end));
|
||||
let nodes: Vec<u32> = bar.model.nodes().keys().copied().collect();
|
||||
bar.model.add_set("all_nodes", Set { nodes, ..Set::default() }).unwrap();
|
||||
bar.model.replace_step(Step::new("Dynamic", Procedure::ExplicitDynamic(step))).unwrap();
|
||||
let r = solve_explicit(&bar.model, Some("Dynamic"), &Limits::default()).unwrap();
|
||||
let hist = &r.history;
|
||||
let peak = DENSITY * c * v0 * WIDTH * WIDTH;
|
||||
let force: Vec<f64> = hist.constraint_force.iter().map(|f| f[0]).collect();
|
||||
// Contact lasts until the wall force drops for good.
|
||||
let release = hist.time.iter().zip(&force).filter(|(_, f)| **f > 0.01 * peak).map(|(t, _)| *t).fold(0.0, f64::max);
|
||||
let rebound = hist.momentum.last().unwrap()[0] / r.total_mass;
|
||||
let error = r.energy.relative_error(r.initial_kinetic);
|
||||
let kinetic = r.energy.kinetic / r.initial_kinetic;
|
||||
let mid = hist.time.iter().position(|t| *t >= LENGTH / c).unwrap();
|
||||
eprintln!(
|
||||
"{} impact (linear q {linear}): contact {release:.5} vs 2L/c = {:.5}; wall force at L/c {:.4e} vs ρcv₀A = {peak:.4e}; rebound {rebound:.6} vs {v0}; final KE/KE₀ {kinetic:.4}; energy balance {error:.2e}; {:?}",
|
||||
kind.name(),
|
||||
2.0 * LENGTH / c,
|
||||
force[mid],
|
||||
r.energy
|
||||
);
|
||||
assert!(r.warnings.iter().all(|w| w.contains("unit system")), "{:?}", r.warnings);
|
||||
assert!(error < 0.01, "{} {:?}", kind.name(), r.energy);
|
||||
// The first layer of nodes (half an element's mass) stops dead on the wall.
|
||||
let stop = 0.5 * (0.5 * DENSITY * WIDTH * WIDTH * LENGTH / 200.0) * v0 * v0;
|
||||
assert!((r.energy.constraint + stop).abs() < 1e-6 * stop, "{} {:?}", kind.name(), r.energy);
|
||||
if linear > 0.0 {
|
||||
assert!(r.energy.viscous > 0.01 * r.initial_kinetic, "{:?}", r.energy);
|
||||
continue;
|
||||
}
|
||||
assert!((release - 2.0 * LENGTH / c).abs() < 0.01 * 2.0 * LENGTH / c, "{} contact {release}", kind.name());
|
||||
assert!((rebound - v0).abs() < 0.01 * v0, "{} rebound {rebound}", kind.name());
|
||||
assert!(kinetic > 0.99, "{} kinetic {kinetic}", kind.name());
|
||||
// While in contact the plateau force is ρ c v₀ A.
|
||||
assert!((force[mid] - peak).abs() < 0.03 * peak, "{} force {} vs {peak}", kind.name(), force[mid]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same model gives the same numbers, bit for bit.
|
||||
#[test]
|
||||
fn runs_are_bit_identical() {
|
||||
let end = 0.3 * LENGTH / wave_speed();
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.hourglass = Hourglass { kind: HourglassKind::Stiffness, coefficient: 0.1 };
|
||||
let mut bar = Bar::new(ElementKind::Hex8, 40, step);
|
||||
bar.activate(
|
||||
None,
|
||||
Some(("push", Load::Force { target: Target::Set("end".into()), vector: [0.0, 0.02, -0.01], distribution: Distribution::Total })),
|
||||
);
|
||||
let a = solve_explicit(&bar.model, None, &Limits::default()).unwrap();
|
||||
let b = solve_explicit(&bar.model, None, &Limits::default()).unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert!(a.increments > 10);
|
||||
}
|
||||
|
||||
/// Hostile and unsupported models are errors, never panics; the caps hold.
|
||||
#[test]
|
||||
fn caps_and_unsupported_models_are_errors() {
|
||||
let end = 0.5 * LENGTH / wave_speed();
|
||||
let bar = Bar::new(ElementKind::Hex8, 20, ExplicitDynamic::new(end));
|
||||
let tight = |f: fn(&mut Limits)| {
|
||||
let mut l = Limits::default();
|
||||
f(&mut l);
|
||||
l
|
||||
};
|
||||
let e = solve_explicit(&bar.model, None, &tight(|l| l.max_increments = 3)).unwrap_err().to_string();
|
||||
assert!(e.contains("increments") && e.contains("element"), "{e}");
|
||||
assert!(solve_explicit(&bar.model, None, &tight(|l| l.max_dofs = 10)).is_err());
|
||||
assert!(solve_explicit(&bar.model, None, &tight(|l| l.max_output_values = 10)).is_err());
|
||||
assert!(solve_explicit(&bar.model, Some("Nope"), &Limits::default()).is_err());
|
||||
|
||||
// A step capped below what it needs stops early, says so and keeps what it computed.
|
||||
let mut capped = ExplicitDynamic::new(end);
|
||||
capped.max_increments = 1_000_000;
|
||||
let mut short = Bar::new(ElementKind::Hex8, 20, capped);
|
||||
let r = solve_explicit(&short.model, None, &Limits::default()).unwrap();
|
||||
assert!(r.completed && r.increments > 5);
|
||||
let needed = r.increments;
|
||||
let Procedure::ExplicitDynamic(mut s) = short.model.step("Dynamic").unwrap().procedure.clone() else { unreachable!() };
|
||||
s.max_increments = needed;
|
||||
s.dt_scale = 0.89;
|
||||
short.model.replace_step(Step::new("Dynamic", Procedure::ExplicitDynamic(s))).unwrap();
|
||||
let r = solve_explicit(&short.model, None, &Limits::default());
|
||||
// Either refused up front (the estimate exceeds the cap) or stopped at the cap with a warning.
|
||||
match r {
|
||||
Ok(r) => assert!(!r.completed && r.warnings.iter().any(|w| w.contains("cap")) && r.time < end, "{r:?}"),
|
||||
Err(e) => assert!(e.to_string().contains("cap"), "{e}"),
|
||||
}
|
||||
|
||||
// Fully integrated hex8, orthotropic tets and missing densities are refused by name.
|
||||
let mut full = Bar::new(ElementKind::Hex8, 4, ExplicitDynamic::new(end));
|
||||
full.model
|
||||
.replace_section(
|
||||
"bar",
|
||||
Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation: Formulation::default() },
|
||||
)
|
||||
.unwrap();
|
||||
assert!(solve_explicit(&full.model, None, &Limits::default()).unwrap_err().to_string().contains("not supported yet"));
|
||||
let mut ortho = Bar::new(ElementKind::Tet4, 4, ExplicitDynamic::new(end));
|
||||
let constants = Elastic::EngineeringConstants { young: [YOUNG; 3], poisson: [0.0; 3], shear: [YOUNG / 2.0; 3] };
|
||||
ortho.model.replace_material("Mat", Material { elastic: Some(constants), density: Some(DENSITY) }).unwrap();
|
||||
assert!(solve_explicit(&ortho.model, None, &Limits::default()).unwrap_err().to_string().contains("orthotropic"));
|
||||
let mut light = Bar::new(ElementKind::Tet4, 4, ExplicitDynamic::new(end));
|
||||
light.model.replace_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }), density: None }).unwrap();
|
||||
assert!(solve_explicit(&light.model, None, &Limits::default()).is_err());
|
||||
// A node behind its wall at the start.
|
||||
let mut behind = ExplicitDynamic::new(end);
|
||||
behind.rigid_walls = vec![RigidWall { set: "end".into(), point: [LENGTH + 1.0, 0.0, 0.0], normal: [-1.0, 0.0, 0.0] }];
|
||||
let mut wall = Bar::new(ElementKind::Hex8, 4, ExplicitDynamic::new(end));
|
||||
wall.model.replace_step(Step::new("Dynamic", Procedure::ExplicitDynamic(behind.clone()))).unwrap();
|
||||
assert!(solve_explicit(&wall.model, None, &Limits::default()).is_ok(), "the wall is beyond the end node, which is on its allowed side");
|
||||
behind.rigid_walls[0].point = [LENGTH - 1.0, 0.0, 0.0];
|
||||
wall.model.replace_step(Step::new("Dynamic", Procedure::ExplicitDynamic(behind))).unwrap();
|
||||
assert!(solve_explicit(&wall.model, None, &Limits::default()).unwrap_err().to_string().contains("behind"));
|
||||
}
|
||||
Reference in new issue
Block a user