Skip to main content

hpr_core/
attitude.rs

1//! Attitude quaternion kinematics.
2//!
3//! The attitude is a unit Hamilton quaternion `q` that rotates body-frame components into
4//! launch-frame (ENU) components, `v_L = q ⊗ v_B ⊗ q*` (glam's [`DQuat::mul_vec3`]); see
5//! `docs/physics/frames.md`. With `ω_B` the body's angular velocity relative to the launch frame,
6//! resolved in the body frame, the kinematic equation is
7//!
8//! ```text
9//! q̇ = ½ q ⊗ (0, ω_B)
10//! ```
11//!
12//! (J. Solà, *Quaternion kinematics for the error-state Kalman filter*, arXiv:1711.02508v1, 2017,
13//! eq. 200 with the local angular rate `ω_L`; the same Hamilton convention and local-to-global
14//! mapping, eq. 206.)
15//!
16//! A general-purpose integrator applied to `q̇` does not preserve `|q| = 1`, so the flight engine
17//! projects back onto the unit sphere after every accepted step ([`renormalize`]). For a rate that
18//! is constant over the step, [`step_constant_rate`] is exact up to rounding:
19//! `q(t + Δt) = q(t) ⊗ exp(½ ω_B Δt)` (Solà eq. 215, zeroth-order integration).
20
21use glam::{DQuat, DVec3};
22
23use crate::error::CoreError;
24
25/// The time derivative `q̇ = ½ q ⊗ (0, ω_B)` of the attitude quaternion, as a (non-unit)
26/// quaternion, for a body angular velocity `omega_body_rad_s` resolved in the body frame.
27#[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
38/// Projects `q` back onto the unit sphere after an integration step.
39///
40/// # Errors
41///
42/// [`CoreError::Domain`] if `q` has zero, subnormal, infinite or NaN length, because then it no
43/// longer encodes a rotation.
44pub 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
56/// Advances the attitude by `dt_s` under a body rate that is constant over the step:
57/// `q ⊗ exp(½ ω_B Δt)`, then renormalized.
58///
59/// # Errors
60///
61/// As [`renormalize`], which only fails if `q` or the rotation is not finite.
62pub 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    /// A spinning, coning body with a closed-form attitude. With
76    /// `q(t) = exp(½ α t u) ⊗ exp(½ β t w)`, differentiating gives `q̇ = ½ q ⊗ (0, ω_B)` with
77    /// `ω_B(t) = β w + R(exp(½ β t w))ᵀ α u`: a spin `β` about the body axis `w` while that axis
78    /// precesses at `α` about the fixed axis `u`.
79    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    /// The angle of the rotation taking `a` to `b`, in radians. `atan2` stays accurate for tiny
108    /// angles, where `acos` of the dot product loses half the digits.
109    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    /// Checks the kinematic equation itself against the closed-form motion by central differences.
115    #[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    /// M1.1 *done when*: the norm stays within 1e-12 of one over 1e6 steps. Classical RK4 on
127    /// `q̇`, renormalized after each step as the flight engine does. Measured after
128    /// renormalization the bound is met by construction, so the test also bounds the drift of
129    /// each raw step before renormalization (a derivative with a spurious real part grows the
130    /// norm and fails it) and checks the attitude against the closed form (a wrong rotation
131    /// fails that).
132    #[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        // For a constant rate RK4 scales the norm by |R(iθ)| = √(1 − θ⁶/72 + θ⁸/576) ≈ 1 − θ⁶/144
155        // per step, θ = |ω|Δt/2 ≈ 3e-4, about 1e-23: far below rounding. The raw drift should be
156        // a few multiples of f64::EPSILON.
157        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    /// RK4 with renormalization over the coning motion from `t = 0` to `t_end`; returns the
168    /// attitude error against the closed form, rad.
169    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    /// Flight-engine step sizes, |ω|Δt of 0.05 to 0.2: the kinematics plus renormalization
184    /// converge at RK4's fourth order to the closed-form attitude, so errors in the derivative
185    /// that only show at large steps are caught.
186    #[test]
187    fn large_steps_converge_at_fourth_order() {
188        // |ω| ≈ 5.3 rad/s; 20 s of coning at Δt = 0.04, 0.02 and 0.01 s.
189        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    /// The exponential-map step is exact for a constant rate, over a million steps.
203    #[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}