nyx_space/od/process/solution/
stats.rs1use crate::linalg::allocator::Allocator;
20use crate::linalg::{Const, DMatrix, DefaultAllocator, DimMin, DimName, DimSub, OVector};
21use crate::md::trajectory::{Interpolatable, Traj};
22pub use crate::od::estimate::*;
23pub use crate::od::*;
24use log::{info, warn};
25use msr::sensitivity::TrackerSensitivity;
26use snafu::ResultExt;
27use statrs::distribution::{ContinuousCDF, Normal};
28use std::fmt;
29use std::ops::Add;
30
31use super::ODSolution;
32
33#[cfg(feature = "python")]
34use pyo3::prelude::*;
35
36#[cfg_attr(feature = "python", pyclass(from_py_object, get_all))]
37#[derive(Copy, Clone, Debug)]
38pub struct NormalizedConsistency {
39 pub normalized_sum: f64,
40 pub k: f64,
41 pub lower_bound: f64,
42 pub upper_bound: f64,
43 pub is_nees: bool,
44}
45
46#[cfg_attr(feature = "python", pymethods)]
47impl NormalizedConsistency {
48 pub fn name(&self) -> &'static str {
49 if self.is_nees { "NEES" } else { "NIS" }
50 }
51
52 pub fn has_statistical_power(&self) -> bool {
54 self.k > 35.0
55 }
56
57 pub fn is_consistent(&self) -> bool {
59 self.normalized_sum < self.upper_bound && self.normalized_sum > self.lower_bound
60 }
61
62 pub fn is_underconfident(&self) -> bool {
64 self.normalized_sum < self.lower_bound
65 }
66
67 pub fn is_overconfident(&self) -> bool {
69 self.normalized_sum > self.upper_bound
70 }
71
72 pub fn log(&self) {
74 let name = self.name();
75 let sum = self.normalized_sum;
76 let upper_bound = self.upper_bound;
77 let lower_bound = self.lower_bound;
78 if sum > upper_bound {
79 warn!(
80 "{name} consistency test failed high: {name} sum {sum:.3} > upper bound {upper_bound:.3}."
81 );
82 if self.is_nees {
83 warn!("Estimation errors are larger than the covariance suggests.");
84 } else {
85 warn!("Innovations are larger than expected");
86 }
87 warn!(
88 "Filter is overconfident: P, R, or Q may be too small, or the dynamics/measurement model may be biased."
89 );
90 } else if sum < lower_bound {
91 warn!(
92 "{name} consistency test failed low: {name} sum {sum:.3} < lower bound {lower_bound:.3}."
93 );
94 if self.is_nees {
95 warn!("Estimation errors are smaller than expected.");
96 } else {
97 warn!("Innovations are smaller than expected.")
98 }
99 warn!("Filter is underconfident: P, R, or Q may be too large.");
100 } else {
101 info!("{name} passed")
102 }
103 }
104
105 #[cfg(feature = "python")]
106 fn __str__(&self) -> String {
107 format!("{self}")
108 }
109
110 #[cfg(feature = "python")]
111 fn __repr__(&self) -> String {
112 format!("{self} @ {self:p}")
113 }
114}
115
116impl fmt::Display for NormalizedConsistency {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 let is_ok = self.is_consistent();
119 write!(
120 f,
121 "{} consistency {} (k={}; bounds: {:.3} < {:.3} < {:.3})",
122 self.name(),
123 if is_ok { "PASSED" } else { "FAILED" },
124 self.k,
125 self.lower_bound,
126 self.normalized_sum,
127 self.upper_bound
128 )
129 }
130}
131
132impl<StateType, EstType, MsrSize, Trk> ODSolution<StateType, EstType, MsrSize, Trk>
133where
134 StateType: Interpolatable + Add<OVector<f64, <StateType as State>::Size>, Output = StateType>,
135 EstType: Estimate<StateType>,
136 MsrSize: DimName,
137 Trk: TrackerSensitivity<StateType, StateType>,
138 <DefaultAllocator as Allocator<<StateType as State>::VecLength>>::Buffer<f64>: Send,
139 DefaultAllocator: Allocator<<StateType as State>::Size>
140 + Allocator<<StateType as State>::VecLength>
141 + Allocator<MsrSize>
142 + Allocator<MsrSize, <StateType as State>::Size>
143 + Allocator<MsrSize, MsrSize>
144 + Allocator<<StateType as State>::Size, <StateType as State>::Size>
145 + Allocator<<StateType as State>::Size, MsrSize>,
146{
147 pub fn rms_prefit_residuals(&self) -> f64 {
149 let mut sum = 0.0;
150 for residual in self.residuals.iter().flatten() {
151 sum += residual.prefit.dot(&residual.prefit);
152 }
153 (sum / (self.residuals.len() as f64)).sqrt()
154 }
155
156 pub fn rms_postfit_residuals(&self) -> f64 {
158 let mut sum = 0.0;
159 for residual in self.residuals.iter().flatten() {
160 sum += residual.postfit.dot(&residual.postfit);
161 }
162 (sum / (self.residuals.len() as f64)).sqrt()
163 }
164
165 pub fn rms_residual_ratios(&self) -> f64 {
167 let mut sum = 0.0;
168 for residual in self.residuals.iter().flatten() {
169 sum += residual.ratio.powi(2);
170 }
171 (sum / (self.residuals.len() as f64)).sqrt()
172 }
173
174 pub fn residual_ratio_within_threshold(&self, threshold: f64) -> Result<f64, ODError> {
176 let mut total = 0;
177 let mut count_within = 0;
178 for residual in self.residuals.iter().flatten() {
179 total += 1;
180 if residual.ratio.abs() <= threshold {
181 count_within += 1;
182 }
183 }
184 if total > 0 {
185 Ok(count_within as f64 / total as f64)
186 } else {
187 Err(ODError::ODNoResiduals {
188 action: "percentage of residuals within threshold",
189 })
190 }
191 }
192
193 pub fn ks_test_normality(&self) -> Result<f64, ODError> {
197 let mut residual_ratios = self
198 .accepted_residuals()
199 .iter()
200 .flat_map(|resid| resid.whitened_resid.into_iter().copied())
201 .collect::<Vec<f64>>();
202
203 if residual_ratios.is_empty() {
204 return Err(ODError::ODNoResiduals {
205 action: "percentage of residuals within threshold",
206 });
207 }
208 residual_ratios.sort_by(|a, b| a.partial_cmp(b).unwrap());
209 let n = residual_ratios.len() as f64;
210 let mean = residual_ratios.iter().sum::<f64>() / n;
211 let variance = residual_ratios
212 .iter()
213 .map(|v| (v - mean).powi(2))
214 .sum::<f64>()
215 / n;
216 let std_dev = variance.sqrt();
217
218 let normal_dist = Normal::new(mean, std_dev).unwrap();
220
221 let mut d_stat = 0.0;
223 for (i, &value) in residual_ratios.iter().enumerate() {
224 let empirical_cdf = (i + 1) as f64 / n;
225 let model_cdf = normal_dist.cdf(value);
226 let diff = (empirical_cdf - model_cdf).abs();
227 if diff > d_stat {
228 d_stat = diff;
229 }
230 }
231 Ok(d_stat)
232 }
233
234 pub fn is_normal(&self, alpha: Option<f64>) -> Result<bool, ODError> {
246 let n = self.accepted_residuals().len();
247 if n == 0 {
248 return Err(ODError::ODNoResiduals {
249 action: "evaluating residual normality",
250 });
251 } else if n < 35 {
252 warn!("KS normality test unreliable for n={n} < 35; skipping");
253 }
254
255 let ks_stat = self.ks_test_normality()?;
256
257 let alpha = alpha.unwrap_or(0.05);
259
260 let c_alpha = (-(alpha * 0.5).ln() * 0.5).sqrt();
262
263 let d_critical = c_alpha / (n as f64).sqrt();
264
265 Ok(ks_stat <= d_critical)
266 }
267
268 pub fn nis_consistency(&self, alpha: Option<f64>) -> Result<NormalizedConsistency, ODError> {
283 let n = self.accepted_residuals().len();
284
285 if n == 0 {
286 return Err(ODError::ODNoResiduals {
287 action: "evaluating NIS consistency",
288 });
289 }
290
291 let nis_sum: f64 = self.accepted_residuals().iter().map(|r| r.nis()).sum();
295
296 let k = (n * MsrSize::DIM) as f64;
299 if k < 35.0 {
300 warn!("NIS consistency test lacks statistical power for n*MsrSize={k} < 35");
301 }
302 let alpha = alpha.unwrap_or(0.05);
304
305 let z_critical = Normal::new(0.0, 1.0)
308 .unwrap()
309 .inverse_cdf(1.0 - alpha / 2.0);
310
311 let factor = 2.0 / (9.0 * k);
314 let lower_bound = k * (1.0 - factor - z_critical * factor.sqrt()).powi(3);
315 let upper_bound = k * (1.0 - factor + z_critical * factor.sqrt()).powi(3);
316
317 Ok(NormalizedConsistency {
318 normalized_sum: nis_sum,
319 lower_bound,
320 upper_bound,
321 k,
322 is_nees: false,
323 })
324 }
325
326 #[deprecated(since = "2.4.2", note = "use `nis_consistency` instead")]
341 pub fn is_nis_consistent(&self, alpha: Option<f64>) -> Result<bool, ODError> {
342 self.nis_consistency(alpha).map(|ok| ok.is_consistent())
343 }
344
345 pub fn nees_consistency(
359 &self,
360 truth_traj: &Traj<StateType>,
361 alpha: Option<f64>,
362 ) -> Result<NormalizedConsistency, ODError>
363 where
364 StateType::Size: DimMin<StateType::Size>,
365 <StateType::Size as DimMin<StateType::Size>>::Output: DimSub<Const<1>>,
366 <<StateType as State>::Size as DimMin<<StateType as State>::Size>>::Output:
367 DimSub<Const<1>>,
368 DefaultAllocator: Allocator<StateType::Size, Const<1>>
369 + Allocator<Const<1>, <StateType as State>::Size>
370 + Allocator<<StateType::Size as DimMin<StateType::Size>>::Output, StateType::Size>
371 + Allocator<StateType::Size, <StateType::Size as DimMin<StateType::Size>>::Output>
372 + Allocator<<StateType::Size as DimMin<StateType::Size>>::Output>
373 + Allocator<
374 <<StateType::Size as DimMin<StateType::Size>>::Output as DimSub<Const<1>>>::Output,
375 >,
376 {
377 let n_total = self.estimates.len();
378
379 if n_total <= 1 {
381 return Err(ODError::ODNoResiduals {
382 action: "evaluating NEES consistency",
383 });
384 }
385
386 let n = n_total - 1;
388
389 let mut nees_sum = 0.0;
390 let mut est_size = None;
391
392 for est in self.estimates.iter().skip(1) {
393 let epoch = est.epoch();
394
395 let truth_state = truth_traj.at(epoch).context(ODTrajSnafu {
396 details: "interpolating truth during NEES test",
397 })?;
398
399 let x_est = est.state().to_state_vector();
401 let x_true = truth_state.to_state_vector();
402 let error = x_est - x_true;
403 let cov = est.covar();
404 let cov = 0.5 * (&cov + cov.transpose());
405
406 if est_size.is_none() {
407 let mut active_count = 0;
410 for i in 0..StateType::Size::dim() {
411 if cov[(i, i)] > 0.0 {
413 active_count += 1;
414 } else {
415 break;
417 }
418 }
419 if active_count < 6 {
420 warn!("Detected est_size of {active_count} < 6; forcing bound to 6");
421 active_count = 6;
422 }
423 est_size = Some(active_count);
424 }
425
426 let true_est_size = est_size.unwrap();
428
429 let cov_e = cov
431 .view((0, 0), (true_est_size, true_est_size))
432 .into_owned();
433 let err_e = error.rows(0, true_est_size).into_owned();
434
435 let eigen = cov_e.symmetric_eigen();
438
439 let max_lambda = eigen.eigenvalues.max();
441 let epsilon = max_lambda * (true_est_size as f64) * f64::EPSILON;
442
443 let mut inv_eigenvalues = eigen.eigenvalues.clone();
445 for i in 0..true_est_size {
446 if inv_eigenvalues[i] > epsilon {
450 inv_eigenvalues[i] = 1.0 / inv_eigenvalues[i];
451 } else {
452 inv_eigenvalues[i] = 0.0;
454 }
455 }
456
457 let p_inv = &eigen.eigenvectors
459 * DMatrix::from_diagonal(&inv_eigenvalues)
460 * eigen.eigenvectors.transpose();
461
462 let nees_matrix = err_e.transpose() * p_inv * err_e;
464 nees_sum += nees_matrix[(0, 0)];
465 }
466
467 let k = (n * est_size.unwrap()) as f64;
469
470 if k < 35.0 {
471 warn!("NEES consistency test lacks statistical power for n*StateDim={k} < 35");
472 }
473
474 let alpha = alpha.unwrap_or(0.05);
476
477 let z_critical = Normal::new(0.0, 1.0)
479 .unwrap()
480 .inverse_cdf(1.0 - alpha / 2.0);
481
482 let factor = 2.0 / (9.0 * k);
484 let lower_bound = k * (1.0 - factor - z_critical * factor.sqrt()).powi(3);
485 let upper_bound = k * (1.0 - factor + z_critical * factor.sqrt()).powi(3);
486
487 Ok(NormalizedConsistency {
488 normalized_sum: nees_sum,
489 lower_bound,
490 upper_bound,
491 k,
492 is_nees: true,
493 })
494 }
495
496 #[deprecated(since = "2.4.2", note = "use `nees_consistency` instead")]
510 pub fn is_nees_consistent(
511 &self,
512 truth_traj: &Traj<StateType>,
513 alpha: Option<f64>,
514 ) -> Result<bool, ODError>
515 where
516 StateType::Size: DimMin<StateType::Size>,
517 <StateType::Size as DimMin<StateType::Size>>::Output: DimSub<Const<1>>,
518 <<StateType as State>::Size as DimMin<<StateType as State>::Size>>::Output:
519 DimSub<Const<1>>,
520 DefaultAllocator: Allocator<StateType::Size, Const<1>>
521 + Allocator<Const<1>, <StateType as State>::Size>
522 + Allocator<<StateType::Size as DimMin<StateType::Size>>::Output, StateType::Size>
523 + Allocator<StateType::Size, <StateType::Size as DimMin<StateType::Size>>::Output>
524 + Allocator<<StateType::Size as DimMin<StateType::Size>>::Output>
525 + Allocator<
526 <<StateType::Size as DimMin<StateType::Size>>::Output as DimSub<Const<1>>>::Output,
527 >,
528 {
529 self.nees_consistency(truth_traj, alpha)
530 .map(|ok| ok.is_consistent())
531 }
532}