1use 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 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 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 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 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 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 for freq in &freq_types {
212 measurement.data.swap_remove(freq);
213 }
214 continue;
215 }
216
217 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 let doppler_shift_hz = transmit_freq_hz * turnaround_ratio.unwrap() - receive_freq_hz;
239 let rho_dot_km_s = (doppler_shift_hz * SPEED_OF_LIGHT_KM_S)
241 / (2.0 * transmit_freq_hz * turnaround_ratio.unwrap());
242
243 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 segment_measurements.retain(|m| !m.data.is_empty());
265
266 measurements.append(segment_measurements);
267
268 Ok(())
269}
270
271impl TrackingDataArc {
272 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 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(¤t_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 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 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 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 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 let iso8601_no_ts = Format::from_str("%Y-%m-%dT%H:%M:%S.%f").unwrap();
749
750 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 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 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 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 writeln!(writer, "DATA_START").map_err(err_hdlr)?;
898
899 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 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}