use phyz_contact::{ContactMaterial, ContactSolverConfig};
use phyz_diff::{
ConvexAdjointError, ConvexContactRollout, FinalStateObjective, convex_adjoint_gradient,
convex_rollout_objective,
};
use phyz_math::{DVec, GRAVITY, Mat3, SpatialInertia, SpatialTransform, Vec3};
use phyz_model::{Geometry, Model, ModelBuilder};
fn plank_half() -> Vec3 {
Vec3::new(0.30, 0.12, 0.02)
}
fn block_half() -> Vec3 {
Vec3::new(0.04, 0.04, 0.04)
}
#[allow(dead_code)]
const PLANK_X: usize = 3;
const BLOCK_X: usize = 9;
fn inertia(mass: f64, h: Vec3) -> SpatialInertia {
let c = mass / 3.0;
SpatialInertia::new(
mass,
Vec3::zeros(),
Mat3::from_diagonal(&Vec3::new(
c * (h.y * h.y + h.z * h.z),
c * (h.x * h.x + h.z * h.z),
c * (h.x * h.x + h.y * h.y),
)),
)
}
fn plank_and_block(plank_mass: f64, block_mass: f64) -> Model {
let mut model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -GRAVITY))
.dt(1e-3)
.add_free_body(
"plank",
-1,
SpatialTransform::identity(),
inertia(plank_mass, plank_half()),
)
.add_free_body(
"block",
-1,
SpatialTransform::identity(),
inertia(block_mass, block_half()),
)
.build();
model.bodies[0].geometry = Some(Geometry::Box {
half_extents: plank_half(),
});
model.bodies[1].geometry = Some(Geometry::Box {
half_extents: block_half(),
});
model
}
fn stacked_q0() -> Vec<f64> {
vec![
0.0,
0.0,
0.0,
0.0,
0.0,
plank_half().z, 0.0,
0.0,
0.0,
0.0,
0.0,
2.0 * plank_half().z + block_half().z, ]
}
fn q_objective(i: usize) -> FinalStateObjective<'static> {
let value: &'static dyn Fn(&[f64], &[f64]) -> f64 =
Box::leak(Box::new(move |q: &[f64], _: &[f64]| q[i]));
type GradFn = dyn Fn(&[f64], &[f64]) -> (Vec<f64>, Vec<f64>);
let gradient: &'static GradFn = Box::leak(Box::new(move |q: &[f64], v: &[f64]| {
let mut gq = vec![0.0; q.len()];
gq[i] = 1.0;
(gq, vec![0.0; v.len()])
}));
FinalStateObjective { value, gradient }
}
struct Case {
name: &'static str,
q0: Vec<f64>,
v0: Vec<f64>,
steps: usize,
plank_push: f64,
obj_index: usize,
tol: f64,
}
fn check(case: &Case) {
let model = plank_and_block(2.0, 1.0);
let material = ContactMaterial {
friction: 0.9,
..ContactMaterial::default()
};
let config = ContactSolverConfig::gradients();
let obj = q_objective(case.obj_index);
let push = case.plank_push;
let ctrl = move |_t: usize| {
let mut u = DVec::zeros(12);
u[PLANK_X] = push;
u
};
fn make<'a>(
m: &'a Model,
material: &ContactMaterial,
config: ContactSolverConfig,
q0: &[f64],
v0: &[f64],
steps: usize,
c: &'a dyn Fn(usize) -> DVec,
) -> ConvexContactRollout<'a> {
ConvexContactRollout {
model: m,
ground_height: 0.0,
material: material.clone(),
config,
q0: DVec::from_slice(q0),
v0: DVec::from_slice(v0),
steps,
ctrl: c,
}
}
let rollout = make(
&model, &material, config, &case.q0, &case.v0, case.steps, &ctrl,
);
let g = convex_adjoint_gradient(&rollout, &obj)
.expect("body-body contacts must no longer be refused");
let h = 1e-7;
let mut rows: Vec<(String, f64, f64)> = Vec::new();
for i in 0..12 {
let mut qp = case.q0.clone();
let mut qm = case.q0.clone();
qp[i] += h;
qm[i] -= h;
let fp = convex_rollout_objective(
&make(&model, &material, config, &qp, &case.v0, case.steps, &ctrl),
&obj,
);
let fm = convex_rollout_objective(
&make(&model, &material, config, &qm, &case.v0, case.steps, &ctrl),
&obj,
);
rows.push((format!("dJ/dq0[{i}]"), g.d_q0[i], (fp - fm) / (2.0 * h)));
}
for i in 0..12 {
let mut vp = case.v0.clone();
let mut vm = case.v0.clone();
vp[i] += h;
vm[i] -= h;
let fp = convex_rollout_objective(
&make(&model, &material, config, &case.q0, &vp, case.steps, &ctrl),
&obj,
);
let fm = convex_rollout_objective(
&make(&model, &material, config, &case.q0, &vm, case.steps, &ctrl),
&obj,
);
rows.push((format!("dJ/dv0[{i}]"), g.d_v0[i], (fp - fm) / (2.0 * h)));
}
let scale = rows.iter().fold(1e-4f64, |m, r| m.max(r.2.abs()));
let mut max_rel: f64 = 0.0;
for (label, adj, fd) in &rows {
let rel = (adj - fd).abs() / fd.abs().max(scale);
max_rel = max_rel.max(rel);
assert!(
rel <= case.tol,
"{}: {label} adjoint {adj} vs FD {fd} (rel {rel:.3e})",
case.name
);
}
eprintln!("{}: max relative error {max_rel:.3e}", case.name);
}
#[test]
fn block_carried_by_friction_on_a_driven_plank() {
check(&Case {
name: "block_carried_by_friction",
q0: stacked_q0(),
v0: vec![0.0; 12],
steps: 120,
plank_push: 6.0,
obj_index: BLOCK_X,
tol: 1e-3,
});
}
#[test]
fn exact_symmetry_gives_a_lane_outside_the_clarke_set() {
let model = plank_and_block(2.0, 1.0);
let material = ContactMaterial {
friction: 0.9,
..ContactMaterial::default()
};
let config = ContactSolverConfig::gradients();
let obj = q_objective(BLOCK_X);
let ctrl = |_t: usize| DVec::zeros(12);
let mut v0 = vec![0.0; 12];
v0[BLOCK_X] = 0.8;
let rollout = ConvexContactRollout {
model: &model,
ground_height: 0.0,
material,
config,
q0: DVec::from_slice(&stacked_q0()),
v0: DVec::from_slice(&v0),
steps: 80,
ctrl: &ctrl,
};
let g = convex_adjoint_gradient(&rollout, &obj).expect("the solve itself converges");
let roll = g.d_q0[0];
assert!(
roll.abs() < 1e-9,
"the symmetric regime should report a flat roll lane, got {roll:.3e}"
);
assert!(
!(-1.391e-2..=-5.457e-3).contains(&roll),
"0 must sit outside the Clarke interval — that is the documented defect"
);
}
#[test]
fn block_sliding_on_the_plank_decelerates() {
let mut v0 = vec![0.0; 12];
v0[BLOCK_X] = 0.8;
let mut q0 = stacked_q0();
q0[0] = 1e-3;
check(&Case {
name: "block_sliding_on_plank",
q0,
v0,
steps: 80,
plank_push: 0.0,
obj_index: BLOCK_X,
tol: 1e-3,
});
}
#[test]
fn slip_to_stick_transition_refuses_rather_than_guessing() {
let model = plank_and_block(2.0, 1.0);
let material = ContactMaterial {
friction: 0.9,
..ContactMaterial::default()
};
let mut v0 = vec![0.0; 12];
v0[BLOCK_X] = 0.8;
let ctrl = |_t: usize| DVec::zeros(12);
let rollout = ConvexContactRollout {
model: &model,
ground_height: 0.0,
material,
config: ContactSolverConfig::gradients(),
q0: DVec::from_slice(&stacked_q0()),
v0: DVec::from_slice(&v0),
steps: 150,
ctrl: &ctrl,
};
match convex_adjoint_gradient(&rollout, &q_objective(BLOCK_X)) {
Err(ConvexAdjointError::Unconverged { step, residual, .. }) => {
eprintln!("slip_to_stick: refused at step {step}, residual {residual:.3e}");
}
Err(other) => panic!("expected a loud non-convergence, got {other:?}"),
Ok(_) => panic!("the transition must not silently yield a gradient"),
}
}