Files
OneCAD-Simulation/crates/solver/tests/contact.rs
T
jyvikoandClaude Opus 5.5 6ca3233aaa solver: Coulomb friction for implicit mortar contact
A return map at the closed secondary nodes on the mortar slip in its rate form (w_A = B_A dx,
B_A = [int Phi_A N_i P, -int Phi_A N_j P]): stick within mu p_A, slip back to the cone, static
coefficient for a node that stuck in the last increment and kinetic for one that slipped; the
friction does the virtual work t . B_A dx, so a slipping interface carries mu N exactly. The
tangent is symmetrised (stick and slip stiffness kept, the coupling to the pressure and the
change of B left out; the residual is exact). Tractions and slip states are committed with the
converged increment. After a chattering cutback, nodes open by less than 1e-3 of their
element's thickness keep their penalty in the tangent for the rest of the step. Results carry
the shear traction, the slip and stick/slip status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 15:14:14 -04:00

466 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Contact in nonlinear static steps against closed forms: the contact patch test on
//! non-matching meshes, Newton's convergence with contact, and what implicit contact refuses.
//! Run with `--nocapture` to see the iterations.
use std::collections::BTreeMap;
use fieldcraft_model::*;
use fieldcraft_solver::contact::Status;
use fieldcraft_solver::{Limits, StaticResult, solve_nonlinear};
/// A model built block by block.
#[derive(Default)]
struct Blocks {
model: Model,
xyz: BTreeMap<u32, [f64; 3]>,
next_node: u32,
next_element: u32,
}
impl Blocks {
/// A structured block of `n` cells over the unit cube mapped by `map`, hex8 or tet4 (six
/// Kuhn tetrahedra per cell); its elements' labels.
fn block(&mut self, kind: ElementKind, n: [usize; 3], map: impl Fn([f64; 3]) -> [f64; 3]) -> Vec<u32> {
let first = self.next_node + 1;
let label = |i: [usize; 3]| first + (i[0] + (n[0] + 1) * (i[1] + (n[1] + 1) * i[2])) as u32;
for k in 0..=n[2] {
for j in 0..=n[1] {
for i in 0..=n[0] {
let x = map([i as f64 / n[0] as f64, j as f64 / n[1] as f64, k as f64 / n[2] as f64]);
let l = label([i, j, k]);
self.model.set_node(l, x).unwrap();
self.xyz.insert(l, x);
self.next_node = self.next_node.max(l);
}
}
}
let mut out = Vec::new();
for k in 0..n[2] {
for j in 0..n[1] {
for i in 0..n[0] {
let at = |d: [usize; 3]| label([i + d[0], j + d[1], k + d[2]]);
let cells: Vec<Vec<u32>> = match kind {
ElementKind::Hex8 => vec![
[[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]].iter().map(|d| at(*d)).collect(),
],
_ => [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]
.iter()
.map(|perm| {
let mut v = [[0usize; 3]; 4];
for s in 0..3 {
v[s + 1] = v[s];
v[s + 1][perm[s]] = 1;
}
let p = v.map(|c| c.map(|x| x as f64));
let e = |i: usize| [0, 1, 2].map(|k| p[i][k] - p[0][k]);
let (a, b, c) = (e(1), e(2), e(3));
let vol =
a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]);
if vol < 0.0 {
v.swap(1, 2);
}
v.iter().map(|d| at(*d)).collect()
})
.collect(),
};
for nodes in cells {
self.next_element += 1;
self.model.set_element(self.next_element, kind, nodes).unwrap();
out.push(self.next_element);
}
}
}
}
out
}
/// The faces of `elements` whose nodes all satisfy `f`.
fn faces(&self, elements: &[u32], f: impl Fn([f64; 3]) -> bool) -> Vec<ElementFace> {
let mut out = Vec::new();
for l in elements {
let e = self.model.element(*l).unwrap();
for (i, face) in e.kind.faces().iter().enumerate() {
if face.iter().all(|k| f(self.xyz[&e.nodes[*k]])) {
out.push(ElementFace { element: *l, face: i as u8 + 1 });
}
}
}
out
}
fn nodes(&self, f: impl Fn([f64; 3]) -> bool) -> Vec<u32> {
self.xyz.iter().filter(|(_, x)| f(**x)).map(|(l, _)| *l).collect()
}
fn node_set(&mut self, name: &str, f: impl Fn([f64; 3]) -> bool) {
let nodes = self.nodes(f);
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
}
fn face_set(&mut self, name: &str, elements: &[u32], f: impl Fn([f64; 3]) -> bool) {
let faces = self.faces(elements, f);
assert!(!faces.is_empty(), "no faces for `{name}`");
self.model.add_set(name, Set { faces, ..Set::default() }).unwrap();
}
fn bc(&mut self, name: &str, values: [Option<f64>; 3], f: impl Fn([f64; 3]) -> bool) {
self.node_set(name, f);
let [a, b, c] = values;
self.model
.add_boundary_condition(name, BoundaryCondition::Displacement { target: Target::Set(name.into()), values: [a, b, c, None, None, None] })
.unwrap();
self.model.set_boundary_condition_active("Step", name, true).unwrap();
}
/// One elastic material and section over every element, and the nonlinear step.
fn define(&mut self, young: f64, poisson: f64, settings: NonlinearStatic) {
let all: Vec<u32> = self.model.elements().keys().copied().collect();
self.model.add_set("all", Set { elements: all, ..Set::default() }).unwrap();
self.model.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young, poisson }), ..Material::default() }).unwrap();
self.model
.add_section(
"solid",
Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation: Formulation::default() },
)
.unwrap();
self.model.add_step(Step::new("Step", Procedure::NonlinearStatic(settings)), None).unwrap();
}
fn pair(&mut self, name: &str, main: &str, secondary: &str) {
let c = Contact::new(ContactSurfaces::Pair { main: main.into(), secondary: secondary.into() });
self.model.add_contact(name, c).unwrap();
}
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn report(r: &StaticResult) {
let h = r.nonlinear.as_ref().unwrap();
for inc in &h.increments {
let forces: Vec<String> = inc.iterations.iter().map(|i| format!("{:.1e}", i.force)).collect();
eprintln!("increment {} λ {:.3} aug {} [{}]", inc.number, inc.load_factor, inc.augmentations, forces.join(" "));
}
}
/// Two blocks pressed together across non-matching meshes (`lower` × `upper` cells, each of
/// `lower_kind` / `upper_kind`), between frictionless symmetry planes: a uniaxial stress.
fn patch(lower_kind: ElementKind, upper_kind: ElementKind, geometry: bool, distorted: bool) -> (Blocks, StaticResult) {
let mut b = Blocks::default();
// A graded lower block, its cells boxes of different sizes, or distorted (its faces stay the
// planes of the cube either way).
let low = if distorted {
b.block(lower_kind, [3, 3, 2], |p| [p[0] + 0.3 * p[0] * (1.0 - p[0]) * (p[1] - 0.5), p[1] + 0.3 * p[1] * (1.0 - p[1]) * (p[0] - 0.4), p[2]])
} else {
b.block(lower_kind, [3, 3, 2], |p| [p[0].powf(1.4), p[1].powf(0.8), p[2]])
};
let upper = [2, 2, 2];
let up = b.block(upper_kind, upper, |p| [p[0], p[1], 1.0 + p[2]]);
b.define(
1000.0,
0.3,
NonlinearStatic {
nonlinear_geometry: geometry,
initial_increment: 0.5,
// Tight for small sliding (linear: exact); finite sliding converges to the default ones.
tolerances: if geometry { Tolerances::default() } else { Tolerances { force: 1e-10, displacement: 1e-8, energy: 1e-16 } },
..NonlinearStatic::default()
},
);
b.face_set("lower_top", &low, |x| near(x[2], 1.0));
b.face_set("upper_bottom", &up, |x| near(x[2], 1.0));
b.pair("interface", "lower_top", "upper_bottom");
b.bc("x0", [Some(0.0), None, None], |x| near(x[0], 0.0));
b.bc("y0", [None, Some(0.0), None], |x| near(x[1], 0.0));
b.bc("z0", [None, None, Some(0.0)], |x| near(x[2], 0.0));
b.bc("push", [None, None, Some(-0.02)], |x| near(x[2], 2.0));
let r = solve_nonlinear(&b.model, Some("Step"), &Limits::default()).unwrap();
report(&r);
(b, r)
}
/// The contact patch test (Taylor & Papadopoulos 1991; Crisfield 2000): a uniform pressure
/// crosses a non-matching interface exactly, so every element of both blocks has the same
/// uniaxial stress, with nothing across.
#[test]
fn the_mortar_interface_passes_the_contact_patch_test() {
for (lower, upper, name) in [
(ElementKind::Hex8, ElementKind::Hex8, "hex8 on hex8"),
(ElementKind::Hex8, ElementKind::Tet4, "tet4 on hex8"),
(ElementKind::Tet4, ElementKind::Hex8, "hex8 on tet4"),
] {
for geometry in [false, true] {
// Small sliding is linear and exact; finite sliding is exact up to the gaps the
// augmentation leaves (their geometric terms, GAP_TOLERANCE) and the convergence.
let tol = if geometry { 1e-4 } else { 1e-9 };
let (b, r) = patch(lower, upper, geometry, false);
let szz: Vec<f64> = r.elements.iter().flat_map(|e| e.nodal.iter().map(|s| s[2])).collect();
let mean = szz.iter().sum::<f64>() / szz.len() as f64;
assert!(mean < 0.0, "{name}: {mean}");
for (e, s) in r.elements.iter().flat_map(|e| e.nodal.iter().map(move |s| (e.label, s))) {
assert!((s[2] - mean).abs() < tol * mean.abs(), "{name} ({geometry}) element {e}: σzz {} vs {mean}", s[2]);
assert!(s[0].abs() < tol * mean.abs() && s[1].abs() < tol * mean.abs(), "{name} ({geometry}) element {e}: {s:?}");
}
let c = &r.contacts[0];
// The pressure is uniform, equal to the stress, at every secondary node.
assert!(c.pressure.iter().all(|p| (p + mean).abs() < tol * mean.abs()), "{name}: {:?} vs {mean}", c.pressure);
// The contact force on the upper block balances the reaction at its top.
let top: f64 = b.nodes(|x| near(x[2], 2.0)).iter().map(|l| r.reaction[r.nodes.binary_search(l).unwrap()][2]).sum();
assert!((c.force[2] + top).abs() < tol * top.abs(), "{name}: {:?} vs {top}", c.force);
assert!(c.force[0].abs() < tol * top.abs() && c.force[1].abs() < tol * top.abs(), "{name}: {:?}", c.force);
assert!((c.normal_force + top).abs() < tol * top.abs(), "{name}: {} vs {top}", c.normal_force);
// Augmented to a gap tolerance far below the penetration a penalty alone leaves.
assert!(c.penetration < 1e-5, "{name}: {}", c.penetration);
}
}
}
/// Hertz contact of a cylinder on a flat (plane strain): a quarter model of an indenter whose
/// bottom is a circle of radius `r` on a block, symmetric about x = 0, one element thick in z
/// with z held (plane strain). Returns the model and the result.
fn hertz_cylinder(finite: bool, push: f64) -> (Blocks, StaticResult) {
let r = 10.0;
let mut b = Blocks::default();
let grade = |s: f64| s.powf(1.6);
let foundation = b.block(ElementKind::Hex8, [24, 14, 1], |p| [5.0 * grade(p[0]), -5.0 * grade(1.0 - p[1]), 0.2 * p[2]]);
let indenter = b.block(ElementKind::Hex8, [20, 10, 1], |p| {
let x = 2.5 * grade(p[0]);
[x, r - (r * r - x * x).sqrt() + 2.5 * grade(p[1]), 0.2 * p[2]]
});
b.define(1000.0, 0.3, NonlinearStatic { nonlinear_geometry: finite, initial_increment: 0.25, ..NonlinearStatic::default() });
b.face_set("flat", &foundation, |x| near(x[1], 0.0));
let bottom = |x: [f64; 3]| (x[1] - (r - (r * r - x[0] * x[0]).sqrt())).abs() < 1e-9;
b.face_set("round", &indenter, bottom);
b.pair("hertz", "flat", "round");
b.bc("symmetry", [Some(0.0), None, None], |x| near(x[0], 0.0));
b.bc("plane", [None, None, Some(0.0)], |_| true);
b.bc("base", [None, Some(0.0), None], |x| near(x[1], -5.0));
let top = 2.5 + r - (r * r - 2.5 * 2.5f64).sqrt();
b.bc("push", [None, Some(-push), None], move |x| {
x[1] > 2.5 && (x[1] - (r - (r * r - x[0] * x[0]).sqrt()) - 2.5).abs() < 1e-9 && x[1] <= top + 1e-9
});
let r = solve_nonlinear(&b.model, Some("Step"), &Limits::default()).unwrap();
report(&r);
(b, r)
}
/// Hertz's line contact (H. Hertz, *Über die Berührung fester elastischer Körper*, J. reine
/// angew. Math. 92:156–171, 1882; K. L. Johnson, *Contact Mechanics*, Cambridge 1985, §4.3):
/// for a load P per length, b = √(4 P R / (π E*)) and p₀ = 2 P / (π b), E* = E / (2 (1 − ν²))
/// for two bodies of one material.
#[test]
fn hertz_cylinder_on_a_flat() {
for finite in [false, true] {
let (b, r) = hertz_cylinder(finite, 0.03);
let c = &r.contacts[0];
let thickness = 0.2;
// The load per length: the reaction at the base over the slab's thickness, both halves.
let base: f64 = b.nodes(|x| near(x[1], -5.0)).iter().map(|l| r.reaction[r.nodes.binary_search(l).unwrap()][1]).sum();
let p = 2.0 * base / thickness;
let e_star = 1000.0 / (2.0 * (1.0 - 0.09));
let half_width = (4.0 * p * 10.0 / (std::f64::consts::PI * e_star)).sqrt();
let peak = 2.0 * p / (std::f64::consts::PI * half_width);
let found = c.pressure.iter().fold(0.0f64, |m, v| m.max(*v));
let (before, next) = edge(&b, c, 2);
eprintln!("finite {finite}: P {p:.4} b {half_width:.4} ({before:.4}..{next:.4}) p0 {peak:.4} found {found:.4}");
assert!((found - peak).abs() < 0.03 * peak, "p0 {found} vs {peak}");
assert!(before <= half_width && half_width <= next, "b {half_width} not in ({before}, {next})");
if finite {
quadratic(&r);
}
}
}
/// The contact's edge along the x axis (the nodes on the plane where coordinate `across` is 0):
/// the node before the last closed one and the first open one after it, so that the edge lies
/// within an element of the last closed node (whose status the augmentation's tolerance decides).
fn edge(b: &Blocks, c: &fieldcraft_solver::contact::ContactResult, across: usize) -> (f64, f64) {
let mut along: Vec<(f64, Status)> =
c.nodes.iter().zip(&c.status).filter(|(l, _)| near(b.xyz[l][across], 0.0)).map(|(l, s)| (b.xyz[l][0], *s)).collect();
along.sort_by(|x, y| x.0.total_cmp(&y.0));
let last = along.iter().rposition(|(_, s)| *s == Status::Closed).unwrap_or(0);
let before = last.checked_sub(1).and_then(|i| along.get(i)).map_or(0.0, |p| p.0);
let next = along.get(last + 1).map_or(f64::INFINITY, |p| p.0);
(before, next)
}
/// Newton's rate with contact: within each solution of an increment (its first, and each
/// augmentation's), once the residual is small every correction squares it, up to a constant
/// (the consistent tangent of the mortar integrals, segmentation and normal included).
fn quadratic(r: &StaticResult) {
let mut checked = 0;
for inc in &r.nonlinear.as_ref().unwrap().increments {
for w in inc.iterations.windows(2) {
let (a, b) = (w[0], w[1]);
// Below 1e-10 of the force scale the sums' round-off takes over.
if a.augmentation != b.augmentation || a.force > 1e-3 || b.force < 1e-10 {
continue;
}
assert!(b.force <= 100.0 * a.force * a.force, "increment {}: {:e} after {:e}", inc.number, b.force, a.force);
checked += 1;
}
}
assert!(checked >= 3, "too few iterations near the solution to judge ({checked})");
}
/// Two unit blocks, the upper one starting `overlap` into the lower one, pressed together.
fn stacked(overlap: f64, penetration: InitialPenetration) -> Blocks {
let mut b = Blocks::default();
let low = b.block(ElementKind::Hex8, [2, 2, 1], |p| p);
let up = b.block(ElementKind::Hex8, [3, 3, 1], |p| [p[0], p[1], 1.0 - overlap + p[2]]);
b.define(1000.0, 0.3, NonlinearStatic { nonlinear_geometry: true, initial_increment: 1.0, ..NonlinearStatic::default() });
b.face_set("lower_top", &low, |x| near(x[2], 1.0));
b.face_set("upper_bottom", &up, |x| near(x[2], 1.0 - overlap));
let mut c = Contact::new(ContactSurfaces::Pair { main: "lower_top".into(), secondary: "upper_bottom".into() });
c.initial_penetration = penetration;
b.model.add_contact("interface", c).unwrap();
b.bc("x0", [Some(0.0), None, None], |x| near(x[0], 0.0));
b.bc("y0", [None, Some(0.0), None], |x| near(x[1], 0.0));
b.bc("z0", [None, None, Some(0.0)], |x| near(x[2], 0.0));
b.bc("push", [None, None, Some(-0.01)], move |x| near(x[2], 2.0 - overlap));
b
}
#[test]
fn initial_overclosure_is_adjusted_or_refused() {
let b = stacked(1e-3, InitialPenetration::Adjust);
let r = solve_nonlinear(&b.model, Some("Step"), &Limits::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("start inside the main surface")), "{:?}", r.warnings);
// Adjusted, the blocks behave as if they started touching: the same stress as without the
// overlap.
let touching = solve_nonlinear(&stacked(0.0, InitialPenetration::Adjust).model, Some("Step"), &Limits::default()).unwrap();
let (a, t) = (r.elements[0].nodal[0][2], touching.elements[0].nodal[0][2]);
assert!((a - t).abs() < 1e-4 * t.abs(), "{a} vs {t}");
let e = solve_nonlinear(&stacked(1e-3, InitialPenetration::Refuse).model, Some("Step"), &Limits::default()).unwrap_err().to_string();
assert!(e.contains("starts") && e.contains("inside"), "{e}");
}
#[test]
fn contact_results_do_not_depend_on_the_thread_count() {
let b = stacked(0.0, InitialPenetration::Adjust);
let run = |threads| fieldcraft_parallel::with_threads(threads, || solve_nonlinear(&b.model, Some("Step"), &Limits::default()).unwrap());
let (one, many) = (run(1), run(4));
assert_eq!(one.displacement, many.displacement);
assert_eq!(one.contacts, many.contacts);
}
#[test]
fn what_implicit_contact_refuses() {
let solve = |m: &Model| solve_nonlinear(m, Some("Step"), &Limits::default()).unwrap_err().to_string();
// Linear analyses refuse every contact by name.
let mut b = stacked(0.0, InitialPenetration::Adjust);
b.model.add_step(Step::new("Linear", Procedure::Static {}), None).unwrap();
let e = fieldcraft_solver::solve_static(&b.model, Some("Linear"), &Limits::default()).unwrap_err().to_string();
assert!(e.contains("interface") && e.contains("linear static"), "{e}");
// General contact.
let mut b = stacked(0.0, InitialPenetration::Adjust);
b.model.add_contact("all", Contact::new(ContactSurfaces::General { surface: None })).unwrap();
assert!(solve(&b.model).contains("general"));
// Arc-length control.
let mut b = stacked(0.0, InitialPenetration::Adjust);
let arc = NonlinearStatic { arc_length: Some(ArcLength::default()), ..NonlinearStatic::default() };
b.model.replace_step(Step { procedure: Procedure::NonlinearStatic(arc), ..b.model.step("Step").unwrap().clone() }).unwrap();
assert!(solve(&b.model).contains("arc-length"));
}
/// Hertz's point contact (Johnson 1985, §4.2): a sphere of radius R on a flat of the same
/// material under a load P has the contact radius a = (3 P R / (4 E*))^(1/3) and the peak
/// pressure p₀ = 3 P / (2 π a²), E* = E / (2 (1 − ν²)). A quarter model, displacement controlled;
/// the load is the reaction at the base.
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn hertz_sphere_on_a_flat() {
let (r, e, nu) = (10.0, 1000.0, 0.3);
let mut b = Blocks::default();
let grade = |s: f64| s.powf(1.5);
let foundation = b.block(ElementKind::Hex8, [16, 16, 10], |p| [5.0 * grade(p[0]), 5.0 * grade(p[1]), -5.0 * grade(1.0 - p[2])]);
let cap = move |x: f64, y: f64| r - (r * r - x * x - y * y).sqrt();
let indenter = b.block(ElementKind::Hex8, [14, 14, 8], |p| {
let (x, y) = (2.5 * grade(p[0]), 2.5 * grade(p[1]));
let bottom = cap(x, y);
[x, y, bottom + (3.0 - bottom) * grade(p[2])]
});
b.define(e, nu, NonlinearStatic { nonlinear_geometry: false, initial_increment: 0.25, ..NonlinearStatic::default() });
b.face_set("flat", &foundation, |x| near(x[2], 0.0));
b.face_set("round", &indenter, move |x| (x[2] - cap(x[0], x[1])).abs() < 1e-9 && x[2] < 1.0);
b.pair("hertz", "flat", "round");
b.bc("x0", [Some(0.0), None, None], |x| near(x[0], 0.0));
b.bc("y0", [None, Some(0.0), None], |x| near(x[1], 0.0));
b.bc("base", [None, None, Some(0.0)], |x| near(x[2], -5.0));
b.bc("push", [None, None, Some(-0.04)], |x| near(x[2], 3.0));
let res = solve_nonlinear(&b.model, Some("Step"), &Limits::default()).unwrap();
report(&res);
let c = &res.contacts[0];
let base: f64 = b.nodes(|x| near(x[2], -5.0)).iter().map(|l| res.reaction[res.nodes.binary_search(l).unwrap()][2]).sum();
let p = 4.0 * base;
let e_star = e / (2.0 * (1.0 - nu * nu));
let a = (3.0 * p * r / (4.0 * e_star)).cbrt();
let peak = 3.0 * p / (2.0 * std::f64::consts::PI * a * a);
let found = c.pressure.iter().fold(0.0f64, |m, v| m.max(*v));
let (before, next) = edge(&b, c, 1);
eprintln!("P {p:.4} a {a:.4} ({before:.4}..{next:.4}) p0 {peak:.4} found {found:.4}");
assert!((found - peak).abs() < 0.05 * peak, "p0 {found} vs {peak}");
assert!(before <= a && a <= next, "a {a} not in ({before}, {next})");
}
/// Two blocks, the upper one pressed down by `down` and moved sideways by `side` at its top
/// (both in proportion over the step), with Coulomb friction `friction`; the reactions at the
/// top (normal, tangential) and the result.
fn sheared(down: f64, side: f64, friction: Friction, finite: bool) -> (f64, f64, StaticResult) {
let mut b = Blocks::default();
let low = b.block(ElementKind::Hex8, [3, 3, 1], |p| p);
let up = b.block(ElementKind::Hex8, [2, 2, 1], |p| [p[0], p[1], 1.0 + p[2]]);
b.define(1000.0, 0.3, NonlinearStatic { nonlinear_geometry: finite, initial_increment: 0.25, ..NonlinearStatic::default() });
b.face_set("lower_top", &low, |x| near(x[2], 1.0));
b.face_set("upper_bottom", &up, |x| near(x[2], 1.0));
let mut c = Contact::new(ContactSurfaces::Pair { main: "lower_top".into(), secondary: "upper_bottom".into() });
c.friction = friction;
b.model.add_contact("interface", c).unwrap();
b.bc("base", [Some(0.0), Some(0.0), Some(0.0)], |x| near(x[2], 0.0));
b.bc("top", [Some(side), Some(0.0), Some(-down)], |x| near(x[2], 2.0));
let r = solve_nonlinear(&b.model, Some("Step"), &Limits::default()).unwrap();
report(&r);
let sum = |k: usize| -> f64 { b.nodes(|x| near(x[2], 2.0)).iter().map(|l| r.reaction[r.nodes.binary_search(l).unwrap()][k]).sum() };
(-sum(2), sum(0), r)
}
/// Coulomb's law across a frictional interface: a tangential demand below μ N sticks (the blocks
/// shear as one, nothing slips), above it the interface slips and carries exactly μ N; with a
/// kinetic coefficient the slipping interface carries μ_k N.
#[test]
fn coulomb_friction_sticks_below_the_cone_and_slips_on_it() {
for finite in [false, true] {
let mu = 0.3;
// Stuck as one body, the shear the top displacement asks for: a frictionless trial gives
// the normal force, a stuck one with a very large μ the tangential force.
let (n, t, _) = sheared(0.01, 0.001, Friction::coulomb(5.0), finite);
let ratio = t / n;
eprintln!("finite {finite}: stuck N {n:.4} T {t:.4} ratio {ratio:.4}");
// Well below the cone (the shear concentrates at the edges of the interface, which slip
// first): sticks, the same forces as glued.
let below = 0.4 * mu / ratio;
let (n1, t1, r1) = sheared(0.01, 0.001 * below, Friction::coulomb(mu), finite);
assert!((t1 / n1 - 0.4 * mu).abs() < 1e-3 * mu, "finite {finite}: {} vs {}", t1 / n1, 0.4 * mu);
// The middle of the interface sticks (its corners, where the shear concentrates, may slip).
// (The upper block's 3 × 3 bottom nodes come first, in x then y order: the fifth.)
assert_eq!(r1.contacts[0].status[4], Status::Stick, "finite {finite}: {:?}", r1.contacts[0].status);
// Above it: slips, carrying μ N.
let (n2, t2, r2) = sheared(0.01, 0.001 * 2.0 * mu / ratio, Friction::coulomb(mu), finite);
// With finite sliding the upper block turns a little as it slides, and the contact acts
// along its face's normal, a few thousandths of a radian from the vertical.
let tol = if finite { 0.03 } else { 1e-3 };
assert!((t2 - mu * n2).abs() < tol * mu * n2, "finite {finite}: T {t2} vs μN {}", mu * n2);
assert!(r2.contacts[0].status.iter().all(|s| *s == Status::Slip), "finite {finite}: {:?}", r2.contacts[0].status);
let shear: f64 = r2.contacts[0].shear.iter().map(|s| s[0]).zip(&r2.contacts[0].pressure).map(|(s, p)| s / p).sum::<f64>()
/ r2.contacts[0].nodes.len() as f64;
// Node by node the nodal forces' ratio scatters a little about μ (the smoothing of the
// nodal pressure and shear differs at the interface's edges); the mean is μ.
assert!((shear + mu).abs() < 0.05 * mu, "finite {finite}: shear over pressure {shear}");
// Kinetic friction once slipping.
let decaying = Friction { static_coefficient: mu, kinetic: Some(0.2), decay: 1.0 };
let (n3, t3, _) = sheared(0.01, 0.001 * 2.0 * mu / ratio, decaying, finite);
assert!((t3 - 0.2 * n3).abs() < tol * 0.2 * n3, "finite {finite}: T {t3} vs μk N {}", 0.2 * n3);
}
}