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 log::{error, info, warn};
23use std::collections::btree_map::Entry;
24use std::collections::BTreeMap;
25use std::ops::Bound::{self, Excluded, Included, Unbounded};
26use std::ops::{Add, AddAssign, RangeBounds};
27
28mod io_ccsds_tdm;
29mod io_parquet;
30
31/// Tracking data storing all of measurements as a B-Tree.
32/// It inherently does NOT support multiple concurrent measurements from several trackers.
33///
34/// # Measurement Moduli, e.g. range modulus
35///
36/// 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
37/// 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_.
38/// 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,
39/// 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.
40/// This is simply because the range code overlaps with itself, effectively loosing track of its own reference:
41/// it's due to the phase shift of the signal "lapping" the original signal length.
42///
43/// ```text
44/// (Spacecraft)
45/// ^
46/// | Actual Distance = 75,661 km
47/// |
48/// 0 km 75,660 km (Wrap-Around)
49/// |-----------------------------------------------|
50/// When the "code length" is exceeded,
51/// measurements wrap back to 0.
52///
53/// So effectively:
54/// Observed code range = Actual range (mod 75,660 km)
55/// 75,661 km → 1 km
56///
57/// ```
58///
59/// Nyx can only resolve the range ambiguity if the tracking data specifies a modulus for this specific measurement type.
60/// For example, in the case of the JPL Range Code and a 1 MHz range clock, the ambiguity interval is 75,660 km.
61///
62/// 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).
63///
64/// ```text
65/// k = computed_obs // ambiguity_interval
66/// real_obs = measured_obs + k * modulus
67/// ```
68///
69/// Reference: JPL DESCANSO, document 214, _Pseudo-Noise and Regenerative Ranging_.
70///
71#[derive(Clone, Default)]
72pub struct TrackingDataArc {
73 /// All measurements in this data arc
74 pub measurements: BTreeMap<Epoch, Measurement>, // BUG: Consider a map of tracking to epoch!
75 /// Source file if loaded from a file or saved to a file.
76 pub source: Option<String>,
77 /// Optionally provide a map of modulos (e.g. the RANGE_MODULO of CCSDS TDM).
78 pub moduli: Option<IndexMap<MeasurementType, f64>>,
79 /// Reject all of the measurements, useful for debugging passes.
80 pub force_reject: bool,
81}
82
83impl TrackingDataArc {
84 /// Set (or overwrites) the modulus of the provided measurement type.
85 pub fn set_moduli(&mut self, msr_type: MeasurementType, modulus: f64) {
86 if modulus.is_nan() || modulus.abs() < f64::EPSILON {
87 warn!("cannot set modulus for {msr_type:?} to {modulus}");
88 return;
89 }
90 if self.moduli.is_none() {
91 self.moduli = Some(IndexMap::new());
92 }
93
94 self.moduli.as_mut().unwrap().insert(msr_type, modulus);
95 }
96
97 /// Applies the moduli to each measurement, if defined.
98 pub fn apply_moduli(&mut self) {
99 if let Some(moduli) = &self.moduli {
100 for msr in self.measurements.values_mut() {
101 for (msr_type, modulus) in moduli {
102 if let Some(msr_value) = msr.data.get_mut(msr_type) {
103 *msr_value %= *modulus;
104 }
105 }
106 }
107 }
108 }
109
110 /// Returns the unique list of aliases in this tracking data arc
111 pub fn unique_aliases(&self) -> IndexSet<String> {
112 self.unique().0
113 }
114
115 /// Returns the unique measurement types in this tracking data arc
116 pub fn unique_types(&self) -> IndexSet<MeasurementType> {
117 self.unique().1
118 }
119
120 /// Returns the unique trackers and unique measurement types in this data arc
121 pub fn unique(&self) -> (IndexSet<String>, IndexSet<MeasurementType>) {
122 let mut aliases = IndexSet::new();
123 let mut types = IndexSet::new();
124 for msr in self.measurements.values() {
125 aliases.insert(msr.tracker.clone());
126 for k in msr.data.keys() {
127 types.insert(*k);
128 }
129 }
130 (aliases, types)
131 }
132
133 /// Returns the start epoch of this tracking arc
134 pub fn start_epoch(&self) -> Option<Epoch> {
135 self.measurements.first_key_value().map(|(k, _)| *k)
136 }
137
138 /// Returns the end epoch of this tracking arc
139 pub fn end_epoch(&self) -> Option<Epoch> {
140 self.measurements.last_key_value().map(|(k, _)| *k)
141 }
142
143 /// Returns the duration this tracking arc
144 pub fn duration(&self) -> Option<Duration> {
145 match self.start_epoch() {
146 Some(start) => self.end_epoch().map(|end| end - start),
147 None => None,
148 }
149 }
150
151 /// Returns the number of measurements in this data arc
152 pub fn len(&self) -> usize {
153 self.measurements.len()
154 }
155
156 /// Returns whether this arc has no measurements.
157 pub fn is_empty(&self) -> bool {
158 self.measurements.is_empty()
159 }
160
161 /// Returns the minimum duration between two subsequent measurements.
162 pub fn min_duration_sep(&self) -> Option<Duration> {
163 if self.is_empty() {
164 None
165 } else {
166 let mut min_sep = Duration::MAX;
167 let mut prev_epoch = self.start_epoch().unwrap();
168 for (epoch, _) in self.measurements.iter().skip(1) {
169 let this_sep = *epoch - prev_epoch;
170 min_sep = min_sep.min(this_sep);
171 prev_epoch = *epoch;
172 }
173 Some(min_sep)
174 }
175 }
176
177 /// Returns a new tracking arc that only contains measurements that fall within the given epoch range.
178 pub fn filter_by_epoch<R: RangeBounds<Epoch>>(mut self, bound: R) -> Self {
179 self.measurements = self
180 .measurements
181 .range(bound)
182 .map(|(epoch, msr)| (*epoch, msr.clone()))
183 .collect::<BTreeMap<Epoch, Measurement>>();
184 self
185 }
186
187 /// Returns a new tracking arc that only contains measurements that fall within the given offset from the first epoch.
188 /// 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.
189 pub fn filter_by_offset<R: RangeBounds<Duration>>(self, bound: R) -> Self {
190 if self.is_empty() {
191 return self;
192 }
193 // Rebuild an epoch bound.
194 let start = match bound.start_bound() {
195 Unbounded => self.start_epoch().unwrap(),
196 Included(offset) | Excluded(offset) => self.start_epoch().unwrap() + *offset,
197 };
198
199 let end = match bound.end_bound() {
200 Unbounded => self.end_epoch().unwrap(),
201 Included(offset) | Excluded(offset) => self.start_epoch().unwrap() + *offset,
202 };
203
204 self.filter_by_epoch(start..end)
205 }
206
207 /// Returns a new tracking arc that only contains measurements from the desired tracker.
208 pub fn filter_by_tracker(mut self, tracker: String) -> Self {
209 self.measurements = self
210 .measurements
211 .iter()
212 .filter_map(|(epoch, msr)| {
213 if msr.tracker == tracker {
214 Some((*epoch, msr.clone()))
215 } else {
216 None
217 }
218 })
219 .collect::<BTreeMap<Epoch, Measurement>>();
220 self
221 }
222
223 /// Returns a new tracking arc that only contains measurements of the provided type.
224 pub fn filter_by_measurement_type(mut self, included_type: MeasurementType) -> Self {
225 self.measurements.retain(|_epoch, msr| {
226 msr.data.retain(|msr_type, _| *msr_type == included_type);
227 !msr.data.is_empty()
228 });
229 self
230 }
231
232 /// Returns a new tracking arc that contains measurements from all trackers except the one provided
233 pub fn exclude_tracker(mut self, excluded_tracker: String) -> Self {
234 self.measurements = self
235 .measurements
236 .iter()
237 .filter_map(|(epoch, msr)| {
238 if msr.tracker != excluded_tracker {
239 Some((*epoch, msr.clone()))
240 } else {
241 None
242 }
243 })
244 .collect::<BTreeMap<Epoch, Measurement>>();
245 self
246 }
247
248 /// Returns a new tracking arc that excludes measurements within the given epoch range.
249 pub fn exclude_by_epoch<R: RangeBounds<Epoch>>(mut self, bound: R) -> Self {
250 info!(
251 "Excluding measurements from {:?} to {:?}",
252 bound.start_bound(),
253 bound.end_bound()
254 );
255
256 let mut new_measurements = BTreeMap::new();
257
258 // Include entries before the excluded range
259 new_measurements.extend(
260 self.measurements
261 .range((Bound::Unbounded, bound.start_bound()))
262 .map(|(epoch, msr)| (*epoch, msr.clone())),
263 );
264
265 // Include entries after the excluded range
266 new_measurements.extend(
267 self.measurements
268 .range((bound.end_bound(), Bound::Unbounded))
269 .map(|(epoch, msr)| (*epoch, msr.clone())),
270 );
271
272 self.measurements = new_measurements;
273 self
274 }
275
276 /// Returns a new tracking arc that contains measurements from all trackers except the one provided
277 pub fn exclude_measurement_type(mut self, excluded_type: MeasurementType) -> Self {
278 self.measurements = self
279 .measurements
280 .iter_mut()
281 .map(|(epoch, msr)| {
282 msr.data.retain(|msr_type, _| *msr_type != excluded_type);
283
284 (*epoch, msr.clone())
285 })
286 .collect::<BTreeMap<Epoch, Measurement>>();
287 self
288 }
289
290 /// Marks measurements within the given epoch range as rejected.
291 pub fn reject_by_epoch<R: RangeBounds<Epoch>>(mut self, bound: R) -> Self {
292 for (_epoch, msr) in self.measurements.range_mut(bound) {
293 msr.rejected = true;
294 }
295 self
296 }
297
298 /// Marks measurements from the provided tracker as rejected.
299 pub fn reject_by_tracker(mut self, tracker: String) -> Self {
300 for msr in self.measurements.values_mut() {
301 if msr.tracker == tracker {
302 msr.rejected = true;
303 }
304 }
305 self
306 }
307
308 /// Downsamples the tracking data to a lower frequency using a simple moving average low-pass filter followed by decimation,
309 /// returning new `TrackingDataArc` with downsampled measurements.
310 ///
311 /// It provides a computationally efficient approach to reduce the sampling rate while mitigating aliasing effects.
312 ///
313 /// # Algorithm
314 ///
315 /// 1. A simple moving average filter is applied as a low-pass filter.
316 /// 2. Decimation is performed by selecting every Nth sample after filtering.
317 ///
318 /// # Advantages
319 ///
320 /// - Computationally efficient, suitable for large datasets common in spaceflight applications.
321 /// - Provides basic anti-aliasing, crucial for preserving signal integrity in orbit determination and tracking.
322 /// - Maintains phase information, important for accurate timing in spacecraft state estimation.
323 ///
324 /// # Limitations
325 ///
326 /// - The frequency response is not as sharp as more sophisticated filters (e.g., FIR, IIR).
327 /// - May not provide optimal stopband attenuation for high-precision applications.
328 ///
329 /// ## Considerations for Spaceflight Applications
330 ///
331 /// - Suitable for initial data reduction in ground station tracking pipelines.
332 /// - Adequate for many orbit determination and tracking tasks where computational speed is prioritized.
333 /// - For high-precision applications (e.g., interplanetary navigation), consider using more advanced filtering techniques.
334 ///
335 pub fn downsample(self, target_step: Duration) -> Self {
336 if self.is_empty() {
337 return self;
338 }
339 let current_step = self.min_duration_sep().unwrap();
340
341 if current_step >= target_step {
342 warn!("cannot downsample tracking data from {current_step} to {target_step} (that would be upsampling)");
343 return self;
344 }
345
346 let current_hz = 1.0 / current_step.to_seconds();
347 let target_hz = 1.0 / target_step.to_seconds();
348
349 // Simple moving average as low-pass filter
350 let window_size = (current_hz / target_hz).round() as usize;
351
352 info!("downsampling tracking data from {current_step} ({current_hz:.6} Hz) to {target_step} ({target_hz:.6} Hz) (N = {window_size})");
353
354 let mut result = TrackingDataArc {
355 source: self.source.clone(),
356 ..Default::default()
357 };
358
359 let measurements: Vec<_> = self.measurements.iter().collect();
360
361 for (i, (epoch, _)) in measurements.iter().enumerate().step_by(window_size) {
362 let start = i.saturating_sub(window_size / 2);
363 let end = (i + window_size / 2 + 1).min(measurements.len());
364 let window = &measurements[start..end];
365
366 let mut filtered_measurement = Measurement {
367 tracker: window[0].1.tracker.clone(),
368 epoch: **epoch,
369 data: IndexMap::new(),
370 rejected: false,
371 };
372
373 // Apply moving average filter for each measurement type
374 for mtype in self.unique_types() {
375 let sum: f64 = window.iter().filter_map(|(_, m)| m.data.get(&mtype)).sum();
376 let count = window
377 .iter()
378 .filter(|(_, m)| m.data.contains_key(&mtype))
379 .count();
380
381 if count > 0 {
382 filtered_measurement.data.insert(mtype, sum / count as f64);
383 }
384 }
385
386 result.measurements.insert(**epoch, filtered_measurement);
387 }
388 result
389 }
390
391 pub fn resid_vs_ref_check(mut self) -> Self {
392 self.force_reject = true;
393 self
394 }
395}
396
397impl fmt::Display for TrackingDataArc {
398 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399 if self.is_empty() {
400 write!(f, "Empty tracking arc")
401 } else {
402 let start = self.start_epoch().unwrap();
403 let end = self.end_epoch().unwrap();
404 let src = match &self.source {
405 Some(src) => format!(" (source: {src})"),
406 None => String::new(),
407 };
408 write!(
409 f,
410 "Tracking arc with {} measurements of type {:?} over {} (from {start} to {end}) with trackers {:?}{src}",
411 self.len(),
412 self.unique_types(),
413 end - start,
414 self.unique_aliases()
415 )
416 }
417 }
418}
419
420impl fmt::Debug for TrackingDataArc {
421 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422 write!(f, "{self} @ {self:p}")
423 }
424}
425
426impl PartialEq for TrackingDataArc {
427 fn eq(&self, other: &Self) -> bool {
428 self.measurements == other.measurements
429 }
430}
431
432impl Add for TrackingDataArc {
433 type Output = Self;
434
435 fn add(mut self, rhs: Self) -> Self::Output {
436 self.force_reject = false;
437 for (epoch, msr) in rhs.measurements {
438 if let Entry::Vacant(e) = self.measurements.entry(epoch) {
439 e.insert(msr);
440 } else {
441 error!("merging tracking data with overlapping epoch is not supported");
442 }
443 }
444
445 self
446 }
447}
448
449impl AddAssign for TrackingDataArc {
450 fn add_assign(&mut self, rhs: Self) {
451 *self = self.clone() + rhs;
452 }
453}