Files
OneCAD-Simulation/crates/explicit/src/integrate.rs
T
jyvikoandClaude Opus 5.5 d5bd74570f Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag
fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server
name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family,
docs and licences. No compatibility shim: no files of the earlier name exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 23:51:40 -04:00

391 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 fieldcraft_model::{ExplicitDynamic, Model, OutputVariable, Procedure, Step};
use crate::elements::{Motion, State, Workspace, internal_forces};
use crate::result::{ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
use crate::system::{NODE_DOFS, 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| NODE_DOFS * 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::<NODE_DOFS>().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)))
.chain(sys.rods.iter().map(|r| (r.label, r.length, r.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 n = sys.dofs();
let mass = sys.dof_mass();
if let Some(d) = mass.iter().position(|m| !(*m > 0.0 && m.is_finite())) {
let label = sys.labels.get(crate::discrete::node_direction(d).0).copied().unwrap_or(0);
return Err(Error::Invalid(format!(
"node {label} has no mass in direction {}; explicit dynamics needs a mass at every node (give the nodes only springs or dashpots use a point mass)",
crate::discrete::node_direction(d).1 + 1
)));
}
// Springs and dashpots have their own stable increment on the lumped mass.
let elements_smallest = critical.first().map(|c| c.increment);
if let Some((l, name)) = &sys.discrete_increment
&& elements_smallest.is_none_or(|e| *l < e)
{
warnings.push(format!("spring or dashpot `{name}` limits the time increment to {l:.3e} (before the scale factor)"));
}
let smallest = elements_smallest
.into_iter()
.chain(sys.discrete_increment.as_ref().map(|l| l.0))
.reduce(f64::min)
.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
)));
}
// Point masses count with their mean over the three directions.
let total_mass: f64 = sys.mass.iter().sum::<f64>() + sys.discrete.mass.iter().sum::<f64>() / 3.0;
let mut ws = Workspace::new(&sys);
let mut previous = Workspace::new(&sys);
let mut state = State::new(&sys);
let mut eroded: Vec<ErodedElement> = Vec::new();
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>();
let inelastic = sys.materials.iter().any(|m| m.j2.as_ref().is_some_and(|j| j.flow.is_some()));
// Start: forces at rest with the initial velocities, then the half increment.
let start = Motion { u: &u, du: &du, v: &v0, dt: 0.0, time: 0.0, bulk: &bulk };
let crit = internal_forces(&sys, &start, &mut state, &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, usize) -> f64| {
let mut s = [0.0; 3];
for (node, c) in x.as_chunks::<NODE_DOFS>().0.iter().enumerate() {
for k in 0..3 {
s[k] += weight(node, k) * c[k];
}
}
s
};
history.momentum.push(sum(v, &|node, k| mass.get(crate::discrete::dof(node, k)).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(NODE_DOFS * 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], state: &State| {
let each = |on: bool, f: &dyn Fn(&crate::elements::ElementState) -> f64| -> Vec<f64> {
if on { state.elements.iter().map(f).collect() } else { Vec::new() }
};
let inelastic = output.stress && inelastic;
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 { state.elements.iter().map(|e| e.point.stress).collect() } else { Vec::new() },
strain: if output.strain { state.elements.iter().map(|e| e.strain).collect() } else { Vec::new() },
plastic_strain: each(inelastic, &|e| e.point.plastic_strain),
damage: each(inelastic, &|e| e.point.damage),
temperature: each(inelastic, &|e| e.point.temperature),
failed: if output.stress { state.elements.iter().map(|e| e.failed.is_some()).collect() } else { Vec::new() },
}
};
let plastic = |state: &State| state.elements.iter().map(|e| e.plastic_work).sum::<f64>();
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, &state));
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 motion = Motion { u: &u, du: &du, v: &v_half, dt: dt_half, time: t, bulk: &bulk };
let crit = internal_forces(&sys, &motion, &mut state, &mut ws)?;
for &(index, failure) in &state.eroded {
let element = state.elements.get(index);
let internal = element.map_or(0.0, |e| e.energy);
energy.eroded += internal;
eroded.push(ErodedElement { label: sys.elements.get(index).copied().unwrap_or(0), time: t, failure, internal_energy: internal });
}
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 per_dof = 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 per_dof.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;
// Every element eroded leaves an infinite critical increment: the nodes fly freely.
if !(energy.balance.is_finite() && !crit.is_nan()) {
return Err(Error::Unstable(format!("the solution is not finite at time {t:.6e} (increment {increments})")));
}
if histories.due(t) || last {
energy.plastic = plastic(&state);
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, &state));
}
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, &state));
}
}
energy.plastic = plastic(&state);
if !eroded.is_empty() {
warnings.push(format!(
"{} elements eroded (their material failed); their internal energy at erosion was {:.4e}",
eroded.len(),
energy.eroded
));
}
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,
eroded,
warnings,
})
}