use nalgebra::{UnitQuaternion, Quaternion, Vector3};
use super::aba::aba_forward_dynamics;
use super::body::ArticulatedBody;
use super::limits::JointLimits;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum IntegrationMethod {
#[default]
SemiImplicitEuler,
ExplicitEuler,
RK4,
VelocityVerlet,
ImplicitEuler,
}
#[derive(Clone, Debug)]
pub struct IntegratorConfig {
pub method: IntegrationMethod,
pub dt: f32,
pub joint_limits: Vec<Option<JointLimits>>,
pub velocity_damping: f32,
pub normalize_quaternions: bool,
}
impl Default for IntegratorConfig {
fn default() -> Self {
Self {
method: IntegrationMethod::SemiImplicitEuler,
dt: 0.001,
joint_limits: Vec::new(),
velocity_damping: 1.0,
normalize_quaternions: true,
}
}
}
pub fn step(body: &mut ArticulatedBody, config: &IntegratorConfig) {
match config.method {
IntegrationMethod::SemiImplicitEuler => step_semi_implicit_euler(body, config),
IntegrationMethod::ExplicitEuler => step_explicit_euler(body, config),
IntegrationMethod::RK4 => step_rk4(body, config),
IntegrationMethod::VelocityVerlet => step_velocity_verlet(body, config),
IntegrationMethod::ImplicitEuler => step_implicit_euler(body, config),
}
}
pub fn compute_accelerations(body: &mut ArticulatedBody, config: &IntegratorConfig) {
let tau_user = body.tau.clone();
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user;
}
pub fn update_velocities(body: &mut ArticulatedBody, config: &IntegratorConfig) {
let dt = config.dt as f64;
let nv = body.dof_count();
for i in 0..nv {
let qd = body.qd[i] as f64;
let qdd = body.qdd[i] as f64;
let comp = body.qd_compensation[i] as f64;
let y = qdd * dt + comp;
let t = qd + y;
let stored = t as f32;
body.qd_compensation[i] = (t - stored as f64) as f32;
body.qd[i] = stored;
}
if config.velocity_damping < 1.0 {
let d = config.velocity_damping;
body.qd *= d;
body.qd_compensation *= d;
}
apply_velocity_limits(body, config);
}
pub fn update_positions(body: &mut ArticulatedBody, config: &IntegratorConfig) {
integrate_positions(body, config.dt);
apply_position_limits(body, config);
if config.normalize_quaternions {
body.normalize_quaternions();
}
}
fn step_semi_implicit_euler(body: &mut ArticulatedBody, config: &IntegratorConfig) {
compute_accelerations(body, config);
update_velocities(body, config);
update_positions(body, config);
}
fn step_explicit_euler(body: &mut ArticulatedBody, config: &IntegratorConfig) {
let dt = config.dt;
let tau_user = body.tau.clone();
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user;
let qd_old = body.qd.clone();
integrate_positions(body, dt);
let nv = body.dof_count();
let dt64 = dt as f64;
for i in 0..nv {
let increment = body.qdd[i] as f64 * dt64;
let comp = body.qd_compensation[i] as f64;
let y = increment + comp;
let t = qd_old[i] as f64 + y;
let stored = t as f32;
body.qd_compensation[i] = (t - stored as f64) as f32;
body.qd[i] = stored;
}
if config.velocity_damping < 1.0 {
let d = config.velocity_damping;
body.qd *= d;
body.qd_compensation *= d;
}
apply_velocity_limits(body, config);
apply_position_limits(body, config);
if config.normalize_quaternions {
body.normalize_quaternions();
}
}
fn step_rk4(body: &mut ArticulatedBody, config: &IntegratorConfig) {
let dt = config.dt;
let nv = body.dof_count();
let q0 = body.q.clone();
let qd0 = body.qd.clone();
let comp0 = body.q_compensation.clone();
let tau_user = body.tau.clone();
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user.clone();
let k1_qd = body.qd.clone();
let k1_qdd = body.qdd.clone();
body.q = q0.clone();
body.q_compensation = comp0.clone();
body.qd = k1_qd.clone();
integrate_positions(body, dt / 2.0);
for i in 0..nv {
body.qd[i] = qd0[i] + k1_qdd[i] * dt / 2.0;
}
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user.clone();
let k2_qd = body.qd.clone();
let k2_qdd = body.qdd.clone();
body.q = q0.clone();
body.q_compensation = comp0.clone();
body.qd = k2_qd.clone();
integrate_positions(body, dt / 2.0);
for i in 0..nv {
body.qd[i] = qd0[i] + k2_qdd[i] * dt / 2.0;
}
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user.clone();
let k3_qd = body.qd.clone();
let k3_qdd = body.qdd.clone();
body.q = q0.clone();
body.q_compensation = comp0.clone();
body.qd = k3_qd.clone();
integrate_positions(body, dt);
for i in 0..nv {
body.qd[i] = qd0[i] + k3_qdd[i] * dt;
}
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user;
let k4_qdd = body.qdd.clone();
let k4_qd = body.qd.clone();
body.q = q0;
body.q_compensation = comp0;
let qd_avg = (&k1_qd + &k2_qd * 2.0 + &k3_qd * 2.0 + &k4_qd) * (1.0 / 6.0);
body.qd = qd_avg;
integrate_positions(body, dt);
for i in 0..nv {
body.qd[i] = qd0[i]
+ dt / 6.0 * (k1_qdd[i] + 2.0 * k2_qdd[i] + 2.0 * k3_qdd[i] + k4_qdd[i]);
body.qd_compensation[i] = 0.0;
}
if config.velocity_damping < 1.0 {
body.qd *= config.velocity_damping;
}
apply_velocity_limits(body, config);
apply_position_limits(body, config);
if config.normalize_quaternions {
body.normalize_quaternions();
}
}
fn step_velocity_verlet(body: &mut ArticulatedBody, config: &IntegratorConfig) {
let dt = config.dt;
let nv = body.dof_count();
let tau_user = body.tau.clone();
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user.clone();
let qdd_old = body.qdd.clone();
let qd_saved = body.qd.clone();
for i in 0..nv {
body.qd[i] += 0.5 * qdd_old[i] * dt;
}
integrate_positions(body, dt);
body.qd = qd_saved;
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user;
for i in 0..nv {
let increment = 0.5_f64 * (qdd_old[i] as f64 + body.qdd[i] as f64) * dt as f64;
let comp = body.qd_compensation[i] as f64;
let y = increment + comp;
let t = body.qd[i] as f64 + y;
let stored = t as f32;
body.qd_compensation[i] = (t - stored as f64) as f32;
body.qd[i] = stored;
}
if config.velocity_damping < 1.0 {
let d = config.velocity_damping;
body.qd *= d;
body.qd_compensation *= d;
}
apply_velocity_limits(body, config);
apply_position_limits(body, config);
if config.normalize_quaternions {
body.normalize_quaternions();
}
}
fn step_implicit_euler(body: &mut ArticulatedBody, config: &IntegratorConfig) {
let dt = config.dt;
let nv = body.dof_count();
let qd0 = body.qd.clone();
let tau_user = body.tau.clone();
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user.clone();
for i in 0..nv {
body.qd[i] = qd0[i] + body.qdd[i] * dt;
}
for _newton_iter in 0..3 {
let q_saved = body.q.clone();
let q_comp_saved = body.q_compensation.clone();
let qd_comp_saved = body.qd_compensation.clone();
integrate_positions(body, dt);
body.tau = tau_user.clone();
apply_limit_forces(body, config);
aba_forward_dynamics(body);
body.tau = tau_user.clone();
for i in 0..nv {
body.qd[i] = qd0[i] + body.qdd[i] * dt;
}
body.q = q_saved;
body.q_compensation = q_comp_saved;
body.qd_compensation = qd_comp_saved;
}
body.qd_compensation.fill(0.0);
if config.velocity_damping < 1.0 {
body.qd *= config.velocity_damping;
}
apply_velocity_limits(body, config);
integrate_positions(body, dt);
apply_position_limits(body, config);
if config.normalize_quaternions {
body.normalize_quaternions();
}
}
fn integrate_positions(body: &mut ArticulatedBody, dt: f32) {
for i in 0..body.body_count() {
let bd = &body.bodies[i];
let dof = bd.joint_type.dof();
if dof == 0 {
continue;
}
match &bd.joint_type {
super::body::GenJointType::Spherical => {
let q_idx = bd.q_index;
let v_idx = bd.v_index;
let qw = body.q[q_idx];
let qi = body.q[q_idx + 1];
let qj = body.q[q_idx + 2];
let qk = body.q[q_idx + 3];
let current = UnitQuaternion::new_normalize(
Quaternion::new(qw, qi, qj, qk),
);
let wx = body.qd[v_idx] * dt;
let wy = body.qd[v_idx + 1] * dt;
let wz = body.qd[v_idx + 2] * dt;
let angle = (wx * wx + wy * wy + wz * wz).sqrt();
let delta = if angle > 1e-10 {
let axis = Vector3::new(wx, wy, wz) / angle;
UnitQuaternion::from_axis_angle(
&nalgebra::Unit::new_normalize(axis),
angle,
)
} else {
UnitQuaternion::identity()
};
let new_q = current * delta;
body.q[q_idx] = new_q.w;
body.q[q_idx + 1] = new_q.i;
body.q[q_idx + 2] = new_q.j;
body.q[q_idx + 3] = new_q.k;
}
super::body::GenJointType::Floating => {
let q_idx = bd.q_index;
let v_idx = bd.v_index;
for k in 0..3 {
let idx = q_idx + k;
let q = body.q[idx] as f64;
let qd = body.qd[v_idx + k] as f64;
let comp = body.q_compensation[idx] as f64;
let y = qd * (dt as f64) + comp;
let t = q + y;
let stored = t as f32;
body.q_compensation[idx] = (t - stored as f64) as f32;
body.q[idx] = stored;
}
let qw = body.q[q_idx + 3];
let qi = body.q[q_idx + 4];
let qj = body.q[q_idx + 5];
let qk = body.q[q_idx + 6];
let current = UnitQuaternion::new_normalize(
Quaternion::new(qw, qi, qj, qk),
);
let wx = body.qd[v_idx + 3] * dt;
let wy = body.qd[v_idx + 4] * dt;
let wz = body.qd[v_idx + 5] * dt;
let angle = (wx * wx + wy * wy + wz * wz).sqrt();
let delta = if angle > 1e-10 {
let axis = Vector3::new(wx, wy, wz) / angle;
UnitQuaternion::from_axis_angle(
&nalgebra::Unit::new_normalize(axis),
angle,
)
} else {
UnitQuaternion::identity()
};
let new_q = current * delta;
body.q[q_idx + 3] = new_q.w;
body.q[q_idx + 4] = new_q.i;
body.q[q_idx + 5] = new_q.j;
body.q[q_idx + 6] = new_q.k;
}
_ => {
let q_idx = bd.q_index;
let v_idx = bd.v_index;
for k in 0..dof {
let idx = q_idx + k;
let q = body.q[idx] as f64;
let qd = body.qd[v_idx + k] as f64;
let comp = body.q_compensation[idx] as f64;
let y = qd * (dt as f64) + comp;
let t = q + y;
let stored = t as f32;
body.q_compensation[idx] = (t - stored as f64) as f32;
body.q[idx] = stored;
}
}
}
}
}
fn apply_limit_forces(body: &mut ArticulatedBody, config: &IntegratorConfig) {
for (i, limits_opt) in config.joint_limits.iter().enumerate() {
if let Some(limits) = limits_opt {
if i < body.body_count() {
let forces = limits.compute_limit_force(body.joint_q(i), body.joint_qd(i));
let bd = &body.bodies[i];
let v_idx = bd.v_index;
let dof = bd.joint_type.dof();
for (k, &f) in forces.iter().enumerate().take(dof.min(forces.len())) {
body.tau[v_idx + k] += f;
}
}
}
}
}
fn apply_velocity_limits(body: &mut ArticulatedBody, config: &IntegratorConfig) {
for (i, limits_opt) in config.joint_limits.iter().enumerate() {
if let Some(limits) = limits_opt {
if i < body.body_count() {
let clamped = limits.clamp_velocity(body.joint_qd(i));
let bd = &body.bodies[i];
let v_idx = bd.v_index;
let dof = bd.joint_type.dof();
for (k, &c) in clamped.iter().enumerate().take(dof.min(clamped.len())) {
body.qd[v_idx + k] = c;
body.qd_compensation[v_idx + k] = 0.0;
}
}
}
}
}
fn apply_position_limits(body: &mut ArticulatedBody, config: &IntegratorConfig) {
for (i, limits_opt) in config.joint_limits.iter().enumerate() {
if let Some(limits) = limits_opt {
if !limits.enabled || i >= body.body_count() {
continue;
}
let bd = &body.bodies[i];
let q_idx = bd.q_index;
let v_idx = bd.v_index;
let dof = bd.joint_type.dof();
for k in 0..dof.min(limits.lower.len()) {
let lo = limits.lower[k];
let hi = limits.upper[k];
if lo == f32::NEG_INFINITY && hi == f32::INFINITY {
continue;
}
if body.q[q_idx + k] < lo {
body.q[q_idx + k] = lo;
body.q_compensation[q_idx + k] = 0.0;
if body.qd[v_idx + k] < 0.0 {
body.qd[v_idx + k] = 0.0;
body.qd_compensation[v_idx + k] = 0.0;
}
} else if body.q[q_idx + k] > hi {
body.q[q_idx + k] = hi;
body.q_compensation[q_idx + k] = 0.0;
if body.qd[v_idx + k] > 0.0 {
body.qd[v_idx + k] = 0.0;
body.qd_compensation[v_idx + k] = 0.0;
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::body::GenJointType;
use super::super::spatial::{SpatialInertia, SpatialTransform};
use approx::assert_relative_eq;
use nalgebra::Matrix3;
fn make_inertia(mass: f32) -> SpatialInertia {
SpatialInertia::from_mass_inertia_at_com(mass, Matrix3::identity() * 0.01 * mass)
}
fn make_pendulum() -> ArticulatedBody {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
let inertia = 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)),
);
body.add_body(
"pendulum",
-1,
GenJointType::Revolute { axis: Vector3::z() },
inertia,
SpatialTransform::identity(),
);
body
}
#[test]
fn test_semi_implicit_euler_free_fall() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"mass",
-1,
GenJointType::Prismatic { axis: Vector3::y() },
make_inertia(1.0),
SpatialTransform::identity(),
);
let config = IntegratorConfig {
method: IntegrationMethod::SemiImplicitEuler,
dt: 0.001,
..Default::default()
};
for _ in 0..1000 {
step(&mut body, &config);
}
let t = 1.0;
let expected_y = -0.5 * 9.81 * t * t; assert_relative_eq!(body.q[0], expected_y, epsilon = 0.1);
}
#[test]
fn test_explicit_euler_free_fall() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"mass",
-1,
GenJointType::Prismatic { axis: Vector3::y() },
make_inertia(1.0),
SpatialTransform::identity(),
);
let config = IntegratorConfig {
method: IntegrationMethod::ExplicitEuler,
dt: 0.001,
..Default::default()
};
for _ in 0..1000 {
step(&mut body, &config);
}
let expected_y = -0.5 * 9.81 * 1.0;
assert_relative_eq!(body.q[0], expected_y, epsilon = 0.1);
}
#[test]
fn test_rk4_free_fall_accuracy() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"mass",
-1,
GenJointType::Prismatic { axis: Vector3::y() },
make_inertia(1.0),
SpatialTransform::identity(),
);
let config = IntegratorConfig {
method: IntegrationMethod::RK4,
dt: 0.01, ..Default::default()
};
for _ in 0..100 {
step(&mut body, &config);
}
let expected_y = -0.5 * 9.81 * 1.0;
assert_relative_eq!(body.q[0], expected_y, epsilon = 0.01);
}
#[test]
fn test_velocity_verlet_free_fall() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"mass",
-1,
GenJointType::Prismatic { axis: Vector3::y() },
make_inertia(1.0),
SpatialTransform::identity(),
);
let config = IntegratorConfig {
method: IntegrationMethod::VelocityVerlet,
dt: 0.001,
..Default::default()
};
for _ in 0..1000 {
step(&mut body, &config);
}
let expected_y = -0.5 * 9.81 * 1.0;
assert_relative_eq!(body.q[0], expected_y, epsilon = 0.05);
}
#[test]
fn test_pendulum_energy_conservation() {
let mut body = make_pendulum();
body.set_joint_q(0, &[0.3]);
let config = IntegratorConfig {
method: IntegrationMethod::SemiImplicitEuler,
dt: 0.0001, ..Default::default()
};
let initial_energy = pendulum_energy(&body);
for _ in 0..10000 {
step(&mut body, &config);
}
let final_energy = pendulum_energy(&body);
let energy_drift = ((final_energy - initial_energy) / initial_energy).abs();
assert!(
energy_drift < 0.01,
"Energy drift: {energy_drift:.4} (initial={initial_energy:.4}, final={final_energy:.4})"
);
}
#[test]
fn test_integrator_with_limits() {
let mut body = make_pendulum();
body.set_joint_q(0, &[1.5]);
let limits = JointLimits::single(-1.57, 1.57)
.with_barrier(5000.0, 500.0, 0.05);
let config = IntegratorConfig {
dt: 0.001,
joint_limits: vec![Some(limits)],
..Default::default()
};
let mut max_q = 0.0_f32;
for _ in 0..1000 {
step(&mut body, &config);
max_q = max_q.max(body.q[0].abs());
}
assert!(max_q < 2.0, "Joint exceeded limits: max_q={max_q}");
}
#[test]
fn test_velocity_damping() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::zeros());
body.add_body(
"link",
-1,
GenJointType::Revolute { axis: Vector3::z() },
make_inertia(1.0),
SpatialTransform::identity(),
);
body.set_joint_qd(0, &[10.0]);
let config = IntegratorConfig {
dt: 0.001,
velocity_damping: 0.99,
..Default::default()
};
for _ in 0..1000 {
step(&mut body, &config);
}
assert!(body.qd[0].abs() < 1.0, "Velocity should decay: {}", body.qd[0]);
}
#[test]
fn test_multi_link_integration() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
for i in 0..3 {
let parent = if i == 0 { -1 } else { (i - 1) as i32 };
body.add_body(
format!("link{i}"),
parent,
GenJointType::Revolute { axis: Vector3::z() },
make_inertia(1.0),
SpatialTransform::from_translation(Vector3::new(0.2, 0.0, 0.0)),
);
}
body.set_joint_q(0, &[0.1]);
let config = IntegratorConfig::default();
for _ in 0..100 {
step(&mut body, &config);
}
for i in 0..body.q.len() {
assert!(body.q[i].is_finite(), "q[{i}] not finite");
}
for i in 0..body.qd.len() {
assert!(body.qd[i].is_finite(), "qd[{i}] not finite");
}
}
#[test]
fn test_rk4_spherical_joint_quaternion_normalization() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"ball",
-1,
GenJointType::Spherical,
make_inertia(1.0),
SpatialTransform::identity(),
);
body.set_joint_qd(0, &[1.0, 0.5, -0.3]);
let config = IntegratorConfig {
method: IntegrationMethod::RK4,
dt: 0.01,
..Default::default()
};
for _ in 0..100 {
step(&mut body, &config);
}
let qw = body.q[0];
let qx = body.q[1];
let qy = body.q[2];
let qz = body.q[3];
let norm = (qw * qw + qx * qx + qy * qy + qz * qz).sqrt();
assert!(
(norm - 1.0).abs() < 0.01,
"Quaternion should be normalized after RK4, got norm = {norm}"
);
}
#[test]
fn test_velocity_verlet_spherical_joint_quaternion() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"ball",
-1,
GenJointType::Spherical,
make_inertia(1.0),
SpatialTransform::identity(),
);
body.set_joint_qd(0, &[0.5, -0.5, 1.0]);
let config = IntegratorConfig {
method: IntegrationMethod::VelocityVerlet,
dt: 0.01,
..Default::default()
};
for _ in 0..100 {
step(&mut body, &config);
}
let qw = body.q[0];
let qx = body.q[1];
let qy = body.q[2];
let qz = body.q[3];
let norm = (qw * qw + qx * qx + qy * qy + qz * qz).sqrt();
assert!(
(norm - 1.0).abs() < 0.01,
"Quaternion should be normalized after Velocity Verlet, got norm = {norm}"
);
}
fn pendulum_energy(body: &ArticulatedBody) -> f32 {
let q = body.q[0];
let qd = body.qd[0];
let m = 1.0;
let l = 0.3;
let g = 9.81;
let i_total = 0.01 + m * l * l;
let ke = 0.5 * i_total * qd * qd;
let pe = m * g * l * q.sin();
ke + pe
}
#[test]
fn test_all_integrators_produce_finite_results() {
let methods = [
IntegrationMethod::SemiImplicitEuler,
IntegrationMethod::ExplicitEuler,
IntegrationMethod::RK4,
IntegrationMethod::VelocityVerlet,
];
for method in &methods {
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() },
make_inertia(1.0),
SpatialTransform::from_translation(Vector3::new(0.3, 0.0, 0.0)),
);
let config = IntegratorConfig {
method: *method,
dt: 0.01,
..Default::default()
};
for s in 0..50 {
step(&mut body, &config);
assert!(body.q[0].is_finite(), "{method:?} produced NaN q at step {s}");
assert!(body.qd[0].is_finite(), "{method:?} produced NaN qd at step {s}");
}
}
}
#[test]
fn test_rk4_more_accurate_than_euler() {
let make_body = || {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body(
"link", -1,
GenJointType::Prismatic { axis: Vector3::y() },
make_inertia(1.0),
SpatialTransform::identity(),
);
body
};
let dt = 0.01;
let steps = 10;
let mut body_euler = make_body();
let euler_config = IntegratorConfig {
method: IntegrationMethod::ExplicitEuler,
dt,
..Default::default()
};
for _ in 0..steps {
step(&mut body_euler, &euler_config);
}
let mut body_rk4 = make_body();
let rk4_config = IntegratorConfig {
method: IntegrationMethod::RK4,
dt,
..Default::default()
};
for _ in 0..steps {
step(&mut body_rk4, &rk4_config);
}
let t = dt * steps as f32;
let analytical_y = -0.5 * 9.81 * t * t;
let euler_err = (body_euler.q[0] - analytical_y).abs();
let rk4_err = (body_rk4.q[0] - analytical_y).abs();
assert!(
rk4_err <= euler_err + 1e-6,
"RK4 error ({rk4_err}) should be <= Euler error ({euler_err})"
);
}
#[test]
fn intent_integrator_state_dimensions_preserved() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("l1", -1, GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
body.add_body("l2", 0, GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::sphere(1.0, 0.1),
SpatialTransform::from_translation(Vector3::new(0.0, -1.0, 0.0)));
let nq_before = body.q.len();
let nv_before = body.qd.len();
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
step(&mut body, &config);
assert_eq!(body.q.len(), nq_before, "q dimension changed after step");
assert_eq!(body.qd.len(), nv_before, "qd dimension changed after step");
}
#[test]
fn intent_integrator_all_methods_produce_finite_values() {
let methods = [
IntegrationMethod::SemiImplicitEuler,
IntegrationMethod::ExplicitEuler,
IntegrationMethod::VelocityVerlet,
IntegrationMethod::RK4,
];
for method in &methods {
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::sphere(1.0, 0.1), SpatialTransform::identity());
body.tau[0] = 1.0;
body.qd[0] = 0.5;
let config = IntegratorConfig {
dt: 0.001,
method: *method,
..Default::default()
};
for _ in 0..100 {
step(&mut body, &config);
}
for i in 0..body.q.len() {
assert!(body.q[i].is_finite(),
"{:?}: q[{}] is not finite after 100 steps: {}", method, i, body.q[i]);
}
for i in 0..body.qd.len() {
assert!(body.qd[i].is_finite(),
"{:?}: qd[{}] is not finite after 100 steps: {}", method, i, body.qd[i]);
}
}
}
#[test]
fn intent_integrator_damping_reduces_velocity() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, 0.0, 0.0)); body.add_body("link", -1, GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
body.qd[0] = 10.0;
let config = IntegratorConfig {
dt: 0.01,
method: IntegrationMethod::SemiImplicitEuler,
velocity_damping: 0.99, ..Default::default()
};
let v_before = body.qd[0].abs();
for _ in 0..100 {
step(&mut body, &config);
}
let v_after = body.qd[0].abs();
assert!(v_after < v_before,
"damping should reduce velocity: before={}, after={}", v_before, v_after);
}
#[test]
fn intent_free_fall_body_accelerates_under_gravity() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("link", -1, GenJointType::Prismatic { axis: Vector3::y() },
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
let config = IntegratorConfig {
dt: 0.01,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
let pos_before = body.q[0];
for _ in 0..100 {
step(&mut body, &config);
}
let pos_after = body.q[0];
assert!(pos_after < pos_before,
"body should fall under gravity: before={}, after={}", pos_before, pos_after);
assert!(body.qd[0] < -1.0,
"should have downward velocity after 1s of free fall, got qd={}", body.qd[0]);
}
#[test]
fn intent_all_integrators_agree_on_free_fall_direction() {
let methods = [
IntegrationMethod::SemiImplicitEuler,
IntegrationMethod::ExplicitEuler,
IntegrationMethod::RK4,
IntegrationMethod::VelocityVerlet,
];
for method in &methods {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("link", -1, GenJointType::Prismatic { axis: Vector3::y() },
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
let config = IntegratorConfig {
dt: 0.001,
method: *method,
..Default::default()
};
for _ in 0..100 {
step(&mut body, &config);
}
assert!(body.q[0] < 0.0,
"{:?}: body should fall (q < 0), got q={}", method, body.q[0]);
assert!(body.qd[0] < 0.0,
"{:?}: should have negative velocity, got qd={}", method, body.qd[0]);
}
}
#[test]
fn intent_rk4_more_accurate_than_euler_for_oscillator() {
fn run_pendulum(method: IntegrationMethod, steps: usize) -> f32 {
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::sphere(1.0, 0.5), SpatialTransform::identity());
body.q[0] = 0.5;
let config = IntegratorConfig {
dt: 0.01,
method,
..Default::default()
};
for _ in 0..steps {
step(&mut body, &config);
}
let ke = 0.5 * body.qd[0] * body.qd[0];
let pe = 9.81 * 0.5 * (1.0 - body.q[0].cos());
ke + pe
}
let initial_energy = 9.81 * 0.5 * (1.0 - 0.5_f32.cos());
let euler_energy = run_pendulum(IntegrationMethod::SemiImplicitEuler, 1000);
let rk4_energy = run_pendulum(IntegrationMethod::RK4, 1000);
let euler_drift = (euler_energy - initial_energy).abs();
let rk4_drift = (rk4_energy - initial_energy).abs();
assert!(rk4_drift < euler_drift + 0.1,
"RK4 should be at least as accurate as Euler: rk4_drift={rk4_drift}, euler_drift={euler_drift}");
}
#[test]
fn intent_zero_gravity_preserves_state() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::zeros());
body.add_body("link", -1, GenJointType::Prismatic { axis: Vector3::y() },
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
body.q[0] = 3.0;
body.qd[0] = 0.0;
let config = IntegratorConfig {
dt: 0.01,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
for _ in 0..100 {
step(&mut body, &config);
}
assert!((body.q[0] - 3.0).abs() < 1e-6,
"body at rest in zero-g should not move: q={}", body.q[0]);
assert!(body.qd[0].abs() < 1e-6,
"velocity should remain zero: qd={}", body.qd[0]);
}
#[test]
fn intent_implicit_euler_more_stable_large_dt() {
let mut body_impl = ArticulatedBody::new();
body_impl.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body_impl.add_body("link", -1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
body_impl.q[0] = 0.5;
let config = IntegratorConfig {
dt: 0.05, method: IntegrationMethod::ImplicitEuler,
..Default::default()
};
for _ in 0..200 {
step(&mut body_impl, &config);
}
assert!(body_impl.q[0].is_finite(), "implicit Euler q should be finite at dt=0.05");
assert!(body_impl.qd[0].is_finite(), "implicit Euler qd should be finite at dt=0.05");
assert!(body_impl.q[0].abs() < 100.0, "implicit Euler should not diverge");
}
#[test]
fn stress_integrator_1000_steps_three_link_chain() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
for i in 0..3 {
let parent = if i == 0 { -1 } else { (i - 1) as i32 };
body.add_body(
&format!("link{i}"), parent,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::sphere(1.0, 0.1),
SpatialTransform::from_translation(Vector3::new(0.0, -0.5, 0.0)),
);
}
body.q[0] = 0.3;
body.q[1] = -0.2;
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
for _ in 0..1000 {
step(&mut body, &config);
}
for i in 0..body.dof_count() {
assert!(body.q[i].is_finite(), "q[{i}]={} must be finite after 1000 steps", body.q[i]);
assert!(body.qd[i].is_finite(), "qd[{i}]={} must be finite", body.qd[i]);
}
}
#[test]
fn intent_velocity_verlet_second_order() {
fn run_free_fall(dt: f32, steps: usize) -> f32 {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("link", -1,
GenJointType::Prismatic { axis: Vector3::y() },
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
let config = IntegratorConfig {
dt,
method: IntegrationMethod::VelocityVerlet,
..Default::default()
};
for _ in 0..steps {
step(&mut body, &config);
}
body.q[0]
}
let t_end = 1.0;
let analytical = -0.5 * 9.81 * t_end * t_end;
let q_coarse = run_free_fall(0.01, 100); let q_fine = run_free_fall(0.005, 200);
let err_coarse = (q_coarse - analytical).abs();
let err_fine = (q_fine - analytical).abs();
if err_coarse > 1e-6 {
let ratio = err_coarse / (err_fine + 1e-10);
assert!(ratio > 2.0,
"Verlet should be 2nd order: err_coarse={err_coarse}, err_fine={err_fine}, ratio={ratio}");
}
}
#[test]
fn property_all_methods_agree_on_direction() {
let methods = [
IntegrationMethod::SemiImplicitEuler,
IntegrationMethod::ExplicitEuler,
IntegrationMethod::RK4,
IntegrationMethod::VelocityVerlet,
IntegrationMethod::ImplicitEuler,
];
for method in &methods {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("link", -1,
GenJointType::Prismatic { axis: Vector3::y() },
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
let config = IntegratorConfig {
dt: 0.001,
method: *method,
..Default::default()
};
for _ in 0..100 {
step(&mut body, &config);
}
assert!(body.q[0] < 0.0,
"{:?}: body should fall (q[0]<0), got q[0]={}", method, body.q[0]);
assert!(body.qd[0] < 0.0,
"{:?}: velocity should be negative, got qd[0]={}", method, body.qd[0]);
}
}
#[test]
fn intent_kahan_compensation_arrays_exist() {
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::sphere(1.0, 0.1), SpatialTransform::identity());
let config = IntegratorConfig {
dt: 0.001,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
step(&mut body, &config);
assert_eq!(body.q_compensation.len(), body.q.len(),
"q_compensation dimension must match q");
assert_eq!(body.qd_compensation.len(), body.qd.len(),
"qd_compensation dimension must match qd");
}
#[test]
fn test_velocity_verlet_spherical_quaternion_stays_unit() {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::zeros());
body.add_body("link", -1, GenJointType::Spherical,
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
body.qd[0] = 1.0; body.qd[1] = 0.5;
let config = IntegratorConfig {
dt: 0.01,
method: IntegrationMethod::VelocityVerlet,
normalize_quaternions: true,
..Default::default()
};
for _ in 0..200 {
step(&mut body, &config);
}
let norm: f32 = body.q.iter().take(4).map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 0.01,
"spherical quaternion norm should be 1 after 200 Verlet steps: {norm}");
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_step_preserves_state_dimensions(
q0 in -2.0f32..2.0,
dt in 0.0001f32..0.01,
) {
let mut body = ArticulatedBody::new();
body.set_gravity(Vector3::new(0.0, -9.81, 0.0));
body.add_body("l1", -1,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::sphere(1.0, 0.1), SpatialTransform::identity());
body.add_body("l2", 0,
GenJointType::Revolute { axis: Vector3::z() },
SpatialInertia::sphere(0.5, 0.1),
SpatialTransform::from_translation(Vector3::new(0.0, -0.5, 0.0)));
body.q[0] = q0;
let nq_before = body.nq();
let nv_before = body.dof_count();
let config = IntegratorConfig {
dt,
method: IntegrationMethod::SemiImplicitEuler,
..Default::default()
};
step(&mut body, &config);
prop_assert_eq!(body.nq(), nq_before, "nq changed after step");
prop_assert_eq!(body.dof_count(), nv_before, "nv changed after step");
prop_assert_eq!(body.q.len(), nq_before);
prop_assert_eq!(body.qd.len(), nv_before);
}
}
}