1use glam::{DQuat, DVec3};
22
23use crate::error::CoreError;
24
25#[must_use]
28pub fn quaternion_derivative(q: DQuat, omega_body_rad_s: DVec3) -> DQuat {
29 let omega = DQuat::from_xyzw(
30 omega_body_rad_s.x,
31 omega_body_rad_s.y,
32 omega_body_rad_s.z,
33 0.0,
34 );
35 (q * omega) * 0.5
36}
37
38pub fn renormalize(q: DQuat) -> Result<DQuat, CoreError> {
45 let length = q.length();
46 if length.is_finite() && length >= f64::MIN_POSITIVE {
47 Ok(q / length)
48 } else {
49 Err(CoreError::Domain {
50 what: "attitude quaternion length",
51 value: length,
52 })
53 }
54}
55
56pub fn step_constant_rate(
63 q: DQuat,
64 omega_body_rad_s: DVec3,
65 dt_s: f64,
66) -> Result<DQuat, CoreError> {
67 let increment = DQuat::from_scaled_axis(omega_body_rad_s * dt_s);
68 renormalize(q * increment)
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 struct Coning {
80 alpha: f64,
81 u: DVec3,
82 beta: f64,
83 w: DVec3,
84 }
85
86 impl Coning {
87 fn attitude(&self, t: f64) -> DQuat {
88 DQuat::from_scaled_axis(self.u * self.alpha * t)
89 * DQuat::from_scaled_axis(self.w * self.beta * t)
90 }
91
92 fn body_rate(&self, t: f64) -> DVec3 {
93 let spin = DQuat::from_scaled_axis(self.w * self.beta * t);
94 self.w * self.beta + spin.inverse().mul_vec3(self.u * self.alpha)
95 }
96 }
97
98 fn coning() -> Coning {
99 Coning {
100 alpha: 0.3,
101 u: DVec3::new(0.0, 0.0, 1.0),
102 beta: 5.0,
103 w: DVec3::new(1.0, 2.0, 2.0) / 3.0,
104 }
105 }
106
107 fn angle_between(a: DQuat, b: DQuat) -> f64 {
110 let d = a.inverse() * b;
111 2.0 * d.xyz().length().atan2(d.w.abs())
112 }
113
114 #[test]
116 fn derivative_matches_closed_form_motion() {
117 let c = coning();
118 for t in [0.0, 0.7, 3.1, 12.0] {
119 let h = 1e-6;
120 let numeric = (c.attitude(t + h) - c.attitude(t - h)) * (0.5 / h);
121 let analytic = quaternion_derivative(c.attitude(t), c.body_rate(t));
122 assert!((numeric - analytic).length() < 1e-8, "t = {t}");
123 }
124 }
125
126 #[test]
133 fn rk4_with_renormalization_keeps_unit_norm_over_a_million_steps() {
134 let c = coning();
135 let dt = 1e-4;
136 let steps = 1_000_000;
137 let mut q = c.attitude(0.0);
138 let mut worst_norm_error = 0.0f64;
139 let mut worst_step_drift = 0.0f64;
140 for n in 0..steps {
141 let t = f64::from(n) * dt;
142 let k1 = quaternion_derivative(q, c.body_rate(t));
143 let k2 = quaternion_derivative(q + k1 * (0.5 * dt), c.body_rate(t + 0.5 * dt));
144 let k3 = quaternion_derivative(q + k2 * (0.5 * dt), c.body_rate(t + 0.5 * dt));
145 let k4 = quaternion_derivative(q + k3 * dt, c.body_rate(t + dt));
146 let raw = q + (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (dt / 6.0);
147 worst_step_drift = worst_step_drift.max((raw.length() - 1.0).abs());
148 q = renormalize(raw).unwrap();
149 worst_norm_error = worst_norm_error.max((q.length() - 1.0).abs());
150 }
151 let t_end = f64::from(steps) * dt;
152 let attitude_error = angle_between(q, c.attitude(t_end));
153 assert!(worst_norm_error <= 1e-12, "norm error {worst_norm_error:e}");
154 assert!(
158 worst_step_drift <= 16.0 * f64::EPSILON,
159 "raw step drift {worst_step_drift:e}"
160 );
161 assert!(
162 attitude_error < 1e-9,
163 "attitude error {attitude_error:e} rad"
164 );
165 }
166
167 fn rk4_coning_error(dt: f64, steps: u32) -> f64 {
170 let c = coning();
171 let mut q = c.attitude(0.0);
172 for n in 0..steps {
173 let t = f64::from(n) * dt;
174 let k1 = quaternion_derivative(q, c.body_rate(t));
175 let k2 = quaternion_derivative(q + k1 * (0.5 * dt), c.body_rate(t + 0.5 * dt));
176 let k3 = quaternion_derivative(q + k2 * (0.5 * dt), c.body_rate(t + 0.5 * dt));
177 let k4 = quaternion_derivative(q + k3 * dt, c.body_rate(t + dt));
178 q = renormalize(q + (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (dt / 6.0)).unwrap();
179 }
180 angle_between(q, c.attitude(f64::from(steps) * dt))
181 }
182
183 #[test]
187 fn large_steps_converge_at_fourth_order() {
188 let coarse = rk4_coning_error(0.04, 500);
190 let medium = rk4_coning_error(0.02, 1000);
191 let fine = rk4_coning_error(0.01, 2000);
192 assert!(coarse < 1e-2, "coarse error {coarse:e}");
193 for (big, small) in [(coarse, medium), (medium, fine)] {
194 let order = (big / small).log2();
195 assert!(
196 (3.7..4.3).contains(&order),
197 "observed order {order}: {big:e} → {small:e}"
198 );
199 }
200 }
201
202 #[test]
204 fn constant_rate_steps_track_the_exact_rotation() {
205 let omega = DVec3::new(0.4, -1.3, 7.0);
206 let q0 = DQuat::from_scaled_axis(DVec3::new(0.2, 0.1, -0.3));
207 let dt = 1e-4;
208 let steps = 1_000_000;
209 let mut q = q0;
210 let mut worst_norm_error = 0.0f64;
211 let mut worst_step_drift = 0.0f64;
212 for _ in 0..steps {
213 let raw = q * DQuat::from_scaled_axis(omega * dt);
214 worst_step_drift = worst_step_drift.max((raw.length() - 1.0).abs());
215 q = step_constant_rate(q, omega, dt).unwrap();
216 worst_norm_error = worst_norm_error.max((q.length() - 1.0).abs());
217 }
218 let exact = q0 * DQuat::from_scaled_axis(omega * (f64::from(steps) * dt));
219 assert!(worst_norm_error <= 1e-12, "norm error {worst_norm_error:e}");
220 assert!(
221 worst_step_drift <= 16.0 * f64::EPSILON,
222 "raw step drift {worst_step_drift:e}"
223 );
224 let attitude_error = angle_between(q, exact);
225 assert!(
226 attitude_error < 1e-9,
227 "attitude error {attitude_error:e} rad"
228 );
229 }
230
231 #[test]
232 fn renormalize_rejects_degenerate_quaternions() {
233 assert!(renormalize(DQuat::from_xyzw(0.0, 0.0, 0.0, 0.0)).is_err());
234 assert!(renormalize(DQuat::from_xyzw(f64::NAN, 0.0, 0.0, 1.0)).is_err());
235 assert!(renormalize(DQuat::from_xyzw(f64::INFINITY, 0.0, 0.0, 1.0)).is_err());
236 let q = renormalize(DQuat::from_xyzw(0.0, 0.0, 3.0, 4.0)).unwrap();
237 assert!((q.length() - 1.0).abs() < 1e-15);
238 }
239}