use crate::radiometric::{Band, ObsKind, ObsWay, RadiometricObs};
use crate::timescales::{self, TwoPartJd};
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TdmMeta {
pub time_system: String,
pub participants: Vec<String>,
pub mode: String,
pub path: String,
pub transmit_band: Option<String>,
pub receive_band: Option<String>,
pub turnaround_numerator: Option<f64>,
pub turnaround_denominator: Option<f64>,
pub range_units: Option<String>,
pub other: Vec<(String, String)>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TdmObs {
pub epoch: String,
pub key: String,
pub value: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TdmSegment {
pub meta: TdmMeta,
pub data: Vec<TdmObs>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TdmFile {
pub version: String,
pub creation_date: String,
pub originator: String,
pub segments: Vec<TdmSegment>,
}
const DATA_KEYWORDS: &[&str] = &[
"RANGE",
"DOPPLER_INSTANTANEOUS",
"DOPPLER_INTEGRATED",
"ANGLE_1",
"ANGLE_2",
"TRANSMIT_FREQ_1",
"TRANSMIT_FREQ_2",
"TRANSMIT_FREQ_3",
"TRANSMIT_FREQ_4",
"TRANSMIT_FREQ_5",
"RECEIVE_FREQ",
"RECEIVE_FREQ_1",
"RECEIVE_FREQ_2",
"RECEIVE_FREQ_3",
"RECEIVE_FREQ_4",
"RECEIVE_FREQ_5",
"PR_N0",
"CN0",
"RECEIVE_PHASE_CT_1",
"TRANSMIT_PHASE_CT_1",
"CLOCK_BIAS",
"CLOCK_DRIFT",
"STEC",
"TROPO_DRY",
"TROPO_WET",
"PRESSURE",
"TEMPERATURE",
"HUMIDITY",
];
fn split_kvn(line: &str) -> Option<(&str, &str)> {
line.split_once('=').map(|(k, v)| (k.trim(), v.trim()))
}
impl TdmFile {
pub fn parse(text: &str) -> Result<TdmFile, String> {
let mut version: Option<String> = None;
let mut creation_date = String::new();
let mut originator = String::new();
let mut segments: Vec<TdmSegment> = Vec::new();
#[derive(PartialEq)]
enum State {
Header,
Meta,
Data,
}
let mut state = State::Header;
let mut cur_meta = TdmMeta::default();
let mut cur_data: Vec<TdmObs> = Vec::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
if line == "COMMENT" || line.starts_with("COMMENT ") || line.starts_with("COMMENT\t") {
continue;
}
match state {
State::Header => {
if line == "META_START" {
if version.is_none() {
return Err("TDM: META_START before CCSDS_TDM_VERS header".to_string());
}
cur_meta = TdmMeta::default();
state = State::Meta;
continue;
}
let (k, v) = split_kvn(line)
.ok_or_else(|| format!("TDM: malformed header line: {line:?}"))?;
match k {
"CCSDS_TDM_VERS" => version = Some(v.to_string()),
"CREATION_DATE" => creation_date = v.to_string(),
"ORIGINATOR" => originator = v.to_string(),
_ => {}
}
}
State::Meta => {
if line == "META_STOP" {
continue;
}
if line == "DATA_START" {
cur_data = Vec::new();
state = State::Data;
continue;
}
let (k, v) = split_kvn(line)
.ok_or_else(|| format!("TDM: malformed metadata line: {line:?}"))?;
apply_meta_key(&mut cur_meta, k, v)?;
}
State::Data => {
if line == "DATA_STOP" {
segments.push(TdmSegment {
meta: std::mem::take(&mut cur_meta),
data: std::mem::take(&mut cur_data),
});
state = State::Header;
continue;
}
let (k, v) = split_kvn(line)
.ok_or_else(|| format!("TDM: malformed data line: {line:?}"))?;
if !is_data_keyword(k) {
return Err(format!("TDM: unrecognised data keyword {k:?}"));
}
let mut it = v.split_whitespace();
let epoch = it
.next()
.ok_or_else(|| format!("TDM: data line {k:?} missing epoch"))?;
let val_str = it
.next()
.ok_or_else(|| format!("TDM: data line {k:?} missing value"))?;
let value = val_str
.parse::<f64>()
.map_err(|_| format!("TDM: data line {k:?} bad value {val_str:?}"))?;
cur_data.push(TdmObs {
epoch: epoch.to_string(),
key: k.to_string(),
value,
});
}
}
}
if state != State::Header {
return Err("TDM: unterminated segment (missing META_STOP/DATA_STOP)".to_string());
}
let version = version.ok_or_else(|| "TDM: missing CCSDS_TDM_VERS header".to_string())?;
Ok(TdmFile {
version,
creation_date,
originator,
segments,
})
}
pub fn to_tdm_string(&self) -> String {
let mut out = String::new();
out.push_str(&format!("CCSDS_TDM_VERS = {}\n", self.version));
out.push_str(&format!("CREATION_DATE = {}\n", self.creation_date));
out.push_str(&format!("ORIGINATOR = {}\n", self.originator));
for seg in &self.segments {
out.push('\n');
out.push_str("META_START\n");
out.push_str(&format!("TIME_SYSTEM = {}\n", seg.meta.time_system));
for (i, p) in seg.meta.participants.iter().enumerate() {
out.push_str(&format!("PARTICIPANT_{} = {}\n", i + 1, p));
}
if !seg.meta.mode.is_empty() {
out.push_str(&format!("MODE = {}\n", seg.meta.mode));
}
if !seg.meta.path.is_empty() {
out.push_str(&format!("PATH = {}\n", seg.meta.path));
}
if let Some(b) = &seg.meta.transmit_band {
out.push_str(&format!("TRANSMIT_BAND = {b}\n"));
}
if let Some(b) = &seg.meta.receive_band {
out.push_str(&format!("RECEIVE_BAND = {b}\n"));
}
if let Some(n) = seg.meta.turnaround_numerator {
out.push_str(&format!("TURNAROUND_NUMERATOR = {}\n", fmt_num(n)));
}
if let Some(d) = seg.meta.turnaround_denominator {
out.push_str(&format!("TURNAROUND_DENOMINATOR = {}\n", fmt_num(d)));
}
if let Some(u) = &seg.meta.range_units {
out.push_str(&format!("RANGE_UNITS = {u}\n"));
}
for (k, v) in &seg.meta.other {
out.push_str(&format!("{k} = {v}\n"));
}
out.push_str("META_STOP\n");
out.push_str("DATA_START\n");
for obs in &seg.data {
out.push_str(&format!(
"{} = {} {}\n",
obs.key,
obs.epoch,
fmt_num(obs.value)
));
}
out.push_str("DATA_STOP\n");
}
out
}
pub fn to_radiometric_obs(&self) -> Vec<RadiometricObs> {
let mut out = Vec::new();
for seg in &self.segments {
let way = path_to_way(&seg.meta.path);
let band = seg
.meta
.transmit_band
.as_deref()
.or(seg.meta.receive_band.as_deref())
.and_then(parse_band);
let (Some(way), Some(band)) = (way, band) else {
continue;
};
for obs in &seg.data {
let kind = match obs.key.as_str() {
"RANGE" => ObsKind::Range,
"DOPPLER_INSTANTANEOUS" | "DOPPLER_INTEGRATED" => ObsKind::Doppler,
_ => continue,
};
let Some(epoch) = parse_epoch_to_tdb(&obs.epoch, &seg.meta.time_system) else {
continue;
};
let value = obs.value * 1000.0;
out.push(RadiometricObs {
kind,
way,
band,
epoch,
value,
sigma: 0.0,
});
}
}
out
}
}
fn apply_meta_key(meta: &mut TdmMeta, key: &str, value: &str) -> Result<(), String> {
match key {
"TIME_SYSTEM" => meta.time_system = value.to_string(),
"MODE" => meta.mode = value.to_string(),
"PATH" => meta.path = value.to_string(),
"TRANSMIT_BAND" => meta.transmit_band = Some(value.to_string()),
"RECEIVE_BAND" => meta.receive_band = Some(value.to_string()),
"TURNAROUND_NUMERATOR" => {
meta.turnaround_numerator = Some(
value
.parse()
.map_err(|_| format!("TDM: bad TURNAROUND_NUMERATOR {value:?}"))?,
)
}
"TURNAROUND_DENOMINATOR" => {
meta.turnaround_denominator = Some(
value
.parse()
.map_err(|_| format!("TDM: bad TURNAROUND_DENOMINATOR {value:?}"))?,
)
}
"RANGE_UNITS" => meta.range_units = Some(value.to_string()),
_ if key.starts_with("PARTICIPANT_") => {
if let Ok(n) = key["PARTICIPANT_".len()..].parse::<usize>() {
if n >= 1 {
if meta.participants.len() < n {
meta.participants.resize(n, String::new());
}
meta.participants[n - 1] = value.to_string();
}
}
}
_ => meta.other.push((key.to_string(), value.to_string())),
}
Ok(())
}
fn is_data_keyword(key: &str) -> bool {
DATA_KEYWORDS.contains(&key)
}
fn fmt_num(x: f64) -> String {
if x.fract() == 0.0 && x.abs() < 1e15 {
format!("{}", x as i64)
} else {
format!("{x}")
}
}
fn parse_band(s: &str) -> Option<Band> {
match s.to_ascii_uppercase().as_str() {
"S" => Some(Band::S),
"X" => Some(Band::X),
"KA" => Some(Band::Ka),
_ => None,
}
}
fn path_to_way(path: &str) -> Option<ObsWay> {
let parts: Vec<&str> = path
.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
.collect();
match parts.len() {
2 => Some(ObsWay::One),
n if n >= 3 => {
if parts.first() == parts.last() {
Some(ObsWay::Two)
} else {
Some(ObsWay::Three)
}
}
_ => None,
}
}
fn parse_epoch_to_tdb(epoch: &str, time_system: &str) -> Option<TwoPartJd> {
let (date, time) = epoch.split_once('T')?;
let d: Vec<&str> = date.split('-').collect();
if d.len() != 3 {
return None;
}
let year: i32 = d[0].parse().ok()?;
let month: u32 = d[1].parse().ok()?;
let day: u32 = d[2].parse().ok()?;
let time = time.strip_suffix('Z').unwrap_or(time);
let t: Vec<&str> = time.split(':').collect();
if t.len() != 3 {
return None;
}
let hour: u32 = t[0].parse().ok()?;
let minute: u32 = t[1].parse().ok()?;
let second: f64 = t[2].parse().ok()?;
let jd_in = timescales::julian_date(year, month, day, hour, minute, second);
let jd_tdb = match time_system.to_ascii_uppercase().as_str() {
"UTC" => timescales::tt_to_tdb(timescales::utc_to_tt(jd_in)),
"TAI" => timescales::tt_to_tdb(timescales::tai_to_tt(jd_in)),
"GPS" => timescales::tt_to_tdb(timescales::gps_to_tt(jd_in)),
"TT" => timescales::tt_to_tdb(jd_in),
"TDB" => jd_in,
_ => return None,
};
Some(TwoPartJd::from_f64(jd_tdb))
}
#[cfg(test)]
mod tests {
use super::*;
const REFERENCE_TDM: &str = include_str!("../tests/fixtures/deepspace/reference.tdm");
#[test]
fn tdm_parse_reference() {
let f = TdmFile::parse(REFERENCE_TDM).expect("reference.tdm parses");
assert_eq!(f.version, "2.0");
assert_eq!(f.creation_date, "2026-06-14T00:00:00.000");
assert_eq!(f.originator, "KSHANA");
assert_eq!(f.segments.len(), 1);
let seg = &f.segments[0];
assert_eq!(seg.meta.time_system, "UTC");
assert_eq!(seg.meta.participants, vec!["DSS-25", "SPACECRAFT"]);
assert_eq!(seg.meta.mode, "SEQUENTIAL");
assert_eq!(seg.meta.path, "1,2,1");
assert_eq!(seg.meta.transmit_band.as_deref(), Some("X"));
assert_eq!(seg.meta.receive_band.as_deref(), Some("X"));
assert_eq!(seg.meta.turnaround_numerator, Some(880.0));
assert_eq!(seg.meta.turnaround_denominator, Some(749.0));
assert_eq!(seg.meta.range_units.as_deref(), Some("km"));
assert!(seg
.meta
.other
.iter()
.any(|(k, v)| k == "START_TIME" && v == "2026-06-14T01:00:00.000"));
assert!(seg
.meta
.other
.iter()
.any(|(k, _)| k == "INTEGRATION_INTERVAL"));
assert_eq!(seg.data.len(), 6);
assert_eq!(seg.data.iter().filter(|o| o.key == "RANGE").count(), 3);
assert_eq!(
seg.data
.iter()
.filter(|o| o.key == "DOPPLER_INTEGRATED")
.count(),
3
);
let first_range = seg.data.iter().find(|o| o.key == "RANGE").unwrap();
assert_eq!(first_range.epoch, "2026-06-14T01:00:00.000");
assert!(
(first_range.value - 1.234_567_890_123_45e8).abs() < 1.0,
"first RANGE value {} km off",
first_range.value
);
}
#[test]
fn tdm_roundtrip() {
let f1 = TdmFile::parse(REFERENCE_TDM).expect("parse 1");
let text = f1.to_tdm_string();
let f2 = TdmFile::parse(&text).expect("emitted TDM re-parses");
assert_eq!(f1, f2, "round-trip changed the parsed structure");
assert!(text.starts_with("CCSDS_TDM_VERS = 2.0\n"));
assert_eq!(text.matches("META_START").count(), 1);
assert_eq!(text.matches("META_STOP").count(), 1);
assert_eq!(text.matches("DATA_START").count(), 1);
assert_eq!(text.matches("DATA_STOP").count(), 1);
assert!(text.contains("RANGE = 2026-06-14T01:00:00.000"));
assert!(text.contains("TURNAROUND_NUMERATOR = 880\n"));
}
#[test]
fn tdm_to_radiometric() {
let f = TdmFile::parse(REFERENCE_TDM).expect("parse");
let obs = f.to_radiometric_obs();
assert_eq!(obs.len(), 6);
let ranges: Vec<&RadiometricObs> =
obs.iter().filter(|o| o.kind == ObsKind::Range).collect();
let dopplers: Vec<&RadiometricObs> =
obs.iter().filter(|o| o.kind == ObsKind::Doppler).collect();
assert_eq!(ranges.len(), 3);
assert_eq!(dopplers.len(), 3);
for o in &obs {
assert_eq!(o.way, ObsWay::Two);
assert_eq!(o.band, Band::X);
assert_eq!(o.sigma, 0.0);
}
assert!(
(ranges[0].value - 1.234_567_890_123_45e11).abs() < 1e3,
"range value {} m off",
ranges[0].value
);
assert!(
(dopplers[0].value - (-1_234.567_89)).abs() < 1e-3,
"doppler value {} m/s off",
dopplers[0].value
);
}
#[test]
fn path_to_way_classifies_links() {
assert_eq!(path_to_way("1,2"), Some(ObsWay::One));
assert_eq!(path_to_way("1,2,1"), Some(ObsWay::Two));
assert_eq!(path_to_way("1,2,3"), Some(ObsWay::Three));
assert_eq!(path_to_way(""), None);
assert_eq!(path_to_way("1"), None);
}
#[test]
fn parse_band_recognises_deep_space_bands() {
assert_eq!(parse_band("S"), Some(Band::S));
assert_eq!(parse_band("X"), Some(Band::X));
assert_eq!(parse_band("KA"), Some(Band::Ka));
assert_eq!(parse_band("ka"), Some(Band::Ka));
assert_eq!(parse_band("L"), None);
}
#[test]
fn multi_segment_parse_and_count() {
let text = "\
CCSDS_TDM_VERS = 2.0
CREATION_DATE = 2026-06-14T00:00:00.000
ORIGINATOR = KSHANA
META_START
TIME_SYSTEM = UTC
PARTICIPANT_1 = DSS-25
PARTICIPANT_2 = SC
MODE = SEQUENTIAL
PATH = 1,2,1
TRANSMIT_BAND = X
RECEIVE_BAND = X
RANGE_UNITS = km
META_STOP
DATA_START
RANGE = 2026-06-14T01:00:00.000 1.0E+08
DATA_STOP
META_START
TIME_SYSTEM = UTC
PARTICIPANT_1 = SC
PARTICIPANT_2 = DSS-25
MODE = SEQUENTIAL
PATH = 1,2
RECEIVE_BAND = S
META_STOP
DATA_START
DOPPLER_INSTANTANEOUS = 2026-06-14T02:00:00.000 -0.5
DATA_STOP
";
let f = TdmFile::parse(text).expect("multi-segment parses");
assert_eq!(f.segments.len(), 2);
assert_eq!(f.segments[0].data.len(), 1);
assert_eq!(f.segments[1].data.len(), 1);
let obs = f.to_radiometric_obs();
assert_eq!(obs.len(), 2);
assert_eq!(obs[0].kind, ObsKind::Range);
assert_eq!(obs[0].way, ObsWay::Two);
assert_eq!(obs[0].band, Band::X);
assert_eq!(obs[1].kind, ObsKind::Doppler);
assert_eq!(obs[1].way, ObsWay::One);
assert_eq!(obs[1].band, Band::S);
}
#[test]
fn missing_version_header_is_an_error() {
let text = "CREATION_DATE = 2026-06-14T00:00:00.000\nORIGINATOR = KSHANA\n";
assert!(TdmFile::parse(text).is_err());
}
#[test]
fn unterminated_segment_is_an_error() {
let text = "\
CCSDS_TDM_VERS = 2.0
CREATION_DATE = 2026-06-14T00:00:00.000
ORIGINATOR = KSHANA
META_START
TIME_SYSTEM = UTC
PARTICIPANT_1 = DSS-25
PATH = 1,2,1
META_STOP
DATA_START
RANGE = 2026-06-14T01:00:00.000 1.0E+08
";
assert!(TdmFile::parse(text).is_err());
}
#[test]
fn comments_and_blank_lines_are_tolerated() {
let text = "\
CCSDS_TDM_VERS = 2.0
COMMENT a header note
CREATION_DATE = 2026-06-14T00:00:00.000
ORIGINATOR = KSHANA
META_START
COMMENT a metadata note
TIME_SYSTEM = UTC
PARTICIPANT_1 = DSS-25
PARTICIPANT_2 = SC
PATH = 1,2,1
TRANSMIT_BAND = X
META_STOP
DATA_START
COMMENT a data note
RANGE = 2026-06-14T01:00:00.000 1.0E+08
DATA_STOP
";
let f = TdmFile::parse(text).expect("comments tolerated");
assert_eq!(f.segments.len(), 1);
assert_eq!(f.segments[0].data.len(), 1);
}
}