use phyz_contact::gradient::{ContactRegime, classify, friction_sensitivity, impulse_sensitivity};
use phyz_contact::{ContactProblem, ContactRow, ContactSolverConfig, solve_contacts};
fn problem(n: usize, mu: f64, b: &[f64], coupling: f64) -> ContactProblem {
let dim = 3 * n;
let mut delassus = vec![0.0; dim * dim];
for i in 0..dim {
delassus[i * dim + i] = 1.0;
}
if n > 1 && coupling != 0.0 {
delassus[3] = coupling;
delassus[3 * dim] = coupling;
}
ContactProblem {
bodies: Vec::new(),
n,
delassus,
free_velocity: b.to_vec(),
rows: vec![
ContactRow {
mu,
restitution: 0.0,
depth: 1e-4,
..Default::default()
};
n
],
}
}
fn fd_wrt_b(p: &ContactProblem, cfg: &ContactSolverConfig, j: usize, h: f64) -> Vec<f64> {
let mut plus = p.clone();
plus.free_velocity[j] += h;
let mut minus = p.clone();
minus.free_velocity[j] -= h;
let sp = solve_contacts(&plus, cfg);
let sm = solve_contacts(&minus, cfg);
sp.impulses
.iter()
.zip(&sm.impulses)
.flat_map(|(a, b)| {
let d = (*a - *b) / (2.0 * h);
[d.x, d.y, d.z]
})
.collect()
}
#[test]
fn sticking_contact_gradient_matches_finite_difference() {
let cfg = ContactSolverConfig::gradients();
let p = problem(1, 0.8, &[-1.0, 0.05, -0.02], 0.0);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
assert_eq!(classify(&p, &sol, 1e-7)[0], ContactRegime::Sticking);
let sens = impulse_sensitivity(&p, &sol, &cfg).expect("sensitivity available");
let dim = 3;
for j in 0..dim {
let mut best = f64::INFINITY;
let mut errs = Vec::new();
for k in 0..4 {
let h = 1e-3 / 10f64.powi(k);
let fd = fd_wrt_b(&p, &cfg, j, h);
let err: f64 = (0..dim)
.map(|row| (fd[row] - sens[row * dim + j]).abs())
.fold(0.0, f64::max);
errs.push(err);
best = best.min(err);
}
assert!(
best < 1e-11,
"column {j}: best abs error {best} (sweep {errs:?})"
);
assert!(
errs[0] < 1e-9,
"column {j}: error at the largest h should already be exact: {errs:?}"
);
}
}
#[test]
fn sliding_contact_gradient_matches_finite_difference() {
let cfg = ContactSolverConfig::gradients();
let p = problem(1, 0.3, &[-1.0, 2.0, 0.0], 0.0);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
assert_eq!(classify(&p, &sol, 1e-7)[0], ContactRegime::Sliding);
let sens = impulse_sensitivity(&p, &sol, &cfg).expect("sensitivity available");
let dim = 3;
let fd = fd_wrt_b(&p, &cfg, 0, 1e-6);
for row in 0..dim {
let a = sens[row * dim];
assert!(
(fd[row] - a).abs() < 1e-4,
"row {row}: analytic {a} vs fd {}",
fd[row]
);
}
}
#[test]
fn coupled_contact_gradient_matches_finite_difference() {
let cfg = ContactSolverConfig::gradients();
let p = problem(2, 0.9, &[-1.0, 0.0, 0.0, -1.0, 0.0, 0.0], 0.5);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
let sens = impulse_sensitivity(&p, &sol, &cfg).expect("sensitivity available");
let _dim = 6;
let cross = sens[3];
assert!(
cross.abs() > 1e-3,
"expected non-zero cross-sensitivity, got {cross}"
);
let fd = fd_wrt_b(&p, &cfg, 3, 1e-6);
assert!(
(fd[0] - cross).abs() < 1e-4,
"cross term: analytic {cross} vs fd {}",
fd[0]
);
}
#[test]
fn friction_gradient_is_zero_when_sticking_and_nonzero_when_sliding() {
let cfg = ContactSolverConfig::gradients();
let stick = problem(1, 0.8, &[-1.0, 0.05, 0.0], 0.0);
let s = solve_contacts(&stick, &cfg);
assert_eq!(classify(&stick, &s, 1e-7)[0], ContactRegime::Sticking);
let g = friction_sensitivity(&stick, &s, &cfg).unwrap();
for (row, v) in g.iter().enumerate() {
assert!(v.abs() < 1e-12, "sticking df/dmu row {row} = {v}");
}
let mut bumped = stick.clone();
bumped.rows[0].mu += 1e-4;
let s2 = solve_contacts(&bumped, &cfg);
assert!((s2.impulses[0] - s.impulses[0]).norm() < 1e-12);
let slide = problem(1, 0.3, &[-1.0, 2.0, 0.0], 0.0);
let s = solve_contacts(&slide, &cfg);
assert_eq!(classify(&slide, &s, 1e-7)[0], ContactRegime::Sliding);
let g = friction_sensitivity(&slide, &s, &cfg).unwrap();
let tangential = g[1];
assert!(
tangential.abs() > 1e-3,
"sliding df_u/dmu should be non-zero, got {tangential}"
);
let h = 1e-6;
let mut plus = slide.clone();
plus.rows[0].mu += h;
let mut minus = slide.clone();
minus.rows[0].mu -= h;
let fd = (solve_contacts(&plus, &cfg).impulses[0].y
- solve_contacts(&minus, &cfg).impulses[0].y)
/ (2.0 * h);
assert!(
(fd - tangential).abs() / fd.abs().max(1e-9) < 1e-3,
"df_u/dmu: analytic {tangential} vs fd {fd}"
);
}
#[test]
fn separating_contact_has_zero_sensitivity() {
let cfg = ContactSolverConfig::gradients();
let p = problem(1, 0.5, &[1.0, 0.0, 0.0], 0.0);
let sol = solve_contacts(&p, &cfg);
assert_eq!(classify(&p, &sol, 1e-7)[0], ContactRegime::Separating);
assert!(sol.impulses[0].norm() < 1e-12);
let sens = impulse_sensitivity(&p, &sol, &cfg).unwrap();
for v in &sens {
assert!(v.abs() < 1e-12, "separating sensitivity {v}");
}
}
#[test]
fn unconverged_solve_yields_no_gradient() {
let cfg = ContactSolverConfig {
max_iterations: 1,
tolerance: 1e-16,
..ContactSolverConfig::gradients()
};
let p = problem(2, 0.5, &[-1.0, 0.3, 0.0, -1.0, 0.3, 0.0], 0.9);
let sol = solve_contacts(&p, &cfg);
assert!(!sol.converged, "test needs an unconverged solve");
assert!(impulse_sensitivity(&p, &sol, &cfg).is_none());
}
#[test]
fn stick_slip_transition_is_a_documented_gradient_discontinuity() {
let cfg = ContactSolverConfig::gradients();
let mu = 0.5;
let mut lo = 0.0;
let mut hi = 5.0;
for _ in 0..60 {
let mid = 0.5 * (lo + hi);
let p = problem(1, mu, &[-1.0, mid, 0.0], 0.0);
let s = solve_contacts(&p, &cfg);
if classify(&p, &s, 1e-9)[0] == ContactRegime::Sliding {
hi = mid;
} else {
lo = mid;
}
}
let critical = 0.5 * (lo + hi);
let p = problem(1, mu, &[-1.0, critical, 0.0], 0.0);
let sol = solve_contacts(&p, &cfg);
let sens = impulse_sensitivity(&p, &sol, &cfg).unwrap();
let analytic = sens[3 + 1];
let h = 0.05;
let fd = fd_wrt_b(&p, &cfg, 1, h)[1];
assert!(
(fd - analytic).abs() > 1e-3,
"expected the transition to be non-smooth; analytic {analytic}, fd {fd}"
);
}
#[test]
fn regularization_smooths_the_response() {
let spread = |reg: f64| {
let cfg = ContactSolverConfig {
regularization: reg,
..ContactSolverConfig::gradients()
};
let sample = |b_n: f64| {
let p = problem(1, 0.5, &[b_n, 0.0, 0.0], 0.0);
let s = solve_contacts(&p, &cfg);
s.impulses[0].x
};
let h = 1e-3;
let left = (sample(-h) - sample(-2.0 * h)) / h;
let right = (sample(2.0 * h) - sample(h)) / h;
(left - right).abs()
};
let stiff = spread(1e-8);
let soft = spread(1e-1);
assert!(
soft <= stiff + 1e-9,
"softening should not worsen the kink: stiff {stiff}, soft {soft}"
);
}
#[test]
fn depth_gradient_matches_finite_difference() {
use phyz_contact::ContactMaterial;
use phyz_contact::gradient::depth_sensitivity;
let cfg = ContactSolverConfig::gradients();
let dt = 1e-3;
let material = ContactMaterial {
friction: 0.9,
..ContactMaterial::default()
};
let depth = 5e-3;
let build = |d: f64| {
let mut p = problem(1, material.friction, &[-0.4, 0.02, 0.0], 0.0);
p.rows[0] = ContactRow::from_material(&material, d, dt, 0.0);
p
};
let p = build(depth);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
assert_eq!(
classify(&p, &sol, 1e-7)[0],
ContactRegime::Sticking,
"this fixture is meant to exercise the sticking branch"
);
let sens = depth_sensitivity(&p, &sol, &cfg, &material.solref, dt).expect("converged");
for h in [1e-5, 1e-6, 1e-7] {
let plus = solve_contacts(&build(depth + h), &cfg);
let minus = solve_contacts(&build(depth - h), &cfg);
let fd = (plus.impulses[0] - minus.impulses[0]) / (2.0 * h);
for (k, want) in [fd.x, fd.y, fd.z].iter().enumerate() {
let got = sens[k * p.n];
assert!(
(got - want).abs() <= 1e-6 * want.abs().max(1.0),
"h={h}: d f[{k}]/d depth analytic {got} vs finite difference {want}"
);
}
}
assert!(
sens[0] > 0.0,
"d f_n / d depth = {} should be positive",
sens[0]
);
}
#[test]
fn depth_gradient_is_zero_when_separating() {
use phyz_contact::ContactMaterial;
use phyz_contact::gradient::depth_sensitivity;
let cfg = ContactSolverConfig::simulation();
let material = ContactMaterial::default();
let dt = 1e-3;
let mut p = problem(1, 0.5, &[2.0, 0.0, 0.0], 0.0);
p.rows[0] = ContactRow::from_material(&material, 1e-5, dt, 0.0);
let sol = solve_contacts(&p, &cfg);
assert_eq!(classify(&p, &sol, 1e-7)[0], ContactRegime::Separating);
let sens = depth_sensitivity(&p, &sol, &cfg, &material.solref, dt).expect("converged");
for (k, v) in sens.iter().enumerate() {
assert_eq!(*v, 0.0, "separating contact has a depth sensitivity at {k}");
}
}
#[test]
fn depth_gradient_inside_the_margin_matches_finite_difference() {
use phyz_contact::ContactMaterial;
use phyz_contact::gradient::depth_sensitivity;
let cfg = ContactSolverConfig::gradients();
let dt = 1e-3;
let material = ContactMaterial {
friction: 0.9,
..ContactMaterial::default()
};
let margin = material.margin;
assert!(margin > 0.0);
let build = |d: f64| {
let mut p = problem(1, material.friction, &[-0.4, 0.02, 0.0], 0.0);
p.rows[0] = ContactRow::from_material(&material, d, dt, 0.0);
p
};
let mut worst = 0.0f64;
for frac in [0.05, 0.15, 0.3, 0.5, 0.65, 0.8] {
let depth = -frac * margin;
let p = build(depth);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
assert_ne!(
classify(&p, &sol, 1e-7)[0],
ContactRegime::Separating,
"gap {frac} of the band should still carry load"
);
let sens = depth_sensitivity(&p, &sol, &cfg, &material.solref, dt).expect("converged");
assert!(
sens[0] > 0.0,
"at gap {frac} of the band, closing the gap must increase the \
normal impulse; got d f_n / d depth = {}",
sens[0]
);
for h in [1e-7, 1e-8] {
let plus = solve_contacts(&build(depth + h), &cfg);
let minus = solve_contacts(&build(depth - h), &cfg);
assert!(plus.converged && minus.converged);
let fd = (plus.impulses[0] - minus.impulses[0]) / (2.0 * h);
for (k, want) in [fd.x, fd.y, fd.z].iter().enumerate() {
let got = sens[k * p.n];
let rel = (got - want).abs() / want.abs().max(1e-9);
worst = worst.max(rel);
assert!(
rel < 1e-5,
"gap {frac} of band, h={h}: d f[{k}]/d depth analytic {got} \
vs finite difference {want} (rel {rel:.3e})"
);
}
}
}
println!("worst relative FD error inside the margin band: {worst:.3e}");
}
#[test]
fn depth_gradient_has_a_documented_hinge_at_zero_depth() {
use phyz_contact::ContactMaterial;
use phyz_contact::gradient::depth_sensitivity;
let cfg = ContactSolverConfig::gradients();
let dt = 1e-3;
let material = ContactMaterial {
friction: 0.9,
..ContactMaterial::default()
};
let build = |d: f64| {
let mut p = problem(1, material.friction, &[-0.4, 0.02, 0.0], 0.0);
p.rows[0] = ContactRow::from_material(&material, d, dt, 0.0);
p
};
let sens_at = |depth: f64| {
let p = build(depth);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
depth_sensitivity(&p, &sol, &cfg, &material.solref, dt).expect("converged")[0]
};
let fd_at = |depth: f64, h: f64| {
let plus = solve_contacts(&build(depth + h), &cfg);
let minus = solve_contacts(&build(depth - h), &cfg);
assert!(plus.converged && minus.converged);
(plus.impulses[0].x - minus.impulses[0].x) / (2.0 * h)
};
let off = 1e-5; let h = 1e-8; for (side, depth) in [("below", -off), ("above", off)] {
let analytic = sens_at(depth);
let fd = fd_at(depth, h);
let rel = (analytic - fd).abs() / fd.abs().max(1e-9);
assert!(
rel < 1e-5,
"{side} zero depth: analytic {analytic} vs finite difference {fd} \
(rel {rel:.3e})"
);
}
let eps = 1e-9;
let below = sens_at(-eps);
let above = sens_at(eps);
assert!(
above > below,
"crossing into penetration must strengthen the response: {below} -> {above}"
);
let p = build(eps);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
let db = impulse_sensitivity(&p, &sol, &cfg).expect("converged");
let erp = material.solref.error_reduction(dt);
let predicted = -db[0] * p.rows[0].impedance * erp / dt;
let observed = above - below;
assert!(
(observed - predicted).abs() / predicted.abs() < 1e-3,
"the hinge must be exactly the bias term switching on: \
observed {observed} vs predicted {predicted}"
);
let force_at = |depth: f64| {
let sol = solve_contacts(&build(depth), &cfg);
assert!(sol.converged);
sol.impulses[0].x
};
let f_below = force_at(-eps);
let f_above = force_at(eps);
assert!(
(f_above - f_below).abs() < 1e-6 * f_above.abs().max(1e-9),
"force must be continuous across zero depth: {f_below} vs {f_above}"
);
}
#[test]
fn depth_gradient_inside_the_solimp_sigmoid_matches_finite_difference() {
use phyz_contact::ContactMaterial;
use phyz_contact::gradient::depth_sensitivity;
let cfg = ContactSolverConfig::gradients();
let dt = 1e-3;
let material = ContactMaterial {
friction: 0.9,
..ContactMaterial::default()
};
let width = material.solimp.width;
let build = |d: f64| {
let mut p = problem(1, material.friction, &[-0.4, 0.02, 0.0], 0.0);
p.rows[0] = ContactRow::from_material(&material, d, dt, 0.0);
p
};
let mut worst = 0.0f64;
for frac in [0.2, 0.4, 0.6, 0.8] {
let depth = frac * width;
let p = build(depth);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
let sens = depth_sensitivity(&p, &sol, &cfg, &material.solref, dt).expect("converged");
for h in [1e-7, 1e-8] {
let plus = solve_contacts(&build(depth + h), &cfg);
let minus = solve_contacts(&build(depth - h), &cfg);
let fd = (plus.impulses[0] - minus.impulses[0]) / (2.0 * h);
for (k, want) in [fd.x, fd.y, fd.z].iter().enumerate() {
let got = sens[k * p.n];
let rel = (got - want).abs() / want.abs().max(1e-9);
worst = worst.max(rel);
assert!(
rel < 1e-5,
"depth {frac}*width, h={h}: d f[{k}]/d depth analytic {got} \
vs finite difference {want} (rel {rel:.3e})"
);
}
}
}
println!("worst relative FD error inside the solimp sigmoid: {worst:.3e}");
}
#[test]
fn coupled_depth_gradient_in_the_margin_matches_finite_difference() {
use phyz_contact::ContactMaterial;
use phyz_contact::gradient::depth_sensitivity;
let cfg = ContactSolverConfig::gradients();
let dt = 1e-3;
let material = ContactMaterial {
friction: 0.9,
..ContactMaterial::default()
};
let margin = material.margin;
let other_depth = 2e-4;
let build = |d0: f64| {
let mut p = problem(
2,
material.friction,
&[-0.4, 0.02, 0.0, -0.5, 0.0, 0.0],
0.35,
);
p.rows[0] = ContactRow::from_material(&material, d0, dt, 0.0);
p.rows[1] = ContactRow::from_material(&material, other_depth, dt, 0.0);
p
};
let depth0 = -0.3 * margin;
let p = build(depth0);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
let sens = depth_sensitivity(&p, &sol, &cfg, &material.solref, dt).expect("converged");
let mut worst = 0.0f64;
for h in [1e-7, 1e-8] {
let plus = solve_contacts(&build(depth0 + h), &cfg);
let minus = solve_contacts(&build(depth0 - h), &cfg);
assert!(plus.converged && minus.converged);
for c in 0..2 {
let fd = (plus.impulses[c] - minus.impulses[c]) / (2.0 * h);
for (k, want) in [fd.x, fd.y, fd.z].iter().enumerate() {
let got = sens[(3 * c + k) * p.n];
let rel = (got - want).abs() / want.abs().max(1e-9);
worst = worst.max(rel);
assert!(
rel < 1e-5,
"h={h}: d f{c}[{k}]/d depth0 analytic {got} vs finite \
difference {want} (rel {rel:.3e})"
);
}
}
}
assert!(
sens[3 * p.n].abs() > 1e-6,
"contact 1 must respond to contact 0's depth through A; got {}",
sens[3 * p.n]
);
println!("worst relative FD error, coupled, contact 0 in band: {worst:.3e}");
}
#[test]
fn rotating_slip_direction_sensitivity_matches_finite_difference() {
let cfg = ContactSolverConfig::gradients();
let p = problem(1, 0.4, &[-1.0, 1.5, 0.8], 0.0);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
assert_eq!(classify(&p, &sol, 1e-7)[0], ContactRegime::Sliding);
let sens = impulse_sensitivity(&p, &sol, &cfg).expect("sensitivity available");
let dim = 3;
for j in 0..dim {
let fd = fd_wrt_b(&p, &cfg, j, 1e-6);
for row in 0..dim {
let a = sens[row * dim + j];
assert!(
(fd[row] - a).abs() < 5e-5,
"col {j} row {row}: analytic {a} vs fd {}",
fd[row]
);
}
}
let fd_w = fd_wrt_b(&p, &cfg, 2, 1e-6);
assert!(
fd_w[1].abs() > 1e-3,
"test setup: the slip rotation channel should be live, got {}",
fd_w[1]
);
assert!(
(sens[dim + 2] - fd_w[1]).abs() / fd_w[1].abs() < 1e-3,
"df_u/db_w: analytic {} vs fd {}",
sens[dim + 2],
fd_w[1]
);
}
#[test]
fn coupled_sliding_contacts_match_finite_difference() {
let cfg = ContactSolverConfig::gradients();
let p = problem(2, 0.5, &[-1.0, 1.2, 0.5, -0.8, 0.9, -0.4], 0.3);
let sol = solve_contacts(&p, &cfg);
assert!(sol.converged);
let regimes = classify(&p, &sol, 1e-7);
assert_eq!(regimes[0], ContactRegime::Sliding);
assert_eq!(regimes[1], ContactRegime::Sliding);
let sens = impulse_sensitivity(&p, &sol, &cfg).expect("sensitivity available");
let dim = 6;
for j in 0..dim {
let fd = fd_wrt_b(&p, &cfg, j, 1e-6);
for row in 0..dim {
let a = sens[row * dim + j];
assert!(
(fd[row] - a).abs() < 5e-5,
"col {j} row {row}: analytic {a} vs fd {}",
fd[row]
);
}
}
}