1use crate::astro::covariance::Covariance6;
7use crate::astro::ndm::{
8 read_covariance6, write_covariance6, FieldMap, NdmHeader, COVARIANCE6_KEYS,
9};
10use crate::astro::xml;
11use crate::format::fmtnum::fmt_num;
12use crate::validate;
13use roxmltree::{Document, Node};
14use std::fmt;
15
16const COMMENT_PREFIX: &str = "COMMENT";
17const OPM_VERSION_KEY: &str = "CCSDS_OPM_VERS";
18
19const METADATA_KEYS: [&str; 5] = [
20 "OBJECT_NAME",
21 "OBJECT_ID",
22 "CENTER_NAME",
23 "REF_FRAME",
24 "TIME_SYSTEM",
25];
26const STATE_KEYS: [&str; 7] = ["EPOCH", "X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT"];
27const KEPLERIAN_KEYS: [&str; 8] = [
28 "SEMI_MAJOR_AXIS",
29 "ECCENTRICITY",
30 "INCLINATION",
31 "RA_OF_ASC_NODE",
32 "ARG_OF_PERICENTER",
33 "TRUE_ANOMALY",
34 "MEAN_ANOMALY",
35 "GM",
36];
37const SPACECRAFT_KEYS: [&str; 5] = [
38 "MASS",
39 "SOLAR_RAD_AREA",
40 "SOLAR_RAD_COEFF",
41 "DRAG_AREA",
42 "DRAG_COEFF",
43];
44const MANEUVER_KEYS: [&str; 7] = [
45 "MAN_EPOCH_IGNITION",
46 "MAN_DURATION",
47 "MAN_DELTA_MASS",
48 "MAN_REF_FRAME",
49 "MAN_DV_1",
50 "MAN_DV_2",
51 "MAN_DV_3",
52];
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct Opm {
57 pub ccsds_opm_vers: String,
58 pub creation_date: Option<String>,
59 pub originator: Option<String>,
60 pub metadata: OpmMetadata,
61 pub state: OpmState,
62 pub keplerian: Option<OpmKeplerian>,
63 pub spacecraft: Option<OpmSpacecraft>,
64 pub covariance: Option<OpmCovariance>,
65 pub maneuvers: Vec<OpmManeuver>,
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub struct OpmMetadata {
71 pub object_name: String,
72 pub object_id: String,
73 pub center_name: String,
74 pub ref_frame: String,
75 pub time_system: String,
76}
77
78#[derive(Debug, Clone, PartialEq)]
80pub struct OpmState {
81 pub epoch: String,
82 pub position_km: [f64; 3],
83 pub velocity_km_s: [f64; 3],
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub struct OpmKeplerian {
89 pub semi_major_axis_km: f64,
90 pub eccentricity: f64,
91 pub inclination_deg: f64,
92 pub ra_of_asc_node_deg: f64,
93 pub arg_of_pericenter_deg: f64,
94 pub anomaly: OpmAnomaly,
95 pub gm_km3_s2: f64,
96}
97
98#[derive(Debug, Clone, PartialEq)]
100pub enum OpmAnomaly {
101 True(f64),
102 Mean(f64),
103}
104
105#[derive(Debug, Clone, PartialEq)]
107pub struct OpmSpacecraft {
108 pub mass_kg: Option<f64>,
109 pub solar_rad_area_m2: Option<f64>,
110 pub solar_rad_coeff: Option<f64>,
111 pub drag_area_m2: Option<f64>,
112 pub drag_coeff: Option<f64>,
113}
114
115#[derive(Debug, Clone, PartialEq)]
117pub struct OpmCovariance {
118 pub cov_ref_frame: Option<String>,
119 pub matrix: Covariance6,
120}
121
122#[derive(Debug, Clone, PartialEq)]
125pub struct OpmManeuver {
126 pub epoch_ignition: String,
127 pub duration_s: f64,
128 pub delta_mass_kg: f64,
129 pub ref_frame: String,
130 pub dv_km_s: [f64; 3],
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum OpmError {
136 MissingField(&'static str),
138 InvalidField {
140 field: &'static str,
141 kind: OpmInputErrorKind,
142 },
143 Field(String),
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum OpmInputErrorKind {
150 Missing,
152 NonFinite,
154 FloatParse,
156 IntParse,
158 NotPositive,
160 Negative,
162 OutOfRange,
164 InvalidCivilDate,
166 InvalidCivilTime,
168}
169
170impl fmt::Display for OpmInputErrorKind {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 let label = match self {
173 Self::Missing => "missing",
174 Self::NonFinite => "not finite",
175 Self::FloatParse => "invalid float",
176 Self::IntParse => "invalid integer",
177 Self::NotPositive => "not positive",
178 Self::Negative => "negative",
179 Self::OutOfRange => "out of range",
180 Self::InvalidCivilDate => "invalid civil date",
181 Self::InvalidCivilTime => "invalid civil time",
182 };
183 f.write_str(label)
184 }
185}
186
187impl From<&validate::FieldError> for OpmInputErrorKind {
188 fn from(error: &validate::FieldError) -> Self {
189 match error {
190 validate::FieldError::Missing { .. } => Self::Missing,
191 validate::FieldError::NonFinite { .. } => Self::NonFinite,
192 validate::FieldError::FloatParse { .. } => Self::FloatParse,
193 validate::FieldError::IntParse { .. } => Self::IntParse,
194 validate::FieldError::NotPositive { .. } => Self::NotPositive,
195 validate::FieldError::Negative { .. } => Self::Negative,
196 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
197 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
198 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
199 }
200 }
201}
202
203impl fmt::Display for OpmError {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 match self {
206 OpmError::MissingField(name) => write!(f, "OPM missing required field {name}"),
207 OpmError::InvalidField { field, kind } => {
208 write!(f, "invalid OPM field {field}: {kind}")
209 }
210 OpmError::Field(msg) => write!(f, "OPM field error: {msg}"),
211 }
212 }
213}
214
215impl std::error::Error for OpmError {}
216
217pub fn parse_kvn(text: &str) -> Result<Opm, OpmError> {
219 let lines = significant_lines(text);
220 let (base_lines, maneuver_blocks) = split_maneuver_blocks(&lines);
221 let map = FieldMap::from_pairs(parse_kv_lines(&base_lines));
222 let header = NdmHeader::read(&map, OPM_VERSION_KEY);
223 if header.vers.is_empty() {
224 return Err(OpmError::MissingField(OPM_VERSION_KEY));
225 }
226
227 Ok(Opm {
228 ccsds_opm_vers: header.vers,
229 creation_date: header.creation_date,
230 originator: header.originator,
231 metadata: parse_metadata(&map)?,
232 state: parse_state(&map)?,
233 keplerian: keplerian_present(&map)
234 .then(|| parse_keplerian(&map))
235 .transpose()?,
236 spacecraft: spacecraft_present(&map)
237 .then(|| parse_spacecraft(&map))
238 .transpose()?,
239 covariance: covariance_present(&map)
240 .then(|| parse_covariance(&map))
241 .transpose()?,
242 maneuvers: maneuver_blocks
243 .iter()
244 .map(|block| parse_maneuver(&FieldMap::from_pairs(parse_kv_lines(block))))
245 .collect::<Result<_, _>>()?,
246 })
247}
248
249pub fn encode_kvn(opm: &Opm) -> String {
251 let mut lines = NdmHeader {
252 vers: opm.ccsds_opm_vers.clone(),
253 creation_date: opm.creation_date.clone(),
254 originator: opm.originator.clone(),
255 }
256 .write_kvn(OPM_VERSION_KEY);
257
258 lines.extend(encode_metadata_kvn(&opm.metadata));
259 lines.extend(encode_state_kvn(&opm.state));
260 if let Some(keplerian) = &opm.keplerian {
261 lines.extend(encode_keplerian_kvn(keplerian));
262 }
263 if let Some(spacecraft) = &opm.spacecraft {
264 lines.extend(encode_spacecraft_kvn(spacecraft));
265 }
266 if let Some(covariance) = &opm.covariance {
267 if let Some(cov_ref_frame) = &covariance.cov_ref_frame {
268 lines.push(format!("COV_REF_FRAME = {cov_ref_frame}"));
269 }
270 lines.extend(write_covariance6(&covariance.matrix));
271 }
272 for maneuver in &opm.maneuvers {
273 lines.extend(encode_maneuver_kvn(maneuver));
274 }
275
276 lines.join("\n")
277}
278
279pub fn parse_xml(text: &str) -> Result<Opm, OpmError> {
281 let doc = Document::parse(text).map_err(|e| OpmError::Field(format!("malformed XML: {e}")))?;
282 let opm_node = doc
283 .descendants()
284 .find(|n| n.is_element() && n.tag_name().name() == "opm")
285 .ok_or_else(|| OpmError::Field("missing opm element".to_string()))?;
286
287 let version = opm_node
288 .attribute("version")
289 .map(str::trim)
290 .filter(|value| !value.is_empty())
291 .map(str::to_string)
292 .or_else(|| node_text(opm_node, OPM_VERSION_KEY))
293 .ok_or(OpmError::MissingField(OPM_VERSION_KEY))?;
294
295 let segment = opm_node
296 .descendants()
297 .find(|n| n.is_element() && n.tag_name().name() == "segment")
298 .ok_or_else(|| OpmError::Field("OPM contains no segment".to_string()))?;
299 let metadata_node = child_element(segment, "metadata")
300 .ok_or_else(|| OpmError::Field("segment missing metadata".to_string()))?;
301 let data_node = child_element(segment, "data")
302 .ok_or_else(|| OpmError::Field("segment missing data".to_string()))?;
303 let state_node = data_node
304 .descendants()
305 .find(|n| n.is_element() && n.tag_name().name() == "stateVector")
306 .ok_or_else(|| OpmError::Field("data missing stateVector".to_string()))?;
307
308 let keplerian_node = data_node
309 .descendants()
310 .find(|n| n.is_element() && n.tag_name().name() == "keplerianElements");
311 let spacecraft_node = data_node
312 .descendants()
313 .find(|n| n.is_element() && n.tag_name().name() == "spacecraftParameters");
314 let covariance_node = data_node
315 .descendants()
316 .find(|n| n.is_element() && n.tag_name().name() == "covarianceMatrix");
317
318 Ok(Opm {
319 ccsds_opm_vers: version,
320 creation_date: node_text(opm_node, "CREATION_DATE"),
321 originator: node_text(opm_node, "ORIGINATOR"),
322 metadata: parse_metadata(&FieldMap::from_pairs(xml_fields(
323 metadata_node,
324 &METADATA_KEYS,
325 )))?,
326 state: parse_state(&FieldMap::from_pairs(xml_fields(state_node, &STATE_KEYS)))?,
327 keplerian: keplerian_node
328 .map(|node| parse_keplerian(&FieldMap::from_pairs(xml_fields(node, &KEPLERIAN_KEYS))))
329 .transpose()?,
330 spacecraft: spacecraft_node
331 .map(|node| parse_spacecraft(&FieldMap::from_pairs(xml_fields(node, &SPACECRAFT_KEYS))))
332 .transpose()?,
333 covariance: covariance_node
334 .map(|node| parse_covariance(&FieldMap::from_pairs(xml_all_fields(node))))
335 .transpose()?,
336 maneuvers: data_node
337 .descendants()
338 .filter(|n| n.is_element() && n.tag_name().name() == "maneuverParameters")
339 .map(|node| parse_maneuver(&FieldMap::from_pairs(xml_fields(node, &MANEUVER_KEYS))))
340 .collect::<Result<_, _>>()?,
341 })
342}
343
344pub fn encode_xml(opm: &Opm) -> String {
346 let mut lines = vec![
347 r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string(),
348 format!(
349 r#"<opm id="CCSDS_OPM_VERS" version="{}">"#,
350 xml::escape(&opm.ccsds_opm_vers)
351 ),
352 " <header>".to_string(),
353 format!(
354 " <CREATION_DATE>{}</CREATION_DATE>",
355 xml::escape_opt(&opm.creation_date)
356 ),
357 format!(
358 " <ORIGINATOR>{}</ORIGINATOR>",
359 xml::escape_opt(&opm.originator)
360 ),
361 " </header>".to_string(),
362 " <body>".to_string(),
363 " <segment>".to_string(),
364 " <metadata>".to_string(),
365 elem_line(8, "OBJECT_NAME", &opm.metadata.object_name),
366 elem_line(8, "OBJECT_ID", &opm.metadata.object_id),
367 elem_line(8, "CENTER_NAME", &opm.metadata.center_name),
368 elem_line(8, "REF_FRAME", &opm.metadata.ref_frame),
369 elem_line(8, "TIME_SYSTEM", &opm.metadata.time_system),
370 " </metadata>".to_string(),
371 " <data>".to_string(),
372 ];
373
374 lines.extend(encode_xml_state(&opm.state));
375 if let Some(keplerian) = &opm.keplerian {
376 lines.extend(encode_xml_keplerian(keplerian));
377 }
378 if let Some(spacecraft) = &opm.spacecraft {
379 lines.extend(encode_xml_spacecraft(spacecraft));
380 }
381 if let Some(covariance) = &opm.covariance {
382 lines.extend(encode_xml_covariance(covariance));
383 }
384 for maneuver in &opm.maneuvers {
385 lines.extend(encode_xml_maneuver(maneuver));
386 }
387
388 lines.push(" </data>".to_string());
389 lines.push(" </segment>".to_string());
390 lines.push(" </body>".to_string());
391 lines.push("</opm>".to_string());
392 lines.join("\n")
393}
394
395fn parse_metadata(map: &FieldMap) -> Result<OpmMetadata, OpmError> {
396 Ok(OpmMetadata {
397 object_name: req_text(map, "OBJECT_NAME")?,
398 object_id: req_text(map, "OBJECT_ID")?,
399 center_name: req_text(map, "CENTER_NAME")?,
400 ref_frame: req_text(map, "REF_FRAME")?,
401 time_system: req_text(map, "TIME_SYSTEM")?,
402 })
403}
404
405fn parse_state(map: &FieldMap) -> Result<OpmState, OpmError> {
406 Ok(OpmState {
407 epoch: req_text(map, "EPOCH")?,
408 position_km: [req_num(map, "X")?, req_num(map, "Y")?, req_num(map, "Z")?],
409 velocity_km_s: [
410 req_num(map, "X_DOT")?,
411 req_num(map, "Y_DOT")?,
412 req_num(map, "Z_DOT")?,
413 ],
414 })
415}
416
417fn keplerian_present(map: &FieldMap) -> bool {
421 KEPLERIAN_KEYS.iter().any(|key| map.get(key).is_some())
422}
423
424fn spacecraft_present(map: &FieldMap) -> bool {
427 SPACECRAFT_KEYS.iter().any(|key| map.get(key).is_some())
428}
429
430fn covariance_present(map: &FieldMap) -> bool {
434 map.get("COV_REF_FRAME").is_some() || COVARIANCE6_KEYS.iter().any(|key| map.get(key).is_some())
435}
436
437fn parse_keplerian(map: &FieldMap) -> Result<OpmKeplerian, OpmError> {
438 let true_anomaly = opt_num(map, "TRUE_ANOMALY")?;
439 let mean_anomaly = opt_num(map, "MEAN_ANOMALY")?;
440 let anomaly = match (true_anomaly, mean_anomaly) {
441 (Some(value), None) => OpmAnomaly::True(value),
442 (None, Some(value)) => OpmAnomaly::Mean(value),
443 (None, None) => {
444 return Err(OpmError::Field(
445 "keplerianElements requires TRUE_ANOMALY or MEAN_ANOMALY".to_string(),
446 ))
447 }
448 (Some(_), Some(_)) => {
449 return Err(OpmError::Field(
450 "keplerianElements cannot contain both TRUE_ANOMALY and MEAN_ANOMALY".to_string(),
451 ))
452 }
453 };
454
455 Ok(OpmKeplerian {
456 semi_major_axis_km: req_num(map, "SEMI_MAJOR_AXIS")?,
457 eccentricity: req_num(map, "ECCENTRICITY")?,
458 inclination_deg: req_num(map, "INCLINATION")?,
459 ra_of_asc_node_deg: req_num(map, "RA_OF_ASC_NODE")?,
460 arg_of_pericenter_deg: req_num(map, "ARG_OF_PERICENTER")?,
461 anomaly,
462 gm_km3_s2: req_num(map, "GM")?,
463 })
464}
465
466fn parse_spacecraft(map: &FieldMap) -> Result<OpmSpacecraft, OpmError> {
467 Ok(OpmSpacecraft {
468 mass_kg: opt_num(map, "MASS")?,
469 solar_rad_area_m2: opt_num(map, "SOLAR_RAD_AREA")?,
470 solar_rad_coeff: opt_num(map, "SOLAR_RAD_COEFF")?,
471 drag_area_m2: opt_num(map, "DRAG_AREA")?,
472 drag_coeff: opt_num(map, "DRAG_COEFF")?,
473 })
474}
475
476fn parse_covariance(map: &FieldMap) -> Result<OpmCovariance, OpmError> {
477 Ok(OpmCovariance {
478 cov_ref_frame: opt_text(map, "COV_REF_FRAME"),
479 matrix: read_covariance6(map).map_err(map_opm_field_error)?,
480 })
481}
482
483fn parse_maneuver(map: &FieldMap) -> Result<OpmManeuver, OpmError> {
484 Ok(OpmManeuver {
485 epoch_ignition: req_text(map, "MAN_EPOCH_IGNITION")?,
486 duration_s: req_num(map, "MAN_DURATION")?,
487 delta_mass_kg: req_num(map, "MAN_DELTA_MASS")?,
488 ref_frame: req_text(map, "MAN_REF_FRAME")?,
489 dv_km_s: [
490 req_num(map, "MAN_DV_1")?,
491 req_num(map, "MAN_DV_2")?,
492 req_num(map, "MAN_DV_3")?,
493 ],
494 })
495}
496
497fn encode_metadata_kvn(metadata: &OpmMetadata) -> Vec<String> {
498 vec![
499 format!("OBJECT_NAME = {}", metadata.object_name),
500 format!("OBJECT_ID = {}", metadata.object_id),
501 format!("CENTER_NAME = {}", metadata.center_name),
502 format!("REF_FRAME = {}", metadata.ref_frame),
503 format!("TIME_SYSTEM = {}", metadata.time_system),
504 ]
505}
506
507fn encode_state_kvn(state: &OpmState) -> Vec<String> {
508 vec![
509 format!("EPOCH = {}", state.epoch),
510 format!("X = {}", fmt_num(state.position_km[0])),
511 format!("Y = {}", fmt_num(state.position_km[1])),
512 format!("Z = {}", fmt_num(state.position_km[2])),
513 format!("X_DOT = {}", fmt_num(state.velocity_km_s[0])),
514 format!("Y_DOT = {}", fmt_num(state.velocity_km_s[1])),
515 format!("Z_DOT = {}", fmt_num(state.velocity_km_s[2])),
516 ]
517}
518
519fn encode_keplerian_kvn(keplerian: &OpmKeplerian) -> Vec<String> {
520 let mut lines = vec![
521 format!(
522 "SEMI_MAJOR_AXIS = {}",
523 fmt_num(keplerian.semi_major_axis_km)
524 ),
525 format!("ECCENTRICITY = {}", fmt_num(keplerian.eccentricity)),
526 format!("INCLINATION = {}", fmt_num(keplerian.inclination_deg)),
527 format!("RA_OF_ASC_NODE = {}", fmt_num(keplerian.ra_of_asc_node_deg)),
528 format!(
529 "ARG_OF_PERICENTER = {}",
530 fmt_num(keplerian.arg_of_pericenter_deg)
531 ),
532 ];
533 match keplerian.anomaly {
534 OpmAnomaly::True(value) => lines.push(format!("TRUE_ANOMALY = {}", fmt_num(value))),
535 OpmAnomaly::Mean(value) => lines.push(format!("MEAN_ANOMALY = {}", fmt_num(value))),
536 }
537 lines.push(format!("GM = {}", fmt_num(keplerian.gm_km3_s2)));
538 lines
539}
540
541fn encode_spacecraft_kvn(spacecraft: &OpmSpacecraft) -> Vec<String> {
542 let mut lines = Vec::new();
543 push_opt_num(&mut lines, "MASS", spacecraft.mass_kg);
544 push_opt_num(&mut lines, "SOLAR_RAD_AREA", spacecraft.solar_rad_area_m2);
545 push_opt_num(&mut lines, "SOLAR_RAD_COEFF", spacecraft.solar_rad_coeff);
546 push_opt_num(&mut lines, "DRAG_AREA", spacecraft.drag_area_m2);
547 push_opt_num(&mut lines, "DRAG_COEFF", spacecraft.drag_coeff);
548 lines
549}
550
551fn encode_maneuver_kvn(maneuver: &OpmManeuver) -> Vec<String> {
552 let mut lines = vec![
553 format!("MAN_EPOCH_IGNITION = {}", maneuver.epoch_ignition),
554 format!("MAN_DURATION = {}", fmt_num(maneuver.duration_s)),
555 format!("MAN_DELTA_MASS = {}", fmt_num(maneuver.delta_mass_kg)),
556 format!("MAN_REF_FRAME = {}", maneuver.ref_frame),
557 ];
558 lines.push(format!("MAN_DV_1 = {}", fmt_num(maneuver.dv_km_s[0])));
559 lines.push(format!("MAN_DV_2 = {}", fmt_num(maneuver.dv_km_s[1])));
560 lines.push(format!("MAN_DV_3 = {}", fmt_num(maneuver.dv_km_s[2])));
561 lines
562}
563
564fn encode_xml_state(state: &OpmState) -> Vec<String> {
565 vec![
566 " <stateVector>".to_string(),
567 elem_line(10, "EPOCH", &state.epoch),
568 elem_line_raw(10, "X", &fmt_num(state.position_km[0])),
569 elem_line_raw(10, "Y", &fmt_num(state.position_km[1])),
570 elem_line_raw(10, "Z", &fmt_num(state.position_km[2])),
571 elem_line_raw(10, "X_DOT", &fmt_num(state.velocity_km_s[0])),
572 elem_line_raw(10, "Y_DOT", &fmt_num(state.velocity_km_s[1])),
573 elem_line_raw(10, "Z_DOT", &fmt_num(state.velocity_km_s[2])),
574 " </stateVector>".to_string(),
575 ]
576}
577
578fn encode_xml_keplerian(keplerian: &OpmKeplerian) -> Vec<String> {
579 let mut lines = vec![
580 " <keplerianElements>".to_string(),
581 elem_line_raw(
582 10,
583 "SEMI_MAJOR_AXIS",
584 &fmt_num(keplerian.semi_major_axis_km),
585 ),
586 elem_line_raw(10, "ECCENTRICITY", &fmt_num(keplerian.eccentricity)),
587 elem_line_raw(10, "INCLINATION", &fmt_num(keplerian.inclination_deg)),
588 elem_line_raw(10, "RA_OF_ASC_NODE", &fmt_num(keplerian.ra_of_asc_node_deg)),
589 elem_line_raw(
590 10,
591 "ARG_OF_PERICENTER",
592 &fmt_num(keplerian.arg_of_pericenter_deg),
593 ),
594 ];
595 match keplerian.anomaly {
596 OpmAnomaly::True(value) => lines.push(elem_line_raw(10, "TRUE_ANOMALY", &fmt_num(value))),
597 OpmAnomaly::Mean(value) => lines.push(elem_line_raw(10, "MEAN_ANOMALY", &fmt_num(value))),
598 }
599 lines.push(elem_line_raw(10, "GM", &fmt_num(keplerian.gm_km3_s2)));
600 lines.push(" </keplerianElements>".to_string());
601 lines
602}
603
604fn encode_xml_spacecraft(spacecraft: &OpmSpacecraft) -> Vec<String> {
605 let mut lines = vec![" <spacecraftParameters>".to_string()];
606 push_opt_xml_num(&mut lines, "MASS", spacecraft.mass_kg);
607 push_opt_xml_num(&mut lines, "SOLAR_RAD_AREA", spacecraft.solar_rad_area_m2);
608 push_opt_xml_num(&mut lines, "SOLAR_RAD_COEFF", spacecraft.solar_rad_coeff);
609 push_opt_xml_num(&mut lines, "DRAG_AREA", spacecraft.drag_area_m2);
610 push_opt_xml_num(&mut lines, "DRAG_COEFF", spacecraft.drag_coeff);
611 lines.push(" </spacecraftParameters>".to_string());
612 lines
613}
614
615fn encode_xml_covariance(covariance: &OpmCovariance) -> Vec<String> {
616 let mut lines = vec![" <covarianceMatrix>".to_string()];
617 if let Some(value) = &covariance.cov_ref_frame {
618 lines.push(elem_line(10, "COV_REF_FRAME", value));
619 }
620 for line in write_covariance6(&covariance.matrix) {
621 if let Some((key, value)) = line.split_once('=') {
622 lines.push(elem_line_raw(10, key.trim(), value.trim()));
623 }
624 }
625 lines.push(" </covarianceMatrix>".to_string());
626 lines
627}
628
629fn encode_xml_maneuver(maneuver: &OpmManeuver) -> Vec<String> {
630 let mut lines = vec![
631 " <maneuverParameters>".to_string(),
632 elem_line(10, "MAN_EPOCH_IGNITION", &maneuver.epoch_ignition),
633 elem_line_raw(10, "MAN_DURATION", &fmt_num(maneuver.duration_s)),
634 elem_line_raw(10, "MAN_DELTA_MASS", &fmt_num(maneuver.delta_mass_kg)),
635 elem_line(10, "MAN_REF_FRAME", &maneuver.ref_frame),
636 ];
637 lines.push(elem_line_raw(10, "MAN_DV_1", &fmt_num(maneuver.dv_km_s[0])));
638 lines.push(elem_line_raw(10, "MAN_DV_2", &fmt_num(maneuver.dv_km_s[1])));
639 lines.push(elem_line_raw(10, "MAN_DV_3", &fmt_num(maneuver.dv_km_s[2])));
640 lines.push(" </maneuverParameters>".to_string());
641 lines
642}
643
644fn significant_lines(text: &str) -> Vec<String> {
645 text.lines()
646 .map(|line| line.trim().to_string())
647 .filter(|line| !line.is_empty() && !line.starts_with(COMMENT_PREFIX))
648 .collect()
649}
650
651fn split_maneuver_blocks(lines: &[String]) -> (Vec<String>, Vec<Vec<String>>) {
652 let markers: Vec<usize> = lines
653 .iter()
654 .enumerate()
655 .filter(|(_, line)| {
656 line.split_once('=')
657 .is_some_and(|(key, _)| key.trim() == "MAN_EPOCH_IGNITION")
658 })
659 .map(|(idx, _)| idx)
660 .collect();
661
662 let Some(first_marker) = markers.first().copied() else {
663 return (lines.to_vec(), Vec::new());
664 };
665
666 let base = lines[..first_marker].to_vec();
667 let mut blocks = Vec::new();
668 for (pos, marker) in markers.iter().copied().enumerate() {
669 let end = markers.get(pos + 1).copied().unwrap_or(lines.len());
670 blocks.push(lines[marker..end].to_vec());
671 }
672 (base, blocks)
673}
674
675fn parse_kv_lines(lines: &[String]) -> Vec<(String, String)> {
676 lines
677 .iter()
678 .filter_map(|line| {
679 line.split_once('=').map(|(key, value)| {
680 (
681 key.trim().to_string(),
682 strip_units(value.trim()).to_string(),
683 )
684 })
685 })
686 .collect()
687}
688
689fn strip_units(value: &str) -> &str {
690 let trimmed = value.trim_end();
691 if let Some(open) = trimmed.rfind('[') {
692 if trimmed.ends_with(']') {
693 return trimmed[..open].trim_end();
694 }
695 }
696 trimmed
697}
698
699fn req_text(map: &FieldMap, field: &'static str) -> Result<String, OpmError> {
700 map.get(field)
701 .map(str::to_string)
702 .ok_or(OpmError::MissingField(field))
703}
704
705fn opt_text(map: &FieldMap, field: &'static str) -> Option<String> {
706 map.get(field).map(str::to_string)
707}
708
709fn req_num(map: &FieldMap, field: &'static str) -> Result<f64, OpmError> {
710 let value = map.get(field).ok_or(OpmError::MissingField(field))?;
711 parse_num(value, field)
712}
713
714fn opt_num(map: &FieldMap, field: &'static str) -> Result<Option<f64>, OpmError> {
715 map.get(field)
716 .map(|value| parse_num(value, field))
717 .transpose()
718}
719
720fn parse_num(value: &str, field: &'static str) -> Result<f64, OpmError> {
721 validate::strict_f64(value, field).map_err(map_opm_field_error)
722}
723
724fn map_opm_field_error(error: validate::FieldError) -> OpmError {
725 OpmError::InvalidField {
726 field: error.field(),
727 kind: OpmInputErrorKind::from(&error),
728 }
729}
730
731fn node_text(node: Node, tag: &str) -> Option<String> {
732 let element = node
733 .descendants()
734 .find(|n| n.is_element() && n.tag_name().name() == tag)?;
735 let text = element.text()?.trim();
736 if text.is_empty() {
737 None
738 } else {
739 Some(text.to_string())
740 }
741}
742
743fn child_element<'a>(node: Node<'a, 'a>, tag: &str) -> Option<Node<'a, 'a>> {
744 node.children()
745 .find(|n| n.is_element() && n.tag_name().name() == tag)
746}
747
748fn xml_fields(node: Node, keys: &[&str]) -> Vec<(String, String)> {
749 keys.iter()
750 .filter_map(|key| node_text(node, key).map(|value| ((*key).to_string(), value)))
751 .collect()
752}
753
754fn xml_all_fields(node: Node) -> Vec<(String, String)> {
755 node.descendants()
756 .filter(Node::is_element)
757 .filter_map(|n| {
758 let text = n.text()?.trim();
759 if text.is_empty() {
760 None
761 } else {
762 Some((n.tag_name().name().to_string(), text.to_string()))
763 }
764 })
765 .collect()
766}
767
768fn push_opt_num(lines: &mut Vec<String>, key: &str, value: Option<f64>) {
769 if let Some(value) = value {
770 lines.push(format!("{key} = {}", fmt_num(value)));
771 }
772}
773
774fn push_opt_xml_num(lines: &mut Vec<String>, key: &str, value: Option<f64>) {
775 if let Some(value) = value {
776 lines.push(elem_line_raw(10, key, &fmt_num(value)));
777 }
778}
779
780fn elem_line(indent: usize, name: &str, value: &str) -> String {
781 elem_line_raw(indent, name, &xml::escape(value))
782}
783
784fn elem_line_raw(indent: usize, name: &str, value: &str) -> String {
785 format!("{:indent$}<{name}>{value}</{name}>", "")
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 fn minimal_kvn() -> String {
793 "\
794CCSDS_OPM_VERS = 2.0
795CREATION_DATE = 2026-06-28T00:00:00
796ORIGINATOR = SIDEREON
797OBJECT_NAME = OSPREY
798OBJECT_ID = 2026-001A
799CENTER_NAME = EARTH
800REF_FRAME = EME2000
801TIME_SYSTEM = UTC
802EPOCH = 2026-06-28T00:00:00
803X = 7000
804Y = 0
805Z = 0
806X_DOT = 0
807Y_DOT = 7.5
808Z_DOT = 1
809"
810 .to_string()
811 }
812
813 #[test]
814 fn malformed_xml_is_an_error() {
815 assert!(parse_xml("<opm></opm><opm></opm>").is_err());
816 }
817
818 #[test]
819 fn missing_required_field_is_an_error() {
820 let kvn = minimal_kvn().replace("OBJECT_ID = 2026-001A\n", "");
821 assert_eq!(parse_kvn(&kvn), Err(OpmError::MissingField("OBJECT_ID")));
822 }
823
824 #[test]
825 fn parses_two_maneuvers_from_kvn() {
826 let kvn = format!(
827 "{}{}",
828 minimal_kvn(),
829 "\
830MAN_EPOCH_IGNITION = 2026-06-28T00:10:00
831MAN_DURATION = 10
832MAN_DELTA_MASS = -0.5
833MAN_REF_FRAME = TNW
834MAN_DV_1 = 0.001
835MAN_DV_2 = 0
836MAN_DV_3 = 0
837MAN_EPOCH_IGNITION = 2026-06-28T00:20:00
838MAN_DURATION = 20
839MAN_DELTA_MASS = -0.7
840MAN_REF_FRAME = TNW
841MAN_DV_1 = 0
842MAN_DV_2 = 0.002
843MAN_DV_3 = 0
844"
845 );
846 let opm = parse_kvn(&kvn).unwrap();
847 assert_eq!(opm.maneuvers.len(), 2);
848 assert_eq!(opm.maneuvers[0].ref_frame, "TNW");
849 assert_eq!(opm.maneuvers[1].dv_km_s, [0.0, 0.002, 0.0]);
850 }
851
852 #[cfg(all(test, sidereon_repo_tests))]
853 mod fixtures {
854 use super::*;
855
856 const OSPREY_KVN: &str = include_str!("../../tests/fixtures/opm/osprey.kvn");
857 const OSPREY_XML: &str = include_str!("../../tests/fixtures/opm/osprey.xml");
858
859 #[test]
860 fn parses_osprey_kvn_fixture() {
861 let opm = parse_kvn(OSPREY_KVN).unwrap();
862 assert_eq!(opm.ccsds_opm_vers, "2.0");
863 assert_eq!(opm.metadata.object_name, "OSPREY-1");
864 assert_eq!(opm.state.position_km[0], 6878.137);
865 assert_eq!(opm.maneuvers.len(), 2);
866 assert!(matches!(
867 opm.keplerian.as_ref().unwrap().anomaly,
868 OpmAnomaly::True(42.0)
869 ));
870 }
871
872 #[test]
873 fn parses_osprey_xml_fixture() {
874 let opm = parse_xml(OSPREY_XML).unwrap();
875 assert_eq!(opm.metadata.object_id, "2026-045A");
876 assert_eq!(opm.spacecraft.as_ref().unwrap().mass_kg, Some(425.0));
877 assert_eq!(
878 opm.covariance.as_ref().unwrap().cov_ref_frame.as_deref(),
879 Some("EME2000")
880 );
881 }
882
883 #[test]
884 fn fixture_kvn_round_trips() {
885 let opm = parse_kvn(OSPREY_KVN).unwrap();
886 assert_eq!(parse_kvn(&encode_kvn(&opm)).unwrap(), opm);
887 }
888
889 #[test]
890 fn fixture_xml_round_trips() {
891 let opm = parse_xml(OSPREY_XML).unwrap();
892 assert_eq!(parse_xml(&encode_xml(&opm)).unwrap(), opm);
893 }
894 }
895}