Skip to main content

nyx_space/od/msr/trackingdata/
io_ccsds_tdm.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*/
18
19use crate::io::ExportCfg;
20use crate::io::watermark::prj_name_ver;
21use crate::io::{InputOutputError, StdIOSnafu};
22use crate::od::msr::{Measurement, MeasurementType};
23use anise::constants::SPEED_OF_LIGHT_KM_S;
24use hifitime::efmt::{Format, Formatter};
25use hifitime::{Duration, Epoch, TimeScale};
26use indexmap::{IndexMap, IndexSet};
27use log::{error, info, warn};
28use snafu::ResultExt;
29use std::collections::HashMap;
30use std::fs::File;
31use std::io::Write;
32use std::io::{BufRead, BufReader, BufWriter};
33use std::path::{Path, PathBuf};
34use std::str::FromStr;
35
36use super::TrackingDataArc;
37
38impl TrackingDataArc {
39    /// Loads a tracking arc from its serialization in CCSDS TDM.
40    ///
41    /// # Support level
42    ///
43    /// - Only the KVN format is supported.
44    /// - Support is limited to orbit determination in "xGEO", i.e. cislunar and deep space missions.
45    /// - Only one metadata and data section per file is tested.
46    ///
47    /// ## Data types
48    ///
49    /// Fully supported:
50    ///     - RANGE
51    ///     - DOPPLER_INSTANTANEOUS, DOPPLER_INTEGRATED
52    ///     - ANGLE_1 / ANGLE_2, as azimuth/elevation only
53    ///
54    /// Partially supported:
55    ///     - TRANSMIT_FREQ / RECEIVE_FREQ : these will be converted to Doppler measurements using the TURNAROUND_NUMERATOR and TURNAROUND_DENOMINATOR in the TDM. The freq rate is _not_ supported.
56    ///
57    /// ## Metadata support
58    ///
59    /// ### Mode
60    ///
61    /// Only the MODE = SEQUENTIAL is supported.
62    ///
63    /// ### Time systems / time scales
64    ///
65    /// All timescales supported by hifitime are supported here. This includes: UTC, TAI, GPS, TT, TDB, TAI, GST, QZSST, TL, TCL.
66    ///
67    /// ### Path
68    ///
69    /// Only one way or two way data is supported, i.e. path must be either `PATH n,m,n` or `PATH n,m`.
70    ///
71    /// Note that the actual indexes of the path are ignored.
72    ///
73    /// ### Participants
74    ///
75    /// `PARTICIPANT_1` must be the ground station / tracker.
76    /// The second participant is ignored: the user must ensure that the Orbit Determination Process is properly configured and the proper arc is given.
77    ///
78    /// ### Turnaround ratio
79    ///
80    /// The turnaround ratio is only accounted for when the data contains RECEIVE_FREQ and TRANSMIT_FREQ data.
81    ///
82    /// ### Range and modulus
83    ///
84    /// Only kilometers are supported in range units. Range modulus is accounted for to compute range ambiguity.
85    ///
86    pub fn from_tdm<P: AsRef<Path>>(
87        path: P,
88        aliases: Option<HashMap<String, String>>,
89    ) -> Result<Self, InputOutputError> {
90        let file = File::open(&path).context(StdIOSnafu {
91            action: "opening CCSDS TDM file for tracking arc",
92        })?;
93
94        let source = path.as_ref().to_path_buf().display().to_string();
95        info!("parsing CCSDS TDM {source}");
96
97        let mut measurements = Vec::new();
98        let mut metadata = HashMap::new();
99
100        let reader = BufReader::new(file);
101
102        let mut in_data_section = false;
103        let mut current_tracker = String::new();
104        let mut time_system = TimeScale::UTC;
105        let mut has_freq_data = false;
106        let mut msr_divider = 1.0;
107
108        for line in reader.lines() {
109            let line = line.context(StdIOSnafu {
110                action: "reading CCSDS TDM file",
111            })?;
112            let line = line.trim();
113
114            if line == "DATA_START" {
115                in_data_section = true;
116                continue;
117            } else if line == "DATA_STOP" {
118                in_data_section = false;
119            }
120
121            if !in_data_section {
122                if line.starts_with("PARTICIPANT_1") {
123                    current_tracker = line.split('=').nth(1).unwrap_or("").trim().to_string();
124                    // If aliases are provided, try to map them.
125                    if let Some(aliases) = &aliases
126                        && let Some(alias) = aliases.get(&current_tracker)
127                    {
128                        current_tracker = alias.clone();
129                    }
130                } else if line.starts_with("TIME_SYSTEM") {
131                    let ts = line.split('=').nth(1).unwrap_or("UTC").trim();
132                    // Support for all time scales of hifitime
133                    if let Ok(ts) = TimeScale::from_str(ts) {
134                        time_system = ts;
135                    } else {
136                        return Err(InputOutputError::UnsupportedData {
137                            which: format!("time scale `{ts}` not supported"),
138                        });
139                    }
140                } else if line.starts_with("PATH") {
141                    match line.split(",").count() {
142                        2 => msr_divider = 1.0,
143                        3 => msr_divider = 2.0,
144                        cnt => {
145                            return Err(InputOutputError::UnsupportedData {
146                                which: format!(
147                                    "found {cnt} paths in TDM, only 1 or 2 are supported"
148                                ),
149                            });
150                        }
151                    }
152                }
153
154                let mut splt = line.split('=');
155                if let Some(keyword) = splt.nth(0) {
156                    // Get the zeroth item again since we've consumed the first zeroth one.
157                    if let Some(value) = splt.nth(0) {
158                        metadata.insert(keyword.trim().to_string(), value.trim().to_string());
159                    }
160                }
161
162                continue;
163            }
164
165            if let Some((mtype, epoch, value)) = parse_measurement_line(line, time_system)? {
166                // 1. Calculate the effective divider for this specific line.
167                // (See the critique below regarding why mutating msr_divider here was a bug).
168                let effective_divider = if [
169                    MeasurementType::ReceiveFrequency,
170                    MeasurementType::TransmitFrequency,
171                    MeasurementType::TransmitFrequencyRate,
172                ]
173                .contains(&mtype)
174                {
175                    has_freq_data = true;
176                    1.0
177                } else {
178                    msr_divider
179                };
180
181                let scaled_value = value / effective_divider;
182
183                // If the last inserted measurement belongs to the exact same tracker
184                // and epoch, we append the sub-observable to its IndexMap.
185                let is_concurrent = measurements.last().is_some_and(|last: &Measurement| {
186                    last.epoch == epoch && last.tracker == current_tracker
187                });
188
189                if is_concurrent {
190                    measurements
191                        .last_mut()
192                        .unwrap() // Safe due to the is_concurrent check yielding true
193                        .data
194                        .insert(mtype, scaled_value);
195                } else {
196                    //  Otherwise, instantiate a new state record and push it.
197                    let mut data = IndexMap::new();
198                    data.insert(mtype, scaled_value);
199
200                    measurements.push(Measurement {
201                        tracker: current_tracker.clone(),
202                        epoch,
203                        data,
204                        rejected: false,
205                    });
206                }
207            }
208        }
209
210        let mut turnaround_ratio = None;
211        let drop_freq_data;
212        if has_freq_data {
213            // If there is any frequency measurement, compute the turn-around ratio.
214            if let Some(ta_num_str) = metadata.get("TURNAROUND_NUMERATOR") {
215                if let Some(ta_denom_str) = metadata.get("TURNAROUND_DENOMINATOR") {
216                    if let Ok(ta_num) = ta_num_str.parse::<i32>() {
217                        if let Ok(ta_denom) = ta_denom_str.parse::<i32>() {
218                            // turn-around ratio is set.
219                            turnaround_ratio = Some(f64::from(ta_num) / f64::from(ta_denom));
220                            info!("turn-around ratio is {ta_num}/{ta_denom}");
221                            drop_freq_data = false;
222                        } else {
223                            error!(
224                                "turn-around denominator `{ta_denom_str}` is not a valid integer"
225                            );
226                            drop_freq_data = true;
227                        }
228                    } else {
229                        error!("turn-around numerator `{ta_num_str}` is not a valid integer");
230                        drop_freq_data = true;
231                    }
232                } else {
233                    error!(
234                        "required turn-around denominator missing from metadata -- dropping ALL RECEIVE/TRANSMIT data"
235                    );
236                    drop_freq_data = true;
237                }
238            } else {
239                error!(
240                    "required turn-around numerator missing from metadata -- dropping ALL RECEIVE/TRANSMIT data"
241                );
242                drop_freq_data = true;
243            }
244        } else {
245            drop_freq_data = true;
246        }
247
248        let corrections_applied = if let Some(corr_flag) = metadata.get("CORRECTIONS_APPLIED") {
249            match corr_flag.trim().to_lowercase().as_str() {
250                "no" => false,
251                "yes" => true,
252                _ => {
253                    warn!("invalid CORRECTIONS_APPLIED `{corr_flag}`");
254                    true
255                }
256            }
257        } else {
258            true
259        };
260
261        // Now, let's convert the receive and transmit frequencies to Doppler measurements in velocity units.
262        // We expect the transmit and receive frequencies to have the exact same timestamp.
263        let mut freq_types = IndexSet::new();
264        freq_types.insert(MeasurementType::ReceiveFrequency);
265        freq_types.insert(MeasurementType::TransmitFrequency);
266        freq_types.insert(MeasurementType::TransmitFrequencyRate);
267
268        let mut latest_transmit_freq = None;
269        let mut latest_transmit_epoch = None;
270        let mut latest_transmit_rate = 0.0;
271
272        let mut all_applied_corrections = IndexSet::new();
273
274        for measurement in &mut measurements {
275            let epoch = measurement.epoch;
276            // Apply corrections if any
277            if !corrections_applied {
278                for msr_type in [
279                    MeasurementType::Range,
280                    MeasurementType::Doppler,
281                    MeasurementType::Azimuth,
282                    MeasurementType::Elevation,
283                    MeasurementType::ReceiveFrequency,
284                    MeasurementType::TransmitFrequency,
285                    MeasurementType::TransmitFrequencyRate,
286                ] {
287                    let kw = format!("CORRECTION_{}", msr_type.ccsds_tdm_name());
288                    if let Some(correction_str) = metadata.get(&kw) {
289                        if let Ok(correction) = correction_str.parse::<f64>() {
290                            measurement.correct(msr_type, correction);
291                            all_applied_corrections.insert(msr_type);
292                        } else {
293                            warn!("invalid correction value for {kw}");
294                        }
295                    }
296                }
297            }
298
299            if drop_freq_data {
300                for freq in &freq_types {
301                    measurement.data.swap_remove(freq);
302                }
303                continue;
304            }
305
306            // Update the transmit frequency and rate if they are set.
307            if let Some(rate) = measurement
308                .data
309                .get(&MeasurementType::TransmitFrequencyRate)
310            {
311                if let (Some(last_f), Some(last_e)) = (latest_transmit_freq, latest_transmit_epoch)
312                {
313                    let dt: Duration = epoch - last_e;
314                    latest_transmit_freq = Some(last_f + latest_transmit_rate * dt.to_seconds());
315                }
316                latest_transmit_epoch = Some(epoch);
317                latest_transmit_rate = *rate;
318            }
319
320            if let Some(freq) = measurement.data.get(&MeasurementType::TransmitFrequency) {
321                latest_transmit_freq = Some(*freq);
322                latest_transmit_epoch = Some(epoch);
323            }
324
325            if !measurement
326                .data
327                .contains_key(&MeasurementType::ReceiveFrequency)
328            {
329                // If there's no receive frequency, we just continue (having updated the transmit freq rate)
330                // but we must remove the transmit freq rate from the measurement.
331                for freq in &freq_types {
332                    measurement.data.swap_remove(freq);
333                }
334                continue;
335            }
336
337            // There is a receive frequency
338            if latest_transmit_freq.is_none() {
339                warn!(
340                    "receive frequency found at {epoch} but no transmit frequency was ever set, ignoring"
341                );
342                for freq in &freq_types {
343                    measurement.data.swap_remove(freq);
344                }
345                continue;
346            }
347
348            let dt: Duration = epoch - latest_transmit_epoch.unwrap();
349            let transmit_freq_hz =
350                latest_transmit_freq.unwrap() + latest_transmit_rate * dt.to_seconds();
351
352            let receive_freq_hz = *measurement
353                .data
354                .get(&MeasurementType::ReceiveFrequency)
355                .unwrap();
356
357            // Compute the Doppler shift, equation from section 3.5.2.8.2 of CCSDS TDM v2 specs
358            let doppler_shift_hz = transmit_freq_hz * turnaround_ratio.unwrap() - receive_freq_hz;
359            // Compute the expected Doppler measurement as range-rate.
360            let rho_dot_km_s = (doppler_shift_hz * SPEED_OF_LIGHT_KM_S)
361                / (2.0 * transmit_freq_hz * turnaround_ratio.unwrap());
362
363            // Finally, replace the frequency data with a Doppler measurement.
364            for freq in &freq_types {
365                measurement.data.swap_remove(freq);
366            }
367            measurement
368                .data
369                .insert(MeasurementType::Doppler, rho_dot_km_s);
370        }
371
372        if !all_applied_corrections.is_empty() {
373            info!("applied corrections for {all_applied_corrections:?}");
374        }
375
376        let moduli = if let Some(range_modulus) = metadata.get("RANGE_MODULUS") {
377            if let Ok(value) = range_modulus.parse::<f64>() {
378                if value > 0.0 {
379                    let mut modulos = IndexMap::new();
380                    modulos.insert(MeasurementType::Range, value);
381                    // Only range modulus exists in TDM files.
382                    Some(modulos)
383                } else {
384                    // Do not apply a modulus of zero.
385                    None
386                }
387            } else {
388                warn!("could not parse RANGE_MODULUS of `{range_modulus}` as a double");
389                None
390            }
391        } else {
392            None
393        };
394
395        // Remove measurements that have no data left after our processing.
396        measurements.retain(|m| !m.data.is_empty());
397
398        let mut trk = Self {
399            measurements,
400            source: Some(source),
401            moduli,
402            force_reject: false,
403        };
404
405        // Ensure data is sorted (TDM spec requires that, but you never know).
406        trk.sort();
407
408        if trk.unique_types().is_empty() {
409            Err(InputOutputError::EmptyDataset {
410                action: "CCSDS TDM file",
411            })
412        } else {
413            Ok(trk)
414        }
415    }
416
417    /// Store this tracking arc to a CCSDS TDM file, with optional metadata and a timestamp appended to the filename.
418    pub fn to_tdm_file<P: AsRef<Path>>(
419        mut self,
420        spacecraft_name: String,
421        aliases: Option<HashMap<String, String>>,
422        path: P,
423        cfg: ExportCfg,
424    ) -> Result<PathBuf, InputOutputError> {
425        if self.is_empty() {
426            return Err(InputOutputError::MissingData {
427                which: " - empty tracking data cannot be exported to TDM".to_string(),
428            });
429        }
430
431        // Filter epochs if needed.
432        if let Some(start_epoch) = cfg.start_epoch {
433            if let Some(end_epoch) = cfg.end_epoch {
434                self = self.filter_by_epoch(start_epoch..end_epoch);
435            } else {
436                self = self.filter_by_epoch(start_epoch..);
437            }
438        } else if let Some(end_epoch) = cfg.end_epoch {
439            self = self.filter_by_epoch(..end_epoch);
440        }
441
442        let tick = Epoch::now().unwrap();
443        info!("Exporting tracking data to CCSDS TDM file...");
444
445        // Grab the path here before we move stuff.
446        let path_buf = cfg.actual_path(path);
447
448        let metadata = cfg.metadata.unwrap_or_default();
449
450        let file = File::create(&path_buf).context(StdIOSnafu {
451            action: "creating CCSDS TDM file for tracking arc",
452        })?;
453        let mut writer = BufWriter::new(file);
454
455        let err_hdlr = |source| InputOutputError::StdIOError {
456            source,
457            action: "writing data to TDM file",
458        };
459
460        // Epoch formmatter.
461        let iso8601_no_ts = Format::from_str("%Y-%m-%dT%H:%M:%S.%f").unwrap();
462
463        // Write mandatory metadata
464        writeln!(writer, "CCSDS_TDM_VERS = 2.0").map_err(err_hdlr)?;
465        writeln!(
466            writer,
467            "\nCOMMENT Build by {} -- https://nyxspace.com",
468            prj_name_ver()
469        )
470        .map_err(err_hdlr)?;
471        writeln!(
472            writer,
473            "COMMENT Nyx Space provided under the AGPL v3 open source license -- https://nyxspace.com/pricing\n"
474        )
475        .map_err(err_hdlr)?;
476        writeln!(
477            writer,
478            "CREATION_DATE = {}",
479            Formatter::new(Epoch::now().unwrap(), iso8601_no_ts)
480        )
481        .map_err(err_hdlr)?;
482        writeln!(
483            writer,
484            "ORIGINATOR = {}\n",
485            metadata
486                .get("originator")
487                .unwrap_or(&"Nyx Space".to_string())
488        )
489        .map_err(err_hdlr)?;
490
491        // Create a new meta section for each tracker and for each measurement type that is one or two way.
492        // Get unique trackers and process each one separately
493        let trackers = self.unique_aliases();
494
495        for tracker in trackers {
496            let tracker_data = self.clone().filter_by_tracker(tracker.clone());
497
498            let types = tracker_data.unique_types();
499
500            let two_way_types = types
501                .iter()
502                .filter(|msr_type| msr_type.may_be_two_way())
503                .copied()
504                .collect::<Vec<_>>();
505
506            let one_way_types = types
507                .iter()
508                .filter(|msr_type| !msr_type.may_be_two_way())
509                .copied()
510                .collect::<Vec<_>>();
511
512            // Add the two-way data first.
513            for (tno, types) in [two_way_types, one_way_types].iter().enumerate() {
514                writeln!(writer, "META_START").map_err(err_hdlr)?;
515                writeln!(writer, "\tTIME_SYSTEM = UTC").map_err(err_hdlr)?;
516                writeln!(
517                    writer,
518                    "\tSTART_TIME = {}",
519                    Formatter::new(tracker_data.start_epoch().unwrap(), iso8601_no_ts)
520                )
521                .map_err(err_hdlr)?;
522                writeln!(
523                    writer,
524                    "\tSTOP_TIME = {}",
525                    Formatter::new(tracker_data.end_epoch().unwrap(), iso8601_no_ts)
526                )
527                .map_err(err_hdlr)?;
528
529                let multiplier = if tno == 0 {
530                    writeln!(writer, "\tPATH = 1,2,1").map_err(err_hdlr)?;
531                    2.0
532                } else {
533                    writeln!(writer, "\tPATH = 1,2").map_err(err_hdlr)?;
534                    1.0
535                };
536
537                writeln!(
538                    writer,
539                    "\tPARTICIPANT_1 = {}",
540                    if let Some(aliases) = &aliases {
541                        if let Some(alias) = aliases.get(&tracker) {
542                            alias
543                        } else {
544                            &tracker
545                        }
546                    } else {
547                        &tracker
548                    }
549                )
550                .map_err(err_hdlr)?;
551
552                writeln!(writer, "\tPARTICIPANT_2 = {spacecraft_name}").map_err(err_hdlr)?;
553
554                writeln!(writer, "\tMODE = SEQUENTIAL").map_err(err_hdlr)?;
555
556                // Add additional metadata, could include timetag ref for example.
557                for (k, v) in &metadata {
558                    if k != "originator" {
559                        writeln!(writer, "\t{k} = {v}").map_err(err_hdlr)?;
560                    }
561                }
562
563                if types.contains(&MeasurementType::Range) {
564                    writeln!(writer, "\tRANGE_UNITS = km").map_err(err_hdlr)?;
565
566                    if let Some(moduli) = &self.moduli
567                        && let Some(range_modulus) = moduli.get(&MeasurementType::Range)
568                    {
569                        writeln!(writer, "\tRANGE_MODULUS = {range_modulus:E}")
570                            .map_err(err_hdlr)?;
571                    }
572                }
573
574                if types.contains(&MeasurementType::Azimuth)
575                    || types.contains(&MeasurementType::Elevation)
576                {
577                    writeln!(writer, "\tANGLE_TYPE = AZEL").map_err(err_hdlr)?;
578                }
579
580                writeln!(writer, "META_STOP\n").map_err(err_hdlr)?;
581
582                // Write the data section
583                writeln!(writer, "DATA_START").map_err(err_hdlr)?;
584
585                // Process measurements for this tracker
586                for m in &tracker_data.measurements {
587                    for (mtype, value) in &m.data {
588                        if !types.contains(mtype) {
589                            continue;
590                        }
591
592                        writeln!(
593                            writer,
594                            "\t{:<20} = {:<23}\t{:.12}",
595                            mtype.ccsds_tdm_name(),
596                            Formatter::new(m.epoch, iso8601_no_ts),
597                            value * multiplier
598                        )
599                        .map_err(err_hdlr)?;
600                    }
601                }
602
603                writeln!(writer, "DATA_STOP\n").map_err(err_hdlr)?;
604            }
605        }
606
607        #[allow(clippy::writeln_empty_string)]
608        writeln!(writer, "").map_err(err_hdlr)?;
609
610        // Return the path this was written to
611        let tock_time = Epoch::now().unwrap() - tick;
612        info!("CCSDS TDM written to {} in {tock_time}", path_buf.display());
613        Ok(path_buf)
614    }
615}
616
617fn parse_measurement_line(
618    line: &str,
619    time_system: TimeScale,
620) -> Result<Option<(MeasurementType, Epoch, f64)>, InputOutputError> {
621    let parts: Vec<&str> = line.split('=').collect();
622    if parts.len() != 2 {
623        return Ok(None);
624    }
625
626    let (mtype_str, data) = (parts[0].trim(), parts[1].trim());
627    let mtype = match mtype_str {
628        "RANGE" => MeasurementType::Range,
629        "DOPPLER_INSTANTANEOUS" | "DOPPLER_INTEGRATED" => MeasurementType::Doppler,
630        "ANGLE_1" => MeasurementType::Azimuth,
631        "ANGLE_2" => MeasurementType::Elevation,
632        "RECEIVE_FREQ" | "RECEIVE_FREQ_1" | "RECEIVE_FREQ_2" | "RECEIVE_FREQ_3"
633        | "RECEIVE_FREQ_4" | "RECEIVE_FREQ_5" => MeasurementType::ReceiveFrequency,
634        "TRANSMIT_FREQ" | "TRANSMIT_FREQ_1" | "TRANSMIT_FREQ_2" | "TRANSMIT_FREQ_3"
635        | "TRANSMIT_FREQ_4" | "TRANSMIT_FREQ_5" => MeasurementType::TransmitFrequency,
636        "TRANSMIT_FREQ_RATE"
637        | "TRANSMIT_FREQ_RATE_1"
638        | "TRANSMIT_FREQ_RATE_2"
639        | "TRANSMIT_FREQ_RATE_3"
640        | "TRANSMIT_FREQ_RATE_4"
641        | "TRANSMIT_FREQ_RATE_5" => MeasurementType::TransmitFrequencyRate,
642        _ => {
643            return Err(InputOutputError::UnsupportedData {
644                which: mtype_str.to_string(),
645            });
646        }
647    };
648
649    let data_parts: Vec<&str> = data.split_whitespace().collect();
650    if data_parts.len() != 2 {
651        return Ok(None);
652    }
653
654    let epoch =
655        Epoch::from_gregorian_str(&format!("{} {time_system}", data_parts[0])).map_err(|e| {
656            InputOutputError::Inconsistency {
657                msg: format!("{e} when parsing epoch"),
658            }
659        })?;
660
661    let value = data_parts[1]
662        .parse::<f64>()
663        .map_err(|e| InputOutputError::UnsupportedData {
664            which: format!("`{}` is not a float: {e}", data_parts[1]),
665        })?;
666
667    Ok(Some((mtype, epoch, value)))
668}