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