engine: friction output of implicit contact

solve.nonlinear counts the slipping nodes, solve.probe gives a secondary node's shear traction
and slip, and the results display draws contact_shear and contact_slip, contact_status coding
stick and slip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 15:14:14 -04:00
1 parent 6ca3233aaa
commit 32c5c1b307
7 files changed
+78 -19

No files matched your search

+1 -1
View File
@@ -428,7 +428,7 @@ pub static CATALOGUE: &[Capability] = &[
"Contact Pair",
Interactions,
"Contact",
"Contact between two surfaces: with friction in explicit dynamics, frictionless mortar contact in nonlinear static steps",
"Contact between two surfaces: with friction in explicit dynamics, mortar contact with friction in nonlinear static steps",
&["interaction.contact.edit", "interaction.contact.delete"],
),
ready(
+1 -1
View File
@@ -41,7 +41,7 @@ pub const PAIR: CommandSpec = CommandSpec {
params: concat!(
r#"{"name": "impact", "main": "plate" (a set of element faces), "secondary": "ball" (another), "#,
r#""friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb, μ = μk + (μs − μk) e^(−decay × slip rate); default frictionless), "penalty_scale": 1 (0 < scale ≤ 1: a factor on the penalty: explicit, the stiffest that leaves the time increment alone; nonlinear static, the augmented Lagrangian's), "initial_penetration": "adjust" (nodes that start inside: explicit moves them onto the surface, strain-free; nonlinear static takes the overlap out of their gap; both report them) | "refuse" (refuse the analysis, naming the first)"#,
r#"} — contact between two surfaces: in explicit dynamics each one's nodes are kept out of the other's faces; in nonlinear static steps `secondary` is the mortar (non-mortar) side whose pressure field is enforced against `main` by an augmented Lagrangian, frictionless so far (fails if the name is taken)"#
r#"} — contact between two surfaces: in explicit dynamics each one's nodes are kept out of the other's faces; in nonlinear static steps `secondary` is the mortar (non-mortar) side whose pressure field is enforced against `main` by an augmented Lagrangian, with Coulomb friction by a return map (static coefficient while a node sticks, kinetic once it slips) (fails if the name is taken)"#
),
form: Some(super::forms::CONTACT_PAIR),
kind: CommandKind::Model,
+2
View File
@@ -883,6 +883,8 @@ pub const CONTOUR: &[Field] = &[
("contact_pressure", "Contact pressure"),
("contact_gap", "Contact gap"),
("contact_status", "Contact status"),
("contact_shear", "Contact shear"),
("contact_slip", "Contact slip"),
("none", "None"),
],
)
+1 -1
View File
@@ -20,7 +20,7 @@ pub const DEFORMED: CommandSpec = CommandSpec {
pub const CONTOUR: CommandSpec = CommandSpec {
id: "results.contour",
label: "Contours",
params: r#"{"step": "Load", "field": "von_mises", "bands": 12} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands"#,
params: r#"{"step": "Load", "field": "von_mises", "bands": 12} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands"#,
form: Some(super::forms::CONTOUR),
kind: CommandKind::Session,
run: contour,
+35 -2
View File
@@ -36,7 +36,7 @@ pub const STATIC: CommandSpec = CommandSpec {
pub const NONLINEAR: CommandSpec = CommandSpec {
id: "solve.nonlinear",
label: "Solve Nonlinear Static Step",
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment, and with contact pairs (interaction.contact.pair: mortar contact by augmented Lagrangian, frictionless) each contact's normal force, largest pressure, closed nodes, deepest penetration and augmentations (solve.probe gives each secondary node's pressure, gap and status); the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment, and with contact pairs (interaction.contact.pair: mortar contact by augmented Lagrangian, Coulomb friction by a return map) each contact's force, normal force, largest pressure, closed and slipping nodes, deepest penetration and augmentations (solve.probe gives each secondary node's pressure, gap and status, and with friction its shear traction and slip); the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
form: Some(super::forms::SOLVE_NONLINEAR),
kind: CommandKind::Model,
run: run_nonlinear,
@@ -245,6 +245,7 @@ fn run_nonlinear(s: &mut Session, p: &Value) -> Result<Value> {
"force": c.force,
"max_pressure": largest(c.nodes.iter().copied().zip(c.pressure.iter().copied())),
"closed_nodes": c.closed(),
"slipping_nodes": c.status.iter().filter(|s| **s == fieldcraft_solver::contact::Status::Slip).count(),
"penetration": c.penetration,
"augmentations": c.augmentations,
})).collect::<Vec<_>>(),
@@ -635,7 +636,12 @@ fn probe_implicit(result: &ImplicitResult, step: &str, nodes: &[u32], p: &Value)
.iter()
.filter_map(|c| {
let k = c.nodes.binary_search(&l).ok()?;
Some(json!({"contact": c.name, "pressure": c.pressure.get(k), "gap": c.gap.get(k), "status": c.status.get(k)}))
let mut entry = json!({"contact": c.name, "pressure": c.pressure.get(k), "gap": c.gap.get(k), "status": c.status.get(k)});
if let (Some(shear), Some(slip)) = (c.shear.get(k), c.slip.get(k)) {
entry["shear"] = json!(shear);
entry["slip"] = json!(slip);
}
Some(entry)
})
.collect();
if !contact.is_empty() {
@@ -1588,4 +1594,31 @@ pub(crate) mod tests {
let e = s.execute("solve.static", &json!({"step": "Linear"})).unwrap_err().to_string();
assert!(e.contains("interface"), "{e}");
}
#[test]
fn solve_and_probe_a_frictional_contact() {
let mut s = Session::default();
pressed_blocks(&mut s);
s.execute("interaction.contact.edit", &json!({"name": "interface", "surfaces": {"type": "pair", "main": "lower_top", "secondary": "upper_bottom"}, "friction": {"static": 0.2}})).unwrap();
s.execute(
"model.boundary_condition.edit",
&json!({"name": "push", "type": "displacement", "target": {"set": "top"}, "values": [0.05, 0, -0.02, null, null, null]}),
)
.unwrap();
let r = s.execute("solve.nonlinear", &json!({"step": "Squeeze"})).unwrap();
let c = &r["contacts"][0];
assert_eq!(c["slipping_nodes"], 16, "{r}");
// Slipping, the interface carries μ N sideways.
let (n, t) = (c["force"][2].as_f64().unwrap(), c["force"][0].as_f64().unwrap());
assert!((t + 0.2 * n).abs() < 1e-3 * n, "{r}");
let third = 1.0 / 3.0;
let probe = s.execute("solve.probe", &json!({"step": "Squeeze", "at": [[third, third, 1.0]]})).unwrap();
let contact = &probe["nodes"][0]["contact"][0];
assert_eq!(contact["status"], "slip", "{probe}");
assert!(contact["shear"][0].as_f64().unwrap() < 0.0 && contact["slip"][0].as_f64().unwrap() > 0.0, "{probe}");
let v = s.execute("results.contour", &json!({"step": "Squeeze", "field": "contact_status"})).unwrap();
assert_eq!(v["contour"]["range"]["max"]["value"], 2.0, "{v}");
let v = s.execute("results.contour", &json!({"step": "Squeeze", "field": "contact_shear"})).unwrap();
assert!(v["contour"]["range"]["max"]["value"].as_f64().unwrap() > 1.0, "{v}");
}
}
+37 -13
View File
@@ -92,12 +92,17 @@ pub enum Field {
ContactPressure,
/// The normalised contact gap of the secondary surfaces' nodes (negative: penetration).
ContactGap,
/// Contact status of the secondary surfaces' nodes: 0 open, 1 closed.
/// Contact status of the secondary surfaces' nodes: 0 open, 1 closed (frictionless) or
/// sticking, 2 slipping.
ContactStatus,
/// The magnitude of the friction's shear traction (0 off the secondary surfaces).
ContactShear,
/// The magnitude of the frictional slip over the last increment (secondary surfaces' nodes).
ContactSlip,
}
impl Field {
pub const ALL: [Field; 20] = [
pub const ALL: [Field; 22] = [
Field::Displacement,
Field::DisplacementX,
Field::DisplacementY,
@@ -118,6 +123,8 @@ impl Field {
Field::ContactPressure,
Field::ContactGap,
Field::ContactStatus,
Field::ContactShear,
Field::ContactSlip,
];
/// The id commands take (`von_mises`…).
@@ -143,6 +150,8 @@ impl Field {
Field::ContactPressure => "contact_pressure",
Field::ContactGap => "contact_gap",
Field::ContactStatus => "contact_status",
Field::ContactShear => "contact_shear",
Field::ContactSlip => "contact_slip",
}
}
@@ -167,7 +176,9 @@ impl Field {
Field::HeatFlux => "Heat flux magnitude",
Field::ContactPressure => "Contact pressure",
Field::ContactGap => "Contact gap",
Field::ContactStatus => "Contact status (0 open, 1 closed)",
Field::ContactStatus => "Contact status (0 open, 1 closed or stick, 2 slip)",
Field::ContactShear => "Contact shear traction",
Field::ContactSlip => "Contact slip",
}
}
@@ -191,7 +202,7 @@ impl Field {
/// Whether it is a contact quantity.
pub fn is_contact(self) -> bool {
matches!(self, Field::ContactPressure | Field::ContactGap | Field::ContactStatus)
matches!(self, Field::ContactPressure | Field::ContactGap | Field::ContactStatus | Field::ContactShear | Field::ContactSlip)
}
/// Whether it is a heat transfer quantity.
@@ -356,7 +367,7 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) ->
Kind::Heat(h) => Ok(h.flux.iter().map(|v| magnitude(*v)).collect()),
_ => Err(unavailable()),
},
Field::ContactPressure | Field::ContactGap | Field::ContactStatus => match r.kind() {
Field::ContactPressure | Field::ContactGap | Field::ContactStatus | Field::ContactShear | Field::ContactSlip => match r.kind() {
Kind::Static(s) if !s.contacts.is_empty() => Ok(contact_values(s, field)),
_ => Err(unavailable()),
},
@@ -373,24 +384,37 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) ->
}
}
/// A contact field at every node of a static result: the largest pressure, the smallest gap or
/// the closed status over the contacts a node is a secondary node of; off them 0 pressure and no
/// gap or status (NaN).
/// A contact field at every node of a static result: the largest pressure, shear, slip or status
/// code, or the smallest gap, over the contacts a node is a secondary node of; off them 0
/// pressure and shear, and no gap, status or slip (NaN).
fn contact_values(s: &StaticResult, field: Field) -> Vec<f64> {
let mut out = vec![if field == Field::ContactPressure { 0.0 } else { f64::NAN }; s.nodes.len()];
use fieldcraft_solver::contact::Status;
let zero = matches!(field, Field::ContactPressure | Field::ContactShear);
let mut out = vec![if zero { 0.0 } else { f64::NAN }; s.nodes.len()];
let larger = |v: &mut f64, x: f64| *v = if v.is_nan() { x } else { v.max(x) };
for c in &s.contacts {
for (k, label) in c.nodes.iter().enumerate() {
let Some(v) = s.nodes.binary_search(label).ok().and_then(|i| out.get_mut(i)) else { continue };
match field {
Field::ContactPressure => *v = v.max(c.pressure.get(k).copied().unwrap_or(0.0)),
Field::ContactPressure => larger(v, c.pressure.get(k).copied().unwrap_or(0.0)),
Field::ContactShear => larger(v, c.shear.get(k).map_or(0.0, |t| magnitude(*t))),
Field::ContactSlip => {
if let Some(g) = c.slip.get(k) {
larger(v, magnitude(*g));
}
}
Field::ContactGap => {
if let Some(Some(g)) = c.gap.get(k) {
*v = if v.is_nan() { *g } else { v.min(*g) };
}
}
Field::ContactStatus => {
let closed = c.status.get(k).is_some_and(|s| *s == fieldcraft_solver::contact::Status::Closed);
*v = if closed || (!v.is_nan() && *v > 0.0) { 1.0 } else { 0.0 };
let code = match c.status.get(k) {
Some(Status::Closed | Status::Stick) => 1.0,
Some(Status::Slip) => 2.0,
Some(Status::Open) | None => 0.0,
};
larger(v, code);
}
_ => {}
}
@@ -525,7 +549,7 @@ mod tests {
}
let (modes, _) = s.latest_result("Modes").unwrap();
assert_eq!(Field::available(modes).count(), 4);
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 6, "all but velocity, the thermal and the contact fields");
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 8, "all but velocity, the thermal and the contact fields");
assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err());
assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err());
assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len());
+1 -1
View File
@@ -104,7 +104,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Constraints | Coupling — Kinematic or distributing coupling of a set to a reference point | ready | `model.coupling.create`, `model.coupling.edit`, `model.coupling.delete` |
| Constraints | Tie — Tie non-matching meshes together | coming soon (M7) | |
| Contact | General Contact — Contact between everything that may touch, or self contact of a surface, with friction (explicit dynamics) | ready | `interaction.contact.general`, `interaction.contact.edit`, `interaction.contact.delete` |
| Contact | Contact Pair — Contact between two surfaces: with friction in explicit dynamics, frictionless mortar contact in nonlinear static steps | ready | `interaction.contact.pair`, `interaction.contact.edit`, `interaction.contact.delete` |
| Contact | Contact Pair — Contact between two surfaces: with friction in explicit dynamics, mortar contact with friction in nonlinear static steps | ready | `interaction.contact.pair`, `interaction.contact.edit`, `interaction.contact.delete` |
| Contact | Find Contacts — Find the parts of the mesh that touch, and make contact pairs of them | ready | `interaction.contact.find` |
| Contact | Rigid Wall — Rigid walls for explicit dynamics | coming soon (M10) | |
| Connectors | Bolt Load — Bolt pretension | coming soon (M7) | |