use std::fmt;
const VERSION_KEY: &str = "CCSDS_TDM_VERS";
const COMMENT_KEY: &str = "COMMENT";
#[derive(Debug, Clone, PartialEq)]
pub struct Tdm {
pub version: String,
pub comments: Vec<String>,
pub creation_date: Option<String>,
pub originator: Option<String>,
pub message_id: Option<String>,
pub header_fields: Vec<TdmField>,
pub segments: Vec<TdmSegment>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TdmSegment {
pub metadata: TdmMetadata,
pub data: TdmDataSection,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TdmField {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TdmMetadata {
pub comments: Vec<String>,
pub fields: Vec<TdmField>,
pub participants: Vec<TdmParticipant>,
pub mode: Option<String>,
pub paths: Vec<TdmPath>,
pub timetag_ref: Option<String>,
pub time_system: Option<String>,
pub range_units: TdmUnit,
}
impl TdmMetadata {
pub fn get_last(&self, key: &str) -> Option<&str> {
self.fields
.iter()
.rev()
.find(|field| field.key == key)
.map(|field| field.value.as_str())
.filter(|value| !value.is_empty())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TdmParticipant {
pub index: u8,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TdmPath {
pub key: String,
pub index: Option<u8>,
pub participants: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TdmDataSection {
pub comments: Vec<String>,
pub records: Vec<TdmDataRecord>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TdmDataRecord {
pub observable: TdmObservable,
pub keyword: String,
pub epoch: String,
pub value: TdmScalar,
pub unit: TdmUnit,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TdmScalar {
pub text: String,
pub value: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TdmObservable {
Range,
DopplerInstantaneous,
DopplerIntegrated,
ReceiveFreq {
participant: Option<u8>,
},
TransmitFreq {
participant: Option<u8>,
},
TransmitFreqRate {
participant: Option<u8>,
},
Angle1,
Angle2,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TdmUnit {
Kilometers,
Seconds,
RangeUnits,
KilometersPerSecond,
Hertz,
HertzPerSecond,
Degrees,
DecibelWatts,
DecibelHertz,
SquareMeters,
Meters,
SecondsPerSecond,
Percent,
Kelvin,
Hectopascals,
TotalElectronContentUnits,
Dimensionless,
Unknown(String),
}
impl TdmUnit {
pub fn as_str(&self) -> &str {
match self {
Self::Kilometers => "km",
Self::Seconds => "s",
Self::RangeUnits => "RU",
Self::KilometersPerSecond => "km/s",
Self::Hertz => "Hz",
Self::HertzPerSecond => "Hz/s",
Self::Degrees => "deg",
Self::DecibelWatts => "dBW",
Self::DecibelHertz => "dBHz",
Self::SquareMeters => "m**2",
Self::Meters => "m",
Self::SecondsPerSecond => "s/s",
Self::Percent => "%",
Self::Kelvin => "K",
Self::Hectopascals => "hPa",
Self::TotalElectronContentUnits => "TECU",
Self::Dimensionless => "n/a",
Self::Unknown(label) => label.as_str(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TdmInputErrorKind {
Missing,
FloatParse,
NonFinite,
NotPositive,
OutOfRange,
InvalidIndex,
UnknownKeyword,
UnexpectedUnit,
NonInteger,
Negative,
NegativeZero,
UnitMismatch,
DecimalMismatch,
}
impl fmt::Display for TdmInputErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
Self::Missing => "missing",
Self::FloatParse => "invalid float",
Self::NonFinite => "not finite",
Self::NotPositive => "not positive",
Self::OutOfRange => "out of range",
Self::InvalidIndex => "invalid index",
Self::UnknownKeyword => "unknown keyword",
Self::UnexpectedUnit => "unexpected unit",
Self::NonInteger => "not an integer",
Self::Negative => "negative",
Self::NegativeZero => "negative zero",
Self::UnitMismatch => "unit mismatch",
Self::DecimalMismatch => "decimal mismatch",
};
f.write_str(label)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TdmError {
MissingVersion,
NoSegments,
Section {
line: usize,
detail: &'static str,
},
MalformedLine {
line: usize,
text: String,
},
MalformedRecord {
line: usize,
keyword: String,
},
InvalidField {
field: String,
kind: TdmInputErrorKind,
},
}
impl fmt::Display for TdmError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingVersion => write!(f, "missing {VERSION_KEY}"),
Self::NoSegments => write!(f, "missing TDM segment"),
Self::Section { line, detail } => {
write!(f, "invalid TDM section at line {line}: {detail}")
}
Self::MalformedLine { line, text } => {
write!(f, "malformed TDM KVN line {line}: {text}")
}
Self::MalformedRecord { line, keyword } => {
write!(f, "malformed TDM data record {keyword} at line {line}")
}
Self::InvalidField { field, kind } => write!(f, "invalid TDM field {field}: {kind}"),
}
}
}
impl std::error::Error for TdmError {}
#[derive(Default)]
struct HeaderBuilder {
version: Option<String>,
comments: Vec<String>,
creation_date: Option<String>,
originator: Option<String>,
message_id: Option<String>,
fields: Vec<TdmField>,
}
#[derive(Default)]
struct MetadataBuilder {
comments: Vec<String>,
fields: Vec<TdmField>,
}
#[derive(Default)]
struct DataBuilder {
comments: Vec<String>,
records: Vec<TdmDataRecord>,
}
pub fn parse_kvn(text: &str) -> Result<Tdm, TdmError> {
let mut header = HeaderBuilder::default();
let mut metadata: Option<MetadataBuilder> = None;
let mut pending_metadata: Option<TdmMetadata> = None;
let mut data: Option<DataBuilder> = None;
let mut segments = Vec::new();
for (idx, raw_line) in text.lines().enumerate() {
let line_no = idx + 1;
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if let Some(comment) = comment_text(line) {
if let Some(builder) = data.as_mut() {
builder.comments.push(comment);
} else if let Some(builder) = metadata.as_mut() {
builder.comments.push(comment);
} else if pending_metadata.is_none() {
header.comments.push(comment);
} else {
return Err(TdmError::Section {
line: line_no,
detail: "comment between metadata and data",
});
}
continue;
}
match line {
"META_START" => {
if metadata.is_some() || data.is_some() || pending_metadata.is_some() {
return Err(TdmError::Section {
line: line_no,
detail: "nested metadata block",
});
}
metadata = Some(MetadataBuilder::default());
continue;
}
"META_STOP" => {
let builder = metadata.take().ok_or(TdmError::Section {
line: line_no,
detail: "metadata stop without metadata start",
})?;
pending_metadata = Some(build_metadata(builder)?);
continue;
}
"DATA_START" => {
if metadata.is_some() || data.is_some() || pending_metadata.is_none() {
return Err(TdmError::Section {
line: line_no,
detail: "data start without completed metadata",
});
}
data = Some(DataBuilder::default());
continue;
}
"DATA_STOP" => {
let builder = data.take().ok_or(TdmError::Section {
line: line_no,
detail: "data stop without data start",
})?;
let metadata = pending_metadata.take().ok_or(TdmError::Section {
line: line_no,
detail: "data stop without metadata",
})?;
segments.push(TdmSegment {
metadata,
data: TdmDataSection {
comments: builder.comments,
records: builder.records,
},
});
continue;
}
_ => {}
}
let (key, value) = parse_assignment(line).ok_or_else(|| TdmError::MalformedLine {
line: line_no,
text: line.to_string(),
})?;
if let Some(builder) = data.as_mut() {
let range_units = pending_metadata
.as_ref()
.map(|metadata| metadata.range_units.clone())
.unwrap_or(TdmUnit::Kilometers);
builder
.records
.push(parse_record(line_no, &key, &value, &range_units)?);
} else if let Some(builder) = metadata.as_mut() {
builder.fields.push(TdmField { key, value });
} else if pending_metadata.is_none() {
parse_header_field(&mut header, key, value);
} else {
return Err(TdmError::Section {
line: line_no,
detail: "field between metadata and data",
});
}
}
if metadata.is_some() {
return Err(TdmError::Section {
line: text.lines().count().saturating_add(1),
detail: "unclosed metadata block",
});
}
if data.is_some() {
return Err(TdmError::Section {
line: text.lines().count().saturating_add(1),
detail: "unclosed data block",
});
}
if pending_metadata.is_some() {
return Err(TdmError::Section {
line: text.lines().count().saturating_add(1),
detail: "metadata without data block",
});
}
let version = header
.version
.filter(|value| !value.is_empty())
.ok_or(TdmError::MissingVersion)?;
if segments.is_empty() {
return Err(TdmError::NoSegments);
}
Ok(Tdm {
version,
comments: header.comments,
creation_date: header.creation_date,
originator: header.originator,
message_id: header.message_id,
header_fields: header.fields,
segments,
})
}
pub fn encode_kvn(tdm: &Tdm) -> Result<String, TdmError> {
validate_tdm(tdm)?;
let mut lines = Vec::new();
lines.push(format!("{VERSION_KEY} = {}", tdm.version));
lines.extend(tdm.comments.iter().map(comment_line));
if let Some(creation_date) = &tdm.creation_date {
lines.push(format!("CREATION_DATE = {creation_date}"));
}
if let Some(originator) = &tdm.originator {
lines.push(format!("ORIGINATOR = {originator}"));
}
if let Some(message_id) = &tdm.message_id {
lines.push(format!("MESSAGE_ID = {message_id}"));
}
lines.extend(tdm.header_fields.iter().map(field_line));
for segment in &tdm.segments {
lines.push("META_START".to_string());
lines.extend(segment.metadata.comments.iter().map(comment_line));
lines.extend(segment.metadata.fields.iter().map(field_line));
lines.push("META_STOP".to_string());
lines.push("DATA_START".to_string());
lines.extend(segment.data.comments.iter().map(comment_line));
for record in &segment.data.records {
lines.push(format!(
"{} = {} {}",
record.keyword, record.epoch, record.value.text
));
}
lines.push("DATA_STOP".to_string());
}
Ok(lines.join("\n"))
}
fn parse_header_field(header: &mut HeaderBuilder, key: String, value: String) {
match key.as_str() {
VERSION_KEY => header.version = Some(value),
"CREATION_DATE" => header.creation_date = empty_to_none(value),
"ORIGINATOR" => header.originator = empty_to_none(value),
"MESSAGE_ID" => header.message_id = empty_to_none(value),
_ => header.fields.push(TdmField { key, value }),
}
}
fn build_metadata(builder: MetadataBuilder) -> Result<TdmMetadata, TdmError> {
let mut participants = Vec::new();
let mut mode = None;
let mut paths = Vec::new();
let mut timetag_ref = None;
let mut time_system = None;
let mut range_units = TdmUnit::Kilometers;
for field in &builder.fields {
if let Some(index) = indexed_suffix(&field.key, "PARTICIPANT")? {
participants.push(TdmParticipant {
index,
name: field.value.clone(),
});
} else if field.key == "MODE" {
mode = empty_to_none(field.value.clone());
} else if field.key == "PATH" || field.key.starts_with("PATH_") {
paths.push(parse_path(field)?);
} else if field.key == "TIMETAG_REF" {
timetag_ref = empty_to_none(field.value.clone());
} else if field.key == "TIME_SYSTEM" {
time_system = empty_to_none(field.value.clone());
} else if field.key == "RANGE_UNITS" && !field.value.is_empty() {
range_units = range_unit_from_label(&field.value)?;
}
}
Ok(TdmMetadata {
comments: builder.comments,
fields: builder.fields,
participants,
mode,
paths,
timetag_ref,
time_system,
range_units,
})
}
fn parse_path(field: &TdmField) -> Result<TdmPath, TdmError> {
let index = if field.key == "PATH" {
None
} else {
Some(indexed_suffix(&field.key, "PATH")?.ok_or_else(|| invalid_index(&field.key))?)
};
let mut participants = Vec::new();
for token in field.value.split(',') {
let trimmed = token.trim();
if trimmed.is_empty() {
return Err(invalid_index(&field.key));
}
let value = trimmed
.parse::<u8>()
.map_err(|_| invalid_index(&field.key))?;
participants.push(value);
}
if participants.is_empty() {
return Err(invalid_index(&field.key));
}
Ok(TdmPath {
key: field.key.clone(),
index,
participants,
})
}
fn parse_record(
line: usize,
keyword: &str,
value: &str,
range_units: &TdmUnit,
) -> Result<TdmDataRecord, TdmError> {
if has_displayed_unit(keyword) || has_displayed_unit(value) {
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::UnexpectedUnit,
});
}
let mut parts = value.split_whitespace();
let epoch = parts
.next()
.ok_or_else(|| malformed_record(line, keyword))?;
let value_text = parts
.next()
.ok_or_else(|| malformed_record(line, keyword))?;
if parts.next().is_some() {
return Err(malformed_record(line, keyword));
}
let observable = observable_from_keyword(keyword)?;
let scalar = parse_scalar(keyword, value_text, &observable)?;
validate_record_value(keyword, &observable, &scalar)?;
let unit = unit_for_keyword(keyword, &observable, range_units);
Ok(TdmDataRecord {
observable,
keyword: keyword.to_string(),
epoch: epoch.to_string(),
value: scalar,
unit,
})
}
fn parse_scalar(
field: &str,
text: &str,
observable: &TdmObservable,
) -> Result<TdmScalar, TdmError> {
if is_nonfinite_float_token(text) {
return Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::NonFinite,
});
}
validate_numeric_token(field, text, observable)?;
let value = text.parse::<f64>().map_err(|_| TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::FloatParse,
})?;
if !value.is_finite() {
return Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::NonFinite,
});
}
let lexical_zero = numeric_token_is_zero(text);
if !lexical_zero && decimal_magnitude_below_minimum_positive_double(text) {
return Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::OutOfRange,
});
}
if value == 0.0 && text.trim_start().starts_with('-') {
return Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::NegativeZero,
});
}
if value == 0.0 && !lexical_zero {
return Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::OutOfRange,
});
}
Ok(TdmScalar {
text: text.to_string(),
value,
})
}
fn is_nonfinite_float_token(text: &str) -> bool {
matches!(
text,
"NaN" | "+NaN" | "-NaN" | "Inf" | "+Inf" | "-Inf" | "Infinity" | "+Infinity" | "-Infinity"
)
}
fn validate_numeric_token(
field: &str,
text: &str,
observable: &TdmObservable,
) -> Result<(), TdmError> {
if matches!(observable, TdmObservable::Other(name) if name == "DOPPLER_COUNT") {
validate_integer_token(field, text)
} else if phase_count_keyword(field) {
validate_phase_count_token(field, text)
} else if is_ccsds_double_token(text) {
Ok(())
} else {
Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::FloatParse,
})
}
}
fn validate_integer_token(field: &str, text: &str) -> Result<(), TdmError> {
let digits = strip_ascii_sign(text);
if digits.is_empty() {
return Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::NonInteger,
});
}
if !digits.chars().all(|character| character.is_ascii_digit()) {
return Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::NonInteger,
});
}
let value = text.parse::<i64>().map_err(|_| TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::OutOfRange,
})?;
if !(i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&value) {
return Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::OutOfRange,
});
}
Ok(())
}
fn validate_phase_count_token(field: &str, text: &str) -> Result<(), TdmError> {
if is_phase_count_token(text) {
Ok(())
} else {
Err(TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::FloatParse,
})
}
}
fn is_ccsds_double_token(text: &str) -> bool {
let Some(unsigned) = strip_optional_sign(text) else {
return false;
};
is_fixed_point_token(unsigned, Some(16)) || is_floating_point_token(unsigned, Some(16))
}
fn is_phase_count_token(text: &str) -> bool {
is_unsigned_integer(text) || is_fixed_point_token(text, None)
}
fn strip_optional_sign(text: &str) -> Option<&str> {
let unsigned = strip_ascii_sign(text);
(!unsigned.is_empty()).then_some(unsigned)
}
fn strip_ascii_sign(text: &str) -> &str {
match text.as_bytes().first() {
Some(b'+') | Some(b'-') => &text[1..],
_ => text,
}
}
fn is_unsigned_integer(text: &str) -> bool {
!text.is_empty() && text.chars().all(|character| character.is_ascii_digit())
}
fn is_fixed_point_token(text: &str, max_digits: Option<usize>) -> bool {
let Some((integer, fraction)) = text.split_once('.') else {
return false;
};
if integer.is_empty()
|| fraction.is_empty()
|| fraction.contains('.')
|| !is_unsigned_integer(integer)
|| !is_unsigned_integer(fraction)
{
return false;
}
match max_digits {
Some(max) => integer.len() + fraction.len() <= max,
None => true,
}
}
fn is_floating_point_token(text: &str, max_digits: Option<usize>) -> bool {
let Some(exponent_index) = text.find(['E', 'e']) else {
return false;
};
let mantissa = &text[..exponent_index];
let exponent = &text[exponent_index + 1..];
if exponent.is_empty() || exponent.find(['E', 'e']).is_some() {
return false;
}
let exponent_digits = strip_ascii_sign(exponent);
if exponent_digits.is_empty()
|| !exponent_digits
.chars()
.all(|character| character.is_ascii_digit())
{
return false;
}
let mut mantissa_chars = mantissa.chars();
let Some(integer) = mantissa_chars.next() else {
return false;
};
if !integer.is_ascii_digit() || mantissa_chars.next() != Some('.') {
return false;
}
let fraction = mantissa_chars.as_str();
if fraction.is_empty() || !is_unsigned_integer(fraction) {
return false;
}
match max_digits {
Some(max) => fraction.len() < max,
None => true,
}
}
fn numeric_token_is_zero(text: &str) -> bool {
let unsigned = strip_ascii_sign(text);
let mantissa = unsigned
.find(['E', 'e'])
.map_or(unsigned, |exponent_index| &unsigned[..exponent_index]);
!mantissa.is_empty()
&& mantissa
.bytes()
.filter(|byte| *byte != b'.')
.all(|byte| byte == b'0')
}
fn decimal_magnitude_below_minimum_positive_double(text: &str) -> bool {
const MIN_POSITIVE_EXPONENT: i32 = -324;
const MIN_POSITIVE_SIGNIFICAND_16: &[u8; 16] = b"4940000000000000";
let Some((exponent, significand)) = normalized_decimal_parts(text) else {
return false;
};
if exponent < MIN_POSITIVE_EXPONENT {
return true;
}
if exponent > MIN_POSITIVE_EXPONENT {
return false;
}
let significand = significand.as_bytes();
for (index, minimum) in MIN_POSITIVE_SIGNIFICAND_16.iter().enumerate() {
let digit = significand.get(index).copied().unwrap_or(b'0');
if digit != *minimum {
return digit < *minimum;
}
}
false
}
fn normalized_decimal_parts(text: &str) -> Option<(i32, String)> {
let unsigned = strip_ascii_sign(text);
let (mantissa, exponent_adjust) = if let Some(exponent_index) = unsigned.find(['E', 'e']) {
(
&unsigned[..exponent_index],
parse_exponent_for_bound(&unsigned[exponent_index + 1..]),
)
} else {
(unsigned, 0)
};
let (integer, fraction) = mantissa.split_once('.')?;
let decimal_index = i32::try_from(integer.len()).ok()?;
let mut digits = String::with_capacity(integer.len() + fraction.len());
digits.push_str(integer);
digits.push_str(fraction);
let leading = digits.bytes().position(|byte| byte != b'0')?;
let leading = i32::try_from(leading).ok()?;
let exponent = exponent_adjust + decimal_index - leading - 1;
Some((exponent, digits[leading as usize..].to_string()))
}
fn parse_exponent_for_bound(text: &str) -> i32 {
let negative = text.starts_with('-');
let digits = strip_ascii_sign(text);
let digits = digits.trim_start_matches('0');
if digits.len() > 4 {
return if negative { -10_000 } else { 10_000 };
}
let value = digits.parse::<i32>().unwrap_or(0);
if negative {
-value
} else {
value
}
}
fn observable_from_keyword(keyword: &str) -> Result<TdmObservable, TdmError> {
match keyword {
"RANGE" => Ok(TdmObservable::Range),
"DOPPLER_INSTANTANEOUS" => Ok(TdmObservable::DopplerInstantaneous),
"DOPPLER_INTEGRATED" => Ok(TdmObservable::DopplerIntegrated),
"ANGLE_1" => Ok(TdmObservable::Angle1),
"ANGLE_2" => Ok(TdmObservable::Angle2),
"RECEIVE_FREQ" => Ok(TdmObservable::ReceiveFreq { participant: None }),
_ => {
if let Some(participant) = indexed_suffix_in_range(keyword, "RECEIVE_FREQ", 1, 5)? {
Ok(TdmObservable::ReceiveFreq {
participant: Some(participant),
})
} else if let Some(participant) =
indexed_suffix_in_range(keyword, "TRANSMIT_FREQ_RATE", 1, 5)?
{
Ok(TdmObservable::TransmitFreqRate {
participant: Some(participant),
})
} else if let Some(participant) =
indexed_suffix_in_range(keyword, "TRANSMIT_FREQ", 1, 5)?
{
Ok(TdmObservable::TransmitFreq {
participant: Some(participant),
})
} else if known_table_3_5_other_keyword(keyword)? {
Ok(TdmObservable::Other(keyword.to_string()))
} else {
Err(unknown_keyword(keyword))
}
}
}
}
fn validate_record_value(
keyword: &str,
observable: &TdmObservable,
scalar: &TdmScalar,
) -> Result<(), TdmError> {
let value = scalar.value;
if value == 0.0 && scalar.text.trim_start().starts_with('-') {
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::NegativeZero,
});
}
if matches!(observable, TdmObservable::TransmitFreq { .. }) && value <= 0.0 {
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::NotPositive,
});
}
if matches!(observable, TdmObservable::Other(name) if name == "DOPPLER_COUNT") {
validate_doppler_count(keyword, scalar)?;
}
if matches!(observable, TdmObservable::Other(name) if name == "RCS" || name == "STEC" || name == "TEMPERATURE")
&& value <= 0.0
{
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::NotPositive,
});
}
if matches!(observable, TdmObservable::Other(name) if name == "TROPO_DRY" || name == "TROPO_WET")
&& value < 0.0
{
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::Negative,
});
}
if matches!(observable, TdmObservable::Other(name) if name == "RHUMIDITY")
&& !(0.0..=100.0).contains(&value)
{
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::OutOfRange,
});
}
if matches!(observable, TdmObservable::Angle1 | TdmObservable::Angle2)
&& !(-180.0..360.0).contains(&value)
{
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::OutOfRange,
});
}
Ok(())
}
fn validate_doppler_count(keyword: &str, scalar: &TdmScalar) -> Result<(), TdmError> {
let text = scalar.text.trim_start();
if text.starts_with('-') {
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::Negative,
});
}
let digits = text.strip_prefix('+').unwrap_or(text);
if digits.is_empty() || !digits.chars().all(|character| character.is_ascii_digit()) {
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::NonInteger,
});
}
let count = digits.parse::<u64>().map_err(|_| TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::OutOfRange,
})?;
if count > i32::MAX as u64 {
return Err(TdmError::InvalidField {
field: keyword.to_string(),
kind: TdmInputErrorKind::OutOfRange,
});
}
Ok(())
}
fn validate_tdm(tdm: &Tdm) -> Result<(), TdmError> {
if tdm.version.is_empty() {
return Err(TdmError::MissingVersion);
}
if tdm.segments.is_empty() {
return Err(TdmError::NoSegments);
}
for segment in &tdm.segments {
for record in &segment.data.records {
if !record.value.value.is_finite() {
return Err(TdmError::InvalidField {
field: record.keyword.clone(),
kind: TdmInputErrorKind::NonFinite,
});
}
let observable = observable_from_keyword(&record.keyword)?;
let parsed = parse_scalar(&record.keyword, &record.value.text, &observable)?;
if parsed.value.to_bits() != record.value.value.to_bits() {
return Err(TdmError::InvalidField {
field: record.keyword.clone(),
kind: TdmInputErrorKind::DecimalMismatch,
});
}
if observable != record.observable {
return Err(TdmError::InvalidField {
field: record.keyword.clone(),
kind: TdmInputErrorKind::UnknownKeyword,
});
}
let expected_unit = unit_for_keyword(
&record.keyword,
&record.observable,
&segment.metadata.range_units,
);
if expected_unit != record.unit {
return Err(TdmError::InvalidField {
field: record.keyword.clone(),
kind: TdmInputErrorKind::UnitMismatch,
});
}
validate_record_value(&record.keyword, &record.observable, &record.value)?;
}
}
Ok(())
}
fn parse_assignment(line: &str) -> Option<(String, String)> {
let (key, raw_value) = line.split_once('=')?;
let key = key.trim().to_string();
Some((key, raw_value.trim().to_string()))
}
fn comment_text(line: &str) -> Option<String> {
if line == COMMENT_KEY {
return Some(String::new());
}
let rest = line.strip_prefix(COMMENT_KEY)?;
if rest
.chars()
.next()
.is_some_and(|character| character.is_ascii_whitespace())
{
Some(rest.trim_start().to_string())
} else {
None
}
}
fn comment_line(comment: &String) -> String {
if comment.is_empty() {
COMMENT_KEY.to_string()
} else {
format!("{COMMENT_KEY} {comment}")
}
}
fn field_line(field: &TdmField) -> String {
format!("{} = {}", field.key, field.value)
}
fn empty_to_none(value: String) -> Option<String> {
(!value.is_empty()).then_some(value)
}
fn malformed_record(line: usize, keyword: &str) -> TdmError {
TdmError::MalformedRecord {
line,
keyword: keyword.to_string(),
}
}
fn has_displayed_unit(value: &str) -> bool {
let trimmed = value.trim_end();
trimmed.ends_with(']') && trimmed.rfind('[').is_some()
}
fn indexed_suffix(key: &str, base: &str) -> Result<Option<u8>, TdmError> {
let Some(suffix) = key
.strip_prefix(base)
.and_then(|rest| rest.strip_prefix('_'))
else {
return Ok(None);
};
if suffix.is_empty() || !suffix.chars().all(|character| character.is_ascii_digit()) {
return Err(invalid_index(key));
}
suffix
.parse::<u8>()
.map(Some)
.map_err(|_| invalid_index(key))
}
fn indexed_suffix_in_range(
key: &str,
base: &str,
min: u8,
max: u8,
) -> Result<Option<u8>, TdmError> {
let Some(index) = indexed_suffix(key, base)? else {
return Ok(None);
};
if (min..=max).contains(&index) {
Ok(Some(index))
} else {
Err(invalid_index(key))
}
}
fn invalid_index(field: &str) -> TdmError {
TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::InvalidIndex,
}
}
fn unknown_keyword(field: &str) -> TdmError {
TdmError::InvalidField {
field: field.to_string(),
kind: TdmInputErrorKind::UnknownKeyword,
}
}
fn range_unit_from_label(label: &str) -> Result<TdmUnit, TdmError> {
match label {
"km" => Ok(TdmUnit::Kilometers),
"s" => Ok(TdmUnit::Seconds),
"RU" => Ok(TdmUnit::RangeUnits),
_ => Err(TdmError::InvalidField {
field: "RANGE_UNITS".to_string(),
kind: TdmInputErrorKind::UnitMismatch,
}),
}
}
fn unit_for_keyword(keyword: &str, observable: &TdmObservable, range_units: &TdmUnit) -> TdmUnit {
match observable {
TdmObservable::Range => range_units.clone(),
TdmObservable::DopplerInstantaneous | TdmObservable::DopplerIntegrated => {
TdmUnit::KilometersPerSecond
}
TdmObservable::ReceiveFreq { .. } | TdmObservable::TransmitFreq { .. } => TdmUnit::Hertz,
TdmObservable::TransmitFreqRate { .. } => TdmUnit::HertzPerSecond,
TdmObservable::Angle1 | TdmObservable::Angle2 => TdmUnit::Degrees,
TdmObservable::Other(_) => unit_for_other_keyword(keyword),
}
}
fn unit_for_other_keyword(keyword: &str) -> TdmUnit {
if indexed_suffix_in_range(keyword, "RECEIVE_PHASE_CT", 1, 5).is_ok_and(|value| value.is_some())
|| indexed_suffix_in_range(keyword, "TRANSMIT_PHASE_CT", 1, 5)
.is_ok_and(|value| value.is_some())
{
return TdmUnit::Dimensionless;
}
match keyword {
"CARRIER_POWER" => TdmUnit::DecibelWatts,
"CLOCK_BIAS" | "DOR" | "VLBI_DELAY" => TdmUnit::Seconds,
"CLOCK_DRIFT" => TdmUnit::SecondsPerSecond,
"DOPPLER_COUNT" | "MAG" => TdmUnit::Dimensionless,
"PC_N0" | "PR_N0" => TdmUnit::DecibelHertz,
"PRESSURE" => TdmUnit::Hectopascals,
"RCS" => TdmUnit::SquareMeters,
"RHUMIDITY" => TdmUnit::Percent,
"STEC" => TdmUnit::TotalElectronContentUnits,
"TEMPERATURE" => TdmUnit::Kelvin,
"TROPO_DRY" | "TROPO_WET" => TdmUnit::Meters,
_ => unreachable!("table 3-5 keyword checked before unit lookup"),
}
}
fn phase_count_keyword(keyword: &str) -> bool {
keyword.starts_with("RECEIVE_PHASE_CT_") || keyword.starts_with("TRANSMIT_PHASE_CT_")
}
fn known_table_3_5_other_keyword(keyword: &str) -> Result<bool, TdmError> {
if indexed_suffix_in_range(keyword, "RECEIVE_PHASE_CT", 1, 5)?.is_some()
|| indexed_suffix_in_range(keyword, "TRANSMIT_PHASE_CT", 1, 5)?.is_some()
{
return Ok(true);
}
Ok(matches!(
keyword,
"CARRIER_POWER"
| "CLOCK_BIAS"
| "CLOCK_DRIFT"
| "DOPPLER_COUNT"
| "DOR"
| "MAG"
| "PC_N0"
| "PR_N0"
| "PRESSURE"
| "RCS"
| "RHUMIDITY"
| "STEC"
| "TEMPERATURE"
| "TROPO_DRY"
| "TROPO_WET"
| "VLBI_DELAY"
))
}
#[cfg(test)]
mod tests {
use super::*;
const SIMPLE: &str = "\
CCSDS_TDM_VERS = 2.0
COMMENT sample
CREATION_DATE = 2005-160T20:15:00Z
ORIGINATOR = NASA
META_START
TIME_SYSTEM = UTC
PARTICIPANT_1 = DSS-25
PARTICIPANT_2 = yyyy-nnnA
MODE = SEQUENTIAL
PATH = 2,1
RANGE_UNITS = km
META_STOP
DATA_START
TRANSMIT_FREQ_2 = 2005-159T17:41:00 32023442781.733
RECEIVE_FREQ_1 = 2005-159T17:41:00 32021034790.7265
RANGE = 2005-159T17:41:00 80452.7542
ANGLE_1 = 2005-159T17:41:00 256.64002393
ANGLE_2 = 2005-159T17:41:00 13.38100016
DATA_STOP";
#[test]
fn parses_frequency_records_without_reformatting_decimal_tokens() {
let tdm = parse_kvn(SIMPLE).unwrap();
let records = &tdm.segments[0].data.records;
assert_eq!(records[0].keyword, "TRANSMIT_FREQ_2");
assert_eq!(records[0].value.text, "32023442781.733");
assert_eq!(records[0].value.value.to_bits(), 0x421d_d2fb_d576_ee98);
assert_eq!(records[0].unit, TdmUnit::Hertz);
assert_eq!(records[1].keyword, "RECEIVE_FREQ_1");
assert_eq!(records[1].value.text, "32021034790.7265");
assert_eq!(records[1].value.value.to_bits(), 0x421d_d268_dc9a_e7f0);
}
#[test]
fn canonical_encode_is_stable() {
let tdm = parse_kvn(SIMPLE).unwrap();
let encoded = encode_kvn(&tdm).unwrap();
let reparsed = parse_kvn(&encoded).unwrap();
assert_eq!(encode_kvn(&reparsed).unwrap(), encoded);
assert_eq!(reparsed, tdm);
}
#[test]
fn malformed_data_record_is_typed_error() {
let err = parse_kvn(
"\
CCSDS_TDM_VERS = 2.0
META_START
TIME_SYSTEM = UTC
META_STOP
DATA_START
RECEIVE_FREQ_1 = 2005-159T17:41:00
DATA_STOP",
)
.unwrap_err();
assert_eq!(
err,
TdmError::MalformedRecord {
line: 6,
keyword: "RECEIVE_FREQ_1".to_string()
}
);
}
#[test]
fn invalid_transmit_frequency_is_rejected() {
let err = parse_kvn(
"\
CCSDS_TDM_VERS = 2.0
META_START
TIME_SYSTEM = UTC
META_STOP
DATA_START
TRANSMIT_FREQ_1 = 2005-159T17:41:00 0.0
DATA_STOP",
)
.unwrap_err();
assert_eq!(
err,
TdmError::InvalidField {
field: "TRANSMIT_FREQ_1".to_string(),
kind: TdmInputErrorKind::NotPositive,
}
);
}
}