nyx_space/od/process/solution/
stats.rs1use crate::linalg::allocator::Allocator;
20use crate::linalg::{DefaultAllocator, DimMin, DimName, DimSub};
21use crate::md::trajectory::{Interpolatable, Traj};
22pub use crate::od::estimate::*;
23pub use crate::od::*;
24use log::warn;
25use msr::sensitivity::TrackerSensitivity;
26use nalgebra::Const;
27use snafu::ResultExt;
28use statrs::distribution::{ContinuousCDF, Normal};
29use std::ops::Add;
30
31use super::ODSolution;
32
33impl<StateType, EstType, MsrSize, Trk> ODSolution<StateType, EstType, MsrSize, Trk>
34where
35 StateType: Interpolatable + Add<OVector<f64, <StateType as State>::Size>, Output = StateType>,
36 EstType: Estimate<StateType>,
37 MsrSize: DimName,
38 Trk: TrackerSensitivity<StateType, StateType>,
39 <DefaultAllocator as Allocator<<StateType as State>::VecLength>>::Buffer<f64>: Send,
40 DefaultAllocator: Allocator<<StateType as State>::Size>
41 + Allocator<<StateType as State>::VecLength>
42 + Allocator<MsrSize>
43 + Allocator<MsrSize, <StateType as State>::Size>
44 + Allocator<MsrSize, MsrSize>
45 + Allocator<<StateType as State>::Size, <StateType as State>::Size>
46 + Allocator<<StateType as State>::Size, MsrSize>,
47{
48 pub fn rms_prefit_residuals(&self) -> f64 {
50 let mut sum = 0.0;
51 for residual in self.residuals.iter().flatten() {
52 sum += residual.prefit.dot(&residual.prefit);
53 }
54 (sum / (self.residuals.len() as f64)).sqrt()
55 }
56
57 pub fn rms_postfit_residuals(&self) -> f64 {
59 let mut sum = 0.0;
60 for residual in self.residuals.iter().flatten() {
61 sum += residual.postfit.dot(&residual.postfit);
62 }
63 (sum / (self.residuals.len() as f64)).sqrt()
64 }
65
66 pub fn rms_residual_ratios(&self) -> f64 {
68 let mut sum = 0.0;
69 for residual in self.residuals.iter().flatten() {
70 sum += residual.ratio.powi(2);
71 }
72 (sum / (self.residuals.len() as f64)).sqrt()
73 }
74
75 pub fn residual_ratio_within_threshold(&self, threshold: f64) -> Result<f64, ODError> {
77 let mut total = 0;
78 let mut count_within = 0;
79 for residual in self.residuals.iter().flatten() {
80 total += 1;
81 if residual.ratio.abs() <= threshold {
82 count_within += 1;
83 }
84 }
85 if total > 0 {
86 Ok(count_within as f64 / total as f64)
87 } else {
88 Err(ODError::ODNoResiduals {
89 action: "percentage of residuals within threshold",
90 })
91 }
92 }
93
94 pub fn ks_test_normality(&self) -> Result<f64, ODError> {
98 let mut residual_ratios = self
99 .accepted_residuals()
100 .iter()
101 .flat_map(|resid| resid.whitened_resid.into_iter().copied())
102 .collect::<Vec<f64>>();
103
104 if residual_ratios.is_empty() {
105 return Err(ODError::ODNoResiduals {
106 action: "percentage of residuals within threshold",
107 });
108 }
109 residual_ratios.sort_by(|a, b| a.partial_cmp(b).unwrap());
110 let n = residual_ratios.len() as f64;
111 let mean = residual_ratios.iter().sum::<f64>() / n;
112 let variance = residual_ratios
113 .iter()
114 .map(|v| (v - mean).powi(2))
115 .sum::<f64>()
116 / n;
117 let std_dev = variance.sqrt();
118
119 let normal_dist = Normal::new(mean, std_dev).unwrap();
121
122 let mut d_stat = 0.0;
124 for (i, &value) in residual_ratios.iter().enumerate() {
125 let empirical_cdf = (i + 1) as f64 / n;
126 let model_cdf = normal_dist.cdf(value);
127 let diff = (empirical_cdf - model_cdf).abs();
128 if diff > d_stat {
129 d_stat = diff;
130 }
131 }
132 Ok(d_stat)
133 }
134
135 pub fn is_normal(&self, alpha: Option<f64>) -> Result<bool, ODError> {
147 let n = self.residuals.iter().flatten().count();
148 if n == 0 {
149 return Err(ODError::ODNoResiduals {
150 action: "evaluating residual normality",
151 });
152 } else if n < 35 {
153 warn!("KS normality test unreliable for n={n} < 35; skipping");
154 }
155 let ks_stat = self.ks_test_normality()?;
156
157 let alpha = alpha.unwrap_or(0.05);
159
160 let c_alpha = (-(alpha * 0.5).ln() * 0.5).sqrt();
162
163 let d_critical = c_alpha / (n as f64).sqrt();
164
165 Ok(ks_stat <= d_critical)
166 }
167
168 pub fn is_nis_consistent(&self, alpha: Option<f64>) -> Result<bool, ODError> {
183 let n = self.accepted_residuals().len();
184
185 if n == 0 {
186 return Err(ODError::ODNoResiduals {
187 action: "evaluating NIS consistency",
188 });
189 }
190
191 let nis_sum: f64 = self.accepted_residuals().iter().map(|r| r.nis()).sum();
195
196 let k = (n * MsrSize::DIM) as f64;
199 if k < 35.0 {
200 warn!("NIS consistency test lacks statistical power for n*MsrSize={k} < 35");
201 }
202 let alpha = alpha.unwrap_or(0.05);
204
205 let z_critical = Normal::new(0.0, 1.0)
208 .unwrap()
209 .inverse_cdf(1.0 - alpha / 2.0);
210
211 let factor = 2.0 / (9.0 * k);
214 let lower_bound = k * (1.0 - factor - z_critical * factor.sqrt()).powi(3);
215 let upper_bound = k * (1.0 - factor + z_critical * factor.sqrt()).powi(3);
216
217 if nis_sum > upper_bound {
218 warn!(
219 "NIS consistency test failed high: NIS sum {nis_sum:.6} > upper bound {upper_bound:.6}. Innovations are larger than expected."
220 );
221 warn!(
222 "Filter may be overconfident: P, R, or Q may be too small, or the dynamics/measurement model may be biased."
223 );
224 Ok(false)
225 } else if nis_sum < lower_bound {
226 warn!(
227 "NIS consistency test failed low: NIS sum {nis_sum:.6} < lower bound {lower_bound:.6}. Innovations are smaller than expected."
228 );
229 warn!("Filter may be underconfident: P, R, or Q may be too large.");
230 Ok(false)
231 } else {
232 Ok(true)
233 }
234 }
235
236 pub fn is_nees_consistent(
253 &self,
254 truth_traj: &Traj<StateType>,
255 alpha: Option<f64>,
256 ) -> Result<bool, ODError>
257 where
258 StateType::Size: DimMin<StateType::Size>,
259 <StateType::Size as DimMin<StateType::Size>>::Output: DimSub<Const<1>>,
260 <<StateType as State>::Size as DimMin<<StateType as State>::Size>>::Output:
261 DimSub<Const<1>>,
262 DefaultAllocator: Allocator<StateType::Size, Const<1>>
263 + Allocator<Const<1>, <StateType as State>::Size>
264 + Allocator<<StateType::Size as DimMin<StateType::Size>>::Output, StateType::Size>
265 + Allocator<StateType::Size, <StateType::Size as DimMin<StateType::Size>>::Output>
266 + Allocator<<StateType::Size as DimMin<StateType::Size>>::Output>
267 + Allocator<
268 <<StateType::Size as DimMin<StateType::Size>>::Output as DimSub<Const<1>>>::Output,
269 >,
270 {
271 let n = self.estimates.len();
272
273 if n == 0 {
274 return Err(ODError::ODNoResiduals {
275 action: "evaluating NEES consistency",
276 });
277 }
278
279 let mut nees_sum = 0.0;
280 let mut state_dim_f64 = 0.0_f64;
281
282 for est in &self.estimates {
283 let epoch = est.epoch();
284
285 let truth_state = truth_traj.at(epoch).context(ODTrajSnafu {
286 details: "interpolating truth during NEES test",
287 })?;
288
289 let x_est = est.state().to_state_vector();
291 let x_true = truth_state.to_state_vector();
292 let error = x_est - x_true;
293
294 let p_mat = est.covar();
297
298 let svd = p_mat.svd(true, true);
301
302 let epsilon = 1e-12;
304
305 let p_inv = svd
307 .clone()
308 .pseudo_inverse(epsilon)
309 .map_err(|_| ODError::SingularInformationMatrix)?;
310
311 let active_dim = svd.rank(epsilon) as f64;
314
315 state_dim_f64 = state_dim_f64.max(active_dim);
317
318 let nees_matrix = error.transpose() * p_inv * &error;
320 nees_sum += nees_matrix[(0, 0)];
321
322 if state_dim_f64 == 0.0 {
324 state_dim_f64 = error.nrows() as f64;
325 }
326 }
327
328 let k = (n as f64) * state_dim_f64;
330
331 if k < 35.0 {
332 warn!("NEES consistency test lacks statistical power for n*StateDim={k} < 35");
333 }
334
335 let alpha = alpha.unwrap_or(0.05);
337
338 let z_critical = Normal::new(0.0, 1.0)
340 .unwrap()
341 .inverse_cdf(1.0 - alpha / 2.0);
342
343 let factor = 2.0 / (9.0 * k);
346 let lower_bound = k * (1.0 - factor - z_critical * factor.sqrt()).powi(3);
347 let upper_bound = k * (1.0 - factor + z_critical * factor.sqrt()).powi(3);
348
349 if nees_sum > upper_bound {
350 warn!(
351 "NEES consistency test failed high: NEES sum {nees_sum:.6} > upper bound {upper_bound:.6}. Estimation errors are larger than the covariance suggests."
352 );
353 warn!(
354 "Filter is overconfident: P is too small. Process noise Q may be too low, or there are unmodeled dynamic biases."
355 );
356 Ok(false)
357 } else if nees_sum < lower_bound {
358 warn!(
359 "NEES consistency test failed low: NEES sum {nees_sum:.6} < lower bound {lower_bound:.6}. Estimation errors are smaller than expected."
360 );
361 warn!(
362 "Filter is underconfident: P is too large. Process noise Q may be too high, or measurement noise R is overestimated."
363 );
364 Ok(false)
365 } else {
366 Ok(true)
367 }
368 }
369}