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