Explicit X2: large deformation, J2 and Johnson-Cook plasticity, damage and erosion (M10)

- model: materials gain solver-neutral plastic (bilinear isotropic/kinematic,
  hardening table, Johnson-Cook with adiabatic heating), damage (Johnson-Cook
  D1-D5) and erosion (plastic strain); explicit steps gain nonlinear_geometry
  (default on) and stress_rate (jaumann | green_naghdi).
- fem: rotation (Hughes-Winget, Flanagan-Taylor), plasticity (radial return,
  capped bracketed Newton for Johnson-Cook, damage; the update takes the
  mechanical strain increment so thermal strains can be subtracted), closed-form
  hex8 gradients for deformed configurations.
- explicit: updated Lagrangian hex8/tet4, per-element material state, erosion
  with reported energy, plastic work in the energy account.
- solver: warns that the linear solver uses the elastic constants of plastic
  materials (results hash-identical).
- decks: Abaqus *PLASTIC (+ Johnson-Cook keywords), Nastran MATS1/TABLES1,
  LS-DYNA *MAT_PLASTIC_KINEMATIC / *MAT_PIECEWISE_LINEAR_PLASTICITY; what a
  format cannot express is reported as Unsupported.
- engine: model.material.plasticity, model.material.damage (catalogue ready),
  explicit element probe, eroded elements in the solve summary.

Step commits before the rebase onto the DOF framework: branch explicit-x2-steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-13 20:52:57 -04:00
1 parent 4c0fcc80ad
commit 50764def18
38 files changed
+2839 -254

No files matched your search

+77 -1
View File
@@ -80,7 +80,7 @@ definition_commands!(
Material,
"material",
"Material",
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density)} — in the model's unit system (model.units), never converted"#,
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density), "plastic": {…} (see model.material.plasticity), "damage": {…}, "erosion": {…} (see model.material.damage)} — in the model's unit system (model.units), never converted"#,
add_material,
replace_material,
remove_material,
@@ -89,6 +89,51 @@ definition_commands!(
MATERIAL_DELETE
);
/// A copy of material `name` with `change` applied, put back through the model's validation.
fn change_material(s: &mut Session, p: &Value, change: impl FnOnce(&mut Material, &Value) -> Result<()>) -> Result<Value> {
let n = name(p)?;
let mut m = s.model.material(n).cloned().ok_or_else(|| EngineError::Params(format!("material `{n}` does not exist")))?;
change(&mut m, p)?;
s.model.replace_material(n, m)?;
Ok(json!({"name": n}))
}
pub const MATERIAL_PLASTICITY: CommandSpec = CommandSpec {
id: "model.material.plasticity",
label: "Plasticity",
params: r#"{"name": "Steel", "plastic": {"type": "bilinear", "yield_stress": 250, "hardening_modulus": 1000, "hardening": "isotropic|kinematic"} | {"type": "table", "points": [{"strain": 0, "stress": 250}, {"strain": 0.1, "stress": 300}]} | {"type": "johnson_cook", "a": 792, "b": 510, "n": 0.26, "c": 0.014, "m": 1.03, "reference_strain_rate": 1, "room_temperature": 294, "melt_temperature": 1793, "specific_heat": 4.77e8, "taylor_quinney": 0.9} | null} — von Mises plasticity of an isotropic material (null removes it); explicit dynamics uses it, the linear solver the elastic constants alone"#,
kind: CommandKind::Model,
run: |s: &mut Session, p: &Value| {
change_material(s, p, |m, p| {
m.plastic = req(p, "plastic")?;
Ok(())
})
},
};
pub const MATERIAL_DAMAGE: CommandSpec = CommandSpec {
id: "model.material.damage",
label: "Damage",
params: r#"{"name": "Steel", "damage": {"type": "johnson_cook", "d1": 0.05, "d2": 3.44, "d3": -2.12, "d4": 0.002, "d5": 0.61} | null, "erosion": {"plastic_strain": 1.0} | null} — damage to failure and element erosion of a plastic material (each key optional; null removes it); a failed element is eroded in explicit dynamics"#,
kind: CommandKind::Model,
run: |s: &mut Session, p: &Value| {
change_material(s, p, |m, p| {
// Absent leaves a field as it is; null removes it.
let (damage, erosion) = (opt(p, "damage")?, opt(p, "erosion")?);
if damage.is_none() && erosion.is_none() {
return Err(EngineError::Params("give `damage`, `erosion` or both".into()));
}
if let Some(d) = damage {
m.damage = d;
}
if let Some(e) = erosion {
m.erosion = e;
}
Ok(())
})
},
};
definition_commands!(
"section",
Section,
@@ -423,4 +468,35 @@ pub(crate) mod tests {
let e = s.execute("model.set.resolve", &json!({"name": "root"})).unwrap_err().to_string();
assert!(e.contains("face left of body Bar is not on the mesh (the mesh carries no geometry tags)"), "{e}");
}
#[test]
fn plasticity_and_damage_edit_a_material() {
let mut s = Session::default();
defined_bar(&mut s);
let bilinear = json!({"type": "bilinear", "yield_stress": 250, "hardening_modulus": 1000});
s.execute("model.material.plasticity", &json!({"name": "Steel", "plastic": bilinear})).unwrap();
assert!(s.model.material("Steel").unwrap().plastic.is_some());
// Damage needs plasticity; D4 and D5 need Johnson–Cook: refused, the model unchanged.
let before = s.model.clone();
let jc_damage = json!({"type": "johnson_cook", "d1": 0.05, "d2": 3.44, "d3": -2.12, "d4": 0.002, "d5": 0.61});
assert!(s.execute("model.material.damage", &json!({"name": "Steel", "damage": jc_damage})).is_err());
assert_eq!(s.model, before);
s.execute(
"model.material.damage",
&json!({"name": "Steel", "damage": {"type": "johnson_cook", "d1": 0.3, "d2": 0, "d3": 0}, "erosion": {"plastic_strain": 0.5}}),
)
.unwrap();
let m = s.model.material("Steel").unwrap();
assert!(m.damage.is_some() && m.erosion.is_some());
s.execute("model.material.damage", &json!({"name": "Steel", "erosion": null})).unwrap();
assert!(s.model.material("Steel").unwrap().erosion.is_none() && s.model.material("Steel").unwrap().damage.is_some());
// Removing plasticity under damage is refused.
assert!(s.execute("model.material.plasticity", &json!({"name": "Steel", "plastic": null})).is_err());
for bad in
[json!({"name": "Steel"}), json!({"name": "Nope", "plastic": null}), json!({"name": "Steel", "plastic": {"type": "table", "points": []}})]
{
assert!(s.execute("model.material.plasticity", &bad).is_err(), "{bad}");
}
assert!(s.execute("model.material.damage", &json!({"name": "Steel"})).is_err());
}
}
+2
View File
@@ -43,6 +43,8 @@ pub static ALL: &[CommandSpec] = &[
define::MATERIAL_CREATE,
define::MATERIAL_EDIT,
define::MATERIAL_DELETE,
define::MATERIAL_PLASTICITY,
define::MATERIAL_DAMAGE,
define::SECTION_CREATE,
define::SECTION_EDIT,
define::SECTION_DELETE,
+47 -9
View File
@@ -54,7 +54,7 @@ pub const FLUID: CommandSpec = CommandSpec {
pub const PROBE: CommandSpec = CommandSpec {
id: "solve.probe",
label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, reaction, stress and von Mises (static), the shape of mode `mode` (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default); a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, reaction, stress and von Mises (static), the shape of mode `mode` (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
kind: CommandKind::Model,
run: probe,
};
@@ -175,6 +175,7 @@ fn explicit_summary(r: &ExplicitResult) -> Value {
"energy_balance": r.energy.relative_error(r.initial_kinetic),
"frames": r.frames.len(),
"history_samples": r.history.time.len(),
"eroded": r.eroded,
"warnings": r.warnings,
})
}
@@ -344,15 +345,15 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
format!("step `{step}` has no result; solve it first")
})
})?;
if let StepResult::Fluid(r) = result.as_ref() {
return probe_fluid(r, p);
}
let nodes = labels(p, "nodes", MAX_PROBE)?;
let result = match result.as_ref() {
StepResult::Explicit(r) => return probe_frame(r, &step, &nodes, p),
StepResult::Explicit(r) => {
let list = |key| if p.get(key).is_some() { labels(p, key, MAX_PROBE) } else { Ok(Vec::new()) };
return probe_frame(r, &step, &list("nodes")?, &list("elements")?, p);
}
StepResult::Fluid(r) => return probe_fluid(r, p),
StepResult::Implicit(r) => r,
};
let nodes = labels(p, "nodes", MAX_PROBE)?;
let locate = |label: u32| {
result
.locate(label)
@@ -399,11 +400,38 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"step": step, "nodes": out}))
}
/// Displacement and velocity at nodes in one field frame of an explicit result.
fn probe_frame(r: &ExplicitResult, step: &str, nodes: &[u32], p: &Value) -> Result<Value> {
/// Displacement and velocity at nodes, and element results, in one field frame of an explicit
/// result.
fn probe_frame(r: &ExplicitResult, step: &str, nodes: &[u32], elements: &[u32], p: &Value) -> Result<Value> {
let last = r.frames.len().saturating_sub(1);
let k = opt::<usize>(p, "frame")?.unwrap_or(last);
let f = r.frames.get(k).ok_or_else(|| EngineError::Params(format!("`frame` must be from 0 to {last}")))?;
if nodes.is_empty() && elements.is_empty() {
return Err(EngineError::Params("give `nodes`, `elements` or both".into()));
}
if !elements.is_empty() && f.stress.is_empty() {
return Err(EngineError::Params(format!("step `{step}` does not output stresses")));
}
let element_values: Vec<Value> = elements
.iter()
.map(|&l| {
let i =
r.elements.binary_search(&l).map_err(|_| EngineError::Params(format!("element {l} has no result (it is not a solved element)")))?;
Ok(json!({
"element": l,
"stress": f.stress.get(i),
"von_mises": f.stress.get(i).map(modelcraft_fem::von_mises),
"strain": f.strain.get(i),
"plastic_strain": f.plastic_strain.get(i),
"damage": f.damage.get(i),
"temperature": f.temperature.get(i),
"eroded": f.failed.get(i),
}))
})
.collect::<Result<_>>()?;
if nodes.is_empty() {
return Ok(json!({"step": step, "frame": k, "time": f.time, "elements": element_values}));
}
if f.displacement.is_empty() {
return Err(EngineError::Params(format!("step `{step}` does not output displacements")));
}
@@ -415,7 +443,7 @@ fn probe_frame(r: &ExplicitResult, step: &str, nodes: &[u32], p: &Value) -> Resu
Ok(json!({"node": l, "displacement": f.displacement.get(i), "velocity": f.velocity.get(i)}))
})
.collect::<Result<_>>()?;
Ok(json!({"step": step, "frame": k, "time": f.time, "nodes": out}))
Ok(json!({"step": step, "frame": k, "time": f.time, "nodes": out, "elements": element_values}))
}
#[cfg(test)]
@@ -850,6 +878,11 @@ pub(crate) mod tests {
assert_eq!(probe["nodes"][1]["velocity"][0].as_f64(), Some(-0.1), "{probe}");
let last = s.execute("solve.probe", &json!({"step": "Impact", "nodes": [9]})).unwrap();
assert_eq!(last["frame"], 4, "{last}");
let el = s.execute("solve.probe", &json!({"step": "Impact", "elements": [1, 8]})).unwrap();
let e = &el["elements"][0];
assert!(e["stress"].as_array().unwrap().len() == 6 && e["von_mises"].as_f64().unwrap() >= 0.0 && e["eroded"] == false, "{el}");
assert!(e["plastic_strain"].is_null(), "an elastic model has no plastic strain: {el}");
assert_eq!(r["eroded"].as_array().map(Vec::len), Some(0), "{r}");
let h = s.execute("solve.history", &json!({"step": "Impact", "every": 2})).unwrap();
assert_eq!(h["time"].as_array().unwrap().len(), h["energies"].as_array().unwrap().len());
assert!(h["nodes"][0]["velocity"].as_array().unwrap().len() > 5, "{h}");
@@ -875,6 +908,11 @@ pub(crate) mod tests {
json!({"step": "Impact", "nodes": [1], "frame": 99}),
json!({"step": "Impact", "nodes": [1], "frame": -1}),
json!({"step": "Impact", "nodes": [0, 4294967296u64]}),
json!({"step": "Impact"}),
json!({"step": "Impact", "elements": [999]}),
json!({"step": "Impact", "elements": [0, -1, 1.5]}),
json!({"step": "Impact", "elements": vec![1; 20_000]}),
json!({"step": "Impact", "elements": "all", "nodes": [1]}),
];
for id in ["solve.explicit", "solve.history", "solve.probe"] {
for p in &hostile {