1use std::fmt;
11
12const VERSION_KEY: &str = "CCSDS_TDM_VERS";
13const COMMENT_KEY: &str = "COMMENT";
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct Tdm {
18 pub version: String,
20 pub comments: Vec<String>,
22 pub creation_date: Option<String>,
24 pub originator: Option<String>,
26 pub message_id: Option<String>,
28 pub header_fields: Vec<TdmField>,
30 pub segments: Vec<TdmSegment>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct TdmSegment {
37 pub metadata: TdmMetadata,
39 pub data: TdmDataSection,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct TdmField {
46 pub key: String,
48 pub value: String,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub struct TdmMetadata {
55 pub comments: Vec<String>,
57 pub fields: Vec<TdmField>,
59 pub participants: Vec<TdmParticipant>,
61 pub mode: Option<String>,
63 pub paths: Vec<TdmPath>,
65 pub timetag_ref: Option<String>,
67 pub time_system: Option<String>,
69 pub range_units: TdmUnit,
71}
72
73impl TdmMetadata {
74 pub fn get_last(&self, key: &str) -> Option<&str> {
76 self.fields
77 .iter()
78 .rev()
79 .find(|field| field.key == key)
80 .map(|field| field.value.as_str())
81 .filter(|value| !value.is_empty())
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct TdmParticipant {
88 pub index: u8,
90 pub name: String,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct TdmPath {
97 pub key: String,
99 pub index: Option<u8>,
101 pub participants: Vec<u8>,
103}
104
105#[derive(Debug, Clone, PartialEq)]
107pub struct TdmDataSection {
108 pub comments: Vec<String>,
110 pub records: Vec<TdmDataRecord>,
112}
113
114#[derive(Debug, Clone, PartialEq)]
116pub struct TdmDataRecord {
117 pub observable: TdmObservable,
119 pub keyword: String,
121 pub epoch: String,
123 pub value: TdmScalar,
125 pub unit: TdmUnit,
127}
128
129#[derive(Debug, Clone, PartialEq)]
131pub struct TdmScalar {
132 pub text: String,
134 pub value: f64,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum TdmObservable {
141 Range,
143 DopplerInstantaneous,
145 DopplerIntegrated,
147 ReceiveFreq {
149 participant: Option<u8>,
151 },
152 TransmitFreq {
154 participant: Option<u8>,
156 },
157 TransmitFreqRate {
159 participant: Option<u8>,
161 },
162 Angle1,
164 Angle2,
166 Other(String),
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum TdmUnit {
173 Kilometers,
175 Seconds,
177 RangeUnits,
179 KilometersPerSecond,
181 Hertz,
183 HertzPerSecond,
185 Degrees,
187 DecibelWatts,
189 DecibelHertz,
191 SquareMeters,
193 Meters,
195 SecondsPerSecond,
197 Percent,
199 Kelvin,
201 Hectopascals,
203 TotalElectronContentUnits,
205 Dimensionless,
207 Unknown(String),
209}
210
211impl TdmUnit {
212 pub fn as_str(&self) -> &str {
214 match self {
215 Self::Kilometers => "km",
216 Self::Seconds => "s",
217 Self::RangeUnits => "RU",
218 Self::KilometersPerSecond => "km/s",
219 Self::Hertz => "Hz",
220 Self::HertzPerSecond => "Hz/s",
221 Self::Degrees => "deg",
222 Self::DecibelWatts => "dBW",
223 Self::DecibelHertz => "dBHz",
224 Self::SquareMeters => "m**2",
225 Self::Meters => "m",
226 Self::SecondsPerSecond => "s/s",
227 Self::Percent => "%",
228 Self::Kelvin => "K",
229 Self::Hectopascals => "hPa",
230 Self::TotalElectronContentUnits => "TECU",
231 Self::Dimensionless => "n/a",
232 Self::Unknown(label) => label.as_str(),
233 }
234 }
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum TdmInputErrorKind {
240 Missing,
242 FloatParse,
244 NonFinite,
246 NotPositive,
248 OutOfRange,
250 InvalidIndex,
252 UnknownKeyword,
254 UnexpectedUnit,
256 NonInteger,
258 Negative,
260 NegativeZero,
262 UnitMismatch,
264 DecimalMismatch,
266}
267
268impl fmt::Display for TdmInputErrorKind {
269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270 let label = match self {
271 Self::Missing => "missing",
272 Self::FloatParse => "invalid float",
273 Self::NonFinite => "not finite",
274 Self::NotPositive => "not positive",
275 Self::OutOfRange => "out of range",
276 Self::InvalidIndex => "invalid index",
277 Self::UnknownKeyword => "unknown keyword",
278 Self::UnexpectedUnit => "unexpected unit",
279 Self::NonInteger => "not an integer",
280 Self::Negative => "negative",
281 Self::NegativeZero => "negative zero",
282 Self::UnitMismatch => "unit mismatch",
283 Self::DecimalMismatch => "decimal mismatch",
284 };
285 f.write_str(label)
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
291pub enum TdmError {
292 MissingVersion,
294 NoSegments,
296 Section {
298 line: usize,
300 detail: &'static str,
302 },
303 MalformedLine {
305 line: usize,
307 text: String,
309 },
310 MalformedRecord {
312 line: usize,
314 keyword: String,
316 },
317 InvalidField {
319 field: String,
321 kind: TdmInputErrorKind,
323 },
324}
325
326impl fmt::Display for TdmError {
327 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328 match self {
329 Self::MissingVersion => write!(f, "missing {VERSION_KEY}"),
330 Self::NoSegments => write!(f, "missing TDM segment"),
331 Self::Section { line, detail } => {
332 write!(f, "invalid TDM section at line {line}: {detail}")
333 }
334 Self::MalformedLine { line, text } => {
335 write!(f, "malformed TDM KVN line {line}: {text}")
336 }
337 Self::MalformedRecord { line, keyword } => {
338 write!(f, "malformed TDM data record {keyword} at line {line}")
339 }
340 Self::InvalidField { field, kind } => write!(f, "invalid TDM field {field}: {kind}"),
341 }
342 }
343}
344
345impl std::error::Error for TdmError {}
346
347#[derive(Default)]
348struct HeaderBuilder {
349 version: Option<String>,
350 comments: Vec<String>,
351 creation_date: Option<String>,
352 originator: Option<String>,
353 message_id: Option<String>,
354 fields: Vec<TdmField>,
355}
356
357#[derive(Default)]
358struct MetadataBuilder {
359 comments: Vec<String>,
360 fields: Vec<TdmField>,
361}
362
363#[derive(Default)]
364struct DataBuilder {
365 comments: Vec<String>,
366 records: Vec<TdmDataRecord>,
367}
368
369pub fn parse_kvn(text: &str) -> Result<Tdm, TdmError> {
377 let mut header = HeaderBuilder::default();
378 let mut metadata: Option<MetadataBuilder> = None;
379 let mut pending_metadata: Option<TdmMetadata> = None;
380 let mut data: Option<DataBuilder> = None;
381 let mut segments = Vec::new();
382
383 for (idx, raw_line) in text.lines().enumerate() {
384 let line_no = idx + 1;
385 let line = raw_line.trim();
386 if line.is_empty() {
387 continue;
388 }
389
390 if let Some(comment) = comment_text(line) {
391 if let Some(builder) = data.as_mut() {
392 builder.comments.push(comment);
393 } else if let Some(builder) = metadata.as_mut() {
394 builder.comments.push(comment);
395 } else if pending_metadata.is_none() {
396 header.comments.push(comment);
397 } else {
398 return Err(TdmError::Section {
399 line: line_no,
400 detail: "comment between metadata and data",
401 });
402 }
403 continue;
404 }
405
406 match line {
407 "META_START" => {
408 if metadata.is_some() || data.is_some() || pending_metadata.is_some() {
409 return Err(TdmError::Section {
410 line: line_no,
411 detail: "nested metadata block",
412 });
413 }
414 metadata = Some(MetadataBuilder::default());
415 continue;
416 }
417 "META_STOP" => {
418 let builder = metadata.take().ok_or(TdmError::Section {
419 line: line_no,
420 detail: "metadata stop without metadata start",
421 })?;
422 pending_metadata = Some(build_metadata(builder)?);
423 continue;
424 }
425 "DATA_START" => {
426 if metadata.is_some() || data.is_some() || pending_metadata.is_none() {
427 return Err(TdmError::Section {
428 line: line_no,
429 detail: "data start without completed metadata",
430 });
431 }
432 data = Some(DataBuilder::default());
433 continue;
434 }
435 "DATA_STOP" => {
436 let builder = data.take().ok_or(TdmError::Section {
437 line: line_no,
438 detail: "data stop without data start",
439 })?;
440 let metadata = pending_metadata.take().ok_or(TdmError::Section {
441 line: line_no,
442 detail: "data stop without metadata",
443 })?;
444 segments.push(TdmSegment {
445 metadata,
446 data: TdmDataSection {
447 comments: builder.comments,
448 records: builder.records,
449 },
450 });
451 continue;
452 }
453 _ => {}
454 }
455
456 let (key, value) = parse_assignment(line).ok_or_else(|| TdmError::MalformedLine {
457 line: line_no,
458 text: line.to_string(),
459 })?;
460
461 if let Some(builder) = data.as_mut() {
462 let range_units = pending_metadata
463 .as_ref()
464 .map(|metadata| metadata.range_units.clone())
465 .unwrap_or(TdmUnit::Kilometers);
466 builder
467 .records
468 .push(parse_record(line_no, &key, &value, &range_units)?);
469 } else if let Some(builder) = metadata.as_mut() {
470 builder.fields.push(TdmField { key, value });
471 } else if pending_metadata.is_none() {
472 parse_header_field(&mut header, key, value);
473 } else {
474 return Err(TdmError::Section {
475 line: line_no,
476 detail: "field between metadata and data",
477 });
478 }
479 }
480
481 if metadata.is_some() {
482 return Err(TdmError::Section {
483 line: text.lines().count().saturating_add(1),
484 detail: "unclosed metadata block",
485 });
486 }
487 if data.is_some() {
488 return Err(TdmError::Section {
489 line: text.lines().count().saturating_add(1),
490 detail: "unclosed data block",
491 });
492 }
493 if pending_metadata.is_some() {
494 return Err(TdmError::Section {
495 line: text.lines().count().saturating_add(1),
496 detail: "metadata without data block",
497 });
498 }
499
500 let version = header
501 .version
502 .filter(|value| !value.is_empty())
503 .ok_or(TdmError::MissingVersion)?;
504 if segments.is_empty() {
505 return Err(TdmError::NoSegments);
506 }
507
508 Ok(Tdm {
509 version,
510 comments: header.comments,
511 creation_date: header.creation_date,
512 originator: header.originator,
513 message_id: header.message_id,
514 header_fields: header.fields,
515 segments,
516 })
517}
518
519pub fn encode_kvn(tdm: &Tdm) -> Result<String, TdmError> {
526 validate_tdm(tdm)?;
527
528 let mut lines = Vec::new();
529 lines.push(format!("{VERSION_KEY} = {}", tdm.version));
530 lines.extend(tdm.comments.iter().map(comment_line));
531 if let Some(creation_date) = &tdm.creation_date {
532 lines.push(format!("CREATION_DATE = {creation_date}"));
533 }
534 if let Some(originator) = &tdm.originator {
535 lines.push(format!("ORIGINATOR = {originator}"));
536 }
537 if let Some(message_id) = &tdm.message_id {
538 lines.push(format!("MESSAGE_ID = {message_id}"));
539 }
540 lines.extend(tdm.header_fields.iter().map(field_line));
541
542 for segment in &tdm.segments {
543 lines.push("META_START".to_string());
544 lines.extend(segment.metadata.comments.iter().map(comment_line));
545 lines.extend(segment.metadata.fields.iter().map(field_line));
546 lines.push("META_STOP".to_string());
547 lines.push("DATA_START".to_string());
548 lines.extend(segment.data.comments.iter().map(comment_line));
549 for record in &segment.data.records {
550 lines.push(format!(
551 "{} = {} {}",
552 record.keyword, record.epoch, record.value.text
553 ));
554 }
555 lines.push("DATA_STOP".to_string());
556 }
557
558 Ok(lines.join("\n"))
559}
560
561fn parse_header_field(header: &mut HeaderBuilder, key: String, value: String) {
562 match key.as_str() {
563 VERSION_KEY => header.version = Some(value),
564 "CREATION_DATE" => header.creation_date = empty_to_none(value),
565 "ORIGINATOR" => header.originator = empty_to_none(value),
566 "MESSAGE_ID" => header.message_id = empty_to_none(value),
567 _ => header.fields.push(TdmField { key, value }),
568 }
569}
570
571fn build_metadata(builder: MetadataBuilder) -> Result<TdmMetadata, TdmError> {
572 let mut participants = Vec::new();
573 let mut mode = None;
574 let mut paths = Vec::new();
575 let mut timetag_ref = None;
576 let mut time_system = None;
577 let mut range_units = TdmUnit::Kilometers;
578
579 for field in &builder.fields {
580 if let Some(index) = indexed_suffix(&field.key, "PARTICIPANT")? {
581 participants.push(TdmParticipant {
582 index,
583 name: field.value.clone(),
584 });
585 } else if field.key == "MODE" {
586 mode = empty_to_none(field.value.clone());
587 } else if field.key == "PATH" || field.key.starts_with("PATH_") {
588 paths.push(parse_path(field)?);
589 } else if field.key == "TIMETAG_REF" {
590 timetag_ref = empty_to_none(field.value.clone());
591 } else if field.key == "TIME_SYSTEM" {
592 time_system = empty_to_none(field.value.clone());
593 } else if field.key == "RANGE_UNITS" && !field.value.is_empty() {
594 range_units = range_unit_from_label(&field.value)?;
595 }
596 }
597
598 Ok(TdmMetadata {
599 comments: builder.comments,
600 fields: builder.fields,
601 participants,
602 mode,
603 paths,
604 timetag_ref,
605 time_system,
606 range_units,
607 })
608}
609
610fn parse_path(field: &TdmField) -> Result<TdmPath, TdmError> {
611 let index = if field.key == "PATH" {
612 None
613 } else {
614 Some(indexed_suffix(&field.key, "PATH")?.ok_or_else(|| invalid_index(&field.key))?)
615 };
616 let mut participants = Vec::new();
617 for token in field.value.split(',') {
618 let trimmed = token.trim();
619 if trimmed.is_empty() {
620 return Err(invalid_index(&field.key));
621 }
622 let value = trimmed
623 .parse::<u8>()
624 .map_err(|_| invalid_index(&field.key))?;
625 participants.push(value);
626 }
627 if participants.is_empty() {
628 return Err(invalid_index(&field.key));
629 }
630 Ok(TdmPath {
631 key: field.key.clone(),
632 index,
633 participants,
634 })
635}
636
637fn parse_record(
638 line: usize,
639 keyword: &str,
640 value: &str,
641 range_units: &TdmUnit,
642) -> Result<TdmDataRecord, TdmError> {
643 if has_displayed_unit(keyword) || has_displayed_unit(value) {
644 return Err(TdmError::InvalidField {
645 field: keyword.to_string(),
646 kind: TdmInputErrorKind::UnexpectedUnit,
647 });
648 }
649
650 let mut parts = value.split_whitespace();
651 let epoch = parts
652 .next()
653 .ok_or_else(|| malformed_record(line, keyword))?;
654 let value_text = parts
655 .next()
656 .ok_or_else(|| malformed_record(line, keyword))?;
657 if parts.next().is_some() {
658 return Err(malformed_record(line, keyword));
659 }
660
661 let observable = observable_from_keyword(keyword)?;
662 let scalar = parse_scalar(keyword, value_text, &observable)?;
663 validate_record_value(keyword, &observable, &scalar)?;
664 let unit = unit_for_keyword(keyword, &observable, range_units);
665
666 Ok(TdmDataRecord {
667 observable,
668 keyword: keyword.to_string(),
669 epoch: epoch.to_string(),
670 value: scalar,
671 unit,
672 })
673}
674
675fn parse_scalar(
676 field: &str,
677 text: &str,
678 observable: &TdmObservable,
679) -> Result<TdmScalar, TdmError> {
680 if is_nonfinite_float_token(text) {
681 return Err(TdmError::InvalidField {
682 field: field.to_string(),
683 kind: TdmInputErrorKind::NonFinite,
684 });
685 }
686 validate_numeric_token(field, text, observable)?;
687 let value = text.parse::<f64>().map_err(|_| TdmError::InvalidField {
688 field: field.to_string(),
689 kind: TdmInputErrorKind::FloatParse,
690 })?;
691 if !value.is_finite() {
692 return Err(TdmError::InvalidField {
693 field: field.to_string(),
694 kind: TdmInputErrorKind::NonFinite,
695 });
696 }
697 let lexical_zero = numeric_token_is_zero(text);
698 if !lexical_zero && decimal_magnitude_below_minimum_positive_double(text) {
699 return Err(TdmError::InvalidField {
700 field: field.to_string(),
701 kind: TdmInputErrorKind::OutOfRange,
702 });
703 }
704 if value == 0.0 && text.trim_start().starts_with('-') {
705 return Err(TdmError::InvalidField {
706 field: field.to_string(),
707 kind: TdmInputErrorKind::NegativeZero,
708 });
709 }
710 if value == 0.0 && !lexical_zero {
711 return Err(TdmError::InvalidField {
712 field: field.to_string(),
713 kind: TdmInputErrorKind::OutOfRange,
714 });
715 }
716 Ok(TdmScalar {
717 text: text.to_string(),
718 value,
719 })
720}
721
722fn is_nonfinite_float_token(text: &str) -> bool {
723 matches!(
724 text,
725 "NaN" | "+NaN" | "-NaN" | "Inf" | "+Inf" | "-Inf" | "Infinity" | "+Infinity" | "-Infinity"
726 )
727}
728
729fn validate_numeric_token(
730 field: &str,
731 text: &str,
732 observable: &TdmObservable,
733) -> Result<(), TdmError> {
734 if matches!(observable, TdmObservable::Other(name) if name == "DOPPLER_COUNT") {
735 validate_integer_token(field, text)
736 } else if phase_count_keyword(field) {
737 validate_phase_count_token(field, text)
738 } else if is_ccsds_double_token(text) {
739 Ok(())
740 } else {
741 Err(TdmError::InvalidField {
742 field: field.to_string(),
743 kind: TdmInputErrorKind::FloatParse,
744 })
745 }
746}
747
748fn validate_integer_token(field: &str, text: &str) -> Result<(), TdmError> {
749 let digits = strip_ascii_sign(text);
750 if digits.is_empty() {
751 return Err(TdmError::InvalidField {
752 field: field.to_string(),
753 kind: TdmInputErrorKind::NonInteger,
754 });
755 }
756 if !digits.chars().all(|character| character.is_ascii_digit()) {
757 return Err(TdmError::InvalidField {
758 field: field.to_string(),
759 kind: TdmInputErrorKind::NonInteger,
760 });
761 }
762 let value = text.parse::<i64>().map_err(|_| TdmError::InvalidField {
763 field: field.to_string(),
764 kind: TdmInputErrorKind::OutOfRange,
765 })?;
766 if !(i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&value) {
767 return Err(TdmError::InvalidField {
768 field: field.to_string(),
769 kind: TdmInputErrorKind::OutOfRange,
770 });
771 }
772 Ok(())
773}
774
775fn validate_phase_count_token(field: &str, text: &str) -> Result<(), TdmError> {
776 if is_phase_count_token(text) {
777 Ok(())
778 } else {
779 Err(TdmError::InvalidField {
780 field: field.to_string(),
781 kind: TdmInputErrorKind::FloatParse,
782 })
783 }
784}
785
786fn is_ccsds_double_token(text: &str) -> bool {
787 let Some(unsigned) = strip_optional_sign(text) else {
788 return false;
789 };
790 is_fixed_point_token(unsigned, Some(16)) || is_floating_point_token(unsigned, Some(16))
791}
792
793fn is_phase_count_token(text: &str) -> bool {
794 is_unsigned_integer(text) || is_fixed_point_token(text, None)
795}
796
797fn strip_optional_sign(text: &str) -> Option<&str> {
798 let unsigned = strip_ascii_sign(text);
799 (!unsigned.is_empty()).then_some(unsigned)
800}
801
802fn strip_ascii_sign(text: &str) -> &str {
803 match text.as_bytes().first() {
804 Some(b'+') | Some(b'-') => &text[1..],
805 _ => text,
806 }
807}
808
809fn is_unsigned_integer(text: &str) -> bool {
810 !text.is_empty() && text.chars().all(|character| character.is_ascii_digit())
811}
812
813fn is_fixed_point_token(text: &str, max_digits: Option<usize>) -> bool {
814 let Some((integer, fraction)) = text.split_once('.') else {
815 return false;
816 };
817 if integer.is_empty()
818 || fraction.is_empty()
819 || fraction.contains('.')
820 || !is_unsigned_integer(integer)
821 || !is_unsigned_integer(fraction)
822 {
823 return false;
824 }
825 match max_digits {
826 Some(max) => integer.len() + fraction.len() <= max,
827 None => true,
828 }
829}
830
831fn is_floating_point_token(text: &str, max_digits: Option<usize>) -> bool {
832 let Some(exponent_index) = text.find(['E', 'e']) else {
833 return false;
834 };
835 let mantissa = &text[..exponent_index];
836 let exponent = &text[exponent_index + 1..];
837 if exponent.is_empty() || exponent.find(['E', 'e']).is_some() {
838 return false;
839 }
840 let exponent_digits = strip_ascii_sign(exponent);
841 if exponent_digits.is_empty()
842 || !exponent_digits
843 .chars()
844 .all(|character| character.is_ascii_digit())
845 {
846 return false;
847 }
848 let mut mantissa_chars = mantissa.chars();
849 let Some(integer) = mantissa_chars.next() else {
850 return false;
851 };
852 if !integer.is_ascii_digit() || mantissa_chars.next() != Some('.') {
853 return false;
854 }
855 let fraction = mantissa_chars.as_str();
856 if fraction.is_empty() || !is_unsigned_integer(fraction) {
857 return false;
858 }
859 match max_digits {
860 Some(max) => fraction.len() < max,
861 None => true,
862 }
863}
864
865fn numeric_token_is_zero(text: &str) -> bool {
866 let unsigned = strip_ascii_sign(text);
867 let mantissa = unsigned
868 .find(['E', 'e'])
869 .map_or(unsigned, |exponent_index| &unsigned[..exponent_index]);
870 !mantissa.is_empty()
871 && mantissa
872 .bytes()
873 .filter(|byte| *byte != b'.')
874 .all(|byte| byte == b'0')
875}
876
877fn decimal_magnitude_below_minimum_positive_double(text: &str) -> bool {
878 const MIN_POSITIVE_EXPONENT: i32 = -324;
879 const MIN_POSITIVE_SIGNIFICAND_16: &[u8; 16] = b"4940000000000000";
880
881 let Some((exponent, significand)) = normalized_decimal_parts(text) else {
882 return false;
883 };
884 if exponent < MIN_POSITIVE_EXPONENT {
885 return true;
886 }
887 if exponent > MIN_POSITIVE_EXPONENT {
888 return false;
889 }
890 let significand = significand.as_bytes();
891 for (index, minimum) in MIN_POSITIVE_SIGNIFICAND_16.iter().enumerate() {
892 let digit = significand.get(index).copied().unwrap_or(b'0');
893 if digit != *minimum {
894 return digit < *minimum;
895 }
896 }
897 false
898}
899
900fn normalized_decimal_parts(text: &str) -> Option<(i32, String)> {
901 let unsigned = strip_ascii_sign(text);
902 let (mantissa, exponent_adjust) = if let Some(exponent_index) = unsigned.find(['E', 'e']) {
903 (
904 &unsigned[..exponent_index],
905 parse_exponent_for_bound(&unsigned[exponent_index + 1..]),
906 )
907 } else {
908 (unsigned, 0)
909 };
910 let (integer, fraction) = mantissa.split_once('.')?;
911 let decimal_index = i32::try_from(integer.len()).ok()?;
912 let mut digits = String::with_capacity(integer.len() + fraction.len());
913 digits.push_str(integer);
914 digits.push_str(fraction);
915 let leading = digits.bytes().position(|byte| byte != b'0')?;
916 let leading = i32::try_from(leading).ok()?;
917 let exponent = exponent_adjust + decimal_index - leading - 1;
918 Some((exponent, digits[leading as usize..].to_string()))
919}
920
921fn parse_exponent_for_bound(text: &str) -> i32 {
922 let negative = text.starts_with('-');
923 let digits = strip_ascii_sign(text);
924 let digits = digits.trim_start_matches('0');
925 if digits.len() > 4 {
926 return if negative { -10_000 } else { 10_000 };
927 }
928 let value = digits.parse::<i32>().unwrap_or(0);
929 if negative {
930 -value
931 } else {
932 value
933 }
934}
935
936fn observable_from_keyword(keyword: &str) -> Result<TdmObservable, TdmError> {
937 match keyword {
938 "RANGE" => Ok(TdmObservable::Range),
939 "DOPPLER_INSTANTANEOUS" => Ok(TdmObservable::DopplerInstantaneous),
940 "DOPPLER_INTEGRATED" => Ok(TdmObservable::DopplerIntegrated),
941 "ANGLE_1" => Ok(TdmObservable::Angle1),
942 "ANGLE_2" => Ok(TdmObservable::Angle2),
943 "RECEIVE_FREQ" => Ok(TdmObservable::ReceiveFreq { participant: None }),
944 _ => {
945 if let Some(participant) = indexed_suffix_in_range(keyword, "RECEIVE_FREQ", 1, 5)? {
946 Ok(TdmObservable::ReceiveFreq {
947 participant: Some(participant),
948 })
949 } else if let Some(participant) =
950 indexed_suffix_in_range(keyword, "TRANSMIT_FREQ_RATE", 1, 5)?
951 {
952 Ok(TdmObservable::TransmitFreqRate {
953 participant: Some(participant),
954 })
955 } else if let Some(participant) =
956 indexed_suffix_in_range(keyword, "TRANSMIT_FREQ", 1, 5)?
957 {
958 Ok(TdmObservable::TransmitFreq {
959 participant: Some(participant),
960 })
961 } else if known_table_3_5_other_keyword(keyword)? {
962 Ok(TdmObservable::Other(keyword.to_string()))
963 } else {
964 Err(unknown_keyword(keyword))
965 }
966 }
967 }
968}
969
970fn validate_record_value(
971 keyword: &str,
972 observable: &TdmObservable,
973 scalar: &TdmScalar,
974) -> Result<(), TdmError> {
975 let value = scalar.value;
976 if value == 0.0 && scalar.text.trim_start().starts_with('-') {
977 return Err(TdmError::InvalidField {
978 field: keyword.to_string(),
979 kind: TdmInputErrorKind::NegativeZero,
980 });
981 }
982 if matches!(observable, TdmObservable::TransmitFreq { .. }) && value <= 0.0 {
983 return Err(TdmError::InvalidField {
984 field: keyword.to_string(),
985 kind: TdmInputErrorKind::NotPositive,
986 });
987 }
988 if matches!(observable, TdmObservable::Other(name) if name == "DOPPLER_COUNT") {
989 validate_doppler_count(keyword, scalar)?;
990 }
991 if matches!(observable, TdmObservable::Other(name) if name == "RCS" || name == "STEC" || name == "TEMPERATURE")
992 && value <= 0.0
993 {
994 return Err(TdmError::InvalidField {
995 field: keyword.to_string(),
996 kind: TdmInputErrorKind::NotPositive,
997 });
998 }
999 if matches!(observable, TdmObservable::Other(name) if name == "TROPO_DRY" || name == "TROPO_WET")
1000 && value < 0.0
1001 {
1002 return Err(TdmError::InvalidField {
1003 field: keyword.to_string(),
1004 kind: TdmInputErrorKind::Negative,
1005 });
1006 }
1007 if matches!(observable, TdmObservable::Other(name) if name == "RHUMIDITY")
1008 && !(0.0..=100.0).contains(&value)
1009 {
1010 return Err(TdmError::InvalidField {
1011 field: keyword.to_string(),
1012 kind: TdmInputErrorKind::OutOfRange,
1013 });
1014 }
1015 if matches!(observable, TdmObservable::Angle1 | TdmObservable::Angle2)
1016 && !(-180.0..360.0).contains(&value)
1017 {
1018 return Err(TdmError::InvalidField {
1019 field: keyword.to_string(),
1020 kind: TdmInputErrorKind::OutOfRange,
1021 });
1022 }
1023 Ok(())
1024}
1025
1026fn validate_doppler_count(keyword: &str, scalar: &TdmScalar) -> Result<(), TdmError> {
1027 let text = scalar.text.trim_start();
1028 if text.starts_with('-') {
1029 return Err(TdmError::InvalidField {
1030 field: keyword.to_string(),
1031 kind: TdmInputErrorKind::Negative,
1032 });
1033 }
1034 let digits = text.strip_prefix('+').unwrap_or(text);
1035 if digits.is_empty() || !digits.chars().all(|character| character.is_ascii_digit()) {
1036 return Err(TdmError::InvalidField {
1037 field: keyword.to_string(),
1038 kind: TdmInputErrorKind::NonInteger,
1039 });
1040 }
1041 let count = digits.parse::<u64>().map_err(|_| TdmError::InvalidField {
1042 field: keyword.to_string(),
1043 kind: TdmInputErrorKind::OutOfRange,
1044 })?;
1045 if count > i32::MAX as u64 {
1046 return Err(TdmError::InvalidField {
1047 field: keyword.to_string(),
1048 kind: TdmInputErrorKind::OutOfRange,
1049 });
1050 }
1051 Ok(())
1052}
1053
1054fn validate_tdm(tdm: &Tdm) -> Result<(), TdmError> {
1055 if tdm.version.is_empty() {
1056 return Err(TdmError::MissingVersion);
1057 }
1058 if tdm.segments.is_empty() {
1059 return Err(TdmError::NoSegments);
1060 }
1061 for segment in &tdm.segments {
1062 for record in &segment.data.records {
1063 if !record.value.value.is_finite() {
1064 return Err(TdmError::InvalidField {
1065 field: record.keyword.clone(),
1066 kind: TdmInputErrorKind::NonFinite,
1067 });
1068 }
1069 let observable = observable_from_keyword(&record.keyword)?;
1070 let parsed = parse_scalar(&record.keyword, &record.value.text, &observable)?;
1071 if parsed.value.to_bits() != record.value.value.to_bits() {
1072 return Err(TdmError::InvalidField {
1073 field: record.keyword.clone(),
1074 kind: TdmInputErrorKind::DecimalMismatch,
1075 });
1076 }
1077 if observable != record.observable {
1078 return Err(TdmError::InvalidField {
1079 field: record.keyword.clone(),
1080 kind: TdmInputErrorKind::UnknownKeyword,
1081 });
1082 }
1083 let expected_unit = unit_for_keyword(
1084 &record.keyword,
1085 &record.observable,
1086 &segment.metadata.range_units,
1087 );
1088 if expected_unit != record.unit {
1089 return Err(TdmError::InvalidField {
1090 field: record.keyword.clone(),
1091 kind: TdmInputErrorKind::UnitMismatch,
1092 });
1093 }
1094 validate_record_value(&record.keyword, &record.observable, &record.value)?;
1095 }
1096 }
1097 Ok(())
1098}
1099
1100fn parse_assignment(line: &str) -> Option<(String, String)> {
1101 let (key, raw_value) = line.split_once('=')?;
1102 let key = key.trim().to_string();
1103 Some((key, raw_value.trim().to_string()))
1104}
1105
1106fn comment_text(line: &str) -> Option<String> {
1107 if line == COMMENT_KEY {
1108 return Some(String::new());
1109 }
1110 let rest = line.strip_prefix(COMMENT_KEY)?;
1111 if rest
1112 .chars()
1113 .next()
1114 .is_some_and(|character| character.is_ascii_whitespace())
1115 {
1116 Some(rest.trim_start().to_string())
1117 } else {
1118 None
1119 }
1120}
1121
1122fn comment_line(comment: &String) -> String {
1123 if comment.is_empty() {
1124 COMMENT_KEY.to_string()
1125 } else {
1126 format!("{COMMENT_KEY} {comment}")
1127 }
1128}
1129
1130fn field_line(field: &TdmField) -> String {
1131 format!("{} = {}", field.key, field.value)
1132}
1133
1134fn empty_to_none(value: String) -> Option<String> {
1135 (!value.is_empty()).then_some(value)
1136}
1137
1138fn malformed_record(line: usize, keyword: &str) -> TdmError {
1139 TdmError::MalformedRecord {
1140 line,
1141 keyword: keyword.to_string(),
1142 }
1143}
1144
1145fn has_displayed_unit(value: &str) -> bool {
1146 let trimmed = value.trim_end();
1147 trimmed.ends_with(']') && trimmed.rfind('[').is_some()
1148}
1149
1150fn indexed_suffix(key: &str, base: &str) -> Result<Option<u8>, TdmError> {
1151 let Some(suffix) = key
1152 .strip_prefix(base)
1153 .and_then(|rest| rest.strip_prefix('_'))
1154 else {
1155 return Ok(None);
1156 };
1157 if suffix.is_empty() || !suffix.chars().all(|character| character.is_ascii_digit()) {
1158 return Err(invalid_index(key));
1159 }
1160 suffix
1161 .parse::<u8>()
1162 .map(Some)
1163 .map_err(|_| invalid_index(key))
1164}
1165
1166fn indexed_suffix_in_range(
1167 key: &str,
1168 base: &str,
1169 min: u8,
1170 max: u8,
1171) -> Result<Option<u8>, TdmError> {
1172 let Some(index) = indexed_suffix(key, base)? else {
1173 return Ok(None);
1174 };
1175 if (min..=max).contains(&index) {
1176 Ok(Some(index))
1177 } else {
1178 Err(invalid_index(key))
1179 }
1180}
1181
1182fn invalid_index(field: &str) -> TdmError {
1183 TdmError::InvalidField {
1184 field: field.to_string(),
1185 kind: TdmInputErrorKind::InvalidIndex,
1186 }
1187}
1188
1189fn unknown_keyword(field: &str) -> TdmError {
1190 TdmError::InvalidField {
1191 field: field.to_string(),
1192 kind: TdmInputErrorKind::UnknownKeyword,
1193 }
1194}
1195
1196fn range_unit_from_label(label: &str) -> Result<TdmUnit, TdmError> {
1197 match label {
1198 "km" => Ok(TdmUnit::Kilometers),
1199 "s" => Ok(TdmUnit::Seconds),
1200 "RU" => Ok(TdmUnit::RangeUnits),
1201 _ => Err(TdmError::InvalidField {
1202 field: "RANGE_UNITS".to_string(),
1203 kind: TdmInputErrorKind::UnitMismatch,
1204 }),
1205 }
1206}
1207
1208fn unit_for_keyword(keyword: &str, observable: &TdmObservable, range_units: &TdmUnit) -> TdmUnit {
1209 match observable {
1210 TdmObservable::Range => range_units.clone(),
1211 TdmObservable::DopplerInstantaneous | TdmObservable::DopplerIntegrated => {
1212 TdmUnit::KilometersPerSecond
1213 }
1214 TdmObservable::ReceiveFreq { .. } | TdmObservable::TransmitFreq { .. } => TdmUnit::Hertz,
1215 TdmObservable::TransmitFreqRate { .. } => TdmUnit::HertzPerSecond,
1216 TdmObservable::Angle1 | TdmObservable::Angle2 => TdmUnit::Degrees,
1217 TdmObservable::Other(_) => unit_for_other_keyword(keyword),
1218 }
1219}
1220
1221fn unit_for_other_keyword(keyword: &str) -> TdmUnit {
1222 if indexed_suffix_in_range(keyword, "RECEIVE_PHASE_CT", 1, 5).is_ok_and(|value| value.is_some())
1223 || indexed_suffix_in_range(keyword, "TRANSMIT_PHASE_CT", 1, 5)
1224 .is_ok_and(|value| value.is_some())
1225 {
1226 return TdmUnit::Dimensionless;
1227 }
1228 match keyword {
1229 "CARRIER_POWER" => TdmUnit::DecibelWatts,
1230 "CLOCK_BIAS" | "DOR" | "VLBI_DELAY" => TdmUnit::Seconds,
1231 "CLOCK_DRIFT" => TdmUnit::SecondsPerSecond,
1232 "DOPPLER_COUNT" | "MAG" => TdmUnit::Dimensionless,
1233 "PC_N0" | "PR_N0" => TdmUnit::DecibelHertz,
1234 "PRESSURE" => TdmUnit::Hectopascals,
1235 "RCS" => TdmUnit::SquareMeters,
1236 "RHUMIDITY" => TdmUnit::Percent,
1237 "STEC" => TdmUnit::TotalElectronContentUnits,
1238 "TEMPERATURE" => TdmUnit::Kelvin,
1239 "TROPO_DRY" | "TROPO_WET" => TdmUnit::Meters,
1240 _ => unreachable!("table 3-5 keyword checked before unit lookup"),
1241 }
1242}
1243
1244fn phase_count_keyword(keyword: &str) -> bool {
1245 keyword.starts_with("RECEIVE_PHASE_CT_") || keyword.starts_with("TRANSMIT_PHASE_CT_")
1246}
1247
1248fn known_table_3_5_other_keyword(keyword: &str) -> Result<bool, TdmError> {
1249 if indexed_suffix_in_range(keyword, "RECEIVE_PHASE_CT", 1, 5)?.is_some()
1250 || indexed_suffix_in_range(keyword, "TRANSMIT_PHASE_CT", 1, 5)?.is_some()
1251 {
1252 return Ok(true);
1253 }
1254
1255 Ok(matches!(
1256 keyword,
1257 "CARRIER_POWER"
1258 | "CLOCK_BIAS"
1259 | "CLOCK_DRIFT"
1260 | "DOPPLER_COUNT"
1261 | "DOR"
1262 | "MAG"
1263 | "PC_N0"
1264 | "PR_N0"
1265 | "PRESSURE"
1266 | "RCS"
1267 | "RHUMIDITY"
1268 | "STEC"
1269 | "TEMPERATURE"
1270 | "TROPO_DRY"
1271 | "TROPO_WET"
1272 | "VLBI_DELAY"
1273 ))
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279
1280 const SIMPLE: &str = "\
1281CCSDS_TDM_VERS = 2.0
1282COMMENT sample
1283CREATION_DATE = 2005-160T20:15:00Z
1284ORIGINATOR = NASA
1285META_START
1286TIME_SYSTEM = UTC
1287PARTICIPANT_1 = DSS-25
1288PARTICIPANT_2 = yyyy-nnnA
1289MODE = SEQUENTIAL
1290PATH = 2,1
1291RANGE_UNITS = km
1292META_STOP
1293DATA_START
1294TRANSMIT_FREQ_2 = 2005-159T17:41:00 32023442781.733
1295RECEIVE_FREQ_1 = 2005-159T17:41:00 32021034790.7265
1296RANGE = 2005-159T17:41:00 80452.7542
1297ANGLE_1 = 2005-159T17:41:00 256.64002393
1298ANGLE_2 = 2005-159T17:41:00 13.38100016
1299DATA_STOP";
1300
1301 #[test]
1302 fn parses_frequency_records_without_reformatting_decimal_tokens() {
1303 let tdm = parse_kvn(SIMPLE).unwrap();
1304 let records = &tdm.segments[0].data.records;
1305 assert_eq!(records[0].keyword, "TRANSMIT_FREQ_2");
1306 assert_eq!(records[0].value.text, "32023442781.733");
1307 assert_eq!(records[0].value.value.to_bits(), 0x421d_d2fb_d576_ee98);
1308 assert_eq!(records[0].unit, TdmUnit::Hertz);
1309 assert_eq!(records[1].keyword, "RECEIVE_FREQ_1");
1310 assert_eq!(records[1].value.text, "32021034790.7265");
1311 assert_eq!(records[1].value.value.to_bits(), 0x421d_d268_dc9a_e7f0);
1312 }
1313
1314 #[test]
1315 fn canonical_encode_is_stable() {
1316 let tdm = parse_kvn(SIMPLE).unwrap();
1317 let encoded = encode_kvn(&tdm).unwrap();
1318 let reparsed = parse_kvn(&encoded).unwrap();
1319 assert_eq!(encode_kvn(&reparsed).unwrap(), encoded);
1320 assert_eq!(reparsed, tdm);
1321 }
1322
1323 #[test]
1324 fn malformed_data_record_is_typed_error() {
1325 let err = parse_kvn(
1326 "\
1327CCSDS_TDM_VERS = 2.0
1328META_START
1329TIME_SYSTEM = UTC
1330META_STOP
1331DATA_START
1332RECEIVE_FREQ_1 = 2005-159T17:41:00
1333DATA_STOP",
1334 )
1335 .unwrap_err();
1336 assert_eq!(
1337 err,
1338 TdmError::MalformedRecord {
1339 line: 6,
1340 keyword: "RECEIVE_FREQ_1".to_string()
1341 }
1342 );
1343 }
1344
1345 #[test]
1346 fn invalid_transmit_frequency_is_rejected() {
1347 let err = parse_kvn(
1348 "\
1349CCSDS_TDM_VERS = 2.0
1350META_START
1351TIME_SYSTEM = UTC
1352META_STOP
1353DATA_START
1354TRANSMIT_FREQ_1 = 2005-159T17:41:00 0.0
1355DATA_STOP",
1356 )
1357 .unwrap_err();
1358 assert_eq!(
1359 err,
1360 TdmError::InvalidField {
1361 field: "TRANSMIT_FREQ_1".to_string(),
1362 kind: TdmInputErrorKind::NotPositive,
1363 }
1364 );
1365 }
1366}