Skip to main content

sidereon_core/astro/
oem.rs

1//! CCSDS Orbit Ephemeris Message (OEM) KVN and XML reader/writer.
2//!
3//! OEM date/time values are carried as raw strings. The reader does not resolve
4//! time systems or normalize epochs, so state-vector lines round-trip through the
5//! canonical IR without calendar rewriting.
6
7use crate::astro::covariance::Covariance6;
8use crate::astro::ndm::{read_covariance6, write_covariance6, FieldMap, NdmHeader};
9use crate::astro::xml;
10use crate::format::fmtnum::fmt_num;
11use crate::format::tokens::Tokenizer;
12use crate::format::{Diagnostics, RecordRef, Skip, SkipReason};
13use crate::validate;
14use roxmltree::{Document, Node};
15use std::fmt;
16
17const COMMENT_PREFIX: &str = "COMMENT";
18const META_START: &str = "META_START";
19const META_STOP: &str = "META_STOP";
20const COVARIANCE_START: &str = "COVARIANCE_START";
21const COVARIANCE_STOP: &str = "COVARIANCE_STOP";
22const OEM_VERSION_KEY: &str = "CCSDS_OEM_VERS";
23
24const METADATA_KEYS: [&str; 11] = [
25    "OBJECT_NAME",
26    "OBJECT_ID",
27    "CENTER_NAME",
28    "REF_FRAME",
29    "TIME_SYSTEM",
30    "START_TIME",
31    "STOP_TIME",
32    "USEABLE_START_TIME",
33    "USEABLE_STOP_TIME",
34    "INTERPOLATION",
35    "INTERPOLATION_DEGREE",
36];
37
38const STATE_NUMBER_KEYS: [&str; 9] = [
39    "X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT", "X_DDOT", "Y_DDOT", "Z_DDOT",
40];
41
42/// Canonical, format-agnostic OEM container.
43#[derive(Debug, Clone, PartialEq)]
44pub struct Oem {
45    pub ccsds_oem_vers: String,
46    pub creation_date: Option<String>,
47    pub originator: Option<String>,
48    pub segments: Vec<OemSegment>,
49    /// Forgiving-parse count of ephemeris data lines skipped as malformed.
50    pub skipped_states: usize,
51}
52
53/// One OEM metadata/data segment.
54#[derive(Debug, Clone, PartialEq)]
55pub struct OemSegment {
56    pub metadata: OemMetadata,
57    pub states: Vec<OemState>,
58    pub covariances: Vec<OemCovariance>,
59}
60
61/// OEM segment metadata.
62#[derive(Debug, Clone, PartialEq)]
63pub struct OemMetadata {
64    pub object_name: String,
65    pub object_id: String,
66    pub center_name: String,
67    pub ref_frame: String,
68    pub time_system: String,
69    pub start_time: String,
70    pub stop_time: String,
71    pub useable_start_time: Option<String>,
72    pub useable_stop_time: Option<String>,
73    pub interpolation: Option<String>,
74    pub interpolation_degree: Option<u32>,
75}
76
77/// One OEM Cartesian state sample.
78#[derive(Debug, Clone, PartialEq)]
79pub struct OemState {
80    pub epoch: String,
81    pub position_km: [f64; 3],
82    pub velocity_km_s: [f64; 3],
83    pub acceleration_km_s2: Option<[f64; 3]>,
84}
85
86/// One OEM covariance block.
87#[derive(Debug, Clone, PartialEq)]
88pub struct OemCovariance {
89    pub epoch: String,
90    pub cov_ref_frame: Option<String>,
91    pub matrix: Covariance6,
92}
93
94/// Failure modes of the OEM readers.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum OemError {
97    /// A required field was absent from the message.
98    MissingField(&'static str),
99    /// A decoded scalar field failed validation.
100    InvalidField {
101        field: &'static str,
102        kind: OemInputErrorKind,
103    },
104    /// A structural or XML-level error.
105    Field(String),
106}
107
108/// OEM boundary-validation failure category.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum OemInputErrorKind {
111    /// A required field was absent.
112    Missing,
113    /// A floating-point field was NaN or infinite.
114    NonFinite,
115    /// A floating-point field could not be parsed.
116    FloatParse,
117    /// An integer field could not be parsed.
118    IntParse,
119    /// A positive physical field was zero or negative.
120    NotPositive,
121    /// A non-negative physical field was negative.
122    Negative,
123    /// A finite numeric field was outside its accepted range.
124    OutOfRange,
125    /// A civil date field was out of range.
126    InvalidCivilDate,
127    /// A civil time field was out of range.
128    InvalidCivilTime,
129}
130
131impl fmt::Display for OemInputErrorKind {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        let label = match self {
134            Self::Missing => "missing",
135            Self::NonFinite => "not finite",
136            Self::FloatParse => "invalid float",
137            Self::IntParse => "invalid integer",
138            Self::NotPositive => "not positive",
139            Self::Negative => "negative",
140            Self::OutOfRange => "out of range",
141            Self::InvalidCivilDate => "invalid civil date",
142            Self::InvalidCivilTime => "invalid civil time",
143        };
144        f.write_str(label)
145    }
146}
147
148impl From<&validate::FieldError> for OemInputErrorKind {
149    fn from(error: &validate::FieldError) -> Self {
150        match error {
151            validate::FieldError::Missing { .. } => Self::Missing,
152            validate::FieldError::NonFinite { .. } => Self::NonFinite,
153            validate::FieldError::FloatParse { .. } => Self::FloatParse,
154            validate::FieldError::IntParse { .. } => Self::IntParse,
155            validate::FieldError::NotPositive { .. } => Self::NotPositive,
156            validate::FieldError::Negative { .. } => Self::Negative,
157            validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
158            validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
159            validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
160        }
161    }
162}
163
164impl fmt::Display for OemError {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        match self {
167            OemError::MissingField(name) => write!(f, "OEM missing required field {name}"),
168            OemError::InvalidField { field, kind } => {
169                write!(f, "invalid OEM field {field}: {kind}")
170            }
171            OemError::Field(msg) => write!(f, "OEM field error: {msg}"),
172        }
173    }
174}
175
176impl std::error::Error for OemError {}
177
178/// Parse a CCSDS OEM in KVN encoding into an [`Oem`].
179pub fn parse_kvn(text: &str) -> Result<Oem, OemError> {
180    let lines = numbered_lines(text);
181    let header_map = FieldMap::from_pairs(parse_kv_lines(
182        &lines
183            .iter()
184            .map(|(_, line)| line.clone())
185            .collect::<Vec<_>>(),
186    ));
187    let header = NdmHeader::read(&header_map, OEM_VERSION_KEY);
188    if header.vers.is_empty() {
189        return Err(OemError::MissingField(OEM_VERSION_KEY));
190    }
191
192    let mut diagnostics = Diagnostics::new();
193    let mut segments = Vec::new();
194    let mut idx = 0usize;
195
196    while idx < lines.len() {
197        let line = lines[idx].1.trim();
198        if line == META_START {
199            let (segment, next_idx) = parse_kvn_segment(&lines, idx, &mut diagnostics)?;
200            segments.push(segment);
201            idx = next_idx;
202        } else {
203            idx += 1;
204        }
205    }
206
207    if segments.is_empty() {
208        return Err(OemError::Field("OEM contains no segment".to_string()));
209    }
210
211    let skipped_states = diagnostics.skips.len();
212    Ok(Oem {
213        ccsds_oem_vers: header.vers,
214        creation_date: header.creation_date,
215        originator: header.originator,
216        segments,
217        skipped_states,
218    })
219}
220
221/// Encode an [`Oem`] as CCSDS OEM KVN.
222pub fn encode_kvn(oem: &Oem) -> String {
223    let mut lines = NdmHeader {
224        vers: oem.ccsds_oem_vers.clone(),
225        creation_date: oem.creation_date.clone(),
226        originator: oem.originator.clone(),
227    }
228    .write_kvn(OEM_VERSION_KEY);
229
230    for segment in &oem.segments {
231        lines.push(META_START.to_string());
232        lines.extend(encode_metadata_kvn(&segment.metadata));
233        lines.push(META_STOP.to_string());
234
235        for state in &segment.states {
236            lines.push(encode_state_kvn(state));
237        }
238
239        for covariance in &segment.covariances {
240            lines.push(COVARIANCE_START.to_string());
241            lines.push(format!("EPOCH = {}", covariance.epoch));
242            if let Some(cov_ref_frame) = &covariance.cov_ref_frame {
243                lines.push(format!("COV_REF_FRAME = {cov_ref_frame}"));
244            }
245            lines.extend(write_covariance6(&covariance.matrix));
246            lines.push(COVARIANCE_STOP.to_string());
247        }
248    }
249
250    lines.join("\n")
251}
252
253/// Parse a CCSDS OEM in XML encoding into an [`Oem`].
254pub fn parse_xml(text: &str) -> Result<Oem, OemError> {
255    let doc = Document::parse(text).map_err(|e| OemError::Field(format!("malformed XML: {e}")))?;
256    let oem_node = doc
257        .descendants()
258        .find(|n| n.is_element() && n.tag_name().name() == "oem")
259        .ok_or_else(|| OemError::Field("missing oem element".to_string()))?;
260
261    let version = oem_node
262        .attribute("version")
263        .map(str::trim)
264        .filter(|value| !value.is_empty())
265        .map(str::to_string)
266        .or_else(|| node_text(oem_node, OEM_VERSION_KEY))
267        .ok_or(OemError::MissingField(OEM_VERSION_KEY))?;
268
269    let segments: Vec<OemSegment> = oem_node
270        .descendants()
271        .filter(|n| n.is_element() && n.tag_name().name() == "segment")
272        .map(parse_xml_segment)
273        .collect::<Result<_, _>>()?;
274
275    if segments.is_empty() {
276        return Err(OemError::Field("OEM contains no segment".to_string()));
277    }
278
279    Ok(Oem {
280        ccsds_oem_vers: version,
281        creation_date: node_text(oem_node, "CREATION_DATE"),
282        originator: node_text(oem_node, "ORIGINATOR"),
283        segments,
284        skipped_states: 0,
285    })
286}
287
288/// Encode an [`Oem`] as CCSDS OEM XML.
289pub fn encode_xml(oem: &Oem) -> String {
290    let mut lines = vec![
291        r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string(),
292        format!(
293            r#"<oem id="CCSDS_OEM_VERS" version="{}">"#,
294            xml::escape(&oem.ccsds_oem_vers)
295        ),
296        "  <header>".to_string(),
297        format!(
298            "    <CREATION_DATE>{}</CREATION_DATE>",
299            xml::escape_opt(&oem.creation_date)
300        ),
301        format!(
302            "    <ORIGINATOR>{}</ORIGINATOR>",
303            xml::escape_opt(&oem.originator)
304        ),
305        "  </header>".to_string(),
306        "  <body>".to_string(),
307    ];
308
309    for segment in &oem.segments {
310        lines.extend(encode_xml_segment(segment));
311    }
312
313    lines.push("  </body>".to_string());
314    lines.push("</oem>".to_string());
315    lines.join("\n")
316}
317
318fn parse_kvn_segment(
319    lines: &[(usize, String)],
320    start_idx: usize,
321    diagnostics: &mut Diagnostics,
322) -> Result<(OemSegment, usize), OemError> {
323    let mut idx = start_idx + 1;
324    let mut metadata_lines = Vec::new();
325    while idx < lines.len() {
326        let line = lines[idx].1.trim();
327        if line == META_STOP {
328            break;
329        }
330        metadata_lines.push(lines[idx].1.clone());
331        idx += 1;
332    }
333    if idx >= lines.len() {
334        return Err(OemError::Field("META_START without META_STOP".to_string()));
335    }
336
337    let metadata = parse_metadata(&FieldMap::from_pairs(parse_kv_lines(&metadata_lines)))?;
338    idx += 1;
339
340    let mut states = Vec::new();
341    let mut covariances = Vec::new();
342    while idx < lines.len() {
343        let (line_no, raw) = &lines[idx];
344        let line = raw.trim();
345
346        if line == META_START {
347            break;
348        }
349        if line.is_empty() || line.starts_with(COMMENT_PREFIX) {
350            idx += 1;
351            continue;
352        }
353        if line == COVARIANCE_START {
354            let (covariance, next_idx) = parse_kvn_covariance(lines, idx)?;
355            covariances.push(covariance);
356            idx = next_idx;
357            continue;
358        }
359        if line == COVARIANCE_STOP {
360            return Err(OemError::Field(
361                "COVARIANCE_STOP without COVARIANCE_START".to_string(),
362            ));
363        }
364
365        match parse_state_line(line) {
366            Ok(state) => states.push(state),
367            Err(StateLineError::WrongTokenCount) => diagnostics.push_skip(Skip {
368                at: RecordRef::at_line(*line_no),
369                reason: SkipReason::Truncated,
370            }),
371            Err(StateLineError::Field(error)) => diagnostics.push_skip(Skip {
372                at: RecordRef::at_line(*line_no),
373                reason: SkipReason::MalformedField(error),
374            }),
375        }
376        idx += 1;
377    }
378
379    Ok((
380        OemSegment {
381            metadata,
382            states,
383            covariances,
384        },
385        idx,
386    ))
387}
388
389fn parse_kvn_covariance(
390    lines: &[(usize, String)],
391    start_idx: usize,
392) -> Result<(OemCovariance, usize), OemError> {
393    let mut idx = start_idx + 1;
394    let mut covariance_lines = Vec::new();
395    while idx < lines.len() {
396        let line = lines[idx].1.trim();
397        if line == COVARIANCE_STOP {
398            let covariance =
399                parse_covariance_map(&FieldMap::from_pairs(parse_kv_lines(&covariance_lines)))?;
400            return Ok((covariance, idx + 1));
401        }
402        if !line.is_empty() && !line.starts_with(COMMENT_PREFIX) {
403            covariance_lines.push(lines[idx].1.clone());
404        }
405        idx += 1;
406    }
407
408    Err(OemError::Field(
409        "COVARIANCE_START without COVARIANCE_STOP".to_string(),
410    ))
411}
412
413fn parse_metadata(map: &FieldMap) -> Result<OemMetadata, OemError> {
414    Ok(OemMetadata {
415        object_name: req_text(map, "OBJECT_NAME")?,
416        object_id: req_text(map, "OBJECT_ID")?,
417        center_name: req_text(map, "CENTER_NAME")?,
418        ref_frame: req_text(map, "REF_FRAME")?,
419        time_system: req_text(map, "TIME_SYSTEM")?,
420        start_time: req_text(map, "START_TIME")?,
421        stop_time: req_text(map, "STOP_TIME")?,
422        useable_start_time: opt_text(map, "USEABLE_START_TIME"),
423        useable_stop_time: opt_text(map, "USEABLE_STOP_TIME"),
424        interpolation: opt_text(map, "INTERPOLATION"),
425        interpolation_degree: opt_u32(map, "INTERPOLATION_DEGREE")?,
426    })
427}
428
429fn parse_state_line(line: &str) -> Result<OemState, StateLineError> {
430    let mut tokenizer = Tokenizer::new(line);
431    let mut tokens = Vec::new();
432    while let Some(token) = tokenizer.next_str() {
433        tokens.push(token);
434    }
435
436    if tokens.len() != 7 && tokens.len() != 10 {
437        return Err(StateLineError::WrongTokenCount);
438    }
439
440    let epoch = tokens[0].to_string();
441    let mut values = [0.0_f64; 9];
442    for (idx, key) in STATE_NUMBER_KEYS.iter().enumerate().take(tokens.len() - 1) {
443        values[idx] = validate::strict_f64(tokens[idx + 1], key).map_err(StateLineError::Field)?;
444    }
445
446    let acceleration_km_s2 = if tokens.len() == 10 {
447        Some([values[6], values[7], values[8]])
448    } else {
449        None
450    };
451
452    Ok(OemState {
453        epoch,
454        position_km: [values[0], values[1], values[2]],
455        velocity_km_s: [values[3], values[4], values[5]],
456        acceleration_km_s2,
457    })
458}
459
460fn parse_covariance_map(map: &FieldMap) -> Result<OemCovariance, OemError> {
461    Ok(OemCovariance {
462        epoch: req_text(map, "EPOCH")?,
463        cov_ref_frame: opt_text(map, "COV_REF_FRAME"),
464        matrix: read_covariance6(map).map_err(map_oem_field_error)?,
465    })
466}
467
468fn parse_xml_segment(segment: Node) -> Result<OemSegment, OemError> {
469    let metadata_node = child_element(segment, "metadata")
470        .ok_or_else(|| OemError::Field("segment missing metadata".to_string()))?;
471    let data_node = child_element(segment, "data")
472        .ok_or_else(|| OemError::Field("segment missing data".to_string()))?;
473
474    let metadata = parse_metadata(&FieldMap::from_pairs(xml_fields(
475        metadata_node,
476        &METADATA_KEYS,
477    )))?;
478    let states = data_node
479        .descendants()
480        .filter(|n| n.is_element() && n.tag_name().name() == "stateVector")
481        .map(parse_xml_state)
482        .collect::<Result<Vec<_>, _>>()?;
483    let covariances = data_node
484        .descendants()
485        .filter(|n| n.is_element() && n.tag_name().name() == "covarianceMatrix")
486        .map(parse_xml_covariance)
487        .collect::<Result<Vec<_>, _>>()?;
488
489    Ok(OemSegment {
490        metadata,
491        states,
492        covariances,
493    })
494}
495
496fn parse_xml_state(node: Node) -> Result<OemState, OemError> {
497    let epoch = node_text(node, "EPOCH").ok_or(OemError::MissingField("EPOCH"))?;
498    let x = required_node_num(node, "X")?;
499    let y = required_node_num(node, "Y")?;
500    let z = required_node_num(node, "Z")?;
501    let xd = required_node_num(node, "X_DOT")?;
502    let yd = required_node_num(node, "Y_DOT")?;
503    let zd = required_node_num(node, "Z_DOT")?;
504
505    let acceleration_km_s2 = match (
506        node_text(node, "X_DDOT"),
507        node_text(node, "Y_DDOT"),
508        node_text(node, "Z_DDOT"),
509    ) {
510        (None, None, None) => None,
511        (Some(xdd), Some(ydd), Some(zdd)) => Some([
512            parse_num(&xdd, "X_DDOT")?,
513            parse_num(&ydd, "Y_DDOT")?,
514            parse_num(&zdd, "Z_DDOT")?,
515        ]),
516        _ => {
517            return Err(OemError::Field(
518                "stateVector acceleration must contain X_DDOT, Y_DDOT, and Z_DDOT".to_string(),
519            ))
520        }
521    };
522
523    Ok(OemState {
524        epoch,
525        position_km: [x, y, z],
526        velocity_km_s: [xd, yd, zd],
527        acceleration_km_s2,
528    })
529}
530
531fn parse_xml_covariance(node: Node) -> Result<OemCovariance, OemError> {
532    let fields = node
533        .descendants()
534        .filter(Node::is_element)
535        .filter_map(|n| {
536            let text = n.text()?.trim();
537            if text.is_empty() {
538                None
539            } else {
540                Some((n.tag_name().name().to_string(), text.to_string()))
541            }
542        })
543        .collect();
544    parse_covariance_map(&FieldMap::from_pairs(fields))
545}
546
547fn encode_metadata_kvn(metadata: &OemMetadata) -> Vec<String> {
548    let mut lines = vec![
549        format!("OBJECT_NAME = {}", metadata.object_name),
550        format!("OBJECT_ID = {}", metadata.object_id),
551        format!("CENTER_NAME = {}", metadata.center_name),
552        format!("REF_FRAME = {}", metadata.ref_frame),
553        format!("TIME_SYSTEM = {}", metadata.time_system),
554        format!("START_TIME = {}", metadata.start_time),
555        format!("STOP_TIME = {}", metadata.stop_time),
556    ];
557    if let Some(value) = &metadata.useable_start_time {
558        lines.push(format!("USEABLE_START_TIME = {value}"));
559    }
560    if let Some(value) = &metadata.useable_stop_time {
561        lines.push(format!("USEABLE_STOP_TIME = {value}"));
562    }
563    if let Some(value) = &metadata.interpolation {
564        lines.push(format!("INTERPOLATION = {value}"));
565    }
566    if let Some(value) = metadata.interpolation_degree {
567        lines.push(format!("INTERPOLATION_DEGREE = {value}"));
568    }
569    lines
570}
571
572fn encode_state_kvn(state: &OemState) -> String {
573    let mut fields = vec![
574        state.epoch.clone(),
575        fmt_num(state.position_km[0]),
576        fmt_num(state.position_km[1]),
577        fmt_num(state.position_km[2]),
578        fmt_num(state.velocity_km_s[0]),
579        fmt_num(state.velocity_km_s[1]),
580        fmt_num(state.velocity_km_s[2]),
581    ];
582    if let Some(accel) = state.acceleration_km_s2 {
583        fields.extend([fmt_num(accel[0]), fmt_num(accel[1]), fmt_num(accel[2])]);
584    }
585    fields.join(" ")
586}
587
588fn encode_xml_segment(segment: &OemSegment) -> Vec<String> {
589    let mut lines = vec![
590        "    <segment>".to_string(),
591        "      <metadata>".to_string(),
592        elem_line(8, "OBJECT_NAME", &segment.metadata.object_name),
593        elem_line(8, "OBJECT_ID", &segment.metadata.object_id),
594        elem_line(8, "CENTER_NAME", &segment.metadata.center_name),
595        elem_line(8, "REF_FRAME", &segment.metadata.ref_frame),
596        elem_line(8, "TIME_SYSTEM", &segment.metadata.time_system),
597        elem_line(8, "START_TIME", &segment.metadata.start_time),
598        elem_line(8, "STOP_TIME", &segment.metadata.stop_time),
599    ];
600    if let Some(value) = &segment.metadata.useable_start_time {
601        lines.push(elem_line(8, "USEABLE_START_TIME", value));
602    }
603    if let Some(value) = &segment.metadata.useable_stop_time {
604        lines.push(elem_line(8, "USEABLE_STOP_TIME", value));
605    }
606    if let Some(value) = &segment.metadata.interpolation {
607        lines.push(elem_line(8, "INTERPOLATION", value));
608    }
609    if let Some(value) = segment.metadata.interpolation_degree {
610        lines.push(elem_line_raw(8, "INTERPOLATION_DEGREE", &value.to_string()));
611    }
612    lines.push("      </metadata>".to_string());
613    lines.push("      <data>".to_string());
614
615    for state in &segment.states {
616        lines.extend(encode_xml_state(state));
617    }
618    for covariance in &segment.covariances {
619        lines.extend(encode_xml_covariance(covariance));
620    }
621
622    lines.push("      </data>".to_string());
623    lines.push("    </segment>".to_string());
624    lines
625}
626
627fn encode_xml_state(state: &OemState) -> Vec<String> {
628    let mut lines = vec![
629        "        <stateVector>".to_string(),
630        elem_line(10, "EPOCH", &state.epoch),
631        elem_line_raw(10, "X", &fmt_num(state.position_km[0])),
632        elem_line_raw(10, "Y", &fmt_num(state.position_km[1])),
633        elem_line_raw(10, "Z", &fmt_num(state.position_km[2])),
634        elem_line_raw(10, "X_DOT", &fmt_num(state.velocity_km_s[0])),
635        elem_line_raw(10, "Y_DOT", &fmt_num(state.velocity_km_s[1])),
636        elem_line_raw(10, "Z_DOT", &fmt_num(state.velocity_km_s[2])),
637    ];
638    if let Some(accel) = state.acceleration_km_s2 {
639        lines.push(elem_line_raw(10, "X_DDOT", &fmt_num(accel[0])));
640        lines.push(elem_line_raw(10, "Y_DDOT", &fmt_num(accel[1])));
641        lines.push(elem_line_raw(10, "Z_DDOT", &fmt_num(accel[2])));
642    }
643    lines.push("        </stateVector>".to_string());
644    lines
645}
646
647fn encode_xml_covariance(covariance: &OemCovariance) -> Vec<String> {
648    let mut lines = vec![
649        "        <covarianceMatrix>".to_string(),
650        elem_line(10, "EPOCH", &covariance.epoch),
651    ];
652    if let Some(value) = &covariance.cov_ref_frame {
653        lines.push(elem_line(10, "COV_REF_FRAME", value));
654    }
655    for line in write_covariance6(&covariance.matrix) {
656        if let Some((key, value)) = line.split_once('=') {
657            lines.push(elem_line_raw(10, key.trim(), value.trim()));
658        }
659    }
660    lines.push("        </covarianceMatrix>".to_string());
661    lines
662}
663
664fn numbered_lines(text: &str) -> Vec<(usize, String)> {
665    text.lines()
666        .enumerate()
667        .map(|(idx, line)| (idx + 1, line.trim().to_string()))
668        .collect()
669}
670
671fn parse_kv_lines(lines: &[String]) -> Vec<(String, String)> {
672    lines
673        .iter()
674        .filter_map(|line| {
675            line.split_once('=').map(|(key, value)| {
676                (
677                    key.trim().to_string(),
678                    strip_units(value.trim()).to_string(),
679                )
680            })
681        })
682        .collect()
683}
684
685fn strip_units(value: &str) -> &str {
686    let trimmed = value.trim_end();
687    if let Some(open) = trimmed.rfind('[') {
688        if trimmed.ends_with(']') {
689            return trimmed[..open].trim_end();
690        }
691    }
692    trimmed
693}
694
695fn req_text(map: &FieldMap, field: &'static str) -> Result<String, OemError> {
696    map.get(field)
697        .map(str::to_string)
698        .ok_or(OemError::MissingField(field))
699}
700
701fn opt_text(map: &FieldMap, field: &'static str) -> Option<String> {
702    map.get(field).map(str::to_string)
703}
704
705fn opt_u32(map: &FieldMap, field: &'static str) -> Result<Option<u32>, OemError> {
706    map.get(field)
707        .map(|value| validate::strict_int::<u32>(value, field).map_err(map_oem_field_error))
708        .transpose()
709}
710
711fn required_node_num(node: Node, tag: &'static str) -> Result<f64, OemError> {
712    let value = node_text(node, tag).ok_or(OemError::MissingField(tag))?;
713    parse_num(&value, tag)
714}
715
716fn parse_num(value: &str, field: &'static str) -> Result<f64, OemError> {
717    validate::strict_f64(value, field).map_err(map_oem_field_error)
718}
719
720fn map_oem_field_error(error: validate::FieldError) -> OemError {
721    OemError::InvalidField {
722        field: error.field(),
723        kind: OemInputErrorKind::from(&error),
724    }
725}
726
727fn node_text(node: Node, tag: &str) -> Option<String> {
728    let element = node
729        .descendants()
730        .find(|n| n.is_element() && n.tag_name().name() == tag)?;
731    let text = element.text()?.trim();
732    if text.is_empty() {
733        None
734    } else {
735        Some(text.to_string())
736    }
737}
738
739fn child_element<'a>(node: Node<'a, 'a>, tag: &str) -> Option<Node<'a, 'a>> {
740    node.children()
741        .find(|n| n.is_element() && n.tag_name().name() == tag)
742}
743
744fn xml_fields(node: Node, keys: &[&str]) -> Vec<(String, String)> {
745    keys.iter()
746        .filter_map(|key| node_text(node, key).map(|value| ((*key).to_string(), value)))
747        .collect()
748}
749
750fn elem_line(indent: usize, name: &str, value: &str) -> String {
751    elem_line_raw(indent, name, &xml::escape(value))
752}
753
754fn elem_line_raw(indent: usize, name: &str, value: &str) -> String {
755    format!("{:indent$}<{name}>{value}</{name}>", "")
756}
757
758enum StateLineError {
759    WrongTokenCount,
760    Field(validate::FieldError),
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766
767    fn diagonal_covariance() -> Covariance6 {
768        Covariance6::from_diagonal([1.0, 2.0, 3.0, 4.0e-6, 5.0e-6, 6.0e-6]).unwrap()
769    }
770
771    #[test]
772    fn forgiving_kvn_skips_malformed_state_lines() {
773        let kvn = "\
774CCSDS_OEM_VERS = 2.0
775CREATION_DATE = 2026-06-28T00:00:00
776ORIGINATOR = SIDEREON
777META_START
778OBJECT_NAME = TEST
779OBJECT_ID = 2026-001A
780CENTER_NAME = EARTH
781REF_FRAME = EME2000
782TIME_SYSTEM = UTC
783START_TIME = 2026-06-28T00:00:00
784STOP_TIME = 2026-06-28T00:10:00
785META_STOP
7862026-06-28T00:00:00 1 2 3 0.1 0.2 0.3
7872026-06-28T00:05:00 1 2
7882026-06-28T00:10:00 1 2 3 0.1 NaN 0.3
789";
790        let oem = parse_kvn(kvn).expect("forgiving OEM parse");
791        assert_eq!(oem.segments[0].states.len(), 1);
792        assert_eq!(oem.skipped_states, 2);
793    }
794
795    #[test]
796    fn malformed_xml_is_an_error() {
797        assert!(parse_xml("<oem></oem><oem></oem>").is_err());
798    }
799
800    #[test]
801    fn covariance_round_trips_through_kvn_and_xml() {
802        let original = Oem {
803            ccsds_oem_vers: "2.0".to_string(),
804            creation_date: Some("2026-06-28T00:00:00".to_string()),
805            originator: Some("SIDEREON".to_string()),
806            skipped_states: 0,
807            segments: vec![OemSegment {
808                metadata: OemMetadata {
809                    object_name: "TEST".to_string(),
810                    object_id: "2026-001A".to_string(),
811                    center_name: "EARTH".to_string(),
812                    ref_frame: "EME2000".to_string(),
813                    time_system: "UTC".to_string(),
814                    start_time: "2026-06-28T00:00:00".to_string(),
815                    stop_time: "2026-06-28T00:10:00".to_string(),
816                    useable_start_time: None,
817                    useable_stop_time: None,
818                    interpolation: Some("LAGRANGE".to_string()),
819                    interpolation_degree: Some(5),
820                },
821                states: vec![OemState {
822                    epoch: "2026-06-28T00:00:00".to_string(),
823                    position_km: [1.0, 2.0, 3.0],
824                    velocity_km_s: [0.1, 0.2, 0.3],
825                    acceleration_km_s2: None,
826                }],
827                covariances: vec![OemCovariance {
828                    epoch: "2026-06-28T00:00:00".to_string(),
829                    cov_ref_frame: Some("RTN".to_string()),
830                    matrix: diagonal_covariance(),
831                }],
832            }],
833        };
834
835        assert_eq!(parse_kvn(&encode_kvn(&original)).unwrap(), original);
836        assert_eq!(parse_xml(&encode_xml(&original)).unwrap(), original);
837    }
838
839    #[cfg(all(test, sidereon_repo_tests))]
840    mod fixtures {
841        use super::*;
842
843        const GPS_KVN: &str = include_str!("../../tests/fixtures/oem/gps.kvn");
844        const GPS_XML: &str = include_str!("../../tests/fixtures/oem/gps.xml");
845
846        #[test]
847        fn parses_gps_kvn_fixture() {
848            let oem = parse_kvn(GPS_KVN).unwrap();
849            assert_eq!(oem.ccsds_oem_vers, "2.0");
850            assert_eq!(oem.originator.as_deref(), Some("SIDEREON TEST"));
851            assert_eq!(oem.segments.len(), 1);
852            assert_eq!(oem.segments[0].metadata.object_name, "GPS BIIRM-8");
853            assert_eq!(oem.segments[0].states.len(), 3);
854            assert_eq!(oem.segments[0].covariances.len(), 1);
855            assert_eq!(oem.skipped_states, 0);
856        }
857
858        #[test]
859        fn parses_gps_xml_fixture() {
860            let oem = parse_xml(GPS_XML).unwrap();
861            assert_eq!(oem.ccsds_oem_vers, "2.0");
862            assert_eq!(oem.segments[0].metadata.object_id, "2005-038A");
863            assert_eq!(oem.segments[0].states[1].epoch, "2026-06-28T00:15:00.000");
864            assert_eq!(
865                oem.segments[0].covariances[0].cov_ref_frame.as_deref(),
866                Some("RTN")
867            );
868        }
869
870        #[test]
871        fn fixture_kvn_round_trips() {
872            let oem = parse_kvn(GPS_KVN).unwrap();
873            assert_eq!(parse_kvn(&encode_kvn(&oem)).unwrap(), oem);
874        }
875
876        #[test]
877        fn fixture_xml_round_trips() {
878            let oem = parse_xml(GPS_XML).unwrap();
879            assert_eq!(parse_xml(&encode_xml(&oem)).unwrap(), oem);
880        }
881    }
882}