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

+2 -2
View File
@@ -203,9 +203,9 @@ pub static CATALOGUE: &[Capability] = &[
"Elastic (isotropic, orthotropic) material with density",
&["model.material.edit", "model.material.delete"],
),
soon("model.material.plasticity", "Plasticity", Properties, "Materials", "M8", "Plastic hardening data"),
ready("model.material.plasticity", "Plasticity", Properties, "Materials", "Von Mises plasticity: bilinear, hardening table or Johnson–Cook"),
soon("model.material.hyperelastic", "Hyperelastic", Properties, "Materials", "M8", "Hyperelastic material models"),
soon("model.material.damage", "Damage", Properties, "Materials", "M8", "Damage initiation and evolution"),
ready("model.material.damage", "Damage", Properties, "Materials", "Johnson–Cook damage and element erosion"),
soon("model.material.thermal", "Thermal", Properties, "Materials", "M8", "Conductivity, specific heat and expansion"),
soon(
"model.material.eos",
+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 {
+8
View File
@@ -349,6 +349,14 @@ mod tests {
json!({"name": "A", "elastic": {"type": "engineering_constants", "young": [1e-300, 1e300, 1], "poisson": [1e300, 0, 0], "shear": [0, 0, 0]}}),
json!({"name": "A", "elastic": {"type": "isotropic", "young": "stiff"}, "density": -0.0}),
json!({"name": "A", "set": "A", "material": "A", "kind": {"type": "solid"}}),
json!({"name": "Steel", "plastic": {"type": "bilinear", "yield_stress": -1e308, "hardening_modulus": f64::MAX}}),
json!({"name": "Steel", "plastic": {"type": "table", "points": vec![json!({"strain": 0, "stress": 1}); 2_000]}}),
json!({"name": "Steel", "plastic": {"type": "table", "points": [{"strain": 0, "stress": 1}, {"strain": 0, "stress": 2}]}}),
json!({"name": "Steel", "plastic": {"type": "johnson_cook", "a": 1, "b": 1, "n": -1, "room_temperature": 1e308, "melt_temperature": -1e308}}),
json!({"name": "Steel", "plastic": {"type": "johnson_cook", "a": 1, "b": 1e308, "n": 1e308, "c": 1e308, "m": 1e-308, "room_temperature": 0, "melt_temperature": 1, "taylor_quinney": 2}}),
json!({"name": "Steel", "plastic": "hard"}),
json!({"name": "Steel", "damage": {"type": "johnson_cook", "d1": 1e308, "d2": -1e308, "d3": 1e308}, "erosion": {"plastic_strain": -1}}),
json!({"name": "Steel", "damage": null, "erosion": {"plastic_strain": 1e-308}}),
json!({"name": "A", "set": "all", "material": "Steel", "kind": {"type": "beam", "profile": {"type": "pipe", "radius": 1, "thickness": 2}, "orientation": [0, 0, 0]}}),
json!({"name": "A", "set": "all", "material": "Steel", "kind": {"type": "shell", "thickness": 1e308, "offset": -0.5, "points": 4294967295u64}}),
json!({"name": "A", "xyz": [1e308, 0, 0]}),