nyx_space/od/msr/trackingdata/
io_ccsds_tdm.rs1use crate::io::ExportCfg;
20use crate::io::watermark::prj_name_ver;
21use crate::io::{InputOutputError, StdIOSnafu};
22use crate::od::msr::{Measurement, MeasurementType};
23use anise::constants::SPEED_OF_LIGHT_KM_S;
24use hifitime::efmt::{Format, Formatter};
25use hifitime::{Duration, Epoch, TimeScale};
26use indexmap::{IndexMap, IndexSet};
27use log::{error, info, warn};
28use snafu::ResultExt;
29use std::collections::HashMap;
30use std::fs::File;
31use std::io::Write;
32use std::io::{BufRead, BufReader, BufWriter};
33use std::path::{Path, PathBuf};
34use std::str::FromStr;
35
36use super::TrackingDataArc;
37
38impl TrackingDataArc {
39 pub fn from_tdm<P: AsRef<Path>>(
87 path: P,
88 aliases: Option<HashMap<String, String>>,
89 ) -> Result<Self, InputOutputError> {
90 let file = File::open(&path).context(StdIOSnafu {
91 action: "opening CCSDS TDM file for tracking arc",
92 })?;
93
94 let source = path.as_ref().to_path_buf().display().to_string();
95 info!("parsing CCSDS TDM {source}");
96
97 let mut measurements = Vec::new();
98 let mut metadata = HashMap::new();
99
100 let reader = BufReader::new(file);
101
102 let mut in_data_section = false;
103 let mut current_tracker = String::new();
104 let mut time_system = TimeScale::UTC;
105 let mut has_freq_data = false;
106 let mut msr_divider = 1.0;
107
108 for line in reader.lines() {
109 let line = line.context(StdIOSnafu {
110 action: "reading CCSDS TDM file",
111 })?;
112 let line = line.trim();
113
114 if line == "DATA_START" {
115 in_data_section = true;
116 continue;
117 } else if line == "DATA_STOP" {
118 in_data_section = false;
119 }
120
121 if !in_data_section {
122 if line.starts_with("PARTICIPANT_1") {
123 current_tracker = line.split('=').nth(1).unwrap_or("").trim().to_string();
124 if let Some(aliases) = &aliases
126 && let Some(alias) = aliases.get(¤t_tracker)
127 {
128 current_tracker = alias.clone();
129 }
130 } else if line.starts_with("TIME_SYSTEM") {
131 let ts = line.split('=').nth(1).unwrap_or("UTC").trim();
132 if let Ok(ts) = TimeScale::from_str(ts) {
134 time_system = ts;
135 } else {
136 return Err(InputOutputError::UnsupportedData {
137 which: format!("time scale `{ts}` not supported"),
138 });
139 }
140 } else if line.starts_with("PATH") {
141 match line.split(",").count() {
142 2 => msr_divider = 1.0,
143 3 => msr_divider = 2.0,
144 cnt => {
145 return Err(InputOutputError::UnsupportedData {
146 which: format!(
147 "found {cnt} paths in TDM, only 1 or 2 are supported"
148 ),
149 });
150 }
151 }
152 }
153
154 let mut splt = line.split('=');
155 if let Some(keyword) = splt.nth(0) {
156 if let Some(value) = splt.nth(0) {
158 metadata.insert(keyword.trim().to_string(), value.trim().to_string());
159 }
160 }
161
162 continue;
163 }
164
165 if let Some((mtype, epoch, value)) = parse_measurement_line(line, time_system)? {
166 let effective_divider = if [
169 MeasurementType::ReceiveFrequency,
170 MeasurementType::TransmitFrequency,
171 MeasurementType::TransmitFrequencyRate,
172 ]
173 .contains(&mtype)
174 {
175 has_freq_data = true;
176 1.0
177 } else {
178 msr_divider
179 };
180
181 let scaled_value = value / effective_divider;
182
183 let is_concurrent = measurements.last().is_some_and(|last: &Measurement| {
186 last.epoch == epoch && last.tracker == current_tracker
187 });
188
189 if is_concurrent {
190 measurements
191 .last_mut()
192 .unwrap() .data
194 .insert(mtype, scaled_value);
195 } else {
196 let mut data = IndexMap::new();
198 data.insert(mtype, scaled_value);
199
200 measurements.push(Measurement {
201 tracker: current_tracker.clone(),
202 epoch,
203 data,
204 rejected: false,
205 });
206 }
207 }
208 }
209
210 let mut turnaround_ratio = None;
211 let drop_freq_data;
212 if has_freq_data {
213 if let Some(ta_num_str) = metadata.get("TURNAROUND_NUMERATOR") {
215 if let Some(ta_denom_str) = metadata.get("TURNAROUND_DENOMINATOR") {
216 if let Ok(ta_num) = ta_num_str.parse::<i32>() {
217 if let Ok(ta_denom) = ta_denom_str.parse::<i32>() {
218 turnaround_ratio = Some(f64::from(ta_num) / f64::from(ta_denom));
220 info!("turn-around ratio is {ta_num}/{ta_denom}");
221 drop_freq_data = false;
222 } else {
223 error!(
224 "turn-around denominator `{ta_denom_str}` is not a valid integer"
225 );
226 drop_freq_data = true;
227 }
228 } else {
229 error!("turn-around numerator `{ta_num_str}` is not a valid integer");
230 drop_freq_data = true;
231 }
232 } else {
233 error!(
234 "required turn-around denominator missing from metadata -- dropping ALL RECEIVE/TRANSMIT data"
235 );
236 drop_freq_data = true;
237 }
238 } else {
239 error!(
240 "required turn-around numerator missing from metadata -- dropping ALL RECEIVE/TRANSMIT data"
241 );
242 drop_freq_data = true;
243 }
244 } else {
245 drop_freq_data = true;
246 }
247
248 let corrections_applied = if let Some(corr_flag) = metadata.get("CORRECTIONS_APPLIED") {
249 match corr_flag.trim().to_lowercase().as_str() {
250 "no" => false,
251 "yes" => true,
252 _ => {
253 warn!("invalid CORRECTIONS_APPLIED `{corr_flag}`");
254 true
255 }
256 }
257 } else {
258 true
259 };
260
261 let mut freq_types = IndexSet::new();
264 freq_types.insert(MeasurementType::ReceiveFrequency);
265 freq_types.insert(MeasurementType::TransmitFrequency);
266 freq_types.insert(MeasurementType::TransmitFrequencyRate);
267
268 let mut latest_transmit_freq = None;
269 let mut latest_transmit_epoch = None;
270 let mut latest_transmit_rate = 0.0;
271
272 let mut all_applied_corrections = IndexSet::new();
273
274 for measurement in &mut measurements {
275 let epoch = measurement.epoch;
276 if !corrections_applied {
278 for msr_type in [
279 MeasurementType::Range,
280 MeasurementType::Doppler,
281 MeasurementType::Azimuth,
282 MeasurementType::Elevation,
283 MeasurementType::ReceiveFrequency,
284 MeasurementType::TransmitFrequency,
285 MeasurementType::TransmitFrequencyRate,
286 ] {
287 let kw = format!("CORRECTION_{}", msr_type.ccsds_tdm_name());
288 if let Some(correction_str) = metadata.get(&kw) {
289 if let Ok(correction) = correction_str.parse::<f64>() {
290 measurement.correct(msr_type, correction);
291 all_applied_corrections.insert(msr_type);
292 } else {
293 warn!("invalid correction value for {kw}");
294 }
295 }
296 }
297 }
298
299 if drop_freq_data {
300 for freq in &freq_types {
301 measurement.data.swap_remove(freq);
302 }
303 continue;
304 }
305
306 if let Some(rate) = measurement
308 .data
309 .get(&MeasurementType::TransmitFrequencyRate)
310 {
311 if let (Some(last_f), Some(last_e)) = (latest_transmit_freq, latest_transmit_epoch)
312 {
313 let dt: Duration = epoch - last_e;
314 latest_transmit_freq = Some(last_f + latest_transmit_rate * dt.to_seconds());
315 }
316 latest_transmit_epoch = Some(epoch);
317 latest_transmit_rate = *rate;
318 }
319
320 if let Some(freq) = measurement.data.get(&MeasurementType::TransmitFrequency) {
321 latest_transmit_freq = Some(*freq);
322 latest_transmit_epoch = Some(epoch);
323 }
324
325 if !measurement
326 .data
327 .contains_key(&MeasurementType::ReceiveFrequency)
328 {
329 for freq in &freq_types {
332 measurement.data.swap_remove(freq);
333 }
334 continue;
335 }
336
337 if latest_transmit_freq.is_none() {
339 warn!(
340 "receive frequency found at {epoch} but no transmit frequency was ever set, ignoring"
341 );
342 for freq in &freq_types {
343 measurement.data.swap_remove(freq);
344 }
345 continue;
346 }
347
348 let dt: Duration = epoch - latest_transmit_epoch.unwrap();
349 let transmit_freq_hz =
350 latest_transmit_freq.unwrap() + latest_transmit_rate * dt.to_seconds();
351
352 let receive_freq_hz = *measurement
353 .data
354 .get(&MeasurementType::ReceiveFrequency)
355 .unwrap();
356
357 let doppler_shift_hz = transmit_freq_hz * turnaround_ratio.unwrap() - receive_freq_hz;
359 let rho_dot_km_s = (doppler_shift_hz * SPEED_OF_LIGHT_KM_S)
361 / (2.0 * transmit_freq_hz * turnaround_ratio.unwrap());
362
363 for freq in &freq_types {
365 measurement.data.swap_remove(freq);
366 }
367 measurement
368 .data
369 .insert(MeasurementType::Doppler, rho_dot_km_s);
370 }
371
372 if !all_applied_corrections.is_empty() {
373 info!("applied corrections for {all_applied_corrections:?}");
374 }
375
376 let moduli = if let Some(range_modulus) = metadata.get("RANGE_MODULUS") {
377 if let Ok(value) = range_modulus.parse::<f64>() {
378 if value > 0.0 {
379 let mut modulos = IndexMap::new();
380 modulos.insert(MeasurementType::Range, value);
381 Some(modulos)
383 } else {
384 None
386 }
387 } else {
388 warn!("could not parse RANGE_MODULUS of `{range_modulus}` as a double");
389 None
390 }
391 } else {
392 None
393 };
394
395 measurements.retain(|m| !m.data.is_empty());
397
398 let mut trk = Self {
399 measurements,
400 source: Some(source),
401 moduli,
402 force_reject: false,
403 };
404
405 trk.sort();
407
408 if trk.unique_types().is_empty() {
409 Err(InputOutputError::EmptyDataset {
410 action: "CCSDS TDM file",
411 })
412 } else {
413 Ok(trk)
414 }
415 }
416
417 pub fn to_tdm_file<P: AsRef<Path>>(
419 mut self,
420 spacecraft_name: String,
421 aliases: Option<HashMap<String, String>>,
422 path: P,
423 cfg: ExportCfg,
424 ) -> Result<PathBuf, InputOutputError> {
425 if self.is_empty() {
426 return Err(InputOutputError::MissingData {
427 which: " - empty tracking data cannot be exported to TDM".to_string(),
428 });
429 }
430
431 if let Some(start_epoch) = cfg.start_epoch {
433 if let Some(end_epoch) = cfg.end_epoch {
434 self = self.filter_by_epoch(start_epoch..end_epoch);
435 } else {
436 self = self.filter_by_epoch(start_epoch..);
437 }
438 } else if let Some(end_epoch) = cfg.end_epoch {
439 self = self.filter_by_epoch(..end_epoch);
440 }
441
442 let tick = Epoch::now().unwrap();
443 info!("Exporting tracking data to CCSDS TDM file...");
444
445 let path_buf = cfg.actual_path(path);
447
448 let metadata = cfg.metadata.unwrap_or_default();
449
450 let file = File::create(&path_buf).context(StdIOSnafu {
451 action: "creating CCSDS TDM file for tracking arc",
452 })?;
453 let mut writer = BufWriter::new(file);
454
455 let err_hdlr = |source| InputOutputError::StdIOError {
456 source,
457 action: "writing data to TDM file",
458 };
459
460 let iso8601_no_ts = Format::from_str("%Y-%m-%dT%H:%M:%S.%f").unwrap();
462
463 writeln!(writer, "CCSDS_TDM_VERS = 2.0").map_err(err_hdlr)?;
465 writeln!(
466 writer,
467 "\nCOMMENT Build by {} -- https://nyxspace.com",
468 prj_name_ver()
469 )
470 .map_err(err_hdlr)?;
471 writeln!(
472 writer,
473 "COMMENT Nyx Space provided under the AGPL v3 open source license -- https://nyxspace.com/pricing\n"
474 )
475 .map_err(err_hdlr)?;
476 writeln!(
477 writer,
478 "CREATION_DATE = {}",
479 Formatter::new(Epoch::now().unwrap(), iso8601_no_ts)
480 )
481 .map_err(err_hdlr)?;
482 writeln!(
483 writer,
484 "ORIGINATOR = {}\n",
485 metadata
486 .get("originator")
487 .unwrap_or(&"Nyx Space".to_string())
488 )
489 .map_err(err_hdlr)?;
490
491 let trackers = self.unique_aliases();
494
495 for tracker in trackers {
496 let tracker_data = self.clone().filter_by_tracker(tracker.clone());
497
498 let types = tracker_data.unique_types();
499
500 let two_way_types = types
501 .iter()
502 .filter(|msr_type| msr_type.may_be_two_way())
503 .copied()
504 .collect::<Vec<_>>();
505
506 let one_way_types = types
507 .iter()
508 .filter(|msr_type| !msr_type.may_be_two_way())
509 .copied()
510 .collect::<Vec<_>>();
511
512 for (tno, types) in [two_way_types, one_way_types].iter().enumerate() {
514 writeln!(writer, "META_START").map_err(err_hdlr)?;
515 writeln!(writer, "\tTIME_SYSTEM = UTC").map_err(err_hdlr)?;
516 writeln!(
517 writer,
518 "\tSTART_TIME = {}",
519 Formatter::new(tracker_data.start_epoch().unwrap(), iso8601_no_ts)
520 )
521 .map_err(err_hdlr)?;
522 writeln!(
523 writer,
524 "\tSTOP_TIME = {}",
525 Formatter::new(tracker_data.end_epoch().unwrap(), iso8601_no_ts)
526 )
527 .map_err(err_hdlr)?;
528
529 let multiplier = if tno == 0 {
530 writeln!(writer, "\tPATH = 1,2,1").map_err(err_hdlr)?;
531 2.0
532 } else {
533 writeln!(writer, "\tPATH = 1,2").map_err(err_hdlr)?;
534 1.0
535 };
536
537 writeln!(
538 writer,
539 "\tPARTICIPANT_1 = {}",
540 if let Some(aliases) = &aliases {
541 if let Some(alias) = aliases.get(&tracker) {
542 alias
543 } else {
544 &tracker
545 }
546 } else {
547 &tracker
548 }
549 )
550 .map_err(err_hdlr)?;
551
552 writeln!(writer, "\tPARTICIPANT_2 = {spacecraft_name}").map_err(err_hdlr)?;
553
554 writeln!(writer, "\tMODE = SEQUENTIAL").map_err(err_hdlr)?;
555
556 for (k, v) in &metadata {
558 if k != "originator" {
559 writeln!(writer, "\t{k} = {v}").map_err(err_hdlr)?;
560 }
561 }
562
563 if types.contains(&MeasurementType::Range) {
564 writeln!(writer, "\tRANGE_UNITS = km").map_err(err_hdlr)?;
565
566 if let Some(moduli) = &self.moduli
567 && let Some(range_modulus) = moduli.get(&MeasurementType::Range)
568 {
569 writeln!(writer, "\tRANGE_MODULUS = {range_modulus:E}")
570 .map_err(err_hdlr)?;
571 }
572 }
573
574 if types.contains(&MeasurementType::Azimuth)
575 || types.contains(&MeasurementType::Elevation)
576 {
577 writeln!(writer, "\tANGLE_TYPE = AZEL").map_err(err_hdlr)?;
578 }
579
580 writeln!(writer, "META_STOP\n").map_err(err_hdlr)?;
581
582 writeln!(writer, "DATA_START").map_err(err_hdlr)?;
584
585 for m in &tracker_data.measurements {
587 for (mtype, value) in &m.data {
588 if !types.contains(mtype) {
589 continue;
590 }
591
592 writeln!(
593 writer,
594 "\t{:<20} = {:<23}\t{:.12}",
595 mtype.ccsds_tdm_name(),
596 Formatter::new(m.epoch, iso8601_no_ts),
597 value * multiplier
598 )
599 .map_err(err_hdlr)?;
600 }
601 }
602
603 writeln!(writer, "DATA_STOP\n").map_err(err_hdlr)?;
604 }
605 }
606
607 #[allow(clippy::writeln_empty_string)]
608 writeln!(writer, "").map_err(err_hdlr)?;
609
610 let tock_time = Epoch::now().unwrap() - tick;
612 info!("CCSDS TDM written to {} in {tock_time}", path_buf.display());
613 Ok(path_buf)
614 }
615}
616
617fn parse_measurement_line(
618 line: &str,
619 time_system: TimeScale,
620) -> Result<Option<(MeasurementType, Epoch, f64)>, InputOutputError> {
621 let parts: Vec<&str> = line.split('=').collect();
622 if parts.len() != 2 {
623 return Ok(None);
624 }
625
626 let (mtype_str, data) = (parts[0].trim(), parts[1].trim());
627 let mtype = match mtype_str {
628 "RANGE" => MeasurementType::Range,
629 "DOPPLER_INSTANTANEOUS" | "DOPPLER_INTEGRATED" => MeasurementType::Doppler,
630 "ANGLE_1" => MeasurementType::Azimuth,
631 "ANGLE_2" => MeasurementType::Elevation,
632 "RECEIVE_FREQ" | "RECEIVE_FREQ_1" | "RECEIVE_FREQ_2" | "RECEIVE_FREQ_3"
633 | "RECEIVE_FREQ_4" | "RECEIVE_FREQ_5" => MeasurementType::ReceiveFrequency,
634 "TRANSMIT_FREQ" | "TRANSMIT_FREQ_1" | "TRANSMIT_FREQ_2" | "TRANSMIT_FREQ_3"
635 | "TRANSMIT_FREQ_4" | "TRANSMIT_FREQ_5" => MeasurementType::TransmitFrequency,
636 "TRANSMIT_FREQ_RATE"
637 | "TRANSMIT_FREQ_RATE_1"
638 | "TRANSMIT_FREQ_RATE_2"
639 | "TRANSMIT_FREQ_RATE_3"
640 | "TRANSMIT_FREQ_RATE_4"
641 | "TRANSMIT_FREQ_RATE_5" => MeasurementType::TransmitFrequencyRate,
642 _ => {
643 return Err(InputOutputError::UnsupportedData {
644 which: mtype_str.to_string(),
645 });
646 }
647 };
648
649 let data_parts: Vec<&str> = data.split_whitespace().collect();
650 if data_parts.len() != 2 {
651 return Ok(None);
652 }
653
654 let epoch =
655 Epoch::from_gregorian_str(&format!("{} {time_system}", data_parts[0])).map_err(|e| {
656 InputOutputError::Inconsistency {
657 msg: format!("{e} when parsing epoch"),
658 }
659 })?;
660
661 let value = data_parts[1]
662 .parse::<f64>()
663 .map_err(|e| InputOutputError::UnsupportedData {
664 which: format!("`{}` is not a float: {e}", data_parts[1]),
665 })?;
666
667 Ok(Some((mtype, epoch, value)))
668}