Explicit dynamics core: central differences, one-point hex8 with hourglass control, nodal-pressure tet4, solve.explicit (M10 X1) (#27)

* Model: explicit dynamic steps (end time, increment scale and cap, output intervals, initial velocities, rigid walls, hourglass and bulk viscosity controls); deck writers refuse them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* fem: uniform-strain hex8 and tet4 kinematics, Flanagan–Belytschko hourglass shape vectors, element frequency bound

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Explicit solver crate (L2): central differences with lumped mass, one-point hex8 with Flanagan–Belytschko hourglass control, Bonet–Burton tet4, bulk viscosity, rigid walls, energy account; wave and impact verification

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Engine: solve.explicit and solve.history commands, explicit results in the session and solve.probe; cantilever period against the modal solver

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Docs: explicit solver in the architecture, roadmap (M10 started), parity and literature (Part H)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Simplify: Model::select_step shared by both solvers, Procedure::sets/is_dynamic, fused per-increment passes without allocation, shared element tail, generic command caps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Catalogue: solve.explicit is ready; solve.history beside solve.probe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Rebase onto the implicit couplings work: Procedure is no longer Copy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 18:28:32 -04:00
1 parent 48039639cb
commit d45a2da5b5
33 files changed
+2757 -65

No files matched your search

+27 -9
View File
@@ -244,8 +244,14 @@ impl Model {
let needs_mass = self
.steps
.iter()
.find(|s| matches!(s.procedure, Procedure::Frequency { .. }))
.map(|s| format!("step `{}` extracts frequencies", s.name))
.find_map(|s| {
let why = match s.procedure {
Procedure::Frequency { .. } => "extracts frequencies",
Procedure::ExplicitDynamic(_) => "is dynamic",
Procedure::Static {} => return None,
};
Some(format!("step `{}` {why}", s.name))
})
.or_else(|| self.loads.iter().find(|(_, l)| matches!(l, Load::Gravity { .. })).map(|(n, _)| format!("load `{n}` is gravity")));
if let Some(why) = needs_mass {
let used: BTreeSet<&str> = self.sections.values().map(|s| s.material.as_str()).collect();
@@ -340,13 +346,18 @@ impl Model {
}
for s in &self.steps {
let subject = format!("step `{}`", s.name);
match s.procedure {
let prescribes_motion = s
.boundary_conditions
.iter()
.filter_map(|b| self.boundary_conditions.get(b))
.any(|b| (1..=6).any(|dof| b.value(dof).is_some_and(|v| v != 0.0)));
for set in s.procedure.sets() {
if set_of(set).nodes.is_empty() {
d.error(&subject, format!("set `{set}` has no nodes"));
}
}
match &s.procedure {
Procedure::Static {} => {
let prescribes_motion = s
.boundary_conditions
.iter()
.filter_map(|b| self.boundary_conditions.get(b))
.any(|b| (1..=6).any(|dof| b.value(dof).is_some_and(|v| v != 0.0)));
if s.loads.is_empty() && !prescribes_motion {
d.warning(&subject, "nothing loads the model in this step");
}
@@ -356,8 +367,15 @@ impl Model {
d.warning(&subject, "loads have no effect on natural frequencies");
}
}
Procedure::ExplicitDynamic(e) => {
let moves = e.initial_velocities.iter().any(|v| v.velocity.iter().any(|c| *c != 0.0));
if s.loads.is_empty() && !prescribes_motion && !moves {
d.warning(&subject, "nothing loads or moves the model in this step");
}
}
}
if s.boundary_conditions.is_empty() {
// A free body is a natural case of transient dynamics (flight, impact).
if s.boundary_conditions.is_empty() && !s.procedure.is_dynamic() {
d.warning(&subject, "no boundary condition is active, so the model is free to move as a rigid body");
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ pub use materials::{Elastic, Material};
pub use mesh::{Element, ElementFace, ElementKind, Node, check_coordinates};
pub use sections::{BeamProfile, Formulation, Integration, Section, SectionKind};
pub use sets::Set;
pub use steps::{OutputVariable, Procedure, Step};
pub use steps::{BulkViscosity, ExplicitDynamic, Hourglass, HourglassKind, InitialVelocity, OutputVariable, Procedure, RigidWall, Step};
pub use units::UnitSystem;
/// The largest node or element label. Nastran's GRID and element ids stop at 99 999 999 (eight
+1
View File
@@ -116,6 +116,7 @@ impl Model {
users
.extend(self.boundary_conditions.iter().filter(|(_, b)| b.target().set() == Some(set)).map(|(n, _)| format!("boundary condition `{n}`")));
users.extend(self.loads.iter().filter(|(_, l)| l.set() == Some(set)).map(|(n, _)| format!("load `{n}`")));
users.extend(self.steps.iter().filter(|s| s.procedure.sets().any(|x| x == set)).map(|s| format!("step `{}`", s.name)));
users
}
+332 -2
View File
@@ -10,7 +10,7 @@ use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::{Error, Model, Result, check_name, context, finite, require};
use crate::{Error, Model, Result, check_name, context, finite, positive, require};
/// Most steps in a model.
pub const MAX_STEPS: usize = 1000;
@@ -18,8 +18,20 @@ pub const MAX_STEPS: usize = 1000;
/// Most modes a frequency step extracts.
pub const MAX_MODES: u32 = 100_000;
/// Most time increments an explicit dynamic step may ask for.
pub const MAX_INCREMENTS: u64 = 1_000_000_000;
/// Most field frames an explicit dynamic step writes (end time over output interval).
pub const MAX_FRAMES: f64 = 10_000.0;
/// Most history samples an explicit dynamic step records (end time over history interval).
pub const MAX_HISTORY_SAMPLES: f64 = 1_000_000.0;
/// Most initial velocities or rigid walls an explicit dynamic step lists.
pub const MAX_STEP_ITEMS: usize = 10_000;
/// What a step solves.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Procedure {
/// Linear static equilibrium under the active loads and boundary conditions.
@@ -33,6 +45,233 @@ pub enum Procedure {
#[serde(default, skip_serializing_if = "Option::is_none")]
max_hz: Option<f64>,
},
/// Transient dynamics by explicit time integration up to `end_time`.
ExplicitDynamic(ExplicitDynamic),
}
/// The settings of an explicit dynamic step.
///
/// Loads act in full from the start of the step; a displacement boundary condition with a value
/// is reached at constant velocity over the step (value / `end_time`), a zero value holds the
/// node. The step starts from the undeformed model at rest, apart from `initial_velocities`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExplicitDynamic {
/// Duration of the step (time units of the unit system).
pub end_time: f64,
/// Fraction of the stable time increment taken (0 < scale ≤ 1).
#[serde(default = "default_dt_scale")]
pub dt_scale: f64,
/// Cap on the time increments; the run stops early (and says so) when it is reached.
#[serde(default = "default_max_increments")]
pub max_increments: u64,
/// Time between field frames (displacement, velocity, stress); by default a twentieth of the
/// step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_interval: Option<f64>,
/// Time between history samples (energies, momentum, constraint forces and the histories of
/// `history_nodes`); by default a thousandth of the step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_interval: Option<f64>,
/// A set whose nodes' displacement and velocity are recorded as histories.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_nodes: Option<String>,
/// Velocities of the nodes of sets at the start of the step (a node in several sets takes
/// the last one listed).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub initial_velocities: Vec<InitialVelocity>,
/// Fixed, frictionless rigid planes the nodes of sets cannot pass through.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rigid_walls: Vec<RigidWall>,
/// Hourglass control of one-point hexahedra.
#[serde(default)]
pub hourglass: Hourglass,
/// Artificial bulk viscosity.
#[serde(default)]
pub bulk_viscosity: BulkViscosity,
}
fn default_dt_scale() -> f64 {
0.9
}
fn default_max_increments() -> u64 {
10_000_000
}
impl ExplicitDynamic {
/// A step of `end_time` with every other setting at its default.
pub fn new(end_time: f64) -> ExplicitDynamic {
ExplicitDynamic {
end_time,
dt_scale: default_dt_scale(),
max_increments: default_max_increments(),
output_interval: None,
history_interval: None,
history_nodes: None,
initial_velocities: Vec::new(),
rigid_walls: Vec::new(),
hourglass: Hourglass::default(),
bulk_viscosity: BulkViscosity::default(),
}
}
/// The time between field frames.
pub fn output_interval(&self) -> f64 {
self.output_interval.unwrap_or(self.end_time / 20.0)
}
/// The time between history samples.
pub fn history_interval(&self) -> f64 {
self.history_interval.unwrap_or(self.end_time / 1000.0)
}
/// The sets the step refers to.
pub fn sets(&self) -> impl Iterator<Item = &str> {
self.history_nodes
.iter()
.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()))
}
pub fn validate(&self) -> Result<()> {
let end = positive("end time", self.end_time)?;
if !(self.dt_scale > 0.0 && self.dt_scale <= 1.0) {
return Err(Error::Invalid(format!("the time increment scale is in (0, 1], got {}", self.dt_scale)));
}
if !(1..=MAX_INCREMENTS).contains(&self.max_increments) {
return Err(Error::Invalid(format!("an explicit step takes 1 to {MAX_INCREMENTS} increments, got {}", self.max_increments)));
}
for (what, interval, most) in
[("output interval", self.output_interval, MAX_FRAMES), ("history interval", self.history_interval, MAX_HISTORY_SAMPLES)]
{
if let Some(i) = interval {
positive(what, i)?;
if end / i > most {
return Err(Error::Invalid(format!("the {what} {i} gives more than {most} samples over the step")));
}
}
}
if self.initial_velocities.len() > MAX_STEP_ITEMS || self.rigid_walls.len() > MAX_STEP_ITEMS {
return Err(Error::Limit(format!("a step lists at most {MAX_STEP_ITEMS} initial velocities and rigid walls")));
}
for v in &self.initial_velocities {
for c in v.velocity {
finite("initial velocity", c)?;
}
}
for w in &self.rigid_walls {
for c in w.point.iter().chain(&w.normal) {
finite("rigid wall", *c)?;
}
if !(w.normal.iter().map(|c| c * c).sum::<f64>() > 0.0) {
return Err(Error::Invalid("a rigid wall's normal must not be zero".into()));
}
}
self.hourglass.validate()?;
self.bulk_viscosity.validate()
}
}
/// A velocity given to the nodes of a set at the start of an explicit step.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InitialVelocity {
pub set: String,
pub velocity: [f64; 3],
}
/// A fixed, frictionless rigid plane through `point`. Its `normal` points to the side the nodes
/// of `set` stay on.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RigidWall {
pub set: String,
pub point: [f64; 3],
pub normal: [f64; 3],
}
/// How one-point hexahedra resist their hourglass (zero-energy) modes.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HourglassKind {
/// Forces proportional to the hourglass velocities.
#[default]
Viscous,
/// Forces proportional to the hourglass displacements.
Stiffness,
}
/// Hourglass control: the kind and a dimensionless coefficient.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hourglass {
#[serde(rename = "type", default)]
pub kind: HourglassKind,
#[serde(default = "default_hourglass_coefficient")]
pub coefficient: f64,
}
/// Largest hourglass coefficient.
pub const MAX_HOURGLASS_COEFFICIENT: f64 = 0.25;
fn default_hourglass_coefficient() -> f64 {
0.1
}
impl Default for Hourglass {
fn default() -> Hourglass {
Hourglass { kind: HourglassKind::Viscous, coefficient: default_hourglass_coefficient() }
}
}
impl Hourglass {
pub fn validate(&self) -> Result<()> {
if !(0.0..=MAX_HOURGLASS_COEFFICIENT).contains(&self.coefficient) {
return Err(Error::Invalid(format!("the hourglass coefficient is in 0..={MAX_HOURGLASS_COEFFICIENT}, got {}", self.coefficient)));
}
Ok(())
}
}
/// Artificial bulk viscosity in compression: a pressure `ρ L (quadratic L ε̇ᵥ² − linear c ε̇ᵥ)`
/// for a volumetric strain rate ε̇ᵥ < 0, element size L and wave speed c.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BulkViscosity {
#[serde(default = "default_linear_viscosity")]
pub linear: f64,
#[serde(default = "default_quadratic_viscosity")]
pub quadratic: f64,
}
/// Largest bulk viscosity coefficient.
pub const MAX_BULK_VISCOSITY: f64 = 10.0;
fn default_linear_viscosity() -> f64 {
0.06
}
fn default_quadratic_viscosity() -> f64 {
1.5
}
impl Default for BulkViscosity {
fn default() -> BulkViscosity {
BulkViscosity { linear: default_linear_viscosity(), quadratic: default_quadratic_viscosity() }
}
}
impl BulkViscosity {
pub fn validate(&self) -> Result<()> {
for (what, v) in [("linear", self.linear), ("quadratic", self.quadratic)] {
if !(0.0..=MAX_BULK_VISCOSITY).contains(&v) {
return Err(Error::Invalid(format!("the {what} bulk viscosity coefficient is in 0..={MAX_BULK_VISCOSITY}, got {v}")));
}
}
Ok(())
}
}
impl Procedure {
@@ -41,10 +280,28 @@ impl Procedure {
match self {
Procedure::Static {} => "static",
Procedure::Frequency { .. } => "frequency",
Procedure::ExplicitDynamic(_) => "explicit_dynamic",
}
}
/// Whether the procedure is transient dynamics (it needs mass; a free body is legitimate).
pub fn is_dynamic(&self) -> bool {
matches!(self, Procedure::ExplicitDynamic(_))
}
/// The sets the procedure's own settings refer to.
pub fn sets(&self) -> impl Iterator<Item = &str> {
let explicit = match self {
Procedure::ExplicitDynamic(e) => Some(e),
Procedure::Static {} | Procedure::Frequency { .. } => None,
};
explicit.into_iter().flat_map(ExplicitDynamic::sets)
}
pub fn validate(&self) -> Result<()> {
if let Procedure::ExplicitDynamic(e) = self {
return e.validate();
}
if let Procedure::Frequency { modes, min_hz, max_hz } = *self {
if !(1..=MAX_MODES).contains(&modes) {
return Err(Error::Invalid(format!("a frequency step extracts 1 to {MAX_MODES} modes, got {modes}")));
@@ -68,6 +325,7 @@ impl Procedure {
match self {
Procedure::Static {} => OutputVariable::ALL.into_iter().collect(),
Procedure::Frequency { .. } => [OutputVariable::Displacement].into_iter().collect(),
Procedure::ExplicitDynamic(_) => [OutputVariable::Displacement, OutputVariable::Stress].into_iter().collect(),
}
}
}
@@ -121,6 +379,29 @@ impl Model {
self.steps.iter().find(|s| s.name == name)
}
/// The step named `name`, or the only step whose procedure is `wanted` when no name is
/// given; it must be such a step (`what` names the kind in messages, e.g. "static").
pub fn select_step(&self, name: Option<&str>, wanted: fn(&Procedure) -> bool, what: &str) -> Result<&Step> {
let step = match name {
Some(n) => self.step(n).ok_or_else(|| Error::Invalid(format!("step `{n}` does not exist")))?,
None => {
let matching: Vec<&Step> = self.steps.iter().filter(|s| wanted(&s.procedure)).collect();
match matching.as_slice() {
[one] => *one,
[] => return Err(Error::Invalid(format!("the model has no {what} step"))),
many => {
let names: Vec<&str> = many.iter().map(|s| s.name.as_str()).collect();
return Err(Error::Invalid(format!("name the step: the model has {what} steps {}", names.join(", "))));
}
}
}
};
if !wanted(&step.procedure) {
return Err(Error::Invalid(format!("step `{}` is a {} step, not a {what} one", step.name, step.procedure.name())));
}
Ok(step)
}
fn step_index(&self, name: &str) -> Result<usize> {
self.steps.iter().position(|s| s.name == name).ok_or_else(|| Error::NotFound(format!("step `{name}`")))
}
@@ -131,6 +412,9 @@ impl Model {
fn check_step(&self, step: &Step) -> Result<()> {
step.procedure.validate().map_err(|e| context("step", &step.name, e))?;
for set in step.procedure.sets() {
require(&self.sets, "set", set).map_err(|e| context("step", &step.name, e))?;
}
for b in &step.boundary_conditions {
require(&self.boundary_conditions, "boundary condition", b).map_err(|e| context("step", &step.name, e))?;
}
@@ -230,4 +514,50 @@ mod tests {
m.remove_step("Modes").unwrap();
assert_eq!(m.steps().len(), 1);
}
#[test]
fn explicit_steps_validate_and_hold_their_sets() {
let mut m = Model::default();
m.add_set("front", Set { nodes: vec![1], ..Set::default() }).unwrap();
let json = serde_json::json!({
"type": "explicit_dynamic", "end_time": 1e-3, "history_nodes": "front",
"initial_velocities": [{"set": "front", "velocity": [-10, 0, 0]}],
"rigid_walls": [{"set": "front", "point": [0, 0, 0], "normal": [1, 0, 0]}],
"hourglass": {"type": "stiffness"}
});
let p: Procedure = serde_json::from_value(json).unwrap();
let Procedure::ExplicitDynamic(e) = &p else { panic!("{p:?}") };
assert_eq!((e.dt_scale, e.hourglass.kind, e.hourglass.coefficient), (0.9, HourglassKind::Stiffness, 0.1));
assert_eq!(e.bulk_viscosity, BulkViscosity { linear: 0.06, quadratic: 1.5 });
assert!((e.output_interval() - 5e-5).abs() < 1e-18 && (e.history_interval() - 1e-6).abs() < 1e-18);
let back: Procedure = serde_json::from_value(serde_json::to_value(&p).unwrap()).unwrap();
assert_eq!(back, p);
m.add_step(Step::new("Impact", p.clone()), None).unwrap();
assert!(matches!(m.remove_set("front"), Err(Error::InUse(_))), "the step refers to the set");
assert!(m.add_step(Step::new("Impact", p), None).is_err(), "names stay unique");
let missing = serde_json::json!({"type": "explicit_dynamic", "end_time": 1.0, "history_nodes": "nope"});
assert!(m.add_step(Step::new("Bad", serde_json::from_value(missing).unwrap()), None).is_err());
let base = ExplicitDynamic::new(1.0);
let bad = [
ExplicitDynamic { end_time: 0.0, ..base.clone() },
ExplicitDynamic { end_time: f64::NAN, ..base.clone() },
ExplicitDynamic { dt_scale: 1.5, ..base.clone() },
ExplicitDynamic { dt_scale: 0.0, ..base.clone() },
ExplicitDynamic { max_increments: 0, ..base.clone() },
ExplicitDynamic { output_interval: Some(1e-9), ..base.clone() },
ExplicitDynamic { history_interval: Some(-1.0), ..base.clone() },
ExplicitDynamic { rigid_walls: vec![RigidWall { set: "front".into(), point: [0.0; 3], normal: [0.0; 3] }], ..base.clone() },
ExplicitDynamic {
initial_velocities: vec![InitialVelocity { set: "front".into(), velocity: [f64::INFINITY, 0.0, 0.0] }],
..base.clone()
},
ExplicitDynamic { hourglass: Hourglass { kind: HourglassKind::Viscous, coefficient: 2.0 }, ..base.clone() },
ExplicitDynamic { bulk_viscosity: BulkViscosity { linear: -1.0, quadratic: 1.0 }, ..base.clone() },
];
for b in bad {
assert!(Procedure::ExplicitDynamic(b.clone()).validate().is_err(), "{b:?}");
}
assert!(serde_json::from_value::<Procedure>(serde_json::json!({"type": "explicit_dynamic", "end_time": 1, "dt": 2})).is_err());
}
}