use phyz::diff::{
AdjointRollout, CollisionMesh, ContactSetup, DofLayout, FinalStateObjective, GroundContact,
adjoint_rollout_gradient, rollout_objective,
};
use phyz::math::{DVec, Mat3, SpatialInertia, SpatialTransform, Vec3};
use phyz::model::{Joint, JointType, Model, ModelBuilder};
fn si_from(p: [f64; 10]) -> SpatialInertia {
SpatialInertia::new(
p[0],
Vec3::new(p[1], p[2], p[3]),
Mat3::new(p[4], p[7], p[8], p[7], p[5], p[9], p[8], p[9], p[6]),
)
}
fn q_objective(i: usize) -> FinalStateObjective<'static> {
FinalStateObjective {
value: Box::leak(Box::new(move |q: &[f64], _v: &[f64]| q[i])),
gradient: 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()])
})),
}
}
fn v_objective(i: usize) -> FinalStateObjective<'static> {
FinalStateObjective {
value: Box::leak(Box::new(move |_q: &[f64], v: &[f64]| v[i])),
gradient: Box::leak(Box::new(move |q: &[f64], v: &[f64]| {
let mut gv = vec![0.0; v.len()];
gv[i] = 1.0;
(vec![0.0; q.len()], gv)
})),
}
}
#[allow(clippy::too_many_arguments)]
fn gate_inertia_vs_fd(
build: &dyn Fn(&[[f64; 10]]) -> Model,
nominal: &[[f64; 10]],
q0: &dyn Fn(&Model) -> Vec<f64>,
v0: Vec<f64>,
steps: usize,
ctrl: &dyn Fn(usize) -> DVec,
objective: &FinalStateObjective,
h_for: &dyn Fn(usize) -> f64,
gate: f64,
dead: f64,
) -> usize {
let j_at = |ps: &[[f64; 10]]| -> f64 {
let m = build(ps);
let ro = AdjointRollout {
q0: q0(&m),
v0: v0.clone(),
steps,
ctrl,
model: &m,
contact: None,
};
rollout_objective(&ro, objective)
};
let model = build(nominal);
let rollout = AdjointRollout {
q0: q0(&model),
v0: v0.clone(),
steps,
ctrl,
model: &model,
contact: None,
};
let g = adjoint_rollout_gradient(&rollout, objective);
let mut live = 0;
for b in 0..nominal.len() {
for k in 0..10 {
let h = h_for(k);
let perturb = |sign: f64| -> f64 {
let mut ps = nominal.to_vec();
ps[b][k] += sign * h;
j_at(&ps)
};
let fd = (perturb(1.0) - perturb(-1.0)) / (2.0 * h);
let adj = g.d_inertia[b][k];
if fd.abs() > dead {
live += 1;
let rel = (adj - fd).abs() / fd.abs();
assert!(
rel <= gate,
"dJ/dπ[body {b}][{k}]: adjoint {adj} vs fd {fd} (rel {rel:.3e})"
);
} else {
assert!(
adj.abs() <= dead,
"dJ/dπ[body {b}][{k}]: fd dead ({fd}) but adjoint {adj}"
);
}
}
}
live
}
#[test]
fn layout_splits_q_and_v_for_multi_dof_joints() {
let model = ModelBuilder::new()
.add_free_body("base", -1, SpatialTransform::identity(), si_from(BASE_PI))
.add_spherical_body("ball", 0, SpatialTransform::identity(), si_from(LINK_PI))
.add_revolute_body("link", 1, SpatialTransform::identity(), si_from(LINK_PI))
.build();
let layout = DofLayout::of(&model);
assert_eq!((layout.nq, layout.nv), (7 + 4 + 1, 6 + 3 + 1));
assert_eq!(layout.q_offsets, vec![0, 7, 11]);
assert_eq!(layout.v_offsets, vec![0, 6, 9]);
assert_eq!(model.nq, model.nv);
assert_ne!(layout.nq, model.nq);
let q = layout.neutral_q(&model);
assert_eq!(q.len(), layout.nq);
assert_eq!(q[3], 1.0, "free joint quaternion w");
assert_eq!(q[7], 1.0, "spherical joint quaternion w");
assert_eq!(q.iter().filter(|&&x| x != 0.0).count(), 2);
}
const BASE_PI: [f64; 10] = [10.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0];
const LINK_PI: [f64; 10] = [1.0, 0.1, 0.0, 0.0, 0.01, 0.01, 0.01, 0.0, 0.0, 0.0];
#[test]
fn free_body_thrust_closed_form() {
const MASS: f64 = 2.5;
const FORCE: f64 = 40.0;
const G: f64 = 9.81;
const DT: f64 = 1.0 / 480.0;
const STEPS: usize = 96;
let model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -G))
.dt(DT)
.add_free_body(
"pod",
-1,
SpatialTransform::identity(),
si_from([MASS, 0.0, 0.0, 0.0, 0.4, 0.4, 0.4, 0.0, 0.0, 0.0]),
)
.build();
let layout = DofLayout::of(&model);
let ctrl = |_t: usize| DVec::from_slice(&[0.0, 0.0, 0.0, 0.0, 0.0, FORCE]);
let rollout = AdjointRollout {
model: &model,
contact: None,
q0: layout.neutral_q(&model),
v0: vec![0.0; layout.nv],
steps: STEPS,
ctrl: &ctrl,
};
let obj = v_objective(5);
let g = adjoint_rollout_gradient(&rollout, &obj);
let t_total = STEPS as f64 * DT;
let j_expected = t_total * (FORCE / MASS - G);
assert!(
(g.objective - j_expected).abs() / j_expected.abs() <= 1e-12,
"J = {} vs closed form {j_expected}",
g.objective
);
let d_mass_expected = -t_total * FORCE / (MASS * MASS);
let rel = (g.d_inertia[0][0] - d_mass_expected).abs() / d_mass_expected.abs();
assert!(
rel <= 1e-12,
"dJ/dm = {} vs closed form {d_mass_expected} (rel {rel:.3e})",
g.d_inertia[0][0]
);
for (k, &gk) in g.d_inertia[0].iter().enumerate().skip(1) {
assert!(
gk.abs() <= 1e-10 * d_mass_expected.abs(),
"π[{k}] should not influence a non-rotating pod, got {gk}"
);
}
}
#[test]
fn free_base_qdd_matches_concrete_aba() {
const DT: f64 = 1e-3;
let joint = Joint {
joint_type: JointType::Revolute,
parent_to_joint: SpatialTransform::from_translation(Vec3::new(0.25, 0.0, 0.0)),
axis: Vec3::new(0.0, 1.0, 0.0),
damping: 0.03,
..Default::default()
};
let model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_free_body("base", -1, SpatialTransform::identity(), si_from(BASE_PI))
.add_body("leg", 0, joint, si_from(LINK_PI))
.build();
let layout = DofLayout::of(&model);
let v: Vec<f64> = vec![0.11, -0.07, 0.19, 0.3, -0.2, 0.05, 0.4];
let u: Vec<f64> = vec![0.5, -0.3, 0.2, 1.0, -2.0, 3.0, 0.07];
let mut state = model.default_state();
state.q[6] = 0.37; for (i, &vi) in v.iter().enumerate() {
state.v[i] = vi;
}
for (i, &ui) in u.iter().enumerate() {
state.ctrl[i] = ui;
}
let qdd_concrete = phyz::rigid::aba(&model, &state);
let mut q0 = layout.neutral_q(&model);
q0[layout.q_offsets[1]] = 0.37;
let ctrl = |_t: usize| DVec::from_slice(&u);
let rollout = AdjointRollout {
model: &model,
contact: None,
q0,
v0: v.clone(),
steps: 1,
ctrl: &ctrl,
};
for j in 0..layout.nv {
let obj = v_objective(j);
let v_next = rollout_objective(&rollout, &obj);
let qdd = (v_next - v[j]) / DT;
assert!(
(qdd - qdd_concrete[j]).abs() <= 1e-9 * (1.0 + qdd_concrete[j].abs()),
"qdd[{j}]: generic {qdd} vs phyz_rigid::aba {}",
qdd_concrete[j]
);
}
}
#[test]
fn free_base_chain_inertia_gradient_matches_fd() {
const DT: f64 = 1e-3;
const STEPS: usize = 200;
let base: [f64; 10] = [
4.0, 0.02, -0.01, 0.03, 0.09, 0.11, 0.13, 0.006, 0.004, 0.002,
];
let leg: [f64; 10] = [
0.8, 0.18, 0.05, -0.04, 0.012, 0.015, 0.018, 0.003, 0.001, 0.002,
];
let build = |ps: &[[f64; 10]]| -> Model {
let joint = Joint {
joint_type: JointType::Revolute,
parent_to_joint: SpatialTransform::from_translation(Vec3::new(0.3, 0.05, 0.0)),
axis: Vec3::new(0.0, 1.0, 0.0),
damping: 0.02,
..Default::default()
};
ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_free_body("base", -1, SpatialTransform::identity(), si_from(ps[0]))
.add_body("leg", 0, joint, si_from(ps[1]))
.build()
};
let q0 = |m: &Model| -> Vec<f64> {
let layout = DofLayout::of(m);
let mut q = layout.neutral_q(m);
let n = (0.9f64 * 0.9 + 0.2 * 0.2 + 0.3 * 0.3 + 0.1 * 0.1).sqrt();
q[0] = 0.1;
q[1] = -0.2;
q[2] = 0.4;
q[3] = 0.9 / n;
q[4] = 0.2 / n;
q[5] = 0.3 / n;
q[6] = 0.1 / n;
q[7] = -0.35; q
};
let ctrl = |_t: usize| DVec::from_slice(&[0.3, -0.2, 0.15, 1.0, -0.5, 2.0, 0.04]);
let v0 = vec![0.25, -0.4, 0.3, 0.2, 0.1, -0.15, 0.35];
let obj = q_objective(0);
let live = gate_inertia_vs_fd(
&build,
&[base, leg],
&q0,
v0,
STEPS,
&ctrl,
&obj,
&|k| match k {
0 => 1e-5,
1..=3 => 1e-6,
_ => 1e-6,
},
1e-6,
1e-8,
);
assert!(
live >= 16,
"only {live}/20 live channels — free base not exercising the packing"
);
}
#[test]
fn spherical_pendulum_inertia_gradient_matches_fd() {
const DT: f64 = 1e-3;
const STEPS: usize = 200;
let bob: [f64; 10] = [
1.2, 0.3, 0.08, -0.05, 0.02, 0.035, 0.05, 0.004, 0.003, 0.002,
];
let build = |ps: &[[f64; 10]]| -> Model {
ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_spherical_body("bob", -1, SpatialTransform::identity(), si_from(ps[0]))
.build()
};
let q0 = |m: &Model| -> Vec<f64> {
let layout = DofLayout::of(m);
let mut q = layout.neutral_q(m);
let n = (0.95f64 * 0.95 + 0.2 * 0.2 + 0.15 * 0.15 + 0.1 * 0.1).sqrt();
q[0] = 0.95 / n;
q[1] = 0.2 / n;
q[2] = 0.15 / n;
q[3] = 0.1 / n;
q
};
let ctrl = |_t: usize| DVec::from_slice(&[0.02, -0.01, 0.015]);
let v0 = vec![0.6, -0.35, 0.25];
let obj = q_objective(1);
let live = gate_inertia_vs_fd(
&build,
&[bob],
&q0,
v0,
STEPS,
&ctrl,
&obj,
&|k| match k {
0 => 1e-5,
1..=3 => 1e-6,
_ => 1e-7,
},
1e-6,
1e-8,
);
assert!(
live >= 7,
"only {live}/10 live channels — spherical gate under-constrained"
);
}
#[test]
fn free_joint_quaternion_stays_unit() {
const DT: f64 = 1e-3;
const STEPS: usize = 5000;
let model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_free_body(
"tumbler",
-1,
SpatialTransform::identity(),
si_from(BASE_PI),
)
.build();
let layout = DofLayout::of(&model);
let ctrl = |_t: usize| DVec::from_slice(&[0.0; 6]);
let rollout = AdjointRollout {
model: &model,
contact: None,
q0: layout.neutral_q(&model),
v0: vec![2.0, -1.5, 3.0, 0.5, 0.2, 1.0],
steps: STEPS,
ctrl: &ctrl,
};
let norm_sq = FinalStateObjective {
value: &|q: &[f64], _v: &[f64]| q[3] * q[3] + q[4] * q[4] + q[5] * q[5] + q[6] * q[6],
gradient: &|q: &[f64], v: &[f64]| (vec![0.0; q.len()], vec![0.0; v.len()]),
};
let n2 = rollout_objective(&rollout, &norm_sq);
assert!(
(n2 - 1.0).abs() < 1e-12,
"quaternion norm² drifted to {n2} after {STEPS} steps"
);
}
#[test]
fn ant_free_base_inertia_gradient_matches_fd() {
const STEPS: usize = 200;
const GATE: f64 = 1e-5;
let load = || -> Model {
phyz_mjcf::MjcfLoader::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../models/ant.xml"
))
.expect("models/ant.xml should load")
.build_model()
};
let model = load();
let layout = DofLayout::of(&model);
assert_eq!(layout.nv, 6 + 8, "ant: free torso + 8 hinges");
assert_eq!(layout.nq, 7 + 8, "ant: quaternion torso + 8 hinges");
assert_ne!(layout.nq, model.nq, "ant is exactly the nq ≠ nv case");
let mut q0 = layout.neutral_q(&model);
for j in 1..model.joints.len() {
let qi = layout.q_offsets[j];
q0[qi] = 0.15 * (j as f64 % 3.0 - 1.0) + 0.1;
}
let v0: Vec<f64> = (0..layout.nv)
.map(|i| 0.05 * (i as f64 % 5.0 - 2.0))
.collect();
let ctrl = |_t: usize| DVec::zeros(14);
let obj = q_objective(2);
let params_of = |m: &Model| -> Vec<[f64; 10]> {
m.bodies
.iter()
.map(|b| phyz::diff::inertia_params(&b.inertia))
.collect()
};
let nominal = params_of(&model);
let j_at = |ps: &[[f64; 10]]| -> f64 {
let mut m = load();
for (b, p) in ps.iter().enumerate() {
m.bodies[b].inertia = si_from(*p);
}
let ro = AdjointRollout {
model: &m,
contact: None,
q0: q0.clone(),
v0: v0.clone(),
steps: STEPS,
ctrl: &ctrl,
};
rollout_objective(&ro, &obj)
};
let rollout = AdjointRollout {
model: &model,
contact: None,
q0: q0.clone(),
v0: v0.clone(),
steps: STEPS,
ctrl: &ctrl,
};
let g = adjoint_rollout_gradient(&rollout, &obj);
let samples: [(usize, usize); 7] = [(0, 0), (0, 1), (0, 6), (1, 0), (1, 1), (2, 0), (2, 5)];
let mut live = 0;
for (b, k) in samples {
let h = 1e-3 * nominal[b][k].abs().max(1e-2);
let perturb = |sign: f64| -> f64 {
let mut ps = nominal.clone();
ps[b][k] += sign * h;
j_at(&ps)
};
let fd = (perturb(1.0) - perturb(-1.0)) / (2.0 * h);
let adj = g.d_inertia[b][k];
if fd.abs() > 1e-8 {
live += 1;
let rel = (adj - fd).abs() / fd.abs();
assert!(
rel <= GATE,
"ant dJ/dπ[body {b}][{k}]: adjoint {adj} vs fd {fd} (rel {rel:.3e})"
);
} else {
assert!(
adj.abs() <= 1e-7,
"ant dJ/dπ[body {b}][{k}]: fd dead ({fd}) but adjoint {adj}"
);
}
}
assert!(live >= 4, "only {live} live ant channels — gate too weak");
}
#[test]
fn free_box_on_plane_vertex_gradient_matches_fd() {
const DT: f64 = 1e-3;
const STEPS: usize = 800;
const GATE: f64 = 1e-4;
const HALF: f64 = 0.05;
const MASS: f64 = 0.5;
let model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(DT)
.add_free_body(
"crate",
-1,
SpatialTransform::identity(),
si_from([MASS, 0.0, 0.0, 0.0, 1e-3, 1e-3, 1e-3, 0.0, 0.0, 0.0]),
)
.build();
let layout = DofLayout::of(&model);
let ground = GroundContact {
height: -HALF, stiffness: 2e3,
damping: 20.0,
};
let box_vertices = |half: f64| -> Vec<Vec3> {
let mut v = Vec::new();
for sx in [-1.0, 1.0] {
for sy in [-1.0, 1.0] {
for sz in [-1.0, 1.0] {
v.push(Vec3::new(sx * half, sy * half, sz * half));
}
}
}
v
};
let ctrl = |_t: usize| DVec::zeros(layout.nv);
let obj = q_objective(2);
let j_with_mesh = |vertices: Vec<Vec3>| -> f64 {
let meshes = [CollisionMesh { body: 0, vertices }];
let ro = AdjointRollout {
model: &model,
contact: Some(ContactSetup {
ground,
meshes: &meshes,
}),
q0: layout.neutral_q(&model),
v0: vec![0.0; layout.nv],
steps: STEPS,
ctrl: &ctrl,
};
rollout_objective(&ro, &obj)
};
let meshes = [CollisionMesh {
body: 0,
vertices: box_vertices(HALF),
}];
let rollout = AdjointRollout {
model: &model,
contact: Some(ContactSetup {
ground,
meshes: &meshes,
}),
q0: layout.neutral_q(&model),
v0: vec![0.0; layout.nv],
steps: STEPS,
ctrl: &ctrl,
};
let g = adjoint_rollout_gradient(&rollout, &obj);
let d_eq = MASS * 9.81 / (4.0 * ground.stiffness);
assert!(
(g.objective + d_eq).abs() < 1e-6,
"box should settle at z = −mg/4k = {}, got {}",
-d_eq,
g.objective
);
const H: f64 = 1e-7;
let samples: [(usize, usize); 4] = [(0, 2), (6, 2), (0, 0), (1, 2)];
for (vi, axis) in samples {
let mut vp = box_vertices(HALF);
let mut vm = box_vertices(HALF);
match axis {
0 => {
vp[vi].x += H;
vm[vi].x -= H;
}
1 => {
vp[vi].y += H;
vm[vi].y -= H;
}
_ => {
vp[vi].z += H;
vm[vi].z -= H;
}
}
let fd = (j_with_mesh(vp) - j_with_mesh(vm)) / (2.0 * H);
let adj = match axis {
0 => g.d_vertices[0][vi].x,
1 => g.d_vertices[0][vi].y,
_ => g.d_vertices[0][vi].z,
};
if fd.abs() > 1e-6 {
let rel = (adj - fd).abs() / fd.abs();
assert!(
rel <= GATE,
"∂J/∂x[v{vi}][{axis}]: adjoint {adj} vs fd {fd} (rel {rel:.3e})"
);
} else {
assert!(
adj.abs() <= 1e-6,
"∂J/∂x[v{vi}][{axis}]: fd dead ({fd}) but adjoint {adj}"
);
}
}
for vi in 0..8 {
let gz = g.d_vertices[0][vi].z;
if vi % 2 == 0 {
let rel = (gz + 0.25).abs() / 0.25;
assert!(
rel <= 1e-3,
"bottom vertex {vi}: ∂J/∂z = {gz}, expected −0.25 (rel {rel:.3e})"
);
} else {
assert!(gz.abs() <= 1e-9, "top vertex {vi} should be dead, got {gz}");
}
assert!(
g.d_vertices[0][vi].x.abs() <= 1e-9 && g.d_vertices[0][vi].y.abs() <= 1e-9,
"tangential channels must be dead without rotation/friction (v{vi})"
);
}
}