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
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40enum TdmParserState {
41    Header,
42    Metadata,
43    Data,
44}
45
46#[allow(clippy::too_many_arguments)]
47fn finish_segment(
48    measurements: &mut Vec<Measurement>,
49    segment_measurements: &mut Vec<Measurement>,
50    segment_metadata: &HashMap<String, String>,
51    msr_divider: f64,
52    has_freq_data: bool,
53    all_applied_corrections: &mut IndexSet<MeasurementType>,
54    moduli: &mut Option<IndexMap<MeasurementType, f64>>,
55) -> Result<(), InputOutputError> {
56    if segment_measurements.is_empty() {
57        return Ok(());
58    }
59
60    let mut turnaround_ratio = None;
61    let drop_freq_data;
62    if has_freq_data {
63        // If there is any frequency measurement, compute the turn-around ratio.
64        if let Some(ta_num_str) = segment_metadata.get("TURNAROUND_NUMERATOR") {
65            if let Some(ta_denom_str) = segment_metadata.get("TURNAROUND_DENOMINATOR") {
66                if let Ok(ta_num) = ta_num_str.parse::<i32>() {
67                    if let Ok(ta_denom) = ta_denom_str.parse::<i32>() {
68                        // turn-around ratio is set.
69                        turnaround_ratio = Some(f64::from(ta_num) / f64::from(ta_denom));
70                        info!("turn-around ratio is {ta_num}/{ta_denom}");
71                        drop_freq_data = false;
72                    } else {
73                        error!("turn-around denominator `{ta_denom_str}` is not a valid integer");
74                        drop_freq_data = true;
75                    }
76                } else {
77                    error!("turn-around numerator `{ta_num_str}` is not a valid integer");
78                    drop_freq_data = true;
79                }
80            } else {
81                error!(
82                    "required turn-around denominator missing from metadata -- dropping ALL RECEIVE/TRANSMIT data"
83                );
84                drop_freq_data = true;
85            }
86        } else {
87            error!(
88                "required turn-around numerator missing from metadata -- dropping ALL RECEIVE/TRANSMIT data"
89            );
90            drop_freq_data = true;
91        }
92    } else {
93        drop_freq_data = true;
94    }
95
96    let corrections_applied = if let Some(corr_flag) = segment_metadata.get("CORRECTIONS_APPLIED") {
97        match corr_flag.trim().to_lowercase().as_str() {
98            "no" => false,
99            "yes" => true,
100            _ => {
101                warn!("invalid CORRECTIONS_APPLIED `{corr_flag}`");
102                false
103            }
104        }
105    } else {
106        false
107    };
108
109    // Now, let's convert the receive and transmit frequencies to Doppler measurements in velocity units.
110    // We expect the transmit and receive frequencies to have the exact same timestamp.
111    let mut freq_types = IndexSet::new();
112    freq_types.insert(MeasurementType::ReceiveFrequency);
113    freq_types.insert(MeasurementType::TransmitFrequency);
114    freq_types.insert(MeasurementType::TransmitFrequencyRate);
115
116    let mut latest_transmit_freq = None;
117    let mut latest_transmit_epoch = None;
118    let mut latest_transmit_rate = 0.0;
119
120    for measurement in segment_measurements.iter_mut() {
121        let epoch = measurement.epoch;
122        // Apply corrections if any
123        if !corrections_applied {
124            for msr_type in [
125                MeasurementType::Range,
126                MeasurementType::Doppler,
127                MeasurementType::Azimuth,
128                MeasurementType::Elevation,
129                MeasurementType::ReceiveFrequency,
130                MeasurementType::TransmitFrequency,
131                MeasurementType::TransmitFrequencyRate,
132            ] {
133                let kws = match msr_type {
134                    MeasurementType::Doppler => vec![
135                        "CORRECTION_DOPPLER".to_string(),
136                        "CORRECTION_DOPPLER_INTEGRATED".to_string(),
137                        "CORRECTION_DOPPLER_INSTANTANEOUS".to_string(),
138                    ],
139                    _ => vec![format!("CORRECTION_{}", msr_type.ccsds_tdm_name())],
140                };
141
142                for kw in kws {
143                    if let Some(correction_str) = segment_metadata.get(&kw) {
144                        if let Ok(correction) = correction_str.parse::<f64>() {
145                            let scaled_correction = match msr_type {
146                                MeasurementType::Range => {
147                                    if let Some(range_units) = segment_metadata.get("RANGE_UNITS") {
148                                        match convert_range_units(
149                                            correction,
150                                            range_units,
151                                            msr_divider,
152                                        ) {
153                                            Ok(sc) => sc,
154                                            Err(e) => {
155                                                warn!("failed to convert CORRECTION_RANGE: {e}");
156                                                continue;
157                                            }
158                                        }
159                                    } else {
160                                        warn!(
161                                            "RANGE_UNITS missing when converting CORRECTION_RANGE"
162                                        );
163                                        correction / msr_divider
164                                    }
165                                }
166                                MeasurementType::Doppler => correction / msr_divider,
167                                _ => correction,
168                            };
169
170                            measurement.correct(msr_type, scaled_correction);
171                            all_applied_corrections.insert(msr_type);
172                        } else {
173                            warn!("invalid correction value for {kw}");
174                        }
175                    }
176                }
177            }
178        }
179
180        if drop_freq_data {
181            for freq in &freq_types {
182                measurement.data.swap_remove(freq);
183            }
184            continue;
185        }
186
187        // Update the transmit frequency and rate if they are set.
188        if let Some(rate) = measurement
189            .data
190            .get(&MeasurementType::TransmitFrequencyRate)
191        {
192            if let (Some(last_f), Some(last_e)) = (latest_transmit_freq, latest_transmit_epoch) {
193                let dt: Duration = epoch - last_e;
194                latest_transmit_freq = Some(last_f + latest_transmit_rate * dt.to_seconds());
195            }
196            latest_transmit_epoch = Some(epoch);
197            latest_transmit_rate = *rate;
198        }
199
200        if let Some(freq) = measurement.data.get(&MeasurementType::TransmitFrequency) {
201            latest_transmit_freq = Some(*freq);
202            latest_transmit_epoch = Some(epoch);
203        }
204
205        if !measurement
206            .data
207            .contains_key(&MeasurementType::ReceiveFrequency)
208        {
209            // If there's no receive frequency, we just continue (having updated the transmit freq rate)
210            // but we must remove the transmit freq rate from the measurement.
211            for freq in &freq_types {
212                measurement.data.swap_remove(freq);
213            }
214            continue;
215        }
216
217        // There is a receive frequency
218        if latest_transmit_freq.is_none() {
219            warn!(
220                "receive frequency found at {epoch} but no transmit frequency was ever set, ignoring"
221            );
222            for freq in &freq_types {
223                measurement.data.swap_remove(freq);
224            }
225            continue;
226        }
227
228        let dt: Duration = epoch - latest_transmit_epoch.unwrap();
229        let transmit_freq_hz =
230            latest_transmit_freq.unwrap() + latest_transmit_rate * dt.to_seconds();
231
232        let receive_freq_hz = *measurement
233            .data
234            .get(&MeasurementType::ReceiveFrequency)
235            .unwrap();
236
237        // Compute the Doppler shift, equation from section 3.5.2.8.2 of CCSDS TDM v2 specs
238        let doppler_shift_hz = transmit_freq_hz * turnaround_ratio.unwrap() - receive_freq_hz;
239        // Compute the expected Doppler measurement as range-rate.
240        let rho_dot_km_s = (doppler_shift_hz * SPEED_OF_LIGHT_KM_S)
241            / (2.0 * transmit_freq_hz * turnaround_ratio.unwrap());
242
243        // Finally, replace the frequency data with a Doppler measurement.
244        for freq in &freq_types {
245            measurement.data.swap_remove(freq);
246        }
247        measurement
248            .data
249            .insert(MeasurementType::Doppler, rho_dot_km_s);
250    }
251
252    if let Some(range_modulus) = segment_metadata.get("RANGE_MODULUS") {
253        if let Ok(value) = range_modulus.parse::<f64>() {
254            if value > 0.0 {
255                let map = moduli.get_or_insert_with(IndexMap::new);
256                map.insert(MeasurementType::Range, value);
257            }
258        } else {
259            warn!("could not parse RANGE_MODULUS of `{range_modulus}` as a double");
260        }
261    }
262
263    // Remove measurements that have no data left after our processing.
264    segment_measurements.retain(|m| !m.data.is_empty());
265
266    measurements.append(segment_measurements);
267
268    Ok(())
269}
270
271impl TrackingDataArc {
272    /// Loads a tracking arc from its serialization in CCSDS TDM.
273    ///
274    /// # Support level
275    ///
276    /// - Only the KVN format is supported.
277    /// - Support is limited to orbit determination in "xGEO", i.e. cislunar and deep space missions.
278    /// - Supports multiple metadata and data sections per file.
279    ///
280    /// ## Data types
281    ///
282    /// Fully supported:
283    ///     - RANGE
284    ///     - DOPPLER_INSTANTANEOUS, DOPPLER_INTEGRATED
285    ///     - ANGLE_1 / ANGLE_2, as azimuth/elevation only
286    ///
287    /// Partially supported:
288    ///     - 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.
289    ///
290    /// ## Metadata support
291    ///
292    /// ### Mode
293    ///
294    /// Only the MODE = SEQUENTIAL is supported.
295    ///
296    /// ### Time systems / time scales
297    ///
298    /// All timescales supported by hifitime are supported here. This includes: UTC, TAI, GPS, TT, TDB, TAI, GST, QZSST, TL, TCL.
299    ///
300    /// ### Path
301    ///
302    /// Only one way or two way data is supported, i.e. path must be either `PATH n,m,n` or `PATH n,m`.
303    ///
304    /// Note that the actual indexes of the path are ignored.
305    ///
306    /// ### Participants
307    ///
308    /// `PARTICIPANT_1` must be the ground station / tracker.
309    /// The second participant is ignored: the user must ensure that the Orbit Determination Process is properly configured and the proper arc is given.
310    ///
311    /// ### Turnaround ratio
312    ///
313    /// The turnaround ratio is only accounted for when the data contains RECEIVE_FREQ and TRANSMIT_FREQ data.
314    ///
315    /// ### Range and modulus
316    ///
317    /// Only kilometers are supported in range units. Range modulus is accounted for to compute range ambiguity.
318    ///
319    pub fn from_tdm<P: AsRef<Path>>(
320        path: P,
321        aliases: Option<HashMap<String, String>>,
322    ) -> Result<Self, InputOutputError> {
323        let file = File::open(&path).context(StdIOSnafu {
324            action: "opening CCSDS TDM file for tracking arc",
325        })?;
326
327        let source = path.as_ref().to_path_buf().display().to_string();
328        info!("parsing CCSDS TDM {source}");
329
330        let reader = BufReader::new(file);
331
332        let mut parser_state = TdmParserState::Header;
333        let mut has_metadata_for_segment = false;
334
335        let mut measurements = Vec::new();
336        let mut segment_measurements = Vec::new();
337        let mut segment_metadata = HashMap::new();
338        let mut all_applied_corrections = IndexSet::new();
339        let mut moduli = None;
340
341        let mut current_tracker = String::new();
342        let mut time_system = TimeScale::UTC;
343        let mut has_freq_data = false;
344        let mut msr_divider = 1.0;
345        let mut integration_ref = None;
346        let mut integration_time = None;
347
348        let parse_one_val =
349            |lno: usize, line: &str, err: &str| -> Result<String, InputOutputError> {
350                match line.split_once('=') {
351                    Some((_, val_str)) => Ok(val_str.trim().to_string()),
352                    None => Err(InputOutputError::TDMError {
353                        msg: format!("line {lno}: {err}"),
354                    }),
355                }
356            };
357
358        for (lno, line) in reader.lines().enumerate() {
359            let line = line.context(StdIOSnafu {
360                action: "reading CCSDS TDM file",
361            })?;
362            let line = line.trim();
363            if line.is_empty() {
364                continue;
365            }
366
367            if line.starts_with("META_START") {
368                match parser_state {
369                    TdmParserState::Metadata => {
370                        return Err(InputOutputError::TDMError {
371                            msg: format!("line {lno}: nested META_START is not allowed"),
372                        });
373                    }
374                    TdmParserState::Data => {
375                        return Err(InputOutputError::TDMError {
376                            msg: format!(
377                                "line {lno}: META_START cannot appear inside an open DATA block"
378                            ),
379                        });
380                    }
381                    TdmParserState::Header => {}
382                }
383                parser_state = TdmParserState::Metadata;
384                has_metadata_for_segment = false;
385                current_tracker.clear();
386                time_system = TimeScale::UTC;
387                has_freq_data = false;
388                msr_divider = 1.0;
389                integration_ref = None;
390                integration_time = None;
391                segment_metadata.clear();
392                segment_measurements.clear();
393                continue;
394            }
395
396            if line.starts_with("META_STOP") {
397                if parser_state != TdmParserState::Metadata {
398                    return Err(InputOutputError::TDMError {
399                        msg: format!("line {lno}: META_STOP without META_START"),
400                    });
401                }
402                parser_state = TdmParserState::Header;
403                has_metadata_for_segment = true;
404                continue;
405            }
406
407            if line.starts_with("DATA_START") {
408                match parser_state {
409                    TdmParserState::Metadata => {
410                        return Err(InputOutputError::TDMError {
411                            msg: format!(
412                                "line {lno}: DATA_START cannot appear inside metadata block"
413                            ),
414                        });
415                    }
416                    TdmParserState::Data => {
417                        return Err(InputOutputError::TDMError {
418                            msg: format!("line {lno}: nested DATA_START is not allowed"),
419                        });
420                    }
421                    TdmParserState::Header => {
422                        if !has_metadata_for_segment {
423                            return Err(InputOutputError::TDMError {
424                                msg: format!("line {lno}: DATA_START without prior metadata block"),
425                            });
426                        }
427                    }
428                }
429                parser_state = TdmParserState::Data;
430                continue;
431            }
432
433            if line.starts_with("DATA_STOP") {
434                if parser_state != TdmParserState::Data {
435                    return Err(InputOutputError::TDMError {
436                        msg: format!("line {lno}: DATA_STOP without DATA_START"),
437                    });
438                }
439                finish_segment(
440                    &mut measurements,
441                    &mut segment_measurements,
442                    &segment_metadata,
443                    msr_divider,
444                    has_freq_data,
445                    &mut all_applied_corrections,
446                    &mut moduli,
447                )?;
448                parser_state = TdmParserState::Header;
449                has_metadata_for_segment = false;
450                continue;
451            }
452
453            if line.starts_with("COMMENT") {
454                continue;
455            }
456
457            // Validate metadata keys appearing outside META_START / META_STOP
458            let metadata_key = line.split_once('=').map(|(key, _)| key.trim());
459            if matches!(
460                metadata_key,
461                Some(
462                    "TIME_SYSTEM"
463                        | "START_TIME"
464                        | "STOP_TIME"
465                        | "PARTICIPANT_1"
466                        | "PARTICIPANT_2"
467                        | "PARTICIPANT_3"
468                        | "PARTICIPANT_4"
469                        | "PARTICIPANT_5"
470                        | "MODE"
471                        | "PATH"
472                        | "PATH_1"
473                        | "PATH_2"
474                        | "TRANSMIT_BAND"
475                        | "RECEIVE_BAND"
476                        | "INTEGRATION_INTERVAL"
477                        | "INTEGRATION_TIME"
478                        | "INTEGRATION_REF"
479                        | "FREQ_OFFSET"
480                        | "RANGE_MODE"
481                        | "RANGE_MODULUS"
482                        | "RANGE_UNITS"
483                        | "ANGLE_TYPE"
484                        | "DATA_QUALITY"
485                        | "CORRECTIONS_APPLIED"
486                        | "CORRECTION_RANGE"
487                        | "CORRECTION_DOPPLER"
488                        | "CORRECTION_DOPPLER_INTEGRATED"
489                        | "CORRECTION_DOPPLER_INSTANTANEOUS"
490                        | "CORRECTION_ANGLE_1"
491                        | "CORRECTION_ANGLE_2"
492                        | "CORRECTION_AZIMUTH"
493                        | "CORRECTION_ELEVATION"
494                        | "CORRECTION_RECEIVE_FREQ"
495                        | "CORRECTION_TRANSMIT_FREQ"
496                        | "CORRECTION_TRANSMIT_FREQ_RATE"
497                        | "TURNAROUND_NUMERATOR"
498                        | "TURNAROUND_DENOMINATOR"
499                        | "INTERPOLATION"
500                        | "INTERPOLATION_DEGREE"
501                )
502            ) && parser_state != TdmParserState::Metadata
503            {
504                return Err(InputOutputError::TDMError {
505                    msg: format!(
506                        "metadata field `{}` appears outside META_START/META_STOP (line {lno})",
507                        metadata_key.unwrap()
508                    ),
509                });
510            }
511
512            if parser_state == TdmParserState::Header {
513                if line.starts_with("CCSDS_TDM_VERS") {
514                    let version_str = parse_one_val(lno, line, "no value for CCSDS_TDM_VERS")?;
515                    match version_str.parse::<f32>() {
516                        Ok(version_val) => match version_val as i16 {
517                            1..=3 => {}
518                            _ => {
519                                return Err(InputOutputError::UnsupportedData {
520                                    which: format!(
521                                        "CCSDS TDM version {version_val} not supported (line {lno})"
522                                    ),
523                                });
524                            }
525                        },
526                        Err(_) => {
527                            return Err(InputOutputError::TDMError {
528                                msg: format!(
529                                    "could not parse TDM version `{version_str}` (line {lno})"
530                                ),
531                            });
532                        }
533                    }
534                }
535                continue;
536            }
537
538            if parser_state == TdmParserState::Metadata {
539                if line.starts_with("PARTICIPANT_1") {
540                    current_tracker = parse_one_val(lno, line, "no value for PARTICIPANT_1")?;
541                    if let Some(aliases) = &aliases
542                        && let Some(alias) = aliases.get(&current_tracker)
543                    {
544                        current_tracker = alias.clone();
545                    }
546                } else if line.starts_with("TIME_SYSTEM") {
547                    let ts = parse_one_val(lno, line, "no value for TIME_SYSTEM")?;
548                    if let Ok(ts_scale) = TimeScale::from_str(&ts) {
549                        time_system = ts_scale;
550                    } else {
551                        return Err(InputOutputError::UnsupportedData {
552                            which: format!("time scale `{ts}` not supported"),
553                        });
554                    }
555                } else if line.starts_with("PATH") {
556                    let path_val = parse_one_val(lno, line, "no value for PATH")?;
557                    match path_val.split(',').count() {
558                        2 => msr_divider = 1.0,
559                        3 => msr_divider = 2.0,
560                        cnt => {
561                            return Err(InputOutputError::UnsupportedData {
562                                which: format!(
563                                    "found {cnt} paths in TDM, only 1 or 2 are supported"
564                                ),
565                            });
566                        }
567                    }
568                } else if line.starts_with("INTEGRATION_REF") {
569                    let value = parse_one_val(lno, line, "no value for INTEGRATION_REF")?;
570                    integration_ref = Some(IntegrationRef::from_str(&value)?);
571                } else if line.starts_with("INTEGRATION_TIME")
572                    || line.starts_with("INTEGRATION_INTERVAL")
573                {
574                    let value = parse_one_val(lno, line, "no value for INTEGRATION_TIME/INTERVAL")?;
575                    let dur = if let Ok(val) = value.parse::<f64>() {
576                        Unit::Second * val
577                    } else if let Ok(dur) = Duration::from_str(&value) {
578                        dur
579                    } else {
580                        return Err(InputOutputError::UnsupportedData {
581                            which: format!("invalid integration time `{value}`"),
582                        });
583                    };
584                    integration_time = Some(dur);
585                }
586
587                if let Some((keyword, value)) = line.split_once('=') {
588                    segment_metadata.insert(keyword.trim().to_string(), value.trim().to_string());
589                }
590                continue;
591            }
592
593            if parser_state == TdmParserState::Data
594                && let Some((mtype, epoch, value)) = parse_measurement_line(line, time_system)?
595            {
596                let effective_divider = if mtype.may_be_two_way() {
597                    msr_divider
598                } else {
599                    if [
600                        MeasurementType::ReceiveFrequency,
601                        MeasurementType::TransmitFrequency,
602                        MeasurementType::TransmitFrequencyRate,
603                    ]
604                    .contains(&mtype)
605                    {
606                        has_freq_data = true;
607                    }
608                    1.0
609                };
610
611                let mut scaled_value = value;
612                if mtype == MeasurementType::Range {
613                    if let Some(range_units) = segment_metadata.get("RANGE_UNITS") {
614                        scaled_value =
615                            convert_range_units(value, range_units.as_str(), effective_divider)?;
616                    } else {
617                        return Err(InputOutputError::MissingData {
618                            which: "RANGE_UNITS not specified in metadata for RANGE measurement"
619                                .to_string(),
620                        });
621                    }
622                } else {
623                    scaled_value /= effective_divider;
624                }
625
626                let is_concurrent =
627                    segment_measurements
628                        .last()
629                        .is_some_and(|last: &Measurement| {
630                            last.epoch == epoch && last.tracker == current_tracker
631                        });
632
633                let doppler_config = match (integration_time, integration_ref) {
634                    (Some(time), Some(reference)) => Some(DopplerConfig {
635                        integration_time: time,
636                        integration_ref: reference,
637                    }),
638                    (Some(time), None) => Some(DopplerConfig {
639                        integration_time: time,
640                        integration_ref: IntegrationRef::default(),
641                    }),
642                    (None, Some(reference)) => Some(DopplerConfig {
643                        integration_time: DopplerConfig::default().integration_time,
644                        integration_ref: reference,
645                    }),
646                    (None, None) => None,
647                };
648
649                if is_concurrent {
650                    let last = segment_measurements.last_mut().unwrap();
651                    last.data.insert(mtype, scaled_value);
652                    if last.doppler_config.is_none() {
653                        last.doppler_config = doppler_config;
654                    }
655                } else {
656                    let mut data = IndexMap::new();
657                    data.insert(mtype, scaled_value);
658
659                    segment_measurements.push(Measurement {
660                        tracker: current_tracker.clone(),
661                        epoch,
662                        data,
663                        rejected: false,
664                        doppler_config,
665                    });
666                }
667            }
668        }
669
670        if parser_state == TdmParserState::Metadata {
671            return Err(InputOutputError::TDMError {
672                msg: "unterminated META_START section at end of file".to_string(),
673            });
674        }
675        if parser_state == TdmParserState::Data {
676            return Err(InputOutputError::TDMError {
677                msg: "unterminated DATA_START section at end of file".to_string(),
678            });
679        }
680
681        if !all_applied_corrections.is_empty() {
682            info!("applied corrections for {all_applied_corrections:?}");
683        }
684
685        let mut trk = Self {
686            measurements,
687            source: Some(source),
688            moduli,
689            force_reject: false,
690        };
691
692        // Ensure data is sorted (TDM spec requires that, but you never know).
693        trk.sort();
694
695        if trk.unique_types().is_empty() {
696            Err(InputOutputError::EmptyDataset {
697                action: "CCSDS TDM file",
698            })
699        } else {
700            Ok(trk)
701        }
702    }
703
704    /// Store this tracking arc to a CCSDS TDM file, with optional metadata and a timestamp appended to the filename.
705    pub fn to_tdm_file<P: AsRef<Path>>(
706        mut self,
707        spacecraft_name: String,
708        aliases: Option<HashMap<String, String>>,
709        path: P,
710        cfg: ExportCfg,
711    ) -> Result<PathBuf, InputOutputError> {
712        if self.is_empty() {
713            return Err(InputOutputError::MissingData {
714                which: " - empty tracking data cannot be exported to TDM".to_string(),
715            });
716        }
717
718        // Filter epochs if needed.
719        if let Some(start_epoch) = cfg.start_epoch {
720            if let Some(end_epoch) = cfg.end_epoch {
721                self = self.filter_by_epoch(start_epoch..end_epoch);
722            } else {
723                self = self.filter_by_epoch(start_epoch..);
724            }
725        } else if let Some(end_epoch) = cfg.end_epoch {
726            self = self.filter_by_epoch(..end_epoch);
727        }
728
729        let tick = Epoch::now().unwrap();
730        info!("Exporting tracking data to CCSDS TDM file...");
731
732        // Grab the path here before we move stuff.
733        let path_buf = cfg.actual_path(path);
734
735        let metadata = cfg.metadata.unwrap_or_default();
736
737        let file = File::create(&path_buf).context(StdIOSnafu {
738            action: "creating CCSDS TDM file for tracking arc",
739        })?;
740        let mut writer = BufWriter::new(file);
741
742        let err_hdlr = |source| InputOutputError::StdIOError {
743            source,
744            action: "writing data to TDM file",
745        };
746
747        // Epoch formmatter.
748        let iso8601_no_ts = Format::from_str("%Y-%m-%dT%H:%M:%S.%f").unwrap();
749
750        // Write mandatory metadata
751        writeln!(writer, "CCSDS_TDM_VERS = 2.0").map_err(err_hdlr)?;
752        writeln!(
753            writer,
754            "\nCOMMENT Build by {} -- https://nyxspace.com",
755            prj_name_ver()
756        )
757        .map_err(err_hdlr)?;
758        writeln!(
759            writer,
760            "COMMENT Nyx Space provided under the AGPL v3 open source license -- https://nyxspace.com/pricing\n"
761        )
762        .map_err(err_hdlr)?;
763        writeln!(
764            writer,
765            "CREATION_DATE = {}",
766            Formatter::new(Epoch::now().unwrap(), iso8601_no_ts)
767        )
768        .map_err(err_hdlr)?;
769        writeln!(
770            writer,
771            "ORIGINATOR = {}\n",
772            metadata
773                .get("originator")
774                .unwrap_or(&"Nyx Space".to_string())
775        )
776        .map_err(err_hdlr)?;
777
778        // Create a new meta section for each tracker and for each measurement type that is one or two way.
779        // Get unique trackers and process each one separately
780        let trackers = self.unique_aliases();
781
782        for tracker in trackers {
783            let tracker_data = self.clone().filter_by_tracker(tracker.clone());
784
785            let types = tracker_data.unique_types();
786
787            let two_way_types = types
788                .iter()
789                .filter(|msr_type| msr_type.may_be_two_way())
790                .copied()
791                .collect::<Vec<_>>();
792
793            let one_way_types = types
794                .iter()
795                .filter(|msr_type| !msr_type.may_be_two_way())
796                .copied()
797                .collect::<Vec<_>>();
798
799            // Add the two-way data first.
800            for (tno, types) in [two_way_types, one_way_types].iter().enumerate() {
801                if types.is_empty() {
802                    continue;
803                }
804                writeln!(writer, "META_START").map_err(err_hdlr)?;
805                writeln!(writer, "\tTIME_SYSTEM = UTC").map_err(err_hdlr)?;
806                writeln!(
807                    writer,
808                    "\tSTART_TIME = {}",
809                    Formatter::new(tracker_data.start_epoch().unwrap(), iso8601_no_ts)
810                )
811                .map_err(err_hdlr)?;
812                writeln!(
813                    writer,
814                    "\tSTOP_TIME = {}",
815                    Formatter::new(tracker_data.end_epoch().unwrap(), iso8601_no_ts)
816                )
817                .map_err(err_hdlr)?;
818
819                let multiplier = if tno == 0 {
820                    writeln!(writer, "\tPATH = 1,2,1").map_err(err_hdlr)?;
821                    2.0
822                } else {
823                    writeln!(writer, "\tPATH = 1,2").map_err(err_hdlr)?;
824                    1.0
825                };
826
827                writeln!(
828                    writer,
829                    "\tPARTICIPANT_1 = {}",
830                    if let Some(aliases) = &aliases {
831                        if let Some(alias) = aliases.get(&tracker) {
832                            alias
833                        } else {
834                            &tracker
835                        }
836                    } else {
837                        &tracker
838                    }
839                )
840                .map_err(err_hdlr)?;
841
842                writeln!(writer, "\tPARTICIPANT_2 = {spacecraft_name}").map_err(err_hdlr)?;
843
844                writeln!(writer, "\tMODE = SEQUENTIAL").map_err(err_hdlr)?;
845
846                // Add additional metadata, could include timetag ref for example.
847                for (k, v) in &metadata {
848                    let k_upper = k.to_uppercase();
849                    if k != "originator"
850                        && (!types.contains(&MeasurementType::Doppler)
851                            || (k_upper != "INTEGRATION_INTERVAL" && k_upper != "INTEGRATION_REF"))
852                    {
853                        writeln!(writer, "\t{k} = {v}").map_err(err_hdlr)?;
854                    }
855                }
856
857                if types.contains(&MeasurementType::Doppler)
858                    && let Some(doppler_cfg) = tracker_data
859                        .measurements
860                        .iter()
861                        .find_map(|m| m.doppler_config)
862                {
863                    writeln!(
864                        writer,
865                        "\tINTEGRATION_INTERVAL = {:.6}",
866                        doppler_cfg.integration_time.to_seconds()
867                    )
868                    .map_err(err_hdlr)?;
869                    let ref_str = match doppler_cfg.integration_ref {
870                        IntegrationRef::Start => "START",
871                        IntegrationRef::Middle => "MIDDLE",
872                        IntegrationRef::End => "END",
873                    };
874                    writeln!(writer, "\tINTEGRATION_REF = {ref_str}").map_err(err_hdlr)?;
875                }
876
877                if types.contains(&MeasurementType::Range) {
878                    writeln!(writer, "\tRANGE_UNITS = km").map_err(err_hdlr)?;
879
880                    if let Some(moduli) = &self.moduli
881                        && let Some(range_modulus) = moduli.get(&MeasurementType::Range)
882                    {
883                        writeln!(writer, "\tRANGE_MODULUS = {range_modulus:E}")
884                            .map_err(err_hdlr)?;
885                    }
886                }
887
888                if types.contains(&MeasurementType::Azimuth)
889                    || types.contains(&MeasurementType::Elevation)
890                {
891                    writeln!(writer, "\tANGLE_TYPE = AZEL").map_err(err_hdlr)?;
892                }
893
894                writeln!(writer, "META_STOP\n").map_err(err_hdlr)?;
895
896                // Write the data section
897                writeln!(writer, "DATA_START").map_err(err_hdlr)?;
898
899                // Process measurements for this tracker
900                for m in &tracker_data.measurements {
901                    for (mtype, value) in &m.data {
902                        if !types.contains(mtype) {
903                            continue;
904                        }
905
906                        writeln!(
907                            writer,
908                            "\t{:<20} = {:<23}\t{:.12}",
909                            mtype.ccsds_tdm_name(),
910                            Formatter::new(m.epoch, iso8601_no_ts),
911                            value * multiplier
912                        )
913                        .map_err(err_hdlr)?;
914                    }
915                }
916
917                writeln!(writer, "DATA_STOP\n").map_err(err_hdlr)?;
918            }
919        }
920
921        #[allow(clippy::writeln_empty_string)]
922        writeln!(writer, "").map_err(err_hdlr)?;
923
924        // Return the path this was written to
925        let tock_time = Epoch::now().unwrap() - tick;
926        info!("CCSDS TDM written to {} in {tock_time}", path_buf.display());
927        Ok(path_buf)
928    }
929}
930
931fn convert_range_units(
932    value: f64,
933    range_units: &str,
934    divider: f64,
935) -> Result<f64, InputOutputError> {
936    match range_units {
937        "km" => Ok(value / divider),
938        "RU" => Err(InputOutputError::UnsupportedData {
939            which: "RANGE_UNITS `RU` requires mission-specific conversion and is not currently supported".to_string(),
940        }),
941        "s" => Ok((value * SPEED_OF_LIGHT_KM_S) / divider),
942        "m" => {
943            warn!(
944                "RANGE_UNITS in TDM file is `m`, which is not CCSDS compliant. Proceeding with conversion to km."
945            );
946            Ok((value / 1000.0) / divider)
947        }
948        "ms" => {
949            warn!(
950                "RANGE_UNITS in TDM file is `ms`, which is not CCSDS compliant. Proceeding with conversion to km."
951            );
952            Ok((value * 1e-3 * SPEED_OF_LIGHT_KM_S) / divider)
953        }
954        "us" => {
955            warn!(
956                "RANGE_UNITS in TDM file is `us`, which is not CCSDS compliant. Proceeding with conversion to km."
957            );
958            Ok((value * 1e-6 * SPEED_OF_LIGHT_KM_S) / divider)
959        }
960        "ns" | "NANOSEC" => {
961            warn!(
962                "RANGE_UNITS in TDM file is `ns`, which is not CCSDS compliant. Proceeding with conversion to km."
963            );
964            Ok((value * 1e-9 * SPEED_OF_LIGHT_KM_S) / divider)
965        }
966        _ => Err(InputOutputError::UnsupportedData {
967            which: format!("unsupported RANGE_UNITS `{range_units}`"),
968        }),
969    }
970}
971
972fn parse_measurement_line(
973    line: &str,
974    time_system: TimeScale,
975) -> Result<Option<(MeasurementType, Epoch, f64)>, InputOutputError> {
976    let parts: Vec<&str> = line.split('=').collect();
977    if parts.len() != 2 {
978        return Ok(None);
979    }
980
981    let (mtype_str, data) = (parts[0].trim(), parts[1].trim());
982    let mtype = match mtype_str {
983        "RANGE" => MeasurementType::Range,
984        "DOPPLER_INSTANTANEOUS" | "DOPPLER_INTEGRATED" => MeasurementType::Doppler,
985        "ANGLE_1" => MeasurementType::Azimuth,
986        "ANGLE_2" => MeasurementType::Elevation,
987        "RECEIVE_FREQ" | "RECEIVE_FREQ_1" | "RECEIVE_FREQ_2" | "RECEIVE_FREQ_3"
988        | "RECEIVE_FREQ_4" | "RECEIVE_FREQ_5" => MeasurementType::ReceiveFrequency,
989        "TRANSMIT_FREQ" | "TRANSMIT_FREQ_1" | "TRANSMIT_FREQ_2" | "TRANSMIT_FREQ_3"
990        | "TRANSMIT_FREQ_4" | "TRANSMIT_FREQ_5" => MeasurementType::TransmitFrequency,
991        "TRANSMIT_FREQ_RATE"
992        | "TRANSMIT_FREQ_RATE_1"
993        | "TRANSMIT_FREQ_RATE_2"
994        | "TRANSMIT_FREQ_RATE_3"
995        | "TRANSMIT_FREQ_RATE_4"
996        | "TRANSMIT_FREQ_RATE_5" => MeasurementType::TransmitFrequencyRate,
997        _ => {
998            return Err(InputOutputError::UnsupportedData {
999                which: mtype_str.to_string(),
1000            });
1001        }
1002    };
1003
1004    let data_parts: Vec<&str> = data.split_whitespace().collect();
1005    if data_parts.len() != 2 {
1006        return Ok(None);
1007    }
1008
1009    let epoch =
1010        Epoch::from_gregorian_str(&format!("{} {time_system}", data_parts[0])).map_err(|e| {
1011            InputOutputError::TDMError {
1012                msg: format!("{e} when parsing epoch"),
1013            }
1014        })?;
1015
1016    let value = data_parts[1]
1017        .parse::<f64>()
1018        .map_err(|e| InputOutputError::UnsupportedData {
1019            which: format!("`{}` is not a float: {e}", data_parts[1]),
1020        })?;
1021
1022    Ok(Some((mtype, epoch, value)))
1023}