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}