Skip to main content

sidereon_core/astro/
tdm.rs

1//! CCSDS Tracking Data Message KVN reader and writer.
2//!
3//! This module implements the CCSDS 503.0-B-2 KVN form as a sans-IO parser and
4//! serializer. Date/time fields remain raw strings, matching the other NDM
5//! readers in this crate. Observable values are kept as both the parsed `f64`
6//! and the exact decimal token read from the message, so frequency-domain
7//! records such as `RECEIVE_FREQ` and `TRANSMIT_FREQ_n` re-emit without decimal
8//! rewriting.
9
10use std::fmt;
11
12const VERSION_KEY: &str = "CCSDS_TDM_VERS";
13const COMMENT_KEY: &str = "COMMENT";
14
15/// A parsed CCSDS Tracking Data Message.
16#[derive(Debug, Clone, PartialEq)]
17pub struct Tdm {
18    /// The `CCSDS_TDM_VERS` header value.
19    pub version: String,
20    /// Header comments in parse order.
21    pub comments: Vec<String>,
22    /// The optional `CREATION_DATE` header value.
23    pub creation_date: Option<String>,
24    /// The optional `ORIGINATOR` header value.
25    pub originator: Option<String>,
26    /// The optional `MESSAGE_ID` header value.
27    pub message_id: Option<String>,
28    /// Header fields that are not part of the common modeled header.
29    pub header_fields: Vec<TdmField>,
30    /// Metadata/data segments in message order.
31    pub segments: Vec<TdmSegment>,
32}
33
34/// One TDM segment, consisting of one metadata block and one data block.
35#[derive(Debug, Clone, PartialEq)]
36pub struct TdmSegment {
37    /// Metadata describing the records in this segment.
38    pub metadata: TdmMetadata,
39    /// Tracking data records in this segment.
40    pub data: TdmDataSection,
41}
42
43/// A KVN key/value field preserved in parse order.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct TdmField {
46    /// The KVN keyword.
47    pub key: String,
48    /// The trimmed KVN value.
49    pub value: String,
50}
51
52/// Metadata extracted from a TDM `META_START` / `META_STOP` block.
53#[derive(Debug, Clone, PartialEq)]
54pub struct TdmMetadata {
55    /// Metadata comments in parse order.
56    pub comments: Vec<String>,
57    /// Raw metadata fields in parse order.
58    pub fields: Vec<TdmField>,
59    /// Parsed `PARTICIPANT_n` entries.
60    pub participants: Vec<TdmParticipant>,
61    /// The optional `MODE` metadata value.
62    pub mode: Option<String>,
63    /// Parsed `PATH`, `PATH_1`, and `PATH_2` entries.
64    pub paths: Vec<TdmPath>,
65    /// The optional `TIMETAG_REF` metadata value.
66    pub timetag_ref: Option<String>,
67    /// The optional `TIME_SYSTEM` metadata value.
68    pub time_system: Option<String>,
69    /// The range unit for `RANGE` records, defaulting to kilometers when absent.
70    pub range_units: TdmUnit,
71}
72
73impl TdmMetadata {
74    /// Return the last metadata value for `key`.
75    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/// One named tracking participant.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct TdmParticipant {
88    /// The numeric suffix from `PARTICIPANT_n`.
89    pub index: u8,
90    /// The participant name.
91    pub name: String,
92}
93
94/// A parsed signal path from `PATH`, `PATH_1`, or `PATH_2`.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct TdmPath {
97    /// The original path keyword.
98    pub key: String,
99    /// The path suffix for `PATH_n`, or `None` for the unindexed `PATH`.
100    pub index: Option<u8>,
101    /// Participant indices listed in path order.
102    pub participants: Vec<u8>,
103}
104
105/// A TDM data block.
106#[derive(Debug, Clone, PartialEq)]
107pub struct TdmDataSection {
108    /// Data-section comments in parse order.
109    pub comments: Vec<String>,
110    /// Data records in parse order.
111    pub records: Vec<TdmDataRecord>,
112}
113
114/// One time-tagged tracking data record.
115#[derive(Debug, Clone, PartialEq)]
116pub struct TdmDataRecord {
117    /// The parsed observable family.
118    pub observable: TdmObservable,
119    /// The original data keyword.
120    pub keyword: String,
121    /// The raw epoch string.
122    pub epoch: String,
123    /// The numeric observable value.
124    pub value: TdmScalar,
125    /// The unit assigned by CCSDS 503.0-B-2.
126    pub unit: TdmUnit,
127}
128
129/// A numeric record value plus the exact decimal token used to encode it.
130#[derive(Debug, Clone, PartialEq)]
131pub struct TdmScalar {
132    /// The exact decimal or scientific-notation token read from the KVN record.
133    pub text: String,
134    /// The parsed finite `f64` value.
135    pub value: f64,
136}
137
138/// Observable families used by TDM tracking data records.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum TdmObservable {
141    /// A `RANGE` record.
142    Range,
143    /// A `DOPPLER_INSTANTANEOUS` record.
144    DopplerInstantaneous,
145    /// A `DOPPLER_INTEGRATED` record.
146    DopplerIntegrated,
147    /// A `RECEIVE_FREQ` or `RECEIVE_FREQ_n` record.
148    ReceiveFreq {
149        /// The participant suffix from `RECEIVE_FREQ_n`, if present.
150        participant: Option<u8>,
151    },
152    /// A `TRANSMIT_FREQ` or `TRANSMIT_FREQ_n` record.
153    TransmitFreq {
154        /// The participant suffix from `TRANSMIT_FREQ_n`, if present.
155        participant: Option<u8>,
156    },
157    /// A `TRANSMIT_FREQ_RATE` or `TRANSMIT_FREQ_RATE_n` record.
158    TransmitFreqRate {
159        /// The participant suffix from `TRANSMIT_FREQ_RATE_n`, if present.
160        participant: Option<u8>,
161    },
162    /// An `ANGLE_1` record.
163    Angle1,
164    /// An `ANGLE_2` record.
165    Angle2,
166    /// A TDM data keyword not modeled as a dedicated enum variant.
167    Other(String),
168}
169
170/// Units attached to TDM data records.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum TdmUnit {
173    /// Kilometers.
174    Kilometers,
175    /// Seconds.
176    Seconds,
177    /// CCSDS range units.
178    RangeUnits,
179    /// Kilometers per second.
180    KilometersPerSecond,
181    /// Hertz.
182    Hertz,
183    /// Hertz per second.
184    HertzPerSecond,
185    /// Degrees.
186    Degrees,
187    /// Decibel watts.
188    DecibelWatts,
189    /// Decibel hertz.
190    DecibelHertz,
191    /// Square meters.
192    SquareMeters,
193    /// Meters.
194    Meters,
195    /// Seconds per second.
196    SecondsPerSecond,
197    /// Percent.
198    Percent,
199    /// Kelvin.
200    Kelvin,
201    /// Hectopascals.
202    Hectopascals,
203    /// Total electron content units.
204    TotalElectronContentUnits,
205    /// Dimensionless quantity.
206    Dimensionless,
207    /// A unit label not modeled by this enum.
208    Unknown(String),
209}
210
211impl TdmUnit {
212    /// Return the canonical unit label.
213    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/// Boundary validation failure category for TDM parsing and encoding.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum TdmInputErrorKind {
240    /// A required field or token was absent.
241    Missing,
242    /// A floating-point value could not be parsed.
243    FloatParse,
244    /// A floating-point value was NaN or infinite.
245    NonFinite,
246    /// A positive field was zero or negative.
247    NotPositive,
248    /// A numeric value was outside the CCSDS domain for that keyword.
249    OutOfRange,
250    /// An indexed keyword or path component did not contain a valid integer.
251    InvalidIndex,
252    /// A TDM data keyword is not defined by CCSDS 503.0-B-2 table 3-5.
253    UnknownKeyword,
254    /// A displayed unit was present even though TDM KVN units are table-defined.
255    UnexpectedUnit,
256    /// An integer-valued field contained a fractional value.
257    NonInteger,
258    /// A non-negative field contained a negative value.
259    Negative,
260    /// A numeric token used a negative zero form.
261    NegativeZero,
262    /// A record unit does not match CCSDS 503.0-B-2 table 3-5.
263    UnitMismatch,
264    /// The stored decimal token and `f64` value do not parse to the same bits.
265    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/// Failure modes for TDM KVN parsing and encoding.
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub enum TdmError {
292    /// The `CCSDS_TDM_VERS` header value was missing.
293    MissingVersion,
294    /// The message contained no complete metadata/data segment.
295    NoSegments,
296    /// A section marker appeared in an invalid location.
297    Section {
298        /// One-based input line number.
299        line: usize,
300        /// The section validation detail.
301        detail: &'static str,
302    },
303    /// A non-comment line was not a valid KVN assignment or section marker.
304    MalformedLine {
305        /// One-based input line number.
306        line: usize,
307        /// The offending input line.
308        text: String,
309    },
310    /// A data record did not contain `epoch value`.
311    MalformedRecord {
312        /// One-based input line number.
313        line: usize,
314        /// The offending data keyword.
315        keyword: String,
316    },
317    /// A field failed numeric or indexed-keyword validation.
318    InvalidField {
319        /// The offending field name.
320        field: String,
321        /// The validation failure category.
322        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
369/// Parse a TDM in CCSDS KVN format.
370///
371/// The parser accepts flexible whitespace around `=` and between the epoch and
372/// value tokens. It requires complete `META_START` / `META_STOP` and
373/// `DATA_START` / `DATA_STOP` blocks, and every data record with a numeric
374/// keyword must contain a finite value. Frequency records are not converted to
375/// range rate and keep their original decimal token for later serialization.
376pub 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
519/// Encode a TDM to canonical CCSDS KVN text.
520///
521/// The output uses `KEY = VALUE` assignments and emits each data record as
522/// `KEY = epoch decimal-token`. Record decimals are not reformatted. Encoding
523/// validates that every stored decimal token parses back to the stored `f64`
524/// bits, which keeps `RECEIVE_FREQ` and `TRANSMIT_FREQ_n` values lossless.
525pub 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}