explicit: opt-in mass scaling to a target increment, the added mass reported

An explicit step may ask for mass scaling to a target time increment (on
a set, or the whole model): elements whose stable increment is below it
get the density that reaches it, with their lumped masses, frequency
bound and hourglass scales; adiabatic heating keeps the physical
density. The run reports the elements scaled, the added mass in all and
per section with its fraction, a warning, and the added mass's kinetic
energy in the energy account. Verified on a uniform bar scaled to twice
its increment (added mass 3×, increment doubled, a quarter of the
kinetic energy left to the physical mass). ROADMAP and parity note the
hyperelastic materials and mass scaling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 14:29:43 -04:00
1 parent ce8e23d05b
commit bc75fd08d1
14 files changed
+249 -14

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (the model's contact definitions, `interaction.contact.*` with `find`, the deck mapping, `fieldcraft_fem::contact`'s closest-point projection and bucket grid, and the explicit solver's segmentation and friction return map are in place to reuse; the implicit solver refuses contact by name until then); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | planned |
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (X3: done — `interaction.contact.pair`/`general`/`find`, segment-to-segment penalty contact both ways with polygon clipping (passes the contact patch test on non-matching meshes), pairs, general (all exterior faces) and self contact, bucket-grid search, deterministic on any thread count, a penalty from the masses and the stable increment that never shortens it, Coulomb friction with exponential decay by a return map, friction on rigid walls, contact energy and friction dissipation in the balance, initial penetrations adjusted or refused; Abaqus `*CONTACT PAIR`/`*CONTACT`/`*FRICTION` and LS-DYNA automatic contacts in the decks; verified by colliding bars, sliding blocks, the patch test and Hertz's sphere impact); hyperelastic materials on the shared material point and mass scaling next; runs on hex and tet meshes | started |
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (X3: done — `interaction.contact.pair`/`general`/`find`, segment-to-segment penalty contact both ways with polygon clipping (passes the contact patch test on non-matching meshes), pairs, general (all exterior faces) and self contact, bucket-grid search, deterministic on any thread count, a penalty from the masses and the stable increment that never shortens it, Coulomb friction with exponential decay by a return map, friction on rigid walls, contact energy and friction dissipation in the balance, initial penetrations adjusted or refused; Abaqus `*CONTACT PAIR`/`*CONTACT`/`*FRICTION` and LS-DYNA automatic contacts in the decks; verified by colliding bars, sliding blocks, the patch test and Hertz's sphere impact); hyperelastic materials (neo-Hookean, Mooney–Rivlin, Yeoh) on the shared material point with average nodal pressure tets, and opt-in mass scaling with the added mass reported: done; runs on hex and tet meshes | started |
| M11 Equations of state and shells | Polynomial, ideal-gas, Mie–Grüneisen, JWL with programmed burn, tabulated; energy coupling; shock verification (Rankine–Hugoniot, Sod, Noh, Sedov); Belytschko–Lin–Tsay shells with self-contact on quad meshes | planned |
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started |
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
+1 -1
View File
@@ -292,7 +292,7 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
pub const STEP_CREATE: CommandSpec = CommandSpec {
id: "model.step.create",
label: "New Step",
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
form: Some(super::forms::STEP_CREATE),
kind: CommandKind::Model,
run: step_create,
+5
View File
@@ -505,6 +505,11 @@ const PROCEDURE: Field = variants(
group("bulk_viscosity", "Bulk viscosity", &[positive("linear", "Linear", D::None), positive("quadratic", "Quadratic", D::None)]),
flag("nonlinear_geometry", "Nonlinear geometry").def("true"),
choice("stress_rate", "Stress rate", &[("jaumann", "Jaumann"), ("green_naghdi", "Green–Naghdi")]),
group(
"mass_scaling",
"Mass scaling",
&[positive("target_increment", "Target increment", D::Time).req(), reference("set", "Set", C::Set)],
),
],
),
variant(
+1
View File
@@ -355,6 +355,7 @@ fn explicit_summary(r: &ExplicitResult) -> Value {
"history_samples": r.history.time.len(),
"eroded": r.eroded,
"contacts": r.contacts,
"mass_scaling": r.mass_scaling,
"warnings": r.warnings,
})
}
+6 -3
View File
@@ -547,6 +547,8 @@ fn hexahedron(sys: &System, h: &crate::system::Hex, m: &Motion, es: &mut Element
let (d, _) = rotation::split(&g);
let d = mechanical(d, h.thermal_rate * m.dt);
let (rho, c) = current(mat, h.kinematics.volume, now.volume);
// Mass scaling raises the density the dynamics see (not the one adiabatic heating uses).
let (rho_s, c_s) = (rho * h.mass_factor, c / h.mass_factor.sqrt());
let stiffer = match &mat.hyperelastic {
Some(model) => {
let mut f = h.kinematics.gradient(&e.u);
@@ -568,7 +570,7 @@ fn hexahedron(sys: &System, h: &crate::system::Hex, m: &Motion, es: &mut Element
// geometry).
let (gamma, scales) = if sys.nonlinear {
let gamma = now.hourglass_vectors(&x_now);
let scales = hex_scales(&now, &gamma, &h.mass, mat, rho, hourglass);
let scales = hex_scales(&now, &gamma, &h.mass, mat, rho_s, hourglass);
(gamma, scales)
} else {
(h.gamma, h.rest)
@@ -593,7 +595,7 @@ fn hexahedron(sys: &System, h: &crate::system::Hex, m: &Motion, es: &mut Element
}
}
}
let element = Element { now: &now, mid: &mid, density: rho, wave_speed: c, length: 2.0 * c / (stiffer * scales.omega2).sqrt() };
let element = Element { now: &now, mid: &mid, density: rho_s, wave_speed: c_s, length: 2.0 * c_s / (stiffer * scales.omega2).sqrt() };
let mut forces = element.finish(&es.point.stress, &e.v, m.bulk);
forces.hourglass = Some(f);
Ok(Outcome::Forces(forces))
@@ -632,6 +634,7 @@ fn tetrahedron(
let thermal = t.thermal_rate * m.dt;
let d = mechanical(d, thermal);
let (rho, c) = current(mat, t.kinematics.volume, inc.now.volume);
let (rho_s, c_s) = (rho * t.mass_factor, c / t.mass_factor.sqrt());
let stiffer = match &mat.hyperelastic {
// The average nodal pressure of a decoupled material (Bonet & Burton 1998, at finite
// strain): each node's pressure U′(J_a) from its tetrahedra's volume ratio, the element's
@@ -660,7 +663,7 @@ fn tetrahedron(
return Ok(Outcome::Eroded(failure));
}
let omega2 = stiffer * if sys.nonlinear { frequency_bound(&inc.now, inc.now.gradient_norm(), mat.modulus, &t.mass) } else { t.omega2 };
let element = Element { now: &inc.now, mid: &inc.mid, density: rho, wave_speed: c, length: 2.0 * c / omega2.sqrt() };
let element = Element { now: &inc.now, mid: &inc.mid, density: rho_s, wave_speed: c_s, length: 2.0 * c_s / omega2.sqrt() };
Ok(Outcome::Forces(element.finish(&es.point.stress, &inc.v, m.bulk)))
}
+13 -4
View File
@@ -201,10 +201,14 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
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) })
.map(|h| (h.label, h.length, h.material, h.mass_factor))
.chain(sys.tets.iter().map(|t| (t.label, t.length, t.material, t.mass_factor)))
.chain(sys.rods.iter().map(|r| (r.label, r.length, r.material, 1.0)))
.map(|(label, length, m, f)| ControllingElement {
label,
// Mass scaling slows the element's waves by √f.
increment: length * f.sqrt() / 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);
@@ -411,6 +415,10 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
energy.external += external;
energy.constraint += dt_n * constraint;
energy.kinetic = 0.5 * kinetic;
if !sys.added_mass.is_empty() {
let per_node = v_now.as_chunks::<NODE_DOFS>().0.iter().zip(&sys.added_mass);
energy.added_kinetic = 0.5 * per_node.map(|(v, m)| m * (v[0] * v[0] + v[1] * v[1] + v[2] * v[2])).sum::<f64>();
}
if !ws.contact_force.is_empty() {
let work: f64 = du.iter().zip(ws.contact_force.iter().zip(&previous.contact_force)).map(|(d, (c, c0))| d * (c0 + c)).sum();
energy.contact -= 0.5 * work;
@@ -483,6 +491,7 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
initial_kinetic,
eroded,
contacts: summaries,
mass_scaling: sys.mass_scaling.clone(),
warnings,
})
}
+1 -1
View File
@@ -46,7 +46,7 @@ pub mod system;
pub use elements::Failure;
pub use integrate::solve_explicit;
pub use result::{ContactSummary, ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
pub use result::{ContactSummary, ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History, MassScalingSummary};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum Error {
+17
View File
@@ -25,6 +25,8 @@ pub struct Energies {
/// Energy taken by contact: the work the model does against the contact forces (the penalty
/// energy stored in the contact springs, plus what friction dissipates there).
pub contact: f64,
/// The kinetic energy of the mass added by mass scaling (part of `kinetic`).
pub added_kinetic: f64,
/// Friction dissipation, in contact and on rigid walls (part of `contact` and of
/// `constraint`).
pub friction: f64,
@@ -106,6 +108,19 @@ pub struct History {
pub velocity: Vec<Vec<[f64; 3]>>,
}
/// What mass scaling added to a run.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct MassScalingSummary {
pub target_increment: f64,
/// Elements whose density was raised.
pub elements: usize,
pub added_mass: f64,
/// The added mass over the model's mass (with it).
pub fraction: f64,
/// The added mass per section, by name.
pub sections: Vec<(String, f64)>,
}
/// What a contact did over the run.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct ContactSummary {
@@ -158,5 +173,7 @@ pub struct ExplicitResult {
pub eroded: Vec<ErodedElement>,
/// Per contact of the model, in name order.
pub contacts: Vec<ContactSummary>,
/// What mass scaling added, when the step asks for it.
pub mass_scaling: Option<MassScalingSummary>,
pub warnings: Vec<String>,
}
+110 -1
View File
@@ -68,6 +68,8 @@ pub struct Hex {
pub length: f64,
/// The rate of its thermal strain, α ΔT / end time (every normal direction).
pub thermal_rate: f64,
/// Its density's factor from mass scaling (1 without).
pub mass_factor: f64,
}
/// A nodal-pressure tetrahedron.
@@ -84,6 +86,8 @@ pub struct Tet {
pub length: f64,
/// The rate of its thermal strain, α ΔT / end time (every normal direction).
pub thermal_rate: f64,
/// Its density's factor from mass scaling (1 without).
pub mass_factor: f64,
}
/// A fixed rigid plane.
@@ -120,6 +124,9 @@ pub struct System {
/// The springs' and dashpots' stable increment on the lumped mass, and a link that sets it,
/// when there are any.
pub discrete_increment: Option<(f64, String)>,
/// What mass scaling added: per node, and per section (in name order).
pub added_mass: Vec<f64>,
pub mass_scaling: Option<crate::result::MassScalingSummary>,
/// Prescribed velocity per degree of freedom (`None` = free).
pub prescribed: Vec<Option<f64>>,
pub walls: Vec<Wall>,
@@ -277,6 +284,8 @@ impl System {
tets: Vec::new(),
rods: Vec::new(),
mass: vec![0.0; n],
added_mass: Vec::new(),
mass_scaling: None,
discrete: Discrete::default(),
discrete_increment: None,
prescribed: vec![None; dofs],
@@ -322,7 +331,19 @@ impl System {
let rest = hex_scales(&k, &gamma, &mass, mat, rho, settings.hourglass);
sys.lump(&nodes, &mass);
let length = 2.0 * c / rest.omega2.sqrt();
sys.hexes.push(Hex { label, index, nodes, material: mi, kinematics: k, gamma, mass, rest, length, thermal_rate: 0.0 });
sys.hexes.push(Hex {
label,
index,
nodes,
material: mi,
kinematics: k,
gamma,
mass,
rest,
length,
thermal_rate: 0.0,
mass_factor: 1.0,
});
}
ElementKind::Tet4 => {
let nodes: [usize; 4] = idx.try_into().map_err(|_| invalid(format!("element {label} does not have 4 nodes")))?;
@@ -341,6 +362,7 @@ impl System {
omega2,
length: 2.0 * c / omega2.sqrt(),
thermal_rate: 0.0,
mass_factor: 1.0,
});
}
ElementKind::Rod2 | ElementKind::Rod3 => {
@@ -369,6 +391,9 @@ impl System {
other => return Err(Error::Internal(format!("{} element kind", other.name()))),
}
}
if let Some(scaling) = &settings.mass_scaling {
sys.scale_mass(model, resolved, scaling, settings)?;
}
sys.discrete = Discrete::new(resolved, sys.dofs(), &sys.xyz, |l| sys.node(l))?;
sys.discrete_increment = sys.discrete.critical_increment(&sys.dof_mass(), sys.nonlinear);
sys.prescribe(model, resolved, step, settings.end_time)?;
@@ -379,6 +404,90 @@ impl System {
Ok(sys)
}
/// Mass scaling to a target increment (see [`fieldcraft_model::MassScaling`]): each solid
/// element (of the set) whose stable increment times the step's scale is below the target
/// gets its density times (target / increment)², its lumped masses and frequency bound with
/// it; the added mass is kept per node and per section.
fn scale_mass(&mut self, model: &Model, resolved: &Resolved, scaling: &fieldcraft_model::MassScaling, settings: &ExplicitDynamic) -> Result<()> {
let chosen: Option<&[u32]> = match &scaling.set {
Some(s) => Some(resolved.sets.get(s).map(|r| r.elements.as_slice()).ok_or_else(|| invalid(format!("set `{s}` is not resolved")))?),
None => None,
};
let wanted = |label: u32| chosen.is_none_or(|l| l.binary_search(&label).is_ok());
let factor = |length: f64, material: usize, materials: &[Material]| -> f64 {
let c = materials.get(material).map_or(f64::INFINITY, |m| m.wave_speed);
let dt = settings.dt_scale * length / c;
if dt > 0.0 && dt < scaling.target_increment { (scaling.target_increment / dt).powi(2) } else { 1.0 }
};
let hourglass = fieldcraft_model::Hourglass { kind: settings.hourglass.kind, coefficient: settings.hourglass.coefficient };
let mut added = vec![0.0; self.labels.len()];
let mut by_element: Vec<(u32, f64)> = Vec::new();
for h in self.hexes.iter_mut().filter(|h| wanted(h.label)) {
let f = factor(h.length, h.material, &self.materials);
if f == 1.0 {
continue;
}
let extra = h.mass.map(|m| m * (f - 1.0));
for (n, x) in h.nodes.iter().zip(extra) {
if let Some(a) = added.get_mut(*n) {
*a += x;
}
}
h.mass = h.mass.map(|m| m * f);
h.mass_factor = f;
if let Some(mat) = self.materials.get(h.material) {
h.rest = hex_scales(&h.kinematics, &h.gamma, &h.mass, mat, mat.density * f, hourglass);
}
by_element.push((h.label, extra.iter().sum()));
}
for t in self.tets.iter_mut().filter(|t| wanted(t.label)) {
let f = factor(t.length, t.material, &self.materials);
if f == 1.0 {
continue;
}
let extra = t.mass.map(|m| m * (f - 1.0));
for (n, x) in t.nodes.iter().zip(extra) {
if let Some(a) = added.get_mut(*n) {
*a += x;
}
}
t.mass = t.mass.map(|m| m * f);
t.omega2 /= f;
t.mass_factor = f;
by_element.push((t.label, extra.iter().sum()));
}
for (m, a) in self.mass.iter_mut().zip(&added) {
*m += a;
}
let total_added: f64 = added.iter().sum();
let total: f64 = self.mass.iter().sum();
let mut sections: Vec<(String, f64)> = Vec::new();
for (name, section) in model.sections() {
let elements = resolved.sets.get(&section.set).map(|r| r.elements.as_slice()).unwrap_or_default();
let mass: f64 = by_element.iter().filter(|(l, _)| elements.binary_search(l).is_ok()).map(|(_, m)| m).sum();
if mass > 0.0 {
sections.push((name.clone(), mass));
}
}
if total_added > 0.0 {
self.warnings.push(format!(
"mass scaling to an increment of {:.3e} added {total_added:.4e} of mass ({:.2}% of the model's) to {} elements",
scaling.target_increment,
100.0 * total_added / total.max(f64::MIN_POSITIVE),
by_element.len()
));
}
self.mass_scaling = Some(crate::result::MassScalingSummary {
target_increment: scaling.target_increment,
elements: by_element.len(),
added_mass: total_added,
fraction: total_added / total.max(f64::MIN_POSITIVE),
sections,
});
self.added_mass = added;
Ok(())
}
/// Refuse what hyperelastic elements cannot take yet: small-displacement steps (the
/// material is a finite-strain law) and thermal strain.
fn check_hyperelastic(&self) -> Result<()> {
+52
View File
@@ -305,3 +305,55 @@ fn whirling_mass_on_a_corotational_rod() {
// A quarter turn: the mass ends near (0, L).
assert!((l + end[0]).abs() < 1e-2 && (end[1] - l).abs() < 1e-2, "{end:?}");
}
/// Mass scaling to twice a uniform bar's stable increment quadruples its density: the added mass
/// is three times the bar's, the stable increment doubles with the halved wave speed
/// c = √(E/ρ), and the kinetic energy of the added mass is three quarters of the total.
#[test]
fn mass_scaling_slows_the_waves_as_the_added_density_says() {
use fieldcraft_explicit::{Limits, solve_explicit};
use fieldcraft_model::*;
let build = |scaled: Option<f64>| {
let mut m = Model::default();
let n = 40;
for i in 0..=n {
for (j, (y, z)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].iter().enumerate() {
m.set_node((4 * i + j + 1) as u32, [i as f64, *y, *z]).unwrap();
}
}
for i in 0..n {
let a = |j: usize| (4 * i + j + 1) as u32;
let b = |j: usize| (4 * (i + 1) + j + 1) as u32;
m.set_element(i as u32 + 1, ElementKind::Hex8, vec![a(0), b(0), b(1), a(1), a(3), b(3), b(2), a(2)]).unwrap();
}
m.add_set("all", Set { elements: (1..=n as u32).collect(), ..Set::default() }).unwrap();
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: 1.0, poisson: 0.0 }), density: Some(1.0), ..Material::default() })
.unwrap();
let formulation = Formulation { integration: Integration::Reduced, ..Formulation::default() };
m.add_section("bar", Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
let mut step = ExplicitDynamic::new(10.0);
step.initial_velocities = vec![InitialVelocity { set: "all".into(), velocity: [0.01, 0.0, 0.0] }];
step.mass_scaling = scaled.map(|t| MassScaling { target_increment: t, set: None });
m.add_step(Step::new("Run", Procedure::ExplicitDynamic(step)), None).unwrap();
solve_explicit(&m, None, &Limits::default()).unwrap()
};
let plain = build(None);
let scaled = build(Some(2.0 * plain.initial_increment));
let s = scaled.mass_scaling.as_ref().unwrap();
eprintln!(
"mass scaling: {s:?}; increments {} vs {}; Δt {:.4e} vs {:.4e}",
scaled.increments, plain.increments, scaled.initial_increment, plain.initial_increment
);
assert_eq!(s.elements, 40);
assert!((s.added_mass / plain.total_mass - 3.0).abs() < 1e-9 && (s.fraction - 0.75).abs() < 1e-9, "{s:?}");
assert_eq!(s.sections.len(), 1);
assert!((scaled.initial_increment / plain.initial_increment - 2.0).abs() < 1e-9);
assert!((scaled.total_mass / plain.total_mass - 4.0).abs() < 1e-9);
assert!((scaled.energy.added_kinetic / scaled.energy.kinetic - 0.75).abs() < 1e-9, "{:?}", scaled.energy);
assert!(scaled.warnings.iter().any(|w| w.contains("mass scaling")));
assert!(
scaled.controlling.first().is_some_and(|c| (0.9 * c.increment / scaled.initial_increment - 1.0).abs() < 1e-6),
"{:?}",
scaled.controlling
);
}
+2 -2
View File
@@ -68,8 +68,8 @@ pub use sections::{BeamProfile, Extent, Formulation, Integration, Section, Secti
pub use sets::Set;
pub use steps::{
ArcConstraint, ArcLength, BulkViscosity, DisplacementLimit, ExplicitDynamic, Hourglass, HourglassKind, Imperfection, InitialVelocity,
MAX_LOAD_PATH_POINTS, MAX_NONLINEAR_INCREMENTS, MAX_NONLINEAR_ITERATIONS, NonlinearStatic, OutputVariable, Procedure, RigidWall, Step,
StressRate, Tolerances, Transient,
MAX_LOAD_PATH_POINTS, MAX_NONLINEAR_INCREMENTS, MAX_NONLINEAR_ITERATIONS, MassScaling, NonlinearStatic, OutputVariable, Procedure, RigidWall,
Step, StressRate, Tolerances, Transient,
};
pub use units::UnitSystem;
+27
View File
@@ -425,6 +425,22 @@ pub struct ExplicitDynamic {
/// The objective stress rate of large deformation.
#[serde(default)]
pub stress_rate: StressRate,
/// Opt-in mass scaling: the elements whose stable increment is below a target get the
/// density that brings them to it (the added mass is reported).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mass_scaling: Option<MassScaling>,
}
/// Mass scaling to a target time increment: every element (of `set`, or of the whole model) whose
/// stable increment, times the step's scale, is below `target_increment` has its density raised
/// by (target / increment)², which brings it to the target. It changes the dynamics: the added
/// mass is reported, per section, with its share of the kinetic energy.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MassScaling {
pub target_increment: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub set: Option<String>,
}
fn yes() -> bool {
@@ -467,6 +483,7 @@ impl ExplicitDynamic {
bulk_viscosity: BulkViscosity::default(),
nonlinear_geometry: true,
stress_rate: StressRate::default(),
mass_scaling: None,
}
}
@@ -487,6 +504,7 @@ impl ExplicitDynamic {
.map(String::as_str)
.chain(self.initial_velocities.iter().map(|v| v.set.as_str()))
.chain(self.rigid_walls.iter().map(|w| w.set.as_str()))
.chain(self.mass_scaling.iter().filter_map(|m| m.set.as_deref()))
}
pub fn validate(&self) -> Result<()> {
@@ -524,6 +542,12 @@ impl ExplicitDynamic {
}
w.friction.validate().map_err(|e| Error::Invalid(format!("rigid wall on set `{}`: {e}", w.set)))?;
}
if let Some(m) = &self.mass_scaling {
positive("mass scaling target increment", m.target_increment)?;
if m.target_increment > end {
return Err(Error::Invalid(format!("the mass scaling target increment {} is longer than the step", m.target_increment)));
}
}
self.hourglass.validate()?;
self.bulk_viscosity.validate()
}
@@ -1012,6 +1036,9 @@ mod tests {
},
ExplicitDynamic { hourglass: Hourglass { kind: HourglassKind::Viscous, coefficient: 2.0 }, ..base.clone() },
ExplicitDynamic { bulk_viscosity: BulkViscosity { linear: -1.0, quadratic: 1.0 }, ..base.clone() },
ExplicitDynamic { mass_scaling: Some(MassScaling { target_increment: 0.0, set: None }), ..base.clone() },
ExplicitDynamic { mass_scaling: Some(MassScaling { target_increment: f64::NAN, set: None }), ..base.clone() },
ExplicitDynamic { mass_scaling: Some(MassScaling { target_increment: 2.0, set: None }), ..base.clone() },
];
for b in bad {
assert!(Procedure::ExplicitDynamic(b.clone()).validate().is_err(), "{b:?}");
+12
View File
@@ -468,3 +468,15 @@ geometry and take no thermal strain yet (both refused by name). Verified by unia
λ = 1.5 of all three laws on hex8 and tet4 against the material point (`crates/explicit/tests/hyperelastic.rs`)
and by `tests/oracle/explicit-neo-hookean-implicit`, a quasi-static explicit pull against the
implicit nonlinear solver.
## Mass scaling (explicit dynamics)
Opt-in per explicit step (`mass_scaling: {target_increment, set}`): every solid element (of the
set, or of the model) whose stable increment times the step's scale is below the target has its
density raised by (target / increment)², which brings its increment to the target (its lumped
masses, frequency bound and hourglass and bulk-viscosity scales follow; adiabatic heating keeps
the physical density). The run reports the elements scaled, the added mass in all and per
section, its fraction of the model's mass, a warning, and the kinetic energy of the added mass
(`energy.added_kinetic`, part of the kinetic energy, so the balance holds). Verified on a uniform
bar scaled to twice its increment: added mass three times the bar's, the increment doubled, the
added mass's share of the kinetic energy three quarters (`crates/explicit/tests/one_d.rs`).
+1 -1
View File
@@ -64,7 +64,7 @@ products are run, no documentation text is copied). Status: **done**, **started*
| Decks | Readers keeping unknown keywords | M9 | | planned |
| Solve | Own linear static and modal solver, verified against analytic solutions | M4 | yes | started (solid elements: tet4, tet10, wedge6, hex8, hex20; 2D plane stress, plane strain and axisymmetric tri3, tri6, quad4 (full, incompatible modes), quad8 (full, reduced); quad4/tri3 MITC shells and Timoshenko beams with profiles, mixed with solids; reference points, kinematic and distributing couplings, moments; centrifugal loads) |
| Solve | Own nonlinear implicit solver: Newton–Raphson with line search and automatic increments, total Lagrangian solids and 2D elements, mixed u–p at finite strain, follower pressure | M15 | yes | started (solids, plane strain, axisymmetric; St Venant–Kirchhoff, neo-Hookean and J2 plasticity (Simo's exponential return map at finite strain, radial return at small strain, consistent tangents, the return shared with explicit dynamics); verified by the elastica, tube inflation, patch and objectivity tests, uniaxial tension to large strain, Hill's thick cylinder, a cantilever's collapse and the Bauschinger effect; Mooney–Rivlin and Yeoh; thermal strain at finite and small strain; a symmetric indefinite LDLᵀ factorization with inertia, Newton through indefinite tangents (hybrid elements at ν = 0.4999 under prescribed displacement); linear buckling of solids (Euler columns, Bryan's plate) and imperfections from its modes; arc-length with critical points from the inertia (shallow-arch snap-through); shells, implicit contact next) |
| Solve | Own explicit dynamics solver (central differences, one-point hex8 with hourglass control, nodal-pressure tet4, bulk viscosity, energy balance), verified against analytic wave and impact solutions | M10 | yes | started (X1 core and X2: large deformation, objective rates, J2 and Johnson–Cook plasticity, damage, erosion; X3: deformable contact and friction, verified by bar collision, sliding blocks, the contact patch test and Hertz) |
| Solve | Own explicit dynamics solver (central differences, one-point hex8 with hourglass control, nodal-pressure tet4, bulk viscosity, energy balance), verified against analytic wave and impact solutions | M10 | yes | started (X1 core and X2: large deformation, objective rates, J2 and Johnson–Cook plasticity, damage, erosion; X3: deformable contact and friction, verified by bar collision, sliding blocks, the contact patch test and Hertz; hyperelastic materials on the shared material point; opt-in mass scaling) |
| Results | Deformed shape, contours, mode shapes, probes, reaction sums | M4 | | started (results in the session, `solve.probe`, reaction sums; display planned) |
| Results | XY and path plots, cuts, display groups, free-body forces, animation | M9 | | planned |
| Results | Readers for public result formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`) | M9 | | planned |