use core::fmt;
use kinavis_kernel::angle::TrueCourse;
use kinavis_kernel::error::{ensure_finite, Result};
use kinavis_kernel::math;
use kinavis_kernel::matrix::Matrix;
use kinavis_kernel::units::Angle;
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(try_from = "StoredQuaternion", into = "StoredQuaternion")
)]
pub struct Quaternion {
w: f64,
x: f64,
y: f64,
z: f64,
}
impl Quaternion {
pub const IDENTITY: Self = Self {
w: 1.0,
x: 0.0,
y: 0.0,
z: 0.0,
};
pub fn new(w: f64, x: f64, y: f64, z: f64) -> Result<Self> {
for value in [w, x, y, z] {
ensure_finite("quaternion component", value)?;
}
let norm = math::sqrt(w * w + x * x + y * y + z * z);
ensure_finite("quaternion norm", 1.0 / norm)?;
Ok(Self {
w: w / norm,
x: x / norm,
y: y / norm,
z: z / norm,
})
}
#[must_use]
pub fn from_euler(roll: Angle, pitch: Angle, yaw: TrueCourse) -> Self {
let (sr, cr) = sin_cos(roll.radians() / 2.0);
let (sp, cp) = sin_cos(pitch.radians() / 2.0);
let (sy, cy) = sin_cos(yaw.radians() / 2.0);
Self {
w: cr * cp * cy + sr * sp * sy,
x: sr * cp * cy - cr * sp * sy,
y: cr * sp * cy + sr * cp * sy,
z: cr * cp * sy - sr * sp * cy,
}
.normalised()
}
#[must_use]
pub fn to_euler(&self) -> Attitude {
let (w, x, y, z) = (self.w, self.x, self.y, self.z);
let sin_pitch = (2.0 * (w * y - z * x)).clamp(-1.0, 1.0);
let pitch = math::asin(sin_pitch);
let (roll, yaw) = if 1.0 - math::abs(sin_pitch) < 1e-12 {
(
0.0,
math::atan2(2.0 * (x * y + w * z), 1.0 - 2.0 * (y * y + z * z)),
)
} else {
(
math::atan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y)),
math::atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)),
)
};
Attitude {
roll: Angle::from_radians(roll).unwrap_or(Angle::ZERO),
pitch: Angle::from_radians(pitch).unwrap_or(Angle::ZERO),
yaw: TrueCourse::from_degrees_wrapped(math::to_degrees(yaw)),
}
}
#[must_use]
pub const fn components(&self) -> [f64; 4] {
[self.w, self.x, self.y, self.z]
}
#[must_use]
pub fn to_matrix(&self) -> Matrix<3, 3> {
let (w, x, y, z) = (self.w, self.x, self.y, self.z);
Matrix::from_rows([
[
1.0 - 2.0 * (y * y + z * z),
2.0 * (x * y - w * z),
2.0 * (x * z + w * y),
],
[
2.0 * (x * y + w * z),
1.0 - 2.0 * (x * x + z * z),
2.0 * (y * z - w * x),
],
[
2.0 * (x * z - w * y),
2.0 * (y * z + w * x),
1.0 - 2.0 * (x * x + y * y),
],
])
}
#[must_use]
pub fn rotate(&self, body: [f64; 3]) -> [f64; 3] {
apply(&self.to_matrix(), body)
}
#[must_use]
pub fn rotate_back(&self, navigation: [f64; 3]) -> [f64; 3] {
apply(&self.to_matrix().transpose(), navigation)
}
#[must_use]
pub fn then(&self, other: &Self) -> Self {
let (aw, ax, ay, az) = (self.w, self.x, self.y, self.z);
let (bw, bx, by, bz) = (other.w, other.x, other.y, other.z);
Self {
w: aw * bw - ax * bx - ay * by - az * bz,
x: aw * bx + ax * bw + ay * bz - az * by,
y: aw * by - ax * bz + ay * bw + az * bx,
z: aw * bz + ax * by - ay * bx + az * bw,
}
}
#[must_use]
pub fn rotated_by_body(&self, rotation: [f64; 3]) -> Self {
self.then(&Self::from_rotation_vector(rotation))
.normalised()
}
#[must_use]
pub fn corrected_by(&self, misalignment: [f64; 3]) -> Self {
let [a, b, c] = misalignment;
Self::from_rotation_vector([-a, -b, -c])
.then(self)
.normalised()
}
#[must_use]
pub fn from_rotation_vector(rotation: [f64; 3]) -> Self {
let [a, b, c] = rotation;
let angle = math::sqrt(a * a + b * b + c * c);
if angle < 1e-12 {
return Self {
w: 1.0,
x: a / 2.0,
y: b / 2.0,
z: c / 2.0,
}
.normalised();
}
let (sin, cos) = sin_cos(angle / 2.0);
let scale = sin / angle;
Self {
w: cos,
x: a * scale,
y: b * scale,
z: c * scale,
}
}
#[must_use]
pub fn normalised(&self) -> Self {
let norm =
math::sqrt(self.w * self.w + self.x * self.x + self.y * self.y + self.z * self.z);
if norm < f64::MIN_POSITIVE || !norm.is_finite() {
return Self::IDENTITY;
}
Self {
w: self.w / norm,
x: self.x / norm,
y: self.y / norm,
z: self.z / norm,
}
}
#[must_use]
pub fn misalignment_from(&self, truth: &Self) -> [f64; 3] {
let inverse = Self {
w: truth.w,
x: -truth.x,
y: -truth.y,
z: -truth.z,
};
let relative = self.then(&inverse).normalised();
let sign = if relative.w < 0.0 { -1.0 } else { 1.0 };
let (w, x, y, z) = (
relative.w * sign,
relative.x * sign,
relative.y * sign,
relative.z * sign,
);
let sin_half = math::sqrt(x * x + y * y + z * z);
if sin_half < 1e-12 {
return [2.0 * x, 2.0 * y, 2.0 * z];
}
let angle = 2.0 * math::atan2(sin_half, w);
let scale = angle / sin_half;
[x * scale, y * scale, z * scale]
}
#[must_use]
pub fn angle_to(&self, other: &Self) -> f64 {
let dot = self.w * other.w + self.x * other.x + self.y * other.y + self.z * other.z;
2.0 * math::acos(math::abs(dot).clamp(0.0, 1.0))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Attitude {
pub roll: Angle,
pub pitch: Angle,
pub yaw: TrueCourse,
}
impl Attitude {
pub const LEVEL_NORTH: Self = Self {
roll: Angle::ZERO,
pitch: Angle::ZERO,
yaw: TrueCourse::NORTH,
};
}
impl fmt::Display for Attitude {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let precision = f.precision().unwrap_or(1);
write!(
f,
"roll {:.*}° pitch {:.*}° yaw {:.*}°",
precision,
self.roll.degrees(),
precision,
self.pitch.degrees(),
precision,
self.yaw.degrees()
)
}
}
pub(crate) fn apply(matrix: &Matrix<3, 3>, vector: [f64; 3]) -> [f64; 3] {
let rows = matrix.rows();
let mut out = [0.0; 3];
for (slot, row) in out.iter_mut().zip(rows) {
*slot = row[0] * vector[0] + row[1] * vector[1] + row[2] * vector[2];
}
out
}
pub(crate) fn skew(v: [f64; 3]) -> Matrix<3, 3> {
Matrix::from_rows([[0.0, -v[2], v[1]], [v[2], 0.0, -v[0]], [-v[1], v[0], 0.0]])
}
pub(crate) fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn sin_cos(radians: f64) -> (f64, f64) {
(math::sin(radians), math::cos(radians))
}
#[cfg(feature = "serde")]
#[derive(serde::Serialize, serde::Deserialize)]
struct StoredQuaternion {
w: f64,
x: f64,
y: f64,
z: f64,
}
#[cfg(feature = "serde")]
impl TryFrom<StoredQuaternion> for Quaternion {
type Error = kinavis_kernel::KernelError;
fn try_from(stored: StoredQuaternion) -> Result<Self> {
Self::new(stored.w, stored.x, stored.y, stored.z)
}
}
#[cfg(feature = "serde")]
impl From<Quaternion> for StoredQuaternion {
fn from(quaternion: Quaternion) -> Self {
Self {
w: quaternion.w,
x: quaternion.x,
y: quaternion.y,
z: quaternion.z,
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::float_cmp, clippy::indexing_slicing)]
use super::*;
fn degrees(value: f64) -> Angle {
Angle::from_degrees(value).unwrap()
}
#[test]
fn euler_angles_round_trip_and_yaw_is_the_heading() {
let q = Quaternion::from_euler(
degrees(10.0),
degrees(-5.0),
TrueCourse::new(271.5).unwrap(),
);
let attitude = q.to_euler();
assert!((attitude.roll.degrees() - 10.0).abs() < 1e-9);
assert!((attitude.pitch.degrees() + 5.0).abs() < 1e-9);
assert!((attitude.yaw.degrees() - 271.5).abs() < 1e-9);
assert_eq!(
std::format!("{attitude}"),
"roll 10.0° pitch -5.0° yaw 271.5°"
);
assert_eq!(Quaternion::IDENTITY.to_euler(), Attitude::LEVEL_NORTH);
}
#[test]
fn a_heading_of_east_carries_forward_to_east() {
let q = Quaternion::from_euler(Angle::ZERO, Angle::ZERO, TrueCourse::new(90.0).unwrap());
let forward = q.rotate([1.0, 0.0, 0.0]);
assert!(forward[0].abs() < 1e-12);
assert!((forward[1] - 1.0).abs() < 1e-12);
let back = q.rotate_back(forward);
assert!((back[0] - 1.0).abs() < 1e-12);
assert!((q.rotate([0.0, 0.0, 1.0])[2] - 1.0).abs() < 1e-12);
}
#[test]
fn a_body_rotation_about_down_adds_to_the_yaw() {
let q = Quaternion::from_euler(Angle::ZERO, Angle::ZERO, TrueCourse::new(350.0).unwrap());
let turned = q.rotated_by_body([0.0, 0.0, math::to_radians(20.0)]);
assert!((turned.to_euler().yaw.degrees() - 10.0).abs() < 1e-9);
assert!(q.rotated_by_body([0.0; 3]).angle_to(&q) < 1e-12);
let tiny = q.rotated_by_body([1e-13, 0.0, 0.0]);
assert!(tiny.angle_to(&q) < 1e-12);
}
#[test]
fn a_navigation_frame_correction_takes_the_misalignment_out() {
let estimate =
Quaternion::from_euler(Angle::ZERO, Angle::ZERO, TrueCourse::new(45.0).unwrap());
let epsilon = math::to_radians(0.5);
let corrected = estimate.corrected_by([0.0, 0.0, epsilon]);
assert!((corrected.to_euler().yaw.degrees() - 44.5).abs() < 1e-9);
let expected =
(Matrix::<3, 3>::identity() - skew([0.0, 0.0, epsilon])) * estimate.to_matrix();
let got = corrected.to_matrix();
for row in 0..3 {
for column in 0..3 {
assert!(
(expected.get(row, column).unwrap() - got.get(row, column).unwrap()).abs()
< 1e-4
);
}
}
}
#[test]
fn the_misalignment_is_what_the_correction_takes_out() {
let truth =
Quaternion::from_euler(degrees(3.0), degrees(-2.0), TrueCourse::new(200.0).unwrap());
let misalignment = [0.01, -0.02, 0.03];
let estimate = Quaternion::from_rotation_vector(misalignment).then(&truth);
let found = estimate.misalignment_from(&truth);
for axis in 0..3 {
assert!(
(found[axis] - misalignment[axis]).abs() < 1e-12,
"{found:?}"
);
}
let corrected = estimate.corrected_by(found);
assert!(corrected.angle_to(&truth) < 1e-12);
assert_eq!(truth.misalignment_from(&truth), [0.0; 3]);
}
#[test]
fn products_compose_rotations_and_the_matrix_agrees() {
let a = Quaternion::from_euler(degrees(20.0), degrees(0.0), TrueCourse::new(0.0).unwrap());
let b = Quaternion::from_euler(degrees(0.0), degrees(30.0), TrueCourse::new(0.0).unwrap());
let v = [0.3, -0.4, 0.5];
let composed = a.then(&b).rotate(v);
let stepwise = a.rotate(b.rotate(v));
let by_matrix = apply(&(a.to_matrix() * b.to_matrix()), v);
for axis in 0..3 {
assert!((composed[axis] - stepwise[axis]).abs() < 1e-12);
assert!((composed[axis] - by_matrix[axis]).abs() < 1e-12);
}
assert!((a.angle_to(&Quaternion::IDENTITY) - math::to_radians(20.0)).abs() < 1e-9);
}
#[test]
fn a_quaternion_is_normalised_and_finite_or_refused() {
let q = Quaternion::new(2.0, 0.0, 0.0, 0.0).unwrap();
assert_eq!(q.components(), [1.0, 0.0, 0.0, 0.0]);
assert!(Quaternion::new(0.0, 0.0, 0.0, 0.0).is_err());
assert!(Quaternion::new(f64::NAN, 0.0, 0.0, 0.0).is_err());
assert_eq!(
Quaternion {
w: 0.0,
x: 0.0,
y: 0.0,
z: 0.0
}
.normalised(),
Quaternion::IDENTITY
);
}
#[test]
fn the_gimbal_lock_case_keeps_the_yaw() {
let q = Quaternion::from_euler(degrees(0.0), degrees(90.0), TrueCourse::new(30.0).unwrap());
let attitude = q.to_euler();
assert!((attitude.pitch.degrees() - 90.0).abs() < 1e-6);
assert_eq!(attitude.roll, Angle::ZERO);
}
#[test]
fn cross_and_skew_agree() {
let a = [1.0, 2.0, 3.0];
let b = [-2.0, 0.5, 4.0];
assert_eq!(cross(a, b), apply(&skew(a), b));
assert_eq!(cross(a, b), [6.5, -10.0, 4.5]);
}
}