nyx_space/od/msr/trackingdata/
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*/
18use super::{measurement::Measurement, MeasurementType};
19use core::fmt;
20use hifitime::prelude::{Duration, Epoch};
21use indexmap::{IndexMap, IndexSet};
22use std::collections::BTreeMap;
23use std::ops::Bound::{Excluded, Included, Unbounded};
24use std::ops::RangeBounds;
25
26mod io_ccsds_tdm;
27mod io_parquet;
28
29/// Tracking data storing all of measurements as a B-Tree.
30/// It inherently does NOT support multiple concurrent measurements from several trackers.
31///
32/// # Measurement Moduli, e.g. range modulus
33///
34/// In the case of ranging, and possibly other data types, a code is used to measure the range to the spacecraft. The length of this code
35/// determines the ambiguity resolution, as per equation 9 in section 2.2.2.2 of the JPL DESCANSO, document 214, _Pseudo-Noise and Regenerative Ranging_.
36/// For example, using the JPL Range Code and a frequency range clock of 1 MHz, the range ambiguity is 75,660 km. In other words,
37/// as soon as the spacecraft is at a range of 75,660 + 1 km the JPL Range Code will report the vehicle to be at a range of 1 km.
38/// This is simply because the range code overlaps with itself, effectively loosing track of its own reference:
39/// it's due to the phase shift of the signal "lapping" the original signal length.
40///
41/// ```text
42///             (Spacecraft)
43///             ^
44///             |    Actual Distance = 75,661 km
45///             |
46/// 0 km                                         75,660 km (Wrap-Around)
47/// |-----------------------------------------------|
48///   When the "code length" is exceeded,
49///   measurements wrap back to 0.
50///
51/// So effectively:
52///     Observed code range = Actual range (mod 75,660 km)
53///     75,661 km → 1 km
54///
55/// ```
56///
57/// Nyx can only resolve the range ambiguity if the tracking data specifies a modulus for this specific measurement type.
58/// For example, in the case of the JPL Range Code and a 1 MHz range clock, the ambiguity interval is 75,660 km.
59///
60/// The measurement used in the Orbit Determination Process then becomes the following, where `//` represents the [Euclidian division](https://doc.rust-lang.org/std/primitive.f64.html#method.div_euclid).
61///
62/// ```text
63/// k = computed_obs // ambiguity_interval
64/// real_obs = measured_obs + k * modulus
65/// ```
66///
67/// Reference: JPL DESCANSO, document 214, _Pseudo-Noise and Regenerative Ranging_.
68///
69#[derive(Clone, Default)]
70pub struct TrackingDataArc {
71    /// All measurements in this data arc
72    pub measurements: BTreeMap<Epoch, Measurement>, // BUG: Consider a map of tracking to epoch!
73    /// Source file if loaded from a file or saved to a file.
74    pub source: Option<String>,
75    /// Optionally provide a map of modulos (e.g. the RANGE_MODULO of CCSDS TDM).
76    pub moduli: Option<IndexMap<MeasurementType, f64>>,
77}
78
79impl TrackingDataArc {
80    /// Set (or overwrites) the modulus of the provided measurement type.
81    pub fn set_moduli(&mut self, msr_type: MeasurementType, modulus: f64) {
82        if self.moduli.is_none() {
83            self.moduli = Some(IndexMap::new());
84        }
85
86        self.moduli.as_mut().unwrap().insert(msr_type, modulus);
87    }
88
89    /// Applies the moduli to each measurement, if defined.
90    pub fn apply_moduli(&mut self) {
91        if let Some(moduli) = &self.moduli {
92            for msr in self.measurements.values_mut() {
93                for (msr_type, modulus) in moduli {
94                    if let Some(msr_value) = msr.data.get_mut(msr_type) {
95                        *msr_value %= *modulus;
96                    }
97                }
98            }
99        }
100    }
101
102    /// Returns the unique list of aliases in this tracking data arc
103    pub fn unique_aliases(&self) -> IndexSet<String> {
104        self.unique().0
105    }
106
107    /// Returns the unique measurement types in this tracking data arc
108    pub fn unique_types(&self) -> IndexSet<MeasurementType> {
109        self.unique().1
110    }
111
112    /// Returns the unique trackers and unique measurement types in this data arc
113    pub fn unique(&self) -> (IndexSet<String>, IndexSet<MeasurementType>) {
114        let mut aliases = IndexSet::new();
115        let mut types = IndexSet::new();
116        for msr in self.measurements.values() {
117            aliases.insert(msr.tracker.clone());
118            for k in msr.data.keys() {
119                types.insert(*k);
120            }
121        }
122        (aliases, types)
123    }
124
125    /// Returns the start epoch of this tracking arc
126    pub fn start_epoch(&self) -> Option<Epoch> {
127        self.measurements.first_key_value().map(|(k, _)| *k)
128    }
129
130    /// Returns the end epoch of this tracking arc
131    pub fn end_epoch(&self) -> Option<Epoch> {
132        self.measurements.last_key_value().map(|(k, _)| *k)
133    }
134
135    /// Returns the number of measurements in this data arc
136    pub fn len(&self) -> usize {
137        self.measurements.len()
138    }
139
140    /// Returns whether this arc has no measurements.
141    pub fn is_empty(&self) -> bool {
142        self.measurements.is_empty()
143    }
144
145    /// Returns the minimum duration between two subsequent measurements.
146    /// This is important to correctly set up the propagator and not miss any measurement.
147    pub fn min_duration_sep(&self) -> Option<Duration> {
148        if self.is_empty() {
149            None
150        } else {
151            let mut min_sep = Duration::MAX;
152            let mut prev_epoch = self.start_epoch().unwrap();
153            for (epoch, _) in self.measurements.iter().skip(1) {
154                let this_sep = *epoch - prev_epoch;
155                min_sep = min_sep.min(this_sep);
156                prev_epoch = *epoch;
157            }
158            Some(min_sep)
159        }
160    }
161
162    /// Returns a new tracking arc that only contains measurements that fall within the given epoch range.
163    pub fn filter_by_epoch<R: RangeBounds<Epoch>>(mut self, bound: R) -> Self {
164        self.measurements = self
165            .measurements
166            .range(bound)
167            .map(|(epoch, msr)| (*epoch, msr.clone()))
168            .collect::<BTreeMap<Epoch, Measurement>>();
169        self
170    }
171
172    /// Returns a new tracking arc that only contains measurements that fall within the given offset from the first epoch
173    pub fn filter_by_offset<R: RangeBounds<Duration>>(self, bound: R) -> Self {
174        if self.is_empty() {
175            return self;
176        }
177        // Rebuild an epoch bound.
178        let start = match bound.start_bound() {
179            Unbounded => self.start_epoch().unwrap(),
180            Included(offset) | Excluded(offset) => self.start_epoch().unwrap() + *offset,
181        };
182
183        let end = match bound.end_bound() {
184            Unbounded => self.end_epoch().unwrap(),
185            Included(offset) | Excluded(offset) => self.end_epoch().unwrap() - *offset,
186        };
187
188        self.filter_by_epoch(start..end)
189    }
190
191    /// Returns a new tracking arc that only contains measurements from the desired tracker.
192    pub fn filter_by_tracker(mut self, tracker: String) -> Self {
193        self.measurements = self
194            .measurements
195            .iter()
196            .filter_map(|(epoch, msr)| {
197                if msr.tracker == tracker {
198                    Some((*epoch, msr.clone()))
199                } else {
200                    None
201                }
202            })
203            .collect::<BTreeMap<Epoch, Measurement>>();
204        self
205    }
206
207    /// Downsamples the tracking data to a lower frequency using a simple moving average low-pass filter followed by decimation,
208    /// returning new `TrackingDataArc` with downsampled measurements.
209    ///
210    /// It provides a computationally efficient approach to reduce the sampling rate while mitigating aliasing effects.
211    ///
212    /// # Algorithm
213    ///
214    /// 1. A simple moving average filter is applied as a low-pass filter.
215    /// 2. Decimation is performed by selecting every Nth sample after filtering.
216    ///
217    /// # Advantages
218    ///
219    /// - Computationally efficient, suitable for large datasets common in spaceflight applications.
220    /// - Provides basic anti-aliasing, crucial for preserving signal integrity in orbit determination and tracking.
221    /// - Maintains phase information, important for accurate timing in spacecraft state estimation.
222    ///
223    /// # Limitations
224    ///
225    /// - The frequency response is not as sharp as more sophisticated filters (e.g., FIR, IIR).
226    /// - May not provide optimal stopband attenuation for high-precision applications.
227    ///
228    /// ## Considerations for Spaceflight Applications
229    ///
230    /// - Suitable for initial data reduction in ground station tracking pipelines.
231    /// - Adequate for many orbit determination and tracking tasks where computational speed is prioritized.
232    /// - For high-precision applications (e.g., interplanetary navigation), consider using more advanced filtering techniques.
233    ///
234    pub fn downsample(self, target_step: Duration) -> Self {
235        if self.is_empty() {
236            return self;
237        }
238        let current_step = self.min_duration_sep().unwrap();
239
240        if current_step >= target_step {
241            warn!("cannot downsample tracking data from {current_step} to {target_step} (that would be upsampling)");
242            return self;
243        }
244
245        let current_hz = 1.0 / current_step.to_seconds();
246        let target_hz = 1.0 / target_step.to_seconds();
247
248        // Simple moving average as low-pass filter
249        let window_size = (current_hz / target_hz).round() as usize;
250
251        info!("downsampling tracking data from {current_step} ({current_hz:.6} Hz) to {target_step} ({target_hz:.6} Hz) (N = {window_size})");
252
253        let mut result = TrackingDataArc {
254            source: self.source.clone(),
255            ..Default::default()
256        };
257
258        let measurements: Vec<_> = self.measurements.iter().collect();
259
260        for (i, (epoch, _)) in measurements.iter().enumerate().step_by(window_size) {
261            let start = if i >= window_size / 2 {
262                i - window_size / 2
263            } else {
264                0
265            };
266            let end = (i + window_size / 2 + 1).min(measurements.len());
267            let window = &measurements[start..end];
268
269            let mut filtered_measurement = Measurement {
270                tracker: window[0].1.tracker.clone(),
271                epoch: **epoch,
272                data: IndexMap::new(),
273            };
274
275            // Apply moving average filter for each measurement type
276            for mtype in self.unique_types() {
277                let sum: f64 = window.iter().filter_map(|(_, m)| m.data.get(&mtype)).sum();
278                let count = window
279                    .iter()
280                    .filter(|(_, m)| m.data.contains_key(&mtype))
281                    .count();
282
283                if count > 0 {
284                    filtered_measurement.data.insert(mtype, sum / count as f64);
285                }
286            }
287
288            result.measurements.insert(**epoch, filtered_measurement);
289        }
290        result
291    }
292}
293
294impl fmt::Display for TrackingDataArc {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        if self.is_empty() {
297            write!(f, "Empty tracking arc")
298        } else {
299            let start = self.start_epoch().unwrap();
300            let end = self.end_epoch().unwrap();
301            let src = match &self.source {
302                Some(src) => format!(" (source: {src})"),
303                None => String::new(),
304            };
305            write!(
306                f,
307                "Tracking arc with {} measurements of type {:?} over {} (from {start} to {end}) with trackers {:?}{src}",
308                self.len(),
309                self.unique_types(),
310                end - start,
311                self.unique_aliases()
312            )
313        }
314    }
315}
316
317impl fmt::Debug for TrackingDataArc {
318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319        write!(f, "{self} @ {self:p}")
320    }
321}
322
323impl PartialEq for TrackingDataArc {
324    fn eq(&self, other: &Self) -> bool {
325        self.measurements == other.measurements
326    }
327}