Skip to main content

sidereon_core/astro/
cdm.rs

1//! CCSDS Conjunction Data Message (CDM) KVN and XML format reader and writer.
2//!
3//! CDM (CCSDS 508.0-B-1) describes a predicted close approach between two space
4//! objects: the time of closest approach, miss geometry, collision probability,
5//! the per-object metadata block, and per-object state vectors with RTN
6//! position-and-velocity covariance. This module owns the language-independent
7//! grammar for both serializations:
8//!
9//! - KVN (Keyword=Value Notation): line tokenization, the `KEY = VALUE [unit]`
10//!   split, trailing-unit stripping, the `COMMENT HBR =` convention,
11//!   object-block segmentation, the leading-float value parser, the
12//!   state/covariance extraction, and the KVN line layout used on encode.
13//! - XML: a real DOM parse via the `roxmltree` crate (which correctly handles
14//!   the `<?xml?>` declaration, comments, namespaces, entity escaping, and
15//!   encoding), then name-based leaf-element lookup over the document and the
16//!   two `<segment>` subtrees for the same state/covariance extraction. Encoding
17//!   emits the CCSDS document layout through a controlled serializer (see
18//!   [`encode_xml`]).
19//!
20//! Both run identically regardless of the calling language, so they live in the
21//! core.
22//!
23//! Date/time fields cross this boundary as raw strings: resolving `TCA` /
24//! `CREATION_DATE` to a concrete instant (and formatting one back) is the host's
25//! job using its native date/time type, exactly as the TLE epoch is handled. This
26//! module deliberately does not depend on the time-scale machinery, and applies
27//! no calendar validation; it only carries the textual value through.
28
29use crate::astro::xml;
30use crate::validate;
31use roxmltree::{Document, Node};
32use std::fmt;
33
34/// Keys of the six-component state vector, in CCSDS order (position then
35/// velocity). Used to pull the state out of a parsed object block.
36const STATE_KEYS: [&str; 6] = ["X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT"];
37/// Keys of the RTN position covariance lower triangle, in CCSDS order.
38const COVARIANCE_KEYS: [&str; 6] = ["CR_R", "CT_R", "CT_T", "CN_R", "CN_T", "CN_N"];
39/// Velocity rows of the RTN covariance lower triangle (`(key, units)` pairs) in
40/// CCSDS order: the position-velocity cross terms in `m**2/s` and the
41/// velocity-velocity terms in `m**2/s**2`. These complete the standard 6x6
42/// covariance (the six [`COVARIANCE_KEYS`] are its position 3x3 block). They are
43/// captured all-or-nothing: a complete block round-trips, a stray or partial
44/// element is canonicalized away.
45const VELOCITY_COVARIANCE_FIELDS: [(&str, &str); 15] = [
46    ("CRDOT_R", "m**2/s"),
47    ("CRDOT_T", "m**2/s"),
48    ("CRDOT_N", "m**2/s"),
49    ("CRDOT_RDOT", "m**2/s**2"),
50    ("CTDOT_R", "m**2/s"),
51    ("CTDOT_T", "m**2/s"),
52    ("CTDOT_N", "m**2/s"),
53    ("CTDOT_RDOT", "m**2/s**2"),
54    ("CTDOT_TDOT", "m**2/s**2"),
55    ("CNDOT_R", "m**2/s"),
56    ("CNDOT_T", "m**2/s"),
57    ("CNDOT_N", "m**2/s"),
58    ("CNDOT_RDOT", "m**2/s**2"),
59    ("CNDOT_TDOT", "m**2/s**2"),
60    ("CNDOT_NDOT", "m**2/s**2"),
61];
62/// Marker keyword whose value (`OBJECT1` / `OBJECT2`) opens an object block.
63const OBJECT_MARKER: &str = "OBJECT";
64/// Comment prefix: lines beginning with this are dropped before key/value
65/// parsing (the embedded `COMMENT HBR =` value is recovered separately).
66const COMMENT_PREFIX: &str = "COMMENT";
67/// Element name wrapping one object's metadata/data block in the CDM XML schema.
68const SEGMENT_TAG: &str = "segment";
69
70/// A two-object conjunction parsed from a CDM message (KVN or XML). Date/time
71/// fields are the raw textual values; the host resolves them to its own instant
72/// type.
73#[derive(Debug, Clone, PartialEq)]
74pub struct CdmKvn {
75    pub creation_date: Option<String>,
76    pub originator: Option<String>,
77    pub message_id: Option<String>,
78    pub tca: Option<String>,
79    pub miss_distance_m: Option<f64>,
80    pub relative_speed_m_s: Option<f64>,
81    pub collision_probability: Option<f64>,
82    pub collision_probability_method: Option<String>,
83    pub hard_body_radius_m: Option<f64>,
84    pub object1: CdmObject,
85    pub object2: CdmObject,
86}
87
88/// One object's CCSDS metadata block, state vector, and RTN covariance. Every
89/// metadata field is the verbatim textual value (CCSDS enum fields such as
90/// `OBJECT_TYPE` and `MANEUVERABLE` are carried as strings); absent fields are
91/// `None` and are not emitted on encode.
92#[derive(Debug, Clone, PartialEq)]
93pub struct CdmObject {
94    pub object_designator: Option<String>,
95    pub catalog_name: Option<String>,
96    pub object_name: Option<String>,
97    pub international_designator: Option<String>,
98    pub object_type: Option<String>,
99    pub operator_contact_position: Option<String>,
100    pub operator_organization: Option<String>,
101    pub operator_phone: Option<String>,
102    pub operator_email: Option<String>,
103    pub ephemeris_name: Option<String>,
104    pub covariance_method: Option<String>,
105    pub maneuverable: Option<String>,
106    pub orbit_center: Option<String>,
107    pub ref_frame: Option<String>,
108    pub gravity_model: Option<String>,
109    pub atmospheric_model: Option<String>,
110    pub n_body_perturbations: Option<String>,
111    pub solar_rad_pressure: Option<String>,
112    pub earth_tides: Option<String>,
113    pub intrack_thrust: Option<String>,
114    /// Position `(x, y, z)` then velocity `(x_dot, y_dot, z_dot)`.
115    pub state: ((f64, f64, f64), (f64, f64, f64)),
116    /// RTN position covariance lower triangle: CR_R, CT_R, CT_T, CN_R, CN_T, CN_N.
117    pub covariance_rtn: [f64; 6],
118    /// RTN velocity covariance lower-triangle rows completing the 6x6 matrix, in
119    /// [`VELOCITY_COVARIANCE_FIELDS`] order, or `None` when the producer carried
120    /// only the position block. Present only when the full 15-element block is.
121    pub velocity_covariance_rtn: Option<[f64; 15]>,
122}
123
124/// The ordered `(metadata key, field value)` pairs for `object`, in CCSDS
125/// 508.0-B-1 metadata-block order. Used by both serializers to write the block
126/// in canonical order, emitting only the fields that are present.
127fn object_metadata_pairs(object: &CdmObject) -> [(&'static str, &Option<String>); 20] {
128    [
129        ("OBJECT_DESIGNATOR", &object.object_designator),
130        ("CATALOG_NAME", &object.catalog_name),
131        ("OBJECT_NAME", &object.object_name),
132        ("INTERNATIONAL_DESIGNATOR", &object.international_designator),
133        ("OBJECT_TYPE", &object.object_type),
134        (
135            "OPERATOR_CONTACT_POSITION",
136            &object.operator_contact_position,
137        ),
138        ("OPERATOR_ORGANIZATION", &object.operator_organization),
139        ("OPERATOR_PHONE", &object.operator_phone),
140        ("OPERATOR_EMAIL", &object.operator_email),
141        ("EPHEMERIS_NAME", &object.ephemeris_name),
142        ("COVARIANCE_METHOD", &object.covariance_method),
143        ("MANEUVERABLE", &object.maneuverable),
144        ("ORBIT_CENTER", &object.orbit_center),
145        ("REF_FRAME", &object.ref_frame),
146        ("GRAVITY_MODEL", &object.gravity_model),
147        ("ATMOSPHERIC_MODEL", &object.atmospheric_model),
148        ("N_BODY_PERTURBATIONS", &object.n_body_perturbations),
149        ("SOLAR_RAD_PRESSURE", &object.solar_rad_pressure),
150        ("EARTH_TIDES", &object.earth_tides),
151        ("INTRACK_THRUST", &object.intrack_thrust),
152    ]
153}
154
155/// Assemble a [`CdmObject`] from a serialization-specific metadata getter and the
156/// already-parsed state and covariance. `get` resolves a metadata key to its
157/// textual value (KVN field lookup or XML leaf-element text), keeping the KVN and
158/// XML readers on one shared field list.
159fn assemble_object<F>(
160    get: F,
161    state: ((f64, f64, f64), (f64, f64, f64)),
162    covariance_rtn: [f64; 6],
163    velocity_covariance_rtn: Option<[f64; 15]>,
164) -> CdmObject
165where
166    F: Fn(&str) -> Option<String>,
167{
168    CdmObject {
169        object_designator: get("OBJECT_DESIGNATOR"),
170        catalog_name: get("CATALOG_NAME"),
171        object_name: get("OBJECT_NAME"),
172        international_designator: get("INTERNATIONAL_DESIGNATOR"),
173        object_type: get("OBJECT_TYPE"),
174        operator_contact_position: get("OPERATOR_CONTACT_POSITION"),
175        operator_organization: get("OPERATOR_ORGANIZATION"),
176        operator_phone: get("OPERATOR_PHONE"),
177        operator_email: get("OPERATOR_EMAIL"),
178        ephemeris_name: get("EPHEMERIS_NAME"),
179        covariance_method: get("COVARIANCE_METHOD"),
180        maneuverable: get("MANEUVERABLE"),
181        orbit_center: get("ORBIT_CENTER"),
182        ref_frame: get("REF_FRAME"),
183        gravity_model: get("GRAVITY_MODEL"),
184        atmospheric_model: get("ATMOSPHERIC_MODEL"),
185        n_body_perturbations: get("N_BODY_PERTURBATIONS"),
186        solar_rad_pressure: get("SOLAR_RAD_PRESSURE"),
187        earth_tides: get("EARTH_TIDES"),
188        intrack_thrust: get("INTRACK_THRUST"),
189        state,
190        covariance_rtn,
191        velocity_covariance_rtn,
192    }
193}
194
195/// Failure modes of [`parse_kvn`]. The message strings are the historical public
196/// contract surfaced by the Elixir binding.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum CdmError {
199    /// An object block was missing one or more state-vector components.
200    IncompleteStateVector,
201    /// A numeric field was absent, malformed, non-finite, or outside its domain.
202    InvalidField {
203        /// The invalid CDM field.
204        field: &'static str,
205        /// The validation failure category.
206        kind: CdmInputErrorKind,
207    },
208    /// The XML reader was handed text that is not a well-formed XML document.
209    MalformedXml(String),
210}
211
212/// CDM boundary-validation failure category.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum CdmInputErrorKind {
215    /// A required field was absent.
216    Missing,
217    /// A floating-point field was NaN or infinite.
218    NonFinite,
219    /// A floating-point field could not be parsed.
220    FloatParse,
221    /// An integer field could not be parsed.
222    IntParse,
223    /// A positive physical field was zero or negative.
224    NotPositive,
225    /// A non-negative physical field was negative.
226    Negative,
227    /// A finite numeric field was outside its accepted range.
228    OutOfRange,
229    /// A civil date field was out of range.
230    InvalidCivilDate,
231    /// A civil time field was out of range.
232    InvalidCivilTime,
233}
234
235impl fmt::Display for CdmInputErrorKind {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        let label = match self {
238            Self::Missing => "missing",
239            Self::NonFinite => "not finite",
240            Self::FloatParse => "invalid float",
241            Self::IntParse => "invalid integer",
242            Self::NotPositive => "not positive",
243            Self::Negative => "negative",
244            Self::OutOfRange => "out of range",
245            Self::InvalidCivilDate => "invalid civil date",
246            Self::InvalidCivilTime => "invalid civil time",
247        };
248        f.write_str(label)
249    }
250}
251
252impl From<&validate::FieldError> for CdmInputErrorKind {
253    fn from(error: &validate::FieldError) -> Self {
254        match error {
255            validate::FieldError::Missing { .. } => Self::Missing,
256            validate::FieldError::NonFinite { .. } => Self::NonFinite,
257            validate::FieldError::FloatParse { .. } => Self::FloatParse,
258            validate::FieldError::IntParse { .. } => Self::IntParse,
259            validate::FieldError::NotPositive { .. } => Self::NotPositive,
260            validate::FieldError::Negative { .. } => Self::Negative,
261            validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
262            validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
263            validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
264        }
265    }
266}
267
268impl fmt::Display for CdmError {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        match self {
271            CdmError::IncompleteStateVector => write!(f, "incomplete state vector"),
272            CdmError::InvalidField { field, kind } => {
273                write!(f, "invalid CDM field {field}: {kind}")
274            }
275            CdmError::MalformedXml(detail) => write!(f, "malformed XML: {detail}"),
276        }
277    }
278}
279
280impl std::error::Error for CdmError {}
281
282/// Parse a CDM in KVN format.
283///
284/// Tokenizes the message, recovers the header/relative-metadata fields, the
285/// `COMMENT HBR =` hard-body radius, and the two object blocks. Date/time fields
286/// are returned verbatim for the host to resolve; presence/format checks on them
287/// (and on `MESSAGE_ID`) are the host's concern. An object block missing any
288/// state component is rejected with [`CdmError::IncompleteStateVector`]. Every
289/// covariance component is required and every accepted numeric state/covariance
290/// value must be finite.
291pub fn parse_kvn(text: &str) -> Result<CdmKvn, CdmError> {
292    let lines = significant_lines(text);
293    let kv = crate::format::kvn::FieldMap::from_pairs(parse_kv_lines(&lines));
294
295    let (object1_kv, object2_kv) = split_object_blocks(&lines);
296    let object1 = parse_object(&object1_kv)?;
297    let object2 = parse_object(&object2_kv)?;
298
299    Ok(CdmKvn {
300        creation_date: kv_get(&kv, "CREATION_DATE"),
301        originator: kv_get(&kv, "ORIGINATOR"),
302        message_id: kv_get(&kv, "MESSAGE_ID"),
303        tca: kv_get(&kv, "TCA"),
304        miss_distance_m: optional_kv_num(&kv, "MISS_DISTANCE")?,
305        relative_speed_m_s: optional_kv_num(&kv, "RELATIVE_SPEED")?,
306        collision_probability: optional_kv_num(&kv, "COLLISION_PROBABILITY")?,
307        collision_probability_method: kv_get(&kv, "COLLISION_PROBABILITY_METHOD"),
308        hard_body_radius_m: parse_hbr(text)?,
309        object1,
310        object2,
311    })
312}
313
314/// Encode a [`CdmKvn`] back to KVN text.
315///
316/// The date/time fields are taken as already-formatted strings (the host owns
317/// the instant-to-string conversion). Numeric values are written with their
318/// shortest round-tripping decimal form, so a re-parse recovers the exact same
319/// bits; the output is therefore round-trip faithful rather than byte-identical
320/// to any one producer.
321pub fn encode_kvn(cdm: &CdmKvn) -> Result<String, CdmError> {
322    validate_cdm(cdm)?;
323    let header = crate::astro::ndm::NdmHeader {
324        vers: "1.0".to_string(),
325        creation_date: cdm.creation_date.clone(),
326        originator: cdm.originator.clone(),
327    };
328    let mut lines: Vec<String> = header.write_kvn("CCSDS_CDM_VERS");
329    lines.extend([
330        format!("MESSAGE_ID = {}", opt_str(&cdm.message_id)),
331        format!("TCA = {}", opt_str(&cdm.tca)),
332        format!("MISS_DISTANCE = {} [m]", opt_num(cdm.miss_distance_m)),
333        format!("RELATIVE_SPEED = {} [m/s]", opt_num(cdm.relative_speed_m_s)),
334        format!(
335            "COLLISION_PROBABILITY = {}",
336            opt_num(cdm.collision_probability)
337        ),
338        format!(
339            "COLLISION_PROBABILITY_METHOD = {}",
340            opt_str(&cdm.collision_probability_method)
341        ),
342    ]);
343
344    if let Some(hbr) = cdm.hard_body_radius_m {
345        lines.push(format!("COMMENT HBR = {}", fmt_num(hbr)));
346    }
347
348    lines.extend(encode_object(&cdm.object1, "OBJECT1"));
349    lines.extend(encode_object(&cdm.object2, "OBJECT2"));
350
351    Ok(lines.join("\n"))
352}
353
354/// Parse a CDM in XML format.
355///
356/// Parses the document with `roxmltree` (a real XML DOM reader: the `<?xml?>`
357/// declaration, comments, namespaces, entity escaping, and encoding are handled
358/// by the library, not by string scanning), then reads the header and
359/// relative-metadata leaf elements by name from the document and the per-object
360/// state/covariance from the two `<segment>` subtrees. The CCSDS CDM XML schema
361/// is flat, so a name match uniquely identifies each value. The full 6x6 RTN
362/// covariance is recovered when its complete velocity block is present; a stray
363/// or partial velocity element (e.g. a lone `CRDOT_R`) is canonicalized away.
364/// Date/time
365/// fields are returned verbatim for the host to resolve, matching [`parse_kvn`];
366/// text that is not well-formed XML is rejected with [`CdmError::MalformedXml`]
367/// and an object block missing any state component with
368/// [`CdmError::IncompleteStateVector`]. Every covariance component is required
369/// and every accepted numeric state/covariance value must be finite.
370pub fn parse_xml(text: &str) -> Result<CdmKvn, CdmError> {
371    let doc = Document::parse(text).map_err(|e| CdmError::MalformedXml(e.to_string()))?;
372    let root = doc.root();
373
374    let mut segments = root
375        .descendants()
376        .filter(|n| n.is_element() && n.tag_name().name() == SEGMENT_TAG);
377    let object1 = parse_xml_object(segments.next())?;
378    let object2 = parse_xml_object(segments.next())?;
379
380    Ok(CdmKvn {
381        creation_date: node_text(root, "CREATION_DATE"),
382        originator: node_text(root, "ORIGINATOR"),
383        message_id: node_text(root, "MESSAGE_ID"),
384        tca: node_text(root, "TCA"),
385        miss_distance_m: optional_node_num(root, "MISS_DISTANCE")?,
386        relative_speed_m_s: optional_node_num(root, "RELATIVE_SPEED")?,
387        collision_probability: optional_node_num(root, "COLLISION_PROBABILITY")?,
388        collision_probability_method: node_text(root, "COLLISION_PROBABILITY_METHOD"),
389        hard_body_radius_m: optional_node_num(root, "HBR")?,
390        object1,
391        object2,
392    })
393}
394
395/// Encode a [`CdmKvn`] to a CCSDS 508.0-B-1 CDM XML document.
396///
397/// The date/time fields are taken as already-formatted strings (the host owns
398/// the instant-to-string conversion), and numeric values use the shortest
399/// round-tripping decimal form, so the output is round-trip faithful rather than
400/// byte-identical to any one producer. String values are XML-escaped.
401///
402/// This is a controlled straight-line serializer (no parsing, no tag scanning)
403/// rather than a generic streaming-writer dependency: the output is a fixed,
404/// documented CCSDS layout (`cdm > header/body > segment > metadata/data`) whose
405/// element nesting and `units` attributes are the inter-system exchange contract,
406/// and every interpolated value is escaped via [`xml::escape`]. The matching
407/// reader is the vetted `roxmltree` DOM parser in [`parse_xml`].
408pub fn encode_xml(cdm: &CdmKvn) -> Result<String, CdmError> {
409    validate_cdm(cdm)?;
410    let mut lines: Vec<String> = vec![
411        r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string(),
412        r#"<cdm id="CCSDS_CDM_VERS" version="1.0">"#.to_string(),
413        "  <header>".to_string(),
414        "    <CCSDS_CDM_VERS>1.0</CCSDS_CDM_VERS>".to_string(),
415        format!(
416            "    <CREATION_DATE>{}</CREATION_DATE>",
417            opt_str(&cdm.creation_date)
418        ),
419        format!(
420            "    <ORIGINATOR>{}</ORIGINATOR>",
421            xml::escape_opt(&cdm.originator)
422        ),
423        format!(
424            "    <MESSAGE_ID>{}</MESSAGE_ID>",
425            xml::escape_opt(&cdm.message_id)
426        ),
427        "  </header>".to_string(),
428        "  <body>".to_string(),
429        "    <relativeMetadataData>".to_string(),
430        format!("      <TCA>{}</TCA>", opt_str(&cdm.tca)),
431        format!(
432            r#"      <MISS_DISTANCE units="m">{}</MISS_DISTANCE>"#,
433            opt_num(cdm.miss_distance_m)
434        ),
435        format!(
436            r#"      <RELATIVE_SPEED units="m/s">{}</RELATIVE_SPEED>"#,
437            opt_num(cdm.relative_speed_m_s)
438        ),
439        format!(
440            "      <COLLISION_PROBABILITY>{}</COLLISION_PROBABILITY>",
441            opt_num(cdm.collision_probability)
442        ),
443        format!(
444            "      <COLLISION_PROBABILITY_METHOD>{}</COLLISION_PROBABILITY_METHOD>",
445            xml::escape_opt(&cdm.collision_probability_method)
446        ),
447        "    </relativeMetadataData>".to_string(),
448    ];
449
450    lines.extend(encode_xml_segment(&cdm.object1, "OBJECT1"));
451    lines.extend(encode_xml_segment(&cdm.object2, "OBJECT2"));
452    lines.push("  </body>".to_string());
453    lines.push("</cdm>".to_string());
454
455    Ok(lines.join("\n"))
456}
457
458// -- KVN tokenization --
459
460/// Trim every line, then drop blanks and comment lines. Comment values that the
461/// grammar needs (the HBR) are recovered from the raw text separately.
462fn significant_lines(text: &str) -> Vec<String> {
463    text.split('\n')
464        .map(|line| line.trim().to_string())
465        .filter(|line| !line.is_empty() && !line.starts_with(COMMENT_PREFIX))
466        .collect()
467}
468
469/// Build the key/value map from `KEY = VALUE` lines. Later duplicates win, which
470/// only matters for header keys repeated across object blocks (those are read per
471/// block instead). The value has any trailing `[unit]` removed.
472fn parse_kv_lines(lines: &[String]) -> Vec<(String, String)> {
473    lines
474        .iter()
475        .filter_map(|line| {
476            line.split_once('=').map(|(key, value)| {
477                (
478                    key.trim().to_string(),
479                    strip_units(value.trim()).to_string(),
480                )
481            })
482        })
483        .collect()
484}
485
486fn kv_get(kv: &crate::format::kvn::FieldMap, key: &str) -> Option<String> {
487    kv.get_last(key).map(str::to_string)
488}
489
490fn optional_kv_num(
491    kv: &crate::format::kvn::FieldMap,
492    key: &'static str,
493) -> Result<Option<f64>, CdmError> {
494    kv.get_last(key)
495        .map(|value| validate::strict_f64(value, key).map_err(map_cdm_field_error))
496        .transpose()
497}
498
499fn required_kv_num(kv: &crate::format::kvn::FieldMap, key: &'static str) -> Result<f64, CdmError> {
500    let value = kv
501        .get_last(key)
502        .ok_or(validate::FieldError::Missing { field: key })
503        .map_err(map_cdm_field_error)?;
504    validate::strict_f64(value, key).map_err(map_cdm_field_error)
505}
506
507fn required_state_kv_num(
508    kv: &crate::format::kvn::FieldMap,
509    key: &'static str,
510) -> Result<f64, CdmError> {
511    let value = kv.get_last(key).ok_or(CdmError::IncompleteStateVector)?;
512    validate::strict_f64(value, key).map_err(map_cdm_field_error)
513}
514
515fn map_cdm_field_error(error: validate::FieldError) -> CdmError {
516    CdmError::InvalidField {
517        field: error.field(),
518        kind: CdmInputErrorKind::from(&error),
519    }
520}
521
522/// Remove a trailing bracketed unit (` [m]`, `[m**2/kg]`, ...) and surrounding
523/// whitespace, leaving the bare value.
524fn strip_units(value: &str) -> &str {
525    let trimmed = value.trim_end();
526    if let Some(open) = trimmed.rfind('[') {
527        if trimmed.ends_with(']') {
528            return trimmed[..open].trim_end();
529        }
530    }
531    trimmed
532}
533
534/// Split the line stream into the two object blocks at the `OBJECT =` markers.
535/// CDM always carries exactly two; if fewer are present the blocks are empty and
536/// the state-vector check rejects the message.
537fn split_object_blocks(lines: &[String]) -> (Vec<String>, Vec<String>) {
538    let markers: Vec<usize> = lines
539        .iter()
540        .enumerate()
541        .filter(|(_, line)| {
542            line.split_once('=')
543                .is_some_and(|(key, _)| key.trim() == OBJECT_MARKER)
544        })
545        .map(|(idx, _)| idx)
546        .collect();
547
548    match markers.as_slice() {
549        [i1, i2, ..] => (lines[*i1..*i2].to_vec(), lines[*i2..].to_vec()),
550        _ => (Vec::new(), Vec::new()),
551    }
552}
553
554fn parse_object(lines: &[String]) -> Result<CdmObject, CdmError> {
555    let kv = crate::format::kvn::FieldMap::from_pairs(parse_kv_lines(lines));
556
557    let mut state = [0.0_f64; 6];
558    for (slot, key) in state.iter_mut().zip(STATE_KEYS) {
559        *slot = required_state_kv_num(&kv, key)?;
560    }
561    validate::finite_slice(&state, "state").map_err(map_cdm_field_error)?;
562
563    let mut covariance_rtn = [0.0_f64; 6];
564    for (slot, key) in covariance_rtn.iter_mut().zip(COVARIANCE_KEYS) {
565        *slot = required_kv_num(&kv, key)?;
566    }
567    validate::finite_slice(&covariance_rtn, "covariance_rtn").map_err(map_cdm_field_error)?;
568    validate_covariance_rtn(&covariance_rtn)?;
569
570    let velocity_covariance_rtn = read_velocity_covariance(|key| optional_kv_num(&kv, key))?;
571
572    Ok(assemble_object(
573        |key| kv_get(&kv, key),
574        (
575            (state[0], state[1], state[2]),
576            (state[3], state[4], state[5]),
577        ),
578        covariance_rtn,
579        velocity_covariance_rtn,
580    ))
581}
582
583/// Read the optional RTN velocity covariance block through a
584/// serialization-specific numeric getter. The 15 lower-triangle velocity terms
585/// are captured all-or-nothing: every element present yields the full block, an
586/// absent or partial block yields `None` (a stray element is canonicalized away).
587/// Any present element must be a finite number.
588fn read_velocity_covariance<F>(get_num: F) -> Result<Option<[f64; 15]>, CdmError>
589where
590    F: Fn(&'static str) -> Result<Option<f64>, CdmError>,
591{
592    let mut values = [0.0_f64; 15];
593    let mut present = 0_usize;
594    for (slot, (key, _units)) in values.iter_mut().zip(VELOCITY_COVARIANCE_FIELDS) {
595        if let Some(value) = get_num(key)? {
596            *slot = value;
597            present += 1;
598        }
599    }
600    if present == VELOCITY_COVARIANCE_FIELDS.len() {
601        Ok(Some(values))
602    } else {
603        Ok(None)
604    }
605}
606
607/// Recover the hard-body radius from a `COMMENT HBR = <value>` line (NASA CARA
608/// convention). Scans the raw text case-insensitively, taking the leading
609/// digits-and-dot run as the value.
610fn parse_hbr(text: &str) -> Result<Option<f64>, CdmError> {
611    for line in text.split('\n') {
612        let trimmed = line.trim();
613        let mut rest = match strip_prefix_ci(trimmed, COMMENT_PREFIX) {
614            Some(rest) if starts_with_ascii_ws(rest) => rest.trim_start(),
615            _ => continue,
616        };
617        rest = match strip_prefix_ci(rest, "HBR") {
618            Some(rest) => rest.trim_start(),
619            None => continue,
620        };
621        let rest = match rest.strip_prefix('=') {
622            Some(rest) => rest.trim_start(),
623            None => continue,
624        };
625        let value = strip_units(rest).split_whitespace().next().unwrap_or("");
626        if value.is_empty() {
627            return Ok(None);
628        }
629        return validate::strict_f64(value, "HBR")
630            .map(Some)
631            .map_err(map_cdm_field_error);
632    }
633    Ok(None)
634}
635
636// -- KVN encoding --
637
638fn encode_object(object: &CdmObject, name: &str) -> Vec<String> {
639    let ((x, y, z), (xd, yd, zd)) = object.state;
640    let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = object.covariance_rtn;
641
642    let mut lines = vec![format!("OBJECT = {name}")];
643    for (key, value) in object_metadata_pairs(object) {
644        if let Some(text) = value {
645            lines.push(format!("{key} = {text}"));
646        }
647    }
648    lines.extend([
649        format!("X = {} [km]", fmt_num(x)),
650        format!("Y = {} [km]", fmt_num(y)),
651        format!("Z = {} [km]", fmt_num(z)),
652        format!("X_DOT = {} [km/s]", fmt_num(xd)),
653        format!("Y_DOT = {} [km/s]", fmt_num(yd)),
654        format!("Z_DOT = {} [km/s]", fmt_num(zd)),
655        format!("CR_R = {} [m**2]", fmt_num(cr_r)),
656        format!("CT_R = {} [m**2]", fmt_num(ct_r)),
657        format!("CT_T = {} [m**2]", fmt_num(ct_t)),
658        format!("CN_R = {} [m**2]", fmt_num(cn_r)),
659        format!("CN_T = {} [m**2]", fmt_num(cn_t)),
660        format!("CN_N = {} [m**2]", fmt_num(cn_n)),
661    ]);
662    if let Some(velocity) = &object.velocity_covariance_rtn {
663        for (value, (key, units)) in velocity.iter().zip(VELOCITY_COVARIANCE_FIELDS) {
664            lines.push(format!("{key} = {} [{units}]", fmt_num(*value)));
665        }
666    }
667    lines
668}
669
670// -- Value helpers --
671
672/// Shortest round-tripping decimal for a finite value.
673fn fmt_num(value: f64) -> String {
674    format!("{value}")
675}
676
677fn opt_str(value: &Option<String>) -> String {
678    value.clone().unwrap_or_default()
679}
680
681fn opt_num(value: Option<f64>) -> String {
682    value.map_or_else(String::new, fmt_num)
683}
684
685fn validate_cdm(cdm: &CdmKvn) -> Result<(), CdmError> {
686    validate_optional_num(cdm.miss_distance_m, "MISS_DISTANCE")?;
687    validate_optional_num(cdm.relative_speed_m_s, "RELATIVE_SPEED")?;
688    validate_optional_num(cdm.collision_probability, "COLLISION_PROBABILITY")?;
689    validate_optional_num(cdm.hard_body_radius_m, "HBR")?;
690    validate_object(&cdm.object1)?;
691    validate_object(&cdm.object2)?;
692    Ok(())
693}
694
695fn validate_optional_num(value: Option<f64>, field: &'static str) -> Result<(), CdmError> {
696    value.map_or(Ok(()), |value| {
697        validate::finite(value, field)
698            .map(|_| ())
699            .map_err(map_cdm_field_error)
700    })
701}
702
703fn validate_object(object: &CdmObject) -> Result<(), CdmError> {
704    let ((x, y, z), (xd, yd, zd)) = object.state;
705    validate::finite_slice(&[x, y, z, xd, yd, zd], "state").map_err(map_cdm_field_error)?;
706    validate::finite_slice(&object.covariance_rtn, "covariance_rtn")
707        .map_err(map_cdm_field_error)?;
708    if let Some(velocity) = &object.velocity_covariance_rtn {
709        validate::finite_slice(velocity, "velocity_covariance_rtn").map_err(map_cdm_field_error)?;
710    }
711    validate_covariance_rtn(&object.covariance_rtn)
712}
713
714fn validate_covariance_rtn(covariance_rtn: &[f64; 6]) -> Result<(), CdmError> {
715    let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = *covariance_rtn;
716    let covariance = [[cr_r, ct_r, cn_r], [ct_r, ct_t, cn_t], [cn_r, cn_t, cn_n]];
717    validate::validate_covariance_psd(&covariance, "covariance_rtn").map_err(map_cdm_field_error)
718}
719
720/// Case-insensitive ASCII prefix strip.
721fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
722    if text
723        .get(..prefix.len())
724        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
725    {
726        text.get(prefix.len()..)
727    } else {
728        None
729    }
730}
731
732fn starts_with_ascii_ws(text: &str) -> bool {
733    text.chars().next().is_some_and(|c| c.is_ascii_whitespace())
734}
735
736// -- XML parsing --
737
738/// First descendant element of `node` (document order) whose local tag name is
739/// `tag`, returning its trimmed text. `None` if the element is absent, empty, or
740/// self-closing. roxmltree decodes entities and ignores attributes, so the value
741/// is the element's resolved text content.
742fn node_text(node: Node, tag: &str) -> Option<String> {
743    let element = node
744        .descendants()
745        .find(|n| n.is_element() && n.tag_name().name() == tag)?;
746    let text = element.text()?.trim();
747    (!text.is_empty()).then(|| text.to_string())
748}
749
750/// Leaf-element value parsed as a finite float, or `None`.
751fn optional_node_num(node: Node, tag: &'static str) -> Result<Option<f64>, CdmError> {
752    node_text(node, tag)
753        .map(|value| validate::strict_f64(&value, tag).map_err(map_cdm_field_error))
754        .transpose()
755}
756
757fn required_node_num(node: Node, tag: &'static str) -> Result<f64, CdmError> {
758    let value = node_text(node, tag)
759        .ok_or(validate::FieldError::Missing { field: tag })
760        .map_err(map_cdm_field_error)?;
761    validate::strict_f64(&value, tag).map_err(map_cdm_field_error)
762}
763
764fn required_state_node_num(node: Node, tag: &'static str) -> Result<f64, CdmError> {
765    let value = node_text(node, tag).ok_or(CdmError::IncompleteStateVector)?;
766    validate::strict_f64(&value, tag).map_err(map_cdm_field_error)
767}
768
769/// Build one [`CdmObject`] from its `<segment>` subtree. A missing segment (fewer
770/// than two present) or any missing state component is rejected with
771/// [`CdmError::IncompleteStateVector`]. Every covariance component is required.
772fn parse_xml_object(segment: Option<Node>) -> Result<CdmObject, CdmError> {
773    let segment = segment.ok_or(CdmError::IncompleteStateVector)?;
774
775    let mut state = [0.0_f64; 6];
776    for (slot, key) in state.iter_mut().zip(STATE_KEYS) {
777        *slot = required_state_node_num(segment, key)?;
778    }
779    validate::finite_slice(&state, "state").map_err(map_cdm_field_error)?;
780
781    let mut covariance_rtn = [0.0_f64; 6];
782    for (slot, key) in covariance_rtn.iter_mut().zip(COVARIANCE_KEYS) {
783        *slot = required_node_num(segment, key)?;
784    }
785    validate::finite_slice(&covariance_rtn, "covariance_rtn").map_err(map_cdm_field_error)?;
786    validate_covariance_rtn(&covariance_rtn)?;
787
788    let velocity_covariance_rtn = read_velocity_covariance(|key| optional_node_num(segment, key))?;
789
790    Ok(assemble_object(
791        |key| node_text(segment, key),
792        (
793            (state[0], state[1], state[2]),
794            (state[3], state[4], state[5]),
795        ),
796        covariance_rtn,
797        velocity_covariance_rtn,
798    ))
799}
800
801// -- XML encoding --
802
803fn encode_xml_segment(object: &CdmObject, name: &str) -> Vec<String> {
804    let ((x, y, z), (xd, yd, zd)) = object.state;
805    let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = object.covariance_rtn;
806
807    let mut lines = vec![
808        "    <segment>".to_string(),
809        "      <metadata>".to_string(),
810        format!("        <OBJECT>{name}</OBJECT>"),
811    ];
812    for (key, value) in object_metadata_pairs(object) {
813        if let Some(text) = value {
814            lines.push(format!("        <{key}>{}</{key}>", xml::escape(text)));
815        }
816    }
817    lines.extend([
818        "      </metadata>".to_string(),
819        "      <data>".to_string(),
820        "        <stateVector>".to_string(),
821        format!(r#"          <X units="km">{}</X>"#, fmt_num(x)),
822        format!(r#"          <Y units="km">{}</Y>"#, fmt_num(y)),
823        format!(r#"          <Z units="km">{}</Z>"#, fmt_num(z)),
824        format!(r#"          <X_DOT units="km/s">{}</X_DOT>"#, fmt_num(xd)),
825        format!(r#"          <Y_DOT units="km/s">{}</Y_DOT>"#, fmt_num(yd)),
826        format!(r#"          <Z_DOT units="km/s">{}</Z_DOT>"#, fmt_num(zd)),
827        "        </stateVector>".to_string(),
828        "        <covarianceMatrix>".to_string(),
829        format!(r#"          <CR_R units="m**2">{}</CR_R>"#, fmt_num(cr_r)),
830        format!(r#"          <CT_R units="m**2">{}</CT_R>"#, fmt_num(ct_r)),
831        format!(r#"          <CT_T units="m**2">{}</CT_T>"#, fmt_num(ct_t)),
832        format!(r#"          <CN_R units="m**2">{}</CN_R>"#, fmt_num(cn_r)),
833        format!(r#"          <CN_T units="m**2">{}</CN_T>"#, fmt_num(cn_t)),
834        format!(r#"          <CN_N units="m**2">{}</CN_N>"#, fmt_num(cn_n)),
835    ]);
836    if let Some(velocity) = &object.velocity_covariance_rtn {
837        for (value, (key, units)) in velocity.iter().zip(VELOCITY_COVARIANCE_FIELDS) {
838            lines.push(format!(
839                r#"          <{key} units="{units}">{}</{key}>"#,
840                fmt_num(*value)
841            ));
842        }
843    }
844    lines.extend([
845        "        </covarianceMatrix>".to_string(),
846        "      </data>".to_string(),
847        "    </segment>".to_string(),
848    ]);
849    lines
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    #[test]
857    fn strip_units_removes_trailing_bracket() {
858        assert_eq!(strip_units("7000.0 [km]"), "7000.0");
859        assert_eq!(strip_units("4.835E-05"), "4.835E-05");
860        assert_eq!(strip_units("0.045663 [m**2/kg]"), "0.045663");
861        assert_eq!(strip_units("97.8 [%]"), "97.8");
862    }
863
864    #[test]
865    fn cdm_covariance_rtn_validation_accepts_psd_lower_triangle() {
866        assert_eq!(
867            validate_covariance_rtn(&[1.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
868            Ok(())
869        );
870    }
871
872    #[test]
873    fn cdm_covariance_rtn_validation_rejects_non_psd_lower_triangle() {
874        let expected = Err(CdmError::InvalidField {
875            field: "covariance_rtn",
876            kind: CdmInputErrorKind::NotPositive,
877        });
878
879        assert_eq!(
880            validate_covariance_rtn(&[-1.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
881            expected
882        );
883        assert_eq!(
884            validate_covariance_rtn(&[1.0, 2.0, 1.0, 0.0, 0.0, 1.0]),
885            expected
886        );
887    }
888
889    #[test]
890    fn incomplete_state_vector_is_rejected() {
891        let kvn = "OBJECT = OBJECT1\nX = 7000.0 [km]\nOBJECT = OBJECT2\nX = 1.0 [km]\n";
892        assert_eq!(parse_kvn(kvn), Err(CdmError::IncompleteStateVector));
893    }
894
895    #[test]
896    fn hbr_is_recovered_from_comment_only() {
897        let with_hbr = "COMMENT HBR = 15.5\n";
898        assert_eq!(parse_hbr(with_hbr), Ok(Some(15.5)));
899        assert_eq!(parse_hbr("COMMENT Relative Metadata/Data\n"), Ok(None));
900    }
901
902    #[test]
903    fn kvn_hbr_comment_with_multibyte_leading_token_is_ignored() {
904        let kvn = "\
905CREATION_DATE = 2024-01-01T00:00:00.000
906MESSAGE_ID = HBR_TEST
907COMMENT \u{1f4a5}BR = 15.5
908TCA = 2024-01-01T12:00:00.000
909OBJECT = OBJECT1
910X = 1.0 [km]
911Y = 2.0 [km]
912Z = 3.0 [km]
913X_DOT = 0.1 [km/s]
914Y_DOT = 0.2 [km/s]
915Z_DOT = 0.3 [km/s]
916CR_R = 1.0 [m**2]
917CT_R = 0.0 [m**2]
918CT_T = 1.0 [m**2]
919CN_R = 0.0 [m**2]
920CN_T = 0.0 [m**2]
921CN_N = 1.0 [m**2]
922OBJECT = OBJECT2
923X = 4.0 [km]
924Y = 5.0 [km]
925Z = 6.0 [km]
926X_DOT = 0.4 [km/s]
927Y_DOT = 0.5 [km/s]
928Z_DOT = 0.6 [km/s]
929CR_R = 1.0 [m**2]
930CT_R = 0.0 [m**2]
931CT_T = 1.0 [m**2]
932CN_R = 0.0 [m**2]
933CN_T = 0.0 [m**2]
934CN_N = 1.0 [m**2]
935";
936        let parsed = parse_kvn(kvn).expect("malformed HBR comment must not panic");
937        assert_eq!(parsed.hard_body_radius_m, None);
938    }
939
940    #[test]
941    fn node_text_reads_leaf_value_ignoring_attrs() {
942        let doc = Document::parse(
943            r#"<r><MESSAGE_ID>abc123</MESSAGE_ID><X units="km">2570.097065</X><ORIGINATOR></ORIGINATOR></r>"#,
944        )
945        .unwrap();
946        let root = doc.root();
947        assert_eq!(node_text(root, "MESSAGE_ID").as_deref(), Some("abc123"));
948        // The `units` attribute is ignored; the element text is returned.
949        assert_eq!(node_text(root, "X").as_deref(), Some("2570.097065"));
950        // An empty leaf element yields None.
951        assert_eq!(node_text(root, "ORIGINATOR"), None);
952
953        // A distinct element name sharing a prefix must not match.
954        let only_xdot = Document::parse(r#"<r><X_DOT units="km/s">4.4</X_DOT></r>"#).unwrap();
955        assert_eq!(node_text(only_xdot.root(), "X"), None);
956    }
957
958    #[test]
959    fn xml_parse_decodes_entities_and_ignores_extra_covariance_element() {
960        let xml = r#"<cdm><body>
961<segment><metadata><OBJECT_NAME>SAT A &amp; B</OBJECT_NAME></metadata>
962<data><stateVector>
963<X units="km">1.0</X><Y units="km">2.0</Y><Z units="km">3.0</Z>
964<X_DOT units="km/s">0.1</X_DOT><Y_DOT units="km/s">0.2</Y_DOT><Z_DOT units="km/s">0.3</Z_DOT>
965</stateVector><covarianceMatrix>
966<CR_R units="m**2">41.42</CR_R><CT_R units="m**2">-8.579</CT_R><CT_T units="m**2">2533.0</CT_T>
967<CN_R units="m**2">-23.13</CN_R><CN_T units="m**2">13.36</CN_T><CN_N units="m**2">70.98</CN_N>
968<CRDOT_R units="m**2/s">2.52e-3</CRDOT_R>
969</covarianceMatrix></data></segment>
970<segment><data><stateVector>
971<X units="km">4.0</X><Y units="km">5.0</Y><Z units="km">6.0</Z>
972<X_DOT units="km/s">0.4</X_DOT><Y_DOT units="km/s">0.5</Y_DOT><Z_DOT units="km/s">0.6</Z_DOT>
973</stateVector><covarianceMatrix>
974<CR_R units="m**2">1.0</CR_R><CT_R units="m**2">0.0</CT_R><CT_T units="m**2">1.0</CT_T>
975<CN_R units="m**2">0.0</CN_R><CN_T units="m**2">0.0</CN_T><CN_N units="m**2">1.0</CN_N>
976</covarianceMatrix></data></segment>
977</body></cdm>"#;
978        let cdm = parse_xml(xml).unwrap();
979        // The DOM reader decodes the `&amp;` entity.
980        assert_eq!(cdm.object1.object_name.as_deref(), Some("SAT A & B"));
981        // The trailing CRDOT_R element is not one of the six RTN keys, so the
982        // covariance is exactly the six lower-triangle components.
983        assert_eq!(
984            cdm.object1.covariance_rtn,
985            [41.42, -8.579, 2533.0, -23.13, 13.36, 70.98]
986        );
987    }
988
989    #[test]
990    fn xml_incomplete_state_vector_is_rejected() {
991        let xml = "<cdm><body>\
992<segment><data><stateVector><X units=\"km\">1.0</X></stateVector></data></segment>\
993<segment><data><stateVector></stateVector></data></segment>\
994</body></cdm>";
995        assert_eq!(parse_xml(xml), Err(CdmError::IncompleteStateVector));
996    }
997
998    #[test]
999    fn xml_malformed_document_is_rejected() {
1000        // Two root elements is not well-formed XML; the DOM reader rejects it
1001        // rather than silently scanning past the structure.
1002        assert!(matches!(
1003            parse_xml("<segment></segment><segment></segment>"),
1004            Err(CdmError::MalformedXml(_))
1005        ));
1006    }
1007
1008    #[test]
1009    fn xml_round_trips_through_encode_and_parse() {
1010        let object = CdmObject {
1011            object_designator: Some("12345".to_string()),
1012            catalog_name: None,
1013            object_name: Some("SAT A & B".to_string()),
1014            international_designator: None,
1015            object_type: None,
1016            operator_contact_position: None,
1017            operator_organization: None,
1018            operator_phone: None,
1019            operator_email: None,
1020            ephemeris_name: None,
1021            covariance_method: None,
1022            maneuverable: None,
1023            orbit_center: None,
1024            ref_frame: Some("EME2000".to_string()),
1025            gravity_model: None,
1026            atmospheric_model: None,
1027            n_body_perturbations: None,
1028            solar_rad_pressure: None,
1029            earth_tides: None,
1030            intrack_thrust: None,
1031            state: ((1.5, 2.5, 3.5), (0.1, 0.2, 0.3)),
1032            covariance_rtn: [41.42, -8.579, 2533.0, -23.13, 13.36, 70.98],
1033            velocity_covariance_rtn: None,
1034        };
1035        let original = CdmKvn {
1036            creation_date: Some("2024-01-01T00:00:00.000".to_string()),
1037            originator: Some("TEST".to_string()),
1038            message_id: Some("ID-1".to_string()),
1039            tca: Some("2024-01-01T12:00:00.000".to_string()),
1040            miss_distance_m: Some(715.0),
1041            relative_speed_m_s: Some(14762.0),
1042            collision_probability: Some(4.835e-5),
1043            collision_probability_method: Some("FOSTER-1992".to_string()),
1044            hard_body_radius_m: None,
1045            object1: object.clone(),
1046            object2: object,
1047        };
1048
1049        let encoded = encode_xml(&original).expect("valid CDM XML encode");
1050        assert!(encoded.starts_with("<?xml"));
1051        // The ampersand in the object name must be escaped on encode.
1052        assert!(encoded.contains("SAT A &amp; B"));
1053
1054        let reparsed = parse_xml(&encoded).unwrap();
1055        assert_eq!(reparsed.object1.state, original.object1.state);
1056        assert_eq!(
1057            reparsed.object2.covariance_rtn,
1058            original.object2.covariance_rtn
1059        );
1060        assert_eq!(reparsed.miss_distance_m, original.miss_distance_m);
1061        assert_eq!(
1062            reparsed.collision_probability,
1063            original.collision_probability
1064        );
1065        assert_eq!(reparsed.message_id, original.message_id);
1066        assert_eq!(reparsed.tca, original.tca);
1067    }
1068
1069    #[test]
1070    fn optional_non_finite_kvn_fields_are_rejected() {
1071        let kvn = "OBJECT = OBJECT1\n\
1072X = 1.0 [km]\nY = 2.0 [km]\nZ = 3.0 [km]\n\
1073X_DOT = 0.1 [km/s]\nY_DOT = 0.2 [km/s]\nZ_DOT = 0.3 [km/s]\n\
1074CR_R = 1.0 [m**2]\nCT_R = 0.0 [m**2]\nCT_T = 1.0 [m**2]\n\
1075CN_R = 0.0 [m**2]\nCN_T = 0.0 [m**2]\nCN_N = 1.0 [m**2]\n\
1076OBJECT = OBJECT2\n\
1077X = 4.0 [km]\nY = 5.0 [km]\nZ = 6.0 [km]\n\
1078X_DOT = 0.4 [km/s]\nY_DOT = 0.5 [km/s]\nZ_DOT = 0.6 [km/s]\n\
1079CR_R = 1.0 [m**2]\nCT_R = 0.0 [m**2]\nCT_T = 1.0 [m**2]\n\
1080CN_R = 0.0 [m**2]\nCN_T = 0.0 [m**2]\nCN_N = 1.0 [m**2]\n\
1081MISS_DISTANCE = NaN [m]\n";
1082
1083        assert_eq!(
1084            parse_kvn(kvn),
1085            Err(CdmError::InvalidField {
1086                field: "MISS_DISTANCE",
1087                kind: CdmInputErrorKind::NonFinite,
1088            })
1089        );
1090    }
1091
1092    #[test]
1093    fn optional_non_finite_xml_fields_are_rejected() {
1094        let xml = r#"<cdm><body>
1095<relativeMetadataData><COLLISION_PROBABILITY>inf</COLLISION_PROBABILITY></relativeMetadataData>
1096<segment><data><stateVector>
1097<X>1.0</X><Y>2.0</Y><Z>3.0</Z><X_DOT>0.1</X_DOT><Y_DOT>0.2</Y_DOT><Z_DOT>0.3</Z_DOT>
1098</stateVector><covarianceMatrix>
1099<CR_R>1.0</CR_R><CT_R>0.0</CT_R><CT_T>1.0</CT_T><CN_R>0.0</CN_R><CN_T>0.0</CN_T><CN_N>1.0</CN_N>
1100</covarianceMatrix></data></segment>
1101<segment><data><stateVector>
1102<X>4.0</X><Y>5.0</Y><Z>6.0</Z><X_DOT>0.4</X_DOT><Y_DOT>0.5</Y_DOT><Z_DOT>0.6</Z_DOT>
1103</stateVector><covarianceMatrix>
1104<CR_R>1.0</CR_R><CT_R>0.0</CT_R><CT_T>1.0</CT_T><CN_R>0.0</CN_R><CN_T>0.0</CN_T><CN_N>1.0</CN_N>
1105</covarianceMatrix></data></segment>
1106</body></cdm>"#;
1107
1108        assert_eq!(
1109            parse_xml(xml),
1110            Err(CdmError::InvalidField {
1111                field: "COLLISION_PROBABILITY",
1112                kind: CdmInputErrorKind::NonFinite,
1113            })
1114        );
1115    }
1116
1117    #[test]
1118    fn encode_rejects_non_finite_public_numeric_fields() {
1119        let object = CdmObject {
1120            object_designator: None,
1121            catalog_name: None,
1122            object_name: None,
1123            international_designator: None,
1124            object_type: None,
1125            operator_contact_position: None,
1126            operator_organization: None,
1127            operator_phone: None,
1128            operator_email: None,
1129            ephemeris_name: None,
1130            covariance_method: None,
1131            maneuverable: None,
1132            orbit_center: None,
1133            ref_frame: None,
1134            gravity_model: None,
1135            atmospheric_model: None,
1136            n_body_perturbations: None,
1137            solar_rad_pressure: None,
1138            earth_tides: None,
1139            intrack_thrust: None,
1140            state: ((1.0, 2.0, 3.0), (0.1, 0.2, 0.3)),
1141            covariance_rtn: [1.0, 0.0, 1.0, 0.0, 0.0, 1.0],
1142            velocity_covariance_rtn: None,
1143        };
1144        let mut cdm = CdmKvn {
1145            creation_date: None,
1146            originator: None,
1147            message_id: None,
1148            tca: None,
1149            miss_distance_m: Some(f64::NAN),
1150            relative_speed_m_s: None,
1151            collision_probability: None,
1152            collision_probability_method: None,
1153            hard_body_radius_m: None,
1154            object1: object.clone(),
1155            object2: object,
1156        };
1157
1158        assert_eq!(
1159            encode_kvn(&cdm),
1160            Err(CdmError::InvalidField {
1161                field: "MISS_DISTANCE",
1162                kind: CdmInputErrorKind::NonFinite,
1163            })
1164        );
1165
1166        cdm.miss_distance_m = Some(1.0);
1167        cdm.object1.state.0 = (f64::INFINITY, 2.0, 3.0);
1168        assert_eq!(
1169            encode_xml(&cdm),
1170            Err(CdmError::InvalidField {
1171                field: "state",
1172                kind: CdmInputErrorKind::NonFinite,
1173            })
1174        );
1175    }
1176
1177    /// A realistic two-object CDM (CCSDS 508.0-B-1 Example 2 shape) carrying the
1178    /// full metadata block and the complete 6x6 RTN covariance for both objects.
1179    /// A non-HBR `COMMENT` line is included to prove comments are canonicalized
1180    /// away by the round trip.
1181    const FULL_KVN: &str = "\
1182CCSDS_CDM_VERS = 1.0
1183CREATION_DATE = 2010-03-12T22:31:12.000
1184ORIGINATOR = JSPOC
1185MESSAGE_ID = 201113719185
1186COMMENT Relative Metadata/Data
1187TCA = 2010-03-13T22:37:52.618
1188MISS_DISTANCE = 715 [m]
1189RELATIVE_SPEED = 14762 [m/s]
1190COLLISION_PROBABILITY = 4.835E-05
1191COLLISION_PROBABILITY_METHOD = FOSTER-1992
1192OBJECT = OBJECT1
1193OBJECT_DESIGNATOR = 12345
1194CATALOG_NAME = SATCAT
1195OBJECT_NAME = SATELLITE A
1196INTERNATIONAL_DESIGNATOR = 1997-030E
1197OBJECT_TYPE = PAYLOAD
1198OPERATOR_ORGANIZATION = INTELSAT
1199EPHEMERIS_NAME = EPHEMERIS SATELLITE A
1200COVARIANCE_METHOD = CALCULATED
1201MANEUVERABLE = YES
1202REF_FRAME = EME2000
1203GRAVITY_MODEL = EGM-96: 36D 36O
1204ATMOSPHERIC_MODEL = JACCHIA 70 DCA
1205N_BODY_PERTURBATIONS = MOON, SUN
1206SOLAR_RAD_PRESSURE = NO
1207EARTH_TIDES = NO
1208INTRACK_THRUST = NO
1209X = 2570.097065 [km]
1210Y = 2244.654904 [km]
1211Z = 6281.497978 [km]
1212X_DOT = 4.418769571 [km/s]
1213Y_DOT = 4.833547743 [km/s]
1214Z_DOT = -3.526774282 [km/s]
1215CR_R = 4.142E+01 [m**2]
1216CT_R = -8.579E+00 [m**2]
1217CT_T = 2.533E+03 [m**2]
1218CN_R = -2.313E+01 [m**2]
1219CN_T = 1.336E+01 [m**2]
1220CN_N = 7.098E+01 [m**2]
1221CRDOT_R = 2.520E-03 [m**2/s]
1222CRDOT_T = -5.476E+00 [m**2/s]
1223CRDOT_N = 8.626E-04 [m**2/s]
1224CRDOT_RDOT = 5.744E-03 [m**2/s**2]
1225CTDOT_R = -1.006E-02 [m**2/s]
1226CTDOT_T = 4.041E-03 [m**2/s]
1227CTDOT_N = -1.359E-03 [m**2/s]
1228CTDOT_RDOT = -1.502E-05 [m**2/s**2]
1229CTDOT_TDOT = 1.049E-05 [m**2/s**2]
1230CNDOT_R = 1.053E-03 [m**2/s]
1231CNDOT_T = -3.412E-03 [m**2/s]
1232CNDOT_N = 1.213E-02 [m**2/s]
1233CNDOT_RDOT = -3.004E-06 [m**2/s**2]
1234CNDOT_TDOT = -1.091E-06 [m**2/s**2]
1235CNDOT_NDOT = 5.529E-05 [m**2/s**2]
1236OBJECT = OBJECT2
1237OBJECT_DESIGNATOR = 30337
1238CATALOG_NAME = SATCAT
1239OBJECT_NAME = FENGYUN 1C DEB
1240INTERNATIONAL_DESIGNATOR = 1999-025AA
1241OBJECT_TYPE = DEBRIS
1242EPHEMERIS_NAME = NONE
1243COVARIANCE_METHOD = CALCULATED
1244MANEUVERABLE = NO
1245REF_FRAME = EME2000
1246GRAVITY_MODEL = EGM-96: 36D 36O
1247ATMOSPHERIC_MODEL = JACCHIA 70 DCA
1248N_BODY_PERTURBATIONS = MOON, SUN
1249SOLAR_RAD_PRESSURE = YES
1250EARTH_TIDES = NO
1251INTRACK_THRUST = NO
1252X = 2569.540800 [km]
1253Y = 2245.093614 [km]
1254Z = 6281.599946 [km]
1255X_DOT = -2.888612500 [km/s]
1256Y_DOT = -6.007247516 [km/s]
1257Z_DOT = 3.328770172 [km/s]
1258CR_R = 1.337E+03 [m**2]
1259CT_R = -4.806E+04 [m**2]
1260CT_T = 2.492E+06 [m**2]
1261CN_R = -3.298E+01 [m**2]
1262CN_T = -7.5888E+02 [m**2]
1263CN_N = 7.105E+01 [m**2]
1264CRDOT_R = 2.591E-03 [m**2/s]
1265CRDOT_T = -4.152E-02 [m**2/s]
1266CRDOT_N = -1.784E-06 [m**2/s]
1267CRDOT_RDOT = 6.886E-05 [m**2/s**2]
1268CTDOT_R = -1.016E-02 [m**2/s]
1269CTDOT_T = -1.506E-04 [m**2/s]
1270CTDOT_N = 1.637E-03 [m**2/s]
1271CTDOT_RDOT = -2.987E-06 [m**2/s**2]
1272CTDOT_TDOT = 1.059E-05 [m**2/s**2]
1273CNDOT_R = 4.400E-03 [m**2/s]
1274CNDOT_T = 8.482E-03 [m**2/s]
1275CNDOT_N = 8.633E-05 [m**2/s]
1276CNDOT_RDOT = -1.903E-06 [m**2/s**2]
1277CNDOT_TDOT = -4.594E-06 [m**2/s**2]
1278CNDOT_NDOT = 5.178E-05 [m**2/s**2]
1279";
1280
1281    /// Assert that the parsed CDM captured the metadata-block and velocity
1282    /// covariance fields, so a passing equality round trip is not a vacuous match
1283    /// on a struct full of `None`.
1284    fn assert_full_fields_captured(parsed: &CdmKvn) {
1285        let o1 = &parsed.object1;
1286        assert_eq!(o1.catalog_name.as_deref(), Some("SATCAT"));
1287        assert_eq!(o1.international_designator.as_deref(), Some("1997-030E"));
1288        assert_eq!(o1.object_type.as_deref(), Some("PAYLOAD"));
1289        assert_eq!(o1.operator_organization.as_deref(), Some("INTELSAT"));
1290        assert_eq!(o1.ephemeris_name.as_deref(), Some("EPHEMERIS SATELLITE A"));
1291        assert_eq!(o1.covariance_method.as_deref(), Some("CALCULATED"));
1292        assert_eq!(o1.maneuverable.as_deref(), Some("YES"));
1293        assert_eq!(o1.gravity_model.as_deref(), Some("EGM-96: 36D 36O"));
1294        assert_eq!(o1.n_body_perturbations.as_deref(), Some("MOON, SUN"));
1295        assert_eq!(o1.intrack_thrust.as_deref(), Some("NO"));
1296        assert_eq!(
1297            o1.velocity_covariance_rtn,
1298            Some([
1299                2.520e-3, -5.476e0, 8.626e-4, 5.744e-3, -1.006e-2, 4.041e-3, -1.359e-3, -1.502e-5,
1300                1.049e-5, 1.053e-3, -3.412e-3, 1.213e-2, -3.004e-6, -1.091e-6, 5.529e-5,
1301            ])
1302        );
1303        assert_eq!(parsed.object2.object_type.as_deref(), Some("DEBRIS"));
1304        assert!(parsed.object2.velocity_covariance_rtn.is_some());
1305    }
1306
1307    #[test]
1308    fn kvn_round_trips_full_metadata_and_velocity_covariance() {
1309        let parsed = parse_kvn(FULL_KVN).expect("parse realistic CDM KVN");
1310        assert_full_fields_captured(&parsed);
1311
1312        let encoded = encode_kvn(&parsed).expect("encode realistic CDM KVN");
1313        // The metadata block and the velocity covariance are emitted.
1314        assert!(encoded.contains("CATALOG_NAME = SATCAT"));
1315        assert!(encoded.contains("INTERNATIONAL_DESIGNATOR = 1997-030E"));
1316        assert!(encoded.contains("OBJECT_TYPE = PAYLOAD"));
1317        assert!(encoded.contains("GRAVITY_MODEL = EGM-96: 36D 36O"));
1318        assert!(encoded.contains("CRDOT_RDOT = "));
1319        assert!(encoded.contains("CNDOT_NDOT = "));
1320        // Comments are canonicalized away.
1321        assert!(!encoded.contains("COMMENT"));
1322
1323        let reparsed = parse_kvn(&encoded).expect("re-parse encoded CDM KVN");
1324        // Every captured field survives parse -> encode -> parse, byte-for-bit.
1325        assert_eq!(reparsed, parsed);
1326    }
1327
1328    /// The same physical message as [`FULL_KVN`], in the CDM XML serialization.
1329    const FULL_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1330<cdm id="CCSDS_CDM_VERS" version="1.0">
1331  <header>
1332    <CCSDS_CDM_VERS>1.0</CCSDS_CDM_VERS>
1333    <CREATION_DATE>2010-03-12T22:31:12.000</CREATION_DATE>
1334    <ORIGINATOR>JSPOC</ORIGINATOR>
1335    <MESSAGE_ID>201113719185</MESSAGE_ID>
1336  </header>
1337  <body>
1338    <relativeMetadataData>
1339      <COMMENT>Relative Metadata/Data</COMMENT>
1340      <TCA>2010-03-13T22:37:52.618</TCA>
1341      <MISS_DISTANCE units="m">715</MISS_DISTANCE>
1342      <RELATIVE_SPEED units="m/s">14762</RELATIVE_SPEED>
1343      <COLLISION_PROBABILITY>4.835E-05</COLLISION_PROBABILITY>
1344      <COLLISION_PROBABILITY_METHOD>FOSTER-1992</COLLISION_PROBABILITY_METHOD>
1345    </relativeMetadataData>
1346    <segment>
1347      <metadata>
1348        <OBJECT>OBJECT1</OBJECT>
1349        <OBJECT_DESIGNATOR>12345</OBJECT_DESIGNATOR>
1350        <CATALOG_NAME>SATCAT</CATALOG_NAME>
1351        <OBJECT_NAME>SATELLITE A</OBJECT_NAME>
1352        <INTERNATIONAL_DESIGNATOR>1997-030E</INTERNATIONAL_DESIGNATOR>
1353        <OBJECT_TYPE>PAYLOAD</OBJECT_TYPE>
1354        <OPERATOR_ORGANIZATION>INTELSAT</OPERATOR_ORGANIZATION>
1355        <EPHEMERIS_NAME>EPHEMERIS SATELLITE A</EPHEMERIS_NAME>
1356        <COVARIANCE_METHOD>CALCULATED</COVARIANCE_METHOD>
1357        <MANEUVERABLE>YES</MANEUVERABLE>
1358        <REF_FRAME>EME2000</REF_FRAME>
1359        <GRAVITY_MODEL>EGM-96: 36D 36O</GRAVITY_MODEL>
1360        <ATMOSPHERIC_MODEL>JACCHIA 70 DCA</ATMOSPHERIC_MODEL>
1361        <N_BODY_PERTURBATIONS>MOON, SUN</N_BODY_PERTURBATIONS>
1362        <SOLAR_RAD_PRESSURE>NO</SOLAR_RAD_PRESSURE>
1363        <EARTH_TIDES>NO</EARTH_TIDES>
1364        <INTRACK_THRUST>NO</INTRACK_THRUST>
1365      </metadata>
1366      <data>
1367        <stateVector>
1368          <X units="km">2570.097065</X>
1369          <Y units="km">2244.654904</Y>
1370          <Z units="km">6281.497978</Z>
1371          <X_DOT units="km/s">4.418769571</X_DOT>
1372          <Y_DOT units="km/s">4.833547743</Y_DOT>
1373          <Z_DOT units="km/s">-3.526774282</Z_DOT>
1374        </stateVector>
1375        <covarianceMatrix>
1376          <CR_R units="m**2">4.142E+01</CR_R>
1377          <CT_R units="m**2">-8.579E+00</CT_R>
1378          <CT_T units="m**2">2.533E+03</CT_T>
1379          <CN_R units="m**2">-2.313E+01</CN_R>
1380          <CN_T units="m**2">1.336E+01</CN_T>
1381          <CN_N units="m**2">7.098E+01</CN_N>
1382          <CRDOT_R units="m**2/s">2.520E-03</CRDOT_R>
1383          <CRDOT_T units="m**2/s">-5.476E+00</CRDOT_T>
1384          <CRDOT_N units="m**2/s">8.626E-04</CRDOT_N>
1385          <CRDOT_RDOT units="m**2/s**2">5.744E-03</CRDOT_RDOT>
1386          <CTDOT_R units="m**2/s">-1.006E-02</CTDOT_R>
1387          <CTDOT_T units="m**2/s">4.041E-03</CTDOT_T>
1388          <CTDOT_N units="m**2/s">-1.359E-03</CTDOT_N>
1389          <CTDOT_RDOT units="m**2/s**2">-1.502E-05</CTDOT_RDOT>
1390          <CTDOT_TDOT units="m**2/s**2">1.049E-05</CTDOT_TDOT>
1391          <CNDOT_R units="m**2/s">1.053E-03</CNDOT_R>
1392          <CNDOT_T units="m**2/s">-3.412E-03</CNDOT_T>
1393          <CNDOT_N units="m**2/s">1.213E-02</CNDOT_N>
1394          <CNDOT_RDOT units="m**2/s**2">-3.004E-06</CNDOT_RDOT>
1395          <CNDOT_TDOT units="m**2/s**2">-1.091E-06</CNDOT_TDOT>
1396          <CNDOT_NDOT units="m**2/s**2">5.529E-05</CNDOT_NDOT>
1397        </covarianceMatrix>
1398      </data>
1399    </segment>
1400    <segment>
1401      <metadata>
1402        <OBJECT>OBJECT2</OBJECT>
1403        <OBJECT_DESIGNATOR>30337</OBJECT_DESIGNATOR>
1404        <CATALOG_NAME>SATCAT</CATALOG_NAME>
1405        <OBJECT_NAME>FENGYUN 1C DEB</OBJECT_NAME>
1406        <INTERNATIONAL_DESIGNATOR>1999-025AA</INTERNATIONAL_DESIGNATOR>
1407        <OBJECT_TYPE>DEBRIS</OBJECT_TYPE>
1408        <EPHEMERIS_NAME>NONE</EPHEMERIS_NAME>
1409        <COVARIANCE_METHOD>CALCULATED</COVARIANCE_METHOD>
1410        <MANEUVERABLE>NO</MANEUVERABLE>
1411        <REF_FRAME>EME2000</REF_FRAME>
1412        <GRAVITY_MODEL>EGM-96: 36D 36O</GRAVITY_MODEL>
1413        <ATMOSPHERIC_MODEL>JACCHIA 70 DCA</ATMOSPHERIC_MODEL>
1414        <N_BODY_PERTURBATIONS>MOON, SUN</N_BODY_PERTURBATIONS>
1415        <SOLAR_RAD_PRESSURE>YES</SOLAR_RAD_PRESSURE>
1416        <EARTH_TIDES>NO</EARTH_TIDES>
1417        <INTRACK_THRUST>NO</INTRACK_THRUST>
1418      </metadata>
1419      <data>
1420        <stateVector>
1421          <X units="km">2569.540800</X>
1422          <Y units="km">2245.093614</Y>
1423          <Z units="km">6281.599946</Z>
1424          <X_DOT units="km/s">-2.888612500</X_DOT>
1425          <Y_DOT units="km/s">-6.007247516</Y_DOT>
1426          <Z_DOT units="km/s">3.328770172</Z_DOT>
1427        </stateVector>
1428        <covarianceMatrix>
1429          <CR_R units="m**2">1.337E+03</CR_R>
1430          <CT_R units="m**2">-4.806E+04</CT_R>
1431          <CT_T units="m**2">2.492E+06</CT_T>
1432          <CN_R units="m**2">-3.298E+01</CN_R>
1433          <CN_T units="m**2">-7.5888E+02</CN_T>
1434          <CN_N units="m**2">7.105E+01</CN_N>
1435          <CRDOT_R units="m**2/s">2.591E-03</CRDOT_R>
1436          <CRDOT_T units="m**2/s">-4.152E-02</CRDOT_T>
1437          <CRDOT_N units="m**2/s">-1.784E-06</CRDOT_N>
1438          <CRDOT_RDOT units="m**2/s**2">6.886E-05</CRDOT_RDOT>
1439          <CTDOT_R units="m**2/s">-1.016E-02</CTDOT_R>
1440          <CTDOT_T units="m**2/s">-1.506E-04</CTDOT_T>
1441          <CTDOT_N units="m**2/s">1.637E-03</CTDOT_N>
1442          <CTDOT_RDOT units="m**2/s**2">-2.987E-06</CTDOT_RDOT>
1443          <CTDOT_TDOT units="m**2/s**2">1.059E-05</CTDOT_TDOT>
1444          <CNDOT_R units="m**2/s">4.400E-03</CNDOT_R>
1445          <CNDOT_T units="m**2/s">8.482E-03</CNDOT_T>
1446          <CNDOT_N units="m**2/s">8.633E-05</CNDOT_N>
1447          <CNDOT_RDOT units="m**2/s**2">-1.903E-06</CNDOT_RDOT>
1448          <CNDOT_TDOT units="m**2/s**2">-4.594E-06</CNDOT_TDOT>
1449          <CNDOT_NDOT units="m**2/s**2">5.178E-05</CNDOT_NDOT>
1450        </covarianceMatrix>
1451      </data>
1452    </segment>
1453  </body>
1454</cdm>"#;
1455
1456    #[test]
1457    fn xml_round_trips_full_metadata_and_velocity_covariance() {
1458        let parsed = parse_xml(FULL_XML).expect("parse realistic CDM XML");
1459        assert_full_fields_captured(&parsed);
1460
1461        let encoded = encode_xml(&parsed).expect("encode realistic CDM XML");
1462        assert!(encoded.contains("<CATALOG_NAME>SATCAT</CATALOG_NAME>"));
1463        assert!(encoded.contains("<OBJECT_TYPE>PAYLOAD</OBJECT_TYPE>"));
1464        assert!(encoded.contains("<GRAVITY_MODEL>EGM-96: 36D 36O</GRAVITY_MODEL>"));
1465        assert!(encoded.contains("<CRDOT_RDOT units=\"m**2/s**2\">"));
1466        assert!(encoded.contains("<CNDOT_NDOT units=\"m**2/s**2\">"));
1467        // The relativeMetadataData COMMENT element is canonicalized away.
1468        assert!(!encoded.contains("<COMMENT>"));
1469
1470        let reparsed = parse_xml(&encoded).expect("re-parse encoded CDM XML");
1471        assert_eq!(reparsed, parsed);
1472    }
1473
1474    #[test]
1475    fn kvn_and_xml_parse_the_realistic_message_identically() {
1476        let from_kvn = parse_kvn(FULL_KVN).expect("parse KVN");
1477        let from_xml = parse_xml(FULL_XML).expect("parse XML");
1478        // The same physical message in either serialization parses field-for-field
1479        // to the same IR, including the full metadata block and 6x6 covariance.
1480        assert_eq!(from_kvn, from_xml);
1481    }
1482}