use qtty::{KmPerSecondsSquared, Second};
use crate::pod::propagation::pod_error::PodDynamicsError;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WhiteAccelPsd(pub [f64; 3]);
impl WhiteAccelPsd {
pub fn isotropic(sigma: KmPerSecondsSquared) -> Self {
let q = sigma.value().powi(2);
Self([q, q, q])
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GaussMarkovParams {
pub sigma: f64,
pub tau: Second,
}
impl GaussMarkovParams {
pub fn variance_after(&self, dt: Second) -> Result<f64, PodDynamicsError> {
if self.sigma < 0.0 || !self.sigma.is_finite() {
return Err(PodDynamicsError::InvalidProcessNoise(
"sigma must be ≥ 0 and finite",
));
}
let tau = self.tau.value();
if tau <= 0.0 || !tau.is_finite() {
return Err(PodDynamicsError::InvalidProcessNoise(
"tau must be > 0 and finite",
));
}
let arg = -2.0 * dt.value() / tau;
Ok(self.sigma.powi(2) * (1.0 - arg.exp()))
}
}
#[derive(Debug, Clone, Copy)]
pub struct ProcessNoiseModel {
pub position_velocity: WhiteAccelPsd,
pub drag_scale: Option<GaussMarkovParams>,
pub srp_scale: Option<GaussMarkovParams>,
pub empirical_white: Option<[KmPerSecondsSquared; 3]>,
}
impl ProcessNoiseModel {
pub fn dim(&self) -> usize {
6 + usize::from(self.drag_scale.is_some())
+ usize::from(self.srp_scale.is_some())
+ if self.empirical_white.is_some() { 3 } else { 0 }
}
pub fn q_matrix(&self, dt: Second) -> Result<Vec<Vec<f64>>, PodDynamicsError> {
let dt_s = dt.value();
if dt_s < 0.0 || !dt_s.is_finite() {
return Err(PodDynamicsError::InvalidProcessNoise(
"dt must be ≥ 0 and finite",
));
}
let n = self.dim();
let mut q = vec![vec![0.0_f64; n]; n];
let dt2 = dt_s * dt_s;
let dt3 = dt2 * dt_s;
for i in 0..3 {
let qi = self.position_velocity.0[i];
if qi < 0.0 || !qi.is_finite() {
return Err(PodDynamicsError::InvalidProcessNoise(
"white-acceleration PSD must be ≥ 0 and finite",
));
}
q[i][i] = qi * dt3 / 3.0;
q[3 + i][3 + i] = qi * dt_s;
let cross = qi * dt2 / 2.0;
q[i][3 + i] = cross;
q[3 + i][i] = cross;
}
let mut idx = 6;
if let Some(gm) = self.drag_scale {
q[idx][idx] = gm.variance_after(dt)?;
idx += 1;
}
if let Some(gm) = self.srp_scale {
q[idx][idx] = gm.variance_after(dt)?;
idx += 1;
}
if let Some(emp) = self.empirical_white {
for k in 0..3 {
let s = emp[k].value();
if s < 0.0 || !s.is_finite() {
return Err(PodDynamicsError::InvalidProcessNoise(
"empirical sigma must be ≥ 0 and finite",
));
}
q[idx + k][idx + k] = s * s * dt_s;
}
}
Ok(q)
}
pub fn is_psd(&self, dt: Second) -> Result<bool, PodDynamicsError> {
let q = self.q_matrix(dt)?;
Ok(matrix_is_psd(&q))
}
}
fn matrix_is_psd(a: &[Vec<f64>]) -> bool {
let n = a.len();
let mut l = vec![vec![0.0_f64; n]; n];
let tol = 1e-18;
for i in 0..n {
let mut s = a[i][i];
s -= l[i][..i].iter().map(|x| x * x).sum::<f64>();
if s < -tol {
return false;
}
let lii = s.max(0.0).sqrt();
l[i][i] = lii;
if lii == 0.0 {
for j in (i + 1)..n {
let s2 = a[j][i]
- l[j][..i]
.iter()
.zip(l[i][..i].iter())
.map(|(a, b)| a * b)
.sum::<f64>();
if s2.abs() > 1e-12 {
return false;
}
l[j][i] = 0.0;
}
} else {
for j in (i + 1)..n {
let s2 = a[j][i]
- l[j][..i]
.iter()
.zip(l[i][..i].iter())
.map(|(a, b)| a * b)
.sum::<f64>();
l[j][i] = s2 / lii;
}
}
}
true
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PiecewiseSegment {
pub duration: Second,
pub sigma_pos_m: f64,
pub sigma_vel_mps: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProcessNoise {
None,
WhiteNoise {
sigma_pos_m: f64,
sigma_vel_mps: f64,
},
PiecewiseConstant {
intervals: Vec<PiecewiseSegment>,
},
}
impl ProcessNoise {
pub fn is_active(&self) -> bool {
!matches!(self, ProcessNoise::None)
}
pub fn sigma_at(&self, elapsed: Second) -> (f64, f64) {
match self {
ProcessNoise::None => (0.0, 0.0),
ProcessNoise::WhiteNoise {
sigma_pos_m,
sigma_vel_mps,
} => (*sigma_pos_m, *sigma_vel_mps),
ProcessNoise::PiecewiseConstant { intervals } => {
if intervals.is_empty() {
return (0.0, 0.0);
}
let mut cumulative = 0.0;
for seg in intervals {
cumulative += seg.duration.value();
if elapsed.value() < cumulative {
return (seg.sigma_pos_m, seg.sigma_vel_mps);
}
}
let last = intervals.last().expect("non-empty checked above");
(last.sigma_pos_m, last.sigma_vel_mps)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dimensions_track_options() {
let m = ProcessNoiseModel {
position_velocity: WhiteAccelPsd::isotropic(KmPerSecondsSquared::new(1e-9)),
drag_scale: Some(GaussMarkovParams {
sigma: 0.1,
tau: Second::new(3600.0),
}),
srp_scale: Some(GaussMarkovParams {
sigma: 0.05,
tau: Second::new(7200.0),
}),
empirical_white: Some([KmPerSecondsSquared::new(1e-10); 3]),
};
assert_eq!(m.dim(), 6 + 1 + 1 + 3);
let q = m.q_matrix(Second::new(60.0)).unwrap();
assert_eq!(q.len(), 11);
}
#[test]
fn rejects_negative_sigma() {
let m = ProcessNoiseModel {
position_velocity: WhiteAccelPsd::isotropic(KmPerSecondsSquared::new(1e-9)),
drag_scale: Some(GaussMarkovParams {
sigma: -1.0,
tau: Second::new(60.0),
}),
srp_scale: None,
empirical_white: None,
};
assert!(matches!(
m.q_matrix(Second::new(10.0)).unwrap_err(),
PodDynamicsError::InvalidProcessNoise(_)
));
}
#[test]
fn process_noise_none_is_inactive() {
assert!(!ProcessNoise::None.is_active());
assert_eq!(ProcessNoise::None.sigma_at(Second::new(0.0)), (0.0, 0.0));
}
#[test]
fn process_noise_white_noise_is_active() {
let n = ProcessNoise::WhiteNoise {
sigma_pos_m: 5.0,
sigma_vel_mps: 0.01,
};
assert!(n.is_active());
assert_eq!(n.sigma_at(Second::new(100.0)), (5.0, 0.01));
}
#[test]
fn piecewise_constant_dispatches_by_elapsed_time() {
let p = ProcessNoise::PiecewiseConstant {
intervals: vec![
PiecewiseSegment {
duration: Second::new(60.0),
sigma_pos_m: 1.0,
sigma_vel_mps: 0.001,
},
PiecewiseSegment {
duration: Second::new(300.0),
sigma_pos_m: 10.0,
sigma_vel_mps: 0.01,
},
],
};
assert_eq!(p.sigma_at(Second::new(0.0)), (1.0, 0.001));
assert_eq!(p.sigma_at(Second::new(59.9)), (1.0, 0.001));
assert_eq!(p.sigma_at(Second::new(60.0)), (10.0, 0.01));
assert_eq!(p.sigma_at(Second::new(9_999.0)), (10.0, 0.01));
}
#[test]
fn piecewise_constant_empty_schedule_returns_zero() {
let p = ProcessNoise::PiecewiseConstant { intervals: vec![] };
assert_eq!(p.sigma_at(Second::new(0.0)), (0.0, 0.0));
}
#[test]
fn q_matrix_negative_dt_is_error() {
let m = ProcessNoiseModel {
position_velocity: WhiteAccelPsd::isotropic(KmPerSecondsSquared::new(1e-9)),
drag_scale: None,
srp_scale: None,
empirical_white: None,
};
assert!(matches!(
m.q_matrix(Second::new(-1.0)).unwrap_err(),
PodDynamicsError::InvalidProcessNoise(_)
));
}
#[test]
fn gauss_markov_negative_sigma_is_error() {
let gm = GaussMarkovParams {
sigma: -1.0,
tau: Second::new(3600.0),
};
assert!(matches!(
gm.variance_after(Second::new(60.0)).unwrap_err(),
PodDynamicsError::InvalidProcessNoise(_)
));
}
#[test]
fn gauss_markov_zero_tau_is_error() {
let gm = GaussMarkovParams {
sigma: 1.0,
tau: Second::new(0.0),
};
assert!(matches!(
gm.variance_after(Second::new(60.0)).unwrap_err(),
PodDynamicsError::InvalidProcessNoise(_)
));
}
#[test]
fn matrix_is_psd_identity_is_true() {
let identity = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
assert!(matrix_is_psd(&identity));
}
#[test]
fn matrix_is_psd_indefinite_is_false() {
let m = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
assert!(!matrix_is_psd(&m));
}
#[test]
fn white_accel_psd_isotropic_squares_value() {
let sigma = 1e-4;
let psd = WhiteAccelPsd::isotropic(KmPerSecondsSquared::new(sigma));
assert!((psd.0[0] - sigma * sigma).abs() < 1e-20);
assert_eq!(psd.0[0], psd.0[1]);
assert_eq!(psd.0[1], psd.0[2]);
}
#[test]
fn is_psd_returns_true_for_valid_model() {
let m = ProcessNoiseModel {
position_velocity: WhiteAccelPsd::isotropic(KmPerSecondsSquared::new(1e-9)),
drag_scale: None,
srp_scale: None,
empirical_white: None,
};
assert!(m.is_psd(Second::new(60.0)).unwrap());
}
}