use nalgebra::{RealField, SMatrix, SVector, UnitQuaternion, Vector3};
use crate::math::wrap_pi;
use crate::state::{Covariance, ErrorState, STATES, State};
use crate::units::{HeadingNoise, Radians};
use crate::update::Observation;
const FORWARD_HORIZONTAL_MIN: f32 = 0.5;
const COURSE_SIGMA_MAX: f32 = 0.258_819;
pub(crate) fn heading_jacobian(state: &State) -> SMatrix<f32, 1, STATES> {
let rotation = state.attitude.quaternion().to_rotation_matrix();
let mut h = SMatrix::<f32, 1, STATES>::zeros();
h.fixed_view_mut::<1, 3>(0, ErrorState::AttitudeX.index())
.copy_from(&rotation.matrix().row(2));
h
}
fn forward(state: &State) -> Vector3<f32> {
state.attitude.quaternion() * Vector3::x()
}
pub(crate) fn has_heading(state: &State) -> bool {
let f = forward(state);
f.x * f.x + f.y * f.y >= FORWARD_HORIZONTAL_MIN * FORWARD_HORIZONTAL_MIN
}
fn forward_heading(state: &State) -> f32 {
heading_of(state.attitude.quaternion())
}
pub(crate) fn heading_of(attitude: UnitQuaternion<f32>) -> f32 {
let f = attitude * Vector3::x();
RealField::atan2(f.y, f.x)
}
pub(crate) fn gnss_observation(
state: &State,
heading: Radians,
noise: HeadingNoise,
) -> Observation<1> {
let r_m = SVector::<f32, 1>::new(noise.variance());
Observation {
y: SVector::<f32, 1>::new(wrap_pi(heading.as_radians() - forward_heading(state))),
h: heading_jacobian(state),
h_b: SVector::<f32, 1>::zeros(),
r_m,
r_gain: r_m,
}
}
fn course_gradient(state: &State) -> Vector3<f32> {
let v = state.velocity.vector();
let v_h2 = v.x * v.x + v.y * v.y;
if v_h2 > 0.0 {
Vector3::new(-v.y / v_h2, v.x / v_h2, 0.0)
} else {
Vector3::zeros()
}
}
pub(crate) fn course_variance(state: &State, covariance: &Covariance) -> Option<f32> {
let gradient = course_gradient(state);
let v = ErrorState::VelocityNorth.index();
let p_vv = covariance.as_matrix().fixed_view::<3, 3>(v, v);
let variance = gradient.dot(&(p_vv * gradient));
let observable = gradient != Vector3::zeros()
&& variance.is_finite()
&& variance <= COURSE_SIGMA_MAX * COURSE_SIGMA_MAX;
observable.then_some(variance)
}
pub(crate) fn course_observation(state: &State, sideslip: HeadingNoise) -> Observation<1> {
let v = state.velocity.vector();
let course = RealField::atan2(v.y, v.x);
let mut h = heading_jacobian(state);
h.fixed_view_mut::<1, 3>(0, ErrorState::VelocityNorth.index())
.copy_from(&(-course_gradient(state)).transpose());
let r_m = SVector::<f32, 1>::new(sideslip.variance());
Observation {
y: SVector::<f32, 1>::new(wrap_pi(course - forward_heading(state))),
h,
h_b: SVector::<f32, 1>::zeros(),
r_m,
r_gain: r_m,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::observation::mag::tests::attitude_of;
use crate::units::Velocity;
use core::f32::consts::PI;
fn state(roll: f32, pitch: f32, yaw: f32, velocity: [f32; 3]) -> State {
State {
attitude: attitude_of(roll, pitch, yaw),
velocity: Velocity::ned(velocity[0], velocity[1], velocity[2]),
..State::default()
}
}
#[test]
fn a_gnss_heading_innovates_by_the_yaw_error_at_any_tilt() {
let estimate = state(0.3, -0.2, 1.1, [0.0; 3]);
let truth = state(0.3, -0.2, 1.25, [0.0; 3]);
let measured = Radians::from_radians(forward_heading(&truth));
let y = gnss_observation(&estimate, measured, HeadingNoise::from_sigma(0.1)).y[0];
assert!((y - 0.15).abs() < 1e-5, "y {y}");
}
#[test]
fn rotating_by_the_innovation_about_down_removes_it() {
let estimate = state(0.3, -0.2, 1.1, [0.0; 3]);
let measured = Radians::from_radians(2.9);
let y = gnss_observation(&estimate, measured, HeadingNoise::from_sigma(0.1)).y[0];
let turned = crate::math::exp_quat(Vector3::z() * y) * estimate.attitude.quaternion();
let after = State {
attitude: crate::units::Attitude::from_quaternion(turned),
..estimate
};
let left = gnss_observation(&after, measured, HeadingNoise::from_sigma(0.1)).y[0];
assert!(left.abs() < 1e-5, "left {left}");
}
#[test]
fn a_heading_across_the_wrap_is_a_small_innovation() {
let estimate = state(0.0, 0.0, PI - 0.05, [0.0; 3]);
let y = gnss_observation(
&estimate,
Radians::from_radians(-PI + 0.05),
HeadingNoise::from_sigma(0.1),
)
.y[0];
assert!((y - 0.1).abs() < 1e-5, "y {y}");
}
#[test]
fn a_forward_axis_near_vertical_has_no_heading() {
assert!(has_heading(&state(0.0, 0.9, 0.0, [0.0; 3])));
assert!(!has_heading(&state(0.0, 1.134, 0.0, [0.0; 3])));
}
#[test]
fn the_course_innovation_is_track_less_heading() {
let s = state(0.0, 0.0, PI / 4.0 - 0.1, [10.0, 10.0, 0.0]);
let o = course_observation(&s, HeadingNoise::from_sigma(0.05));
assert!((o.y[0] - 0.1).abs() < 1e-5, "y {}", o.y[0]);
assert_eq!(o.r_m[0], HeadingNoise::from_sigma(0.05).variance());
}
#[test]
fn the_course_jacobian_matches_a_finite_difference_in_velocity() {
let s = state(0.1, 0.05, 0.4, [12.0, 5.0, -1.0]);
let h = course_observation(&s, HeadingNoise::from_sigma(0.05)).h;
for axis in 0..3 {
let step = 1e-2;
let mut moved = s.velocity.vector();
moved[axis] += step;
let bumped = State {
velocity: Velocity::from_vector(moved),
..s
};
let dy = course_observation(&bumped, HeadingNoise::from_sigma(0.05)).y[0]
- course_observation(&s, HeadingNoise::from_sigma(0.05)).y[0];
let expected = -h[(0, ErrorState::VelocityNorth.index() + axis)] * step;
assert!(
(dy - expected).abs() < 1e-5,
"axis {axis}: {dy} vs {expected}"
);
}
}
#[test]
fn a_course_needs_speed_against_the_velocity_uncertainty() {
let mut sigmas = [0.1; STATES];
let v = ErrorState::VelocityNorth.index();
sigmas[v..v + 3].fill(0.5);
let p = Covariance::from_sigmas(sigmas);
assert!(course_variance(&state(0.0, 0.0, 0.0, [1.8, 0.0, 0.0]), &p).is_none());
let fast = course_variance(&state(0.0, 0.0, 0.0, [2.1, 0.0, 0.0]), &p);
assert!(fast.is_some_and(|var| (var - 0.25 / (2.1 * 2.1)).abs() < 1e-6));
assert!(course_variance(&state(0.0, 0.0, 0.0, [0.0, 0.0, 5.0]), &p).is_none());
let still = course_observation(
&state(0.0, 0.0, 0.0, [0.0; 3]),
HeadingNoise::from_sigma(0.1),
);
assert!(still.y[0].is_finite() && still.h.iter().all(|x| x.is_finite()));
}
}