1use crate::astro::xml;
30use crate::validate;
31use roxmltree::{Document, Node};
32use std::fmt;
33
34const STATE_KEYS: [&str; 6] = ["X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT"];
37const COVARIANCE_KEYS: [&str; 6] = ["CR_R", "CT_R", "CT_T", "CN_R", "CN_T", "CN_N"];
39const 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];
62const OBJECT_MARKER: &str = "OBJECT";
64const COMMENT_PREFIX: &str = "COMMENT";
67const SEGMENT_TAG: &str = "segment";
69
70#[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#[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 pub state: ((f64, f64, f64), (f64, f64, f64)),
116 pub covariance_rtn: [f64; 6],
118 pub velocity_covariance_rtn: Option<[f64; 15]>,
122}
123
124fn 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
155fn 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#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum CdmError {
199 IncompleteStateVector,
201 InvalidField {
203 field: &'static str,
205 kind: CdmInputErrorKind,
207 },
208 MalformedXml(String),
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum CdmInputErrorKind {
215 Missing,
217 NonFinite,
219 FloatParse,
221 IntParse,
223 NotPositive,
225 Negative,
227 OutOfRange,
229 InvalidCivilDate,
231 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
282pub 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
314pub 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
354pub 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
395pub 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
458fn 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
469fn 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
522fn 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
534fn 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
583fn 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
607fn 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
636fn 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
670fn 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
720fn 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
736fn 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
750fn 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
769fn 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
801fn 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 assert_eq!(node_text(root, "X").as_deref(), Some("2570.097065"));
950 assert_eq!(node_text(root, "ORIGINATOR"), None);
952
953 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 & 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 assert_eq!(cdm.object1.object_name.as_deref(), Some("SAT A & B"));
981 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 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 assert!(encoded.contains("SAT A & 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 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 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 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 assert!(!encoded.contains("COMMENT"));
1322
1323 let reparsed = parse_kvn(&encoded).expect("re-parse encoded CDM KVN");
1324 assert_eq!(reparsed, parsed);
1326 }
1327
1328 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 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 assert_eq!(from_kvn, from_xml);
1481 }
1482}