Skip to main content

nyx_space/od/process/
mod.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::linalg::allocator::Allocator;
20use crate::linalg::{DefaultAllocator, DimName};
21use crate::md::trajectory::{Interpolatable, Traj};
22pub use crate::od::estimate::*;
23pub use crate::od::ground_station::*;
24use crate::od::msr::IntegrationRef;
25pub use crate::od::snc::*;
26pub use crate::od::*;
27use crate::propagators::Propagator;
28pub use crate::time::{Duration, Unit};
29use anise::prelude::Almanac;
30use indexmap::IndexSet;
31use log::{debug, error, info, warn};
32use msr::sensitivity::TrackerSensitivity;
33use snafu::prelude::*;
34use solution::kalman::KalmanVariant;
35use std::collections::BTreeMap;
36use std::marker::PhantomData;
37use std::ops::Add;
38use typed_builder::TypedBuilder;
39
40mod rejectcrit;
41use self::kalman::KalmanFilter;
42use self::msr::TrackingDataArc;
43pub use self::rejectcrit::SigmaRejection;
44mod solution;
45pub use solution::{NormalizedConsistency, ODSolution};
46mod initializers;
47
48/// An orbit determination process (ODP) which filters OD measurements through a Kalman filter.
49#[derive(Clone, TypedBuilder)]
50#[builder(doc)]
51#[allow(clippy::upper_case_acronyms)]
52pub struct KalmanODProcess<
53    D: Dynamics,
54    MsrSize: DimName,
55    Accel: DimName,
56    Trk: TrackerSensitivity<D::StateType, D::StateType>,
57> where
58    D::StateType:
59        Interpolatable + Add<OVector<f64, <D::StateType as State>::Size>, Output = D::StateType>,
60    <DefaultAllocator as Allocator<<D::StateType as State>::VecLength>>::Buffer<f64>: Send,
61    <DefaultAllocator as Allocator<<D::StateType as State>::Size>>::Buffer<f64>: Copy,
62    <DefaultAllocator as Allocator<<D::StateType as State>::Size, <D::StateType as State>::Size>>::Buffer<f64>: Copy,
63    DefaultAllocator: Allocator<<D::StateType as State>::Size>
64        + Allocator<<D::StateType as State>::VecLength>
65        + Allocator<MsrSize>
66        + Allocator<MsrSize, <D::StateType as State>::Size>
67        + Allocator<<D::StateType as State>::Size, MsrSize>
68        + Allocator<MsrSize, MsrSize>
69        + Allocator<<D::StateType as State>::Size, <D::StateType as State>::Size>
70        + Allocator<Accel>
71        + Allocator<Accel, Accel>
72        + Allocator<<D::StateType as State>::Size, Accel>
73        + Allocator<Accel, <D::StateType as State>::Size>,
74{
75    /// Propagator used for the estimation
76    pub prop: Propagator<D>,
77    /// Kalman filter variant
78    #[builder(default)]
79    pub kf_variant: KalmanVariant,
80    /// Residual rejection criteria allows preventing bad measurements from affecting the estimation.
81    #[builder(default, setter(strip_option))]
82    pub sigma_reject: Option<SigmaRejection>,
83    /// Tracking devices
84    #[builder(default_code = "BTreeMap::new()")]
85    pub devices: BTreeMap<String, Trk>,
86    /// A sets of process noise (usually noted Q), must be ordered chronologically
87    #[builder(default_code = "vec![]")]
88    pub process_noise: Vec<ProcessNoise<Accel>>,
89    /// Maximum step size where the STM linearization is assumed correct (1 minute is usually fine)
90    #[builder(default_code = "1 * Unit::Minute")]
91    pub max_step: Duration,
92    /// Precision of the measurement epoch when processing measurements.
93    #[builder(default_code = "1 * Unit::Microsecond")]
94    pub epoch_precision: Duration,
95    pub almanac: Arc<Almanac>,
96    #[builder(default_code = "PhantomData::<MsrSize>")]
97    _msr_size: PhantomData<MsrSize>,
98}
99
100impl<
101        D: Dynamics,
102        MsrSize: DimName,
103        Accel: DimName,
104        Trk: TrackerSensitivity<D::StateType, D::StateType>,
105    > KalmanODProcess<D, MsrSize, Accel, Trk>
106where
107    D::StateType:
108        Interpolatable + Add<OVector<f64, <D::StateType as State>::Size>, Output = D::StateType>,
109    <DefaultAllocator as Allocator<<D::StateType as State>::VecLength>>::Buffer<f64>: Send,
110    <DefaultAllocator as Allocator<<D::StateType as State>::Size>>::Buffer<f64>: Copy,
111    <DefaultAllocator as Allocator<<D::StateType as State>::Size, <D::StateType as State>::Size>>::Buffer<f64>: Copy,
112    DefaultAllocator: Allocator<<D::StateType as State>::Size>
113        + Allocator<<D::StateType as State>::VecLength>
114        + Allocator<MsrSize>
115        + Allocator<MsrSize, <D::StateType as State>::Size>
116        + Allocator<<D::StateType as State>::Size, MsrSize>
117        + Allocator<MsrSize, MsrSize>
118        + Allocator<<D::StateType as State>::Size, <D::StateType as State>::Size>
119        + Allocator<Accel>
120        + Allocator<Accel, Accel>
121        + Allocator<<D::StateType as State>::Size, Accel>
122        + Allocator<Accel, <D::StateType as State>::Size>
123        + Allocator<nalgebra::Const<1>, MsrSize>,
124{
125    /// Process the provided tracking arc for this orbit determination process.
126    #[allow(clippy::erasing_op)]
127    pub fn process_arc(
128        &self,
129        initial_estimate: KfEstimate<D::StateType>,
130        arc: &TrackingDataArc,
131    ) -> Result<ODSolution<D::StateType, KfEstimate<D::StateType>, MsrSize, Trk>, ODError> {
132        // Initialize the solution.
133        let mut od_sol = ODSolution::new(self.devices.clone(), arc.unique_types());
134
135        let measurements = &arc.measurements;
136        ensure!(
137            measurements.len() >= 2,
138            TooFewMeasurementsSnafu {
139                need: 2_usize,
140                action: "running a Kalman filter"
141            }
142        );
143
144        ensure!(
145            !self.max_step.is_negative() && self.max_step != Duration::ZERO,
146            StepSizeSnafu { step: self.max_step }
147        );
148
149        // Check proper configuration.
150        if MsrSize::DIM > arc.unique_types().len() {
151            error!("Filter misconfigured: expect high rejection count!");
152            error!(
153                "Arc only contains {} measurement types, but filter configured for {}.",
154                arc.unique_types().len(),
155                MsrSize::DIM
156            );
157            error!("Filter should be configured for these numbers to match.");
158            error!("Consider running subsequent arcs if ground stations provide different measurements or switch to Scalar processing.");
159        }
160
161        let mut cfg_errors = vec![];
162        for tracker in &arc.unique_aliases() {
163            if let Some(device) = self.devices.get(tracker) {
164                if let Err(e) =
165                device.is_compatible(tracker, &arc.clone().filter_by_tracker(tracker.clone())) {
166                    cfg_errors.push(e);
167                }
168            } else {
169                error!("Tracker `{tracker}` from TrackingDataArc is not configured in the OD Process.");
170                error!("Measurements from `{tracker}` will be ignored!");
171            }
172        }
173        if !cfg_errors.is_empty() {
174            // Return all the errors at once.
175            let msg = cfg_errors.iter().map(|e| e.to_string()).collect::<Vec<String>>().join("\n---\n");
176            return Err(ODError::ODConfigError { source: ConfigError::InvalidConfig { msg } })
177        }
178
179        // Start by propagating the estimator.
180        let num_msrs = measurements.len();
181
182        // Set up the propagator instance.
183        let prop = self.prop.clone();
184        let mut prop_instance = prop.with(initial_estimate.nominal_state().with_stm(), self.almanac.clone()).quiet();
185
186        // Update the step size of the navigation propagator if it isn't already fixed step
187        if !prop_instance.fixed_step {
188            prop_instance.set_step(self.max_step, false);
189        }
190
191        let prop_time = arc.end_epoch().unwrap() - initial_estimate.epoch();
192        info!("Navigation propagating for a total of {prop_time} with step size {}", self.max_step);
193
194        let resid_crit = if arc.force_reject {
195            warn!("Rejecting all measurements from {arc} (force_reject is True in TrackingDataArc)");
196            Some(SigmaRejection { num_sigmas: 0.0 })
197        } else {
198            self.sigma_reject
199        };
200
201        let mut epoch = prop_instance.state.epoch();
202
203        let mut reported = [false; 11];
204        reported[0] = true; // Prevent showing "0% done"
205        info!(
206            "Processing {num_msrs} measurement epochs from {:?}",
207            arc.unique_aliases()
208        );
209
210        // Set up the Kalman filter.
211        let mut kf = KalmanFilter::<D::StateType, Accel> {
212            prev_estimate: initial_estimate,
213            process_noise: self.process_noise.clone(),
214            variant: self.kf_variant,
215            prev_used_snc: 0,
216        };
217
218        kf.initialize_process_noises();
219
220        let mut devices = self.devices.clone();
221
222        // We'll build a trajectory of the estimated states. This will be used to compute the measurements.
223        let mut traj: Traj<D::StateType> = Traj::new();
224
225        let mut msr_accepted_cnt: usize = 0;
226        let mut msr_rejected_cnt: usize = 0;
227        let mut unknown_trackers = IndexSet::new();
228        let tick = Epoch::now().unwrap();
229
230        for (msr_cnt, msr) in measurements.iter().enumerate() {
231            let next_msr_epoch = msr.epoch;
232
233            // Advance the propagator
234            loop {
235                let delta_t = next_msr_epoch - epoch;
236
237                // Propagate for the minimum time between the maximum step size, the next step size, and the duration to the next measurement.
238                let next_step_size = delta_t.min(prop_instance.step_size).min(self.max_step);
239
240                // Remove any states at or after the current propagation epoch before appending new steps
241                let keep_idx = traj.states.partition_point(|s| s.epoch() < epoch);
242                traj.states.truncate(keep_idx);
243
244                debug!("propagate for {next_step_size} (Δt to next msr: {delta_t})");
245                let (latest_state, traj_covar) = prop_instance
246                    .for_duration_with_traj(next_step_size)
247                    .context(ODPropSnafu)?;
248
249                for state in traj_covar.states {
250                    // NOTE: At the time being, only spacecraft estimation is possible, and the trajectory will always be the exact state
251                    // that was propagated. Even once ground station biases are estimated, these won't go through the propagator.
252                    traj.states.push(state);
253                }
254
255                // Now that we've advanced the propagator, let's see whether we're at the time of the next measurement.
256
257                // Extract the state and update the STM in the filter.
258                let nominal_state = prop_instance.state;
259                // Get the datetime and info needed to compute the theoretical measurement according to the model
260                epoch = nominal_state.epoch();
261
262                // Perform a measurement update, accounting for possible errors in measurement timestamps
263                if (nominal_state.epoch() - next_msr_epoch).abs() < self.epoch_precision {
264                    // Force the state epoch to match the measurement epoch exactly.
265                    // This prevents infinite loops where the propagator (especially if fixed step)
266                    // fails to step a tiny amount (drift) to reach the exact measurement time.
267                    prop_instance.state.set_epoch(next_msr_epoch);
268
269                    if msr.rejected {
270                        debug!("Skipping manually rejected measurement at {epoch}");
271                        let est = kf.time_update(nominal_state)?;
272                        od_sol.push_time_update(est);
273                        prop_instance.state.reset_stm();
274                        msr_rejected_cnt += 1;
275                    } else {
276                        // Get the computed observations
277                        match devices.get_mut(&msr.tracker) {
278                            Some(device) => {
279                                let msr_types = device.measurement_types().clone();
280
281                                // Inspect the measurement to see if look-ahead is required for light-time computation
282                                let pre_lookahead_len = traj.states.len();
283                                if let Some(dop_cfg) = msr.doppler_config && dop_cfg.integration_ref != IntegrationRef::End {
284                                    let lookahead_by = match dop_cfg.integration_ref {
285                                        IntegrationRef::Start => dop_cfg.integration_time,
286                                        IntegrationRef::Middle => dop_cfg.integration_time * 0.5,
287                                        _ => unreachable!()
288                                    };
289                                    let mut lookahead_prop = prop.with(latest_state, self.almanac.clone()).quiet();
290                                    let (_, lookahead_traj) = lookahead_prop
291                                        .for_duration_with_traj(lookahead_by)
292                                        .context(ODPropSnafu)?;
293
294                                    for state in lookahead_traj.states.into_iter().filter(|s| s.epoch() > latest_state.epoch()) {
295                                        traj.states.push(state);
296                                    }
297                                }
298
299                                // Current nominal prior (needed for separate processing of simultaneous measurements)
300                                let prior_nominal_state = prop_instance.state;
301                                let mut current_state_estimate = prior_nominal_state;
302                                let mut any_measurement_accepted = false;
303
304                                // Perform several measurement updates to ensure the desired dimensionality.
305                                let windows = msr_types.len() / MsrSize::DIM;
306                                for wno in 0..=windows {
307                                    // Update the nominal state in case we're ingesting several measurements
308                                    // sequentially for the same epoch.
309                                    let cur_msr_types = msr_types
310                                        .iter()
311                                        .copied()
312                                        .skip(wno * MsrSize::DIM)
313                                        .take(MsrSize::DIM)
314                                        .collect::<IndexSet<_>>();
315
316                                    if cur_msr_types.is_empty() {
317                                        // We've processed all measurements.
318                                        break;
319                                    }
320
321                                    // If this measurement type is unavailable, continue to the next one.
322                                    if !msr.availability(&cur_msr_types)
323                                        .iter()
324                                        .any(|avail| *avail)
325                                    {
326                                        continue;
327                                    }
328
329                                    // Grab the un-modulo'd real observation
330                                    let mut real_obs: OVector<f64, MsrSize> =
331                                        msr.observation(&cur_msr_types);
332
333                                    // Check that the observation is valid.
334                                    for val in real_obs.iter().copied() {
335                                        ensure!(
336                                            val.is_finite(),
337                                            InvalidMeasurementSnafu {
338                                                epoch: next_msr_epoch,
339                                                val
340                                            }
341                                        );
342                                    }
343
344                                    // Compute device specific matrices
345                                    // Sensitivity (H tilde) is computed on the _pristine_ nominal estimate
346                                    // i.e. it is not polluted by a partial measurement update if there are
347                                    // multiple concurrent measurement processed sequentially.
348                                    let h_tilde = device.h_tilde::<MsrSize>(
349                                        msr,
350                                        &cur_msr_types,
351                                        &prior_nominal_state,
352                                        &self.almanac,
353                                    )?;
354
355                                    let measurement_covar = device
356                                        .measurement_covar_matrix(&cur_msr_types, epoch)?;
357
358                                    // Evaluate the observation from the trajectory with the look-ahead states
359                                    // but it does not include any of the states from the measurement update.
360                                    let computed_meas_res = device.measure(epoch, &traj, None, &self.almanac);
361
362                                    if let Some(computed_meas) = computed_meas_res?
363                                    {
364                                        // Apply any biases on the computed observation
365                                        let obs_bias = device.measurement_bias_vector::<MsrSize>(
366                                            &cur_msr_types,
367                                            epoch,
368                                        )?;
369
370                                        let mut computed_obs = computed_meas
371                                            .observation::<MsrSize>(&cur_msr_types)
372                                            - obs_bias;
373
374                                        // Apply the modulo to the real obs
375                                        if let Some(moduli) = &arc.moduli {
376                                            let mut obs_ambiguity =
377                                                OVector::<f64, MsrSize>::zeros();
378
379                                            for (i, msr_type) in cur_msr_types.iter().enumerate() {
380                                                if let Some(modulus) = moduli.get(msr_type) {
381                                                    let k = computed_obs[i].div_euclid(*modulus);
382                                                    // real_obs = measured_obs + k * modulus
383                                                    obs_ambiguity[i] = k * *modulus;
384                                                }
385                                            }
386                                            real_obs += obs_ambiguity;
387                                        }
388
389                                        // Map prior shifts to account for partial state update: h_eff = h(x0) + H * (x_curr - x0)
390                                        let delta_state = current_state_estimate.to_state_vector() - prior_nominal_state.to_state_vector();
391                                        let obs_shift = &h_tilde * delta_state;
392                                        computed_obs += obs_shift;
393
394                                        // Kalman measurement update on the filter covariance and state
395                                        let (estimate, mut residual, gain) = kf.measurement_update(
396                                            current_state_estimate,
397                                            real_obs,
398                                            computed_obs,
399                                            measurement_covar,
400                                            h_tilde,
401                                            resid_crit,
402                                        )?;
403
404                                        debug!(
405                                            "processed measurement #{msr_cnt} for {cur_msr_types:?} @ {epoch} from {}",
406                                            device.name()
407                                        );
408
409                                        if residual.rejected {
410                                            msr_rejected_cnt += 1;
411                                        } else {
412                                            msr_accepted_cnt += 1;
413                                            current_state_estimate = estimate.state();
414                                            any_measurement_accepted = true;
415                                        }
416
417
418                                        residual.tracker = Some(device.name());
419                                        residual.msr_types = cur_msr_types;
420                                        od_sol.push_measurement_update(estimate, residual, gain);
421                                    } else {
422                                        debug!(
423                                            "Device {} does not expect measurement at {epoch}, skipping",
424                                            msr.tracker
425                                        );
426                                        msr_rejected_cnt += 1;
427                                    }
428                                }
429
430                                // Strip the temporary states to maintain trajectory causality
431                                traj.states.truncate(pre_lookahead_len);
432
433                                if any_measurement_accepted && kf.replace_state() {
434                                    // Only update the state of the EKF if at least one residual was not rejected.
435                                    prop_instance.state = current_state_estimate;
436                                    traj.states.pop();
437                                    traj.states.push(prop_instance.state);
438                                }
439
440                                // Reset the STM strictly once per epoch, after all updates have been absorbed
441                                prop_instance.state.reset_stm();
442                            }
443                            None => {
444                                if !unknown_trackers.contains(&msr.tracker) {
445                                    error!(
446                                        "Tracker {} is not in the list of configured devices",
447                                        msr.tracker
448                                    );
449                                    unknown_trackers.insert(msr.tracker.clone());
450                                }
451                            }
452                        }
453                    }
454
455                    let msr_prct = (10.0 * (msr_cnt as f64) / (num_msrs as f64)) as usize;
456                    if !reported[msr_prct] {
457                        let msg = format!(
458                            "{:>3}% done - {epoch} - {msr_accepted_cnt:.0} measurements accepted, {:.0} rejected",
459                            10 * msr_prct, msr_rejected_cnt
460                        );
461                        if msr_accepted_cnt < msr_rejected_cnt {
462                            warn!("{msg}");
463                        } else {
464                            info!("{msg}");
465                        }
466                        reported[msr_prct] = true;
467                    }
468
469                    break;
470                } else {
471                    // No measurement can be used here, let's just do a time update and continue advancing the propagator.
472                    // State deviation is always zero for an EKF time update so we don't do anything different than for a CKF.
473                    let est = kf.time_update(nominal_state)?;
474                    od_sol.push_time_update(est);
475                    prop_instance.state.reset_stm();
476                }
477            }
478        }
479
480        // Always report the 100% mark
481        if !reported[10] {
482            let tock_time = Epoch::now().unwrap() - tick;
483            info!(
484                "100% done - {epoch} - {msr_accepted_cnt} measurements accepted, {msr_rejected_cnt} rejected (done in {tock_time})",
485            );
486        }
487
488        Ok(od_sol)
489    }
490
491    /// Perform a time update. Continuously predicts the trajectory until the provided end epoch, with covariance mapping at each step.
492    pub fn predict_until(
493        &self,
494        initial_estimate: KfEstimate<D::StateType>,
495        end_epoch: Epoch,
496    ) -> Result<ODSolution<D::StateType, KfEstimate<D::StateType>, MsrSize, Trk>, ODError> {
497        // Initialize the solution with no measurement types.
498        let mut od_sol = ODSolution::new(self.devices.clone(), IndexSet::new());
499
500        od_sol.push_time_update(initial_estimate);
501
502        // Set up the propagator instance.
503        let prop = self.prop.clone();
504        let mut prop_instance = prop.with(initial_estimate.nominal_state().with_stm(), self.almanac.clone()).quiet();
505
506
507        // Set up the Kalman filter.
508        let mut kf = KalmanFilter::<D::StateType, Accel> {
509            prev_estimate: initial_estimate,
510            process_noise: self.process_noise.clone(),
511            variant: self.kf_variant,
512            prev_used_snc: 0,
513        };
514
515        let prop_time = end_epoch - kf.previous_estimate().epoch();
516        info!("Mapping covariance for {prop_time} every {} until {end_epoch}", self.max_step);
517
518        loop {
519            let nominal_state = prop_instance.for_duration(self.max_step).context(ODPropSnafu)?;
520            // Extract the state and update the STM in the filter.
521            // Get the datetime and info needed to compute the theoretical measurement according to the model
522            let epoch = nominal_state.epoch();
523            // No measurement can be used here, let's just do a time update
524            debug!("time update {epoch}");
525            let est = kf.time_update(nominal_state)?;
526            od_sol.push_time_update(est);
527            prop_instance.state.reset_stm();
528            if epoch >= end_epoch {
529                break;
530            }
531        }
532
533        Ok(od_sol)
534    }
535
536    /// Perform a time update. Continuously predicts the trajectory for the provided duration, with covariance mapping at each step.
537    pub fn predict_for(
538        &self,
539        initial_estimate: KfEstimate<D::StateType>,
540        duration: Duration,
541    ) -> Result<ODSolution<D::StateType, KfEstimate<D::StateType>, MsrSize, Trk>, ODError> {
542        let end_epoch = initial_estimate.nominal_state().epoch() + duration;
543        self.predict_until(initial_estimate, end_epoch)
544    }
545}