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
19use crate::cosmic::AstroPhysicsSnafu;
20pub use crate::errors::NyxError;
21use crate::errors::StateAstroSnafu;
22use crate::linalg::allocator::Allocator;
23use crate::linalg::{DefaultAllocator, DimName, OMatrix, OVector};
24use crate::md::StateParameter;
25pub use crate::od::estimate::{Estimate, KfEstimate, Residual};
26use crate::od::prelude::KalmanVariant;
27use crate::od::process::ResidRejectCrit;
28pub use crate::od::snc::ProcessNoise;
29use crate::od::{ODDynamicsSnafu, ODError, ODStateSnafu, State};
30pub use crate::time::{Epoch, Unit};
31use log::info;
32use snafu::prelude::*;
33
34use super::KalmanFilter;
35
36impl<T, A> KalmanFilter<T, A>
37where
38 A: DimName,
39 T: State,
40 DefaultAllocator: Allocator<<T as State>::Size>
41 + Allocator<<T as State>::VecLength>
42 + Allocator<A>
43 + Allocator<<T as State>::Size, <T as State>::Size>
44 + Allocator<A, A>
45 + Allocator<<T as State>::Size, A>
46 + Allocator<A, <T as State>::Size>,
47 <DefaultAllocator as Allocator<<T as State>::Size>>::Buffer<f64>: Copy,
48 <DefaultAllocator as Allocator<<T as State>::Size, <T as State>::Size>>::Buffer<f64>: Copy,
49{
50 /// Returns the previous estimate
51 pub fn previous_estimate(&self) -> &KfEstimate<T> {
52 &self.prev_estimate
53 }
54
55 pub fn set_previous_estimate(&mut self, est: &KfEstimate<T>) {
56 self.prev_estimate = *est;
57 }
58
59 /// Computes a time update/prediction (i.e. advances the filter estimate with the updated STM).
60 ///
61 /// May return a FilterError if the STM was not updated.
62 pub fn time_update(&mut self, nominal_state: T) -> Result<KfEstimate<T>, ODError> {
63 let stm = nominal_state.stm().context(ODDynamicsSnafu)?;
64 let mut covar_bar = stm * self.prev_estimate.covar * stm.transpose();
65
66 // Try to apply an SNC, if applicable
67 for (i, snc) in self.process_noise.iter().enumerate().rev() {
68 if let Some(mut snc_matrix) = snc.to_matrix(nominal_state.epoch()) {
69 if let Some(local_frame) = snc.local_frame {
70 // Rotate the SNC from the definition frame into the state frame.
71 let dcm = local_frame
72 .dcm_to_inertial(nominal_state.orbit())
73 .context(AstroPhysicsSnafu)
74 .context(StateAstroSnafu {
75 param: StateParameter::Epoch,
76 })
77 .context(ODStateSnafu {
78 action: "rotating SNC from definition frame into state frame",
79 })?;
80
81 // Note: the SNC must be a diagonal matrix, so we only update the diagonals!
82 match A::DIM {
83 3 => {
84 let new_snc = dcm.rot_mat
85 * snc_matrix.fixed_view::<3, 3>(0, 0)
86 * dcm.rot_mat.transpose();
87 for i in 0..A::DIM {
88 snc_matrix[(i, i)] = new_snc[(i, i)];
89 }
90 }
91 6 => {
92 let new_snc = dcm.state_dcm()
93 * snc_matrix.fixed_view::<6, 6>(0, 0)
94 * dcm.transpose().state_dcm();
95 for i in 0..A::DIM {
96 snc_matrix[(i, i)] = new_snc[(i, i)];
97 }
98 }
99 _ => {
100 return Err(ODError::ODLimitation {
101 action: "only process noises of size 3x3 or 6x6 are supported",
102 })
103 }
104 }
105 }
106 // Check if we're using another SNC than the one before
107 if self.prev_used_snc != i {
108 info!("Switched to {i}-th {snc}");
109 self.prev_used_snc = i;
110 }
111
112 // Let's compute the Gamma matrix, an approximation of the time integral
113 // which assumes that the acceleration is constant between these two measurements.
114 let mut gamma = OMatrix::<f64, <T as State>::Size, A>::zeros();
115 let delta_t = (nominal_state.epoch() - self.prev_estimate.epoch()).to_seconds();
116 for blk in 0..A::dim() / 3 {
117 for i in 0..3 {
118 let idx_i = i + A::dim() * blk;
119 let idx_j = i + 3 * blk;
120 let idx_k = i + 3 + A::dim() * blk;
121 // For first block
122 // (0, 0) (1, 1) (2, 2) <=> \Delta t^2/2
123 // (3, 0) (4, 1) (5, 2) <=> \Delta t
124 // Second block
125 // (6, 3) (7, 4) (8, 5) <=> \Delta t^2/2
126 // (9, 3) (10, 4) (11, 5) <=> \Delta t
127 // * \Delta t^2/2
128 // (i, i) when blk = 0
129 // (i + A::dim() * blk, i + 3) when blk = 1
130 // (i + A::dim() * blk, i + 3 * blk)
131 // * \Delta t
132 // (i + 3, i) when blk = 0
133 // (i + 3, i + 9) when blk = 1 (and I think i + 12 + 3)
134 // (i + 3 + A::dim() * blk, i + 3 * blk)
135 gamma[(idx_i, idx_j)] = delta_t.powi(2) / 2.0;
136 gamma[(idx_k, idx_j)] = delta_t;
137 }
138 }
139 // Let's add the process noise
140 covar_bar += &gamma * snc_matrix * &gamma.transpose();
141 // And break so we don't add any more process noise
142 break;
143 }
144 }
145
146 let state_bar = if matches!(self.variant, KalmanVariant::DeviationTracking) {
147 stm * self.prev_estimate.state_deviation
148 } else {
149 OVector::<f64, <T as State>::Size>::zeros()
150 };
151 let estimate = KfEstimate {
152 nominal_state,
153 state_deviation: state_bar,
154 covar: covar_bar,
155 covar_bar,
156 stm,
157 predicted: true,
158 };
159 self.prev_estimate = estimate;
160 // Update the prev epoch for all SNCs
161 for snc in &mut self.process_noise {
162 snc.prev_epoch = Some(self.prev_estimate.epoch());
163 }
164 Ok(estimate)
165 }
166
167 /// Computes the measurement update with a provided real observation and computed observation.
168 ///
169 /// May return a FilterError if the STM or sensitivity matrices were not updated.
170 pub fn measurement_update<M: DimName>(
171 &mut self,
172 nominal_state: T,
173 real_obs: OVector<f64, M>,
174 computed_obs: OVector<f64, M>,
175 r_k: OMatrix<f64, M, M>,
176 h_tilde: OMatrix<f64, M, <T as State>::Size>,
177 resid_rejection: Option<ResidRejectCrit>,
178 ) -> Result<
179 (
180 KfEstimate<T>,
181 Residual<M>,
182 Option<OMatrix<f64, <T as State>::Size, M>>,
183 ),
184 ODError,
185 >
186 where
187 DefaultAllocator: Allocator<M>
188 + Allocator<M, M>
189 + Allocator<M, <T as State>::Size>
190 + Allocator<<T as State>::Size, M>
191 + Allocator<nalgebra::Const<1>, M>,
192 {
193 let epoch = nominal_state.epoch();
194
195 // Grab the state transition matrix.
196 let stm = nominal_state.stm().context(ODDynamicsSnafu)?;
197
198 // Propagate the covariance.
199 let mut covar_bar = stm * self.prev_estimate.covar * stm.transpose();
200
201 // Apply any process noise as in a normal time update, if applicable
202 for (i, snc) in self.process_noise.iter().enumerate().rev() {
203 if let Some(mut snc_matrix) = snc.to_matrix(epoch) {
204 if let Some(local_frame) = snc.local_frame {
205 // Rotate the SNC from the definition frame into the state frame.
206 let dcm = local_frame
207 .dcm_to_inertial(nominal_state.orbit())
208 .context(AstroPhysicsSnafu)
209 .context(StateAstroSnafu {
210 param: StateParameter::Epoch,
211 })
212 .context(ODStateSnafu {
213 action: "rotating SNC from definition frame into state frame",
214 })?;
215
216 // Note: the SNC must be a diagonal matrix, so we only update the diagonals!
217 match A::DIM {
218 3 => {
219 let new_snc = dcm.rot_mat
220 * snc_matrix.fixed_view::<3, 3>(0, 0)
221 * dcm.rot_mat.transpose();
222 for i in 0..A::DIM {
223 snc_matrix[(i, i)] = new_snc[(i, i)];
224 }
225 }
226 6 => {
227 let new_snc = dcm.state_dcm()
228 * snc_matrix.fixed_view::<6, 6>(0, 0)
229 * dcm.transpose().state_dcm();
230 for i in 0..A::DIM {
231 snc_matrix[(i, i)] = new_snc[(i, i)];
232 }
233 }
234 _ => {
235 return Err(ODError::ODLimitation {
236 action: "only process noises of size 3x3 or 6x6 are supported",
237 })
238 }
239 }
240 }
241 // Check if we're using another SNC than the one before
242 if self.prev_used_snc != i {
243 info!("Switched to {i}-th {snc}");
244 self.prev_used_snc = i;
245 }
246
247 // Let's compute the Gamma matrix, an approximation of the time integral
248 // which assumes that the acceleration is constant between these two measurements.
249 let mut gamma = OMatrix::<f64, <T as State>::Size, A>::zeros();
250 let delta_t = (nominal_state.epoch() - self.prev_estimate.epoch()).to_seconds();
251 for blk in 0..A::dim() / 3 {
252 for i in 0..3 {
253 let idx_i = i + A::dim() * blk;
254 let idx_j = i + 3 * blk;
255 let idx_k = i + 3 + A::dim() * blk;
256 // For first block
257 // (0, 0) (1, 1) (2, 2) <=> \Delta t^2/2
258 // (3, 0) (4, 1) (5, 2) <=> \Delta t
259 // Second block
260 // (6, 3) (7, 4) (8, 5) <=> \Delta t^2/2
261 // (9, 3) (10, 4) (11, 5) <=> \Delta t
262 // * \Delta t^2/2
263 // (i, i) when blk = 0
264 // (i + A::dim() * blk, i + 3) when blk = 1
265 // (i + A::dim() * blk, i + 3 * blk)
266 // * \Delta t
267 // (i + 3, i) when blk = 0
268 // (i + 3, i + 9) when blk = 1 (and I think i + 12 + 3)
269 // (i + 3 + A::dim() * blk, i + 3 * blk)
270 gamma[(idx_i, idx_j)] = delta_t.powi(2) / 2.0;
271 gamma[(idx_k, idx_j)] = delta_t;
272 }
273 }
274 // Let's add the process noise
275 covar_bar += &gamma * snc_matrix * &gamma.transpose();
276 // And break so we don't add any more process noise
277 break;
278 }
279 }
280
281 // Project the propagated covariance into the measurement space.
282 let h_p_ht = &h_tilde * covar_bar * &h_tilde.transpose();
283
284 // Compute the innovation matrix (S_k).
285 let s_k = &h_p_ht + &r_k;
286
287 // Compute observation deviation/error (usually marked as y_i)
288 let prefit = real_obs.clone() - computed_obs.clone();
289
290 // Compute the prefit ratio for the automatic rejection.
291 // The measurement covariance is the square of the measurement itself.
292 // So we compute its Cholesky decomposition to return to the non squared values.
293 let r_k_chol = match s_k.clone().cholesky() {
294 Some(r_k_clone) => r_k_clone.l(),
295 None => {
296 // In very rare case, when there isn't enough noise in the measurements,
297 // the inverting of S_k fails. If so, we revert back to the nominal Kalman derivation.
298 r_k.clone().cholesky().ok_or(ODError::SingularNoiseRk)?.l()
299 }
300 };
301
302 // Compute the ratio as the average of each component of the prefit over the square root of the measurement
303 // matrix r_k. Refer to ODTK MathSpec equation 4.10.
304 let ratio = (s_k
305 .diagonal()
306 .iter()
307 .zip(prefit.iter())
308 .map(|(r, prefit)| (prefit / r.sqrt()).powi(2))
309 .sum::<f64>()
310 / (M::DIM as f64))
311 .sqrt();
312
313 if let Some(resid_reject) = resid_rejection {
314 if ratio.abs() > resid_reject.num_sigmas {
315 // Reject this whole measurement and perform only a time update
316 let pred_est = self.time_update(nominal_state)?;
317 return Ok((
318 pred_est,
319 Residual::rejected(
320 epoch,
321 prefit,
322 ratio,
323 r_k_chol.diagonal(),
324 real_obs,
325 computed_obs,
326 ),
327 None,
328 ));
329 }
330 }
331
332 // Instead of inverting the innovation matrix S_k, we will use the (super short) arXiv paper 1111.4144
333 // which shows how to use the Cholesky decomposition to invert a matrix, core tenets repeated here for my reference.
334 // \forall A \ in \mathbb{R}^{n\times n}, X=A^{-1} <=> A*X=I
335 // Cholesky: A = L*L^T
336 // Therefore, L*L^T*X = I
337 // 1. Solve L * Y = I => Y = L^{-1} (via forward sub)
338 // 2. Solve L^T * X = Y => X = (L^T)^{-1} * L^{-1} = A^{-1}
339 //
340 // _However_, we can be more clever still!
341 // Instead of explicitly inverting the innovation matrix S_k, we solve the linear system
342 // S_k * K^T = H * P using Cholesky decomposition.
343 // This avoids the numerical instability of computing S_k^-1 directly.
344 // Math context:
345 // We want to solve A * X = B for X.
346 // 1. Decompose A into L * L^T (Cholesky).
347 // 2. Solve L * Y = B for Y (Forward substitution).
348 // 3. Solve L^T * X = Y for X (Backward substitution).
349
350 // Prepare the RHS of the linear system: (P * H^T)^T = H * P
351 // We want to solve: S_k * K^T = H * P
352 // So K = (S_k \ (H * P))^T
353 let p_ht = covar_bar * h_tilde.transpose();
354 let rhs = p_ht.transpose();
355
356 // Solve for Gain using Cholesky
357 // We try standard Cholesky first.
358 let gain = match s_k.clone().cholesky() {
359 Some(chol) => {
360 // SOLVE, don't invert.
361 // chol.solve(B) computes S_k^{-1} * B more stably than inv(S_k) * B
362 let k_t = chol.solve(&rhs);
363 k_t.transpose()
364 }
365 None => {
366 // If this fails, revert the LU decomposition of nalgebra
367 // Invert the innovation covariance.
368 match s_k.try_inverse() {
369 Some(s_k_inv) => covar_bar * &h_tilde.transpose() * &s_k_inv,
370 None => {
371 eprintln!(
372 "SINGULAR GAIN\nr = {r_k}\nh = {h_tilde:.3e}\ncovar = {covar_bar:.3e}"
373 );
374 return Err(ODError::SingularKalmanGain);
375 }
376 }
377 }
378 };
379
380 // Compute the state estimate, depends on the variant.
381 let (state_hat, res) = match self.variant {
382 KalmanVariant::ReferenceUpdate => {
383 // In EKF, the state hat is actually the state deviation. We trust the gain to be correct,
384 // so we just apply it directly to the prefit residual.
385 let state_hat = &gain * &prefit;
386 let postfit = &prefit - (&h_tilde * state_hat);
387 (
388 state_hat,
389 Residual::accepted(
390 epoch,
391 prefit,
392 postfit,
393 ratio,
394 r_k_chol.diagonal(),
395 real_obs,
396 computed_obs,
397 ),
398 )
399 }
400 KalmanVariant::DeviationTracking => {
401 // Time update
402 let state_bar = stm * self.prev_estimate.state_deviation;
403 let postfit = &prefit - (&h_tilde * state_bar);
404 (
405 state_bar + &gain * &postfit,
406 Residual::accepted(
407 epoch,
408 prefit,
409 postfit,
410 ratio,
411 r_k_chol.diagonal(),
412 real_obs,
413 computed_obs,
414 ),
415 )
416 }
417 };
418
419 // Compute covariance (Joseph update)
420 let first_term =
421 OMatrix::<f64, <T as State>::Size, <T as State>::Size>::identity() - &gain * &h_tilde;
422 let covar =
423 first_term * covar_bar * first_term.transpose() + &gain * &r_k * &gain.transpose();
424
425 // Force symmetry on the covariance
426 let covar = 0.5 * (covar + covar.transpose());
427
428 // And wrap up
429 let estimate = KfEstimate {
430 nominal_state,
431 state_deviation: state_hat,
432 covar,
433 covar_bar,
434 stm,
435 predicted: false,
436 };
437
438 self.prev_estimate = estimate;
439 // Update the prev epoch for all SNCs
440 for snc in &mut self.process_noise {
441 snc.prev_epoch = Some(self.prev_estimate.epoch());
442 }
443
444 Ok((estimate, res, Some(gain)))
445 }
446
447 pub fn replace_state(&self) -> bool {
448 matches!(self.variant, KalmanVariant::ReferenceUpdate)
449 }
450
451 /// Overwrites all of the process noises to the one provided
452 pub fn set_process_noise(&mut self, snc: ProcessNoise<A>) {
453 self.process_noise = vec![snc];
454 }
455}