Files
OneCAD-Simulation/crates/solver/src/nonlinear.rs
T
jyvikoandClaude Opus 5.5 3f9e9e30d8 fem, solver: thermal strain in nonlinear static steps
Temperatures now strain the material in nonlinear steps instead of being
refused. At finite strain the deformation gradient splits multiplicatively,
F = F_m F_theta with F_theta = diag(1 + alpha_i dT) at each point: the material
sees F_m and the loops run on the thermally expanded volume J_theta dV0 (mixed
elements project det F_m), so the tangent stays consistent. At small strain the
split is additive, and elements on their linear stiffness take the equivalent
nodal forces as a dead load. The step's temperatures are its temperature loads,
or the steady conduction of its temperature conditions and heat loads (one-way
coupling, as the linear coupled step); both at once are refused. They ramp with
the load factor from each material's reference temperature. Johnson-Cook
plasticity softens at the point's temperature. Verified: free expansion and a
fully constrained neo-Hookean block at 20 % thermal stretch, full and hybrid
(exact); a bar held at its ends heated past yield (exact); the linear limit and
the conducted temperatures against the linear solvers; tangents by central
differences with a temperature field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:23:06 -04:00

1110 lines
54 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.
//! Nonlinear static analysis: equilibrium followed in load increments with Newton–Raphson
//! iterations.
//!
//! **Load path.** The step's load factor λ runs from 0 to 1. The dead loads (forces, tractions,
//! gravity, body forces; pressure too without nonlinear geometry) are λ times their values, a
//! follower pressure λ p on the deformed faces, and every prescribed displacement λ times its
//! value (u = T_f q + λ T_p u_p, the constraint elimination of the linear solver,
//! [`crate::constraints`]).
//!
//! **Newton–Raphson** with the full consistent tangent, refactored every iteration (Bathe
//! 2014, §8.4; M. A. Crisfield, *Non-linear Finite Element Analysis of Solids and Structures*,
//! vol. 1, Wiley 1991, ch. 9): K_T δq = r with r = T_fᵀ (λ f_ext(u) − f_int(u)), then, when the
//! step asks for it (it is off by default: on large elastic rotations the full Newton step
//! converges in fewer iterations), a line search on the work of the residual along δq, G(s) = δq · r(q + s δq): s = 1 is taken when
//! |G(1)| ≤ 0.8 |G(0)| (always near the solution, so quadratic convergence is kept), otherwise
//! s is moved to the root of the line through (0, G(0)) and (s, G(s)), within [0.1, 1], at most
//! five times (Crisfield 1991, §9.3). An increment has converged when, at once,
//! - **force**: the largest residual component is at most `force` times the largest force the
//! step has seen (applied, internal, reaction), never less than a round-off floor of 10⁻⁸ of a
//! typical diagonal stiffness times the model's size;
//! - **displacement**: the largest correction is at most `displacement` times the largest
//! displacement of the increment;
//! - **energy**: the work of the correction against the residual, s δq · r, is at most `energy`
//! times that of the first iteration (K.-J. Bathe, A. P. Cimento, *Some practical procedures
//! for the solution of nonlinear finite element equations*, CMAME 22:59–85, 1980);
//!
//! or when the force residual is a thousandth of its tolerance (round-off: no correction can
//! improve it). With path-dependent materials, an increment that starts within the force
//! tolerance (a predictor that already solved it) waives the energy criterion, whose reference
//! would be round-off.
//!
//! **Automatic incrementation.** Each increment starts from the linear extrapolation of the
//! last converged one (the free motion scaled by the ratio of their sizes), which keeps the
//! inner nodes in step with the prescribed ones; from the last converged state when the
//! extrapolation inverts an element. The first increment is the step's `initial_increment`; two
//! increments in a row that converge in at most four iterations let the next grow by half (up
//! to `max_increment`), one that needs more than eight shrinks it by a quarter. An increment is
//! cut back to a quarter of its size when it fails: an element inverts, the residual is not
//! finite, the residual grows in two successive iterations, the tangent is not positive definite
//! (a limit point or an unstable material), or it does not converge in `max_iterations`. The
//! step fails cleanly, naming the load factor and the last reason, when an increment falls below
//! `min_increment`, when it has taken `max_increments` increments, or when the Newton iterations
//! of the whole step reach [`crate::Limits::max_newton_iterations`].
//!
//! **Follower pressure** and the Cholesky factorization: the load stiffness of a pressure on the
//! deformed surface is not symmetric (Schweizerhof & Ramm 1984); its skew part is left out of the
//! tangent (the symmetric part is kept), so the factorization stays the supernodal Cholesky of the
//! linear solver. The residual is always exact, so the converged solution is unchanged; only the
//! rate of convergence can drop where the skew part matters. Over a closed surface, or one whose
//! boundary is held, the skew parts of neighbouring faces cancel and nothing is left out; the
//! result reports the skew part's size relative to the load stiffness when it is not small.
//!
//! **Elements.** Solids (tet4, tet10, wedge6, hex8, hex20 full and reduced, hybrid hex8, hex20
//! and tet10) and 2D plane strain and axisymmetric elements, total Lagrangian
//! ([`fieldcraft_fem::finite`]); without nonlinear geometry an element of a plastic material
//! takes the same loops with small strain, and an elastic one its linear stiffness (K u).
//! Springs act along their fixed directions. Trusses, shells and beams (large rotations), plane
//! stress, incompatible modes (with nonlinear geometry, or with an inelastic material) and
//! centrifugal loads are refused by name.
//!
//! **Thermal strain.** The step's temperatures are its temperature loads, or, when it has
//! temperature boundary conditions or heat loads, their steady conduction solved first (one-way
//! coupling, as the linear coupled temperature–displacement step). They ramp with the load
//! factor from each material's reference temperature, ΔT(λ) = λ (T − T_ref). With nonlinear
//! geometry the deformation gradient splits multiplicatively, F = F_m F_θ with
//! F_θ = diag(1 + αᵢ ΔT), the material sees F_m and the stress is J_θ τ_m per reference volume
//! (fem's `Temperature`); with small strain the split is additive, ε_m = ε − α ΔT; elements on
//! their linear stiffness take the equivalent nodal forces, a dead load. A Johnson–Cook material
//! softens at the point's temperature.
//!
//! **Plasticity** (`Material::plastic`, J2 with the radial return explicit dynamics uses,
//! [`fieldcraft_fem::elastoplastic`]): Simo's exponential return map at finite strain, the
//! additive radial return at small strain, both with their consistent tangents, so Newton keeps
//! its quadratic rate. The plastic history is committed when an increment converges and
//! discarded when it is cut back. A static step has no time, so the Johnson–Cook strain-rate term
//! is refused (C = 0 is the rate-independent law), and kinematic hardening at finite strain is
//! refused (its back stress breaks the principal-frame return); damage, erosion and adiabatic
//! heating act in explicit dynamics only, with a warning. Results add the equivalent plastic
//! strain at the integration points.
//! Couplings stay the linear (small-rotation) constraints of the linear solver, with a warning.
//!
//! Element forces and tangents are computed in parallel and added in label order, so results are
//! the same bit for bit on any number of threads.
use fieldcraft_fem::constitutive::MaterialModel;
use fieldcraft_fem::dense::Matrix;
use fieldcraft_fem::element::{Extrapolation, extrapolation};
use fieldcraft_fem::finite::{Kinematics, Temperature};
use fieldcraft_fem::plane::Theory;
use fieldcraft_fem::quadrature::Rule;
use fieldcraft_fem::{DofKind, DofSet, ElementType, PointStress, face, von_mises};
use fieldcraft_model::{BoundaryCondition, Extent, Hardening, Load, Model, NonlinearStatic, Plastic, Procedure, SectionKind, Step};
use fieldcraft_parallel as parallel;
use serde::Serialize;
use crate::analysis::{
ElementStress, GRAIN, PointResult, ROUND, Setup, StaticResult, Stats, budget, factor_error, factor_raw, ignored_dashpots, pattern, reduction,
split,
};
use crate::cholesky::FactorError;
use crate::constraints::Reduction;
use crate::sparse::{Csr, add_block};
use crate::system::{Mesh, add_element_thermal_loads, loads, temperature_loads, translations};
use crate::{Error, Limits, Result};
/// One Newton iteration of an increment.
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct Iteration {
/// Largest residual component over the force scale.
pub force: f64,
/// Largest correction over the largest displacement of the increment.
pub displacement: f64,
/// Work of the correction against the residual over the first iteration's.
pub energy: f64,
/// The line search's step along the correction (1: the full Newton step).
pub line_search: f64,
}
/// One increment (converged, or cut back).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Increment {
/// From 1, attempts included.
pub number: usize,
/// The load factor reached (when converged) or attempted.
pub load_factor: f64,
/// Its size, as a fraction of the step.
pub size: f64,
pub iterations: Vec<Iteration>,
pub converged: bool,
/// Why it was cut back.
#[serde(skip_serializing_if = "Option::is_none")]
pub cutback: Option<String>,
}
/// The displacements at the end of a converged increment.
#[derive(Debug, Clone, PartialEq)]
pub struct Frame {
pub load_factor: f64,
/// Per node, in [`StaticResult::nodes`] order.
pub displacement: Vec<[f64; 3]>,
}
/// The increments of a nonlinear static step and the displacements after each converged one.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct History {
pub increments: Vec<Increment>,
/// The undeformed state (load factor 0), then one per converged increment while they fit
/// [`MAX_FRAME_VALUES`].
pub frames: Vec<Frame>,
/// Newton iterations over the step.
pub iterations: usize,
}
impl History {
/// The load factor of every frame.
pub fn load_factors(&self) -> Vec<f64> {
self.frames.iter().map(|f| f.load_factor).collect()
}
}
/// Most displacement values the frames of one step keep (3 per node per frame).
pub const MAX_FRAME_VALUES: usize = 30_000_000;
/// Line search: accept a step whose residual work is at most this fraction of the first.
const LINE_SEARCH_ACCEPT: f64 = 0.8;
/// Line search: most trial steps, and the range of the step.
const LINE_SEARCH_TRIALS: usize = 5;
const LINE_SEARCH_MIN: f64 = 0.1;
/// Cutback factor of a failed increment, growth after fast ones and shrinking after slow ones.
const CUTBACK: f64 = 0.25;
const GROWTH: f64 = 1.5;
const SHRINK: f64 = 0.75;
const FAST_ITERATIONS: usize = 4;
const SLOW_ITERATIONS: usize = 8;
/// A force residual this fraction of its tolerance is round-off.
const ROUND_OFF: f64 = 1e-3;
/// A pressure on element faces (or 2D edges) that follows the deformation.
struct Follower {
element: u32,
/// Layout slots of the face's nodes, and their reference positions.
nodes: Vec<usize>,
xyz: Vec<[f64; 3]>,
magnitude: f64,
/// The out-of-plane extent of a 2D edge; `None` for a solid face.
extent: Option<Extent>,
/// The translations of its nodes (x, y, z, or x, y per node).
dofs: Vec<usize>,
}
/// Everything an evaluation of the out-of-balance forces needs.
struct Problem<'a> {
mesh: &'a Mesh,
red: &'a Reduction,
/// Dead loads at λ = 1.
dead: Vec<f64>,
followers: Vec<Follower>,
/// Springs (linear along their fixed directions), on the pattern.
springs: Option<Csr>,
/// Large-deformation kinematics.
finite: bool,
/// Each element's global degrees of freedom, in its matrices' order, and the kinds per node.
element_dofs: Vec<Vec<usize>>,
element_kinds: Vec<DofSet>,
/// Each element's material model.
materials: Vec<MaterialModel>,
/// The step's temperature field at λ = 1, per mesh node (`None` where the field leaves a
/// node at its material's reference temperature); `None` without one.
field: Option<Vec<Option<f64>>>,
/// The tangent's pattern, zero.
pattern: Csr,
}
/// The internal and external forces at a state, and the tangent when asked for.
struct Evaluation {
internal: Vec<f64>,
external: Vec<f64>,
tangent: Option<Csr>,
/// Every element's material history at this state (committed when the increment
/// converges).
history: Vec<Vec<f64>>,
}
/// Why an attempt at an increment failed, when that calls for a cutback rather than an error.
enum Failure {
Cutback(String),
Fatal(Error),
}
impl From<Error> for Failure {
fn from(e: Error) -> Failure {
match e {
// An inverted element or a material that cannot take the deformation: a smaller
// increment may avoid it.
Error::Element { label, source: fieldcraft_fem::Error::Degenerate(m) } => Failure::Cutback(format!("element {label}: {m}")),
Error::NotConverged(m) => Failure::Cutback(m),
e => Failure::Fatal(e),
}
}
}
fn norm_inf(v: &[f64]) -> f64 {
v.iter().fold(0.0, |m, x| m.max(x.abs()))
}
fn dot(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
/// ½(K + sign Kᵀ) of a row-major m × m matrix: its symmetric (sign 1) or skew (−1) part.
fn half(k: &[f64], m: usize, sign: f64) -> Vec<f64> {
(0..m * m).map(|i| 0.5 * (k.get(i).copied().unwrap_or(0.0) + sign * k.get((i % m) * m + i / m).copied().unwrap_or(0.0))).collect()
}
/// The out-of-balance forces over the free equations, T_fᵀ (f_ext − f_int).
fn free_residual(red: &Reduction, e: &Evaluation) -> Vec<f64> {
let r: Vec<f64> = e.external.iter().zip(&e.internal).map(|(a, b)| a - b).collect();
red.restrict(&r)
}
impl Problem<'_> {
/// Nodal displacements of an element (3 per node for solids, 2 for 2D elements).
fn element_vector(&self, e: usize, u: &[f64]) -> Vec<f64> {
self.element_dofs.get(e).map(|dofs| dofs.iter().map(|d| u.get(*d).copied().unwrap_or(0.0)).collect()).unwrap_or_default()
}
/// How element `e` is evaluated: total Lagrangian with nonlinear geometry, with small
/// strain when its material is inelastic, and by its linear stiffness otherwise (`None`).
fn kinematics(&self, e: usize) -> Option<Kinematics> {
if self.finite { Some(Kinematics::Finite) } else { self.materials.get(e).filter(|m| m.is_path_dependent()).map(|_| Kinematics::Small) }
}
/// The temperatures of element `e` at load factor λ, which ramps them from the reference
/// temperature to the field's (J2 Johnson–Cook softening and thermal strain): the
/// material's expansion and reference temperature, or no expansion from the Johnson–Cook
/// room temperature; `None` without a field or for a material neither affects.
fn temperature(&self, e: usize, lambda: f64) -> Option<Temperature> {
let field = self.field.as_ref()?;
let el = self.mesh.elements.get(e)?;
let softens = el.plastic.as_ref().is_some_and(|j2| j2.is_temperature_dependent());
let (alpha, reference) = match el.expansion {
Some(x) => x,
None if softens => ([0.0; 3], el.plastic.as_ref().map_or(0.0, |j2| j2.initial_temperature())),
None => return None,
};
let nodal: Vec<f64> =
el.nodes.iter().map(|n| field.get(*n).copied().flatten().map_or(reference, |t| reference + lambda * (t - reference))).collect();
(softens || nodal.iter().any(|t| *t != reference)).then_some(Temperature { alpha, reference, nodal })
}
/// Internal forces and the tangent of element `e` at displacements `u` (all degrees of
/// freedom) and load factor λ.
fn element(&self, e: usize, u: &[f64], lambda: f64, tangent: bool, committed: &[f64]) -> Result<(Vec<f64>, Option<Matrix>, Vec<f64>)> {
let el = self.mesh.elements.get(e).ok_or_else(|| Error::Internal("element index".into()))?;
let at = |err| Error::element(el.label, err);
let ue = self.element_vector(e, u);
let kernel = el.kernel()?;
let Some(kin) = self.kinematics(e) else {
let k = kernel.stiffness(&el.d).map_err(at)?;
let f = k.mul_vec(&ue);
return Ok((f, tangent.then_some(k), committed.to_vec()));
};
let material = self.materials.get(e).ok_or_else(|| Error::Internal("element material".into()))?;
let mut updated = committed.to_vec();
let r = kernel.nonlinear(material, &ue, kin, self.temperature(e, lambda).as_ref(), committed, &mut updated, tangent).map_err(at)?;
Ok((r.force, r.tangent, updated))
}
/// The forces of a follower pressure at λ (on [`Follower::dofs`]) and its load stiffness
/// (full, not symmetric).
fn follower(&self, f: &Follower, u: &[f64], lambda: f64) -> Result<(Vec<f64>, Vec<f64>)> {
let at = |err| Error::element(f.element, err);
let ue: Vec<f64> = f.dofs.iter().map(|d| u.get(*d).copied().unwrap_or(0.0)).collect();
let p = lambda * f.magnitude;
let (force, k) = match f.extent {
None => {
let (force, k) = face::follower_pressure(&f.xyz, ue.as_chunks::<3>().0, p).map_err(at)?;
(force.into_iter().flatten().collect(), k)
}
Some(extent) => {
let (force, k) = face::follower_edge_pressure(&f.xyz, ue.as_chunks::<2>().0, extent, p).map_err(at)?;
(force.into_iter().flatten().collect(), k)
}
};
Ok((force, k))
}
/// The forces at displacements `u` and load factor λ, and the tangent (symmetric part of the
/// load stiffness included) when `tangent`.
fn evaluate(&self, u: &[f64], lambda: f64, tangent: bool, committed: &[Vec<f64>]) -> Result<Evaluation> {
let layout = &self.mesh.layout;
let n = u.len();
let mut internal = vec![0.0; n];
let mut k = tangent.then(|| self.pattern.clone());
let mut history = Vec::with_capacity(self.mesh.elements.len());
for round in parallel::blocks(self.mesh.elements.len(), ROUND) {
let start = round.start;
let computed = parallel::map_grained(round.len(), GRAIN, |i| {
self.element(start + i, u, lambda, tangent, committed.get(start + i).map(Vec::as_slice).unwrap_or_default())
});
for (i, c) in computed.into_iter().enumerate() {
let (f, ke, updated) = c?;
history.push(updated);
let e = start + i;
let el = self.mesh.elements.get(e).ok_or_else(|| Error::Internal("element index".into()))?;
for (d, v) in self.element_dofs.get(e).into_iter().flatten().zip(&f) {
if let Some(x) = internal.get_mut(*d) {
*x += v;
}
}
if let (Some(k), Some(ke)) = (k.as_mut(), ke) {
let kinds = self.element_kinds.get(e).copied().ok_or_else(|| Error::Internal("element kinds".into()))?;
add_block(k, layout, &el.nodes, kinds, &ke.data)?;
}
}
}
if let Some(s) = &self.springs {
let mut ku = vec![0.0; s.n];
s.mul(u.get(..s.n).unwrap_or_default(), &mut ku);
for (x, v) in internal.iter_mut().zip(&ku) {
*x += v;
}
// Both are on the mesh's pattern.
if let Some(k) = k.as_mut() {
k.val.iter_mut().zip(&s.val).for_each(|(x, v)| *x += v);
}
}
let mut external: Vec<f64> = self.dead.iter().map(|f| lambda * f).collect();
for f in &self.followers {
let (force, kl) = self.follower(f, u, lambda)?;
for (d, v) in f.dofs.iter().zip(&force) {
if let Some(x) = external.get_mut(*d) {
*x += v;
}
}
if let Some(k) = k.as_mut() {
add_block(k, layout, &f.nodes, self.mesh.translation_kinds(), &half(&kl, f.dofs.len(), 1.0))?;
}
}
if internal.iter().chain(&external).any(|v| !v.is_finite()) {
return Err(Error::NotConverged("the forces are not finite".into()));
}
Ok(Evaluation { internal, external, tangent: k, history })
}
/// The size of the follower load stiffness's skew part relative to the whole, over the free
/// degrees of freedom (Frobenius norms), at `u` and λ; `None` without followers.
fn follower_asymmetry(&self, u: &[f64], lambda: f64) -> Result<Option<f64>> {
if self.followers.is_empty() {
return Ok(None);
}
let layout = &self.mesh.layout;
let (mut skew, mut whole) = (self.pattern.clone(), self.pattern.clone());
for f in &self.followers {
let (_, kl) = self.follower(f, u, lambda)?;
add_block(&mut skew, layout, &f.nodes, self.mesh.translation_kinds(), &half(&kl, f.dofs.len(), -1.0))?;
add_block(&mut whole, layout, &f.nodes, self.mesh.translation_kinds(), &kl)?;
}
let mut free = vec![false; skew.n];
for d in &self.red.eq_dof {
if let Some(x) = free.get_mut(*d) {
*x = true;
}
}
let norm = |a: &Csr| -> f64 {
let mut sum = 0.0;
for r in (0..a.n).filter(|r| free.get(*r).copied().unwrap_or(false)) {
let (cols, vals) = a.row(r);
for (c, v) in cols.iter().zip(vals) {
if free.get(*c).copied().unwrap_or(false) {
sum += v * v;
}
}
}
sum.sqrt()
};
let total = norm(&whole);
Ok((total > 0.0).then(|| norm(&skew) / total))
}
}
/// Mutable state of the Newton loop over the step.
struct Run<'a> {
problem: &'a Problem<'a>,
settings: &'a NonlinearStatic,
limits: &'a Limits,
/// The largest force seen so far (∞-norm), and the round-off floor of the force scale.
force_scale: f64,
force_floor: Option<f64>,
length: f64,
iterations: usize,
factor_entries: usize,
/// Every element's material history at the end of the last converged increment
/// (integration points × the material's history length).
committed: Vec<Vec<f64>>,
/// Whether a material keeps history (the tangent predictor and its convergence rules).
path_dependent: bool,
/// Whether the undeformed model's tangent factors, once asked.
undeformed_stable: Option<bool>,
}
/// The outcome of a converged increment.
struct Converged {
q: Vec<f64>,
evaluation: Evaluation,
}
impl Run<'_> {
/// The residual over the free equations, T_fᵀ (f_ext − f_int), and the force scale.
fn residual(&mut self, e: &Evaluation) -> Vec<f64> {
self.force_scale = self.force_scale.max(norm_inf(&e.external)).max(norm_inf(&e.internal));
free_residual(self.problem.red, e)
}
fn scale(&self) -> f64 {
self.force_scale.max(self.force_floor.unwrap_or(0.0)).max(f64::MIN_POSITIVE)
}
fn state(&self, q: &[f64], lambda: f64, tangent: bool) -> std::result::Result<Evaluation, Failure> {
Ok(self.problem.evaluate(&self.problem.red.expand_scaled(q, lambda), lambda, tangent, &self.committed)?)
}
/// Whether the tangent of the undeformed, unloaded model is positive definite: a first
/// tangent that is not, at a state the first increment has already strained, is then the
/// material or the geometry (a cutback), not a model that can move freely (an error).
fn undeformed_is_stable(&mut self) -> std::result::Result<bool, Failure> {
if let Some(stable) = self.undeformed_stable {
return Ok(stable);
}
let mut e = self.problem.evaluate(&vec![0.0; self.problem.mesh.dofs()], 0.0, true, &self.committed)?;
let k = e.tangent.take().ok_or_else(|| Failure::Fatal(Error::Internal("tangent".into())))?;
let [kff] = self.problem.red.reduce([&k])?;
let stable = factor_raw(&kff, self.limits).is_ok();
self.undeformed_stable = Some(stable);
Ok(stable)
}
/// The tangent predictor of an increment from the converged `q0` at load factor `from` to
/// `to`, and its work δq · r (the increment's reference for the energy criterion, since the
/// Newton iterations after it only correct it): one linear solve with the tangent of the converged state for the change of the
/// loads and of the prescribed displacements, K δq = T_fᵀ (f_ext(to) − f_int(u₀) − K T_p Δλ u_p).
/// It spreads a prescribed motion into the interior before the material sees it, which a
/// path-dependent material needs: started from the boundary's jump alone, the elements next
/// to it would flow plastically far beyond the solution. `None` when the tangent cannot be
/// factored (the increment then starts from `q0`).
fn tangent_predictor(&mut self, q0: &[f64], from: f64, to: f64) -> std::result::Result<Option<(Vec<f64>, f64)>, Failure> {
let red = self.problem.red;
let mut e = self.problem.evaluate(&red.expand_scaled(q0, from), to, true, &self.committed)?;
let k = e.tangent.take().ok_or_else(|| Failure::Fatal(Error::Internal("tangent".into())))?;
let jump = red.expand_scaled(&vec![0.0; red.neq()], to - from);
let mut kj = vec![0.0; jump.len()];
k.mul(&jump, &mut kj);
let rf: Vec<f64> = free_residual(red, &e).iter().zip(red.restrict(&kj)).map(|(a, b)| a - b).collect();
let mut dq = rf.clone();
let [kff] = red.reduce([&k])?;
drop(k);
let Ok(chol) = factor_raw(&kff, self.limits) else { return Ok(None) };
chol.solve(&mut dq)?;
let energy = dot(&dq, &rf).abs();
Ok(dq.iter().all(|v| v.is_finite()).then(|| (q0.iter().zip(&dq).map(|(a, d)| a + d).collect(), energy)))
}
/// Newton iterations from the converged `q0` (at the previous load factor) to load factor
/// `lambda`.
fn increment(
&mut self,
q0: &[f64],
predictor: Option<&[f64]>,
predicted_energy: Option<f64>,
lambda: f64,
size: f64,
iterations: &mut Vec<Iteration>,
) -> std::result::Result<Converged, Failure> {
let red = self.problem.red;
let tol = self.settings.tolerances;
// Start from the extrapolated predictor when it is a valid state, else from q0.
let start = predictor.map(|p| self.state(p, lambda, true).map(|e| (p.to_vec(), e)));
let (mut q, mut eval) = match start {
Some(Ok(s)) => s,
Some(Err(Failure::Fatal(e))) => return Err(Failure::Fatal(e)),
Some(Err(Failure::Cutback(_))) | None => (q0.to_vec(), self.state(q0, lambda, true)?),
};
let mut r = self.residual(&eval);
// With path-dependent materials, an increment that starts within the force tolerance (a
// predictor that already solved it) has only round-off left for the energy criterion to
// measure against: it is waived.
let started_converged = self.path_dependent && self.force_floor.is_some() && norm_inf(&r) <= tol.force * self.scale();
let prescribed = size * red.largest_prescribed();
// The energy criterion's reference: the first correction's work, or the tangent
// predictor's when the increment started from one that is still in use.
let mut first_energy: Option<f64> = predicted_energy.filter(|_| q.as_slice() != q0);
let mut history: Vec<f64> = Vec::new();
let max_iterations = usize::try_from(self.settings.max_iterations).unwrap_or(usize::MAX);
for _ in 0..max_iterations {
// The round-off rule: nothing left to correct.
if norm_inf(&r) <= ROUND_OFF * tol.force * self.scale() && self.force_floor.is_some() {
return Ok(Converged { q, evaluation: eval });
}
if self.iterations >= self.limits.max_newton_iterations {
return Err(Failure::Fatal(Error::Limit(format!(
"the step took {} Newton iterations, the limit (raise max_newton_iterations or loosen the tolerances)",
self.iterations
))));
}
self.iterations += 1;
let k = eval.tangent.take().ok_or_else(|| Failure::Fatal(Error::Internal("tangent".into())))?;
let [kff] = red.reduce([&k])?;
// The first tangent of the step: a singular one is a modelling error, not a cutback.
let first = self.force_floor.is_none();
if first {
let diagonal = kff.diagonal();
let mean = diagonal.iter().map(|d| d.abs()).sum::<f64>() / diagonal.len().max(1) as f64;
self.force_floor = Some(1e-8 * mean * self.length);
}
drop(k);
let chol = match factor_raw(&kff, self.limits) {
Ok(c) => c,
Err(FactorError::NotPositiveDefinite { .. }) if !first || self.undeformed_is_stable()? => {
return Err(Failure::Cutback(
"the tangent stiffness is not positive definite (a limit point, a bifurcation or an unstable material)".into(),
));
}
Err(e) => return Err(Failure::Fatal(factor_error(e, self.problem.mesh, red))),
};
self.factor_entries = chol.entries();
drop(kff);
let mut dq = r.clone();
chol.solve(&mut dq)?;
if dq.iter().any(|v| !v.is_finite()) {
return Err(Failure::Cutback("the Newton correction is not finite".into()));
}
let g0 = dot(&dq, &r);
let (s, next) = self.line_search(&q, &dq, lambda, g0)?;
for (x, d) in q.iter_mut().zip(&dq) {
*x += s * d;
}
eval = next;
let r_old = std::mem::replace(&mut r, self.residual(&eval));
let energy = (s * dot(&dq, &r_old)).abs();
let first = *first_energy.get_or_insert(energy);
let moved = q.iter().zip(q0).fold(0.0f64, |m, (a, b)| m.max((a - b).abs()));
let reference = moved.max(prescribed).max(1e-12 * self.length);
let it = Iteration {
force: norm_inf(&r) / self.scale(),
displacement: s * norm_inf(&dq) / reference,
energy: if first > 0.0 { energy / first } else { 0.0 },
line_search: s,
};
iterations.push(it);
if !it.force.is_finite() {
return Err(Failure::Cutback("the residual is not finite".into()));
}
let energy_met = it.energy <= tol.energy || started_converged;
if (it.force <= tol.force && it.displacement <= tol.displacement && energy_met) || it.force <= ROUND_OFF * tol.force {
return Ok(Converged { q, evaluation: eval });
}
history.push(it.force);
if let [.., a, b, c] = history[..]
&& c > b
&& b > a
{
return Err(Failure::Cutback(format!("the residual grows ({a:.2e}, {b:.2e}, {c:.2e} of the force scale)")));
}
}
Err(Failure::Cutback(format!("no convergence in {} iterations", self.settings.max_iterations)))
}
/// The line search along `dq` from `q`: the accepted step and the state there (with the
/// tangent).
fn line_search(&mut self, q: &[f64], dq: &[f64], lambda: f64, g0: f64) -> std::result::Result<(f64, Evaluation), Failure> {
let at = |s: f64| -> Vec<f64> { q.iter().zip(dq).map(|(a, b)| a + s * b).collect() };
// The full step, with its tangent (taken in most iterations).
let full = self.state(&at(1.0), lambda, true);
if !self.settings.line_search {
return full.map(|e| (1.0, e));
}
let red = self.problem.red;
let work = |e: &Evaluation| dot(dq, &free_residual(red, e));
let mut s = 1.0;
let mut g = match &full {
Ok(e) => {
let g1 = work(e);
if g1.abs() <= LINE_SEARCH_ACCEPT * g0.abs() {
return full.map(|e| (1.0, e));
}
Some(g1)
}
Err(Failure::Cutback(_)) => None,
Err(Failure::Fatal(_)) => return full.map(|e| (1.0, e)),
};
let mut best: Option<(f64, f64)> = g.map(|g| (1.0, g.abs()));
for _ in 0..LINE_SEARCH_TRIALS {
// Root of the line through (0, g0) and (s, g), or half the step after a failure.
let next = match g {
Some(gs) if (g0 - gs).abs() > 0.0 => (s * g0 / (g0 - gs)).clamp(LINE_SEARCH_MIN, 1.0),
Some(_) => break,
None => (0.5 * s).max(LINE_SEARCH_MIN),
};
if next == s {
break;
}
s = next;
g = match self.state(&at(s), lambda, false) {
Ok(e) => Some(work(&e)),
Err(Failure::Cutback(_)) => None,
Err(f) => return Err(f),
};
if let Some(gs) = g {
if best.is_none_or(|(_, b)| gs.abs() < b) {
best = Some((s, gs.abs()));
}
if gs.abs() <= LINE_SEARCH_ACCEPT * g0.abs() {
break;
}
}
}
let Some((s, _)) = best else {
return Err(Failure::Cutback("every step along the Newton correction inverts an element".into()));
};
if s == 1.0 {
return full.map(|e| (1.0, e));
}
self.state(&at(s), lambda, true).map(|e| (s, e))
}
}
/// Refuse what nonlinear steps cannot take yet, and warn about what they simplify.
fn check_supported(model: &Model, mesh: &Mesh, step: &Step, settings: &NonlinearStatic, warnings: &mut Vec<String>) -> Result<()> {
let at = |what: String| Error::NotSupported(format!("{what} in nonlinear steps (step `{}`)", step.name));
if let Some(t) = mesh.trusses.first() {
return Err(at(format!("trusses (element {})", t.label)));
}
if let Some(s) = mesh.structural.first() {
return Err(at(format!("shells and beams, whose large rotations are not implemented yet (element {})", s.label)));
}
for e in &mesh.elements {
if !settings.nonlinear_geometry && e.hyperelastic.is_some() {
return Err(at(format!("a hyperelastic material without nonlinear geometry (element {})", e.label)));
}
}
if let Some(name) = step.loads.iter().find(|n| matches!(model.load(n), Some(Load::Centrifugal { .. }))) {
return Err(at(format!("centrifugal loads (load `{name}`), which depend on the deformed position")));
}
plasticity(model, step, settings, warnings)?;
if settings.nonlinear_geometry
&& let Some(name) = model.couplings().keys().next()
{
warnings.push(format!(
"coupling `{name}` and the others are linear constraints (small rotations of their reference points) in this nonlinear step"
));
}
Ok(())
}
/// Refuse the plasticity a static step cannot take, and warn about what it leaves out, for the
/// materials of the solid and 2D sections.
fn plasticity(model: &Model, step: &Step, settings: &NonlinearStatic, warnings: &mut Vec<String>) -> Result<()> {
let at = |what: String| Error::NotSupported(format!("{what} in nonlinear steps (step `{}`)", step.name));
let used: std::collections::BTreeSet<&str> = model
.sections()
.values()
.filter(|s| matches!(s.kind, SectionKind::Solid {}) || Theory::from_section(&s.kind).is_some())
.map(|s| s.material.as_str())
.collect();
for name in used {
let Some(m) = model.material(name) else { continue };
match &m.plastic {
Some(Plastic::JohnsonCook { c, specific_heat, .. }) => {
if *c != 0.0 {
return Err(at(format!(
"the Johnson–Cook strain-rate term of material `{name}` (C = {c}): a static step has no time, so no strain rate; set C to 0 or use explicit dynamics"
)));
}
if specific_heat.is_some() {
warnings.push(format!(
"material `{name}`: a static step is isothermal, so plastic work does not heat it (adiabatic heating acts in explicit dynamics)"
));
}
}
Some(Plastic::Bilinear { hardening: Hardening::Kinematic, .. }) if settings.nonlinear_geometry => {
return Err(at(format!(
"the kinematic hardening of material `{name}` at finite strain (its back stress is not coaxial with the elastic stretch, which the exponential return map needs); use nonlinear_geometry false or isotropic hardening"
)));
}
_ => {}
}
if m.plastic.is_some() && (m.damage.is_some() || m.erosion.is_some()) {
warnings.push(format!("material `{name}`: damage and erosion act in explicit dynamics; this static step leaves them out"));
}
}
Ok(())
}
/// The step's temperature field per mesh node: the steady heat solution of its temperature
/// boundary conditions and heat loads when it has some (one-way coupling, as the coupled
/// temperature–displacement step of the linear solver), or its temperature loads.
fn temperatures(model: &Model, resolved: &fieldcraft_model::Resolved, mesh: &Mesh, step: &Step, limits: &Limits) -> Result<Option<Vec<Option<f64>>>> {
let conducts = step.boundary_conditions.iter().any(|b| model.boundary_conditions().get(b).is_some_and(BoundaryCondition::is_thermal))
|| step.loads.iter().any(|l| model.load(l).is_some_and(Load::is_thermal));
let loads = temperature_loads(model, resolved, mesh, step)?;
match (conducts, loads) {
(true, Some(_)) => Err(Error::Invalid(format!(
"step `{}` gives temperatures both by temperature loads and by heat conditions (temperature boundary conditions, heat loads); use one",
step.name
))),
(true, None) => Ok(Some(crate::heat::temperatures_at(model, resolved, step, limits, &mesh.labels)?)),
(false, field) => Ok(field),
}
}
/// The follower pressures of a step (with nonlinear geometry), and the step without them for
/// the dead loads.
fn followers(model: &Model, resolved: &fieldcraft_model::Resolved, mesh: &Mesh, step: &Step) -> Result<(Vec<Follower>, Step)> {
let mut out = Vec::new();
let mut dead = step.clone();
for name in &step.loads {
let Some(Load::Pressure { set, magnitude }) = model.load(name) else { continue };
dead.loads.remove(name);
let slots = |labels: Vec<u32>| -> Result<(Vec<usize>, Vec<[f64; 3]>, Vec<usize>)> {
let idx: Vec<usize> = labels
.iter()
.map(|l| mesh.node(*l).ok_or_else(|| Error::Invalid(format!("load `{name}`: node {l} belongs to no solid element"))))
.collect::<Result<_>>()?;
let xyz = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
let mut dofs = Vec::with_capacity(3 * idx.len());
for n in &idx {
dofs.extend(translations(mesh, *n)?);
}
Ok((idx, xyz, dofs))
};
if let Some(theory) = mesh.theory {
if resolved.sets.get(set).is_some_and(|s| !s.faces.is_empty()) {
return Err(Error::Invalid(format!("load `{name}`: a 2D model's pressures act on element edges, and set `{set}` has element faces")));
}
for g in resolved.planar_edges(set) {
let labels =
model.edge_nodes(g).ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} edge {} is missing", g.element, g.edge)))?;
let (nodes, xyz, dofs) = slots(labels)?;
out.push(Follower { element: g.element, nodes, xyz, magnitude: *magnitude, extent: Some(theory.extent()), dofs });
}
} else {
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(|| Error::Invalid(format!("load `{name}`: element {} face {} is missing", fc.element, fc.face)))?;
let (nodes, xyz, dofs) = slots(labels)?;
out.push(Follower { element: fc.element, nodes, xyz, magnitude: *magnitude, extent: None, dofs });
}
}
}
Ok((out, dead))
}
/// Solve a nonlinear static step: the step named `step`, or the model's only nonlinear static
/// step.
pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Result<StaticResult> {
let resolved = model.resolve()?;
let step = model.select_step(step, |p| matches!(p, Procedure::NonlinearStatic(_)), "nonlinear static")?;
let Procedure::NonlinearStatic(settings) = &step.procedure else {
return Err(Error::Internal("nonlinear static step".into()));
};
settings.validate()?;
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).chain(mesh.warnings.iter().cloned()).collect();
check_supported(model, &mesh, step, settings, &mut warnings)?;
let (graph, order) = pattern(&mesh);
ignored_dashpots(&mesh, &mut warnings);
let setup = Setup { model, resolved: &resolved, mesh: &mesh, step };
let red = reduction(&setup, true, &order, budget(limits, &mesh, &graph, 1), &mut warnings)?;
if red.neq() == 0 {
return Err(Error::Invalid("every degree of freedom is prescribed; there is nothing to solve".into()));
}
let (follower_loads, dead_step) =
if settings.nonlinear_geometry { followers(model, &resolved, &mesh, step)? } else { (Vec::new(), step.clone()) };
let dead = loads(model, &resolved, &mesh, &dead_step, &red.active)?;
let field = temperatures(model, &resolved, &mesh, step, limits)?;
let springs = if mesh.discrete.springs.iter().any(|s| !s.dashpot) {
let mut s = graph.zero_matrix(&mesh.layout);
mesh.discrete.add_stiffness(&mut s)?;
Some(s)
} else {
None
};
let element_kinds = mesh.elements.iter().map(|e| Ok(e.kernel()?.dofs().node)).collect::<Result<Vec<_>>>()?;
let element_dofs = mesh
.elements
.iter()
.zip(&element_kinds)
.map(|(e, kinds)| {
mesh.layout.element_dofs(&e.nodes, *kinds).ok_or_else(|| Error::Internal(format!("element {} degrees of freedom", e.label)))
})
.collect::<Result<Vec<_>>>()?;
let mut problem = Problem {
mesh: &mesh,
red: &red,
dead,
followers: follower_loads,
springs,
finite: settings.nonlinear_geometry,
element_dofs,
element_kinds,
materials: mesh
.elements
.iter()
.map(|e| match &e.plastic {
Some(j2) => MaterialModel::Plastic(j2.clone()),
None => MaterialModel::from_elasticity(&e.d, e.hyperelastic.as_ref()),
})
.collect(),
field,
pattern: graph.zero_matrix(&mesh.layout),
};
// Elements on their linear stiffness take the thermal strain as the equivalent nodal
// forces, a dead load proportional to the load factor like the temperatures.
if let Some(field) = problem.field.take() {
let linear: Vec<bool> = (0..mesh.elements.len()).map(|i| problem.kinematics(i).is_none()).collect();
add_element_thermal_loads(&mut problem.dead, &mesh, &field, |i| linear.get(i).copied().unwrap_or(false))?;
problem.field = Some(field);
}
let (lo, hi) = mesh.xyz.iter().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(lo, hi), p| {
(std::array::from_fn(|k| lo[k].min(p[k])), std::array::from_fn(|k| hi[k].max(p[k])))
});
let length = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::<f64>().sqrt().max(f64::MIN_POSITIVE);
let committed = mesh
.elements
.iter()
.zip(&problem.materials)
.map(|(e, m)| Ok(vec![0.0; e.kernel()?.rule().points().len() * m.history_len()]))
.collect::<Result<Vec<_>>>()?;
// The undeformed state first: an element that is bad before anything moves is an error in
// the model, not a reason to cut increments back.
problem.evaluate(&vec![0.0; mesh.dofs()], 0.0, false, &committed)?;
let path_dependent = problem.materials.iter().any(MaterialModel::is_path_dependent);
let mut run = Run {
problem: &problem,
settings,
limits,
force_scale: 0.0,
force_floor: None,
length,
iterations: 0,
factor_entries: 0,
committed,
path_dependent,
undeformed_stable: None,
};
let mut history = History::default();
let (mut frame_values, mut frames_full) = (0usize, false);
let values = 3 * mesh.labels.len();
let mut keep_frame = |history: &mut History, lambda: f64, u: &[f64], warnings: &mut Vec<String>| {
if frames_full {
return;
}
if frame_values.saturating_add(values) > MAX_FRAME_VALUES {
warnings.push(format!("the displacement frames stop at load factor {lambda}: the step keeps at most {MAX_FRAME_VALUES} values"));
frames_full = true;
return;
}
frame_values += values;
history.frames.push(Frame { load_factor: lambda, displacement: split(u, &mesh).0 });
};
keep_frame(&mut history, 0.0, &vec![0.0; mesh.dofs()], &mut warnings);
let mut q = vec![0.0; red.neq()];
// The step fraction t and the load factor λ(t) of the step's load path (λ = t without one).
let (mut t, mut lambda) = (0.0f64, 0.0f64);
let mut size = settings.initial_increment;
let mut fast = 0usize;
let mut last: Option<Evaluation> = None;
// The last converged increment's free motion and the change of the load factor over it.
let mut previous: Option<(Vec<f64>, f64)> = None;
let max_increments = usize::try_from(settings.max_increments).unwrap_or(usize::MAX);
while t < 1.0 {
if history.increments.len() >= max_increments {
return Err(Error::Limit(format!(
"step `{}`: {} increments reached the step's cap at load factor {lambda:.6} (raise max_increments)",
step.name, settings.max_increments
)));
}
// An increment ends at the next point of the load path at the latest.
let turn = settings.next_turn(t);
let remaining = turn - t;
let (target, this) = if size >= remaining * (1.0 - 1e-9) { (turn, remaining) } else { (t + size, size) };
let target_lambda = settings.load_factor(target);
let change = if settings.load_path.is_some() { target_lambda - lambda } else { this };
let mut iterations = Vec::new();
let number = history.increments.len() + 1;
// Linear extrapolation of the last converged increment's free motion, in proportion to
// the change of the load factor (so it turns with the load path).
let mut predictor: Option<Vec<f64>> = previous
.as_ref()
.filter(|(_, prev)| *prev != 0.0)
.map(|(dq, prev): &(Vec<f64>, f64)| q.iter().zip(dq).map(|(a, d)| a + change / prev * d).collect());
// Path-dependent materials start without one from the tangent predictor.
let mut predicted_energy = None;
if predictor.is_none() && path_dependent {
match run.tangent_predictor(&q, lambda, target_lambda) {
Ok(Some((p, energy))) => (predictor, predicted_energy) = (Some(p), Some(energy)),
Ok(None) | Err(Failure::Cutback(_)) => {}
Err(Failure::Fatal(e)) => return Err(e),
}
}
match run.increment(&q, predictor.as_deref(), predicted_energy, target_lambda, change.abs(), &mut iterations) {
Ok(c) => {
previous = Some((c.q.iter().zip(&q).map(|(a, b)| a - b).collect(), change));
let count = iterations.len();
history.increments.push(Increment { number, load_factor: target_lambda, size: this, iterations, converged: true, cutback: None });
(t, lambda) = (target, target_lambda);
q = c.q;
keep_frame(&mut history, lambda, &red.expand_scaled(&q, lambda), &mut warnings);
run.committed.clone_from(&c.evaluation.history);
last = Some(c.evaluation);
fast = if count <= FAST_ITERATIONS { fast + 1 } else { 0 };
if fast >= 2 {
size *= GROWTH;
} else if count > SLOW_ITERATIONS {
size *= SHRINK;
}
size = size.clamp(settings.min_increment, settings.max_increment);
}
Err(Failure::Fatal(e)) => return Err(e),
Err(Failure::Cutback(reason)) => {
history.increments.push(Increment {
number,
load_factor: target_lambda,
size: this,
iterations,
converged: false,
cutback: Some(reason.clone()),
});
fast = 0;
size = this * CUTBACK;
if size < settings.min_increment * (1.0 - 1e-9) {
return Err(Error::NotConverged(format!(
"step `{}` stopped at load factor {lambda:.6} (step fraction {t:.6}): the increment to {target_lambda:.6} failed ({reason}) and a smaller one would be below the minimum increment {}",
step.name, settings.min_increment
)));
}
}
}
}
history.iterations = run.iterations;
let u = red.expand_scaled(&q, lambda);
let evaluation = match last {
Some(e) => e,
None => problem.evaluate(&u, lambda, false, &run.committed)?,
};
if let Some(a) = problem.follower_asymmetry(&u, lambda)?
&& a > 1e-6
{
warnings.push(format!(
"follower pressure: the skew part of its load stiffness ({a:.1e} of it, over the free degrees of freedom) is left out of the tangent; the solution is exact, Newton may converge more slowly"
));
}
finish(&mesh, step, &red, &problem, u, lambda, evaluation, &run.committed, history, run.factor_entries, warnings)
}
/// The final state as a static result: displacements, reactions, Cauchy stresses and
/// logarithmic strains.
#[allow(clippy::too_many_arguments)]
fn finish(
mesh: &Mesh,
step: &Step,
red: &Reduction,
problem: &Problem,
u: Vec<f64>,
lambda: f64,
e: Evaluation,
committed: &[Vec<f64>],
history: History,
factor_entries: usize,
mut warnings: Vec<String>,
) -> Result<StaticResult> {
let (displacement, rotation, point_motion) = split(&u, mesh);
let out_of_balance: Vec<f64> = e.internal.iter().zip(&e.external).map(|(a, b)| a - b).collect();
let reactions = red.reactions_of(&out_of_balance);
let (reaction, reaction_moment, point_reaction) = split(&reactions, mesh);
let (mut applied_total, mut reaction_total) = ([0.0; 3], [0.0; 3]);
for (dof, (fv, rv)) in e.external.iter().zip(&reactions).enumerate() {
let Some(d) = mesh.kind(dof).and_then(DofKind::direction) else { continue };
if let (Some(a), Some(r)) = (applied_total.get_mut(d), reaction_total.get_mut(d)) {
*a += fv;
*r += rv;
}
}
let reference_points = mesh
.points
.iter()
.zip(point_motion.iter().zip(&point_reaction))
.map(|(p, (d, r))| PointResult { name: p.name.clone(), label: p.label, displacement: *d, reaction: *r })
.collect();
#[allow(clippy::type_complexity)]
let all_points = parallel::map_grained(mesh.elements.len(), GRAIN, |i| -> Result<(Vec<PointStress>, Vec<f64>, &'static ElementType, Rule)> {
let el = mesh.elements.get(i).ok_or_else(|| Error::Internal("element index".into()))?;
let at = |err| Error::element(el.label, err);
let ue = problem.element_vector(i, &u);
let kernel = el.kernel()?;
let history = committed.get(i).map(Vec::as_slice).unwrap_or_default();
let material = problem.materials.get(i).ok_or_else(|| Error::Internal("element material".into()))?;
let points = match problem.kinematics(i) {
Some(kin) => kernel.nonlinear_stresses(material, &ue, kin, problem.temperature(i, lambda).as_ref(), history),
None => {
let per_node: Vec<[f64; 3]> = el.nodes.iter().map(|n| displacement.get(*n).copied().unwrap_or_default()).collect();
let thermal = problem.field.as_deref().and_then(|f| el.thermal_strain(f)).map(|mut t| {
t.delta.iter_mut().for_each(|d| *d *= lambda);
t
});
kernel.stresses_with(&el.d, &per_node, thermal.as_ref())
}
}
.map_err(at)?;
let len = material.history_len();
let plastic: Vec<f64> = if len > 0 { history.chunks(len).filter_map(|h| material.plastic_strain(h)).collect() } else { Vec::new() };
Ok((points, plastic, kernel.ty(), kernel.rule()))
});
let mut extrapolations: Vec<(&'static ElementType, Rule, Extrapolation)> = Vec::new();
let mut elements = Vec::with_capacity(mesh.elements.len());
let mut sum = vec![[0.0; 6]; mesh.labels.len()];
let mut count = vec![0u32; mesh.labels.len()];
for (el, computed) in mesh.elements.iter().zip(all_points) {
let (points, plastic_strain, ty, rule) = computed?;
let values: Vec<[f64; 6]> = points.iter().map(|p| p.stress).collect();
let index = match extrapolations.iter().position(|(t, r, _)| std::ptr::eq(*t, ty) && *r == rule) {
Some(i) => i,
None => {
extrapolations.push((ty, rule, extrapolation(ty, rule).map_err(|err| Error::element(el.label, err))?));
extrapolations.len() - 1
}
};
let nodal = extrapolations.get(index).map(|(_, _, x)| x.apply(&values)).unwrap_or_default();
for (n, s) in el.nodes.iter().zip(&nodal) {
if let (Some(acc), Some(c)) = (sum.get_mut(*n), count.get_mut(*n)) {
for k in 0..6 {
acc[k] += s[k];
}
*c += 1;
}
}
elements.push(ElementStress { label: el.label, kind: el.kind, points, nodal, plastic_strain });
}
let stress: Vec<[f64; 6]> = sum.iter().zip(&count).map(|(s, c)| s.map(|v| if *c > 0 { v / f64::from(*c) } else { 0.0 })).collect();
let von_mises = stress.iter().map(von_mises).collect();
let scale = applied_total.iter().chain(&reaction_total).fold(0.0f64, |m, v| m.max(v.abs()));
let ground = mesh.discrete.ground_force(&u, mesh);
let imbalance = (0..3).map(|c| (applied_total[c] + reaction_total[c] + ground[c]).abs()).fold(0.0, f64::max);
if scale > 0.0 && imbalance > 1e-6 * scale {
warnings.push(format!("loads and reactions differ by {imbalance:.3e} (relative {:.1e})", imbalance / scale));
}
Ok(StaticResult {
step: step.name.clone(),
nodes: mesh.labels.clone(),
displacement,
rotation,
reaction,
reaction_moment,
stress,
von_mises,
elements,
shells: Vec::new(),
beams: Vec::new(),
reference_points,
applied_total,
reaction_total,
stats: Stats { dofs: mesh.dofs(), equations: red.neq(), factor_entries, lanczos_vectors: 0 },
warnings,
temperature: None,
nonlinear: Some(history),
})
}