Skip to main content

sidereon_core/astro/
opm.rs

1//! CCSDS Orbit Parameter Message (OPM) KVN and XML reader/writer.
2//!
3//! OPM date/time values are preserved as text. The parser validates required
4//! scalar presence and numeric fields, but it does not normalize epochs.
5
6use crate::astro::covariance::Covariance6;
7use crate::astro::ndm::{
8    read_covariance6, write_covariance6, FieldMap, NdmHeader, COVARIANCE6_KEYS,
9};
10use crate::astro::xml;
11use crate::format::fmtnum::fmt_num;
12use crate::validate;
13use roxmltree::{Document, Node};
14use std::fmt;
15
16const COMMENT_PREFIX: &str = "COMMENT";
17const OPM_VERSION_KEY: &str = "CCSDS_OPM_VERS";
18
19const METADATA_KEYS: [&str; 5] = [
20    "OBJECT_NAME",
21    "OBJECT_ID",
22    "CENTER_NAME",
23    "REF_FRAME",
24    "TIME_SYSTEM",
25];
26const STATE_KEYS: [&str; 7] = ["EPOCH", "X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT"];
27const KEPLERIAN_KEYS: [&str; 8] = [
28    "SEMI_MAJOR_AXIS",
29    "ECCENTRICITY",
30    "INCLINATION",
31    "RA_OF_ASC_NODE",
32    "ARG_OF_PERICENTER",
33    "TRUE_ANOMALY",
34    "MEAN_ANOMALY",
35    "GM",
36];
37const SPACECRAFT_KEYS: [&str; 5] = [
38    "MASS",
39    "SOLAR_RAD_AREA",
40    "SOLAR_RAD_COEFF",
41    "DRAG_AREA",
42    "DRAG_COEFF",
43];
44const MANEUVER_KEYS: [&str; 7] = [
45    "MAN_EPOCH_IGNITION",
46    "MAN_DURATION",
47    "MAN_DELTA_MASS",
48    "MAN_REF_FRAME",
49    "MAN_DV_1",
50    "MAN_DV_2",
51    "MAN_DV_3",
52];
53
54/// Canonical, format-agnostic OPM container.
55#[derive(Debug, Clone, PartialEq)]
56pub struct Opm {
57    pub ccsds_opm_vers: String,
58    pub creation_date: Option<String>,
59    pub originator: Option<String>,
60    pub metadata: OpmMetadata,
61    pub state: OpmState,
62    pub keplerian: Option<OpmKeplerian>,
63    pub spacecraft: Option<OpmSpacecraft>,
64    pub covariance: Option<OpmCovariance>,
65    pub maneuvers: Vec<OpmManeuver>,
66}
67
68/// OPM metadata block.
69#[derive(Debug, Clone, PartialEq)]
70pub struct OpmMetadata {
71    pub object_name: String,
72    pub object_id: String,
73    pub center_name: String,
74    pub ref_frame: String,
75    pub time_system: String,
76}
77
78/// OPM Cartesian state vector.
79#[derive(Debug, Clone, PartialEq)]
80pub struct OpmState {
81    pub epoch: String,
82    pub position_km: [f64; 3],
83    pub velocity_km_s: [f64; 3],
84}
85
86/// Optional OPM Keplerian elements.
87#[derive(Debug, Clone, PartialEq)]
88pub struct OpmKeplerian {
89    pub semi_major_axis_km: f64,
90    pub eccentricity: f64,
91    pub inclination_deg: f64,
92    pub ra_of_asc_node_deg: f64,
93    pub arg_of_pericenter_deg: f64,
94    pub anomaly: OpmAnomaly,
95    pub gm_km3_s2: f64,
96}
97
98/// OPM true or mean anomaly.
99#[derive(Debug, Clone, PartialEq)]
100pub enum OpmAnomaly {
101    True(f64),
102    Mean(f64),
103}
104
105/// Optional OPM spacecraft parameters.
106#[derive(Debug, Clone, PartialEq)]
107pub struct OpmSpacecraft {
108    pub mass_kg: Option<f64>,
109    pub solar_rad_area_m2: Option<f64>,
110    pub solar_rad_coeff: Option<f64>,
111    pub drag_area_m2: Option<f64>,
112    pub drag_coeff: Option<f64>,
113}
114
115/// Optional OPM 6x6 covariance.
116#[derive(Debug, Clone, PartialEq)]
117pub struct OpmCovariance {
118    pub cov_ref_frame: Option<String>,
119    pub matrix: Covariance6,
120}
121
122/// One OPM maneuver block. Every field is mandatory in CCSDS 502.0-B when a
123/// maneuver is present, including `MAN_REF_FRAME`.
124#[derive(Debug, Clone, PartialEq)]
125pub struct OpmManeuver {
126    pub epoch_ignition: String,
127    pub duration_s: f64,
128    pub delta_mass_kg: f64,
129    pub ref_frame: String,
130    pub dv_km_s: [f64; 3],
131}
132
133/// Failure modes of the OPM readers.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum OpmError {
136    /// A required field was absent from the message.
137    MissingField(&'static str),
138    /// A decoded scalar field failed validation.
139    InvalidField {
140        field: &'static str,
141        kind: OpmInputErrorKind,
142    },
143    /// A structural or XML-level error.
144    Field(String),
145}
146
147/// OPM boundary-validation failure category.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum OpmInputErrorKind {
150    /// A required field was absent.
151    Missing,
152    /// A floating-point field was NaN or infinite.
153    NonFinite,
154    /// A floating-point field could not be parsed.
155    FloatParse,
156    /// An integer field could not be parsed.
157    IntParse,
158    /// A positive physical field was zero or negative.
159    NotPositive,
160    /// A non-negative physical field was negative.
161    Negative,
162    /// A finite numeric field was outside its accepted range.
163    OutOfRange,
164    /// A civil date field was out of range.
165    InvalidCivilDate,
166    /// A civil time field was out of range.
167    InvalidCivilTime,
168}
169
170impl fmt::Display for OpmInputErrorKind {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        let label = match self {
173            Self::Missing => "missing",
174            Self::NonFinite => "not finite",
175            Self::FloatParse => "invalid float",
176            Self::IntParse => "invalid integer",
177            Self::NotPositive => "not positive",
178            Self::Negative => "negative",
179            Self::OutOfRange => "out of range",
180            Self::InvalidCivilDate => "invalid civil date",
181            Self::InvalidCivilTime => "invalid civil time",
182        };
183        f.write_str(label)
184    }
185}
186
187impl From<&validate::FieldError> for OpmInputErrorKind {
188    fn from(error: &validate::FieldError) -> Self {
189        match error {
190            validate::FieldError::Missing { .. } => Self::Missing,
191            validate::FieldError::NonFinite { .. } => Self::NonFinite,
192            validate::FieldError::FloatParse { .. } => Self::FloatParse,
193            validate::FieldError::IntParse { .. } => Self::IntParse,
194            validate::FieldError::NotPositive { .. } => Self::NotPositive,
195            validate::FieldError::Negative { .. } => Self::Negative,
196            validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
197            validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
198            validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
199        }
200    }
201}
202
203impl fmt::Display for OpmError {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        match self {
206            OpmError::MissingField(name) => write!(f, "OPM missing required field {name}"),
207            OpmError::InvalidField { field, kind } => {
208                write!(f, "invalid OPM field {field}: {kind}")
209            }
210            OpmError::Field(msg) => write!(f, "OPM field error: {msg}"),
211        }
212    }
213}
214
215impl std::error::Error for OpmError {}
216
217/// Parse a CCSDS OPM in KVN encoding into an [`Opm`].
218pub fn parse_kvn(text: &str) -> Result<Opm, OpmError> {
219    let lines = significant_lines(text);
220    let (base_lines, maneuver_blocks) = split_maneuver_blocks(&lines);
221    let map = FieldMap::from_pairs(parse_kv_lines(&base_lines));
222    let header = NdmHeader::read(&map, OPM_VERSION_KEY);
223    if header.vers.is_empty() {
224        return Err(OpmError::MissingField(OPM_VERSION_KEY));
225    }
226
227    Ok(Opm {
228        ccsds_opm_vers: header.vers,
229        creation_date: header.creation_date,
230        originator: header.originator,
231        metadata: parse_metadata(&map)?,
232        state: parse_state(&map)?,
233        keplerian: keplerian_present(&map)
234            .then(|| parse_keplerian(&map))
235            .transpose()?,
236        spacecraft: spacecraft_present(&map)
237            .then(|| parse_spacecraft(&map))
238            .transpose()?,
239        covariance: covariance_present(&map)
240            .then(|| parse_covariance(&map))
241            .transpose()?,
242        maneuvers: maneuver_blocks
243            .iter()
244            .map(|block| parse_maneuver(&FieldMap::from_pairs(parse_kv_lines(block))))
245            .collect::<Result<_, _>>()?,
246    })
247}
248
249/// Encode an [`Opm`] as CCSDS OPM KVN.
250pub fn encode_kvn(opm: &Opm) -> String {
251    let mut lines = NdmHeader {
252        vers: opm.ccsds_opm_vers.clone(),
253        creation_date: opm.creation_date.clone(),
254        originator: opm.originator.clone(),
255    }
256    .write_kvn(OPM_VERSION_KEY);
257
258    lines.extend(encode_metadata_kvn(&opm.metadata));
259    lines.extend(encode_state_kvn(&opm.state));
260    if let Some(keplerian) = &opm.keplerian {
261        lines.extend(encode_keplerian_kvn(keplerian));
262    }
263    if let Some(spacecraft) = &opm.spacecraft {
264        lines.extend(encode_spacecraft_kvn(spacecraft));
265    }
266    if let Some(covariance) = &opm.covariance {
267        if let Some(cov_ref_frame) = &covariance.cov_ref_frame {
268            lines.push(format!("COV_REF_FRAME = {cov_ref_frame}"));
269        }
270        lines.extend(write_covariance6(&covariance.matrix));
271    }
272    for maneuver in &opm.maneuvers {
273        lines.extend(encode_maneuver_kvn(maneuver));
274    }
275
276    lines.join("\n")
277}
278
279/// Parse a CCSDS OPM in XML encoding into an [`Opm`].
280pub fn parse_xml(text: &str) -> Result<Opm, OpmError> {
281    let doc = Document::parse(text).map_err(|e| OpmError::Field(format!("malformed XML: {e}")))?;
282    let opm_node = doc
283        .descendants()
284        .find(|n| n.is_element() && n.tag_name().name() == "opm")
285        .ok_or_else(|| OpmError::Field("missing opm element".to_string()))?;
286
287    let version = opm_node
288        .attribute("version")
289        .map(str::trim)
290        .filter(|value| !value.is_empty())
291        .map(str::to_string)
292        .or_else(|| node_text(opm_node, OPM_VERSION_KEY))
293        .ok_or(OpmError::MissingField(OPM_VERSION_KEY))?;
294
295    let segment = opm_node
296        .descendants()
297        .find(|n| n.is_element() && n.tag_name().name() == "segment")
298        .ok_or_else(|| OpmError::Field("OPM contains no segment".to_string()))?;
299    let metadata_node = child_element(segment, "metadata")
300        .ok_or_else(|| OpmError::Field("segment missing metadata".to_string()))?;
301    let data_node = child_element(segment, "data")
302        .ok_or_else(|| OpmError::Field("segment missing data".to_string()))?;
303    let state_node = data_node
304        .descendants()
305        .find(|n| n.is_element() && n.tag_name().name() == "stateVector")
306        .ok_or_else(|| OpmError::Field("data missing stateVector".to_string()))?;
307
308    let keplerian_node = data_node
309        .descendants()
310        .find(|n| n.is_element() && n.tag_name().name() == "keplerianElements");
311    let spacecraft_node = data_node
312        .descendants()
313        .find(|n| n.is_element() && n.tag_name().name() == "spacecraftParameters");
314    let covariance_node = data_node
315        .descendants()
316        .find(|n| n.is_element() && n.tag_name().name() == "covarianceMatrix");
317
318    Ok(Opm {
319        ccsds_opm_vers: version,
320        creation_date: node_text(opm_node, "CREATION_DATE"),
321        originator: node_text(opm_node, "ORIGINATOR"),
322        metadata: parse_metadata(&FieldMap::from_pairs(xml_fields(
323            metadata_node,
324            &METADATA_KEYS,
325        )))?,
326        state: parse_state(&FieldMap::from_pairs(xml_fields(state_node, &STATE_KEYS)))?,
327        keplerian: keplerian_node
328            .map(|node| parse_keplerian(&FieldMap::from_pairs(xml_fields(node, &KEPLERIAN_KEYS))))
329            .transpose()?,
330        spacecraft: spacecraft_node
331            .map(|node| parse_spacecraft(&FieldMap::from_pairs(xml_fields(node, &SPACECRAFT_KEYS))))
332            .transpose()?,
333        covariance: covariance_node
334            .map(|node| parse_covariance(&FieldMap::from_pairs(xml_all_fields(node))))
335            .transpose()?,
336        maneuvers: data_node
337            .descendants()
338            .filter(|n| n.is_element() && n.tag_name().name() == "maneuverParameters")
339            .map(|node| parse_maneuver(&FieldMap::from_pairs(xml_fields(node, &MANEUVER_KEYS))))
340            .collect::<Result<_, _>>()?,
341    })
342}
343
344/// Encode an [`Opm`] as CCSDS OPM XML.
345pub fn encode_xml(opm: &Opm) -> String {
346    let mut lines = vec![
347        r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string(),
348        format!(
349            r#"<opm id="CCSDS_OPM_VERS" version="{}">"#,
350            xml::escape(&opm.ccsds_opm_vers)
351        ),
352        "  <header>".to_string(),
353        format!(
354            "    <CREATION_DATE>{}</CREATION_DATE>",
355            xml::escape_opt(&opm.creation_date)
356        ),
357        format!(
358            "    <ORIGINATOR>{}</ORIGINATOR>",
359            xml::escape_opt(&opm.originator)
360        ),
361        "  </header>".to_string(),
362        "  <body>".to_string(),
363        "    <segment>".to_string(),
364        "      <metadata>".to_string(),
365        elem_line(8, "OBJECT_NAME", &opm.metadata.object_name),
366        elem_line(8, "OBJECT_ID", &opm.metadata.object_id),
367        elem_line(8, "CENTER_NAME", &opm.metadata.center_name),
368        elem_line(8, "REF_FRAME", &opm.metadata.ref_frame),
369        elem_line(8, "TIME_SYSTEM", &opm.metadata.time_system),
370        "      </metadata>".to_string(),
371        "      <data>".to_string(),
372    ];
373
374    lines.extend(encode_xml_state(&opm.state));
375    if let Some(keplerian) = &opm.keplerian {
376        lines.extend(encode_xml_keplerian(keplerian));
377    }
378    if let Some(spacecraft) = &opm.spacecraft {
379        lines.extend(encode_xml_spacecraft(spacecraft));
380    }
381    if let Some(covariance) = &opm.covariance {
382        lines.extend(encode_xml_covariance(covariance));
383    }
384    for maneuver in &opm.maneuvers {
385        lines.extend(encode_xml_maneuver(maneuver));
386    }
387
388    lines.push("      </data>".to_string());
389    lines.push("    </segment>".to_string());
390    lines.push("  </body>".to_string());
391    lines.push("</opm>".to_string());
392    lines.join("\n")
393}
394
395fn parse_metadata(map: &FieldMap) -> Result<OpmMetadata, OpmError> {
396    Ok(OpmMetadata {
397        object_name: req_text(map, "OBJECT_NAME")?,
398        object_id: req_text(map, "OBJECT_ID")?,
399        center_name: req_text(map, "CENTER_NAME")?,
400        ref_frame: req_text(map, "REF_FRAME")?,
401        time_system: req_text(map, "TIME_SYSTEM")?,
402    })
403}
404
405fn parse_state(map: &FieldMap) -> Result<OpmState, OpmError> {
406    Ok(OpmState {
407        epoch: req_text(map, "EPOCH")?,
408        position_km: [req_num(map, "X")?, req_num(map, "Y")?, req_num(map, "Z")?],
409        velocity_km_s: [
410            req_num(map, "X_DOT")?,
411            req_num(map, "Y_DOT")?,
412            req_num(map, "Z_DOT")?,
413        ],
414    })
415}
416
417/// A Keplerian-elements block is present when any of its keys (KVN) or its XML
418/// element is supplied. Once present, every CCSDS-mandatory field is required, so
419/// a malformed block errors instead of vanishing.
420fn keplerian_present(map: &FieldMap) -> bool {
421    KEPLERIAN_KEYS.iter().any(|key| map.get(key).is_some())
422}
423
424/// A spacecraft-parameters block is present when any of its keys (KVN) or its XML
425/// element is supplied. Its sub-fields are individually optional.
426fn spacecraft_present(map: &FieldMap) -> bool {
427    SPACECRAFT_KEYS.iter().any(|key| map.get(key).is_some())
428}
429
430/// A covariance block is present when its reference-frame label or any of the 21
431/// lower-triangle keys (KVN) or its XML element is supplied. Once present, every
432/// matrix component is required.
433fn covariance_present(map: &FieldMap) -> bool {
434    map.get("COV_REF_FRAME").is_some() || COVARIANCE6_KEYS.iter().any(|key| map.get(key).is_some())
435}
436
437fn parse_keplerian(map: &FieldMap) -> Result<OpmKeplerian, OpmError> {
438    let true_anomaly = opt_num(map, "TRUE_ANOMALY")?;
439    let mean_anomaly = opt_num(map, "MEAN_ANOMALY")?;
440    let anomaly = match (true_anomaly, mean_anomaly) {
441        (Some(value), None) => OpmAnomaly::True(value),
442        (None, Some(value)) => OpmAnomaly::Mean(value),
443        (None, None) => {
444            return Err(OpmError::Field(
445                "keplerianElements requires TRUE_ANOMALY or MEAN_ANOMALY".to_string(),
446            ))
447        }
448        (Some(_), Some(_)) => {
449            return Err(OpmError::Field(
450                "keplerianElements cannot contain both TRUE_ANOMALY and MEAN_ANOMALY".to_string(),
451            ))
452        }
453    };
454
455    Ok(OpmKeplerian {
456        semi_major_axis_km: req_num(map, "SEMI_MAJOR_AXIS")?,
457        eccentricity: req_num(map, "ECCENTRICITY")?,
458        inclination_deg: req_num(map, "INCLINATION")?,
459        ra_of_asc_node_deg: req_num(map, "RA_OF_ASC_NODE")?,
460        arg_of_pericenter_deg: req_num(map, "ARG_OF_PERICENTER")?,
461        anomaly,
462        gm_km3_s2: req_num(map, "GM")?,
463    })
464}
465
466fn parse_spacecraft(map: &FieldMap) -> Result<OpmSpacecraft, OpmError> {
467    Ok(OpmSpacecraft {
468        mass_kg: opt_num(map, "MASS")?,
469        solar_rad_area_m2: opt_num(map, "SOLAR_RAD_AREA")?,
470        solar_rad_coeff: opt_num(map, "SOLAR_RAD_COEFF")?,
471        drag_area_m2: opt_num(map, "DRAG_AREA")?,
472        drag_coeff: opt_num(map, "DRAG_COEFF")?,
473    })
474}
475
476fn parse_covariance(map: &FieldMap) -> Result<OpmCovariance, OpmError> {
477    Ok(OpmCovariance {
478        cov_ref_frame: opt_text(map, "COV_REF_FRAME"),
479        matrix: read_covariance6(map).map_err(map_opm_field_error)?,
480    })
481}
482
483fn parse_maneuver(map: &FieldMap) -> Result<OpmManeuver, OpmError> {
484    Ok(OpmManeuver {
485        epoch_ignition: req_text(map, "MAN_EPOCH_IGNITION")?,
486        duration_s: req_num(map, "MAN_DURATION")?,
487        delta_mass_kg: req_num(map, "MAN_DELTA_MASS")?,
488        ref_frame: req_text(map, "MAN_REF_FRAME")?,
489        dv_km_s: [
490            req_num(map, "MAN_DV_1")?,
491            req_num(map, "MAN_DV_2")?,
492            req_num(map, "MAN_DV_3")?,
493        ],
494    })
495}
496
497fn encode_metadata_kvn(metadata: &OpmMetadata) -> Vec<String> {
498    vec![
499        format!("OBJECT_NAME = {}", metadata.object_name),
500        format!("OBJECT_ID = {}", metadata.object_id),
501        format!("CENTER_NAME = {}", metadata.center_name),
502        format!("REF_FRAME = {}", metadata.ref_frame),
503        format!("TIME_SYSTEM = {}", metadata.time_system),
504    ]
505}
506
507fn encode_state_kvn(state: &OpmState) -> Vec<String> {
508    vec![
509        format!("EPOCH = {}", state.epoch),
510        format!("X = {}", fmt_num(state.position_km[0])),
511        format!("Y = {}", fmt_num(state.position_km[1])),
512        format!("Z = {}", fmt_num(state.position_km[2])),
513        format!("X_DOT = {}", fmt_num(state.velocity_km_s[0])),
514        format!("Y_DOT = {}", fmt_num(state.velocity_km_s[1])),
515        format!("Z_DOT = {}", fmt_num(state.velocity_km_s[2])),
516    ]
517}
518
519fn encode_keplerian_kvn(keplerian: &OpmKeplerian) -> Vec<String> {
520    let mut lines = vec![
521        format!(
522            "SEMI_MAJOR_AXIS = {}",
523            fmt_num(keplerian.semi_major_axis_km)
524        ),
525        format!("ECCENTRICITY = {}", fmt_num(keplerian.eccentricity)),
526        format!("INCLINATION = {}", fmt_num(keplerian.inclination_deg)),
527        format!("RA_OF_ASC_NODE = {}", fmt_num(keplerian.ra_of_asc_node_deg)),
528        format!(
529            "ARG_OF_PERICENTER = {}",
530            fmt_num(keplerian.arg_of_pericenter_deg)
531        ),
532    ];
533    match keplerian.anomaly {
534        OpmAnomaly::True(value) => lines.push(format!("TRUE_ANOMALY = {}", fmt_num(value))),
535        OpmAnomaly::Mean(value) => lines.push(format!("MEAN_ANOMALY = {}", fmt_num(value))),
536    }
537    lines.push(format!("GM = {}", fmt_num(keplerian.gm_km3_s2)));
538    lines
539}
540
541fn encode_spacecraft_kvn(spacecraft: &OpmSpacecraft) -> Vec<String> {
542    let mut lines = Vec::new();
543    push_opt_num(&mut lines, "MASS", spacecraft.mass_kg);
544    push_opt_num(&mut lines, "SOLAR_RAD_AREA", spacecraft.solar_rad_area_m2);
545    push_opt_num(&mut lines, "SOLAR_RAD_COEFF", spacecraft.solar_rad_coeff);
546    push_opt_num(&mut lines, "DRAG_AREA", spacecraft.drag_area_m2);
547    push_opt_num(&mut lines, "DRAG_COEFF", spacecraft.drag_coeff);
548    lines
549}
550
551fn encode_maneuver_kvn(maneuver: &OpmManeuver) -> Vec<String> {
552    let mut lines = vec![
553        format!("MAN_EPOCH_IGNITION = {}", maneuver.epoch_ignition),
554        format!("MAN_DURATION = {}", fmt_num(maneuver.duration_s)),
555        format!("MAN_DELTA_MASS = {}", fmt_num(maneuver.delta_mass_kg)),
556        format!("MAN_REF_FRAME = {}", maneuver.ref_frame),
557    ];
558    lines.push(format!("MAN_DV_1 = {}", fmt_num(maneuver.dv_km_s[0])));
559    lines.push(format!("MAN_DV_2 = {}", fmt_num(maneuver.dv_km_s[1])));
560    lines.push(format!("MAN_DV_3 = {}", fmt_num(maneuver.dv_km_s[2])));
561    lines
562}
563
564fn encode_xml_state(state: &OpmState) -> Vec<String> {
565    vec![
566        "        <stateVector>".to_string(),
567        elem_line(10, "EPOCH", &state.epoch),
568        elem_line_raw(10, "X", &fmt_num(state.position_km[0])),
569        elem_line_raw(10, "Y", &fmt_num(state.position_km[1])),
570        elem_line_raw(10, "Z", &fmt_num(state.position_km[2])),
571        elem_line_raw(10, "X_DOT", &fmt_num(state.velocity_km_s[0])),
572        elem_line_raw(10, "Y_DOT", &fmt_num(state.velocity_km_s[1])),
573        elem_line_raw(10, "Z_DOT", &fmt_num(state.velocity_km_s[2])),
574        "        </stateVector>".to_string(),
575    ]
576}
577
578fn encode_xml_keplerian(keplerian: &OpmKeplerian) -> Vec<String> {
579    let mut lines = vec![
580        "        <keplerianElements>".to_string(),
581        elem_line_raw(
582            10,
583            "SEMI_MAJOR_AXIS",
584            &fmt_num(keplerian.semi_major_axis_km),
585        ),
586        elem_line_raw(10, "ECCENTRICITY", &fmt_num(keplerian.eccentricity)),
587        elem_line_raw(10, "INCLINATION", &fmt_num(keplerian.inclination_deg)),
588        elem_line_raw(10, "RA_OF_ASC_NODE", &fmt_num(keplerian.ra_of_asc_node_deg)),
589        elem_line_raw(
590            10,
591            "ARG_OF_PERICENTER",
592            &fmt_num(keplerian.arg_of_pericenter_deg),
593        ),
594    ];
595    match keplerian.anomaly {
596        OpmAnomaly::True(value) => lines.push(elem_line_raw(10, "TRUE_ANOMALY", &fmt_num(value))),
597        OpmAnomaly::Mean(value) => lines.push(elem_line_raw(10, "MEAN_ANOMALY", &fmt_num(value))),
598    }
599    lines.push(elem_line_raw(10, "GM", &fmt_num(keplerian.gm_km3_s2)));
600    lines.push("        </keplerianElements>".to_string());
601    lines
602}
603
604fn encode_xml_spacecraft(spacecraft: &OpmSpacecraft) -> Vec<String> {
605    let mut lines = vec!["        <spacecraftParameters>".to_string()];
606    push_opt_xml_num(&mut lines, "MASS", spacecraft.mass_kg);
607    push_opt_xml_num(&mut lines, "SOLAR_RAD_AREA", spacecraft.solar_rad_area_m2);
608    push_opt_xml_num(&mut lines, "SOLAR_RAD_COEFF", spacecraft.solar_rad_coeff);
609    push_opt_xml_num(&mut lines, "DRAG_AREA", spacecraft.drag_area_m2);
610    push_opt_xml_num(&mut lines, "DRAG_COEFF", spacecraft.drag_coeff);
611    lines.push("        </spacecraftParameters>".to_string());
612    lines
613}
614
615fn encode_xml_covariance(covariance: &OpmCovariance) -> Vec<String> {
616    let mut lines = vec!["        <covarianceMatrix>".to_string()];
617    if let Some(value) = &covariance.cov_ref_frame {
618        lines.push(elem_line(10, "COV_REF_FRAME", value));
619    }
620    for line in write_covariance6(&covariance.matrix) {
621        if let Some((key, value)) = line.split_once('=') {
622            lines.push(elem_line_raw(10, key.trim(), value.trim()));
623        }
624    }
625    lines.push("        </covarianceMatrix>".to_string());
626    lines
627}
628
629fn encode_xml_maneuver(maneuver: &OpmManeuver) -> Vec<String> {
630    let mut lines = vec![
631        "        <maneuverParameters>".to_string(),
632        elem_line(10, "MAN_EPOCH_IGNITION", &maneuver.epoch_ignition),
633        elem_line_raw(10, "MAN_DURATION", &fmt_num(maneuver.duration_s)),
634        elem_line_raw(10, "MAN_DELTA_MASS", &fmt_num(maneuver.delta_mass_kg)),
635        elem_line(10, "MAN_REF_FRAME", &maneuver.ref_frame),
636    ];
637    lines.push(elem_line_raw(10, "MAN_DV_1", &fmt_num(maneuver.dv_km_s[0])));
638    lines.push(elem_line_raw(10, "MAN_DV_2", &fmt_num(maneuver.dv_km_s[1])));
639    lines.push(elem_line_raw(10, "MAN_DV_3", &fmt_num(maneuver.dv_km_s[2])));
640    lines.push("        </maneuverParameters>".to_string());
641    lines
642}
643
644fn significant_lines(text: &str) -> Vec<String> {
645    text.lines()
646        .map(|line| line.trim().to_string())
647        .filter(|line| !line.is_empty() && !line.starts_with(COMMENT_PREFIX))
648        .collect()
649}
650
651fn split_maneuver_blocks(lines: &[String]) -> (Vec<String>, Vec<Vec<String>>) {
652    let markers: Vec<usize> = lines
653        .iter()
654        .enumerate()
655        .filter(|(_, line)| {
656            line.split_once('=')
657                .is_some_and(|(key, _)| key.trim() == "MAN_EPOCH_IGNITION")
658        })
659        .map(|(idx, _)| idx)
660        .collect();
661
662    let Some(first_marker) = markers.first().copied() else {
663        return (lines.to_vec(), Vec::new());
664    };
665
666    let base = lines[..first_marker].to_vec();
667    let mut blocks = Vec::new();
668    for (pos, marker) in markers.iter().copied().enumerate() {
669        let end = markers.get(pos + 1).copied().unwrap_or(lines.len());
670        blocks.push(lines[marker..end].to_vec());
671    }
672    (base, blocks)
673}
674
675fn parse_kv_lines(lines: &[String]) -> Vec<(String, String)> {
676    lines
677        .iter()
678        .filter_map(|line| {
679            line.split_once('=').map(|(key, value)| {
680                (
681                    key.trim().to_string(),
682                    strip_units(value.trim()).to_string(),
683                )
684            })
685        })
686        .collect()
687}
688
689fn strip_units(value: &str) -> &str {
690    let trimmed = value.trim_end();
691    if let Some(open) = trimmed.rfind('[') {
692        if trimmed.ends_with(']') {
693            return trimmed[..open].trim_end();
694        }
695    }
696    trimmed
697}
698
699fn req_text(map: &FieldMap, field: &'static str) -> Result<String, OpmError> {
700    map.get(field)
701        .map(str::to_string)
702        .ok_or(OpmError::MissingField(field))
703}
704
705fn opt_text(map: &FieldMap, field: &'static str) -> Option<String> {
706    map.get(field).map(str::to_string)
707}
708
709fn req_num(map: &FieldMap, field: &'static str) -> Result<f64, OpmError> {
710    let value = map.get(field).ok_or(OpmError::MissingField(field))?;
711    parse_num(value, field)
712}
713
714fn opt_num(map: &FieldMap, field: &'static str) -> Result<Option<f64>, OpmError> {
715    map.get(field)
716        .map(|value| parse_num(value, field))
717        .transpose()
718}
719
720fn parse_num(value: &str, field: &'static str) -> Result<f64, OpmError> {
721    validate::strict_f64(value, field).map_err(map_opm_field_error)
722}
723
724fn map_opm_field_error(error: validate::FieldError) -> OpmError {
725    OpmError::InvalidField {
726        field: error.field(),
727        kind: OpmInputErrorKind::from(&error),
728    }
729}
730
731fn node_text(node: Node, tag: &str) -> Option<String> {
732    let element = node
733        .descendants()
734        .find(|n| n.is_element() && n.tag_name().name() == tag)?;
735    let text = element.text()?.trim();
736    if text.is_empty() {
737        None
738    } else {
739        Some(text.to_string())
740    }
741}
742
743fn child_element<'a>(node: Node<'a, 'a>, tag: &str) -> Option<Node<'a, 'a>> {
744    node.children()
745        .find(|n| n.is_element() && n.tag_name().name() == tag)
746}
747
748fn xml_fields(node: Node, keys: &[&str]) -> Vec<(String, String)> {
749    keys.iter()
750        .filter_map(|key| node_text(node, key).map(|value| ((*key).to_string(), value)))
751        .collect()
752}
753
754fn xml_all_fields(node: Node) -> Vec<(String, String)> {
755    node.descendants()
756        .filter(Node::is_element)
757        .filter_map(|n| {
758            let text = n.text()?.trim();
759            if text.is_empty() {
760                None
761            } else {
762                Some((n.tag_name().name().to_string(), text.to_string()))
763            }
764        })
765        .collect()
766}
767
768fn push_opt_num(lines: &mut Vec<String>, key: &str, value: Option<f64>) {
769    if let Some(value) = value {
770        lines.push(format!("{key} = {}", fmt_num(value)));
771    }
772}
773
774fn push_opt_xml_num(lines: &mut Vec<String>, key: &str, value: Option<f64>) {
775    if let Some(value) = value {
776        lines.push(elem_line_raw(10, key, &fmt_num(value)));
777    }
778}
779
780fn elem_line(indent: usize, name: &str, value: &str) -> String {
781    elem_line_raw(indent, name, &xml::escape(value))
782}
783
784fn elem_line_raw(indent: usize, name: &str, value: &str) -> String {
785    format!("{:indent$}<{name}>{value}</{name}>", "")
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791
792    fn minimal_kvn() -> String {
793        "\
794CCSDS_OPM_VERS = 2.0
795CREATION_DATE = 2026-06-28T00:00:00
796ORIGINATOR = SIDEREON
797OBJECT_NAME = OSPREY
798OBJECT_ID = 2026-001A
799CENTER_NAME = EARTH
800REF_FRAME = EME2000
801TIME_SYSTEM = UTC
802EPOCH = 2026-06-28T00:00:00
803X = 7000
804Y = 0
805Z = 0
806X_DOT = 0
807Y_DOT = 7.5
808Z_DOT = 1
809"
810        .to_string()
811    }
812
813    #[test]
814    fn malformed_xml_is_an_error() {
815        assert!(parse_xml("<opm></opm><opm></opm>").is_err());
816    }
817
818    #[test]
819    fn missing_required_field_is_an_error() {
820        let kvn = minimal_kvn().replace("OBJECT_ID = 2026-001A\n", "");
821        assert_eq!(parse_kvn(&kvn), Err(OpmError::MissingField("OBJECT_ID")));
822    }
823
824    #[test]
825    fn parses_two_maneuvers_from_kvn() {
826        let kvn = format!(
827            "{}{}",
828            minimal_kvn(),
829            "\
830MAN_EPOCH_IGNITION = 2026-06-28T00:10:00
831MAN_DURATION = 10
832MAN_DELTA_MASS = -0.5
833MAN_REF_FRAME = TNW
834MAN_DV_1 = 0.001
835MAN_DV_2 = 0
836MAN_DV_3 = 0
837MAN_EPOCH_IGNITION = 2026-06-28T00:20:00
838MAN_DURATION = 20
839MAN_DELTA_MASS = -0.7
840MAN_REF_FRAME = TNW
841MAN_DV_1 = 0
842MAN_DV_2 = 0.002
843MAN_DV_3 = 0
844"
845        );
846        let opm = parse_kvn(&kvn).unwrap();
847        assert_eq!(opm.maneuvers.len(), 2);
848        assert_eq!(opm.maneuvers[0].ref_frame, "TNW");
849        assert_eq!(opm.maneuvers[1].dv_km_s, [0.0, 0.002, 0.0]);
850    }
851
852    #[cfg(all(test, sidereon_repo_tests))]
853    mod fixtures {
854        use super::*;
855
856        const OSPREY_KVN: &str = include_str!("../../tests/fixtures/opm/osprey.kvn");
857        const OSPREY_XML: &str = include_str!("../../tests/fixtures/opm/osprey.xml");
858
859        #[test]
860        fn parses_osprey_kvn_fixture() {
861            let opm = parse_kvn(OSPREY_KVN).unwrap();
862            assert_eq!(opm.ccsds_opm_vers, "2.0");
863            assert_eq!(opm.metadata.object_name, "OSPREY-1");
864            assert_eq!(opm.state.position_km[0], 6878.137);
865            assert_eq!(opm.maneuvers.len(), 2);
866            assert!(matches!(
867                opm.keplerian.as_ref().unwrap().anomaly,
868                OpmAnomaly::True(42.0)
869            ));
870        }
871
872        #[test]
873        fn parses_osprey_xml_fixture() {
874            let opm = parse_xml(OSPREY_XML).unwrap();
875            assert_eq!(opm.metadata.object_id, "2026-045A");
876            assert_eq!(opm.spacecraft.as_ref().unwrap().mass_kg, Some(425.0));
877            assert_eq!(
878                opm.covariance.as_ref().unwrap().cov_ref_frame.as_deref(),
879                Some("EME2000")
880            );
881        }
882
883        #[test]
884        fn fixture_kvn_round_trips() {
885            let opm = parse_kvn(OSPREY_KVN).unwrap();
886            assert_eq!(parse_kvn(&encode_kvn(&opm)).unwrap(), opm);
887        }
888
889        #[test]
890        fn fixture_xml_round_trips() {
891            let opm = parse_xml(OSPREY_XML).unwrap();
892            assert_eq!(parse_xml(&encode_xml(&opm)).unwrap(), opm);
893        }
894    }
895}