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