Skip to main content

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}