Skip to main content

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::{MeasurementType, measurement::Measurement};
19use core::fmt;
20use hifitime::prelude::{Duration, Epoch};
21use indexmap::{IndexMap, IndexSet};
22use log::{info, warn};
23use std::ops::Bound::{self, Excluded, Included, Unbounded};
24use std::ops::{Add, AddAssign, RangeBounds};
25
26mod io_ccsds_tdm;
27mod io_parquet;
28
29#[cfg(feature = "python")]
30use pyo3::prelude::*;
31#[cfg(feature = "python")]
32mod python;
33
34/// Tracking data storing all of measurements as a B-Tree.
35/// It inherently does NOT support multiple concurrent measurements from several trackers.
36///
37/// # Measurement Moduli, e.g. range modulus
38///
39/// 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
40/// 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_.
41/// 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,
42/// 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.
43/// This is simply because the range code overlaps with itself, effectively loosing track of its own reference:
44/// it's due to the phase shift of the signal "lapping" the original signal length.
45///
46/// ```text
47///             (Spacecraft)
48///             ^
49///             |    Actual Distance = 75,661 km
50///             |
51/// 0 km                                         75,660 km (Wrap-Around)
52/// |-----------------------------------------------|
53///   When the "code length" is exceeded,
54///   measurements wrap back to 0.
55///
56/// So effectively:
57///     Observed code range = Actual range (mod 75,660 km)
58///     75,661 km → 1 km
59///
60/// ```
61///
62/// Nyx can only resolve the range ambiguity if the tracking data specifies a modulus for this specific measurement type.
63/// For example, in the case of the JPL Range Code and a 1 MHz range clock, the ambiguity interval is 75,660 km.
64///
65/// 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).
66///
67/// ```text
68/// k = computed_obs // ambiguity_interval
69/// real_obs = measured_obs + k * modulus
70/// ```
71///
72/// Reference: JPL DESCANSO, document 214, _Pseudo-Noise and Regenerative Ranging_.
73///
74#[derive(Clone, Default)]
75#[cfg_attr(feature = "python", pyclass(from_py_object))]
76pub struct TrackingDataArc {
77    /// All measurements in this data arc
78    pub measurements: Vec<Measurement>,
79    /// Source file if loaded from a file or saved to a file.
80    pub source: Option<String>,
81    /// Optionally provide a map of modulos (e.g. the RANGE_MODULO of CCSDS TDM).
82    pub moduli: Option<IndexMap<MeasurementType, f64>>,
83    /// Reject all of the measurements, useful for debugging passes.
84    pub force_reject: bool,
85}
86
87#[cfg_attr(feature = "python", pymethods)]
88impl TrackingDataArc {
89    /// Sort these measurements by epoch
90    pub fn sort(&mut self) {
91        self.measurements.sort_unstable_by(|a, b| {
92            a.epoch
93                .cmp(&b.epoch)
94                .then_with(|| a.tracker.cmp(&b.tracker))
95        });
96
97        // Coalesce adjacent duplicate elements in exactly O(K) time.
98        // dedup_by passes pointers to `(next_element, kept_element)`.
99        // If the closure returns true, `next_element` is physically dropped.
100        self.measurements.dedup_by(|next, kept| {
101            if next.epoch == kept.epoch && next.tracker == kept.tracker {
102                // The tracker and epoch are identical. Drain the sub-observables
103                // from the redundant 'next' measurement and merge them into the 'kept' one.
104                kept.data.extend(next.data.drain(..));
105
106                // If either partial record was manually flagged as rejected,
107                // the combined radiometric record must retain that suspicion.
108                kept.rejected |= next.rejected;
109
110                // Return true to destroy the redundant parent struct.
111                true
112            } else {
113                // Elements differ structurally. Keep both.
114                false
115            }
116        });
117    }
118    /// Returns the start epoch of this tracking arc
119    pub fn start_epoch(&self) -> Option<Epoch> {
120        self.measurements.first().map(|msr| msr.epoch)
121    }
122
123    /// Returns the end epoch of this tracking arc
124    pub fn end_epoch(&self) -> Option<Epoch> {
125        self.measurements.last().map(|msr| msr.epoch)
126    }
127
128    /// Returns the duration this tracking arc
129    pub fn duration(&self) -> Option<Duration> {
130        match self.start_epoch() {
131            Some(start) => self.end_epoch().map(|end| end - start),
132            None => None,
133        }
134    }
135
136    /// Returns the number of measurements in this data arc
137    pub fn len(&self) -> usize {
138        self.measurements.len()
139    }
140
141    /// Returns whether this arc has no measurements.
142    pub fn is_empty(&self) -> bool {
143        self.measurements.is_empty()
144    }
145
146    /// Returns the minimum duration between two subsequent measurements.
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 msr in self.measurements.iter().skip(1) {
154                let epoch = msr.epoch;
155                let this_sep = epoch - prev_epoch;
156                min_sep = min_sep.min(this_sep);
157                prev_epoch = epoch;
158            }
159            Some(min_sep)
160        }
161    }
162    /// Set (or overwrites) the modulus of the provided measurement type.
163    pub fn set_moduli(&mut self, msr_type: MeasurementType, modulus: f64) {
164        if modulus.is_nan() || modulus.abs() < f64::EPSILON {
165            warn!("cannot set modulus for {msr_type:?} to {modulus}");
166            return;
167        }
168        if self.moduli.is_none() {
169            self.moduli = Some(IndexMap::new());
170        }
171
172        self.moduli.as_mut().unwrap().insert(msr_type, modulus);
173    }
174
175    /// Applies the moduli to each measurement, if defined.
176    pub fn apply_moduli(&mut self) {
177        if let Some(moduli) = &self.moduli {
178            for msr in &mut self.measurements {
179                for (msr_type, modulus) in moduli {
180                    if let Some(msr_value) = msr.data.get_mut(msr_type) {
181                        *msr_value %= *modulus;
182                    }
183                }
184            }
185        }
186    }
187
188    /// Downsamples the tracking data to a lower frequency using a simple moving average low-pass filter followed by decimation,
189    /// returning new `TrackingDataArc` with downsampled measurements.
190    ///
191    /// It provides a computationally efficient approach to reduce the sampling rate while mitigating aliasing effects.
192    ///
193    /// # Algorithm
194    ///
195    /// 1. A simple moving average filter is applied as a low-pass filter.
196    /// 2. Decimation is performed by selecting every Nth sample after filtering.
197    ///
198    /// # Advantages
199    ///
200    /// - Computationally efficient, suitable for large datasets common in spaceflight applications.
201    /// - Provides basic anti-aliasing, crucial for preserving signal integrity in orbit determination and tracking.
202    /// - Maintains phase information, important for accurate timing in spacecraft state estimation.
203    ///
204    /// # Limitations
205    ///
206    /// - The frequency response is not as sharp as more sophisticated filters (e.g., FIR, IIR).
207    /// - May not provide optimal stopband attenuation for high-precision applications.
208    ///
209    /// ## Considerations for Spaceflight Applications
210    ///
211    /// - Suitable for initial data reduction in ground station tracking pipelines.
212    /// - Adequate for many orbit determination and tracking tasks where computational speed is prioritized.
213    /// - For high-precision applications (e.g., interplanetary navigation), consider using more advanced filtering techniques.
214    ///
215    /// :type target_step: Duration
216    /// :rtype: Self
217    pub fn downsample(&self, target_step: Duration) -> Self {
218        if self.is_empty() {
219            return self.clone();
220        }
221        let current_step = self.min_duration_sep().unwrap();
222
223        if current_step >= target_step {
224            warn!(
225                "cannot downsample tracking data from {current_step} to {target_step} (that would be upsampling)"
226            );
227            return self.clone();
228        }
229
230        let current_hz = 1.0 / current_step.to_seconds();
231        let target_hz = 1.0 / target_step.to_seconds();
232
233        // Simple moving average as low-pass filter
234        let window_size = (current_hz / target_hz).round() as usize;
235
236        info!(
237            "downsampling tracking data from {current_step} ({current_hz:.6} Hz) to {target_step} ({target_hz:.6} Hz) (N = {window_size})"
238        );
239
240        let mut result = TrackingDataArc {
241            source: self.source.clone(),
242            ..Default::default()
243        };
244
245        let measurements: Vec<_> = self.measurements.iter().collect();
246
247        for (i, msr) in measurements.iter().enumerate().step_by(window_size) {
248            let epoch = msr.epoch;
249            let start = i.saturating_sub(window_size / 2);
250            let end = (i + window_size / 2 + 1).min(measurements.len());
251            let window = &measurements[start..end];
252
253            let mut filtered_measurement = Measurement {
254                tracker: window[0].tracker.clone(),
255                epoch,
256                data: IndexMap::new(),
257                rejected: false,
258            };
259
260            // Apply moving average filter for each measurement type
261            for mtype in self.unique_types() {
262                let sum: f64 = window.iter().filter_map(|m| m.data.get(&mtype)).sum();
263                let count = window
264                    .iter()
265                    .filter(|m| m.data.contains_key(&mtype))
266                    .count();
267
268                if count > 0 {
269                    filtered_measurement.data.insert(mtype, sum / count as f64);
270                }
271            }
272
273            result.measurements.push(filtered_measurement);
274        }
275        result.sort();
276        result
277    }
278
279    /// Splits a long tracking data arc into smaller chunks, each up to `max_duration` long.
280    pub fn chunk(&self, max_duration: Duration) -> Vec<TrackingDataArc> {
281        let mut chunks = Vec::new();
282        if self.is_empty() || max_duration <= Duration::ZERO {
283            return chunks;
284        }
285
286        let mut start_idx = 0;
287        let total_measurements = self.measurements.len();
288
289        while start_idx < total_measurements {
290            let chunk_start_epoch = self.measurements[start_idx].epoch;
291            let chunk_end_time = chunk_start_epoch + max_duration;
292
293            // Isolate the remaining, unprocessed portion of the vector
294            let remaining = &self.measurements[start_idx..];
295
296            // Perform a binary search on the remaining slice to find the first
297            // index that strictly exceeds the chunk_end_time.
298            let offset = remaining.partition_point(|msr| msr.epoch <= chunk_end_time);
299
300            let end_idx = start_idx + offset;
301
302            // Extract and clone ONLY the measurements belonging to this chunk.
303            // This drops the memory complexity from O(K * N) to strictly O(N).
304            let chunk_measurements = self.measurements[start_idx..end_idx].to_vec();
305
306            chunks.push(TrackingDataArc {
307                measurements: chunk_measurements,
308                source: self.source.clone(),
309                moduli: self.moduli.clone(),
310                force_reject: self.force_reject,
311            });
312
313            // Advance the window to the exact start of the next chunk
314            start_idx = end_idx;
315        }
316
317        chunks
318    }
319}
320
321impl TrackingDataArc {
322    /// Helper method to resolve bounds into slice indices via binary search.
323    fn resolve_bounds<R: RangeBounds<Epoch>>(&self, bound: R) -> (usize, usize) {
324        // Find the lower bound index via O(log N) binary search
325        let start_idx = match bound.start_bound() {
326            Bound::Included(&epoch) => self.measurements.partition_point(|m| m.epoch < epoch),
327            Bound::Excluded(&epoch) => self.measurements.partition_point(|m| m.epoch <= epoch),
328            Bound::Unbounded => 0,
329        };
330
331        // Find the upper bound index via O(log N) binary search
332        let end_idx = match bound.end_bound() {
333            Bound::Included(&epoch) => self.measurements.partition_point(|m| m.epoch <= epoch),
334            Bound::Excluded(&epoch) => self.measurements.partition_point(|m| m.epoch < epoch),
335            Bound::Unbounded => self.measurements.len(),
336        };
337
338        (start_idx, end_idx)
339    }
340
341    /// Returns the unique list of aliases in this tracking data arc
342    pub fn unique_aliases(&self) -> IndexSet<String> {
343        self.unique().0
344    }
345
346    /// Returns the unique measurement types in this tracking data arc
347    pub fn unique_types(&self) -> IndexSet<MeasurementType> {
348        self.unique().1
349    }
350
351    /// Returns the unique trackers and unique measurement types in this data arc
352    pub fn unique(&self) -> (IndexSet<String>, IndexSet<MeasurementType>) {
353        let mut aliases = IndexSet::new();
354        let mut types = IndexSet::new();
355        for msr in &self.measurements {
356            aliases.insert(msr.tracker.clone());
357            for k in msr.data.keys() {
358                types.insert(*k);
359            }
360        }
361        (aliases, types)
362    }
363
364    /// Returns a new tracking arc that only contains measurements that fall within the given epoch range.
365    ///
366    /// Executes in O(N) time strictly due to memory shifting, requiring zero new allocations.
367    pub fn filter_by_epoch<R: RangeBounds<Epoch>>(mut self, bound: R) -> Self {
368        let (start_idx, end_idx) = self.resolve_bounds(bound);
369
370        // Handle disjoint bounds or out-of-range queries
371        if start_idx >= end_idx || start_idx >= self.measurements.len() {
372            self.measurements.clear();
373            return self;
374        }
375
376        // In-place memory reduction
377        // Truncate the tail first. This drops trailing measurements without shifting.
378        self.measurements.truncate(end_idx);
379
380        // Drain the head. This removes preceding measurements and shifts the
381        // remaining valid data leftward to index 0 in a single memory move.
382        self.measurements.drain(0..start_idx);
383
384        // Note that the order is preserved, so we don't need to sort again.
385
386        // Clear unused memory
387        self.measurements.shrink_to_fit();
388
389        self
390    }
391
392    /// Returns a new tracking arc that only contains measurements that fall within the given offset from the first epoch.
393    /// For example, a bound of 30.minutes()..90.minutes() will only read measurements from the start of the arc + 30 minutes until start + 90 minutes.
394    pub fn filter_by_offset<R: RangeBounds<Duration>>(self, bound: R) -> Self {
395        if self.is_empty() {
396            return self;
397        }
398        // Rebuild an epoch bound.
399        let start = match bound.start_bound() {
400            Unbounded => self.start_epoch().unwrap(),
401            Included(offset) | Excluded(offset) => self.start_epoch().unwrap() + *offset,
402        };
403
404        let end = match bound.end_bound() {
405            Unbounded => self.end_epoch().unwrap(),
406            Included(offset) | Excluded(offset) => self.start_epoch().unwrap() + *offset,
407        };
408
409        self.filter_by_epoch(start..end)
410    }
411
412    /// Returns a new tracking arc that only contains measurements from the desired tracker.
413    pub fn filter_by_tracker(mut self, tracker: String) -> Self {
414        self.measurements = self
415            .measurements
416            .iter()
417            .filter_map(|msr| {
418                if msr.tracker == tracker {
419                    Some(msr.clone())
420                } else {
421                    None
422                }
423            })
424            .collect::<Vec<Measurement>>();
425        self
426    }
427
428    /// Returns a new tracking arc that only contains measurements of the provided type.
429    pub fn filter_by_measurement_type(mut self, included_type: MeasurementType) -> Self {
430        self.measurements.retain_mut(|msr| {
431            msr.data.retain(|msr_type, _| *msr_type == included_type);
432            !msr.data.is_empty()
433        });
434        self
435    }
436
437    /// Returns a new tracking arc that contains measurements from all trackers except the one provided
438    pub fn exclude_tracker(mut self, excluded_tracker: String) -> Self {
439        self.measurements = self
440            .measurements
441            .iter()
442            .filter_map(|msr| {
443                if msr.tracker != excluded_tracker {
444                    Some(msr.clone())
445                } else {
446                    None
447                }
448            })
449            .collect::<Vec<Measurement>>();
450        self
451    }
452
453    /// Returns a new tracking arc that excludes measurements within the given epoch range.
454    ///
455    /// Executes an in-place O(N) memory shift with zero heap allocations.
456    pub fn exclude_by_epoch<R: RangeBounds<Epoch>>(mut self, bound: R) -> Self {
457        let (start_idx, end_idx) = self.resolve_bounds(bound);
458
459        if start_idx < end_idx && start_idx < self.measurements.len() {
460            // Drain removes the specified range and shifts all subsequent elements
461            // leftward to fill the gap. The extracted elements are immediately dropped.
462            self.measurements.drain(start_idx..end_idx);
463        }
464
465        self
466    }
467
468    /// Returns a new tracking arc that contains measurements from all trackers except the one provided
469    pub fn exclude_measurement_type(mut self, excluded_type: MeasurementType) -> Self {
470        self.measurements = self
471            .measurements
472            .iter_mut()
473            .map(|msr| {
474                msr.data.retain(|msr_type, _| *msr_type != excluded_type);
475                msr.clone()
476            })
477            .collect::<Vec<Measurement>>();
478        self
479    }
480
481    /// Marks measurements within the given epoch range as rejected.
482    ///
483    /// Operates in O(log N) for bound resolution and O(K) for iteration, where K is the slice length.
484    pub fn reject_by_epoch<R: RangeBounds<Epoch>>(mut self, bound: R) -> Self {
485        let (start_idx, end_idx) = self.resolve_bounds(bound);
486
487        if start_idx < end_idx && start_idx < self.measurements.len() {
488            for msr in &mut self.measurements[start_idx..end_idx] {
489                msr.rejected = true;
490            }
491        }
492        self
493    }
494
495    /// Marks measurements from the provided tracker as rejected.
496    /// Requires an O(N) scan. The parameter is downgraded to &str to prevent heap allocations.
497    pub fn reject_by_tracker(mut self, tracker: &str) -> Self {
498        for msr in &mut self.measurements {
499            if msr.tracker == tracker {
500                msr.rejected = true;
501            }
502        }
503        self
504    }
505
506    pub fn resid_vs_ref_check(mut self) -> Self {
507        self.force_reject = true;
508        self
509    }
510}
511
512impl fmt::Display for TrackingDataArc {
513    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
514        if self.is_empty() {
515            write!(f, "Empty tracking arc")
516        } else {
517            let start = self.start_epoch().unwrap();
518            let end = self.end_epoch().unwrap();
519            let src = match &self.source {
520                Some(src) => format!(" (source: {src})"),
521                None => String::new(),
522            };
523            write!(
524                f,
525                "Tracking arc with {} measurements of type {:?} over {} (from {start} to {end}) with trackers {:?}{src}",
526                self.len(),
527                self.unique_types(),
528                end - start,
529                self.unique_aliases()
530            )
531        }
532    }
533}
534
535impl fmt::Debug for TrackingDataArc {
536    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537        write!(f, "{self} @ {self:p}")
538    }
539}
540
541impl PartialEq for TrackingDataArc {
542    fn eq(&self, other: &Self) -> bool {
543        self.measurements == other.measurements
544    }
545}
546
547impl Add for TrackingDataArc {
548    type Output = Self;
549
550    fn add(mut self, rhs: Self) -> Self::Output {
551        self.force_reject = false;
552        self.measurements.extend(rhs.measurements);
553        self.sort();
554
555        self.force_reject = false;
556        self
557    }
558}
559
560impl AddAssign for TrackingDataArc {
561    fn add_assign(&mut self, rhs: Self) {
562        *self = self.clone() + rhs;
563    }
564}