1use crate::astro::xml;
29use crate::validate;
30use roxmltree::{Document, Node};
31use std::fmt;
32
33const STATE_KEYS: [&str; 6] = ["X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT"];
36const COVARIANCE_KEYS: [&str; 6] = ["CR_R", "CT_R", "CT_T", "CN_R", "CN_T", "CN_N"];
38const OBJECT_MARKER: &str = "OBJECT";
40const COMMENT_PREFIX: &str = "COMMENT";
43const SEGMENT_TAG: &str = "segment";
45
46#[derive(Debug, Clone, PartialEq)]
50pub struct CdmKvn {
51 pub creation_date: Option<String>,
52 pub originator: Option<String>,
53 pub message_id: Option<String>,
54 pub tca: Option<String>,
55 pub miss_distance_m: Option<f64>,
56 pub relative_speed_m_s: Option<f64>,
57 pub collision_probability: Option<f64>,
58 pub collision_probability_method: Option<String>,
59 pub hard_body_radius_m: Option<f64>,
60 pub object1: CdmObject,
61 pub object2: CdmObject,
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct CdmObject {
67 pub object_designator: Option<String>,
68 pub catalog_name: Option<String>,
69 pub object_name: Option<String>,
70 pub international_designator: Option<String>,
71 pub object_type: Option<String>,
72 pub ref_frame: Option<String>,
73 pub state: ((f64, f64, f64), (f64, f64, f64)),
75 pub covariance_rtn: [f64; 6],
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum CdmError {
83 IncompleteStateVector,
85 InvalidField {
87 field: &'static str,
89 kind: CdmInputErrorKind,
91 },
92 MalformedXml(String),
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum CdmInputErrorKind {
99 Missing,
101 NonFinite,
103 FloatParse,
105 IntParse,
107 NotPositive,
109 Negative,
111 OutOfRange,
113 InvalidCivilDate,
115 InvalidCivilTime,
117}
118
119impl fmt::Display for CdmInputErrorKind {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 let label = match self {
122 Self::Missing => "missing",
123 Self::NonFinite => "not finite",
124 Self::FloatParse => "invalid float",
125 Self::IntParse => "invalid integer",
126 Self::NotPositive => "not positive",
127 Self::Negative => "negative",
128 Self::OutOfRange => "out of range",
129 Self::InvalidCivilDate => "invalid civil date",
130 Self::InvalidCivilTime => "invalid civil time",
131 };
132 f.write_str(label)
133 }
134}
135
136impl From<&validate::FieldError> for CdmInputErrorKind {
137 fn from(error: &validate::FieldError) -> Self {
138 match error {
139 validate::FieldError::Missing { .. } => Self::Missing,
140 validate::FieldError::NonFinite { .. } => Self::NonFinite,
141 validate::FieldError::FloatParse { .. } => Self::FloatParse,
142 validate::FieldError::IntParse { .. } => Self::IntParse,
143 validate::FieldError::NotPositive { .. } => Self::NotPositive,
144 validate::FieldError::Negative { .. } => Self::Negative,
145 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
146 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
147 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
148 }
149 }
150}
151
152impl fmt::Display for CdmError {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 match self {
155 CdmError::IncompleteStateVector => write!(f, "incomplete state vector"),
156 CdmError::InvalidField { field, kind } => {
157 write!(f, "invalid CDM field {field}: {kind}")
158 }
159 CdmError::MalformedXml(detail) => write!(f, "malformed XML: {detail}"),
160 }
161 }
162}
163
164impl std::error::Error for CdmError {}
165
166pub fn parse_kvn(text: &str) -> Result<CdmKvn, CdmError> {
176 let lines = significant_lines(text);
177 let kv = parse_kv_lines(&lines);
178
179 let (object1_kv, object2_kv) = split_object_blocks(&lines);
180 let object1 = parse_object(&object1_kv)?;
181 let object2 = parse_object(&object2_kv)?;
182
183 Ok(CdmKvn {
184 creation_date: kv_get(&kv, "CREATION_DATE"),
185 originator: kv_get(&kv, "ORIGINATOR"),
186 message_id: kv_get(&kv, "MESSAGE_ID"),
187 tca: kv_get(&kv, "TCA"),
188 miss_distance_m: optional_kv_num(&kv, "MISS_DISTANCE")?,
189 relative_speed_m_s: optional_kv_num(&kv, "RELATIVE_SPEED")?,
190 collision_probability: optional_kv_num(&kv, "COLLISION_PROBABILITY")?,
191 collision_probability_method: kv_get(&kv, "COLLISION_PROBABILITY_METHOD"),
192 hard_body_radius_m: parse_hbr(text)?,
193 object1,
194 object2,
195 })
196}
197
198pub fn encode_kvn(cdm: &CdmKvn) -> Result<String, CdmError> {
206 validate_cdm(cdm)?;
207 let mut lines: Vec<String> = vec![
208 "CCSDS_CDM_VERS = 1.0".to_string(),
209 format!("CREATION_DATE = {}", opt_str(&cdm.creation_date)),
210 format!("ORIGINATOR = {}", opt_str(&cdm.originator)),
211 format!("MESSAGE_ID = {}", opt_str(&cdm.message_id)),
212 format!("TCA = {}", opt_str(&cdm.tca)),
213 format!("MISS_DISTANCE = {} [m]", opt_num(cdm.miss_distance_m)),
214 format!("RELATIVE_SPEED = {} [m/s]", opt_num(cdm.relative_speed_m_s)),
215 format!(
216 "COLLISION_PROBABILITY = {}",
217 opt_num(cdm.collision_probability)
218 ),
219 format!(
220 "COLLISION_PROBABILITY_METHOD = {}",
221 opt_str(&cdm.collision_probability_method)
222 ),
223 ];
224
225 if let Some(hbr) = cdm.hard_body_radius_m {
226 lines.push(format!("COMMENT HBR = {}", fmt_num(hbr)));
227 }
228
229 lines.extend(encode_object(&cdm.object1, "OBJECT1"));
230 lines.extend(encode_object(&cdm.object2, "OBJECT2"));
231
232 Ok(lines.join("\n"))
233}
234
235pub fn parse_xml(text: &str) -> Result<CdmKvn, CdmError> {
250 let doc = Document::parse(text).map_err(|e| CdmError::MalformedXml(e.to_string()))?;
251 let root = doc.root();
252
253 let mut segments = root
254 .descendants()
255 .filter(|n| n.is_element() && n.tag_name().name() == SEGMENT_TAG);
256 let object1 = parse_xml_object(segments.next())?;
257 let object2 = parse_xml_object(segments.next())?;
258
259 Ok(CdmKvn {
260 creation_date: node_text(root, "CREATION_DATE"),
261 originator: node_text(root, "ORIGINATOR"),
262 message_id: node_text(root, "MESSAGE_ID"),
263 tca: node_text(root, "TCA"),
264 miss_distance_m: optional_node_num(root, "MISS_DISTANCE")?,
265 relative_speed_m_s: optional_node_num(root, "RELATIVE_SPEED")?,
266 collision_probability: optional_node_num(root, "COLLISION_PROBABILITY")?,
267 collision_probability_method: node_text(root, "COLLISION_PROBABILITY_METHOD"),
268 hard_body_radius_m: optional_node_num(root, "HBR")?,
269 object1,
270 object2,
271 })
272}
273
274pub fn encode_xml(cdm: &CdmKvn) -> Result<String, CdmError> {
288 validate_cdm(cdm)?;
289 let mut lines: Vec<String> = vec![
290 r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string(),
291 r#"<cdm id="CCSDS_CDM_VERS" version="1.0">"#.to_string(),
292 " <header>".to_string(),
293 " <CCSDS_CDM_VERS>1.0</CCSDS_CDM_VERS>".to_string(),
294 format!(
295 " <CREATION_DATE>{}</CREATION_DATE>",
296 opt_str(&cdm.creation_date)
297 ),
298 format!(
299 " <ORIGINATOR>{}</ORIGINATOR>",
300 xml::escape_opt(&cdm.originator)
301 ),
302 format!(
303 " <MESSAGE_ID>{}</MESSAGE_ID>",
304 xml::escape_opt(&cdm.message_id)
305 ),
306 " </header>".to_string(),
307 " <body>".to_string(),
308 " <relativeMetadataData>".to_string(),
309 format!(" <TCA>{}</TCA>", opt_str(&cdm.tca)),
310 format!(
311 r#" <MISS_DISTANCE units="m">{}</MISS_DISTANCE>"#,
312 opt_num(cdm.miss_distance_m)
313 ),
314 format!(
315 r#" <RELATIVE_SPEED units="m/s">{}</RELATIVE_SPEED>"#,
316 opt_num(cdm.relative_speed_m_s)
317 ),
318 format!(
319 " <COLLISION_PROBABILITY>{}</COLLISION_PROBABILITY>",
320 opt_num(cdm.collision_probability)
321 ),
322 format!(
323 " <COLLISION_PROBABILITY_METHOD>{}</COLLISION_PROBABILITY_METHOD>",
324 xml::escape_opt(&cdm.collision_probability_method)
325 ),
326 " </relativeMetadataData>".to_string(),
327 ];
328
329 lines.extend(encode_xml_segment(&cdm.object1, "OBJECT1"));
330 lines.extend(encode_xml_segment(&cdm.object2, "OBJECT2"));
331 lines.push(" </body>".to_string());
332 lines.push("</cdm>".to_string());
333
334 Ok(lines.join("\n"))
335}
336
337fn significant_lines(text: &str) -> Vec<String> {
342 text.split('\n')
343 .map(|line| line.trim().to_string())
344 .filter(|line| !line.is_empty() && !line.starts_with(COMMENT_PREFIX))
345 .collect()
346}
347
348fn parse_kv_lines(lines: &[String]) -> Vec<(String, String)> {
352 lines
353 .iter()
354 .filter_map(|line| {
355 line.split_once('=').map(|(key, value)| {
356 (
357 key.trim().to_string(),
358 strip_units(value.trim()).to_string(),
359 )
360 })
361 })
362 .collect()
363}
364
365fn kv_lookup<'a>(kv: &'a [(String, String)], key: &str) -> Option<&'a str> {
367 kv.iter()
368 .rev()
369 .find(|(k, _)| k == key)
370 .map(|(_, v)| v.as_str())
371}
372
373fn kv_get(kv: &[(String, String)], key: &str) -> Option<String> {
374 kv_lookup(kv, key).map(str::to_string)
375}
376
377fn optional_kv_num(kv: &[(String, String)], key: &'static str) -> Result<Option<f64>, CdmError> {
378 match kv_lookup(kv, key) {
379 Some(value) => validate::strict_f64(value, key)
380 .map(Some)
381 .map_err(map_cdm_field_error),
382 None => Ok(None),
383 }
384}
385
386fn required_kv_num(kv: &[(String, String)], key: &'static str) -> Result<f64, CdmError> {
387 let value = kv_lookup(kv, key)
388 .ok_or(validate::FieldError::Missing { field: key })
389 .map_err(map_cdm_field_error)?;
390 validate::strict_f64(value, key).map_err(map_cdm_field_error)
391}
392
393fn required_state_kv_num(kv: &[(String, String)], key: &'static str) -> Result<f64, CdmError> {
394 let value = kv_lookup(kv, key).ok_or(CdmError::IncompleteStateVector)?;
395 validate::strict_f64(value, key).map_err(map_cdm_field_error)
396}
397
398fn map_cdm_field_error(error: validate::FieldError) -> CdmError {
399 CdmError::InvalidField {
400 field: error.field(),
401 kind: CdmInputErrorKind::from(&error),
402 }
403}
404
405fn strip_units(value: &str) -> &str {
408 let trimmed = value.trim_end();
409 if let Some(open) = trimmed.rfind('[') {
410 if trimmed.ends_with(']') {
411 return trimmed[..open].trim_end();
412 }
413 }
414 trimmed
415}
416
417fn split_object_blocks(lines: &[String]) -> (Vec<String>, Vec<String>) {
421 let markers: Vec<usize> = lines
422 .iter()
423 .enumerate()
424 .filter(|(_, line)| {
425 line.split_once('=')
426 .is_some_and(|(key, _)| key.trim() == OBJECT_MARKER)
427 })
428 .map(|(idx, _)| idx)
429 .collect();
430
431 match markers.as_slice() {
432 [i1, i2, ..] => (lines[*i1..*i2].to_vec(), lines[*i2..].to_vec()),
433 _ => (Vec::new(), Vec::new()),
434 }
435}
436
437fn parse_object(lines: &[String]) -> Result<CdmObject, CdmError> {
438 let kv = parse_kv_lines(lines);
439
440 let mut state = [0.0_f64; 6];
441 for (slot, key) in state.iter_mut().zip(STATE_KEYS) {
442 *slot = required_state_kv_num(&kv, key)?;
443 }
444 validate::finite_slice(&state, "state").map_err(map_cdm_field_error)?;
445
446 let mut covariance_rtn = [0.0_f64; 6];
447 for (slot, key) in covariance_rtn.iter_mut().zip(COVARIANCE_KEYS) {
448 *slot = required_kv_num(&kv, key)?;
449 }
450 validate::finite_slice(&covariance_rtn, "covariance_rtn").map_err(map_cdm_field_error)?;
451 validate_covariance_rtn(&covariance_rtn)?;
452
453 Ok(CdmObject {
454 object_designator: kv_get(&kv, "OBJECT_DESIGNATOR"),
455 catalog_name: kv_get(&kv, "CATALOG_NAME"),
456 object_name: kv_get(&kv, "OBJECT_NAME"),
457 international_designator: kv_get(&kv, "INTERNATIONAL_DESIGNATOR"),
458 object_type: kv_get(&kv, "OBJECT_TYPE"),
459 ref_frame: kv_get(&kv, "REF_FRAME"),
460 state: (
461 (state[0], state[1], state[2]),
462 (state[3], state[4], state[5]),
463 ),
464 covariance_rtn,
465 })
466}
467
468fn parse_hbr(text: &str) -> Result<Option<f64>, CdmError> {
472 for line in text.split('\n') {
473 let trimmed = line.trim();
474 let mut rest = match strip_prefix_ci(trimmed, COMMENT_PREFIX) {
475 Some(rest) if starts_with_ascii_ws(rest) => rest.trim_start(),
476 _ => continue,
477 };
478 rest = match strip_prefix_ci(rest, "HBR") {
479 Some(rest) => rest.trim_start(),
480 None => continue,
481 };
482 let rest = match rest.strip_prefix('=') {
483 Some(rest) => rest.trim_start(),
484 None => continue,
485 };
486 let value = strip_units(rest).split_whitespace().next().unwrap_or("");
487 if value.is_empty() {
488 return Ok(None);
489 }
490 return validate::strict_f64(value, "HBR")
491 .map(Some)
492 .map_err(map_cdm_field_error);
493 }
494 Ok(None)
495}
496
497fn encode_object(object: &CdmObject, name: &str) -> Vec<String> {
500 let ((x, y, z), (xd, yd, zd)) = object.state;
501 let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = object.covariance_rtn;
502
503 vec![
504 format!("OBJECT = {name}"),
505 format!("OBJECT_DESIGNATOR = {}", opt_str(&object.object_designator)),
506 format!("OBJECT_NAME = {}", opt_str(&object.object_name)),
507 format!("REF_FRAME = {}", opt_str(&object.ref_frame)),
508 format!("X = {} [km]", fmt_num(x)),
509 format!("Y = {} [km]", fmt_num(y)),
510 format!("Z = {} [km]", fmt_num(z)),
511 format!("X_DOT = {} [km/s]", fmt_num(xd)),
512 format!("Y_DOT = {} [km/s]", fmt_num(yd)),
513 format!("Z_DOT = {} [km/s]", fmt_num(zd)),
514 format!("CR_R = {} [m**2]", fmt_num(cr_r)),
515 format!("CT_R = {} [m**2]", fmt_num(ct_r)),
516 format!("CT_T = {} [m**2]", fmt_num(ct_t)),
517 format!("CN_R = {} [m**2]", fmt_num(cn_r)),
518 format!("CN_T = {} [m**2]", fmt_num(cn_t)),
519 format!("CN_N = {} [m**2]", fmt_num(cn_n)),
520 ]
521}
522
523fn fmt_num(value: f64) -> String {
527 format!("{value}")
528}
529
530fn opt_str(value: &Option<String>) -> String {
531 value.clone().unwrap_or_default()
532}
533
534fn opt_num(value: Option<f64>) -> String {
535 value.map(fmt_num).unwrap_or_default()
536}
537
538fn validate_cdm(cdm: &CdmKvn) -> Result<(), CdmError> {
539 validate_optional_num(cdm.miss_distance_m, "MISS_DISTANCE")?;
540 validate_optional_num(cdm.relative_speed_m_s, "RELATIVE_SPEED")?;
541 validate_optional_num(cdm.collision_probability, "COLLISION_PROBABILITY")?;
542 validate_optional_num(cdm.hard_body_radius_m, "HBR")?;
543 validate_object(&cdm.object1)?;
544 validate_object(&cdm.object2)?;
545 Ok(())
546}
547
548fn validate_optional_num(value: Option<f64>, field: &'static str) -> Result<(), CdmError> {
549 match value {
550 Some(value) => validate::finite(value, field)
551 .map(|_| ())
552 .map_err(map_cdm_field_error),
553 None => Ok(()),
554 }
555}
556
557fn validate_object(object: &CdmObject) -> Result<(), CdmError> {
558 let ((x, y, z), (xd, yd, zd)) = object.state;
559 validate::finite_slice(&[x, y, z, xd, yd, zd], "state").map_err(map_cdm_field_error)?;
560 validate::finite_slice(&object.covariance_rtn, "covariance_rtn")
561 .map_err(map_cdm_field_error)?;
562 validate_covariance_rtn(&object.covariance_rtn)
563}
564
565fn validate_covariance_rtn(covariance_rtn: &[f64; 6]) -> Result<(), CdmError> {
566 let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = *covariance_rtn;
567 let covariance = [[cr_r, ct_r, cn_r], [ct_r, ct_t, cn_t], [cn_r, cn_t, cn_n]];
568 validate::validate_covariance_psd(&covariance, "covariance_rtn").map_err(map_cdm_field_error)
569}
570
571fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
573 if text
574 .get(..prefix.len())
575 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
576 {
577 text.get(prefix.len()..)
578 } else {
579 None
580 }
581}
582
583fn starts_with_ascii_ws(text: &str) -> bool {
584 text.chars().next().is_some_and(|c| c.is_ascii_whitespace())
585}
586
587fn node_text(node: Node, tag: &str) -> Option<String> {
594 let element = node
595 .descendants()
596 .find(|n| n.is_element() && n.tag_name().name() == tag)?;
597 let text = element.text()?.trim();
598 if text.is_empty() {
599 None
600 } else {
601 Some(text.to_string())
602 }
603}
604
605fn optional_node_num(node: Node, tag: &'static str) -> Result<Option<f64>, CdmError> {
607 match node_text(node, tag) {
608 Some(value) => validate::strict_f64(&value, tag)
609 .map(Some)
610 .map_err(map_cdm_field_error),
611 None => Ok(None),
612 }
613}
614
615fn required_node_num(node: Node, tag: &'static str) -> Result<f64, CdmError> {
616 let value = node_text(node, tag)
617 .ok_or(validate::FieldError::Missing { field: tag })
618 .map_err(map_cdm_field_error)?;
619 validate::strict_f64(&value, tag).map_err(map_cdm_field_error)
620}
621
622fn required_state_node_num(node: Node, tag: &'static str) -> Result<f64, CdmError> {
623 let value = node_text(node, tag).ok_or(CdmError::IncompleteStateVector)?;
624 validate::strict_f64(&value, tag).map_err(map_cdm_field_error)
625}
626
627fn parse_xml_object(segment: Option<Node>) -> Result<CdmObject, CdmError> {
631 let segment = segment.ok_or(CdmError::IncompleteStateVector)?;
632
633 let mut state = [0.0_f64; 6];
634 for (slot, key) in state.iter_mut().zip(STATE_KEYS) {
635 *slot = required_state_node_num(segment, key)?;
636 }
637 validate::finite_slice(&state, "state").map_err(map_cdm_field_error)?;
638
639 let mut covariance_rtn = [0.0_f64; 6];
640 for (slot, key) in covariance_rtn.iter_mut().zip(COVARIANCE_KEYS) {
641 *slot = required_node_num(segment, key)?;
642 }
643 validate::finite_slice(&covariance_rtn, "covariance_rtn").map_err(map_cdm_field_error)?;
644 validate_covariance_rtn(&covariance_rtn)?;
645
646 Ok(CdmObject {
647 object_designator: node_text(segment, "OBJECT_DESIGNATOR"),
648 catalog_name: node_text(segment, "CATALOG_NAME"),
649 object_name: node_text(segment, "OBJECT_NAME"),
650 international_designator: node_text(segment, "INTERNATIONAL_DESIGNATOR"),
651 object_type: node_text(segment, "OBJECT_TYPE"),
652 ref_frame: node_text(segment, "REF_FRAME"),
653 state: (
654 (state[0], state[1], state[2]),
655 (state[3], state[4], state[5]),
656 ),
657 covariance_rtn,
658 })
659}
660
661fn encode_xml_segment(object: &CdmObject, name: &str) -> Vec<String> {
664 let ((x, y, z), (xd, yd, zd)) = object.state;
665 let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = object.covariance_rtn;
666
667 vec![
668 " <segment>".to_string(),
669 " <metadata>".to_string(),
670 format!(" <OBJECT>{name}</OBJECT>"),
671 format!(
672 " <OBJECT_DESIGNATOR>{}</OBJECT_DESIGNATOR>",
673 xml::escape_opt(&object.object_designator)
674 ),
675 format!(
676 " <OBJECT_NAME>{}</OBJECT_NAME>",
677 xml::escape_opt(&object.object_name)
678 ),
679 format!(
680 " <REF_FRAME>{}</REF_FRAME>",
681 xml::escape_opt(&object.ref_frame)
682 ),
683 " </metadata>".to_string(),
684 " <data>".to_string(),
685 " <stateVector>".to_string(),
686 format!(r#" <X units="km">{}</X>"#, fmt_num(x)),
687 format!(r#" <Y units="km">{}</Y>"#, fmt_num(y)),
688 format!(r#" <Z units="km">{}</Z>"#, fmt_num(z)),
689 format!(r#" <X_DOT units="km/s">{}</X_DOT>"#, fmt_num(xd)),
690 format!(r#" <Y_DOT units="km/s">{}</Y_DOT>"#, fmt_num(yd)),
691 format!(r#" <Z_DOT units="km/s">{}</Z_DOT>"#, fmt_num(zd)),
692 " </stateVector>".to_string(),
693 " <covarianceMatrix>".to_string(),
694 format!(r#" <CR_R units="m**2">{}</CR_R>"#, fmt_num(cr_r)),
695 format!(r#" <CT_R units="m**2">{}</CT_R>"#, fmt_num(ct_r)),
696 format!(r#" <CT_T units="m**2">{}</CT_T>"#, fmt_num(ct_t)),
697 format!(r#" <CN_R units="m**2">{}</CN_R>"#, fmt_num(cn_r)),
698 format!(r#" <CN_T units="m**2">{}</CN_T>"#, fmt_num(cn_t)),
699 format!(r#" <CN_N units="m**2">{}</CN_N>"#, fmt_num(cn_n)),
700 " </covarianceMatrix>".to_string(),
701 " </data>".to_string(),
702 " </segment>".to_string(),
703 ]
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
711 fn strip_units_removes_trailing_bracket() {
712 assert_eq!(strip_units("7000.0 [km]"), "7000.0");
713 assert_eq!(strip_units("4.835E-05"), "4.835E-05");
714 assert_eq!(strip_units("0.045663 [m**2/kg]"), "0.045663");
715 assert_eq!(strip_units("97.8 [%]"), "97.8");
716 }
717
718 #[test]
719 fn cdm_covariance_rtn_validation_accepts_psd_lower_triangle() {
720 assert_eq!(
721 validate_covariance_rtn(&[1.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
722 Ok(())
723 );
724 }
725
726 #[test]
727 fn cdm_covariance_rtn_validation_rejects_non_psd_lower_triangle() {
728 let expected = Err(CdmError::InvalidField {
729 field: "covariance_rtn",
730 kind: CdmInputErrorKind::NotPositive,
731 });
732
733 assert_eq!(
734 validate_covariance_rtn(&[-1.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
735 expected
736 );
737 assert_eq!(
738 validate_covariance_rtn(&[1.0, 2.0, 1.0, 0.0, 0.0, 1.0]),
739 expected
740 );
741 }
742
743 #[test]
744 fn incomplete_state_vector_is_rejected() {
745 let kvn = "OBJECT = OBJECT1\nX = 7000.0 [km]\nOBJECT = OBJECT2\nX = 1.0 [km]\n";
746 assert_eq!(parse_kvn(kvn), Err(CdmError::IncompleteStateVector));
747 }
748
749 #[test]
750 fn hbr_is_recovered_from_comment_only() {
751 let with_hbr = "COMMENT HBR = 15.5\n";
752 assert_eq!(parse_hbr(with_hbr), Ok(Some(15.5)));
753 assert_eq!(parse_hbr("COMMENT Relative Metadata/Data\n"), Ok(None));
754 }
755
756 #[test]
757 fn kvn_hbr_comment_with_multibyte_leading_token_is_ignored() {
758 let kvn = "\
759CREATION_DATE = 2024-01-01T00:00:00.000
760MESSAGE_ID = HBR_TEST
761COMMENT \u{1f4a5}BR = 15.5
762TCA = 2024-01-01T12:00:00.000
763OBJECT = OBJECT1
764X = 1.0 [km]
765Y = 2.0 [km]
766Z = 3.0 [km]
767X_DOT = 0.1 [km/s]
768Y_DOT = 0.2 [km/s]
769Z_DOT = 0.3 [km/s]
770CR_R = 1.0 [m**2]
771CT_R = 0.0 [m**2]
772CT_T = 1.0 [m**2]
773CN_R = 0.0 [m**2]
774CN_T = 0.0 [m**2]
775CN_N = 1.0 [m**2]
776OBJECT = OBJECT2
777X = 4.0 [km]
778Y = 5.0 [km]
779Z = 6.0 [km]
780X_DOT = 0.4 [km/s]
781Y_DOT = 0.5 [km/s]
782Z_DOT = 0.6 [km/s]
783CR_R = 1.0 [m**2]
784CT_R = 0.0 [m**2]
785CT_T = 1.0 [m**2]
786CN_R = 0.0 [m**2]
787CN_T = 0.0 [m**2]
788CN_N = 1.0 [m**2]
789";
790 let parsed = parse_kvn(kvn).expect("malformed HBR comment must not panic");
791 assert_eq!(parsed.hard_body_radius_m, None);
792 }
793
794 #[test]
795 fn node_text_reads_leaf_value_ignoring_attrs() {
796 let doc = Document::parse(
797 r#"<r><MESSAGE_ID>abc123</MESSAGE_ID><X units="km">2570.097065</X><ORIGINATOR></ORIGINATOR></r>"#,
798 )
799 .unwrap();
800 let root = doc.root();
801 assert_eq!(node_text(root, "MESSAGE_ID").as_deref(), Some("abc123"));
802 assert_eq!(node_text(root, "X").as_deref(), Some("2570.097065"));
804 assert_eq!(node_text(root, "ORIGINATOR"), None);
806
807 let only_xdot = Document::parse(r#"<r><X_DOT units="km/s">4.4</X_DOT></r>"#).unwrap();
809 assert_eq!(node_text(only_xdot.root(), "X"), None);
810 }
811
812 #[test]
813 fn xml_parse_decodes_entities_and_ignores_extra_covariance_element() {
814 let xml = r#"<cdm><body>
815<segment><metadata><OBJECT_NAME>SAT A & B</OBJECT_NAME></metadata>
816<data><stateVector>
817<X units="km">1.0</X><Y units="km">2.0</Y><Z units="km">3.0</Z>
818<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>
819</stateVector><covarianceMatrix>
820<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>
821<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>
822<CRDOT_R units="m**2/s">2.52e-3</CRDOT_R>
823</covarianceMatrix></data></segment>
824<segment><data><stateVector>
825<X units="km">4.0</X><Y units="km">5.0</Y><Z units="km">6.0</Z>
826<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>
827</stateVector><covarianceMatrix>
828<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>
829<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>
830</covarianceMatrix></data></segment>
831</body></cdm>"#;
832 let cdm = parse_xml(xml).unwrap();
833 assert_eq!(cdm.object1.object_name.as_deref(), Some("SAT A & B"));
835 assert_eq!(
838 cdm.object1.covariance_rtn,
839 [41.42, -8.579, 2533.0, -23.13, 13.36, 70.98]
840 );
841 }
842
843 #[test]
844 fn xml_incomplete_state_vector_is_rejected() {
845 let xml = "<cdm><body>\
846<segment><data><stateVector><X units=\"km\">1.0</X></stateVector></data></segment>\
847<segment><data><stateVector></stateVector></data></segment>\
848</body></cdm>";
849 assert_eq!(parse_xml(xml), Err(CdmError::IncompleteStateVector));
850 }
851
852 #[test]
853 fn xml_malformed_document_is_rejected() {
854 assert!(matches!(
857 parse_xml("<segment></segment><segment></segment>"),
858 Err(CdmError::MalformedXml(_))
859 ));
860 }
861
862 #[test]
863 fn xml_round_trips_through_encode_and_parse() {
864 let object = CdmObject {
865 object_designator: Some("12345".to_string()),
866 catalog_name: None,
867 object_name: Some("SAT A & B".to_string()),
868 international_designator: None,
869 object_type: None,
870 ref_frame: Some("EME2000".to_string()),
871 state: ((1.5, 2.5, 3.5), (0.1, 0.2, 0.3)),
872 covariance_rtn: [41.42, -8.579, 2533.0, -23.13, 13.36, 70.98],
873 };
874 let original = CdmKvn {
875 creation_date: Some("2024-01-01T00:00:00.000".to_string()),
876 originator: Some("TEST".to_string()),
877 message_id: Some("ID-1".to_string()),
878 tca: Some("2024-01-01T12:00:00.000".to_string()),
879 miss_distance_m: Some(715.0),
880 relative_speed_m_s: Some(14762.0),
881 collision_probability: Some(4.835e-5),
882 collision_probability_method: Some("FOSTER-1992".to_string()),
883 hard_body_radius_m: None,
884 object1: object.clone(),
885 object2: object,
886 };
887
888 let encoded = encode_xml(&original).expect("valid CDM XML encode");
889 assert!(encoded.starts_with("<?xml"));
890 assert!(encoded.contains("SAT A & B"));
892
893 let reparsed = parse_xml(&encoded).unwrap();
894 assert_eq!(reparsed.object1.state, original.object1.state);
895 assert_eq!(
896 reparsed.object2.covariance_rtn,
897 original.object2.covariance_rtn
898 );
899 assert_eq!(reparsed.miss_distance_m, original.miss_distance_m);
900 assert_eq!(
901 reparsed.collision_probability,
902 original.collision_probability
903 );
904 assert_eq!(reparsed.message_id, original.message_id);
905 assert_eq!(reparsed.tca, original.tca);
906 }
907
908 #[test]
909 fn optional_non_finite_kvn_fields_are_rejected() {
910 let kvn = "OBJECT = OBJECT1\n\
911X = 1.0 [km]\nY = 2.0 [km]\nZ = 3.0 [km]\n\
912X_DOT = 0.1 [km/s]\nY_DOT = 0.2 [km/s]\nZ_DOT = 0.3 [km/s]\n\
913CR_R = 1.0 [m**2]\nCT_R = 0.0 [m**2]\nCT_T = 1.0 [m**2]\n\
914CN_R = 0.0 [m**2]\nCN_T = 0.0 [m**2]\nCN_N = 1.0 [m**2]\n\
915OBJECT = OBJECT2\n\
916X = 4.0 [km]\nY = 5.0 [km]\nZ = 6.0 [km]\n\
917X_DOT = 0.4 [km/s]\nY_DOT = 0.5 [km/s]\nZ_DOT = 0.6 [km/s]\n\
918CR_R = 1.0 [m**2]\nCT_R = 0.0 [m**2]\nCT_T = 1.0 [m**2]\n\
919CN_R = 0.0 [m**2]\nCN_T = 0.0 [m**2]\nCN_N = 1.0 [m**2]\n\
920MISS_DISTANCE = NaN [m]\n";
921
922 assert_eq!(
923 parse_kvn(kvn),
924 Err(CdmError::InvalidField {
925 field: "MISS_DISTANCE",
926 kind: CdmInputErrorKind::NonFinite,
927 })
928 );
929 }
930
931 #[test]
932 fn optional_non_finite_xml_fields_are_rejected() {
933 let xml = r#"<cdm><body>
934<relativeMetadataData><COLLISION_PROBABILITY>inf</COLLISION_PROBABILITY></relativeMetadataData>
935<segment><data><stateVector>
936<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>
937</stateVector><covarianceMatrix>
938<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>
939</covarianceMatrix></data></segment>
940<segment><data><stateVector>
941<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>
942</stateVector><covarianceMatrix>
943<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>
944</covarianceMatrix></data></segment>
945</body></cdm>"#;
946
947 assert_eq!(
948 parse_xml(xml),
949 Err(CdmError::InvalidField {
950 field: "COLLISION_PROBABILITY",
951 kind: CdmInputErrorKind::NonFinite,
952 })
953 );
954 }
955
956 #[test]
957 fn encode_rejects_non_finite_public_numeric_fields() {
958 let object = CdmObject {
959 object_designator: None,
960 catalog_name: None,
961 object_name: None,
962 international_designator: None,
963 object_type: None,
964 ref_frame: None,
965 state: ((1.0, 2.0, 3.0), (0.1, 0.2, 0.3)),
966 covariance_rtn: [1.0, 0.0, 1.0, 0.0, 0.0, 1.0],
967 };
968 let mut cdm = CdmKvn {
969 creation_date: None,
970 originator: None,
971 message_id: None,
972 tca: None,
973 miss_distance_m: Some(f64::NAN),
974 relative_speed_m_s: None,
975 collision_probability: None,
976 collision_probability_method: None,
977 hard_body_radius_m: None,
978 object1: object.clone(),
979 object2: object,
980 };
981
982 assert_eq!(
983 encode_kvn(&cdm),
984 Err(CdmError::InvalidField {
985 field: "MISS_DISTANCE",
986 kind: CdmInputErrorKind::NonFinite,
987 })
988 );
989
990 cdm.miss_distance_m = Some(1.0);
991 cdm.object1.state.0 = (f64::INFINITY, 2.0, 3.0);
992 assert_eq!(
993 encode_xml(&cdm),
994 Err(CdmError::InvalidField {
995 field: "state",
996 kind: CdmInputErrorKind::NonFinite,
997 })
998 );
999 }
1000}