use crate::astro::xml;
use crate::validate;
use roxmltree::{Document, Node};
use std::fmt;
const STATE_KEYS: [&str; 6] = ["X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT"];
const COVARIANCE_KEYS: [&str; 6] = ["CR_R", "CT_R", "CT_T", "CN_R", "CN_T", "CN_N"];
const OBJECT_MARKER: &str = "OBJECT";
const COMMENT_PREFIX: &str = "COMMENT";
const SEGMENT_TAG: &str = "segment";
#[derive(Debug, Clone, PartialEq)]
pub struct CdmKvn {
pub creation_date: Option<String>,
pub originator: Option<String>,
pub message_id: Option<String>,
pub tca: Option<String>,
pub miss_distance_m: Option<f64>,
pub relative_speed_m_s: Option<f64>,
pub collision_probability: Option<f64>,
pub collision_probability_method: Option<String>,
pub hard_body_radius_m: Option<f64>,
pub object1: CdmObject,
pub object2: CdmObject,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CdmObject {
pub object_designator: Option<String>,
pub catalog_name: Option<String>,
pub object_name: Option<String>,
pub international_designator: Option<String>,
pub object_type: Option<String>,
pub ref_frame: Option<String>,
pub state: ((f64, f64, f64), (f64, f64, f64)),
pub covariance_rtn: [f64; 6],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CdmError {
IncompleteStateVector,
InvalidField {
field: &'static str,
kind: CdmInputErrorKind,
},
MalformedXml(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CdmInputErrorKind {
Missing,
NonFinite,
FloatParse,
IntParse,
NotPositive,
Negative,
OutOfRange,
InvalidCivilDate,
InvalidCivilTime,
}
impl fmt::Display for CdmInputErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
Self::Missing => "missing",
Self::NonFinite => "not finite",
Self::FloatParse => "invalid float",
Self::IntParse => "invalid integer",
Self::NotPositive => "not positive",
Self::Negative => "negative",
Self::OutOfRange => "out of range",
Self::InvalidCivilDate => "invalid civil date",
Self::InvalidCivilTime => "invalid civil time",
};
f.write_str(label)
}
}
impl From<&validate::FieldError> for CdmInputErrorKind {
fn from(error: &validate::FieldError) -> Self {
match error {
validate::FieldError::Missing { .. } => Self::Missing,
validate::FieldError::NonFinite { .. } => Self::NonFinite,
validate::FieldError::FloatParse { .. } => Self::FloatParse,
validate::FieldError::IntParse { .. } => Self::IntParse,
validate::FieldError::NotPositive { .. } => Self::NotPositive,
validate::FieldError::Negative { .. } => Self::Negative,
validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
}
}
}
impl fmt::Display for CdmError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CdmError::IncompleteStateVector => write!(f, "incomplete state vector"),
CdmError::InvalidField { field, kind } => {
write!(f, "invalid CDM field {field}: {kind}")
}
CdmError::MalformedXml(detail) => write!(f, "malformed XML: {detail}"),
}
}
}
impl std::error::Error for CdmError {}
pub fn parse_kvn(text: &str) -> Result<CdmKvn, CdmError> {
let lines = significant_lines(text);
let kv = parse_kv_lines(&lines);
let (object1_kv, object2_kv) = split_object_blocks(&lines);
let object1 = parse_object(&object1_kv)?;
let object2 = parse_object(&object2_kv)?;
Ok(CdmKvn {
creation_date: kv_get(&kv, "CREATION_DATE"),
originator: kv_get(&kv, "ORIGINATOR"),
message_id: kv_get(&kv, "MESSAGE_ID"),
tca: kv_get(&kv, "TCA"),
miss_distance_m: optional_kv_num(&kv, "MISS_DISTANCE")?,
relative_speed_m_s: optional_kv_num(&kv, "RELATIVE_SPEED")?,
collision_probability: optional_kv_num(&kv, "COLLISION_PROBABILITY")?,
collision_probability_method: kv_get(&kv, "COLLISION_PROBABILITY_METHOD"),
hard_body_radius_m: parse_hbr(text)?,
object1,
object2,
})
}
pub fn encode_kvn(cdm: &CdmKvn) -> Result<String, CdmError> {
validate_cdm(cdm)?;
let mut lines: Vec<String> = vec![
"CCSDS_CDM_VERS = 1.0".to_string(),
format!("CREATION_DATE = {}", opt_str(&cdm.creation_date)),
format!("ORIGINATOR = {}", opt_str(&cdm.originator)),
format!("MESSAGE_ID = {}", opt_str(&cdm.message_id)),
format!("TCA = {}", opt_str(&cdm.tca)),
format!("MISS_DISTANCE = {} [m]", opt_num(cdm.miss_distance_m)),
format!("RELATIVE_SPEED = {} [m/s]", opt_num(cdm.relative_speed_m_s)),
format!(
"COLLISION_PROBABILITY = {}",
opt_num(cdm.collision_probability)
),
format!(
"COLLISION_PROBABILITY_METHOD = {}",
opt_str(&cdm.collision_probability_method)
),
];
if let Some(hbr) = cdm.hard_body_radius_m {
lines.push(format!("COMMENT HBR = {}", fmt_num(hbr)));
}
lines.extend(encode_object(&cdm.object1, "OBJECT1"));
lines.extend(encode_object(&cdm.object2, "OBJECT2"));
Ok(lines.join("\n"))
}
pub fn parse_xml(text: &str) -> Result<CdmKvn, CdmError> {
let doc = Document::parse(text).map_err(|e| CdmError::MalformedXml(e.to_string()))?;
let root = doc.root();
let mut segments = root
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == SEGMENT_TAG);
let object1 = parse_xml_object(segments.next())?;
let object2 = parse_xml_object(segments.next())?;
Ok(CdmKvn {
creation_date: node_text(root, "CREATION_DATE"),
originator: node_text(root, "ORIGINATOR"),
message_id: node_text(root, "MESSAGE_ID"),
tca: node_text(root, "TCA"),
miss_distance_m: optional_node_num(root, "MISS_DISTANCE")?,
relative_speed_m_s: optional_node_num(root, "RELATIVE_SPEED")?,
collision_probability: optional_node_num(root, "COLLISION_PROBABILITY")?,
collision_probability_method: node_text(root, "COLLISION_PROBABILITY_METHOD"),
hard_body_radius_m: optional_node_num(root, "HBR")?,
object1,
object2,
})
}
pub fn encode_xml(cdm: &CdmKvn) -> Result<String, CdmError> {
validate_cdm(cdm)?;
let mut lines: Vec<String> = vec![
r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string(),
r#"<cdm id="CCSDS_CDM_VERS" version="1.0">"#.to_string(),
" <header>".to_string(),
" <CCSDS_CDM_VERS>1.0</CCSDS_CDM_VERS>".to_string(),
format!(
" <CREATION_DATE>{}</CREATION_DATE>",
opt_str(&cdm.creation_date)
),
format!(
" <ORIGINATOR>{}</ORIGINATOR>",
xml::escape_opt(&cdm.originator)
),
format!(
" <MESSAGE_ID>{}</MESSAGE_ID>",
xml::escape_opt(&cdm.message_id)
),
" </header>".to_string(),
" <body>".to_string(),
" <relativeMetadataData>".to_string(),
format!(" <TCA>{}</TCA>", opt_str(&cdm.tca)),
format!(
r#" <MISS_DISTANCE units="m">{}</MISS_DISTANCE>"#,
opt_num(cdm.miss_distance_m)
),
format!(
r#" <RELATIVE_SPEED units="m/s">{}</RELATIVE_SPEED>"#,
opt_num(cdm.relative_speed_m_s)
),
format!(
" <COLLISION_PROBABILITY>{}</COLLISION_PROBABILITY>",
opt_num(cdm.collision_probability)
),
format!(
" <COLLISION_PROBABILITY_METHOD>{}</COLLISION_PROBABILITY_METHOD>",
xml::escape_opt(&cdm.collision_probability_method)
),
" </relativeMetadataData>".to_string(),
];
lines.extend(encode_xml_segment(&cdm.object1, "OBJECT1"));
lines.extend(encode_xml_segment(&cdm.object2, "OBJECT2"));
lines.push(" </body>".to_string());
lines.push("</cdm>".to_string());
Ok(lines.join("\n"))
}
fn significant_lines(text: &str) -> Vec<String> {
text.split('\n')
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty() && !line.starts_with(COMMENT_PREFIX))
.collect()
}
fn parse_kv_lines(lines: &[String]) -> Vec<(String, String)> {
lines
.iter()
.filter_map(|line| {
line.split_once('=').map(|(key, value)| {
(
key.trim().to_string(),
strip_units(value.trim()).to_string(),
)
})
})
.collect()
}
fn kv_lookup<'a>(kv: &'a [(String, String)], key: &str) -> Option<&'a str> {
kv.iter()
.rev()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
fn kv_get(kv: &[(String, String)], key: &str) -> Option<String> {
kv_lookup(kv, key).map(str::to_string)
}
fn optional_kv_num(kv: &[(String, String)], key: &'static str) -> Result<Option<f64>, CdmError> {
match kv_lookup(kv, key) {
Some(value) => validate::strict_f64(value, key)
.map(Some)
.map_err(map_cdm_field_error),
None => Ok(None),
}
}
fn required_kv_num(kv: &[(String, String)], key: &'static str) -> Result<f64, CdmError> {
let value = kv_lookup(kv, key)
.ok_or(validate::FieldError::Missing { field: key })
.map_err(map_cdm_field_error)?;
validate::strict_f64(value, key).map_err(map_cdm_field_error)
}
fn required_state_kv_num(kv: &[(String, String)], key: &'static str) -> Result<f64, CdmError> {
let value = kv_lookup(kv, key).ok_or(CdmError::IncompleteStateVector)?;
validate::strict_f64(value, key).map_err(map_cdm_field_error)
}
fn map_cdm_field_error(error: validate::FieldError) -> CdmError {
CdmError::InvalidField {
field: error.field(),
kind: CdmInputErrorKind::from(&error),
}
}
fn strip_units(value: &str) -> &str {
let trimmed = value.trim_end();
if let Some(open) = trimmed.rfind('[') {
if trimmed.ends_with(']') {
return trimmed[..open].trim_end();
}
}
trimmed
}
fn split_object_blocks(lines: &[String]) -> (Vec<String>, Vec<String>) {
let markers: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, line)| {
line.split_once('=')
.is_some_and(|(key, _)| key.trim() == OBJECT_MARKER)
})
.map(|(idx, _)| idx)
.collect();
match markers.as_slice() {
[i1, i2, ..] => (lines[*i1..*i2].to_vec(), lines[*i2..].to_vec()),
_ => (Vec::new(), Vec::new()),
}
}
fn parse_object(lines: &[String]) -> Result<CdmObject, CdmError> {
let kv = parse_kv_lines(lines);
let mut state = [0.0_f64; 6];
for (slot, key) in state.iter_mut().zip(STATE_KEYS) {
*slot = required_state_kv_num(&kv, key)?;
}
validate::finite_slice(&state, "state").map_err(map_cdm_field_error)?;
let mut covariance_rtn = [0.0_f64; 6];
for (slot, key) in covariance_rtn.iter_mut().zip(COVARIANCE_KEYS) {
*slot = required_kv_num(&kv, key)?;
}
validate::finite_slice(&covariance_rtn, "covariance_rtn").map_err(map_cdm_field_error)?;
validate_covariance_rtn(&covariance_rtn)?;
Ok(CdmObject {
object_designator: kv_get(&kv, "OBJECT_DESIGNATOR"),
catalog_name: kv_get(&kv, "CATALOG_NAME"),
object_name: kv_get(&kv, "OBJECT_NAME"),
international_designator: kv_get(&kv, "INTERNATIONAL_DESIGNATOR"),
object_type: kv_get(&kv, "OBJECT_TYPE"),
ref_frame: kv_get(&kv, "REF_FRAME"),
state: (
(state[0], state[1], state[2]),
(state[3], state[4], state[5]),
),
covariance_rtn,
})
}
fn parse_hbr(text: &str) -> Result<Option<f64>, CdmError> {
for line in text.split('\n') {
let trimmed = line.trim();
let mut rest = match strip_prefix_ci(trimmed, COMMENT_PREFIX) {
Some(rest) if starts_with_ascii_ws(rest) => rest.trim_start(),
_ => continue,
};
rest = match strip_prefix_ci(rest, "HBR") {
Some(rest) => rest.trim_start(),
None => continue,
};
let rest = match rest.strip_prefix('=') {
Some(rest) => rest.trim_start(),
None => continue,
};
let value = strip_units(rest).split_whitespace().next().unwrap_or("");
if value.is_empty() {
return Ok(None);
}
return validate::strict_f64(value, "HBR")
.map(Some)
.map_err(map_cdm_field_error);
}
Ok(None)
}
fn encode_object(object: &CdmObject, name: &str) -> Vec<String> {
let ((x, y, z), (xd, yd, zd)) = object.state;
let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = object.covariance_rtn;
vec![
format!("OBJECT = {name}"),
format!("OBJECT_DESIGNATOR = {}", opt_str(&object.object_designator)),
format!("OBJECT_NAME = {}", opt_str(&object.object_name)),
format!("REF_FRAME = {}", opt_str(&object.ref_frame)),
format!("X = {} [km]", fmt_num(x)),
format!("Y = {} [km]", fmt_num(y)),
format!("Z = {} [km]", fmt_num(z)),
format!("X_DOT = {} [km/s]", fmt_num(xd)),
format!("Y_DOT = {} [km/s]", fmt_num(yd)),
format!("Z_DOT = {} [km/s]", fmt_num(zd)),
format!("CR_R = {} [m**2]", fmt_num(cr_r)),
format!("CT_R = {} [m**2]", fmt_num(ct_r)),
format!("CT_T = {} [m**2]", fmt_num(ct_t)),
format!("CN_R = {} [m**2]", fmt_num(cn_r)),
format!("CN_T = {} [m**2]", fmt_num(cn_t)),
format!("CN_N = {} [m**2]", fmt_num(cn_n)),
]
}
fn fmt_num(value: f64) -> String {
format!("{value}")
}
fn opt_str(value: &Option<String>) -> String {
value.clone().unwrap_or_default()
}
fn opt_num(value: Option<f64>) -> String {
value.map(fmt_num).unwrap_or_default()
}
fn validate_cdm(cdm: &CdmKvn) -> Result<(), CdmError> {
validate_optional_num(cdm.miss_distance_m, "MISS_DISTANCE")?;
validate_optional_num(cdm.relative_speed_m_s, "RELATIVE_SPEED")?;
validate_optional_num(cdm.collision_probability, "COLLISION_PROBABILITY")?;
validate_optional_num(cdm.hard_body_radius_m, "HBR")?;
validate_object(&cdm.object1)?;
validate_object(&cdm.object2)?;
Ok(())
}
fn validate_optional_num(value: Option<f64>, field: &'static str) -> Result<(), CdmError> {
match value {
Some(value) => validate::finite(value, field)
.map(|_| ())
.map_err(map_cdm_field_error),
None => Ok(()),
}
}
fn validate_object(object: &CdmObject) -> Result<(), CdmError> {
let ((x, y, z), (xd, yd, zd)) = object.state;
validate::finite_slice(&[x, y, z, xd, yd, zd], "state").map_err(map_cdm_field_error)?;
validate::finite_slice(&object.covariance_rtn, "covariance_rtn")
.map_err(map_cdm_field_error)?;
validate_covariance_rtn(&object.covariance_rtn)
}
fn validate_covariance_rtn(covariance_rtn: &[f64; 6]) -> Result<(), CdmError> {
let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = *covariance_rtn;
let covariance = [[cr_r, ct_r, cn_r], [ct_r, ct_t, cn_t], [cn_r, cn_t, cn_n]];
validate::validate_covariance_psd(&covariance, "covariance_rtn").map_err(map_cdm_field_error)
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text
.get(..prefix.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
{
text.get(prefix.len()..)
} else {
None
}
}
fn starts_with_ascii_ws(text: &str) -> bool {
text.chars().next().is_some_and(|c| c.is_ascii_whitespace())
}
fn node_text(node: Node, tag: &str) -> Option<String> {
let element = node
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == tag)?;
let text = element.text()?.trim();
if text.is_empty() {
None
} else {
Some(text.to_string())
}
}
fn optional_node_num(node: Node, tag: &'static str) -> Result<Option<f64>, CdmError> {
match node_text(node, tag) {
Some(value) => validate::strict_f64(&value, tag)
.map(Some)
.map_err(map_cdm_field_error),
None => Ok(None),
}
}
fn required_node_num(node: Node, tag: &'static str) -> Result<f64, CdmError> {
let value = node_text(node, tag)
.ok_or(validate::FieldError::Missing { field: tag })
.map_err(map_cdm_field_error)?;
validate::strict_f64(&value, tag).map_err(map_cdm_field_error)
}
fn required_state_node_num(node: Node, tag: &'static str) -> Result<f64, CdmError> {
let value = node_text(node, tag).ok_or(CdmError::IncompleteStateVector)?;
validate::strict_f64(&value, tag).map_err(map_cdm_field_error)
}
fn parse_xml_object(segment: Option<Node>) -> Result<CdmObject, CdmError> {
let segment = segment.ok_or(CdmError::IncompleteStateVector)?;
let mut state = [0.0_f64; 6];
for (slot, key) in state.iter_mut().zip(STATE_KEYS) {
*slot = required_state_node_num(segment, key)?;
}
validate::finite_slice(&state, "state").map_err(map_cdm_field_error)?;
let mut covariance_rtn = [0.0_f64; 6];
for (slot, key) in covariance_rtn.iter_mut().zip(COVARIANCE_KEYS) {
*slot = required_node_num(segment, key)?;
}
validate::finite_slice(&covariance_rtn, "covariance_rtn").map_err(map_cdm_field_error)?;
validate_covariance_rtn(&covariance_rtn)?;
Ok(CdmObject {
object_designator: node_text(segment, "OBJECT_DESIGNATOR"),
catalog_name: node_text(segment, "CATALOG_NAME"),
object_name: node_text(segment, "OBJECT_NAME"),
international_designator: node_text(segment, "INTERNATIONAL_DESIGNATOR"),
object_type: node_text(segment, "OBJECT_TYPE"),
ref_frame: node_text(segment, "REF_FRAME"),
state: (
(state[0], state[1], state[2]),
(state[3], state[4], state[5]),
),
covariance_rtn,
})
}
fn encode_xml_segment(object: &CdmObject, name: &str) -> Vec<String> {
let ((x, y, z), (xd, yd, zd)) = object.state;
let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = object.covariance_rtn;
vec![
" <segment>".to_string(),
" <metadata>".to_string(),
format!(" <OBJECT>{name}</OBJECT>"),
format!(
" <OBJECT_DESIGNATOR>{}</OBJECT_DESIGNATOR>",
xml::escape_opt(&object.object_designator)
),
format!(
" <OBJECT_NAME>{}</OBJECT_NAME>",
xml::escape_opt(&object.object_name)
),
format!(
" <REF_FRAME>{}</REF_FRAME>",
xml::escape_opt(&object.ref_frame)
),
" </metadata>".to_string(),
" <data>".to_string(),
" <stateVector>".to_string(),
format!(r#" <X units="km">{}</X>"#, fmt_num(x)),
format!(r#" <Y units="km">{}</Y>"#, fmt_num(y)),
format!(r#" <Z units="km">{}</Z>"#, fmt_num(z)),
format!(r#" <X_DOT units="km/s">{}</X_DOT>"#, fmt_num(xd)),
format!(r#" <Y_DOT units="km/s">{}</Y_DOT>"#, fmt_num(yd)),
format!(r#" <Z_DOT units="km/s">{}</Z_DOT>"#, fmt_num(zd)),
" </stateVector>".to_string(),
" <covarianceMatrix>".to_string(),
format!(r#" <CR_R units="m**2">{}</CR_R>"#, fmt_num(cr_r)),
format!(r#" <CT_R units="m**2">{}</CT_R>"#, fmt_num(ct_r)),
format!(r#" <CT_T units="m**2">{}</CT_T>"#, fmt_num(ct_t)),
format!(r#" <CN_R units="m**2">{}</CN_R>"#, fmt_num(cn_r)),
format!(r#" <CN_T units="m**2">{}</CN_T>"#, fmt_num(cn_t)),
format!(r#" <CN_N units="m**2">{}</CN_N>"#, fmt_num(cn_n)),
" </covarianceMatrix>".to_string(),
" </data>".to_string(),
" </segment>".to_string(),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_units_removes_trailing_bracket() {
assert_eq!(strip_units("7000.0 [km]"), "7000.0");
assert_eq!(strip_units("4.835E-05"), "4.835E-05");
assert_eq!(strip_units("0.045663 [m**2/kg]"), "0.045663");
assert_eq!(strip_units("97.8 [%]"), "97.8");
}
#[test]
fn cdm_covariance_rtn_validation_accepts_psd_lower_triangle() {
assert_eq!(
validate_covariance_rtn(&[1.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
Ok(())
);
}
#[test]
fn cdm_covariance_rtn_validation_rejects_non_psd_lower_triangle() {
let expected = Err(CdmError::InvalidField {
field: "covariance_rtn",
kind: CdmInputErrorKind::NotPositive,
});
assert_eq!(
validate_covariance_rtn(&[-1.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
expected
);
assert_eq!(
validate_covariance_rtn(&[1.0, 2.0, 1.0, 0.0, 0.0, 1.0]),
expected
);
}
#[test]
fn incomplete_state_vector_is_rejected() {
let kvn = "OBJECT = OBJECT1\nX = 7000.0 [km]\nOBJECT = OBJECT2\nX = 1.0 [km]\n";
assert_eq!(parse_kvn(kvn), Err(CdmError::IncompleteStateVector));
}
#[test]
fn hbr_is_recovered_from_comment_only() {
let with_hbr = "COMMENT HBR = 15.5\n";
assert_eq!(parse_hbr(with_hbr), Ok(Some(15.5)));
assert_eq!(parse_hbr("COMMENT Relative Metadata/Data\n"), Ok(None));
}
#[test]
fn kvn_hbr_comment_with_multibyte_leading_token_is_ignored() {
let kvn = "\
CREATION_DATE = 2024-01-01T00:00:00.000
MESSAGE_ID = HBR_TEST
COMMENT \u{1f4a5}BR = 15.5
TCA = 2024-01-01T12:00:00.000
OBJECT = OBJECT1
X = 1.0 [km]
Y = 2.0 [km]
Z = 3.0 [km]
X_DOT = 0.1 [km/s]
Y_DOT = 0.2 [km/s]
Z_DOT = 0.3 [km/s]
CR_R = 1.0 [m**2]
CT_R = 0.0 [m**2]
CT_T = 1.0 [m**2]
CN_R = 0.0 [m**2]
CN_T = 0.0 [m**2]
CN_N = 1.0 [m**2]
OBJECT = OBJECT2
X = 4.0 [km]
Y = 5.0 [km]
Z = 6.0 [km]
X_DOT = 0.4 [km/s]
Y_DOT = 0.5 [km/s]
Z_DOT = 0.6 [km/s]
CR_R = 1.0 [m**2]
CT_R = 0.0 [m**2]
CT_T = 1.0 [m**2]
CN_R = 0.0 [m**2]
CN_T = 0.0 [m**2]
CN_N = 1.0 [m**2]
";
let parsed = parse_kvn(kvn).expect("malformed HBR comment must not panic");
assert_eq!(parsed.hard_body_radius_m, None);
}
#[test]
fn node_text_reads_leaf_value_ignoring_attrs() {
let doc = Document::parse(
r#"<r><MESSAGE_ID>abc123</MESSAGE_ID><X units="km">2570.097065</X><ORIGINATOR></ORIGINATOR></r>"#,
)
.unwrap();
let root = doc.root();
assert_eq!(node_text(root, "MESSAGE_ID").as_deref(), Some("abc123"));
assert_eq!(node_text(root, "X").as_deref(), Some("2570.097065"));
assert_eq!(node_text(root, "ORIGINATOR"), None);
let only_xdot = Document::parse(r#"<r><X_DOT units="km/s">4.4</X_DOT></r>"#).unwrap();
assert_eq!(node_text(only_xdot.root(), "X"), None);
}
#[test]
fn xml_parse_decodes_entities_and_ignores_extra_covariance_element() {
let xml = r#"<cdm><body>
<segment><metadata><OBJECT_NAME>SAT A & B</OBJECT_NAME></metadata>
<data><stateVector>
<X units="km">1.0</X><Y units="km">2.0</Y><Z units="km">3.0</Z>
<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>
</stateVector><covarianceMatrix>
<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>
<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>
<CRDOT_R units="m**2/s">2.52e-3</CRDOT_R>
</covarianceMatrix></data></segment>
<segment><data><stateVector>
<X units="km">4.0</X><Y units="km">5.0</Y><Z units="km">6.0</Z>
<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>
</stateVector><covarianceMatrix>
<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>
<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>
</covarianceMatrix></data></segment>
</body></cdm>"#;
let cdm = parse_xml(xml).unwrap();
assert_eq!(cdm.object1.object_name.as_deref(), Some("SAT A & B"));
assert_eq!(
cdm.object1.covariance_rtn,
[41.42, -8.579, 2533.0, -23.13, 13.36, 70.98]
);
}
#[test]
fn xml_incomplete_state_vector_is_rejected() {
let xml = "<cdm><body>\
<segment><data><stateVector><X units=\"km\">1.0</X></stateVector></data></segment>\
<segment><data><stateVector></stateVector></data></segment>\
</body></cdm>";
assert_eq!(parse_xml(xml), Err(CdmError::IncompleteStateVector));
}
#[test]
fn xml_malformed_document_is_rejected() {
assert!(matches!(
parse_xml("<segment></segment><segment></segment>"),
Err(CdmError::MalformedXml(_))
));
}
#[test]
fn xml_round_trips_through_encode_and_parse() {
let object = CdmObject {
object_designator: Some("12345".to_string()),
catalog_name: None,
object_name: Some("SAT A & B".to_string()),
international_designator: None,
object_type: None,
ref_frame: Some("EME2000".to_string()),
state: ((1.5, 2.5, 3.5), (0.1, 0.2, 0.3)),
covariance_rtn: [41.42, -8.579, 2533.0, -23.13, 13.36, 70.98],
};
let original = CdmKvn {
creation_date: Some("2024-01-01T00:00:00.000".to_string()),
originator: Some("TEST".to_string()),
message_id: Some("ID-1".to_string()),
tca: Some("2024-01-01T12:00:00.000".to_string()),
miss_distance_m: Some(715.0),
relative_speed_m_s: Some(14762.0),
collision_probability: Some(4.835e-5),
collision_probability_method: Some("FOSTER-1992".to_string()),
hard_body_radius_m: None,
object1: object.clone(),
object2: object,
};
let encoded = encode_xml(&original).expect("valid CDM XML encode");
assert!(encoded.starts_with("<?xml"));
assert!(encoded.contains("SAT A & B"));
let reparsed = parse_xml(&encoded).unwrap();
assert_eq!(reparsed.object1.state, original.object1.state);
assert_eq!(
reparsed.object2.covariance_rtn,
original.object2.covariance_rtn
);
assert_eq!(reparsed.miss_distance_m, original.miss_distance_m);
assert_eq!(
reparsed.collision_probability,
original.collision_probability
);
assert_eq!(reparsed.message_id, original.message_id);
assert_eq!(reparsed.tca, original.tca);
}
#[test]
fn optional_non_finite_kvn_fields_are_rejected() {
let kvn = "OBJECT = OBJECT1\n\
X = 1.0 [km]\nY = 2.0 [km]\nZ = 3.0 [km]\n\
X_DOT = 0.1 [km/s]\nY_DOT = 0.2 [km/s]\nZ_DOT = 0.3 [km/s]\n\
CR_R = 1.0 [m**2]\nCT_R = 0.0 [m**2]\nCT_T = 1.0 [m**2]\n\
CN_R = 0.0 [m**2]\nCN_T = 0.0 [m**2]\nCN_N = 1.0 [m**2]\n\
OBJECT = OBJECT2\n\
X = 4.0 [km]\nY = 5.0 [km]\nZ = 6.0 [km]\n\
X_DOT = 0.4 [km/s]\nY_DOT = 0.5 [km/s]\nZ_DOT = 0.6 [km/s]\n\
CR_R = 1.0 [m**2]\nCT_R = 0.0 [m**2]\nCT_T = 1.0 [m**2]\n\
CN_R = 0.0 [m**2]\nCN_T = 0.0 [m**2]\nCN_N = 1.0 [m**2]\n\
MISS_DISTANCE = NaN [m]\n";
assert_eq!(
parse_kvn(kvn),
Err(CdmError::InvalidField {
field: "MISS_DISTANCE",
kind: CdmInputErrorKind::NonFinite,
})
);
}
#[test]
fn optional_non_finite_xml_fields_are_rejected() {
let xml = r#"<cdm><body>
<relativeMetadataData><COLLISION_PROBABILITY>inf</COLLISION_PROBABILITY></relativeMetadataData>
<segment><data><stateVector>
<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>
</stateVector><covarianceMatrix>
<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>
</covarianceMatrix></data></segment>
<segment><data><stateVector>
<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>
</stateVector><covarianceMatrix>
<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>
</covarianceMatrix></data></segment>
</body></cdm>"#;
assert_eq!(
parse_xml(xml),
Err(CdmError::InvalidField {
field: "COLLISION_PROBABILITY",
kind: CdmInputErrorKind::NonFinite,
})
);
}
#[test]
fn encode_rejects_non_finite_public_numeric_fields() {
let object = CdmObject {
object_designator: None,
catalog_name: None,
object_name: None,
international_designator: None,
object_type: None,
ref_frame: None,
state: ((1.0, 2.0, 3.0), (0.1, 0.2, 0.3)),
covariance_rtn: [1.0, 0.0, 1.0, 0.0, 0.0, 1.0],
};
let mut cdm = CdmKvn {
creation_date: None,
originator: None,
message_id: None,
tca: None,
miss_distance_m: Some(f64::NAN),
relative_speed_m_s: None,
collision_probability: None,
collision_probability_method: None,
hard_body_radius_m: None,
object1: object.clone(),
object2: object,
};
assert_eq!(
encode_kvn(&cdm),
Err(CdmError::InvalidField {
field: "MISS_DISTANCE",
kind: CdmInputErrorKind::NonFinite,
})
);
cdm.miss_distance_m = Some(1.0);
cdm.object1.state.0 = (f64::INFINITY, 2.0, 3.0);
assert_eq!(
encode_xml(&cdm),
Err(CdmError::InvalidField {
field: "state",
kind: CdmInputErrorKind::NonFinite,
})
);
}
}