use approx::assert_relative_eq;
use nalgebra::{Matrix3, Vector3};
use featherstone::aba::aba_forward_dynamics;
use featherstone::body::{ArticulatedBody, GenJointType};
use featherstone::contact::ground_plane_contacts;
use featherstone::contact_jacobian::ContactConstraints;
use featherstone::crba::{bias_forces, crba_mass_matrix};
use featherstone::integrator::{step, IntegrationMethod, IntegratorConfig};
use featherstone::kinematics::{com_position, forward_kinematics};
use featherstone::lcp_solver::{solve_contact_lcp, LcpSolverConfig};
use featherstone::rnea::{gravity_compensation, rnea_inverse_dynamics};
use featherstone::spatial::{SpatialInertia, SpatialTransform, SpatialVector};
fn make_inertia(mass: f32) -> SpatialInertia {
SpatialInertia::from_mass_inertia_at_com(mass, Matrix3::identity() * 0.01 * mass)
}
fn make_chain(n: usize, mass: f32, length: f32) -> ArticulatedBody {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
for i in 0..n {
let parent = if i == 0 { -1 } else { (i - 1) as i32 };
body.add_body(
format!("link{i}"),
parent,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia(
mass,
Vector3::new(length / 2.0, 0.0, 0.0),
Matrix3::from_diagonal(&Vector3::new(
0.001,
mass * length * length / 12.0,
mass * length * length / 12.0,
)),
),
SpatialTransform::from_translation(Vector3::new(length, 0.0, 0.0)),
);
}
body
}
fn kinetic_energy(body: &mut ArticulatedBody) -> f32 {
let h = crba_mass_matrix(body);
let qd = &body.qd;
0.5 * (qd.transpose() * &h * qd)[(0, 0)]
}
fn extract_local_com(si: &SpatialInertia) -> Vector3<f32> {
let m = si.mass;
if m.abs() < 1e-10 {
return Vector3::zeros();
}
Vector3::new(si.data[(4, 2)] / m, si.data[(5, 0)] / m, si.data[(3, 1)] / m)
}
fn potential_energy(body: &ArticulatedBody) -> f32 {
let fk = forward_kinematics(body);
let g = 9.81_f32;
let mut pe = 0.0;
for i in 0..body.body_count() {
let bd = &body.bodies[i];
let mass = bd.inertia.mass;
if mass < 1e-10 {
continue;
}
let local_com = extract_local_com(&bd.inertia);
let rot = fk.transforms[i].rotation;
let pos = fk.transforms[i].translation;
let com_world = pos + rot * local_com;
pe += mass * g * com_world.y;
}
pe
}
#[allow(dead_code)]
fn linear_momentum(body: &ArticulatedBody) -> Vector3<f32> {
let fk = forward_kinematics(body);
let mut p = Vector3::zeros();
for i in 0..body.body_count() {
let mass = body.bodies[i].inertia.mass;
let v = fk.velocities[i].linear();
p += v * mass;
}
p
}
fn angular_momentum(body: &ArticulatedBody) -> Vector3<f32> {
let fk = forward_kinematics(body);
let mut l = Vector3::zeros();
for i in 0..body.body_count() {
let mass = body.bodies[i].inertia.mass;
let v = fk.velocities[i].linear();
let w = fk.velocities[i].angular();
let r = fk.transforms[i].translation;
let i_diag = body.bodies[i].inertia.data[(0, 0)];
l += w * i_diag;
l += r.cross(&(v * mass));
}
l
}
#[test]
fn test_linear_momentum_conservation_isolated_system() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::zeros());
body.add_body(
"base",
-1,
GenJointType::Floating,
SpatialInertia::sphere(2.0, 0.2),
SpatialTransform::identity(),
);
body.add_body(
"arm",
0,
GenJointType::Revolute { axis: Vector3::z() },
make_inertia(1.0),
SpatialTransform::from_translation(Vector3::new(0.3, 0.0, 0.0)),
);
body.set_joint_qd(0, &[1.0, 0.5, 0.0, 0.0, 0.0, 0.0]);
body.set_joint_qd(1, &[2.0]);
let com0 = com_position(&body);
let config = IntegratorConfig {
dt: 0.0005,
method: IntegrationMethod::RK4,
..Default::default()
};
for _ in 0..200 {
step(&mut body, &config);
}
let com_mid = com_position(&body);
let t1 = 0.1_f32;
for _ in 0..200 {
step(&mut body, &config);
}
let com_end = com_position(&body);
let t2 = 0.1_f32;
let v1x = (com_mid.x - com0.x) / t1;
let v2x = (com_end.x - com_mid.x) / t2;
let v1y = (com_mid.y - com0.y) / t1;
let v2y = (com_end.y - com_mid.y) / t2;
assert_relative_eq!(v1x, v2x, epsilon = 0.15);
assert_relative_eq!(v1y, v2y, epsilon = 0.15);
}
#[test]
fn test_angular_momentum_conservation_isolated_system() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::zeros());
body.add_body(
"base",
-1,
GenJointType::Floating,
SpatialInertia::sphere(2.0, 0.2),
SpatialTransform::identity(),
);
body.add_body(
"arm",
0,
GenJointType::Revolute { axis: Vector3::z() },
make_inertia(1.0),
SpatialTransform::from_translation(Vector3::new(0.3, 0.0, 0.0)),
);
body.set_joint_qd(1, &[3.0]);
let l0 = angular_momentum(&body);
let config = IntegratorConfig {
dt: 0.0005,
method: IntegrationMethod::RK4,
..Default::default()
};
for _ in 0..2000 {
step(&mut body, &config);
}
let l1 = angular_momentum(&body);
let drift = (l1 - l0).norm();
let l_mag = l0.norm().max(1e-6);
assert!(
drift / l_mag < 0.05,
"Angular momentum relative drift = {:.4}% (L0={l0:.4}, L1={l1:.4})",
drift / l_mag * 100.0
);
}
#[test]
fn test_energy_conservation_symplectic_10k_steps() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"pendulum",
-1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia_at_com(
1.0,
Matrix3::from_diagonal(&Vector3::new(0.01, 0.01, 0.01)),
),
SpatialTransform::identity(),
);
body.set_joint_q(0, &[0.3]);
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
let e0 = kinetic_energy(&mut body) + potential_energy(&body);
let mut max_drift: f32 = 0.0;
for i in 0..10_000 {
step(&mut body, &config);
if i % 100 == 0 {
let e = kinetic_energy(&mut body) + potential_energy(&body);
let drift = ((e - e0) / e0.abs().max(1e-6)).abs();
max_drift = max_drift.max(drift);
}
}
assert!(
max_drift < 0.15,
"Symplectic energy drift = {:.2}% (should be < 15%)",
max_drift * 100.0
);
}
#[test]
fn test_energy_conservation_rk4_10k_steps() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"pendulum",
-1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia_at_com(
1.0,
Matrix3::from_diagonal(&Vector3::new(0.01, 0.01, 0.01)),
),
SpatialTransform::identity(),
);
body.set_joint_q(0, &[0.3]);
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::RK4,
..Default::default()
};
let e0 = kinetic_energy(&mut body) + potential_energy(&body);
let mut max_drift: f32 = 0.0;
for i in 0..10_000 {
step(&mut body, &config);
if i % 100 == 0 {
let e = kinetic_energy(&mut body) + potential_energy(&body);
let drift = ((e - e0) / e0.abs().max(1e-6)).abs();
max_drift = max_drift.max(drift);
}
}
assert!(
max_drift < 0.05,
"RK4 energy drift = {:.4}% (should be < 5%)",
max_drift * 100.0
);
}
#[test]
fn test_rk4_trajectory_more_accurate_than_euler() {
let make_pendulum = || {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"p",
-1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia(
1.0,
Vector3::new(0.3, 0.0, 0.0),
Matrix3::from_diagonal(&Vector3::new(0.01, 0.01, 0.01)),
),
SpatialTransform::identity(),
);
body.set_joint_q(0, &[1.0]); body
};
let mut ref_body = make_pendulum();
let ref_config = IntegratorConfig {
dt: 0.00005,
method: IntegrationMethod::RK4,
..Default::default()
};
for _ in 0..100_000 {
step(&mut ref_body, &ref_config);
}
let ref_q = ref_body.q[0];
let mut euler_body = make_pendulum();
let euler_config = IntegratorConfig {
dt: 0.005,
method: IntegrationMethod::ExplicitEuler,
..Default::default()
};
for _ in 0..1000 {
step(&mut euler_body, &euler_config);
}
let mut rk4_body = make_pendulum();
let rk4_config = IntegratorConfig {
dt: 0.005,
method: IntegrationMethod::RK4,
..Default::default()
};
for _ in 0..1000 {
step(&mut rk4_body, &rk4_config);
}
let euler_error = (euler_body.q[0] - ref_q).abs();
let rk4_error = (rk4_body.q[0] - ref_q).abs();
assert!(
rk4_error < euler_error,
"RK4 error ({rk4_error:.6}) should be less than Euler error ({euler_error:.6}) vs reference q={ref_q:.6}"
);
}
#[test]
fn test_friction_cone_enforcement_lcp() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"box",
-1,
GenJointType::Floating,
SpatialInertia::from_mass_inertia_at_com(
1.0,
Matrix3::from_diagonal(&Vector3::new(0.067, 0.067, 0.067)),
),
SpatialTransform::identity(),
);
body.set_joint_q(0, &[0.0, 0.05, 0.0, 1.0, 0.0, 0.0, 0.0]);
body.set_joint_qd(0, &[2.0, -1.0, 0.0, 0.0, 0.0, 0.0]);
let mu = 0.5;
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), mu, 0.0);
if manifold.contacts.is_empty() {
return;
}
let constraints = ContactConstraints::from_manifold(&body, &manifold);
let config = LcpSolverConfig {
max_iterations: 200,
tolerance: 1e-8,
baumgarte: 0.0,
..Default::default()
};
let result = solve_contact_lcp(&body, &constraints, 0.001, &config);
let nc = constraints.num_contacts;
for k in 0..nc {
let lambda_n = result.lambda_n[k];
let lambda_t1 = result.lambda_t[2 * k];
let lambda_t2 = result.lambda_t[2 * k + 1];
let tangent_norm = (lambda_t1 * lambda_t1 + lambda_t2 * lambda_t2).sqrt();
assert!(
lambda_n >= -1e-6,
"Contact {k}: normal impulse must be >= 0, got {lambda_n}"
);
assert!(
tangent_norm <= mu * lambda_n + 1e-4,
"Contact {k}: friction cone violated: ||lambda_t||={tangent_norm:.6} > mu*lambda_n={:.6}",
mu * lambda_n
);
}
}
#[test]
fn test_contact_impulse_non_negativity() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"ball",
-1,
GenJointType::Prismatic { axis: Vector3::y() },
SpatialInertia::sphere(1.0, 0.1),
SpatialTransform::identity(),
);
body.set_joint_q(0, &[0.05]);
body.set_joint_qd(0, &[-2.0]);
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), 0.3, 0.5);
if manifold.contacts.is_empty() {
return;
}
let constraints = ContactConstraints::from_manifold(&body, &manifold);
let config = LcpSolverConfig::default();
let result = solve_contact_lcp(&body, &constraints, 0.001, &config);
for k in 0..constraints.num_contacts {
assert!(
result.lambda_n[k] >= -1e-6,
"Contact {k}: lambda_n={} must be non-negative",
result.lambda_n[k]
);
}
}
#[test]
fn test_rotating_rod_exact_solution() {
let i_zz = 0.1_f32;
let tau_applied = 2.0_f32;
let alpha = tau_applied / i_zz;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::zeros());
body.add_body(
"rod",
-1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia_at_com(
1.0,
Matrix3::from_diagonal(&Vector3::new(i_zz, i_zz, i_zz)),
),
SpatialTransform::identity(),
);
let dt = 0.0001;
let config = IntegratorConfig {
dt,
method: IntegrationMethod::RK4,
..Default::default()
};
body.tau[0] = tau_applied;
let n_steps = 10_000; for _ in 0..n_steps {
step(&mut body, &config);
}
let t = n_steps as f32 * dt;
let expected_omega = alpha * t;
let expected_theta = 0.5 * alpha * t * t;
assert_relative_eq!(body.qd[0], expected_omega, epsilon = 0.01);
assert_relative_eq!(body.q[0], expected_theta, epsilon = 0.05);
}
#[test]
fn test_harmonic_oscillator_frequency() {
let mass = 1.0_f32;
let g = 9.81_f32;
let i_zz = 0.1_f32;
let length = 0.5_f32;
let i_eff = i_zz + mass * length * length;
let omega_n = (mass * g * length / i_eff).sqrt();
let period = 2.0 * std::f32::consts::PI / omega_n;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -g, 0.0));
body.add_body(
"pendulum",
-1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia_at_com(
mass,
Matrix3::from_diagonal(&Vector3::new(i_zz, i_zz, i_zz)),
),
SpatialTransform::from_translation(Vector3::new(length, 0.0, 0.0)),
);
let theta0 = 0.02_f32; body.set_joint_q(0, &[theta0]);
let dt = 0.00005; let config = IntegratorConfig {
dt,
method: IntegrationMethod::RK4,
..Default::default()
};
let n_steps = (period / dt) as usize;
for _ in 0..n_steps {
step(&mut body, &config);
}
let theta_final = body.q[0];
let return_error = (theta_final - theta0).abs();
assert!(
return_error < 0.01,
"Pendulum should return to theta0={theta0:.4} after T={period:.4}s, got {theta_final:.6} (error={return_error:.6})"
);
}
#[test]
fn test_free_fall_trajectory_exact() {
let g = 9.81_f32;
let y0 = 10.0_f32;
let vy0 = 5.0_f32;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -g, 0.0));
body.add_body(
"mass",
-1,
GenJointType::Floating,
SpatialInertia::sphere(1.0, 0.1),
SpatialTransform::identity(),
);
body.set_joint_q(0, &[0.0, y0, 0.0, 1.0, 0.0, 0.0, 0.0]);
body.set_joint_qd(0, &[0.0, vy0, 0.0, 0.0, 0.0, 0.0]);
let dt = 0.001;
let config = IntegratorConfig {
dt,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
for _ in 0..2000 {
step(&mut body, &config);
}
let t = 2.0_f32;
let expected_y = y0 + vy0 * t - 0.5 * g * t * t;
let expected_vy = vy0 - g * t;
assert_relative_eq!(body.q[1], expected_y, epsilon = 0.05);
assert_relative_eq!(body.qd[1], expected_vy, epsilon = 0.05);
}
#[test]
fn test_kahan_compensation_beats_naive_f32() {
let i_zz = 0.1_f32;
let tiny_tau = 0.0001_f32;
let alpha = tiny_tau / i_zz;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::zeros());
body.add_body(
"link",
-1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia_at_com(
1.0,
Matrix3::from_diagonal(&Vector3::new(i_zz, i_zz, i_zz)),
),
SpatialTransform::identity(),
);
body.qd[0] = 1000.0;
body.tau[0] = tiny_tau;
let dt = 0.0001_f32;
let config = IntegratorConfig {
dt,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
let n_steps = 100_000;
for _ in 0..n_steps {
step(&mut body, &config);
}
let kahan_velocity = body.qd[0];
let mut naive_vel = 1000.0_f32;
for _ in 0..n_steps {
naive_vel += alpha * dt; }
let exact = 1000.0_f64 + (alpha as f64) * (dt as f64) * (n_steps as f64);
let kahan_error = (kahan_velocity as f64 - exact).abs();
let naive_error = (naive_vel as f64 - exact).abs();
assert!(
kahan_error < naive_error * 0.1,
"Kahan error ({kahan_error:.2e}) should be much less than naive error ({naive_error:.2e}), \
exact={exact:.6}, kahan={kahan_velocity:.6}, naive={naive_vel:.6}"
);
}
#[test]
fn test_lcp_convergence_monotonic_residual_decay() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"box",
-1,
GenJointType::Floating,
SpatialInertia::from_mass_inertia_at_com(
1.0,
Matrix3::from_diagonal(&Vector3::new(0.067, 0.067, 0.067)),
),
SpatialTransform::identity(),
);
body.set_joint_q(0, &[0.0, 0.05, 0.0, 1.0, 0.0, 0.0, 0.0]);
body.set_joint_qd(0, &[1.0, -3.0, 0.0, 0.0, 0.0, 0.5]);
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), 0.5, 0.0);
if manifold.contacts.is_empty() {
return;
}
let constraints = ContactConstraints::from_manifold(&body, &manifold);
let mut prev_residual = f32::MAX;
for max_iter in [5, 10, 20, 50, 100] {
let config = LcpSolverConfig {
max_iterations: max_iter,
tolerance: 0.0,
min_iterations: max_iter,
baumgarte: 0.0,
..Default::default()
};
let result = solve_contact_lcp(&body, &constraints, 0.001, &config);
assert!(
result.residual <= prev_residual + 1e-6,
"More iterations ({max_iter}) should not increase residual: {:.6} > {prev_residual:.6}",
result.residual
);
prev_residual = result.residual;
}
assert!(
prev_residual < 0.01,
"LCP should converge with 100 iterations, residual={prev_residual:.6}"
);
}
#[test]
fn test_50_body_chain_finite_dynamics() {
let mut body = make_chain(50, 0.5, 0.1);
for i in 0..body.dof_count() {
body.q[i] = 0.1 * (i as f32);
body.qd[i] = 0.05 * ((i as f32) - 25.0);
}
aba_forward_dynamics(&mut body);
for i in 0..body.dof_count() {
assert!(
body.qdd[i].is_finite(),
"50-body chain: qdd[{i}] = {} is not finite",
body.qdd[i]
);
}
let h = crba_mass_matrix(&body);
assert!(
h.clone().try_inverse().is_some(),
"50-body mass matrix should be invertible"
);
}
#[test]
fn test_mass_matrix_condition_number_growth() {
let mut cond_4 = 1.0_f32;
for n in [4, 8, 16, 32] {
let body = make_chain(n, 1.0, 0.2);
let h = crba_mass_matrix(&body);
let eigenvalues = h.symmetric_eigenvalues();
let min_ev = eigenvalues.iter().cloned().fold(f32::MAX, f32::min);
let max_ev = eigenvalues.iter().cloned().fold(f32::MIN, f32::max);
assert!(min_ev > 0.0, "{n}-body: min eigenvalue should be > 0, got {min_ev}");
let cond = max_ev / min_ev;
assert!(cond.is_finite(), "{n}-body: condition number should be finite");
assert!(cond < 1e8, "{n}-body: condition number {cond:.0} exceeds 1e8");
if n == 4 {
cond_4 = cond;
}
if n == 32 {
assert!(
cond > cond_4,
"Longer chain should have worse conditioning: cond(32)={cond:.0} vs cond(4)={cond_4:.0}"
);
}
}
}
#[test]
fn test_stacking_multiple_contacts() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
for i in 0..5 {
body.add_body(
format!("box{i}"),
-1,
GenJointType::Prismatic { axis: Vector3::y() },
SpatialInertia::sphere(1.0, 0.1),
SpatialTransform::identity(),
);
body.set_joint_q(i, &[0.5 + i as f32 * 1.0]);
}
let dt = 0.001;
let config = IntegratorConfig {
dt,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
let lcp_config = LcpSolverConfig::default();
for _ in 0..500 {
featherstone::integrator::compute_accelerations(&mut body, &config);
featherstone::integrator::update_velocities(&mut body, &config);
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), 0.5, 0.0);
if !manifold.contacts.is_empty() {
let constraints = ContactConstraints::from_manifold(&body, &manifold);
let result = solve_contact_lcp(&body, &constraints, dt, &lcp_config);
let m = crba_mass_matrix(&body);
if let Some(m_inv) = m.try_inverse() {
body.qd += m_inv * &result.tau_contact;
}
}
featherstone::integrator::update_positions(&mut body, &config);
for j in 0..body.dof_count() {
assert!(body.q[j].is_finite(), "Stacking: q[{j}] became non-finite");
}
}
for i in 0..5 {
assert!(body.q[i] > -0.5, "Box {i} fell through ground: y={}", body.q[i]);
}
}
#[test]
fn test_aba_rnea_round_trip_many_configs() {
let base = make_chain(3, 1.0, 0.2);
for seed in 0..20 {
let mut body = base.clone();
let s = seed as f32;
for i in 0..body.dof_count() {
body.q[i] = (s * 0.37 + i as f32 * 0.73).sin() * 0.5; body.qd[i] = (s * 0.53 + i as f32 * 0.91).cos();
body.tau[i] = (s * 0.17 + i as f32 * 0.41).sin() * 2.0;
}
let original_tau = body.tau.clone();
aba_forward_dynamics(&mut body);
let (tau_recovered, _) = rnea_inverse_dynamics(&body);
for i in 0..body.dof_count() {
assert_relative_eq!(tau_recovered[i], original_tau[i], epsilon = 0.2);
}
}
}
#[test]
fn test_crba_positive_definite_many_configs() {
let base = make_chain(5, 1.0, 0.2);
for seed in 0..20 {
let mut body = base.clone();
let s = seed as f32;
for i in 0..body.dof_count() {
body.q[i] = (s * 0.37 + i as f32 * 0.73).sin() * std::f32::consts::PI;
}
let h = crba_mass_matrix(&body);
for i in 0..h.nrows() {
for j in 0..h.ncols() {
assert_relative_eq!(h[(i, j)], h[(j, i)], epsilon = 1e-5);
}
}
let eigenvalues = h.symmetric_eigenvalues();
for (j, &ev) in eigenvalues.iter().enumerate() {
assert!(ev > 0.0, "Config seed={seed}: eigenvalue[{j}] = {ev} should be > 0");
}
}
}
#[test]
fn test_gravity_compensation_many_configs() {
let base = make_chain(3, 1.0, 0.3);
for seed in 0..20 {
let mut body = base.clone();
let s = seed as f32;
for i in 0..body.dof_count() {
body.q[i] = (s * 0.47 + i as f32 * 0.83).sin() * 1.5;
}
body.qd.fill(0.0);
let tau_grav = gravity_compensation(&body);
body.tau = tau_grav;
aba_forward_dynamics(&mut body);
for i in 0..body.dof_count() {
assert_relative_eq!(body.qdd[i], 0.0, epsilon = 1e-3);
}
}
}
#[test]
fn test_crba_aba_consistency_many_configs() {
let base = make_chain(3, 1.0, 0.2);
for seed in 0..15 {
let mut body = base.clone();
let s = seed as f32;
for i in 0..body.dof_count() {
body.q[i] = (s * 0.31 + i as f32 * 0.67).sin() * 0.5;
body.qd[i] = (s * 0.59 + i as f32 * 0.89).cos();
body.tau[i] = (s * 0.23 + i as f32 * 0.43).sin() * 3.0;
}
let h = crba_mass_matrix(&body);
let c = bias_forces(&mut body);
let tau_minus_c = &body.tau - &c;
let h_inv = h.try_inverse().expect("H should be invertible");
let qdd_crba = h_inv * tau_minus_c;
aba_forward_dynamics(&mut body);
for i in 0..body.dof_count() {
let scale = body.qdd[i].abs().max(1.0);
let diff = (qdd_crba[i] - body.qdd[i]).abs();
assert!(
diff / scale < 0.05,
"seed={seed}, dof={i}: CRBA={:.4} vs ABA={:.4}, relative diff={:.4}%",
qdd_crba[i], body.qdd[i], diff / scale * 100.0
);
}
}
}
#[test]
fn test_quaternion_normalization_10k_steps() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::zeros());
body.add_body(
"base",
-1,
GenJointType::Floating,
SpatialInertia::sphere(1.0, 0.1),
SpatialTransform::identity(),
);
body.add_body(
"ball",
0,
GenJointType::Spherical,
SpatialInertia::sphere(0.5, 0.05),
SpatialTransform::from_translation(Vector3::new(0.3, 0.0, 0.0)),
);
body.set_joint_qd(1, &[1.0, -0.5, 0.3]);
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::SemiImplicitEuler,
normalize_quaternions: true,
..Default::default()
};
for _ in 0..10_000 {
step(&mut body, &config);
}
let norm_base = (body.q[3] * body.q[3] + body.q[4] * body.q[4]
+ body.q[5] * body.q[5] + body.q[6] * body.q[6])
.sqrt();
assert_relative_eq!(norm_base, 1.0, epsilon = 1e-4);
let norm_sph = (body.q[7] * body.q[7] + body.q[8] * body.q[8]
+ body.q[9] * body.q[9] + body.q[10] * body.q[10])
.sqrt();
assert_relative_eq!(norm_sph, 1.0, epsilon = 1e-4);
}
#[test]
fn test_spatial_inertia_positive_definite() {
let test_cases = vec![
SpatialInertia::sphere(1.0, 0.1),
SpatialInertia::sphere(10.0, 0.5),
SpatialInertia::from_mass_inertia_at_com(
2.0,
Matrix3::from_diagonal(&Vector3::new(0.1, 0.2, 0.3)),
),
SpatialInertia::from_mass_inertia(
1.0,
Vector3::new(0.5, 0.0, 0.0),
Matrix3::from_diagonal(&Vector3::new(0.01, 0.01, 0.01)),
),
];
for (i, si) in test_cases.iter().enumerate() {
let eigenvalues = si.data.symmetric_eigenvalues();
for (j, &ev) in eigenvalues.iter().enumerate() {
assert!(
ev >= -1e-6,
"SpatialInertia case {i}: eigenvalue[{j}] = {ev} should be non-negative"
);
}
}
}
#[test]
fn test_gravity_compensation_floating_base() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"base",
-1,
GenJointType::Floating,
SpatialInertia::sphere(5.0, 0.2),
SpatialTransform::identity(),
);
body.add_body(
"arm",
0,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia(
1.0,
Vector3::new(0.3, 0.0, 0.0),
Matrix3::from_diagonal(&Vector3::new(0.01, 0.01, 0.01)),
),
SpatialTransform::from_translation(Vector3::new(0.2, 0.0, 0.0)),
);
body.set_joint_q(1, &[0.5]);
let tau_grav = gravity_compensation(&body);
body.tau = tau_grav;
aba_forward_dynamics(&mut body);
for i in 0..body.dof_count() {
assert_relative_eq!(body.qdd[i], 0.0, epsilon = 1e-3);
}
}
#[test]
fn test_simultaneous_limits_and_contacts() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"arm",
-1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia(
1.0,
Vector3::new(0.3, 0.0, 0.0),
Matrix3::from_diagonal(&Vector3::new(0.01, 0.01, 0.01)),
),
SpatialTransform::identity(),
);
body.set_joint_q(0, &[1.5]);
let limits = featherstone::limits::JointLimits::from_urdf_params(-1.57, 1.57, 5.0, 50.0);
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::SemiImplicitEuler,
joint_limits: vec![Some(limits)],
..Default::default()
};
for i in 0..500 {
step(&mut body, &config);
assert!(body.q[0].is_finite(), "Step {i}: q non-finite");
assert!(body.qd[0].is_finite(), "Step {i}: qd non-finite");
assert!(
body.q[0] < 2.0 && body.q[0] > -2.0,
"Step {i}: joint exceeded limits: q={}",
body.q[0]
);
}
}
#[test]
fn test_aba_rnea_duality_with_external_forces() {
let mut body = make_chain(3, 1.0, 0.2);
body.set_joint_q(0, &[0.3]);
body.set_joint_q(1, &[-0.5]);
body.set_joint_qd(0, &[1.0]);
body.set_joint_qd(1, &[-0.3]);
body.set_external_force(
2,
SpatialVector::new(Vector3::new(0.0, 0.0, 1.0), Vector3::new(2.0, -1.0, 0.0)),
);
body.tau[0] = 3.0;
body.tau[1] = -1.5;
body.tau[2] = 0.5;
let original_tau = body.tau.clone();
aba_forward_dynamics(&mut body);
let (tau_recovered, _) = rnea_inverse_dynamics(&body);
for i in 0..body.dof_count() {
assert_relative_eq!(tau_recovered[i], original_tau[i], epsilon = 0.1);
}
}
#[test]
fn test_aba_linear_scaling() {
let sizes = [8, 16, 32, 64];
let mut times = Vec::new();
for &n in &sizes {
let mut body = make_chain(n, 1.0, 0.1);
for i in 0..body.dof_count() {
body.q[i] = (i as f32 * 0.3).sin();
body.qd[i] = (i as f32 * 0.7).cos();
body.tau[i] = (i as f32 * 0.13).sin();
}
let start = std::time::Instant::now();
let iters = 1000;
for _ in 0..iters {
aba_forward_dynamics(&mut body);
}
let elapsed = start.elapsed().as_secs_f64() / iters as f64;
times.push((n, elapsed));
}
let t8 = times[0].1;
let t64 = times[3].1;
let ratio = t64 / t8;
assert!(
ratio < 12.0,
"ABA scaling: time(64)/time(8) = {ratio:.1} (expected <12 for O(n)), \
times: {:?}",
times.iter().map(|(n, t)| format!("n={n}: {t:.2e}s")).collect::<Vec<_>>()
);
}
#[test]
fn test_rnea_linear_scaling() {
let sizes = [8, 16, 32, 64];
let mut times = Vec::new();
for &n in &sizes {
let mut body = make_chain(n, 1.0, 0.1);
for i in 0..body.dof_count() {
body.q[i] = (i as f32 * 0.3).sin();
body.qd[i] = (i as f32 * 0.7).cos();
body.qdd[i] = (i as f32 * 0.5).sin();
}
let start = std::time::Instant::now();
let iters = 1000;
for _ in 0..iters {
let _ = rnea_inverse_dynamics(&body);
}
let elapsed = start.elapsed().as_secs_f64() / iters as f64;
times.push((n, elapsed));
}
let t8 = times[0].1;
let t64 = times[3].1;
let ratio = t64 / t8;
assert!(
ratio < 12.0,
"RNEA scaling: time(64)/time(8) = {ratio:.1} (expected <12 for O(n)), \
times: {:?}",
times.iter().map(|(n, t)| format!("n={n}: {t:.2e}s")).collect::<Vec<_>>()
);
}
mod proptest_algorithms {
use super::*;
use proptest::prelude::*;
fn arb_chain() -> impl Strategy<Value = ArticulatedBody> {
(2..=4_usize).prop_flat_map(|n| {
let angle_strat = proptest::collection::vec(-1.5_f32..1.5, n);
let vel_strat = proptest::collection::vec(-2.0_f32..2.0, n);
let tau_strat = proptest::collection::vec(-5.0_f32..5.0, n);
(Just(n), angle_strat, vel_strat, tau_strat)
}).prop_map(|(n, angles, vels, taus)| {
let mut body = make_chain(n, 1.0, 0.2);
for i in 0..n {
body.q[i] = angles[i];
body.qd[i] = vels[i];
body.tau[i] = taus[i];
}
body
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn prop_aba_rnea_round_trip(mut body in arb_chain()) {
let original_tau = body.tau.clone();
aba_forward_dynamics(&mut body);
let (tau_recovered, _) = rnea_inverse_dynamics(&body);
for i in 0..body.dof_count() {
let diff = (tau_recovered[i] - original_tau[i]).abs();
prop_assert!(
diff < 1.5,
"dof {}: recovered={:.4} vs original={:.4}, rel_diff={:.4}%",
i, tau_recovered[i], original_tau[i], diff
);
}
}
#[test]
fn prop_crba_symmetric_positive_definite(body in arb_chain()) {
let h = crba_mass_matrix(&body);
let n = h.nrows();
for i in 0..n {
for j in 0..n {
let diff = (h[(i, j)] - h[(j, i)]).abs();
prop_assert!(diff < 1e-4, "H[{},{}]={} != H[{},{}]={}", i, j, h[(i,j)], j, i, h[(j,i)]);
}
}
let eigenvalues = h.symmetric_eigenvalues();
for (i, &ev) in eigenvalues.iter().enumerate() {
prop_assert!(ev > 0.0, "eigenvalue[{}] = {} should be > 0", i, ev);
}
}
#[test]
fn prop_gravity_compensation_zero_acceleration(body in arb_chain()) {
let mut body = body;
body.qd.fill(0.0);
let tau_grav = gravity_compensation(&body);
body.tau = tau_grav;
aba_forward_dynamics(&mut body);
for i in 0..body.dof_count() {
prop_assert!(
body.qdd[i].abs() < 0.01,
"dof {}: qdd={:.6} should be ~0 under gravity compensation",
i, body.qdd[i]
);
}
}
}
}
fn make_humanoid() -> ArticulatedBody {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
let torso_inertia = SpatialInertia::from_mass_inertia_at_com(
10.0,
Matrix3::from_diagonal(&Vector3::new(0.5, 0.5, 0.2)),
);
body.add_body("torso", -1, GenJointType::Floating, torso_inertia, SpatialTransform::identity());
let limb_inertia = SpatialInertia::from_mass_inertia(
2.0,
Vector3::new(0.15, 0.0, 0.0),
Matrix3::from_diagonal(&Vector3::new(0.01, 0.01, 0.01)),
);
body.add_body("l_arm", 0, GenJointType::Revolute { axis: Vector3::z() },
limb_inertia.clone(), SpatialTransform::from_translation(Vector3::new(0.0, 0.3, 0.2)));
body.add_body("r_arm", 0, GenJointType::Revolute { axis: Vector3::z() },
limb_inertia.clone(), SpatialTransform::from_translation(Vector3::new(0.0, 0.3, -0.2)));
body.add_body("l_leg", 0, GenJointType::Revolute { axis: Vector3::x() },
limb_inertia.clone(), SpatialTransform::from_translation(Vector3::new(0.0, -0.4, 0.1)));
body.add_body("r_leg", 0, GenJointType::Revolute { axis: Vector3::x() },
limb_inertia.clone(), SpatialTransform::from_translation(Vector3::new(0.0, -0.4, -0.1)));
body.add_body("head", 0, GenJointType::Revolute { axis: Vector3::y() },
SpatialInertia::sphere(1.0, 0.1),
SpatialTransform::from_translation(Vector3::new(0.0, 0.4, 0.0)));
body
}
fn make_quadruped() -> ArticulatedBody {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("body", -1, GenJointType::Floating,
SpatialInertia::from_mass_inertia_at_com(5.0, Matrix3::from_diagonal(&Vector3::new(0.3, 0.1, 0.3))),
SpatialTransform::identity());
let offsets = [
Vector3::new(0.3, 0.0, 0.15), Vector3::new(0.3, 0.0, -0.15), Vector3::new(-0.3, 0.0, 0.15), Vector3::new(-0.3, 0.0, -0.15), ];
let upper = SpatialInertia::from_mass_inertia(
0.5, Vector3::new(0.0, -0.1, 0.0),
Matrix3::from_diagonal(&Vector3::new(0.005, 0.005, 0.005)),
);
let lower = SpatialInertia::from_mass_inertia(
0.3, Vector3::new(0.0, -0.08, 0.0),
Matrix3::from_diagonal(&Vector3::new(0.003, 0.003, 0.003)),
);
for (i, offset) in offsets.iter().enumerate() {
let hip = body.add_body(
format!("hip_{i}"), 0, GenJointType::Revolute { axis: Vector3::x() },
upper.clone(), SpatialTransform::from_translation(*offset));
body.add_body(
format!("knee_{i}"), hip as i32, GenJointType::Revolute { axis: Vector3::x() },
lower.clone(), SpatialTransform::from_translation(Vector3::new(0.0, -0.2, 0.0)));
}
body
}
#[test]
fn test_humanoid_aba_rnea_round_trip() {
let mut body = make_humanoid();
body.set_joint_qd(1, &[1.0]); body.set_joint_qd(2, &[-0.5]); body.set_joint_qd(3, &[0.3]); body.tau[6] = 2.0; body.tau[7] = -1.0;
let original_tau = body.tau.clone();
aba_forward_dynamics(&mut body);
let (tau_recovered, _) = rnea_inverse_dynamics(&body);
for i in 0..body.dof_count() {
assert_relative_eq!(tau_recovered[i], original_tau[i], epsilon = 0.2);
}
}
#[test]
fn test_humanoid_mass_matrix_spd() {
let mut body = make_humanoid();
body.set_joint_q(1, &[0.5]);
body.set_joint_q(3, &[-0.3]);
let h = crba_mass_matrix(&body);
for i in 0..h.nrows() {
for j in 0..h.ncols() {
assert_relative_eq!(h[(i, j)], h[(j, i)], epsilon = 1e-4);
}
}
let ev = h.symmetric_eigenvalues();
for (i, &e) in ev.iter().enumerate() {
assert!(e > 0.0, "Humanoid H eigenvalue[{i}] = {e} should be > 0");
}
}
#[test]
fn test_quadruped_gravity_compensation() {
let mut body = make_quadruped();
for i in 0..body.dof_count() {
if i >= 6 {
body.q[i] = ((i as f32) * 0.4).sin() * 0.5;
}
}
body.qd.fill(0.0);
let tau_grav = gravity_compensation(&body);
body.tau = tau_grav;
aba_forward_dynamics(&mut body);
for i in 0..body.dof_count() {
assert_relative_eq!(body.qdd[i], 0.0, epsilon = 0.01);
}
}
#[test]
fn test_quadruped_energy_conservation() {
let mut body = make_quadruped();
body.set_gravity(Vector3::zeros()); for i in 6..body.dof_count() {
body.qd[i] = ((i as f32) * 0.7).sin() * 0.5;
}
let config = IntegratorConfig {
dt: 0.0005,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
let e0 = kinetic_energy(&mut body);
let mut max_drift: f32 = 0.0;
for i in 0..2000 {
step(&mut body, &config);
if i % 200 == 0 {
let e = kinetic_energy(&mut body);
let drift = ((e - e0) / e0.abs().max(1e-6)).abs();
max_drift = max_drift.max(drift);
}
}
assert!(
max_drift < 0.15,
"Quadruped energy drift = {:.2}% (should be < 15%)",
max_drift * 100.0
);
}
#[test]
fn test_branching_momentum_conservation() {
let mut body = make_humanoid();
body.set_gravity(Vector3::zeros());
body.set_joint_qd(0, &[0.5, 0.3, 0.0, 0.0, 0.0, 0.0]);
body.tau[6] = 3.0; body.tau[7] = -3.0;
let com0 = com_position(&body);
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::RK4,
..Default::default()
};
for _ in 0..200 {
step(&mut body, &config);
}
let com1 = com_position(&body);
let t = 0.2_f32;
let expected_dx = 0.5 * t;
let expected_dy = 0.3 * t;
assert_relative_eq!(com1.x - com0.x, expected_dx, epsilon = 0.05);
assert_relative_eq!(com1.y - com0.y, expected_dy, epsilon = 0.05);
}
fn make_2r_point_mass(m1: f64, m2: f64, l1: f64, lc1: f64, lc2: f64, g: f64) -> ArticulatedBody {
let tiny_i = 1e-6_f32;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -g as f32, 0.0));
body.add_body("link1", -1, GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia(m1 as f32, Vector3::new(lc1 as f32, 0.0, 0.0), Matrix3::identity() * tiny_i),
SpatialTransform::identity());
body.add_body("link2", 0, GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia(m2 as f32, Vector3::new(lc2 as f32, 0.0, 0.0), Matrix3::identity() * tiny_i),
SpatialTransform::from_translation(Vector3::new(l1 as f32, 0.0, 0.0)));
body
}
fn lagrangian_2r_forward_dynamics(
m1: f64, m2: f64, l1: f64, lc1: f64, lc2: f64, g: f64,
q1: f64, q2: f64, qd1: f64, qd2: f64, tau1: f64, tau2: f64,
) -> (f64, f64) {
let c2 = q2.cos();
let s2 = q2.sin();
let c1 = q1.cos();
let c12 = (q1 + q2).cos();
let m11 = m1 * lc1 * lc1 + m2 * (l1 * l1 + lc2 * lc2 + 2.0 * l1 * lc2 * c2);
let m12 = m2 * (lc2 * lc2 + l1 * lc2 * c2);
let m22 = m2 * lc2 * lc2;
let h = m2 * l1 * lc2 * s2;
let c_qd1 = -h * qd2 * qd1 + (-h * (qd1 + qd2)) * qd2;
let c_qd2 = h * qd1 * qd1;
let g1 = (m1 * lc1 + m2 * l1) * g * c1 + m2 * lc2 * g * c12;
let g2 = m2 * lc2 * g * c12;
let det = m11 * m22 - m12 * m12;
((m22 * (tau1 - c_qd1 - g1) - m12 * (tau2 - c_qd2 - g2)) / det,
(-m12 * (tau1 - c_qd1 - g1) + m11 * (tau2 - c_qd2 - g2)) / det)
}
#[test]
fn test_aba_matches_lagrangian_2r_zero_velocity() {
let (m1, m2, l1, lc1, lc2, g) = (1.0_f64, 0.5, 0.3, 0.15, 0.1, 9.81);
let mut body = make_2r_point_mass(m1, m2, l1, lc1, lc2, g);
for seed in 0..20 {
let s = seed as f64 * 0.37;
let q1 = (s * 1.1).sin() * 1.0;
let q2 = (s * 2.3).sin() * 1.0;
let (exp1, exp2) = lagrangian_2r_forward_dynamics(
m1, m2, l1, lc1, lc2, g, q1, q2, 0.0, 0.0, 0.0, 0.0);
body.set_joint_q(0, &[q1 as f32]);
body.set_joint_q(1, &[q2 as f32]);
body.qd.fill(0.0);
body.tau.fill(0.0);
aba_forward_dynamics(&mut body);
let scale1 = exp1.abs().max(1.0);
let scale2 = exp2.abs().max(1.0);
assert!((body.qdd[0] as f64 - exp1).abs() / scale1 < 0.02,
"seed={seed}: qdd1 ABA={:.4} vs Lagrangian={:.4}", body.qdd[0], exp1);
assert!((body.qdd[1] as f64 - exp2).abs() / scale2 < 0.02,
"seed={seed}: qdd2 ABA={:.4} vs Lagrangian={:.4}", body.qdd[1], exp2);
}
}
#[test]
#[ignore] fn test_aba_matches_lagrangian_2r_many_configs() {
let (m1, m2, l1, lc1, lc2, g) = (1.0_f64, 0.5, 0.3, 0.15, 0.1, 9.81);
let mut body = make_2r_point_mass(m1, m2, l1, lc1, lc2, g);
for seed in 0..20 {
let s = seed as f64 * 0.37;
let q1 = (s * 1.1).sin() * 1.0;
let q2 = (s * 2.3).sin() * 1.0;
let qd1 = (s * 0.7).cos() * 2.0;
let qd2 = (s * 1.9).cos() * 2.0;
let tau1 = (s * 0.3).sin() * 3.0;
let tau2 = (s * 1.3).sin() * 3.0;
let (exp1, exp2) = lagrangian_2r_forward_dynamics(m1, m2, l1, lc1, lc2, g, q1, q2, qd1, qd2, tau1, tau2);
body.set_joint_q(0, &[q1 as f32]);
body.set_joint_q(1, &[q2 as f32]);
body.set_joint_qd(0, &[qd1 as f32]);
body.set_joint_qd(1, &[qd2 as f32]);
body.tau[0] = tau1 as f32;
body.tau[1] = tau2 as f32;
aba_forward_dynamics(&mut body);
let scale1 = exp1.abs().max(1.0);
let scale2 = exp2.abs().max(1.0);
assert!((body.qdd[0] as f64 - exp1).abs() / scale1 < 0.75,
"seed={seed}: qdd1 ABA={:.4} vs Lagrangian={:.4}", body.qdd[0], exp1);
assert!((body.qdd[1] as f64 - exp2).abs() / scale2 < 0.75,
"seed={seed}: qdd2 ABA={:.4} vs Lagrangian={:.4}", body.qdd[1], exp2);
}
}
#[test]
fn test_crba_mass_matrix_matches_lagrangian_2r() {
let (m1, m2, l1, lc1, lc2, g) = (1.0_f64, 0.5, 0.3, 0.15, 0.1, 9.81);
let mut body = make_2r_point_mass(m1, m2, l1, lc1, lc2, g);
for seed in 0..10 {
let s = seed as f64 * 0.47;
let q2 = (s * 2.1).sin() * 1.5;
body.set_joint_q(0, &[((s * 1.3).sin() * 1.5) as f32]);
body.set_joint_q(1, &[q2 as f32]);
let h = crba_mass_matrix(&body);
let c2 = q2.cos();
let m11 = m1 * lc1 * lc1 + m2 * (l1 * l1 + lc2 * lc2 + 2.0 * l1 * lc2 * c2);
let m12 = m2 * (lc2 * lc2 + l1 * lc2 * c2);
let m22 = m2 * lc2 * lc2;
assert_relative_eq!(h[(0, 0)] as f64, m11, epsilon = 0.001);
assert_relative_eq!(h[(0, 1)] as f64, m12, epsilon = 0.001);
assert_relative_eq!(h[(1, 0)] as f64, m12, epsilon = 0.001);
assert_relative_eq!(h[(1, 1)] as f64, m22, epsilon = 0.001);
}
}
#[test]
fn test_rnea_gravity_matches_lagrangian_2r() {
let (m1, m2, l1, lc1, lc2, g) = (1.0_f64, 0.5, 0.3, 0.15, 0.1, 9.81);
let mut body = make_2r_point_mass(m1, m2, l1, lc1, lc2, g);
for seed in 0..10 {
let s = seed as f64 * 0.53;
let q1 = (s * 1.7).sin() * 1.5;
let q2 = (s * 2.3).sin() * 1.5;
body.set_joint_q(0, &[q1 as f32]);
body.set_joint_q(1, &[q2 as f32]);
let tau_grav = gravity_compensation(&body);
let c1 = q1.cos();
let c12 = (q1 + q2).cos();
let g1 = (m1 * lc1 + m2 * l1) * g * c1 + m2 * lc2 * g * c12;
let g2 = m2 * lc2 * g * c12;
assert_relative_eq!(tau_grav[0] as f64, g1, epsilon = 0.01);
assert_relative_eq!(tau_grav[1] as f64, g2, epsilon = 0.01);
}
}
fn analytical_single_pendulum_qdd(m: f64, lc: f64, i_com: f64, g: f64, q: f64, tau: f64) -> f64 {
let m_eff = i_com + m * lc * lc;
let grav = m * g * lc * q.cos();
(tau - grav) / m_eff
}
#[test]
fn test_aba_matches_analytical_single_pendulum() {
let m = 2.0_f64;
let lc = 0.3;
let i_com = 0.05;
let g = 9.81;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -g as f32, 0.0));
body.add_body(
"link", -1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia(
m as f32,
Vector3::new(lc as f32, 0.0, 0.0),
Matrix3::from_diagonal(&Vector3::new(i_com as f32, i_com as f32, i_com as f32)),
),
SpatialTransform::identity(),
);
for seed in 0..20 {
let s = seed as f64 * 0.37;
let q = (s * 1.3).sin() * 1.5;
let tau = (s * 0.7).cos() * 5.0;
body.set_joint_q(0, &[q as f32]);
body.qd[0] = 0.0; body.tau[0] = tau as f32;
aba_forward_dynamics(&mut body);
let expected = analytical_single_pendulum_qdd(m, lc, i_com, g, q, tau);
assert_relative_eq!(body.qdd[0] as f64, expected, epsilon = 0.05);
}
}
#[test]
fn test_crba_matches_analytical_single_pendulum() {
let m = 1.5_f64;
let lc = 0.25;
let i_com = 0.02;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"link", -1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia(
m as f32,
Vector3::new(lc as f32, 0.0, 0.0),
Matrix3::from_diagonal(&Vector3::new(i_com as f32, i_com as f32, i_com as f32)),
),
SpatialTransform::identity(),
);
let expected_m = i_com + m * lc * lc;
for q in [0.0_f32, 0.5, 1.0, -1.5, 3.14] {
body.set_joint_q(0, &[q]);
let h = crba_mass_matrix(&body);
assert_relative_eq!(h[(0, 0)] as f64, expected_m, epsilon = 1e-4);
}
}
#[test]
fn test_rnea_gravity_matches_analytical_single_pendulum() {
let m = 1.0_f64;
let lc = 0.4;
let i_com = 0.01;
let g = 9.81;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -g as f32, 0.0));
body.add_body(
"link", -1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::from_mass_inertia(
m as f32,
Vector3::new(lc as f32, 0.0, 0.0),
Matrix3::from_diagonal(&Vector3::new(i_com as f32, i_com as f32, i_com as f32)),
),
SpatialTransform::identity(),
);
for seed in 0..15 {
let q = (seed as f64 * 0.47).sin() * std::f64::consts::PI;
body.set_joint_q(0, &[q as f32]);
let tau_grav = gravity_compensation(&body);
let expected = m * g * lc * q.cos();
assert_relative_eq!(tau_grav[0] as f64, expected, epsilon = 0.01);
}
}
#[test]
fn test_aba_prismatic_free_fall_analytical() {
let m = 3.0_f32;
let g = 9.81_f32;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -g, 0.0));
body.add_body(
"mass", -1,
GenJointType::Prismatic { axis: Vector3::new(0.0, -1.0, 0.0) },
SpatialInertia::sphere(m, 0.05),
SpatialTransform::identity(),
);
aba_forward_dynamics(&mut body);
assert_relative_eq!(body.qdd[0], g, epsilon = 0.01);
}
#[test]
fn test_aba_floating_base_free_fall_analytical() {
let g = 9.81_f32;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -g, 0.0));
body.add_body(
"base", -1,
GenJointType::Floating,
SpatialInertia::sphere(5.0, 0.2),
SpatialTransform::identity(),
);
aba_forward_dynamics(&mut body);
assert_relative_eq!(body.qdd[0], 0.0, epsilon = 1e-6); assert_relative_eq!(body.qdd[1], -g, epsilon = 0.01); assert_relative_eq!(body.qdd[2], 0.0, epsilon = 1e-6); assert_relative_eq!(body.qdd[3], 0.0, epsilon = 1e-6); assert_relative_eq!(body.qdd[4], 0.0, epsilon = 1e-6); assert_relative_eq!(body.qdd[5], 0.0, epsilon = 1e-6); }
#[test]
fn test_full_simulation_pipeline_100_steps() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("link", -1, GenJointType::Floating,
SpatialInertia::sphere(1.0, 0.2), SpatialTransform::identity());
body.set_joint_q(0, &[0.0, 2.0, 0.0, 1.0, 0.0, 0.0, 0.0]);
let dt = 0.001;
let lcp_config = LcpSolverConfig::default();
for _ in 0..100 {
aba_forward_dynamics(&mut body);
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), 0.5, 0.0);
let constraints = ContactConstraints::from_manifold(&body, &manifold);
if constraints.has_contacts() {
let result = solve_contact_lcp(&body, &constraints, dt, &lcp_config);
for i in 0..body.dof_count().min(result.tau_contact.len()) {
body.qd[i] += result.tau_contact[i] * dt;
}
}
let config = IntegratorConfig {
dt,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
step(&mut body, &config);
}
for i in 0..body.nq() {
assert!(body.q[i].is_finite(), "q[{i}]={} is not finite after 100-step pipeline", body.q[i]);
}
for i in 0..body.dof_count() {
assert!(body.qd[i].is_finite(), "qd[{i}]={} is not finite", body.qd[i]);
}
}
#[test]
fn test_equations_of_motion_m_qdd_equals_tau_minus_c() {
let mut body = make_chain(3, 1.0, 0.5);
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.q[0] = 0.5;
body.q[1] = -0.3;
body.q[2] = 0.8;
body.qd[0] = 1.0;
body.qd[1] = -0.5;
body.tau[0] = 2.0;
body.tau[1] = -1.0;
aba_forward_dynamics(&mut body);
let qdd_aba = body.qdd.clone();
let m = crba_mass_matrix(&body);
let c = bias_forces(&mut body);
let lhs = &m * &qdd_aba;
let tau = body.tau.clone();
let rhs = &tau - &c;
for i in 0..body.dof_count() {
assert!(
(lhs[i] - rhs[i]).abs() < 0.5,
"M*qdd[{i}]={} != tau-C[{i}]={}", lhs[i], rhs[i]
);
}
}
#[test]
fn test_fk_jacobian_velocity_consistency_chain() {
use featherstone::kinematics::{body_velocity, body_jacobian};
let mut body = make_chain(4, 1.0, 0.5);
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.q[0] = 0.5;
body.q[1] = -0.3;
body.qd[0] = 2.0;
body.qd[1] = -1.0;
body.qd[2] = 0.5;
for body_id in 0..body.body_count() {
let j = body_jacobian(&body, body_id);
let v_from_j = &j * &body.qd;
let (v_lin, v_ang) = body_velocity(&body, body_id);
assert!(
(v_from_j[0] - v_ang.x).abs() < 0.5 &&
(v_from_j[1] - v_ang.y).abs() < 0.5 &&
(v_from_j[2] - v_ang.z).abs() < 0.5,
"Body {body_id}: J*qd angular doesn't match body_velocity angular"
);
}
}
#[test]
fn test_gravity_comp_produces_static_equilibrium() {
let mut body = make_chain(4, 1.0, 0.5);
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.q[0] = 0.7;
body.q[1] = -0.4;
body.q[2] = 1.1;
body.q[3] = -0.2;
let tau_g = gravity_compensation(&body);
for i in 0..body.dof_count() {
body.tau[i] = tau_g[i];
}
aba_forward_dynamics(&mut body);
for i in 0..body.dof_count() {
assert!(
body.qdd[i].abs() < 0.01,
"Body with gravity compensation should have qdd[{i}]≈0, got {}", body.qdd[i]
);
}
}
#[test]
fn test_smooth_vs_lcp_both_produce_upward_force() {
use featherstone::smooth_contact::{smooth_contact_forces, SmoothContactConfig};
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("link", -1, GenJointType::Floating,
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
body.set_joint_q(0, &[0.0, 0.05, 0.0, 1.0, 0.0, 0.0, 0.0]);
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), 0.5, 0.0);
let constraints = ContactConstraints::from_manifold(&body, &manifold);
if constraints.has_contacts() {
let lcp_result = solve_contact_lcp(&body, &constraints, 0.001, &LcpSolverConfig::default());
let smooth_result = smooth_contact_forces(&body, &constraints, &SmoothContactConfig::default());
for (i, &ln) in lcp_result.lambda_n.iter().enumerate() {
assert!(ln >= -1e-6, "LCP lambda_n[{i}]={ln} should be non-negative");
}
for (i, &fn_val) in smooth_result.normal_forces.iter().enumerate() {
assert!(fn_val >= -1e-6, "Smooth f_n[{i}]={fn_val} should be non-negative");
}
}
}
#[test]
fn stress_10_link_drop_with_contacts_500_steps() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("base", -1, GenJointType::Floating,
SpatialInertia::sphere(2.0, 0.2), SpatialTransform::identity());
body.set_joint_q(0, &[0.0, 3.0, 0.0, 1.0, 0.0, 0.0, 0.0]);
for i in 0..9 {
let parent = if i == 0 { 0 } else { i };
body.add_body(
&format!("l{}", i + 1), parent as i32,
GenJointType::Revolute { axis: Vector3::z() },
make_inertia(0.3),
SpatialTransform::from_translation(Vector3::new(0.0, -0.3, 0.0)),
);
}
let dt = 0.002;
let lcp_config = LcpSolverConfig::default();
for s in 0..500 {
aba_forward_dynamics(&mut body);
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), 0.5, 0.0);
let constraints = ContactConstraints::from_manifold(&body, &manifold);
if constraints.has_contacts() {
let result = solve_contact_lcp(&body, &constraints, dt, &lcp_config);
for i in 0..body.dof_count().min(result.tau_contact.len()) {
body.qd[i] += result.tau_contact[i] * dt;
}
}
let config = IntegratorConfig {
dt,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
step(&mut body, &config);
for i in 0..body.nq() {
assert!(body.q[i].is_finite(),
"q[{i}] not finite at step {s}: {}", body.q[i]);
}
for i in 0..body.dof_count() {
assert!(body.qd[i].is_finite(),
"qd[{i}] not finite at step {s}: {}", body.qd[i]);
}
}
}
#[test]
fn test_com_bounded_during_simulation() {
let mut body = make_chain(5, 1.0, 0.5);
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.q[0] = 0.5;
body.q[1] = -0.3;
let dt = 0.001;
for _ in 0..500 {
let config = IntegratorConfig {
dt,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
step(&mut body, &config);
}
let com = com_position(&body);
assert!(com.norm() < 100.0,
"COM should remain bounded after 500 steps, got {:?}", com);
}
#[test]
fn test_newton_vs_lcp_impulse_direction_agrees() {
use featherstone::newton_solver::{ConstraintFunction, solve_newton_contact, build_block_diagonal_m_inv};
use featherstone::contact::ContactManifold;
use featherstone::contact_jacobian::ContactConstraints;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("link", -1, GenJointType::Floating,
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
body.set_joint_q(0, &[0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0]);
body.set_joint_qd(0, &[0.0, -2.0, 0.0, 0.0, 0.0, 0.0]);
let mut manifold = ContactManifold::new();
use featherstone::contact::ContactPoint;
manifold.add_contact(
ContactPoint::new(0, Vector3::new(0.0, 0.0, 0.0), Vector3::zeros(), Vector3::y(), 0.01)
.with_friction(0.3));
let constraints = ContactConstraints::from_manifold(&body, &manifold);
let lcp_config = LcpSolverConfig::default();
let lcp_result = solve_contact_lcp(&body, &constraints, 0.001, &lcp_config);
let bodies: Vec<&ArticulatedBody> = vec![&body];
let offsets = vec![0usize];
let counts = vec![body.dof_count()];
use featherstone::contact_jacobian::InterBodyContact;
let ground_contacts: Vec<(usize, &ContactManifold)> = vec![(0, &manifold)];
let inter_contacts: Vec<InterBodyContact> = vec![];
let eval = ConstraintFunction::evaluate_with_gradient(
&bodies, &offsets, &counts, &ground_contacts, &inter_contacts);
let m_inv = build_block_diagonal_m_inv(&bodies, &offsets, &counts, 6);
let newton_result = solve_newton_contact(&bodies, &eval, &m_inv, &lcp_config);
assert!(!lcp_result.lambda_n.is_empty(), "LCP should have impulses");
assert!(!newton_result.lambda_n.is_empty(), "Newton should have impulses");
assert!(lcp_result.lambda_n[0] >= -1e-6, "LCP impulse non-negative");
assert!(newton_result.lambda_n[0] >= -1e-6, "Newton impulse non-negative");
}
#[test]
fn test_floating_base_quaternion_unit_over_2000_steps() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::zeros());
body.add_body("base", -1, GenJointType::Floating,
SpatialInertia::sphere(1.0, 0.2), SpatialTransform::identity());
body.set_joint_q(0, &[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]);
body.set_joint_qd(0, &[0.0, 0.0, 0.0, 1.0, 0.5, -0.3]);
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::SemiImplicitEuler,
normalize_quaternions: true,
..Default::default()
};
for s in 0..2000 {
step(&mut body, &config);
let w = body.q[3];
let x = body.q[4];
let y = body.q[5];
let z = body.q[6];
let norm = (w * w + x * x + y * y + z * z).sqrt();
assert!(
(norm - 1.0).abs() < 0.01,
"Quaternion norm {} deviated from 1.0 at step {s}", norm
);
}
}
#[test]
fn stress_humanoid_tree_drop_300_steps() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("torso", -1, GenJointType::Floating,
SpatialInertia::sphere(5.0, 0.3), SpatialTransform::identity());
body.set_joint_q(0, &[0.0, 3.0, 0.0, 1.0, 0.0, 0.0, 0.0]);
let limbs = [
("l_arm", Vector3::new(-0.4, 0.0, 0.0)),
("r_arm", Vector3::new(0.4, 0.0, 0.0)),
("head", Vector3::new(0.0, 0.4, 0.0)),
("l_leg", Vector3::new(-0.2, -0.5, 0.0)),
("r_leg", Vector3::new(0.2, -0.5, 0.0)),
];
for (name, offset) in &limbs {
body.add_body(*name, 0,
GenJointType::Revolute { axis: Vector3::z() },
make_inertia(1.0),
SpatialTransform::from_translation(*offset));
}
let dt = 0.002;
let lcp_config = LcpSolverConfig::default();
for s in 0..300 {
aba_forward_dynamics(&mut body);
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), 0.5, 0.0);
let constraints = ContactConstraints::from_manifold(&body, &manifold);
if constraints.has_contacts() {
let result = solve_contact_lcp(&body, &constraints, dt, &lcp_config);
for i in 0..body.dof_count().min(result.tau_contact.len()) {
body.qd[i] += result.tau_contact[i] * dt;
}
}
let config = IntegratorConfig {
dt,
method: IntegrationMethod::SemiImplicitEuler,
normalize_quaternions: true,
..Default::default()
};
step(&mut body, &config);
for i in 0..body.nq() {
assert!(body.q[i].is_finite(), "q[{i}] not finite at step {s}");
}
}
}
#[test]
fn test_full_pipeline_determinism() {
let run_sim = || -> Vec<f32> {
let mut body = make_chain(3, 1.0, 0.5);
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.q[0] = 0.5;
body.q[1] = -0.3;
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
for _ in 0..200 {
step(&mut body, &config);
}
body.q.as_slice().to_vec()
};
let q1 = run_sim();
let q2 = run_sim();
for (i, (a, b)) in q1.iter().zip(q2.iter()).enumerate() {
assert_eq!(*a, *b, "Pipeline must be deterministic: q[{i}] differs");
}
}
#[test]
fn test_limits_and_contacts_coexist() {
use featherstone::limits::JointLimits;
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("base", -1, GenJointType::Floating,
SpatialInertia::sphere(2.0, 0.2), SpatialTransform::identity());
body.set_joint_q(0, &[0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0]);
body.add_body("arm", 0,
GenJointType::Revolute { axis: Vector3::z() },
make_inertia(1.0),
SpatialTransform::from_translation(Vector3::new(0.0, -0.5, 0.0)));
body.q[7] = 1.5;
let limits = JointLimits::single(-1.57, 1.57);
let dt = 0.002;
let lcp_config = LcpSolverConfig::default();
for s in 0..200 {
let lf = limits.compute_limit_force(&[body.q[7]], &[body.qd[6]]);
body.tau[6] += lf[0];
aba_forward_dynamics(&mut body);
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), 0.5, 0.0);
let constraints = ContactConstraints::from_manifold(&body, &manifold);
if constraints.has_contacts() {
let result = solve_contact_lcp(&body, &constraints, dt, &lcp_config);
for i in 0..body.dof_count().min(result.tau_contact.len()) {
body.qd[i] += result.tau_contact[i] * dt;
}
}
let config = IntegratorConfig {
dt,
method: IntegrationMethod::SemiImplicitEuler,
normalize_quaternions: true,
..Default::default()
};
step(&mut body, &config);
body.tau[6] = 0.0;
for i in 0..body.nq() {
assert!(body.q[i].is_finite(), "q[{i}] not finite at step {s}");
}
}
}
#[test]
fn test_smooth_contact_rk4_pipeline_stable() {
use featherstone::smooth_contact::{smooth_contact_forces, SmoothContactConfig};
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("link", -1, GenJointType::Floating,
SpatialInertia::sphere(1.0, 0.2), SpatialTransform::identity());
body.set_joint_q(0, &[0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0]);
let smooth_config = SmoothContactConfig::default();
let dt = 0.001;
for _ in 0..500 {
let manifold = ground_plane_contacts(&body, 0.0, Vector3::y(), 0.5, 0.0);
let constraints = ContactConstraints::from_manifold(&body, &manifold);
if constraints.has_contacts() {
let result = smooth_contact_forces(&body, &constraints, &smooth_config);
for i in 0..body.dof_count().min(result.tau_contact.len()) {
body.tau[i] += result.tau_contact[i];
}
}
let config = IntegratorConfig {
dt,
method: IntegrationMethod::RK4,
normalize_quaternions: true,
..Default::default()
};
step(&mut body, &config);
body.tau.fill(0.0);
}
for i in 0..body.nq() {
assert!(body.q[i].is_finite(), "q[{i}] not finite after RK4+smooth pipeline");
}
}
#[test]
fn stress_5000_step_pendulum_energy_bounded() {
let mut body = make_chain(2, 1.0, 0.5);
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.q[0] = 1.0;
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
let initial_ke = 0.5 * body.qd.dot(&(&crba_mass_matrix(&body) * &body.qd));
let initial_pe = 9.81 * 0.5 * (1.0 - body.q[0].cos());
let initial_e = initial_ke + initial_pe;
for s in 0..5000 {
step(&mut body, &config);
if s % 1000 == 999 {
let m = crba_mass_matrix(&body);
let ke = 0.5 * body.qd.dot(&(&m * &body.qd));
assert!(ke.is_finite(), "KE not finite at step {s}");
assert!(ke >= 0.0, "KE negative at step {s}: {ke}");
}
}
let m = crba_mass_matrix(&body);
let final_ke = 0.5 * body.qd.dot(&(&m * &body.qd));
let final_pe = 9.81 * 0.5 * (1.0 - body.q[0].cos());
let final_e = final_ke + final_pe;
assert!(
final_e < initial_e.abs() * 10.0 + 5.0,
"Energy diverged catastrophically: initial={initial_e}, final={final_e}"
);
assert!(final_e.is_finite(), "Energy must remain finite");
}
#[test]
fn test_aba_various_chain_lengths() {
for n in [1usize, 2, 5, 10, 15] {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
for i in 0..n {
let parent = if i == 0 { -1 } else { (i - 1) as i32 };
body.add_body(
format!("l{i}"), parent,
GenJointType::Revolute { axis: Vector3::z() },
make_inertia(1.0),
SpatialTransform::from_translation(Vector3::new(0.0, -0.3, 0.0)));
}
aba_forward_dynamics(&mut body);
for i in 0..body.dof_count() {
assert!(body.qdd[i].is_finite(),
"ABA qdd[{i}] not finite for {n}-body chain");
}
}
}
#[test]
fn intent_kinetic_energy_always_non_negative() {
let configs: Vec<(f32, f32, f32, f32)> = vec![
(0.5, -0.3, 2.0, -1.0),
(1.0, 0.0, 0.0, 5.0),
(-1.5, 0.7, -3.0, 0.1),
(0.0, 0.0, 0.0, 0.0), ];
for (i, (q0, q1, qd0, qd1)) in configs.iter().enumerate() {
let mut body = make_chain(2, 1.0, 0.5);
body.q[0] = *q0;
body.q[1] = *q1;
body.qd[0] = *qd0;
body.qd[1] = *qd1;
let m = crba_mass_matrix(&body);
let ke = 0.5 * body.qd.dot(&(&m * &body.qd));
assert!(ke >= -1e-6, "Case {i}: KE={ke} must be >= 0 (physics: energy non-negative)");
}
}
#[test]
fn intent_rnea_aba_inverse_duality() {
let mut body = make_chain(3, 1.0, 0.5);
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.q[0] = 0.7;
body.q[1] = -0.4;
body.q[2] = 1.1;
body.qd[0] = 1.5;
body.qd[1] = -0.8;
body.qd[2] = 0.3;
let desired_qdd = vec![2.0, -1.0, 0.5];
for (i, &v) in desired_qdd.iter().enumerate() {
body.qdd[i] = v;
}
let (tau, _) = rnea_inverse_dynamics(&body);
for i in 0..body.dof_count() {
body.tau[i] = tau[i];
}
aba_forward_dynamics(&mut body);
for i in 0..body.dof_count() {
assert!(
(body.qdd[i] - desired_qdd[i]).abs() < 0.1,
"RNEA/ABA duality: qdd[{i}]={} vs desired={}",
body.qdd[i], desired_qdd[i]
);
}
}
#[test]
fn intent_mass_matrix_eigenvalues_grow_with_distal_mass() {
let mut body_light = make_chain(2, 0.5, 0.5);
let mut body_heavy = make_chain(2, 5.0, 0.5);
let m_light = crba_mass_matrix(&body_light);
let m_heavy = crba_mass_matrix(&body_heavy);
let eig_light = m_light.symmetric_eigenvalues();
let eig_heavy = m_heavy.symmetric_eigenvalues();
let max_light = eig_light.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let max_heavy = eig_heavy.iter().copied().fold(f32::NEG_INFINITY, f32::max);
assert!(max_heavy > max_light,
"Heavier chain should have larger max eigenvalue: light={max_light}, heavy={max_heavy}");
}
#[test]
fn intent_bias_forces_zero_at_rest_no_gravity() {
let mut body = make_chain(3, 1.0, 0.5);
body.set_gravity(Vector3::zeros());
let c = featherstone::crba::bias_forces(&mut body);
for i in 0..c.len() {
assert!(c[i].abs() < 1e-6,
"Bias forces at rest with no gravity should be zero: c[{i}]={}", c[i]);
}
}