use multicalc::Dual;
use multicalc::linear_algebra::{Vector, Vector3D};
use multicalc::ode::ExponentialMap;
use multicalc::spatial::SO3;
fn prescribed_rate(time: f64) -> Vector3D<f64> {
Vector::new([0.8 * (1.3 * time).cos(), 0.5 * (0.7 * time).sin(), 1.1])
}
fn prescribed_rate_change(time: f64) -> Vector3D<f64> {
Vector::new([-1.04 * (1.3 * time).sin(), 0.35 * (0.7 * time).cos(), 0.0])
}
fn reference_orientation(steps: usize, final_time: f64) -> SO3<f64> {
let rate = |time: f64, _orientation: SO3<f64>| prescribed_rate(time);
ExponentialMap::integrate_attitude(
&rate,
0.0,
SO3::identity(),
final_time / steps as f64,
steps,
|_, _| {},
)
}
fn angle_between(a: SO3<f64>, b: SO3<f64>) -> f64 {
(a.inverse() * b).log().norm()
}
#[test]
fn steady_rate_matches_the_closed_form_turn() {
let turning_about_z = Vector::new([0.0, 0.0, 2.0]);
let timestep = 0.35;
let stepped = ExponentialMap::attitude_step(SO3::identity(), turning_about_z, timestep);
let expected = SO3::exp(Vector::new([0.0, 0.0, 0.7]));
assert!(angle_between(stepped, expected) < 1e-15);
}
#[test]
fn zero_rate_leaves_the_orientation_alone() {
let start = SO3::exp(Vector::new([0.3, -0.2, 0.5]));
let not_turning = Vector::new([0.0, 0.0, 0.0]);
let stepped = ExponentialMap::attitude_step(start, not_turning, 0.01);
assert!(angle_between(start, stepped) < 1e-15);
}
#[test]
fn half_turn_step_lands_on_the_half_turn() {
let turning_about_z = Vector::new([0.0, 0.0, core::f64::consts::PI]);
let stepped = ExponentialMap::attitude_step(SO3::identity(), turning_about_z, 1.0);
assert!((stepped.log().norm() - core::f64::consts::PI).abs() < 1e-12);
}
#[test]
fn unit_length_holds_over_a_long_run() {
let rate = |time: f64, _orientation: SO3<f64>| prescribed_rate(time);
let facing =
ExponentialMap::integrate_attitude(&rate, 0.0, SO3::identity(), 1e-4, 200_000, |_, _| {});
assert!((facing.quaternion().norm() - 1.0).abs() < 1e-12);
}
#[test]
fn first_order_step_converges_first_order() {
let reference = reference_orientation(200_000, 1.0);
let endpoint_error = |steps: usize| {
let timestep = 1.0 / steps as f64;
let mut orientation = SO3::<f64>::identity();
for step in 0..steps {
let time = step as f64 * timestep;
orientation =
ExponentialMap::attitude_step(orientation, prescribed_rate(time), timestep);
}
angle_between(orientation, reference)
};
let ratio = endpoint_error(200) / endpoint_error(400);
assert!((1.6..=2.4).contains(&ratio), "convergence ratio {ratio}");
}
#[test]
fn midpoint_step_converges_second_order() {
let reference = reference_orientation(200_000, 1.0);
let endpoint_error = |steps: usize| {
let timestep = 1.0 / steps as f64;
let mut orientation = SO3::<f64>::identity();
for step in 0..steps {
let time = step as f64 * timestep;
orientation = ExponentialMap::attitude_step_with_angular_acceleration(
orientation,
prescribed_rate(time),
prescribed_rate_change(time),
timestep,
);
}
angle_between(orientation, reference)
};
let ratio = endpoint_error(200) / endpoint_error(400);
assert!((3.2..=4.8).contains(&ratio), "convergence ratio {ratio}");
}
#[test]
fn integrate_attitude_converges_second_order() {
let reference = reference_orientation(200_000, 1.0);
let endpoint_error = |steps: usize| {
let rate = |time: f64, _orientation: SO3<f64>| prescribed_rate(time);
let facing = ExponentialMap::integrate_attitude(
&rate,
0.0,
SO3::identity(),
1.0 / steps as f64,
steps,
|_, _| {},
);
angle_between(facing, reference)
};
let ratio = endpoint_error(200) / endpoint_error(400);
assert!((3.2..=4.8).contains(&ratio), "convergence ratio {ratio}");
}
#[test]
fn observer_sees_every_node_starting_with_the_first() {
let steady = |_time: f64, _orientation: SO3<f64>| Vector::new([0.0, 0.0, 1.0]);
let steps = 10;
let timestep = 0.1;
let mut nodes: Vec<(f64, f64)> = Vec::new();
let _facing = ExponentialMap::integrate_attitude(
&steady,
0.0,
SO3::identity(),
timestep,
steps,
|time, orientation| nodes.push((time, orientation.log()[2])),
);
assert_eq!(nodes.len(), steps + 1);
assert_eq!(nodes[0].0, 0.0);
assert!(nodes[0].1.abs() < 1e-15);
assert!((nodes[steps].0 - 1.0).abs() < 1e-12);
assert!((nodes[steps].1 - 1.0).abs() < 1e-12);
}
#[test]
fn differentiates_through_the_small_angle_branch() {
let timestep = 1e-3;
let turn_from_rate = |rate_z: f64| {
let angular_rate = Vector::new([
Dual::constant(0.0),
Dual::constant(0.0),
Dual::variable(rate_z),
]);
let stepped = ExponentialMap::attitude_step(
SO3::<Dual<f64>>::identity(),
angular_rate,
Dual::constant(timestep),
);
stepped.log()[2].deriv
};
for rate_z in [0.0, 1e-5, 10.0] {
let derivative = turn_from_rate(rate_z);
assert!(derivative.is_finite(), "derivative at ω_z = {rate_z}");
assert!(
(derivative - timestep).abs() < 1e-6,
"derivative {derivative} at ω_z = {rate_z}"
);
}
}
#[test]
fn f32_holds_unit_length_and_round_trips() {
let rate = |time: f32, _orientation: SO3<f32>| {
Vector::new([0.8 * (1.3 * time).cos(), 0.5 * (0.7 * time).sin(), 1.1])
};
let facing = ExponentialMap::integrate_attitude(
&rate,
0.0_f32,
SO3::identity(),
1e-4_f32,
100_000,
|_, _| {},
);
assert!((facing.quaternion().norm() - 1.0).abs() < 1e-3);
let turn = Vector::new([0.2_f32, -0.1, 0.4]);
let round_tripped = SO3::<f32>::exp(turn).log();
assert!((round_tripped - turn).norm() < 1e-5);
}