nyx_space/od/kalman/filtering.rs
1/*
2 Nyx, blazing fast astrodynamics
3 Copyright (C) 2018-onwards Christopher Rabotin <christopher.rabotin@gmail.com>
4
5 This program is free software: you can redistribute it and/or modify
6 it under the terms of the GNU Affero General Public License as published
7 by the Free Software Foundation, either version 3 of the License, or
8 (at your option) any later version.
9
10 This program is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 GNU Affero General Public License for more details.
14
15 You should have received a copy of the GNU Affero General Public License
16 along with this program. If not, see <https://www.gnu.org/licenses/>.
17*/
18
19pub use crate::errors::NyxError;
20use crate::linalg::allocator::Allocator;
21use crate::linalg::{DefaultAllocator, DimName, OMatrix, OVector};
22pub use crate::od::estimate::{Estimate, KfEstimate, Residual};
23use crate::od::prelude::KalmanVariant;
24use crate::od::process::SigmaRejection;
25pub use crate::od::snc::ProcessNoise;
26use crate::od::{ODDynamicsSnafu, ODError, State};
27pub use crate::time::{Epoch, Unit};
28use log::info;
29use snafu::prelude::*;
30
31use super::KalmanFilter;
32
33impl<T, A> KalmanFilter<T, A>
34where
35 A: DimName,
36 T: State,
37 DefaultAllocator: Allocator<<T as State>::Size>
38 + Allocator<<T as State>::VecLength>
39 + Allocator<A>
40 + Allocator<<T as State>::Size, <T as State>::Size>
41 + Allocator<A, A>
42 + Allocator<<T as State>::Size, A>
43 + Allocator<A, <T as State>::Size>,
44 <DefaultAllocator as Allocator<<T as State>::Size>>::Buffer<f64>: Copy,
45 <DefaultAllocator as Allocator<<T as State>::Size, <T as State>::Size>>::Buffer<f64>: Copy,
46{
47 /// Returns the previous estimate
48 pub fn previous_estimate(&self) -> &KfEstimate<T> {
49 &self.prev_estimate
50 }
51
52 pub fn set_previous_estimate(&mut self, est: &KfEstimate<T>) {
53 self.prev_estimate = *est;
54 }
55
56 /// Computes a time update/prediction (i.e. advances the filter estimate with the updated STM).
57 ///
58 /// May return a FilterError if the STM was not updated.
59 pub fn time_update(&mut self, nominal_state: T) -> Result<KfEstimate<T>, ODError> {
60 let stm = nominal_state.stm().context(ODDynamicsSnafu)?;
61 let mut covar_bar = stm * self.prev_estimate.covar * stm.transpose();
62
63 // Apply any process noise as in a normal time update, if applicable
64 for (i, snc) in self.process_noise.iter().enumerate().rev() {
65 if let Some(snc_contrib) = snc.propagate::<<T as State>::Size>(
66 nominal_state.orbit(),
67 nominal_state.epoch() - self.prev_estimate.epoch(),
68 )? {
69 if self.prev_used_snc != i {
70 info!("Switched to {i}-th {snc}");
71 self.prev_used_snc = i;
72 }
73 // Let's add the process noise
74 covar_bar += snc_contrib;
75 // And break so we don't add any more process noise
76 break;
77 }
78 }
79
80 let state_bar = if matches!(self.variant, KalmanVariant::DeviationTracking) {
81 stm * self.prev_estimate.state_deviation
82 } else {
83 OVector::<f64, <T as State>::Size>::zeros()
84 };
85
86 // Force symmetry on the covariance
87 covar_bar = 0.5 * (covar_bar + covar_bar.transpose());
88
89 // Clamp negative machine-precision noise on the diagonal
90 for i in 0..<T as State>::Size::DIM {
91 if covar_bar[(i, i)].is_sign_negative() {
92 covar_bar[(i, i)] = 0.0;
93 }
94 }
95
96 let estimate = KfEstimate {
97 nominal_state,
98 state_deviation: state_bar,
99 covar: covar_bar,
100 covar_bar,
101 stm,
102 predicted: true,
103 };
104 self.prev_estimate = estimate;
105 // Update the prev epoch for all SNCs
106 for snc in &mut self.process_noise {
107 snc.prev_epoch = Some(self.prev_estimate.epoch());
108 }
109 Ok(estimate)
110 }
111
112 /// Computes the measurement update with a provided real observation and computed observation.
113 ///
114 /// May return a FilterError if the STM or sensitivity matrices were not updated.
115 pub fn measurement_update<M: DimName>(
116 &mut self,
117 nominal_state: T,
118 real_obs: OVector<f64, M>,
119 computed_obs: OVector<f64, M>,
120 r_k: OMatrix<f64, M, M>,
121 h_tilde: OMatrix<f64, M, <T as State>::Size>,
122 resid_rejection: Option<SigmaRejection>,
123 ) -> Result<
124 (
125 KfEstimate<T>,
126 Residual<M>,
127 Option<OMatrix<f64, <T as State>::Size, M>>,
128 ),
129 ODError,
130 >
131 where
132 DefaultAllocator: Allocator<M>
133 + Allocator<M, M>
134 + Allocator<M, <T as State>::Size>
135 + Allocator<<T as State>::Size, M>
136 + Allocator<nalgebra::Const<1>, M>,
137 {
138 let epoch = nominal_state.epoch();
139
140 // Grab the state transition matrix.
141 let stm = nominal_state.stm().context(ODDynamicsSnafu)?;
142
143 // Propagate the covariance.
144 let mut covar_bar = stm * self.prev_estimate.covar * stm.transpose();
145
146 // Apply any process noise as in a normal time update, if applicable
147 for (i, snc) in self.process_noise.iter().enumerate().rev() {
148 if let Some(snc_contrib) = snc.propagate::<<T as State>::Size>(
149 nominal_state.orbit(),
150 nominal_state.epoch() - self.prev_estimate.epoch(),
151 )? {
152 if self.prev_used_snc != i {
153 info!("Switched to {i}-th {snc}");
154 self.prev_used_snc = i;
155 }
156 // Let's add the process noise
157 covar_bar += snc_contrib;
158 // And break so we don't add any more process noise
159 break;
160 }
161 }
162
163 // Project the propagated covariance into the measurement space.
164 let p_ht = covar_bar * h_tilde.transpose();
165 let h_p_ht = &h_tilde * &p_ht;
166
167 // Compute the innovation matrix (S_k).
168 let s_k = &h_p_ht + &r_k;
169
170 // Compute observation deviation/error (usually marked as y_i)
171 let prefit = real_obs.clone() - computed_obs.clone();
172
173 // Compute the prefit ratio for the automatic rejection.
174 // The measurement covariance is the square of the measurement itself.
175 // So we compute its Cholesky decomposition to return to the non squared values.
176 let s_k_chol = match s_k.clone().cholesky() {
177 Some(r_k_clone) => r_k_clone,
178 None => {
179 // In very rare case, when there isn't enough noise in the measurements,
180 // the inverting of S_k fails. If so, we revert back to the nominal Kalman derivation.
181 r_k.clone().cholesky().ok_or(ODError::SingularNoiseRk)?
182 }
183 };
184
185 // Get the L factor from the Cholesky decomposition
186 let l_matrix = s_k_chol.l();
187
188 // Solve L * v = prefit for the whitened residual vector v. This is an O(n^2) triangular solve, faster than a full Cholesky solve.
189 let whitened_resid = l_matrix.solve_lower_triangular(&prefit).unwrap();
190
191 // Compute the RMS ratio using the norm of the whitened vector This is the true Mahalanobis-based N-sigma ratio.
192 let ratio = (whitened_resid.norm_squared() / (M::DIM as f64)).sqrt();
193
194 // Compute the physical 1-sigma envelop. Using the diagonal of S_k (not L) is correct for physical innovation plots.
195 let innovation_trend = s_k.diagonal().map(|x| x.sqrt());
196
197 if let Some(resid_reject) = resid_rejection
198 && ratio > resid_reject.num_sigmas
199 {
200 // Reject this whole measurement and perform only a time update
201 let pred_est = self.time_update(nominal_state)?;
202 let resid = Residual::rejected(
203 epoch,
204 prefit,
205 whitened_resid,
206 ratio,
207 innovation_trend,
208 real_obs,
209 computed_obs,
210 );
211
212 return Ok((pred_est, resid, None));
213 }
214
215 // Instead of inverting the innovation matrix S_k, we will use the (super short) arXiv paper 1111.4144
216 // which shows how to use the Cholesky decomposition to invert a matrix, core tenets repeated here for my reference.
217 // \forall A \ in \mathbb{R}^{n\times n}, X=A^{-1} <=> A*X=I
218 // Cholesky: A = L*L^T
219 // Therefore, L*L^T*X = I
220 // 1. Solve L * Y = I => Y = L^{-1} (via forward sub)
221 // 2. Solve L^T * X = Y => X = (L^T)^{-1} * L^{-1} = A^{-1}
222 //
223 // _However_, we can be more clever still!
224 // Instead of explicitly inverting the innovation matrix S_k, we solve the linear system
225 // S_k * K^T = H * P using Cholesky decomposition.
226 // This avoids the numerical instability of computing S_k^-1 directly.
227 // Math context:
228 // We want to solve A * X = B for X.
229 // 1. Decompose A into L * L^T (Cholesky).
230 // 2. Solve L * Y = B for Y (Forward substitution).
231 // 3. Solve L^T * X = Y for X (Backward substitution).
232
233 // Prepare the RHS of the linear system: (P * H^T)^T = H * P
234 // We want to solve: S_k * K^T = H * P
235 // So K = (S_k \ (H * P))^T
236 let rhs = p_ht.transpose();
237
238 // Solve for Gain using Cholesky
239 // We try standard Cholesky first.
240 let gain = match s_k.clone().cholesky() {
241 Some(chol) => {
242 // SOLVE, don't invert.
243 // chol.solve(B) computes S_k^{-1} * B more stably than inv(S_k) * B
244 let k_t = chol.solve(&rhs);
245 k_t.transpose()
246 }
247 None => {
248 // If this fails, revert the LU decomposition of nalgebra
249 // Invert the innovation covariance.
250 match s_k.try_inverse() {
251 Some(s_k_inv) => covar_bar * &h_tilde.transpose() * &s_k_inv,
252 None => {
253 eprintln!(
254 "SINGULAR GAIN\nr = {r_k}\nh = {h_tilde:.3e}\ncovar = {covar_bar:.3e}"
255 );
256 return Err(ODError::SingularKalmanGain);
257 }
258 }
259 }
260 };
261
262 // Compute the state estimate, depends on the variant.
263 let (state_hat, res) = match self.variant {
264 KalmanVariant::ReferenceUpdate => {
265 // In EKF, the state hat is actually the state deviation. We trust the gain to be correct,
266 // so we just apply it directly to the prefit residual.
267 let state_hat = &gain * &prefit;
268 let postfit = &prefit - (&h_tilde * state_hat);
269 let resid = Residual::accepted(
270 epoch,
271 prefit,
272 whitened_resid,
273 postfit,
274 ratio,
275 innovation_trend,
276 real_obs,
277 computed_obs,
278 );
279 (state_hat, resid)
280 }
281 KalmanVariant::DeviationTracking => {
282 // Time update
283 let state_bar = stm * self.prev_estimate.state_deviation;
284 let postfit = &prefit - (&h_tilde * state_bar);
285 (
286 state_bar + &gain * &postfit,
287 Residual::accepted(
288 epoch,
289 prefit,
290 whitened_resid,
291 postfit,
292 ratio,
293 innovation_trend,
294 real_obs,
295 computed_obs,
296 ),
297 )
298 }
299 };
300
301 // Compute covariance (Joseph update)
302 let first_term =
303 OMatrix::<f64, <T as State>::Size, <T as State>::Size>::identity() - &gain * &h_tilde;
304 let mut covar =
305 first_term * covar_bar * first_term.transpose() + &gain * &r_k * &gain.transpose();
306
307 // Force symmetry on the covariance
308 covar = 0.5 * (covar + covar.transpose());
309
310 // Clamp negative machine-precision noise on the diagonal
311 for i in 0..<T as State>::Size::DIM {
312 if covar[(i, i)].is_sign_negative() {
313 covar[(i, i)] = 0.0;
314 }
315 }
316
317 // And wrap up
318 let estimate = KfEstimate {
319 nominal_state,
320 state_deviation: state_hat,
321 covar,
322 covar_bar,
323 stm,
324 predicted: false,
325 };
326
327 self.prev_estimate = estimate;
328 // Update the prev epoch for all SNCs
329 for snc in &mut self.process_noise {
330 snc.prev_epoch = Some(self.prev_estimate.epoch());
331 }
332
333 Ok((estimate, res, Some(gain)))
334 }
335
336 pub fn replace_state(&self) -> bool {
337 matches!(self.variant, KalmanVariant::ReferenceUpdate)
338 }
339
340 /// Overwrites all of the process noises to the one provided
341 pub fn set_process_noise(&mut self, snc: ProcessNoise<A>) {
342 self.process_noise = vec![snc];
343 }
344}