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 .copied()
308 .enumerate()
309 .map(|(idx, r)| prefit[idx] / r.sqrt())
310 .sum::<f64>()
311 / (M::DIM as f64);
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 // Invert the innovation covariance.
333 let s_k_inv = match s_k.try_inverse() {
334 Some(s_k_inv) => s_k_inv,
335 None => return Err(ODError::SingularKalmanGain),
336 };
337
338 let gain = covar_bar * &h_tilde.transpose() * &s_k_inv;
339
340 // Compute the state estimate, depends on the variant.
341 let (state_hat, res) = match self.variant {
342 KalmanVariant::ReferenceUpdate => {
343 // In EKF, the state hat is actually the state deviation. We trust the gain to be correct,
344 // so we just apply it directly to the prefit residual.
345 let state_hat = &gain * &prefit;
346 let postfit = &prefit - (&h_tilde * state_hat);
347 (
348 state_hat,
349 Residual::accepted(
350 epoch,
351 prefit,
352 postfit,
353 ratio,
354 r_k_chol.diagonal(),
355 real_obs,
356 computed_obs,
357 ),
358 )
359 }
360 KalmanVariant::DeviationTracking => {
361 // Time update
362 let state_bar = stm * self.prev_estimate.state_deviation;
363 let postfit = &prefit - (&h_tilde * state_bar);
364 (
365 state_bar + &gain * &postfit,
366 Residual::accepted(
367 epoch,
368 prefit,
369 postfit,
370 ratio,
371 r_k_chol.diagonal(),
372 real_obs,
373 computed_obs,
374 ),
375 )
376 }
377 };
378
379 // Compute covariance (Joseph update)
380 let first_term =
381 OMatrix::<f64, <T as State>::Size, <T as State>::Size>::identity() - &gain * &h_tilde;
382 let covar =
383 first_term * covar_bar * first_term.transpose() + &gain * &r_k * &gain.transpose();
384
385 // And wrap up
386 let estimate = KfEstimate {
387 nominal_state,
388 state_deviation: state_hat,
389 covar,
390 covar_bar,
391 stm,
392 predicted: false,
393 };
394
395 self.prev_estimate = estimate;
396 // Update the prev epoch for all SNCs
397 for snc in &mut self.process_noise {
398 snc.prev_epoch = Some(self.prev_estimate.epoch());
399 }
400
401 Ok((estimate, res, Some(gain)))
402 }
403
404 pub fn replace_state(&self) -> bool {
405 matches!(self.variant, KalmanVariant::ReferenceUpdate)
406 }
407
408 /// Overwrites all of the process noises to the one provided
409 pub fn set_process_noise(&mut self, snc: ProcessNoise<A>) {
410 self.process_noise = vec![snc];
411 }
412}