1use crate::astro::sgp4::{self, ElementSet, Error as Sgp4Error, Satellite, Sgp4InputErrorKind};
30use crate::astro::tle;
31use crate::astro::xml;
32use crate::validate;
33use roxmltree::Document;
34use std::fmt::{self, Write as _};
35
36const FIELD_TAGS: &[&str] = &[
41 "CREATION_DATE",
42 "ORIGINATOR",
43 "OBJECT_NAME",
44 "OBJECT_ID",
45 "CENTER_NAME",
46 "REF_FRAME",
47 "TIME_SYSTEM",
48 "MEAN_ELEMENT_THEORY",
49 "EPOCH",
50 "MEAN_MOTION",
51 "ECCENTRICITY",
52 "INCLINATION",
53 "RA_OF_ASC_NODE",
54 "ARG_OF_PERICENTER",
55 "MEAN_ANOMALY",
56 "EPHEMERIS_TYPE",
57 "CLASSIFICATION_TYPE",
58 "NORAD_CAT_ID",
59 "ELEMENT_SET_NO",
60 "REV_AT_EPOCH",
61 "BSTAR",
62 "MEAN_MOTION_DOT",
63 "MEAN_MOTION_DDOT",
64];
65
66#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct OmmEpoch {
71 pub year: i32,
72 pub month: u32,
73 pub day: u32,
74 pub hour: u32,
75 pub minute: u32,
76 pub second: u32,
77 pub microsecond: u32,
79}
80
81#[derive(Debug, Clone, PartialEq)]
89pub struct Omm {
90 pub ccsds_omm_vers: String,
92 pub creation_date: Option<String>,
93 pub originator: Option<String>,
94 pub object_name: Option<String>,
95 pub object_id: Option<String>,
97 pub center_name: Option<String>,
98 pub ref_frame: Option<String>,
99 pub time_system: Option<String>,
100 pub mean_element_theory: Option<String>,
101
102 pub epoch: OmmEpoch,
104 pub mean_motion: f64,
106 pub eccentricity: f64,
108 pub inclination_deg: f64,
110 pub ra_of_asc_node_deg: f64,
112 pub arg_of_pericenter_deg: f64,
114 pub mean_anomaly_deg: f64,
116
117 pub ephemeris_type: i32,
119 pub classification_type: String,
120 pub norad_cat_id: u32,
121 pub element_set_no: i32,
122 pub rev_at_epoch: i64,
123 pub bstar: f64,
125 pub mean_motion_dot: f64,
127 pub mean_motion_ddot: f64,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum OmmError {
134 MissingField(&'static str),
136 InvalidField {
138 field: &'static str,
139 kind: OmmInputErrorKind,
140 },
141 Field(String),
143 Epoch(String),
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum OmmInputErrorKind {
150 Missing,
152 NonFinite,
154 FloatParse,
156 IntParse,
158 NotPositive,
160 Negative,
162 OutOfRange,
164 InvalidCivilDate,
166 InvalidCivilTime,
168}
169
170impl fmt::Display for OmmInputErrorKind {
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 OmmInputErrorKind {
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 OmmError {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 match self {
206 OmmError::MissingField(name) => write!(f, "OMM missing required field {name}"),
207 OmmError::InvalidField { field, kind } => {
208 write!(f, "invalid OMM field {field}: {kind}")
209 }
210 OmmError::Field(msg) => write!(f, "OMM field error: {msg}"),
211 OmmError::Epoch(msg) => write!(f, "OMM epoch error: {msg}"),
212 }
213 }
214}
215
216impl std::error::Error for OmmError {}
217
218pub fn parse_kvn(text: &str) -> Result<Omm, OmmError> {
226 let map = crate::format::kvn::FieldMap::parse(text);
227 Omm::from_field_map(&map)
228}
229
230pub fn encode_kvn(omm: &Omm) -> String {
235 let mut out = String::new();
236 let header = crate::astro::ndm::NdmHeader {
237 vers: omm.ccsds_omm_vers.clone(),
238 creation_date: omm.creation_date.clone(),
239 originator: omm.originator.clone(),
240 };
241 for line in header.write_kvn("CCSDS_OMM_VERS") {
242 out.push_str(&line);
243 out.push('\n');
244 }
245
246 let mut kv = |key: &str, value: &str| {
247 out.push_str(key);
248 out.push_str(" = ");
249 out.push_str(value);
250 out.push('\n');
251 };
252
253 kv("OBJECT_NAME", omm.object_name.as_deref().unwrap_or(""));
254 kv("OBJECT_ID", omm.object_id.as_deref().unwrap_or(""));
255 kv("CENTER_NAME", omm.center_name.as_deref().unwrap_or(""));
256 kv("REF_FRAME", omm.ref_frame.as_deref().unwrap_or(""));
257 kv("TIME_SYSTEM", omm.time_system.as_deref().unwrap_or(""));
258 kv(
259 "MEAN_ELEMENT_THEORY",
260 omm.mean_element_theory.as_deref().unwrap_or(""),
261 );
262 kv("EPOCH", &omm.epoch.to_iso8601());
263 kv("MEAN_MOTION", &fmt_num(omm.mean_motion));
264 kv("ECCENTRICITY", &fmt_num(omm.eccentricity));
265 kv("INCLINATION", &fmt_num(omm.inclination_deg));
266 kv("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg));
267 kv("ARG_OF_PERICENTER", &fmt_num(omm.arg_of_pericenter_deg));
268 kv("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg));
269 kv("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string());
270 kv("CLASSIFICATION_TYPE", &omm.classification_type);
271 kv("NORAD_CAT_ID", &omm.norad_cat_id.to_string());
272 kv("ELEMENT_SET_NO", &omm.element_set_no.to_string());
273 kv("REV_AT_EPOCH", &omm.rev_at_epoch.to_string());
274 kv("BSTAR", &fmt_num(omm.bstar));
275 kv("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot));
276 kv("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot));
277 out
278}
279
280pub fn parse_xml(text: &str) -> Result<Omm, OmmError> {
291 let doc = Document::parse(text).map_err(|e| OmmError::Field(format!("malformed XML: {e}")))?;
292 let mut fields: Vec<(String, String)> = Vec::new();
293
294 if let Some(omm_el) = doc
295 .descendants()
296 .find(|n| n.is_element() && n.tag_name().name() == "omm")
297 {
298 if let Some(version) = omm_el.attribute("version") {
299 fields.push(("CCSDS_OMM_VERS".to_string(), version.trim().to_string()));
300 }
301 }
302
303 for node in doc.descendants().filter(roxmltree::Node::is_element) {
304 let name = node.tag_name().name();
305 if FIELD_TAGS.contains(&name) {
306 let value = node.text().unwrap_or("").trim().to_string();
307 fields.push((name.to_string(), value));
308 }
309 }
310
311 let map = crate::format::kvn::FieldMap::from_pairs(fields);
312 Omm::from_field_map(&map)
313}
314
315pub fn encode_xml(omm: &Omm) -> String {
319 fn elem(name: &str, value: &str) -> String {
320 format!("<{name}>{value}</{name}>")
321 }
322 let opt = |value: &Option<String>| xml::escape_opt(value);
323
324 let mut s = String::new();
325 s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
326 s.push_str("<ndm>\n");
327 let _ = writeln!(
328 s,
329 "<omm id=\"CCSDS_OMM_VERS\" version=\"{}\">",
330 xml::escape(&omm.ccsds_omm_vers)
331 );
332
333 s.push_str("<header>");
334 s.push_str(&elem("CREATION_DATE", &opt(&omm.creation_date)));
335 s.push_str(&elem("ORIGINATOR", &opt(&omm.originator)));
336 s.push_str("</header>\n");
337
338 s.push_str("<body><segment>\n<metadata>");
339 s.push_str(&elem("OBJECT_NAME", &opt(&omm.object_name)));
340 s.push_str(&elem("OBJECT_ID", &opt(&omm.object_id)));
341 s.push_str(&elem("CENTER_NAME", &opt(&omm.center_name)));
342 s.push_str(&elem("REF_FRAME", &opt(&omm.ref_frame)));
343 s.push_str(&elem("TIME_SYSTEM", &opt(&omm.time_system)));
344 s.push_str(&elem("MEAN_ELEMENT_THEORY", &opt(&omm.mean_element_theory)));
345 s.push_str("</metadata>\n<data>\n<meanElements>");
346
347 s.push_str(&elem("EPOCH", &omm.epoch.to_iso8601()));
348 s.push_str(&elem("MEAN_MOTION", &fmt_num(omm.mean_motion)));
349 s.push_str(&elem("ECCENTRICITY", &fmt_num(omm.eccentricity)));
350 s.push_str(&elem("INCLINATION", &fmt_num(omm.inclination_deg)));
351 s.push_str(&elem("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg)));
352 s.push_str(&elem(
353 "ARG_OF_PERICENTER",
354 &fmt_num(omm.arg_of_pericenter_deg),
355 ));
356 s.push_str(&elem("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg)));
357 s.push_str("</meanElements>\n<tleParameters>");
358
359 s.push_str(&elem("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string()));
360 s.push_str(&elem(
361 "CLASSIFICATION_TYPE",
362 &xml::escape(&omm.classification_type),
363 ));
364 s.push_str(&elem("NORAD_CAT_ID", &omm.norad_cat_id.to_string()));
365 s.push_str(&elem("ELEMENT_SET_NO", &omm.element_set_no.to_string()));
366 s.push_str(&elem("REV_AT_EPOCH", &omm.rev_at_epoch.to_string()));
367 s.push_str(&elem("BSTAR", &fmt_num(omm.bstar)));
368 s.push_str(&elem("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot)));
369 s.push_str(&elem("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot)));
370 s.push_str("</tleParameters>\n</data>\n</segment></body>\n</omm>\n</ndm>\n");
371 s
372}
373
374pub fn parse_json(text: &str) -> Result<Omm, OmmError> {
384 use serde_json::Value;
385
386 let value: Value =
387 serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
388 let object = match &value {
389 Value::Array(items) => items
390 .first()
391 .ok_or_else(|| OmmError::Field("empty JSON array".to_string()))?,
392 Value::Object(_) => &value,
393 _ => {
394 return Err(OmmError::Field(
395 "expected a JSON object or array".to_string(),
396 ))
397 }
398 };
399 omm_from_json_value(object)
400}
401
402fn omm_from_json_value(object: &serde_json::Value) -> Result<Omm, OmmError> {
405 let map = object
406 .as_object()
407 .ok_or_else(|| OmmError::Field("expected a JSON object".to_string()))?;
408 let fields: Vec<(String, String)> = map
409 .iter()
410 .map(|(key, value)| (key.clone(), json_scalar_to_string(value)))
411 .collect();
412 let map = crate::format::kvn::FieldMap::from_pairs(fields);
413 Omm::from_field_map(&map)
414}
415
416#[derive(Debug, Clone, PartialEq)]
419pub struct OmmArray {
420 pub omms: Vec<Omm>,
422 pub skipped: usize,
429}
430
431pub fn parse_json_array(text: &str) -> Result<OmmArray, OmmError> {
441 use serde_json::Value;
442
443 let value: Value =
444 serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
445 let items: &[Value] = match &value {
446 Value::Array(items) => items.as_slice(),
447 Value::Object(_) => std::slice::from_ref(&value),
448 _ => {
449 return Err(OmmError::Field(
450 "expected a JSON object or array".to_string(),
451 ))
452 }
453 };
454
455 let mut omms = Vec::with_capacity(items.len());
456 let mut skipped = 0usize;
457 for object in items {
458 match omm_from_json_value(object) {
459 Ok(omm) => omms.push(omm),
460 Err(_) => skipped += 1,
461 }
462 }
463 Ok(OmmArray { omms, skipped })
464}
465
466pub fn encode_json(omm: &Omm) -> String {
472 use serde_json::{Map, Number, Value};
473
474 let num = |x: f64| Number::from_f64(x).map_or(Value::Null, Value::Number);
475 let opt = |value: &Option<String>| value.clone().map_or(Value::Null, Value::String);
476
477 let mut map = Map::new();
478 map.insert(
479 "CCSDS_OMM_VERS".into(),
480 Value::String(omm.ccsds_omm_vers.clone()),
481 );
482 map.insert("CREATION_DATE".into(), opt(&omm.creation_date));
483 map.insert("ORIGINATOR".into(), opt(&omm.originator));
484 map.insert("OBJECT_NAME".into(), opt(&omm.object_name));
485 map.insert("OBJECT_ID".into(), opt(&omm.object_id));
486 map.insert("CENTER_NAME".into(), opt(&omm.center_name));
487 map.insert("REF_FRAME".into(), opt(&omm.ref_frame));
488 map.insert("TIME_SYSTEM".into(), opt(&omm.time_system));
489 map.insert("MEAN_ELEMENT_THEORY".into(), opt(&omm.mean_element_theory));
490 map.insert("EPOCH".into(), Value::String(omm.epoch.to_iso8601()));
491 map.insert("MEAN_MOTION".into(), num(omm.mean_motion));
492 map.insert("ECCENTRICITY".into(), num(omm.eccentricity));
493 map.insert("INCLINATION".into(), num(omm.inclination_deg));
494 map.insert("RA_OF_ASC_NODE".into(), num(omm.ra_of_asc_node_deg));
495 map.insert("ARG_OF_PERICENTER".into(), num(omm.arg_of_pericenter_deg));
496 map.insert("MEAN_ANOMALY".into(), num(omm.mean_anomaly_deg));
497 map.insert(
498 "EPHEMERIS_TYPE".into(),
499 Value::Number(omm.ephemeris_type.into()),
500 );
501 map.insert(
502 "CLASSIFICATION_TYPE".into(),
503 Value::String(omm.classification_type.clone()),
504 );
505 map.insert(
506 "NORAD_CAT_ID".into(),
507 Value::Number(omm.norad_cat_id.into()),
508 );
509 map.insert(
510 "ELEMENT_SET_NO".into(),
511 Value::Number(omm.element_set_no.into()),
512 );
513 map.insert(
514 "REV_AT_EPOCH".into(),
515 Value::Number(omm.rev_at_epoch.into()),
516 );
517 map.insert("BSTAR".into(), num(omm.bstar));
518 map.insert("MEAN_MOTION_DOT".into(), num(omm.mean_motion_dot));
519 map.insert("MEAN_MOTION_DDOT".into(), num(omm.mean_motion_ddot));
520 Value::Object(map).to_string()
521}
522
523fn json_scalar_to_string(value: &serde_json::Value) -> String {
526 use serde_json::Value;
527 match value {
528 Value::String(s) => s.clone(),
529 Value::Number(n) => n.to_string(),
530 Value::Bool(b) => b.to_string(),
531 Value::Null => String::new(),
532 other => other.to_string(),
533 }
534}
535
536pub fn parse(text: &str) -> Result<Omm, OmmError> {
543 match text.trim_start().chars().next() {
544 Some('<') => parse_xml(text),
545 Some('{') | Some('[') => parse_json_detected(text),
546 _ => parse_kvn(text),
547 }
548}
549
550pub fn parse_epoch(text: &str) -> Result<OmmEpoch, OmmError> {
560 OmmEpoch::parse(text, validate::CivilSecondPolicy::UtcLike)
561}
562
563fn parse_json_detected(text: &str) -> Result<Omm, OmmError> {
564 parse_json(text)
565}
566
567impl Omm {
570 pub(crate) fn from_field_map(map: &crate::format::kvn::FieldMap) -> Result<Omm, OmmError> {
574 let get = |key: &str| map.get(key);
575
576 let time_system = xml_text(get("TIME_SYSTEM"), "TIME_SYSTEM")?;
577 let epoch = OmmEpoch::parse(
578 get("EPOCH").ok_or(OmmError::MissingField("EPOCH"))?,
579 omm_civil_second_policy(time_system.as_deref()),
580 )?;
581
582 Ok(Omm {
583 ccsds_omm_vers: xml_text_or_default(get("CCSDS_OMM_VERS"), "CCSDS_OMM_VERS", "2.0")?,
584 creation_date: xml_text(get("CREATION_DATE"), "CREATION_DATE")?,
585 originator: xml_text(get("ORIGINATOR"), "ORIGINATOR")?,
586 object_name: xml_text(get("OBJECT_NAME"), "OBJECT_NAME")?,
587 object_id: xml_text(get("OBJECT_ID"), "OBJECT_ID")?,
588 center_name: xml_text(get("CENTER_NAME"), "CENTER_NAME")?,
589 ref_frame: xml_text(get("REF_FRAME"), "REF_FRAME")?,
590 time_system,
591 mean_element_theory: xml_text(get("MEAN_ELEMENT_THEORY"), "MEAN_ELEMENT_THEORY")?,
592 epoch,
593 mean_motion: req_num(get("MEAN_MOTION"), "MEAN_MOTION")?,
594 eccentricity: req_num(get("ECCENTRICITY"), "ECCENTRICITY")?,
595 inclination_deg: req_num(get("INCLINATION"), "INCLINATION")?,
596 ra_of_asc_node_deg: req_num(get("RA_OF_ASC_NODE"), "RA_OF_ASC_NODE")?,
597 arg_of_pericenter_deg: req_num(get("ARG_OF_PERICENTER"), "ARG_OF_PERICENTER")?,
598 mean_anomaly_deg: req_num(get("MEAN_ANOMALY"), "MEAN_ANOMALY")?,
599 ephemeris_type: opt_int(get("EPHEMERIS_TYPE"), "EPHEMERIS_TYPE")?.unwrap_or(0),
600 classification_type: xml_text_or_default(
601 get("CLASSIFICATION_TYPE"),
602 "CLASSIFICATION_TYPE",
603 "U",
604 )?,
605 norad_cat_id: req_int(get("NORAD_CAT_ID"), "NORAD_CAT_ID")?,
606 element_set_no: opt_int(get("ELEMENT_SET_NO"), "ELEMENT_SET_NO")?.unwrap_or(999),
607 rev_at_epoch: opt_int(get("REV_AT_EPOCH"), "REV_AT_EPOCH")?.unwrap_or(0),
608 bstar: req_num(get("BSTAR"), "BSTAR")?,
609 mean_motion_dot: req_num(get("MEAN_MOTION_DOT"), "MEAN_MOTION_DOT")?,
610 mean_motion_ddot: req_num(get("MEAN_MOTION_DDOT"), "MEAN_MOTION_DDOT")?,
611 })
612 }
613}
614
615fn xml_text(value: Option<&str>, field: &'static str) -> Result<Option<String>, OmmError> {
616 value
617 .map(|value| xml_text_value(value, field).map(str::to_string))
618 .transpose()
619}
620
621fn xml_text_or_default(
622 value: Option<&str>,
623 field: &'static str,
624 default: &'static str,
625) -> Result<String, OmmError> {
626 xml_text_value(value.unwrap_or(default), field).map(str::to_string)
627}
628
629fn xml_text_value<'a>(value: &'a str, field: &'static str) -> Result<&'a str, OmmError> {
630 if let Some(ch) = xml::first_illegal_xml_1_0_char(value) {
631 return Err(OmmError::Field(format!(
632 "field {field} contains XML-illegal character U+{:04X}",
633 ch as u32
634 )));
635 }
636 Ok(value)
637}
638
639impl Omm {
642 pub fn to_element_set(&self) -> Result<ElementSet, OmmError> {
651 validate_omm_bridge(self)?;
652 Ok(ElementSet {
653 epoch: self.epoch.sgp4_julian_date(),
654 bstar: tle::assumed_decimal_quantize(self.bstar),
655 mean_motion_dot: self.mean_motion_dot,
656 mean_motion_double_dot: tle::assumed_decimal_quantize(self.mean_motion_ddot),
657 eccentricity: self.eccentricity,
658 argument_of_perigee_deg: self.arg_of_pericenter_deg,
659 inclination_deg: self.inclination_deg,
660 mean_anomaly_deg: self.mean_anomaly_deg,
661 mean_motion_rev_per_day: self.mean_motion,
662 right_ascension_deg: self.ra_of_asc_node_deg,
663 catalog_number: self.norad_cat_id,
664 })
665 }
666}
667
668impl Satellite {
669 pub fn from_omm(omm: &Omm) -> Result<Self, Sgp4Error> {
674 let elements = omm.to_element_set().map_err(map_omm_bridge_to_sgp4)?;
675 Self::from_elements(&elements)
676 }
677}
678
679fn validate_omm_bridge(omm: &Omm) -> Result<(), OmmError> {
680 validate::finite_positive(omm.mean_motion, "mean_motion").map_err(map_omm_field_error)?;
681 validate::finite_in_range_exclusive_upper(omm.eccentricity, 0.0, 1.0, "eccentricity")
682 .map_err(map_omm_field_error)?;
683 validate::finite(omm.inclination_deg, "inclination_deg").map_err(map_omm_field_error)?;
684 validate::finite(omm.ra_of_asc_node_deg, "ra_of_asc_node_deg").map_err(map_omm_field_error)?;
685 validate::finite(omm.arg_of_pericenter_deg, "arg_of_pericenter_deg")
686 .map_err(map_omm_field_error)?;
687 validate::finite(omm.mean_anomaly_deg, "mean_anomaly_deg").map_err(map_omm_field_error)?;
688 validate::finite(omm.bstar, "bstar").map_err(map_omm_field_error)?;
689 validate::finite(omm.mean_motion_dot, "mean_motion_dot").map_err(map_omm_field_error)?;
690 validate::finite(omm.mean_motion_ddot, "mean_motion_ddot").map_err(map_omm_field_error)?;
691 Ok(())
692}
693
694fn map_omm_bridge_to_sgp4(error: OmmError) -> Sgp4Error {
695 match error {
696 OmmError::InvalidField { field, kind } => Sgp4Error::InvalidInput {
697 field,
698 kind: match kind {
699 OmmInputErrorKind::NonFinite => Sgp4InputErrorKind::NonFinite,
700 OmmInputErrorKind::NotPositive => Sgp4InputErrorKind::NotPositive,
701 OmmInputErrorKind::Negative => Sgp4InputErrorKind::Negative,
702 OmmInputErrorKind::OutOfRange => Sgp4InputErrorKind::OutOfRange,
703 OmmInputErrorKind::Missing => Sgp4InputErrorKind::Missing,
704 OmmInputErrorKind::FloatParse => Sgp4InputErrorKind::FloatParse,
705 OmmInputErrorKind::IntParse => Sgp4InputErrorKind::IntParse,
706 OmmInputErrorKind::InvalidCivilDate => Sgp4InputErrorKind::InvalidCivilDate,
707 OmmInputErrorKind::InvalidCivilTime => Sgp4InputErrorKind::InvalidCivilTime,
708 },
709 },
710 other => Sgp4Error::InvalidTle(other.to_string()),
711 }
712}
713
714impl OmmEpoch {
717 fn parse(text: &str, second_policy: validate::CivilSecondPolicy) -> Result<OmmEpoch, OmmError> {
719 let e = crate::astro::ndm::NdmEpoch::parse(text, second_policy)
720 .map_err(|err| map_omm_epoch_field_error(err, text))?;
721 Ok(OmmEpoch {
722 year: e.year,
723 month: e.month,
724 day: e.day,
725 hour: e.hour,
726 minute: e.minute,
727 second: e.second,
728 microsecond: e.microsecond,
729 })
730 }
731
732 fn sgp4_julian_date(&self) -> sgp4::JulianDate {
735 sgp4::sgp4_julian_date_from_calendar(
736 self.year,
737 self.month as i32,
738 self.day as i32,
739 self.hour as i32,
740 self.minute as i32,
741 self.second as f64 + self.microsecond as f64 / 1_000_000.0,
742 )
743 }
744
745 fn to_iso8601(&self) -> String {
747 format!(
748 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}",
749 self.year, self.month, self.day, self.hour, self.minute, self.second, self.microsecond
750 )
751 }
752}
753
754fn omm_civil_second_policy(time_system: Option<&str>) -> validate::CivilSecondPolicy {
757 let Some(label) = time_system.map(str::trim).filter(|label| !label.is_empty()) else {
758 return validate::CivilSecondPolicy::UtcLike;
759 };
760 if label.eq_ignore_ascii_case("UTC")
761 || label.eq_ignore_ascii_case("GLO")
762 || label.eq_ignore_ascii_case("GLONASS")
763 {
764 validate::CivilSecondPolicy::UtcLike
765 } else {
766 validate::CivilSecondPolicy::Continuous
767 }
768}
769
770fn req_num(value: Option<&str>, field: &'static str) -> Result<f64, OmmError> {
771 let value = value.ok_or(OmmError::MissingField(field))?;
772 parse_num(value, field)
773}
774
775fn parse_num(value: &str, field: &'static str) -> Result<f64, OmmError> {
776 validate::strict_f64(value, field).map_err(map_omm_field_error)
777}
778
779fn req_int<T>(value: Option<&str>, field: &'static str) -> Result<T, OmmError>
780where
781 T: std::str::FromStr,
782{
783 let value = value.ok_or(OmmError::MissingField(field))?;
784 parse_int(value, field)
785}
786
787fn opt_int<T>(value: Option<&str>, field: &'static str) -> Result<Option<T>, OmmError>
788where
789 T: std::str::FromStr,
790{
791 value.map(|v| parse_int(v, field)).transpose()
792}
793
794fn parse_int<T>(value: &str, field: &'static str) -> Result<T, OmmError>
795where
796 T: std::str::FromStr,
797{
798 validate::strict_int::<T>(value, field).map_err(map_omm_field_error)
799}
800
801fn map_omm_field_error(error: validate::FieldError) -> OmmError {
802 OmmError::InvalidField {
803 field: error.field(),
804 kind: OmmInputErrorKind::from(&error),
805 }
806}
807
808fn map_omm_epoch_field_error(error: validate::FieldError, full: &str) -> OmmError {
809 match error {
810 validate::FieldError::Missing { .. }
811 | validate::FieldError::FloatParse { .. }
812 | validate::FieldError::IntParse { .. } => {
813 OmmError::Epoch(format!("invalid seconds in {full:?}"))
814 }
815 _ => map_omm_field_error(error),
816 }
817}
818
819fn fmt_num(value: f64) -> String {
821 format!("{value}")
822}
823
824#[cfg(all(test, sidereon_repo_tests))]
825mod tests {
826 use super::*;
827
828 const ISS_KVN: &str = include_str!("../../tests/fixtures/omm/25544.kvn");
829 const ISS_XML: &str = include_str!("../../tests/fixtures/omm/25544.xml");
830
831 fn canonical(omm: &Omm) -> Omm {
838 Omm {
839 ccsds_omm_vers: String::new(),
840 creation_date: None,
841 originator: None,
842 center_name: None,
843 ref_frame: None,
844 time_system: None,
845 mean_element_theory: None,
846 ..omm.clone()
847 }
848 }
849
850 fn kvn_with_field(field: &str, value: &str) -> String {
851 kvn_with_fields(&[(field, value)])
852 }
853
854 fn kvn_with_fields(fields: &[(&str, &str)]) -> String {
855 ISS_KVN
856 .lines()
857 .map(|line| match line.split_once('=') {
858 Some((key, _)) => fields
859 .iter()
860 .find(|(field, _)| key.trim() == *field)
861 .map_or_else(
862 || line.to_string(),
863 |(field, value)| format!("{field} = {value}"),
864 ),
865 _ => line.to_string(),
866 })
867 .collect::<Vec<_>>()
868 .join("\n")
869 }
870
871 fn kvn_without_field(field: &str) -> String {
872 ISS_KVN
873 .lines()
874 .filter(|line| match line.split_once('=') {
875 Some((key, _)) => key.trim() != field,
876 None => true,
877 })
878 .collect::<Vec<_>>()
879 .join("\n")
880 }
881
882 #[test]
883 fn parses_iss_kvn_fields() {
884 let omm = parse_kvn(ISS_KVN).unwrap();
885 assert_eq!(omm.ccsds_omm_vers, "2.0");
886 assert_eq!(omm.object_name.as_deref(), Some("ISS (ZARYA)"));
887 assert_eq!(omm.object_id.as_deref(), Some("1998-067A"));
888 assert_eq!(omm.norad_cat_id, 25544);
889 assert_eq!(omm.mean_motion, 15.49273435);
890 assert_eq!(omm.eccentricity, 0.0004737);
891 assert_eq!(omm.inclination_deg, 51.6332);
892 assert_eq!(omm.bstar, 0.00017172);
893 assert_eq!(omm.mean_motion_dot, 9.113e-5);
894 assert_eq!(omm.mean_motion_ddot, 0.0);
895 assert_eq!(
896 omm.epoch,
897 OmmEpoch {
898 year: 2026,
899 month: 6,
900 day: 17,
901 hour: 4,
902 minute: 32,
903 second: 52,
904 microsecond: 99296,
905 }
906 );
907 }
908
909 #[test]
910 fn parse_kvn_requires_drag_terms() {
911 for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
912 assert_eq!(
913 parse_kvn(&kvn_without_field(field)),
914 Err(OmmError::MissingField(field))
915 );
916 }
917 }
918
919 #[test]
920 fn parse_kvn_rejects_non_finite_drag_terms() {
921 for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
922 assert_eq!(
923 parse_kvn(&kvn_with_field(field, "NaN")),
924 Err(OmmError::InvalidField {
925 field,
926 kind: OmmInputErrorKind::NonFinite,
927 })
928 );
929 }
930 }
931
932 #[test]
933 fn parse_kvn_rejects_negative_norad_catalog_id() {
934 assert_eq!(
935 parse_kvn(&kvn_with_field("NORAD_CAT_ID", "-1")),
936 Err(OmmError::InvalidField {
937 field: "NORAD_CAT_ID",
938 kind: OmmInputErrorKind::IntParse,
939 })
940 );
941 }
942
943 #[test]
944 fn parse_kvn_rejects_oversized_norad_catalog_id() {
945 assert_eq!(
946 parse_kvn(&kvn_with_field("NORAD_CAT_ID", "4294967296")),
947 Err(OmmError::InvalidField {
948 field: "NORAD_CAT_ID",
949 kind: OmmInputErrorKind::IntParse,
950 })
951 );
952 }
953
954 #[test]
955 fn parse_kvn_rejects_invalid_civil_epoch() {
956 assert_eq!(
957 parse_kvn(&kvn_with_field("EPOCH", "2026-02-30T04:32:52.099296")),
958 Err(OmmError::InvalidField {
959 field: "civil datetime",
960 kind: OmmInputErrorKind::InvalidCivilDate,
961 })
962 );
963 assert_eq!(
964 parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T24:00:00.000000")),
965 Err(OmmError::InvalidField {
966 field: "civil datetime",
967 kind: OmmInputErrorKind::InvalidCivilTime,
968 })
969 );
970 }
971
972 #[test]
973 fn parse_kvn_accepts_utc_leap_second_epoch() {
974 let omm = parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:60.000000Z"))
975 .expect("OMM leap-second epoch");
976 assert_eq!(
977 omm.epoch,
978 OmmEpoch {
979 year: 2016,
980 month: 12,
981 day: 31,
982 hour: 23,
983 minute: 59,
984 second: 60,
985 microsecond: 0,
986 }
987 );
988 }
989
990 #[test]
991 fn parse_kvn_rejects_gps_time_leap_second_epoch() {
992 assert_eq!(
993 parse_kvn(&kvn_with_fields(&[
994 ("TIME_SYSTEM", "GPS"),
995 ("EPOCH", "2016-12-31T23:59:60.000000Z"),
996 ])),
997 Err(OmmError::InvalidField {
998 field: "civil datetime",
999 kind: OmmInputErrorKind::InvalidCivilTime,
1000 })
1001 );
1002 }
1003
1004 #[test]
1005 fn parse_kvn_rejects_invalid_leap_second_range() {
1006 assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:61.000000Z")).is_err());
1007 assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:-1.000000Z")).is_err());
1008 }
1009
1010 #[test]
1011 fn parse_kvn_requires_fractional_epoch_digits() {
1012 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.500"))
1013 .expect("fractional epoch");
1014 assert_eq!(omm.epoch.microsecond, 500_000);
1015
1016 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.5Z"))
1017 .expect("fractional epoch with UTC suffix");
1018 assert_eq!(omm.epoch.microsecond, 500_000);
1019
1020 for epoch in [
1021 "2026-06-17T04:32:52.abc",
1022 "2026-06-17T04:32:52.abcZ",
1023 "2026-06-17T04:32:52.5x",
1024 "2026-06-17T04:32:52.5xZ",
1025 "2026-06-17T04:32:52.",
1026 ] {
1027 assert!(
1028 matches!(
1029 parse_kvn(&kvn_with_field("EPOCH", epoch)),
1030 Err(OmmError::Epoch(_))
1031 ),
1032 "{epoch} must be rejected"
1033 );
1034 }
1035 }
1036
1037 #[test]
1038 fn parse_kvn_carries_rounded_fractional_epoch_seconds() {
1039 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.9999995"))
1040 .expect("fractional epoch carry");
1041 assert_eq!(
1042 omm.epoch,
1043 OmmEpoch {
1044 year: 2026,
1045 month: 6,
1046 day: 17,
1047 hour: 4,
1048 minute: 32,
1049 second: 53,
1050 microsecond: 0,
1051 }
1052 );
1053 assert!(
1054 encode_kvn(&omm).contains("EPOCH = 2026-06-17T04:32:53.000000"),
1055 "carried epoch must encode with six fractional digits"
1056 );
1057 }
1058
1059 #[test]
1060 fn parse_kvn_carries_continuous_time_fractional_epoch_across_day() {
1061 let omm = parse_kvn(&kvn_with_fields(&[
1062 ("TIME_SYSTEM", "GPS"),
1063 ("EPOCH", "2026-06-17T23:59:59.9999995"),
1064 ]))
1065 .expect("continuous-time fractional epoch carry");
1066 assert_eq!(
1067 omm.epoch,
1068 OmmEpoch {
1069 year: 2026,
1070 month: 6,
1071 day: 18,
1072 hour: 0,
1073 minute: 0,
1074 second: 0,
1075 microsecond: 0,
1076 }
1077 );
1078 }
1079
1080 #[test]
1081 fn parse_kvn_carries_continuous_time_fractional_epoch_across_year() {
1082 let ordinary = parse_kvn(&kvn_with_fields(&[
1083 ("TIME_SYSTEM", "GPS"),
1084 ("EPOCH", "2026-12-31T23:59:58.123456"),
1085 ]))
1086 .expect("ordinary continuous-time epoch");
1087 assert_eq!(
1088 ordinary.epoch,
1089 OmmEpoch {
1090 year: 2026,
1091 month: 12,
1092 day: 31,
1093 hour: 23,
1094 minute: 59,
1095 second: 58,
1096 microsecond: 123_456,
1097 }
1098 );
1099 assert!(
1100 encode_kvn(&ordinary).contains("EPOCH = 2026-12-31T23:59:58.123456"),
1101 "ordinary epoch must encode unchanged"
1102 );
1103
1104 let carried = parse_kvn(&kvn_with_fields(&[
1105 ("TIME_SYSTEM", "GPS"),
1106 ("EPOCH", "2026-12-31T23:59:59.9999995"),
1107 ]))
1108 .expect("continuous-time fractional epoch carry across year");
1109 assert_eq!(
1110 carried.epoch,
1111 OmmEpoch {
1112 year: 2027,
1113 month: 1,
1114 day: 1,
1115 hour: 0,
1116 minute: 0,
1117 second: 0,
1118 microsecond: 0,
1119 }
1120 );
1121 assert!(
1122 encode_kvn(&carried).contains("EPOCH = 2027-01-01T00:00:00.000000"),
1123 "carried epoch must encode with the next year"
1124 );
1125 }
1126
1127 #[test]
1128 fn kvn_round_trips_through_struct() {
1129 let omm = parse_kvn(ISS_KVN).unwrap();
1130 let reparsed = parse_kvn(&encode_kvn(&omm)).unwrap();
1131 assert_eq!(omm, reparsed);
1132 }
1133
1134 #[test]
1135 fn xml_matches_kvn_orbital_content() {
1136 let kvn = parse_kvn(ISS_KVN).unwrap();
1137 let xml = parse_xml(ISS_XML).unwrap();
1138 assert_eq!(canonical(&kvn), canonical(&xml));
1139 }
1140
1141 #[test]
1142 fn xml_round_trips_through_struct() {
1143 let omm = parse_xml(ISS_XML).unwrap();
1144 let reparsed = parse_xml(&encode_xml(&omm)).unwrap();
1145 assert_eq!(omm, reparsed);
1146 }
1147
1148 #[test]
1149 fn parse_kvn_rejects_xml_illegal_text_controls() {
1150 let err = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\u{0005}SGP4"))
1151 .expect_err("XML-illegal control characters must not enter OMM text fields");
1152 assert_eq!(
1153 err,
1154 OmmError::Field(
1155 "field MEAN_ELEMENT_THEORY contains XML-illegal character U+0005".to_string()
1156 )
1157 );
1158
1159 let omm = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\tSGP4"))
1160 .expect("XML-legal text control must remain valid");
1161 let reparsed = parse_xml(&encode_xml(&omm)).expect("encoded OMM must remain valid XML");
1162 assert_eq!(omm, reparsed);
1163 }
1164
1165 #[test]
1166 fn xml_round_trip_preserves_carriage_returns_in_text_values() {
1167 for value in ["SGP\rSGP4", "SGP\r\nSGP4"] {
1168 let mut omm = parse_kvn(ISS_KVN).expect("base OMM must parse");
1169 omm.mean_element_theory = Some(value.to_string());
1170 let encoded = encode_xml(&omm);
1171 assert!(encoded.contains("
"));
1172 assert!(!encoded.contains('\r'));
1173 let reparsed = parse_xml(&encoded).expect("encoded OMM must remain valid XML");
1174 assert_eq!(omm.mean_element_theory, reparsed.mean_element_theory);
1175 assert_eq!(omm, reparsed);
1176 }
1177 }
1178
1179 #[test]
1180 fn json_matches_kvn_orbital_content() {
1181 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1182 let kvn = parse_kvn(ISS_KVN).unwrap();
1183 let json = parse_json(ISS_JSON).unwrap();
1184 assert_eq!(canonical(&kvn), canonical(&json));
1185 }
1186
1187 #[test]
1188 fn json_round_trips_through_struct() {
1189 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1190 let omm = parse_json(ISS_JSON).unwrap();
1191 let reparsed = parse_json(&encode_json(&omm)).unwrap();
1192 assert_eq!(omm, reparsed);
1193 }
1194
1195 #[test]
1196 fn parse_auto_detects_encoding() {
1197 let from_kvn = parse(ISS_KVN).unwrap();
1198 let from_xml = parse(ISS_XML).unwrap();
1199 assert_eq!(parse_kvn(ISS_KVN).unwrap(), from_kvn);
1200 assert_eq!(parse_xml(ISS_XML).unwrap(), from_xml);
1201 assert_eq!(canonical(&from_kvn), canonical(&from_xml));
1202 }
1203
1204 #[test]
1205 fn parse_auto_detects_json_array() {
1206 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1207 assert_eq!(parse(ISS_JSON).unwrap(), parse_json(ISS_JSON).unwrap());
1209 }
1210
1211 #[test]
1212 fn parse_json_array_skips_malformed_objects_and_counts_them() {
1213 let good = |norad: u32, id: &str| {
1218 format!(
1219 r#"{{"OBJECT_NAME":"SAT","OBJECT_ID":"{id}","EPOCH":"2026-06-17T04:32:52.099296","MEAN_MOTION":15.49273435,"ECCENTRICITY":0.0004737,"INCLINATION":51.6332,"RA_OF_ASC_NODE":300.0813,"ARG_OF_PERICENTER":195.1146,"MEAN_ANOMALY":164.9702,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":{norad},"ELEMENT_SET_NO":999,"REV_AT_EPOCH":57175,"BSTAR":0.00017172,"MEAN_MOTION_DOT":9.113e-5,"MEAN_MOTION_DDOT":0}}"#
1220 )
1221 };
1222 let text = format!(
1223 "[{}, \"not an object\", {{\"OBJECT_NAME\":\"BROKEN\",\"NORAD_CAT_ID\":99999}}, {}]",
1224 good(25544, "1998-067A"),
1225 good(25545, "1998-067B"),
1226 );
1227
1228 let result = parse_json_array(&text).expect("array with bad entries must still parse");
1229 assert_eq!(result.skipped, 2, "the string and the malformed object");
1230 let norads: Vec<u32> = result.omms.iter().map(|o| o.norad_cat_id).collect();
1231 assert_eq!(norads, vec![25544, 25545], "both good OMMs must survive");
1232 }
1233
1234 #[test]
1235 fn bstar_quantizes_onto_assumed_decimal_grid() {
1236 let omm = parse_kvn(ISS_KVN).unwrap();
1239 let es = omm.to_element_set().expect("valid OMM bridge");
1240 assert_eq!(es.bstar, 0.17172 * 10.0_f64.powi(-3));
1241 assert_ne!(es.bstar, omm.bstar);
1242 }
1243
1244 #[test]
1245 fn to_element_set_rejects_invalid_bridge_fields() {
1246 let mut omm = parse_kvn(ISS_KVN).unwrap();
1247 omm.mean_motion = f64::NAN;
1248 assert_eq!(
1249 omm.to_element_set(),
1250 Err(OmmError::InvalidField {
1251 field: "mean_motion",
1252 kind: OmmInputErrorKind::NonFinite
1253 })
1254 );
1255
1256 let mut omm = parse_kvn(ISS_KVN).unwrap();
1257 omm.eccentricity = 1.0;
1258 assert_eq!(
1259 omm.to_element_set(),
1260 Err(OmmError::InvalidField {
1261 field: "eccentricity",
1262 kind: OmmInputErrorKind::OutOfRange
1263 })
1264 );
1265 }
1266
1267 #[test]
1268 fn from_omm_preserves_epoch_year_outside_tle_pivot_range() {
1269 let omm = parse_kvn(&kvn_with_field("EPOCH", "2057-01-01T00:00:00.000000"))
1270 .expect("future OMM epoch");
1271 let sat = Satellite::from_omm(&omm).expect("OMM with full-year epoch must initialize");
1272
1273 let epoch = sat.epoch_jd();
1274 let actual_jd = epoch.0 + epoch.1;
1275 let expected_jd = crate::astro::time::scales::julian_day_number(2057, 1, 1) as f64 - 0.5;
1276 let aliased_1957_jd =
1277 crate::astro::time::scales::julian_day_number(1957, 1, 1) as f64 - 0.5;
1278
1279 assert!(
1280 (actual_jd - expected_jd).abs() < 1.0e-9,
1281 "OMM epoch JD {actual_jd} must match the true 2057 epoch {expected_jd}",
1282 );
1283 assert!(
1284 (actual_jd - aliased_1957_jd).abs() > 36_000.0,
1285 "OMM epoch JD {actual_jd} must not alias to 1957 {aliased_1957_jd}",
1286 );
1287 }
1288
1289 #[test]
1290 fn from_omm_uses_parser_rounded_year_end_epoch_directly() {
1291 for (epoch, expected_year) in [
1292 ("2021-12-31T23:59:59.9999995", 2022),
1293 ("2020-12-31T23:59:59.9999995", 2021),
1294 ] {
1295 let omm = parse_kvn(&kvn_with_field("EPOCH", epoch)).expect("year-end OMM epoch");
1296 assert_eq!(omm.epoch.year, expected_year);
1297 assert_eq!(omm.epoch.month, 1);
1298 assert_eq!(omm.epoch.day, 1);
1299 assert_eq!(omm.epoch.hour, 0);
1300 assert_eq!(omm.epoch.minute, 0);
1301 assert_eq!(omm.epoch.second, 0);
1302 assert_eq!(omm.epoch.microsecond, 0);
1303
1304 let sat = Satellite::from_omm(&omm).expect("rounded year-end OMM must initialize");
1305 let epoch_jd = sat.epoch_jd();
1306 let actual_jd = epoch_jd.0 + epoch_jd.1;
1307 let expected_jd =
1308 crate::astro::time::scales::julian_day_number(expected_year, 1, 1) as f64 - 0.5;
1309
1310 assert!(
1311 (actual_jd - expected_jd).abs() < 1.0e-9,
1312 "{epoch} carried to JD {actual_jd}, expected {expected_jd}",
1313 );
1314 }
1315 }
1316
1317 #[test]
1318 fn from_omm_rejects_invalid_sgp4_element_fields() {
1319 let mut omm = parse_kvn(ISS_KVN).unwrap();
1320 omm.mean_motion = f64::NAN;
1321 let err = Satellite::from_omm(&omm).expect_err("non-finite mean motion must error");
1322 assert_eq!(
1323 err,
1324 Sgp4Error::InvalidInput {
1325 field: "mean_motion",
1326 kind: crate::astro::sgp4::Sgp4InputErrorKind::NonFinite,
1327 }
1328 );
1329
1330 let mut omm = parse_kvn(ISS_KVN).unwrap();
1331 omm.eccentricity = 1.0;
1332 let err = Satellite::from_omm(&omm).expect_err("eccentricity >= 1 must error");
1333 assert_eq!(
1334 err,
1335 Sgp4Error::InvalidInput {
1336 field: "eccentricity",
1337 kind: crate::astro::sgp4::Sgp4InputErrorKind::OutOfRange,
1338 }
1339 );
1340 }
1341}