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