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:
authored and GitHub committed 2026-10-13 18:28:32 -04:00
1 parent 48039639cb
commit d45a2da5b5
33 files changed
+2757 -65

No files matched your search

+15
View File
@@ -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 }
+213
View File
@@ -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
}
+343
View File
@@ -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,
})
}
+92
View File
@@ -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 }
}
}
+99
View File
@@ -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>,
}
+420
View File
@@ -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, &section.material)?);
materials.len() - 1
}
};
let set = resolved.sets.get(&section.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, &section.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(())
}
}
+350
View File
@@ -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"));
}