1use crate::astro::covariance::Covariance6;
8use crate::astro::ndm::{read_covariance6, write_covariance6, FieldMap, NdmHeader};
9use crate::astro::xml;
10use crate::format::fmtnum::fmt_num;
11use crate::format::tokens::Tokenizer;
12use crate::format::{Diagnostics, RecordRef, Skip, SkipReason};
13use crate::validate;
14use roxmltree::{Document, Node};
15use std::fmt;
16
17const COMMENT_PREFIX: &str = "COMMENT";
18const META_START: &str = "META_START";
19const META_STOP: &str = "META_STOP";
20const COVARIANCE_START: &str = "COVARIANCE_START";
21const COVARIANCE_STOP: &str = "COVARIANCE_STOP";
22const OEM_VERSION_KEY: &str = "CCSDS_OEM_VERS";
23
24const METADATA_KEYS: [&str; 11] = [
25 "OBJECT_NAME",
26 "OBJECT_ID",
27 "CENTER_NAME",
28 "REF_FRAME",
29 "TIME_SYSTEM",
30 "START_TIME",
31 "STOP_TIME",
32 "USEABLE_START_TIME",
33 "USEABLE_STOP_TIME",
34 "INTERPOLATION",
35 "INTERPOLATION_DEGREE",
36];
37
38const STATE_NUMBER_KEYS: [&str; 9] = [
39 "X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT", "X_DDOT", "Y_DDOT", "Z_DDOT",
40];
41
42#[derive(Debug, Clone, PartialEq)]
44pub struct Oem {
45 pub ccsds_oem_vers: String,
46 pub creation_date: Option<String>,
47 pub originator: Option<String>,
48 pub segments: Vec<OemSegment>,
49 pub skipped_states: usize,
51}
52
53#[derive(Debug, Clone, PartialEq)]
55pub struct OemSegment {
56 pub metadata: OemMetadata,
57 pub states: Vec<OemState>,
58 pub covariances: Vec<OemCovariance>,
59}
60
61#[derive(Debug, Clone, PartialEq)]
63pub struct OemMetadata {
64 pub object_name: String,
65 pub object_id: String,
66 pub center_name: String,
67 pub ref_frame: String,
68 pub time_system: String,
69 pub start_time: String,
70 pub stop_time: String,
71 pub useable_start_time: Option<String>,
72 pub useable_stop_time: Option<String>,
73 pub interpolation: Option<String>,
74 pub interpolation_degree: Option<u32>,
75}
76
77#[derive(Debug, Clone, PartialEq)]
79pub struct OemState {
80 pub epoch: String,
81 pub position_km: [f64; 3],
82 pub velocity_km_s: [f64; 3],
83 pub acceleration_km_s2: Option<[f64; 3]>,
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub struct OemCovariance {
89 pub epoch: String,
90 pub cov_ref_frame: Option<String>,
91 pub matrix: Covariance6,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum OemError {
97 MissingField(&'static str),
99 InvalidField {
101 field: &'static str,
102 kind: OemInputErrorKind,
103 },
104 Field(String),
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum OemInputErrorKind {
111 Missing,
113 NonFinite,
115 FloatParse,
117 IntParse,
119 NotPositive,
121 Negative,
123 OutOfRange,
125 InvalidCivilDate,
127 InvalidCivilTime,
129}
130
131impl fmt::Display for OemInputErrorKind {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 let label = match self {
134 Self::Missing => "missing",
135 Self::NonFinite => "not finite",
136 Self::FloatParse => "invalid float",
137 Self::IntParse => "invalid integer",
138 Self::NotPositive => "not positive",
139 Self::Negative => "negative",
140 Self::OutOfRange => "out of range",
141 Self::InvalidCivilDate => "invalid civil date",
142 Self::InvalidCivilTime => "invalid civil time",
143 };
144 f.write_str(label)
145 }
146}
147
148impl From<&validate::FieldError> for OemInputErrorKind {
149 fn from(error: &validate::FieldError) -> Self {
150 match error {
151 validate::FieldError::Missing { .. } => Self::Missing,
152 validate::FieldError::NonFinite { .. } => Self::NonFinite,
153 validate::FieldError::FloatParse { .. } => Self::FloatParse,
154 validate::FieldError::IntParse { .. } => Self::IntParse,
155 validate::FieldError::NotPositive { .. } => Self::NotPositive,
156 validate::FieldError::Negative { .. } => Self::Negative,
157 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
158 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
159 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
160 }
161 }
162}
163
164impl fmt::Display for OemError {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 match self {
167 OemError::MissingField(name) => write!(f, "OEM missing required field {name}"),
168 OemError::InvalidField { field, kind } => {
169 write!(f, "invalid OEM field {field}: {kind}")
170 }
171 OemError::Field(msg) => write!(f, "OEM field error: {msg}"),
172 }
173 }
174}
175
176impl std::error::Error for OemError {}
177
178pub fn parse_kvn(text: &str) -> Result<Oem, OemError> {
180 let lines = numbered_lines(text);
181 let header_map = FieldMap::from_pairs(parse_kv_lines(
182 &lines
183 .iter()
184 .map(|(_, line)| line.clone())
185 .collect::<Vec<_>>(),
186 ));
187 let header = NdmHeader::read(&header_map, OEM_VERSION_KEY);
188 if header.vers.is_empty() {
189 return Err(OemError::MissingField(OEM_VERSION_KEY));
190 }
191
192 let mut diagnostics = Diagnostics::new();
193 let mut segments = Vec::new();
194 let mut idx = 0usize;
195
196 while idx < lines.len() {
197 let line = lines[idx].1.trim();
198 if line == META_START {
199 let (segment, next_idx) = parse_kvn_segment(&lines, idx, &mut diagnostics)?;
200 segments.push(segment);
201 idx = next_idx;
202 } else {
203 idx += 1;
204 }
205 }
206
207 if segments.is_empty() {
208 return Err(OemError::Field("OEM contains no segment".to_string()));
209 }
210
211 let skipped_states = diagnostics.skips.len();
212 Ok(Oem {
213 ccsds_oem_vers: header.vers,
214 creation_date: header.creation_date,
215 originator: header.originator,
216 segments,
217 skipped_states,
218 })
219}
220
221pub fn encode_kvn(oem: &Oem) -> String {
223 let mut lines = NdmHeader {
224 vers: oem.ccsds_oem_vers.clone(),
225 creation_date: oem.creation_date.clone(),
226 originator: oem.originator.clone(),
227 }
228 .write_kvn(OEM_VERSION_KEY);
229
230 for segment in &oem.segments {
231 lines.push(META_START.to_string());
232 lines.extend(encode_metadata_kvn(&segment.metadata));
233 lines.push(META_STOP.to_string());
234
235 for state in &segment.states {
236 lines.push(encode_state_kvn(state));
237 }
238
239 for covariance in &segment.covariances {
240 lines.push(COVARIANCE_START.to_string());
241 lines.push(format!("EPOCH = {}", covariance.epoch));
242 if let Some(cov_ref_frame) = &covariance.cov_ref_frame {
243 lines.push(format!("COV_REF_FRAME = {cov_ref_frame}"));
244 }
245 lines.extend(write_covariance6(&covariance.matrix));
246 lines.push(COVARIANCE_STOP.to_string());
247 }
248 }
249
250 lines.join("\n")
251}
252
253pub fn parse_xml(text: &str) -> Result<Oem, OemError> {
255 let doc = Document::parse(text).map_err(|e| OemError::Field(format!("malformed XML: {e}")))?;
256 let oem_node = doc
257 .descendants()
258 .find(|n| n.is_element() && n.tag_name().name() == "oem")
259 .ok_or_else(|| OemError::Field("missing oem element".to_string()))?;
260
261 let version = oem_node
262 .attribute("version")
263 .map(str::trim)
264 .filter(|value| !value.is_empty())
265 .map(str::to_string)
266 .or_else(|| node_text(oem_node, OEM_VERSION_KEY))
267 .ok_or(OemError::MissingField(OEM_VERSION_KEY))?;
268
269 let segments: Vec<OemSegment> = oem_node
270 .descendants()
271 .filter(|n| n.is_element() && n.tag_name().name() == "segment")
272 .map(parse_xml_segment)
273 .collect::<Result<_, _>>()?;
274
275 if segments.is_empty() {
276 return Err(OemError::Field("OEM contains no segment".to_string()));
277 }
278
279 Ok(Oem {
280 ccsds_oem_vers: version,
281 creation_date: node_text(oem_node, "CREATION_DATE"),
282 originator: node_text(oem_node, "ORIGINATOR"),
283 segments,
284 skipped_states: 0,
285 })
286}
287
288pub fn encode_xml(oem: &Oem) -> String {
290 let mut lines = vec![
291 r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string(),
292 format!(
293 r#"<oem id="CCSDS_OEM_VERS" version="{}">"#,
294 xml::escape(&oem.ccsds_oem_vers)
295 ),
296 " <header>".to_string(),
297 format!(
298 " <CREATION_DATE>{}</CREATION_DATE>",
299 xml::escape_opt(&oem.creation_date)
300 ),
301 format!(
302 " <ORIGINATOR>{}</ORIGINATOR>",
303 xml::escape_opt(&oem.originator)
304 ),
305 " </header>".to_string(),
306 " <body>".to_string(),
307 ];
308
309 for segment in &oem.segments {
310 lines.extend(encode_xml_segment(segment));
311 }
312
313 lines.push(" </body>".to_string());
314 lines.push("</oem>".to_string());
315 lines.join("\n")
316}
317
318fn parse_kvn_segment(
319 lines: &[(usize, String)],
320 start_idx: usize,
321 diagnostics: &mut Diagnostics,
322) -> Result<(OemSegment, usize), OemError> {
323 let mut idx = start_idx + 1;
324 let mut metadata_lines = Vec::new();
325 while idx < lines.len() {
326 let line = lines[idx].1.trim();
327 if line == META_STOP {
328 break;
329 }
330 metadata_lines.push(lines[idx].1.clone());
331 idx += 1;
332 }
333 if idx >= lines.len() {
334 return Err(OemError::Field("META_START without META_STOP".to_string()));
335 }
336
337 let metadata = parse_metadata(&FieldMap::from_pairs(parse_kv_lines(&metadata_lines)))?;
338 idx += 1;
339
340 let mut states = Vec::new();
341 let mut covariances = Vec::new();
342 while idx < lines.len() {
343 let (line_no, raw) = &lines[idx];
344 let line = raw.trim();
345
346 if line == META_START {
347 break;
348 }
349 if line.is_empty() || line.starts_with(COMMENT_PREFIX) {
350 idx += 1;
351 continue;
352 }
353 if line == COVARIANCE_START {
354 let (covariance, next_idx) = parse_kvn_covariance(lines, idx)?;
355 covariances.push(covariance);
356 idx = next_idx;
357 continue;
358 }
359 if line == COVARIANCE_STOP {
360 return Err(OemError::Field(
361 "COVARIANCE_STOP without COVARIANCE_START".to_string(),
362 ));
363 }
364
365 match parse_state_line(line) {
366 Ok(state) => states.push(state),
367 Err(StateLineError::WrongTokenCount) => diagnostics.push_skip(Skip {
368 at: RecordRef::at_line(*line_no),
369 reason: SkipReason::Truncated,
370 }),
371 Err(StateLineError::Field(error)) => diagnostics.push_skip(Skip {
372 at: RecordRef::at_line(*line_no),
373 reason: SkipReason::MalformedField(error),
374 }),
375 }
376 idx += 1;
377 }
378
379 Ok((
380 OemSegment {
381 metadata,
382 states,
383 covariances,
384 },
385 idx,
386 ))
387}
388
389fn parse_kvn_covariance(
390 lines: &[(usize, String)],
391 start_idx: usize,
392) -> Result<(OemCovariance, usize), OemError> {
393 let mut idx = start_idx + 1;
394 let mut covariance_lines = Vec::new();
395 while idx < lines.len() {
396 let line = lines[idx].1.trim();
397 if line == COVARIANCE_STOP {
398 let covariance =
399 parse_covariance_map(&FieldMap::from_pairs(parse_kv_lines(&covariance_lines)))?;
400 return Ok((covariance, idx + 1));
401 }
402 if !line.is_empty() && !line.starts_with(COMMENT_PREFIX) {
403 covariance_lines.push(lines[idx].1.clone());
404 }
405 idx += 1;
406 }
407
408 Err(OemError::Field(
409 "COVARIANCE_START without COVARIANCE_STOP".to_string(),
410 ))
411}
412
413fn parse_metadata(map: &FieldMap) -> Result<OemMetadata, OemError> {
414 Ok(OemMetadata {
415 object_name: req_text(map, "OBJECT_NAME")?,
416 object_id: req_text(map, "OBJECT_ID")?,
417 center_name: req_text(map, "CENTER_NAME")?,
418 ref_frame: req_text(map, "REF_FRAME")?,
419 time_system: req_text(map, "TIME_SYSTEM")?,
420 start_time: req_text(map, "START_TIME")?,
421 stop_time: req_text(map, "STOP_TIME")?,
422 useable_start_time: opt_text(map, "USEABLE_START_TIME"),
423 useable_stop_time: opt_text(map, "USEABLE_STOP_TIME"),
424 interpolation: opt_text(map, "INTERPOLATION"),
425 interpolation_degree: opt_u32(map, "INTERPOLATION_DEGREE")?,
426 })
427}
428
429fn parse_state_line(line: &str) -> Result<OemState, StateLineError> {
430 let mut tokenizer = Tokenizer::new(line);
431 let mut tokens = Vec::new();
432 while let Some(token) = tokenizer.next_str() {
433 tokens.push(token);
434 }
435
436 if tokens.len() != 7 && tokens.len() != 10 {
437 return Err(StateLineError::WrongTokenCount);
438 }
439
440 let epoch = tokens[0].to_string();
441 let mut values = [0.0_f64; 9];
442 for (idx, key) in STATE_NUMBER_KEYS.iter().enumerate().take(tokens.len() - 1) {
443 values[idx] = validate::strict_f64(tokens[idx + 1], key).map_err(StateLineError::Field)?;
444 }
445
446 let acceleration_km_s2 = if tokens.len() == 10 {
447 Some([values[6], values[7], values[8]])
448 } else {
449 None
450 };
451
452 Ok(OemState {
453 epoch,
454 position_km: [values[0], values[1], values[2]],
455 velocity_km_s: [values[3], values[4], values[5]],
456 acceleration_km_s2,
457 })
458}
459
460fn parse_covariance_map(map: &FieldMap) -> Result<OemCovariance, OemError> {
461 Ok(OemCovariance {
462 epoch: req_text(map, "EPOCH")?,
463 cov_ref_frame: opt_text(map, "COV_REF_FRAME"),
464 matrix: read_covariance6(map).map_err(map_oem_field_error)?,
465 })
466}
467
468fn parse_xml_segment(segment: Node) -> Result<OemSegment, OemError> {
469 let metadata_node = child_element(segment, "metadata")
470 .ok_or_else(|| OemError::Field("segment missing metadata".to_string()))?;
471 let data_node = child_element(segment, "data")
472 .ok_or_else(|| OemError::Field("segment missing data".to_string()))?;
473
474 let metadata = parse_metadata(&FieldMap::from_pairs(xml_fields(
475 metadata_node,
476 &METADATA_KEYS,
477 )))?;
478 let states = data_node
479 .descendants()
480 .filter(|n| n.is_element() && n.tag_name().name() == "stateVector")
481 .map(parse_xml_state)
482 .collect::<Result<Vec<_>, _>>()?;
483 let covariances = data_node
484 .descendants()
485 .filter(|n| n.is_element() && n.tag_name().name() == "covarianceMatrix")
486 .map(parse_xml_covariance)
487 .collect::<Result<Vec<_>, _>>()?;
488
489 Ok(OemSegment {
490 metadata,
491 states,
492 covariances,
493 })
494}
495
496fn parse_xml_state(node: Node) -> Result<OemState, OemError> {
497 let epoch = node_text(node, "EPOCH").ok_or(OemError::MissingField("EPOCH"))?;
498 let x = required_node_num(node, "X")?;
499 let y = required_node_num(node, "Y")?;
500 let z = required_node_num(node, "Z")?;
501 let xd = required_node_num(node, "X_DOT")?;
502 let yd = required_node_num(node, "Y_DOT")?;
503 let zd = required_node_num(node, "Z_DOT")?;
504
505 let acceleration_km_s2 = match (
506 node_text(node, "X_DDOT"),
507 node_text(node, "Y_DDOT"),
508 node_text(node, "Z_DDOT"),
509 ) {
510 (None, None, None) => None,
511 (Some(xdd), Some(ydd), Some(zdd)) => Some([
512 parse_num(&xdd, "X_DDOT")?,
513 parse_num(&ydd, "Y_DDOT")?,
514 parse_num(&zdd, "Z_DDOT")?,
515 ]),
516 _ => {
517 return Err(OemError::Field(
518 "stateVector acceleration must contain X_DDOT, Y_DDOT, and Z_DDOT".to_string(),
519 ))
520 }
521 };
522
523 Ok(OemState {
524 epoch,
525 position_km: [x, y, z],
526 velocity_km_s: [xd, yd, zd],
527 acceleration_km_s2,
528 })
529}
530
531fn parse_xml_covariance(node: Node) -> Result<OemCovariance, OemError> {
532 let fields = node
533 .descendants()
534 .filter(Node::is_element)
535 .filter_map(|n| {
536 let text = n.text()?.trim();
537 if text.is_empty() {
538 None
539 } else {
540 Some((n.tag_name().name().to_string(), text.to_string()))
541 }
542 })
543 .collect();
544 parse_covariance_map(&FieldMap::from_pairs(fields))
545}
546
547fn encode_metadata_kvn(metadata: &OemMetadata) -> Vec<String> {
548 let mut lines = vec![
549 format!("OBJECT_NAME = {}", metadata.object_name),
550 format!("OBJECT_ID = {}", metadata.object_id),
551 format!("CENTER_NAME = {}", metadata.center_name),
552 format!("REF_FRAME = {}", metadata.ref_frame),
553 format!("TIME_SYSTEM = {}", metadata.time_system),
554 format!("START_TIME = {}", metadata.start_time),
555 format!("STOP_TIME = {}", metadata.stop_time),
556 ];
557 if let Some(value) = &metadata.useable_start_time {
558 lines.push(format!("USEABLE_START_TIME = {value}"));
559 }
560 if let Some(value) = &metadata.useable_stop_time {
561 lines.push(format!("USEABLE_STOP_TIME = {value}"));
562 }
563 if let Some(value) = &metadata.interpolation {
564 lines.push(format!("INTERPOLATION = {value}"));
565 }
566 if let Some(value) = metadata.interpolation_degree {
567 lines.push(format!("INTERPOLATION_DEGREE = {value}"));
568 }
569 lines
570}
571
572fn encode_state_kvn(state: &OemState) -> String {
573 let mut fields = vec![
574 state.epoch.clone(),
575 fmt_num(state.position_km[0]),
576 fmt_num(state.position_km[1]),
577 fmt_num(state.position_km[2]),
578 fmt_num(state.velocity_km_s[0]),
579 fmt_num(state.velocity_km_s[1]),
580 fmt_num(state.velocity_km_s[2]),
581 ];
582 if let Some(accel) = state.acceleration_km_s2 {
583 fields.extend([fmt_num(accel[0]), fmt_num(accel[1]), fmt_num(accel[2])]);
584 }
585 fields.join(" ")
586}
587
588fn encode_xml_segment(segment: &OemSegment) -> Vec<String> {
589 let mut lines = vec![
590 " <segment>".to_string(),
591 " <metadata>".to_string(),
592 elem_line(8, "OBJECT_NAME", &segment.metadata.object_name),
593 elem_line(8, "OBJECT_ID", &segment.metadata.object_id),
594 elem_line(8, "CENTER_NAME", &segment.metadata.center_name),
595 elem_line(8, "REF_FRAME", &segment.metadata.ref_frame),
596 elem_line(8, "TIME_SYSTEM", &segment.metadata.time_system),
597 elem_line(8, "START_TIME", &segment.metadata.start_time),
598 elem_line(8, "STOP_TIME", &segment.metadata.stop_time),
599 ];
600 if let Some(value) = &segment.metadata.useable_start_time {
601 lines.push(elem_line(8, "USEABLE_START_TIME", value));
602 }
603 if let Some(value) = &segment.metadata.useable_stop_time {
604 lines.push(elem_line(8, "USEABLE_STOP_TIME", value));
605 }
606 if let Some(value) = &segment.metadata.interpolation {
607 lines.push(elem_line(8, "INTERPOLATION", value));
608 }
609 if let Some(value) = segment.metadata.interpolation_degree {
610 lines.push(elem_line_raw(8, "INTERPOLATION_DEGREE", &value.to_string()));
611 }
612 lines.push(" </metadata>".to_string());
613 lines.push(" <data>".to_string());
614
615 for state in &segment.states {
616 lines.extend(encode_xml_state(state));
617 }
618 for covariance in &segment.covariances {
619 lines.extend(encode_xml_covariance(covariance));
620 }
621
622 lines.push(" </data>".to_string());
623 lines.push(" </segment>".to_string());
624 lines
625}
626
627fn encode_xml_state(state: &OemState) -> Vec<String> {
628 let mut lines = vec![
629 " <stateVector>".to_string(),
630 elem_line(10, "EPOCH", &state.epoch),
631 elem_line_raw(10, "X", &fmt_num(state.position_km[0])),
632 elem_line_raw(10, "Y", &fmt_num(state.position_km[1])),
633 elem_line_raw(10, "Z", &fmt_num(state.position_km[2])),
634 elem_line_raw(10, "X_DOT", &fmt_num(state.velocity_km_s[0])),
635 elem_line_raw(10, "Y_DOT", &fmt_num(state.velocity_km_s[1])),
636 elem_line_raw(10, "Z_DOT", &fmt_num(state.velocity_km_s[2])),
637 ];
638 if let Some(accel) = state.acceleration_km_s2 {
639 lines.push(elem_line_raw(10, "X_DDOT", &fmt_num(accel[0])));
640 lines.push(elem_line_raw(10, "Y_DDOT", &fmt_num(accel[1])));
641 lines.push(elem_line_raw(10, "Z_DDOT", &fmt_num(accel[2])));
642 }
643 lines.push(" </stateVector>".to_string());
644 lines
645}
646
647fn encode_xml_covariance(covariance: &OemCovariance) -> Vec<String> {
648 let mut lines = vec![
649 " <covarianceMatrix>".to_string(),
650 elem_line(10, "EPOCH", &covariance.epoch),
651 ];
652 if let Some(value) = &covariance.cov_ref_frame {
653 lines.push(elem_line(10, "COV_REF_FRAME", value));
654 }
655 for line in write_covariance6(&covariance.matrix) {
656 if let Some((key, value)) = line.split_once('=') {
657 lines.push(elem_line_raw(10, key.trim(), value.trim()));
658 }
659 }
660 lines.push(" </covarianceMatrix>".to_string());
661 lines
662}
663
664fn numbered_lines(text: &str) -> Vec<(usize, String)> {
665 text.lines()
666 .enumerate()
667 .map(|(idx, line)| (idx + 1, line.trim().to_string()))
668 .collect()
669}
670
671fn parse_kv_lines(lines: &[String]) -> Vec<(String, String)> {
672 lines
673 .iter()
674 .filter_map(|line| {
675 line.split_once('=').map(|(key, value)| {
676 (
677 key.trim().to_string(),
678 strip_units(value.trim()).to_string(),
679 )
680 })
681 })
682 .collect()
683}
684
685fn strip_units(value: &str) -> &str {
686 let trimmed = value.trim_end();
687 if let Some(open) = trimmed.rfind('[') {
688 if trimmed.ends_with(']') {
689 return trimmed[..open].trim_end();
690 }
691 }
692 trimmed
693}
694
695fn req_text(map: &FieldMap, field: &'static str) -> Result<String, OemError> {
696 map.get(field)
697 .map(str::to_string)
698 .ok_or(OemError::MissingField(field))
699}
700
701fn opt_text(map: &FieldMap, field: &'static str) -> Option<String> {
702 map.get(field).map(str::to_string)
703}
704
705fn opt_u32(map: &FieldMap, field: &'static str) -> Result<Option<u32>, OemError> {
706 map.get(field)
707 .map(|value| validate::strict_int::<u32>(value, field).map_err(map_oem_field_error))
708 .transpose()
709}
710
711fn required_node_num(node: Node, tag: &'static str) -> Result<f64, OemError> {
712 let value = node_text(node, tag).ok_or(OemError::MissingField(tag))?;
713 parse_num(&value, tag)
714}
715
716fn parse_num(value: &str, field: &'static str) -> Result<f64, OemError> {
717 validate::strict_f64(value, field).map_err(map_oem_field_error)
718}
719
720fn map_oem_field_error(error: validate::FieldError) -> OemError {
721 OemError::InvalidField {
722 field: error.field(),
723 kind: OemInputErrorKind::from(&error),
724 }
725}
726
727fn node_text(node: Node, tag: &str) -> Option<String> {
728 let element = node
729 .descendants()
730 .find(|n| n.is_element() && n.tag_name().name() == tag)?;
731 let text = element.text()?.trim();
732 if text.is_empty() {
733 None
734 } else {
735 Some(text.to_string())
736 }
737}
738
739fn child_element<'a>(node: Node<'a, 'a>, tag: &str) -> Option<Node<'a, 'a>> {
740 node.children()
741 .find(|n| n.is_element() && n.tag_name().name() == tag)
742}
743
744fn xml_fields(node: Node, keys: &[&str]) -> Vec<(String, String)> {
745 keys.iter()
746 .filter_map(|key| node_text(node, key).map(|value| ((*key).to_string(), value)))
747 .collect()
748}
749
750fn elem_line(indent: usize, name: &str, value: &str) -> String {
751 elem_line_raw(indent, name, &xml::escape(value))
752}
753
754fn elem_line_raw(indent: usize, name: &str, value: &str) -> String {
755 format!("{:indent$}<{name}>{value}</{name}>", "")
756}
757
758enum StateLineError {
759 WrongTokenCount,
760 Field(validate::FieldError),
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 fn diagonal_covariance() -> Covariance6 {
768 Covariance6::from_diagonal([1.0, 2.0, 3.0, 4.0e-6, 5.0e-6, 6.0e-6]).unwrap()
769 }
770
771 #[test]
772 fn forgiving_kvn_skips_malformed_state_lines() {
773 let kvn = "\
774CCSDS_OEM_VERS = 2.0
775CREATION_DATE = 2026-06-28T00:00:00
776ORIGINATOR = SIDEREON
777META_START
778OBJECT_NAME = TEST
779OBJECT_ID = 2026-001A
780CENTER_NAME = EARTH
781REF_FRAME = EME2000
782TIME_SYSTEM = UTC
783START_TIME = 2026-06-28T00:00:00
784STOP_TIME = 2026-06-28T00:10:00
785META_STOP
7862026-06-28T00:00:00 1 2 3 0.1 0.2 0.3
7872026-06-28T00:05:00 1 2
7882026-06-28T00:10:00 1 2 3 0.1 NaN 0.3
789";
790 let oem = parse_kvn(kvn).expect("forgiving OEM parse");
791 assert_eq!(oem.segments[0].states.len(), 1);
792 assert_eq!(oem.skipped_states, 2);
793 }
794
795 #[test]
796 fn malformed_xml_is_an_error() {
797 assert!(parse_xml("<oem></oem><oem></oem>").is_err());
798 }
799
800 #[test]
801 fn covariance_round_trips_through_kvn_and_xml() {
802 let original = Oem {
803 ccsds_oem_vers: "2.0".to_string(),
804 creation_date: Some("2026-06-28T00:00:00".to_string()),
805 originator: Some("SIDEREON".to_string()),
806 skipped_states: 0,
807 segments: vec![OemSegment {
808 metadata: OemMetadata {
809 object_name: "TEST".to_string(),
810 object_id: "2026-001A".to_string(),
811 center_name: "EARTH".to_string(),
812 ref_frame: "EME2000".to_string(),
813 time_system: "UTC".to_string(),
814 start_time: "2026-06-28T00:00:00".to_string(),
815 stop_time: "2026-06-28T00:10:00".to_string(),
816 useable_start_time: None,
817 useable_stop_time: None,
818 interpolation: Some("LAGRANGE".to_string()),
819 interpolation_degree: Some(5),
820 },
821 states: vec![OemState {
822 epoch: "2026-06-28T00:00:00".to_string(),
823 position_km: [1.0, 2.0, 3.0],
824 velocity_km_s: [0.1, 0.2, 0.3],
825 acceleration_km_s2: None,
826 }],
827 covariances: vec![OemCovariance {
828 epoch: "2026-06-28T00:00:00".to_string(),
829 cov_ref_frame: Some("RTN".to_string()),
830 matrix: diagonal_covariance(),
831 }],
832 }],
833 };
834
835 assert_eq!(parse_kvn(&encode_kvn(&original)).unwrap(), original);
836 assert_eq!(parse_xml(&encode_xml(&original)).unwrap(), original);
837 }
838
839 #[cfg(all(test, sidereon_repo_tests))]
840 mod fixtures {
841 use super::*;
842
843 const GPS_KVN: &str = include_str!("../../tests/fixtures/oem/gps.kvn");
844 const GPS_XML: &str = include_str!("../../tests/fixtures/oem/gps.xml");
845
846 #[test]
847 fn parses_gps_kvn_fixture() {
848 let oem = parse_kvn(GPS_KVN).unwrap();
849 assert_eq!(oem.ccsds_oem_vers, "2.0");
850 assert_eq!(oem.originator.as_deref(), Some("SIDEREON TEST"));
851 assert_eq!(oem.segments.len(), 1);
852 assert_eq!(oem.segments[0].metadata.object_name, "GPS BIIRM-8");
853 assert_eq!(oem.segments[0].states.len(), 3);
854 assert_eq!(oem.segments[0].covariances.len(), 1);
855 assert_eq!(oem.skipped_states, 0);
856 }
857
858 #[test]
859 fn parses_gps_xml_fixture() {
860 let oem = parse_xml(GPS_XML).unwrap();
861 assert_eq!(oem.ccsds_oem_vers, "2.0");
862 assert_eq!(oem.segments[0].metadata.object_id, "2005-038A");
863 assert_eq!(oem.segments[0].states[1].epoch, "2026-06-28T00:15:00.000");
864 assert_eq!(
865 oem.segments[0].covariances[0].cov_ref_frame.as_deref(),
866 Some("RTN")
867 );
868 }
869
870 #[test]
871 fn fixture_kvn_round_trips() {
872 let oem = parse_kvn(GPS_KVN).unwrap();
873 assert_eq!(parse_kvn(&encode_kvn(&oem)).unwrap(), oem);
874 }
875
876 #[test]
877 fn fixture_xml_round_trips() {
878 let oem = parse_xml(GPS_XML).unwrap();
879 assert_eq!(parse_xml(&encode_xml(&oem)).unwrap(), oem);
880 }
881 }
882}