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

+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,
})
}