1use crate::astro::sgp4::{
30 self, ElementSet, Error as Sgp4Error, JulianDate, Satellite, Sgp4InputErrorKind,
31};
32use crate::astro::tle;
33use crate::astro::xml;
34use crate::validate;
35use roxmltree::Document;
36use std::fmt::{self, Write as _};
37
38const FIELD_TAGS: &[&str] = &[
43 "CREATION_DATE",
44 "ORIGINATOR",
45 "OBJECT_NAME",
46 "OBJECT_ID",
47 "CENTER_NAME",
48 "REF_FRAME",
49 "TIME_SYSTEM",
50 "MEAN_ELEMENT_THEORY",
51 "EPOCH",
52 "MEAN_MOTION",
53 "ECCENTRICITY",
54 "INCLINATION",
55 "RA_OF_ASC_NODE",
56 "ARG_OF_PERICENTER",
57 "MEAN_ANOMALY",
58 "EPHEMERIS_TYPE",
59 "CLASSIFICATION_TYPE",
60 "NORAD_CAT_ID",
61 "ELEMENT_SET_NO",
62 "REV_AT_EPOCH",
63 "BSTAR",
64 "MEAN_MOTION_DOT",
65 "MEAN_MOTION_DDOT",
66];
67
68#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
72pub struct OmmEpoch {
73 pub year: i32,
74 pub month: u32,
75 pub day: u32,
76 pub hour: u32,
77 pub minute: u32,
78 pub second: u32,
79 pub microsecond: u32,
81 #[serde(default, skip_serializing_if = "is_zero_u32")]
85 pub femtosecond: u32,
86}
87
88#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
96pub struct Omm {
97 pub ccsds_omm_vers: String,
99 pub creation_date: Option<String>,
100 pub originator: Option<String>,
101 pub object_name: Option<String>,
102 pub object_id: Option<String>,
104 pub center_name: Option<String>,
105 pub ref_frame: Option<String>,
106 pub time_system: Option<String>,
107 pub mean_element_theory: Option<String>,
108
109 pub epoch: OmmEpoch,
111 pub mean_motion: f64,
113 pub eccentricity: f64,
115 pub inclination_deg: f64,
117 pub ra_of_asc_node_deg: f64,
119 pub arg_of_pericenter_deg: f64,
121 pub mean_anomaly_deg: f64,
123
124 pub ephemeris_type: i32,
126 pub classification_type: String,
127 pub norad_cat_id: u32,
128 pub element_set_no: i32,
129 pub rev_at_epoch: i64,
130 pub bstar: f64,
132 pub mean_motion_dot: f64,
134 pub mean_motion_ddot: f64,
136 #[serde(default, skip)]
148 pub exact_sgp4_epoch: Option<JulianDate>,
149 #[serde(default = "default_quantize_tle_derived_fields", skip_serializing)]
158 pub quantize_tle_derived_fields: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum OmmError {
164 MissingField(&'static str),
166 InvalidField {
168 field: &'static str,
169 kind: OmmInputErrorKind,
170 },
171 Field(String),
173 Epoch(String),
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum OmmInputErrorKind {
180 Missing,
182 NonFinite,
184 FloatParse,
186 IntParse,
188 NotPositive,
190 Negative,
192 OutOfRange,
194 InvalidCivilDate,
196 InvalidCivilTime,
198}
199
200impl fmt::Display for OmmInputErrorKind {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 let label = match self {
203 Self::Missing => "missing",
204 Self::NonFinite => "not finite",
205 Self::FloatParse => "invalid float",
206 Self::IntParse => "invalid integer",
207 Self::NotPositive => "not positive",
208 Self::Negative => "negative",
209 Self::OutOfRange => "out of range",
210 Self::InvalidCivilDate => "invalid civil date",
211 Self::InvalidCivilTime => "invalid civil time",
212 };
213 f.write_str(label)
214 }
215}
216
217impl From<&validate::FieldError> for OmmInputErrorKind {
218 fn from(error: &validate::FieldError) -> Self {
219 match error {
220 validate::FieldError::Missing { .. } => Self::Missing,
221 validate::FieldError::NonFinite { .. } => Self::NonFinite,
222 validate::FieldError::FloatParse { .. } => Self::FloatParse,
223 validate::FieldError::IntParse { .. } => Self::IntParse,
224 validate::FieldError::NotPositive { .. } => Self::NotPositive,
225 validate::FieldError::Negative { .. } => Self::Negative,
226 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
227 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
228 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
229 }
230 }
231}
232
233impl fmt::Display for OmmError {
234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235 match self {
236 OmmError::MissingField(name) => write!(f, "OMM missing required field {name}"),
237 OmmError::InvalidField { field, kind } => {
238 write!(f, "invalid OMM field {field}: {kind}")
239 }
240 OmmError::Field(msg) => write!(f, "OMM field error: {msg}"),
241 OmmError::Epoch(msg) => write!(f, "OMM epoch error: {msg}"),
242 }
243 }
244}
245
246impl std::error::Error for OmmError {}
247
248pub fn parse_kvn(text: &str) -> Result<Omm, OmmError> {
256 let map = crate::format::kvn::FieldMap::parse(text);
257 Omm::from_field_map(&map)
258}
259
260pub fn encode_kvn(omm: &Omm) -> String {
266 let mut out = String::new();
267 let header = crate::astro::ndm::NdmHeader {
268 vers: omm.ccsds_omm_vers.clone(),
269 creation_date: omm.creation_date.clone(),
270 originator: omm.originator.clone(),
271 };
272 for line in header.write_kvn("CCSDS_OMM_VERS") {
273 out.push_str(&line);
274 out.push('\n');
275 }
276
277 let mut kv = |key: &str, value: &str| {
278 out.push_str(key);
279 out.push_str(" = ");
280 out.push_str(value);
281 out.push('\n');
282 };
283
284 kv("OBJECT_NAME", omm.object_name.as_deref().unwrap_or(""));
285 kv("OBJECT_ID", omm.object_id.as_deref().unwrap_or(""));
286 kv("CENTER_NAME", omm.center_name.as_deref().unwrap_or(""));
287 kv("REF_FRAME", omm.ref_frame.as_deref().unwrap_or(""));
288 kv("TIME_SYSTEM", omm.time_system.as_deref().unwrap_or(""));
289 kv(
290 "MEAN_ELEMENT_THEORY",
291 omm.mean_element_theory.as_deref().unwrap_or(""),
292 );
293 kv("EPOCH", &omm.epoch.to_iso8601());
294 kv("MEAN_MOTION", &fmt_num(omm.mean_motion));
295 kv("ECCENTRICITY", &fmt_num(omm.eccentricity));
296 kv("INCLINATION", &fmt_num(omm.inclination_deg));
297 kv("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg));
298 kv("ARG_OF_PERICENTER", &fmt_num(omm.arg_of_pericenter_deg));
299 kv("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg));
300 kv("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string());
301 kv("CLASSIFICATION_TYPE", &omm.classification_type);
302 kv("NORAD_CAT_ID", &omm.norad_cat_id.to_string());
303 kv("ELEMENT_SET_NO", &omm.element_set_no.to_string());
304 kv("REV_AT_EPOCH", &omm.rev_at_epoch.to_string());
305 kv("BSTAR", &fmt_num(omm.bstar));
306 kv("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot));
307 kv("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot));
308 out
309}
310
311pub fn parse_xml(text: &str) -> Result<Omm, OmmError> {
322 let doc = Document::parse(text).map_err(|e| OmmError::Field(format!("malformed XML: {e}")))?;
323 let mut fields: Vec<(String, String)> = Vec::new();
324
325 if let Some(omm_el) = doc
326 .descendants()
327 .find(|n| n.is_element() && n.tag_name().name() == "omm")
328 {
329 if let Some(version) = omm_el.attribute("version") {
330 fields.push(("CCSDS_OMM_VERS".to_string(), version.trim().to_string()));
331 }
332 }
333
334 for node in doc.descendants().filter(roxmltree::Node::is_element) {
335 let name = node.tag_name().name();
336 if FIELD_TAGS.contains(&name) {
337 let value = node.text().unwrap_or("").trim().to_string();
338 fields.push((name.to_string(), value));
339 }
340 }
341
342 let map = crate::format::kvn::FieldMap::from_pairs(fields);
343 Omm::from_field_map(&map)
344}
345
346pub fn encode_xml(omm: &Omm) -> String {
350 fn elem(name: &str, value: &str) -> String {
351 format!("<{name}>{value}</{name}>")
352 }
353 let opt = |value: &Option<String>| xml::escape_opt(value);
354
355 let mut s = String::new();
356 s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
357 s.push_str("<ndm>\n");
358 let _ = writeln!(
359 s,
360 "<omm id=\"CCSDS_OMM_VERS\" version=\"{}\">",
361 xml::escape(&omm.ccsds_omm_vers)
362 );
363
364 s.push_str("<header>");
365 s.push_str(&elem("CREATION_DATE", &opt(&omm.creation_date)));
366 s.push_str(&elem("ORIGINATOR", &opt(&omm.originator)));
367 s.push_str("</header>\n");
368
369 s.push_str("<body><segment>\n<metadata>");
370 s.push_str(&elem("OBJECT_NAME", &opt(&omm.object_name)));
371 s.push_str(&elem("OBJECT_ID", &opt(&omm.object_id)));
372 s.push_str(&elem("CENTER_NAME", &opt(&omm.center_name)));
373 s.push_str(&elem("REF_FRAME", &opt(&omm.ref_frame)));
374 s.push_str(&elem("TIME_SYSTEM", &opt(&omm.time_system)));
375 s.push_str(&elem("MEAN_ELEMENT_THEORY", &opt(&omm.mean_element_theory)));
376 s.push_str("</metadata>\n<data>\n<meanElements>");
377
378 s.push_str(&elem("EPOCH", &omm.epoch.to_iso8601()));
379 s.push_str(&elem("MEAN_MOTION", &fmt_num(omm.mean_motion)));
380 s.push_str(&elem("ECCENTRICITY", &fmt_num(omm.eccentricity)));
381 s.push_str(&elem("INCLINATION", &fmt_num(omm.inclination_deg)));
382 s.push_str(&elem("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg)));
383 s.push_str(&elem(
384 "ARG_OF_PERICENTER",
385 &fmt_num(omm.arg_of_pericenter_deg),
386 ));
387 s.push_str(&elem("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg)));
388 s.push_str("</meanElements>\n<tleParameters>");
389
390 s.push_str(&elem("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string()));
391 s.push_str(&elem(
392 "CLASSIFICATION_TYPE",
393 &xml::escape(&omm.classification_type),
394 ));
395 s.push_str(&elem("NORAD_CAT_ID", &omm.norad_cat_id.to_string()));
396 s.push_str(&elem("ELEMENT_SET_NO", &omm.element_set_no.to_string()));
397 s.push_str(&elem("REV_AT_EPOCH", &omm.rev_at_epoch.to_string()));
398 s.push_str(&elem("BSTAR", &fmt_num(omm.bstar)));
399 s.push_str(&elem("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot)));
400 s.push_str(&elem("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot)));
401 s.push_str("</tleParameters>\n</data>\n</segment></body>\n</omm>\n</ndm>\n");
402 s
403}
404
405pub fn parse_json(text: &str) -> Result<Omm, OmmError> {
415 use serde_json::Value;
416
417 let value: Value =
418 serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
419 let object = match &value {
420 Value::Array(items) => items
421 .first()
422 .ok_or_else(|| OmmError::Field("empty JSON array".to_string()))?,
423 Value::Object(_) => &value,
424 _ => {
425 return Err(OmmError::Field(
426 "expected a JSON object or array".to_string(),
427 ))
428 }
429 };
430 omm_from_json_value(object)
431}
432
433fn omm_from_json_value(object: &serde_json::Value) -> Result<Omm, OmmError> {
436 let map = object
437 .as_object()
438 .ok_or_else(|| OmmError::Field("expected a JSON object".to_string()))?;
439 let fields: Vec<(String, String)> = map
440 .iter()
441 .map(|(key, value)| (key.clone(), json_scalar_to_string(value)))
442 .collect();
443 let map = crate::format::kvn::FieldMap::from_pairs(fields);
444 Omm::from_field_map(&map)
445}
446
447#[derive(Debug, Clone, PartialEq)]
450pub struct OmmArray {
451 pub omms: Vec<Omm>,
453 pub skipped: usize,
460}
461
462pub fn parse_json_array(text: &str) -> Result<OmmArray, OmmError> {
472 use serde_json::Value;
473
474 let value: Value =
475 serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
476 let items: &[Value] = match &value {
477 Value::Array(items) => items.as_slice(),
478 Value::Object(_) => std::slice::from_ref(&value),
479 _ => {
480 return Err(OmmError::Field(
481 "expected a JSON object or array".to_string(),
482 ))
483 }
484 };
485
486 let mut omms = Vec::with_capacity(items.len());
487 let mut skipped = 0usize;
488 for object in items {
489 match omm_from_json_value(object) {
490 Ok(omm) => omms.push(omm),
491 Err(_) => skipped += 1,
492 }
493 }
494 Ok(OmmArray { omms, skipped })
495}
496
497pub fn encode_json(omm: &Omm) -> String {
503 use serde_json::{Map, Number, Value};
504
505 let num = |x: f64| Number::from_f64(x).map_or(Value::Null, Value::Number);
506 let opt = |value: &Option<String>| value.clone().map_or(Value::Null, Value::String);
507
508 let mut map = Map::new();
509 map.insert(
510 "CCSDS_OMM_VERS".into(),
511 Value::String(omm.ccsds_omm_vers.clone()),
512 );
513 map.insert("CREATION_DATE".into(), opt(&omm.creation_date));
514 map.insert("ORIGINATOR".into(), opt(&omm.originator));
515 map.insert("OBJECT_NAME".into(), opt(&omm.object_name));
516 map.insert("OBJECT_ID".into(), opt(&omm.object_id));
517 map.insert("CENTER_NAME".into(), opt(&omm.center_name));
518 map.insert("REF_FRAME".into(), opt(&omm.ref_frame));
519 map.insert("TIME_SYSTEM".into(), opt(&omm.time_system));
520 map.insert("MEAN_ELEMENT_THEORY".into(), opt(&omm.mean_element_theory));
521 map.insert("EPOCH".into(), Value::String(omm.epoch.to_iso8601()));
522 map.insert("MEAN_MOTION".into(), num(omm.mean_motion));
523 map.insert("ECCENTRICITY".into(), num(omm.eccentricity));
524 map.insert("INCLINATION".into(), num(omm.inclination_deg));
525 map.insert("RA_OF_ASC_NODE".into(), num(omm.ra_of_asc_node_deg));
526 map.insert("ARG_OF_PERICENTER".into(), num(omm.arg_of_pericenter_deg));
527 map.insert("MEAN_ANOMALY".into(), num(omm.mean_anomaly_deg));
528 map.insert(
529 "EPHEMERIS_TYPE".into(),
530 Value::Number(omm.ephemeris_type.into()),
531 );
532 map.insert(
533 "CLASSIFICATION_TYPE".into(),
534 Value::String(omm.classification_type.clone()),
535 );
536 map.insert(
537 "NORAD_CAT_ID".into(),
538 Value::Number(omm.norad_cat_id.into()),
539 );
540 map.insert(
541 "ELEMENT_SET_NO".into(),
542 Value::Number(omm.element_set_no.into()),
543 );
544 map.insert(
545 "REV_AT_EPOCH".into(),
546 Value::Number(omm.rev_at_epoch.into()),
547 );
548 map.insert("BSTAR".into(), num(omm.bstar));
549 map.insert("MEAN_MOTION_DOT".into(), num(omm.mean_motion_dot));
550 map.insert("MEAN_MOTION_DDOT".into(), num(omm.mean_motion_ddot));
551 Value::Object(map).to_string()
552}
553
554fn json_scalar_to_string(value: &serde_json::Value) -> String {
557 use serde_json::Value;
558 match value {
559 Value::String(s) => s.clone(),
560 Value::Number(n) => n.to_string(),
561 Value::Bool(b) => b.to_string(),
562 Value::Null => String::new(),
563 other => other.to_string(),
564 }
565}
566
567pub fn parse(text: &str) -> Result<Omm, OmmError> {
574 match text.trim_start().chars().next() {
575 Some('<') => parse_xml(text),
576 Some('{') | Some('[') => parse_json_detected(text),
577 _ => parse_kvn(text),
578 }
579}
580
581pub fn parse_epoch(text: &str) -> Result<OmmEpoch, OmmError> {
592 OmmEpoch::parse(text, validate::CivilSecondPolicy::UtcLike)
593}
594
595fn parse_json_detected(text: &str) -> Result<Omm, OmmError> {
596 parse_json(text)
597}
598
599impl Omm {
602 pub(crate) fn from_field_map(map: &crate::format::kvn::FieldMap) -> Result<Omm, OmmError> {
606 let get = |key: &str| map.get(key);
607
608 let time_system = xml_text(get("TIME_SYSTEM"), "TIME_SYSTEM")?;
609 let epoch = OmmEpoch::parse(
610 get("EPOCH").ok_or(OmmError::MissingField("EPOCH"))?,
611 omm_civil_second_policy(time_system.as_deref()),
612 )?;
613
614 Ok(Omm {
615 ccsds_omm_vers: xml_text_or_default(get("CCSDS_OMM_VERS"), "CCSDS_OMM_VERS", "2.0")?,
616 creation_date: xml_text(get("CREATION_DATE"), "CREATION_DATE")?,
617 originator: xml_text(get("ORIGINATOR"), "ORIGINATOR")?,
618 object_name: xml_text(get("OBJECT_NAME"), "OBJECT_NAME")?,
619 object_id: xml_text(get("OBJECT_ID"), "OBJECT_ID")?,
620 center_name: xml_text(get("CENTER_NAME"), "CENTER_NAME")?,
621 ref_frame: xml_text(get("REF_FRAME"), "REF_FRAME")?,
622 time_system,
623 mean_element_theory: xml_text(get("MEAN_ELEMENT_THEORY"), "MEAN_ELEMENT_THEORY")?,
624 epoch,
625 mean_motion: req_num(get("MEAN_MOTION"), "MEAN_MOTION")?,
626 eccentricity: req_num(get("ECCENTRICITY"), "ECCENTRICITY")?,
627 inclination_deg: req_num(get("INCLINATION"), "INCLINATION")?,
628 ra_of_asc_node_deg: req_num(get("RA_OF_ASC_NODE"), "RA_OF_ASC_NODE")?,
629 arg_of_pericenter_deg: req_num(get("ARG_OF_PERICENTER"), "ARG_OF_PERICENTER")?,
630 mean_anomaly_deg: req_num(get("MEAN_ANOMALY"), "MEAN_ANOMALY")?,
631 ephemeris_type: opt_int(get("EPHEMERIS_TYPE"), "EPHEMERIS_TYPE")?.unwrap_or(0),
632 classification_type: xml_text_or_default(
633 get("CLASSIFICATION_TYPE"),
634 "CLASSIFICATION_TYPE",
635 "U",
636 )?,
637 norad_cat_id: req_int(get("NORAD_CAT_ID"), "NORAD_CAT_ID")?,
638 element_set_no: opt_int(get("ELEMENT_SET_NO"), "ELEMENT_SET_NO")?.unwrap_or(999),
639 rev_at_epoch: opt_int(get("REV_AT_EPOCH"), "REV_AT_EPOCH")?.unwrap_or(0),
640 bstar: req_num(get("BSTAR"), "BSTAR")?,
641 mean_motion_dot: req_num(get("MEAN_MOTION_DOT"), "MEAN_MOTION_DOT")?,
642 mean_motion_ddot: req_num(get("MEAN_MOTION_DDOT"), "MEAN_MOTION_DDOT")?,
643 exact_sgp4_epoch: None,
644 quantize_tle_derived_fields: true,
645 })
646 }
647}
648
649fn xml_text(value: Option<&str>, field: &'static str) -> Result<Option<String>, OmmError> {
650 value
651 .map(|value| xml_text_value(value, field).map(str::to_string))
652 .transpose()
653}
654
655fn xml_text_or_default(
656 value: Option<&str>,
657 field: &'static str,
658 default: &'static str,
659) -> Result<String, OmmError> {
660 xml_text_value(value.unwrap_or(default), field).map(str::to_string)
661}
662
663fn xml_text_value<'a>(value: &'a str, field: &'static str) -> Result<&'a str, OmmError> {
664 if let Some(ch) = xml::first_illegal_xml_1_0_char(value) {
665 return Err(OmmError::Field(format!(
666 "field {field} contains XML-illegal character U+{:04X}",
667 ch as u32
668 )));
669 }
670 Ok(value)
671}
672
673impl Omm {
676 pub fn to_element_set(&self) -> Result<ElementSet, OmmError> {
685 validate_omm_bridge(self)?;
686 let bstar = if self.quantize_tle_derived_fields {
687 tle::assumed_decimal_quantize(self.bstar)
688 } else {
689 self.bstar
690 };
691 let mean_motion_double_dot = if self.quantize_tle_derived_fields {
692 tle::assumed_decimal_quantize(self.mean_motion_ddot)
693 } else {
694 self.mean_motion_ddot
695 };
696 Ok(ElementSet {
697 epoch: self
698 .exact_sgp4_epoch
699 .unwrap_or_else(|| self.epoch.sgp4_julian_date()),
700 bstar,
701 mean_motion_dot: self.mean_motion_dot,
702 mean_motion_double_dot,
703 eccentricity: self.eccentricity,
704 argument_of_perigee_deg: self.arg_of_pericenter_deg,
705 inclination_deg: self.inclination_deg,
706 mean_anomaly_deg: self.mean_anomaly_deg,
707 mean_motion_rev_per_day: self.mean_motion,
708 right_ascension_deg: self.ra_of_asc_node_deg,
709 catalog_number: self.norad_cat_id,
710 })
711 }
712}
713
714impl Satellite {
715 pub fn from_omm(omm: &Omm) -> Result<Self, Sgp4Error> {
720 let elements = omm.to_element_set().map_err(map_omm_bridge_to_sgp4)?;
721 Self::from_elements(&elements)
722 }
723}
724
725fn validate_omm_bridge(omm: &Omm) -> Result<(), OmmError> {
726 if omm.epoch.microsecond >= 1_000_000 {
727 return Err(OmmError::InvalidField {
728 field: "epoch.microsecond",
729 kind: OmmInputErrorKind::OutOfRange,
730 });
731 }
732 if omm.epoch.femtosecond >= 1_000_000_000 {
733 return Err(OmmError::InvalidField {
734 field: "epoch.femtosecond",
735 kind: OmmInputErrorKind::OutOfRange,
736 });
737 }
738 validate::finite_positive(omm.mean_motion, "mean_motion").map_err(map_omm_field_error)?;
739 validate::finite_in_range_exclusive_upper(omm.eccentricity, 0.0, 1.0, "eccentricity")
740 .map_err(map_omm_field_error)?;
741 validate::finite(omm.inclination_deg, "inclination_deg").map_err(map_omm_field_error)?;
742 validate::finite(omm.ra_of_asc_node_deg, "ra_of_asc_node_deg").map_err(map_omm_field_error)?;
743 validate::finite(omm.arg_of_pericenter_deg, "arg_of_pericenter_deg")
744 .map_err(map_omm_field_error)?;
745 validate::finite(omm.mean_anomaly_deg, "mean_anomaly_deg").map_err(map_omm_field_error)?;
746 validate::finite(omm.bstar, "bstar").map_err(map_omm_field_error)?;
747 validate::finite(omm.mean_motion_dot, "mean_motion_dot").map_err(map_omm_field_error)?;
748 validate::finite(omm.mean_motion_ddot, "mean_motion_ddot").map_err(map_omm_field_error)?;
749 Ok(())
750}
751
752fn map_omm_bridge_to_sgp4(error: OmmError) -> Sgp4Error {
753 match error {
754 OmmError::InvalidField { field, kind } => Sgp4Error::InvalidInput {
755 field,
756 kind: match kind {
757 OmmInputErrorKind::NonFinite => Sgp4InputErrorKind::NonFinite,
758 OmmInputErrorKind::NotPositive => Sgp4InputErrorKind::NotPositive,
759 OmmInputErrorKind::Negative => Sgp4InputErrorKind::Negative,
760 OmmInputErrorKind::OutOfRange => Sgp4InputErrorKind::OutOfRange,
761 OmmInputErrorKind::Missing => Sgp4InputErrorKind::Missing,
762 OmmInputErrorKind::FloatParse => Sgp4InputErrorKind::FloatParse,
763 OmmInputErrorKind::IntParse => Sgp4InputErrorKind::IntParse,
764 OmmInputErrorKind::InvalidCivilDate => Sgp4InputErrorKind::InvalidCivilDate,
765 OmmInputErrorKind::InvalidCivilTime => Sgp4InputErrorKind::InvalidCivilTime,
766 },
767 },
768 other => Sgp4Error::InvalidTle(other.to_string()),
769 }
770}
771
772impl OmmEpoch {
775 fn parse(text: &str, second_policy: validate::CivilSecondPolicy) -> Result<OmmEpoch, OmmError> {
778 let e = crate::astro::ndm::NdmEpoch::parse(text, second_policy)
779 .map_err(|err| map_omm_epoch_field_error(err, text.trim()))?;
780 Ok(OmmEpoch {
781 year: e.year,
782 month: e.month,
783 day: e.day,
784 hour: e.hour,
785 minute: e.minute,
786 second: e.second,
787 microsecond: e.microsecond,
788 femtosecond: e.femtosecond,
789 })
790 }
791
792 fn sgp4_julian_date(&self) -> sgp4::JulianDate {
795 sgp4::sgp4_julian_date_from_calendar(
796 self.year,
797 self.month as i32,
798 self.day as i32,
799 self.hour as i32,
800 self.minute as i32,
801 self.second as f64
802 + self.microsecond as f64 / 1_000_000.0
803 + self.femtosecond as f64 / 1_000_000_000_000_000.0,
804 )
805 }
806
807 pub(crate) fn from_sgp4_julian_date(epoch: JulianDate) -> Self {
808 let (mut jd_midnight, mut day_fraction) = if (epoch.0.fract().abs() - 0.5).abs() < 1.0e-9 {
809 (epoch.0, epoch.1)
810 } else if epoch.1 >= 0.5 {
811 (epoch.0 + 0.5, epoch.1 - 0.5)
812 } else {
813 (epoch.0 - 0.5, epoch.1 + 0.5)
814 };
815 let day_carry = day_fraction.floor();
816 jd_midnight += day_carry;
817 day_fraction -= day_carry;
818 let (year, month, day, hour, minute, second) =
819 crate::astro::time::civil::civil_from_split_julian_date(jd_midnight, day_fraction);
820 let whole_second = second.floor();
821 let subsecond = second - whole_second;
822 let mut femtoseconds = (subsecond * FEMTOSECONDS_PER_SECOND as f64).round() as i128;
823 let mut second = whole_second as u32;
824 if femtoseconds == FEMTOSECONDS_PER_SECOND {
825 second += 1;
826 femtoseconds = 0;
827 }
828 OmmEpoch {
829 year: year as i32,
830 month: month as u32,
831 day: day as u32,
832 hour: hour as u32,
833 minute: minute as u32,
834 second,
835 microsecond: (femtoseconds / FEMTOSECONDS_PER_MICROSECOND) as u32,
836 femtosecond: (femtoseconds % FEMTOSECONDS_PER_MICROSECOND) as u32,
837 }
838 }
839
840 fn to_iso8601(&self) -> String {
844 crate::astro::ndm::NdmEpoch {
845 year: self.year,
846 month: self.month,
847 day: self.day,
848 hour: self.hour,
849 minute: self.minute,
850 second: self.second,
851 microsecond: self.microsecond,
852 femtosecond: self.femtosecond,
853 }
854 .to_iso8601()
855 }
856}
857
858const FEMTOSECONDS_PER_SECOND: i128 = 1_000_000_000_000_000;
859const FEMTOSECONDS_PER_MICROSECOND: i128 = 1_000_000_000;
860
861fn is_zero_u32(value: &u32) -> bool {
862 *value == 0
863}
864
865fn default_quantize_tle_derived_fields() -> bool {
866 true
867}
868
869fn omm_civil_second_policy(time_system: Option<&str>) -> validate::CivilSecondPolicy {
872 let Some(label) = time_system.map(str::trim).filter(|label| !label.is_empty()) else {
873 return validate::CivilSecondPolicy::UtcLike;
874 };
875 if label.eq_ignore_ascii_case("UTC")
876 || label.eq_ignore_ascii_case("GLO")
877 || label.eq_ignore_ascii_case("GLONASS")
878 {
879 validate::CivilSecondPolicy::UtcLike
880 } else {
881 validate::CivilSecondPolicy::Continuous
882 }
883}
884
885fn req_num(value: Option<&str>, field: &'static str) -> Result<f64, OmmError> {
886 let value = value.ok_or(OmmError::MissingField(field))?;
887 parse_num(value, field)
888}
889
890fn parse_num(value: &str, field: &'static str) -> Result<f64, OmmError> {
891 validate::strict_f64(value, field).map_err(map_omm_field_error)
892}
893
894fn req_int<T>(value: Option<&str>, field: &'static str) -> Result<T, OmmError>
895where
896 T: std::str::FromStr,
897{
898 let value = value.ok_or(OmmError::MissingField(field))?;
899 parse_int(value, field)
900}
901
902fn opt_int<T>(value: Option<&str>, field: &'static str) -> Result<Option<T>, OmmError>
903where
904 T: std::str::FromStr,
905{
906 value.map(|v| parse_int(v, field)).transpose()
907}
908
909fn parse_int<T>(value: &str, field: &'static str) -> Result<T, OmmError>
910where
911 T: std::str::FromStr,
912{
913 validate::strict_int::<T>(value, field).map_err(map_omm_field_error)
914}
915
916fn map_omm_field_error(error: validate::FieldError) -> OmmError {
917 OmmError::InvalidField {
918 field: error.field(),
919 kind: OmmInputErrorKind::from(&error),
920 }
921}
922
923fn map_omm_epoch_field_error(error: validate::FieldError, full: &str) -> OmmError {
924 match error {
925 validate::FieldError::Missing { .. }
926 | validate::FieldError::FloatParse { .. }
927 | validate::FieldError::IntParse { .. } => {
928 OmmError::Epoch(format!("invalid seconds in {full:?}"))
929 }
930 _ => map_omm_field_error(error),
931 }
932}
933
934fn fmt_num(value: f64) -> String {
936 format!("{value}")
937}
938
939#[cfg(all(test, sidereon_repo_tests))]
940mod tests {
941 use super::*;
942
943 const ISS_KVN: &str = include_str!("../../tests/fixtures/omm/25544.kvn");
944 const ISS_XML: &str = include_str!("../../tests/fixtures/omm/25544.xml");
945
946 fn canonical(omm: &Omm) -> Omm {
953 Omm {
954 ccsds_omm_vers: String::new(),
955 creation_date: None,
956 originator: None,
957 center_name: None,
958 ref_frame: None,
959 time_system: None,
960 mean_element_theory: None,
961 ..omm.clone()
962 }
963 }
964
965 fn kvn_with_field(field: &str, value: &str) -> String {
966 kvn_with_fields(&[(field, value)])
967 }
968
969 fn kvn_with_fields(fields: &[(&str, &str)]) -> String {
970 ISS_KVN
971 .lines()
972 .map(|line| match line.split_once('=') {
973 Some((key, _)) => fields
974 .iter()
975 .find(|(field, _)| key.trim() == *field)
976 .map_or_else(
977 || line.to_string(),
978 |(field, value)| format!("{field} = {value}"),
979 ),
980 _ => line.to_string(),
981 })
982 .collect::<Vec<_>>()
983 .join("\n")
984 }
985
986 fn kvn_without_field(field: &str) -> String {
987 ISS_KVN
988 .lines()
989 .filter(|line| match line.split_once('=') {
990 Some((key, _)) => key.trim() != field,
991 None => true,
992 })
993 .collect::<Vec<_>>()
994 .join("\n")
995 }
996
997 #[test]
998 fn parses_iss_kvn_fields() {
999 let omm = parse_kvn(ISS_KVN).unwrap();
1000 assert_eq!(omm.ccsds_omm_vers, "2.0");
1001 assert_eq!(omm.object_name.as_deref(), Some("ISS (ZARYA)"));
1002 assert_eq!(omm.object_id.as_deref(), Some("1998-067A"));
1003 assert_eq!(omm.norad_cat_id, 25544);
1004 assert_eq!(omm.mean_motion, 15.49273435);
1005 assert_eq!(omm.eccentricity, 0.0004737);
1006 assert_eq!(omm.inclination_deg, 51.6332);
1007 assert_eq!(omm.bstar, 0.00017172);
1008 assert_eq!(omm.mean_motion_dot, 9.113e-5);
1009 assert_eq!(omm.mean_motion_ddot, 0.0);
1010 assert_eq!(
1011 omm.epoch,
1012 OmmEpoch {
1013 year: 2026,
1014 month: 6,
1015 day: 17,
1016 hour: 4,
1017 minute: 32,
1018 second: 52,
1019 microsecond: 99296,
1020 femtosecond: 0,
1021 }
1022 );
1023 }
1024
1025 #[test]
1026 fn parse_kvn_requires_drag_terms() {
1027 for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
1028 assert_eq!(
1029 parse_kvn(&kvn_without_field(field)),
1030 Err(OmmError::MissingField(field))
1031 );
1032 }
1033 }
1034
1035 #[test]
1036 fn parse_kvn_rejects_non_finite_drag_terms() {
1037 for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
1038 assert_eq!(
1039 parse_kvn(&kvn_with_field(field, "NaN")),
1040 Err(OmmError::InvalidField {
1041 field,
1042 kind: OmmInputErrorKind::NonFinite,
1043 })
1044 );
1045 }
1046 }
1047
1048 #[test]
1049 fn parse_kvn_rejects_negative_norad_catalog_id() {
1050 assert_eq!(
1051 parse_kvn(&kvn_with_field("NORAD_CAT_ID", "-1")),
1052 Err(OmmError::InvalidField {
1053 field: "NORAD_CAT_ID",
1054 kind: OmmInputErrorKind::IntParse,
1055 })
1056 );
1057 }
1058
1059 #[test]
1060 fn parse_kvn_rejects_oversized_norad_catalog_id() {
1061 assert_eq!(
1062 parse_kvn(&kvn_with_field("NORAD_CAT_ID", "4294967296")),
1063 Err(OmmError::InvalidField {
1064 field: "NORAD_CAT_ID",
1065 kind: OmmInputErrorKind::IntParse,
1066 })
1067 );
1068 }
1069
1070 #[test]
1071 fn parse_kvn_rejects_invalid_civil_epoch() {
1072 assert_eq!(
1073 parse_kvn(&kvn_with_field("EPOCH", "2026-02-30T04:32:52.099296")),
1074 Err(OmmError::InvalidField {
1075 field: "civil datetime",
1076 kind: OmmInputErrorKind::InvalidCivilDate,
1077 })
1078 );
1079 assert_eq!(
1080 parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T24:00:00.000000")),
1081 Err(OmmError::InvalidField {
1082 field: "civil datetime",
1083 kind: OmmInputErrorKind::InvalidCivilTime,
1084 })
1085 );
1086 }
1087
1088 #[test]
1089 fn parse_kvn_accepts_utc_leap_second_epoch() {
1090 let omm = parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:60.000000Z"))
1091 .expect("OMM leap-second epoch");
1092 assert_eq!(
1093 omm.epoch,
1094 OmmEpoch {
1095 year: 2016,
1096 month: 12,
1097 day: 31,
1098 hour: 23,
1099 minute: 59,
1100 second: 60,
1101 microsecond: 0,
1102 femtosecond: 0,
1103 }
1104 );
1105 }
1106
1107 #[test]
1108 fn parse_kvn_rejects_gps_time_leap_second_epoch() {
1109 assert_eq!(
1110 parse_kvn(&kvn_with_fields(&[
1111 ("TIME_SYSTEM", "GPS"),
1112 ("EPOCH", "2016-12-31T23:59:60.000000Z"),
1113 ])),
1114 Err(OmmError::InvalidField {
1115 field: "civil datetime",
1116 kind: OmmInputErrorKind::InvalidCivilTime,
1117 })
1118 );
1119 }
1120
1121 #[test]
1122 fn parse_kvn_rejects_invalid_leap_second_range() {
1123 assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:61.000000Z")).is_err());
1124 assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:-1.000000Z")).is_err());
1125 }
1126
1127 #[test]
1128 fn parse_kvn_requires_fractional_epoch_digits() {
1129 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.500"))
1130 .expect("fractional epoch");
1131 assert_eq!(omm.epoch.microsecond, 500_000);
1132
1133 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.5Z"))
1134 .expect("fractional epoch with UTC suffix");
1135 assert_eq!(omm.epoch.microsecond, 500_000);
1136
1137 for epoch in [
1138 "2026-06-17T04:32:52.abc",
1139 "2026-06-17T04:32:52.abcZ",
1140 "2026-06-17T04:32:52.5x",
1141 "2026-06-17T04:32:52.5xZ",
1142 "2026-06-17T04:32:52.",
1143 ] {
1144 assert!(
1145 matches!(
1146 parse_kvn(&kvn_with_field("EPOCH", epoch)),
1147 Err(OmmError::Epoch(_))
1148 ),
1149 "{epoch} must be rejected"
1150 );
1151 }
1152 }
1153
1154 #[test]
1155 fn parse_kvn_preserves_sub_microsecond_epoch_seconds() {
1156 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.9999995"))
1157 .expect("fractional epoch");
1158 assert_eq!(
1159 omm.epoch,
1160 OmmEpoch {
1161 year: 2026,
1162 month: 6,
1163 day: 17,
1164 hour: 4,
1165 minute: 32,
1166 second: 52,
1167 microsecond: 999_999,
1168 femtosecond: 500_000_000,
1169 }
1170 );
1171 assert!(
1172 encode_kvn(&omm).contains("EPOCH = 2026-06-17T04:32:52.999999500000000"),
1173 "sub-microsecond epoch must encode with high fractional precision"
1174 );
1175 }
1176
1177 #[test]
1178 fn parse_kvn_preserves_continuous_time_sub_microsecond_epoch_near_day() {
1179 let omm = parse_kvn(&kvn_with_fields(&[
1180 ("TIME_SYSTEM", "GPS"),
1181 ("EPOCH", "2026-06-17T23:59:59.9999995"),
1182 ]))
1183 .expect("continuous-time fractional epoch");
1184 assert_eq!(
1185 omm.epoch,
1186 OmmEpoch {
1187 year: 2026,
1188 month: 6,
1189 day: 17,
1190 hour: 23,
1191 minute: 59,
1192 second: 59,
1193 microsecond: 999_999,
1194 femtosecond: 500_000_000,
1195 }
1196 );
1197 }
1198
1199 #[test]
1200 fn parse_kvn_preserves_continuous_time_sub_microsecond_epoch_near_year() {
1201 let ordinary = parse_kvn(&kvn_with_fields(&[
1202 ("TIME_SYSTEM", "GPS"),
1203 ("EPOCH", "2026-12-31T23:59:58.123456"),
1204 ]))
1205 .expect("ordinary continuous-time epoch");
1206 assert_eq!(
1207 ordinary.epoch,
1208 OmmEpoch {
1209 year: 2026,
1210 month: 12,
1211 day: 31,
1212 hour: 23,
1213 minute: 59,
1214 second: 58,
1215 microsecond: 123_456,
1216 femtosecond: 0,
1217 }
1218 );
1219 assert!(
1220 encode_kvn(&ordinary).contains("EPOCH = 2026-12-31T23:59:58.123456"),
1221 "ordinary epoch must encode unchanged"
1222 );
1223
1224 let carried = parse_kvn(&kvn_with_fields(&[
1225 ("TIME_SYSTEM", "GPS"),
1226 ("EPOCH", "2026-12-31T23:59:59.9999995"),
1227 ]))
1228 .expect("continuous-time fractional epoch near year boundary");
1229 assert_eq!(
1230 carried.epoch,
1231 OmmEpoch {
1232 year: 2026,
1233 month: 12,
1234 day: 31,
1235 hour: 23,
1236 minute: 59,
1237 second: 59,
1238 microsecond: 999_999,
1239 femtosecond: 500_000_000,
1240 }
1241 );
1242 assert!(
1243 encode_kvn(&carried).contains("EPOCH = 2026-12-31T23:59:59.999999500000000"),
1244 "sub-microsecond year-end epoch must encode unchanged"
1245 );
1246 }
1247
1248 #[test]
1249 fn kvn_round_trips_through_struct() {
1250 let omm = parse_kvn(ISS_KVN).unwrap();
1251 let reparsed = parse_kvn(&encode_kvn(&omm)).unwrap();
1252 assert_eq!(omm, reparsed);
1253 }
1254
1255 #[test]
1256 fn kvn_re_encodes_catalog_epoch_byte_faithfully() {
1257 let source_epoch = ISS_KVN
1261 .lines()
1262 .find_map(|line| match line.split_once('=') {
1263 Some((key, value)) if key.trim() == "EPOCH" => Some(value.trim()),
1264 _ => None,
1265 })
1266 .expect("fixture EPOCH");
1267 let encoded = encode_kvn(&parse_kvn(ISS_KVN).unwrap());
1268 assert!(
1269 encoded.contains(&format!("EPOCH = {source_epoch}\n")),
1270 "catalog epoch {source_epoch} must re-encode byte-faithfully"
1271 );
1272 assert_eq!(source_epoch.len(), "2026-06-17T04:32:52.099296".len());
1273 }
1274
1275 #[test]
1276 fn kvn_round_trips_femtosecond_epoch_through_struct() {
1277 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.9999995")).unwrap();
1279 assert_eq!(omm.epoch.femtosecond, 500_000_000);
1280 let reparsed = parse_kvn(&encode_kvn(&omm)).unwrap();
1281 assert_eq!(omm, reparsed);
1282 assert_eq!(reparsed.epoch.femtosecond, 500_000_000);
1283 }
1284
1285 #[test]
1286 fn xml_matches_kvn_orbital_content() {
1287 let kvn = parse_kvn(ISS_KVN).unwrap();
1288 let xml = parse_xml(ISS_XML).unwrap();
1289 assert_eq!(canonical(&kvn), canonical(&xml));
1290 }
1291
1292 #[test]
1293 fn xml_round_trips_through_struct() {
1294 let omm = parse_xml(ISS_XML).unwrap();
1295 let reparsed = parse_xml(&encode_xml(&omm)).unwrap();
1296 assert_eq!(omm, reparsed);
1297 }
1298
1299 #[test]
1300 fn parse_kvn_rejects_xml_illegal_text_controls() {
1301 let err = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\u{0005}SGP4"))
1302 .expect_err("XML-illegal control characters must not enter OMM text fields");
1303 assert_eq!(
1304 err,
1305 OmmError::Field(
1306 "field MEAN_ELEMENT_THEORY contains XML-illegal character U+0005".to_string()
1307 )
1308 );
1309
1310 let omm = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\tSGP4"))
1311 .expect("XML-legal text control must remain valid");
1312 let reparsed = parse_xml(&encode_xml(&omm)).expect("encoded OMM must remain valid XML");
1313 assert_eq!(omm, reparsed);
1314 }
1315
1316 #[test]
1317 fn xml_round_trip_preserves_carriage_returns_in_text_values() {
1318 for value in ["SGP\rSGP4", "SGP\r\nSGP4"] {
1319 let mut omm = parse_kvn(ISS_KVN).expect("base OMM must parse");
1320 omm.mean_element_theory = Some(value.to_string());
1321 let encoded = encode_xml(&omm);
1322 assert!(encoded.contains("
"));
1323 assert!(!encoded.contains('\r'));
1324 let reparsed = parse_xml(&encoded).expect("encoded OMM must remain valid XML");
1325 assert_eq!(omm.mean_element_theory, reparsed.mean_element_theory);
1326 assert_eq!(omm, reparsed);
1327 }
1328 }
1329
1330 #[test]
1331 fn json_matches_kvn_orbital_content() {
1332 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1333 let kvn = parse_kvn(ISS_KVN).unwrap();
1334 let json = parse_json(ISS_JSON).unwrap();
1335 assert_eq!(canonical(&kvn), canonical(&json));
1336 }
1337
1338 #[test]
1339 fn json_round_trips_through_struct() {
1340 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1341 let omm = parse_json(ISS_JSON).unwrap();
1342 let reparsed = parse_json(&encode_json(&omm)).unwrap();
1343 assert_eq!(omm, reparsed);
1344 }
1345
1346 #[test]
1347 fn parse_auto_detects_encoding() {
1348 let from_kvn = parse(ISS_KVN).unwrap();
1349 let from_xml = parse(ISS_XML).unwrap();
1350 assert_eq!(parse_kvn(ISS_KVN).unwrap(), from_kvn);
1351 assert_eq!(parse_xml(ISS_XML).unwrap(), from_xml);
1352 assert_eq!(canonical(&from_kvn), canonical(&from_xml));
1353 }
1354
1355 #[test]
1356 fn parse_auto_detects_json_array() {
1357 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1358 assert_eq!(parse(ISS_JSON).unwrap(), parse_json(ISS_JSON).unwrap());
1360 }
1361
1362 #[test]
1363 fn parse_json_array_skips_malformed_objects_and_counts_them() {
1364 let good = |norad: u32, id: &str| {
1369 format!(
1370 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}}"#
1371 )
1372 };
1373 let text = format!(
1374 "[{}, \"not an object\", {{\"OBJECT_NAME\":\"BROKEN\",\"NORAD_CAT_ID\":99999}}, {}]",
1375 good(25544, "1998-067A"),
1376 good(25545, "1998-067B"),
1377 );
1378
1379 let result = parse_json_array(&text).expect("array with bad entries must still parse");
1380 assert_eq!(result.skipped, 2, "the string and the malformed object");
1381 let norads: Vec<u32> = result.omms.iter().map(|o| o.norad_cat_id).collect();
1382 assert_eq!(norads, vec![25544, 25545], "both good OMMs must survive");
1383 }
1384
1385 #[test]
1386 fn bstar_quantizes_onto_assumed_decimal_grid() {
1387 let omm = parse_kvn(ISS_KVN).unwrap();
1390 let es = omm.to_element_set().expect("valid OMM bridge");
1391 assert_eq!(es.bstar, 0.17172 * 10.0_f64.powi(-3));
1392 assert_ne!(es.bstar, omm.bstar);
1393 }
1394
1395 #[test]
1396 fn to_element_set_rejects_invalid_bridge_fields() {
1397 let mut omm = parse_kvn(ISS_KVN).unwrap();
1398 omm.mean_motion = f64::NAN;
1399 assert_eq!(
1400 omm.to_element_set(),
1401 Err(OmmError::InvalidField {
1402 field: "mean_motion",
1403 kind: OmmInputErrorKind::NonFinite
1404 })
1405 );
1406
1407 let mut omm = parse_kvn(ISS_KVN).unwrap();
1408 omm.eccentricity = 1.0;
1409 assert_eq!(
1410 omm.to_element_set(),
1411 Err(OmmError::InvalidField {
1412 field: "eccentricity",
1413 kind: OmmInputErrorKind::OutOfRange
1414 })
1415 );
1416 }
1417
1418 #[test]
1419 fn from_omm_preserves_epoch_year_outside_tle_pivot_range() {
1420 let omm = parse_kvn(&kvn_with_field("EPOCH", "2057-01-01T00:00:00.000000"))
1421 .expect("future OMM epoch");
1422 let sat = Satellite::from_omm(&omm).expect("OMM with full-year epoch must initialize");
1423
1424 let epoch = sat.epoch_jd();
1425 let actual_jd = epoch.0 + epoch.1;
1426 let expected_jd = crate::astro::time::scales::julian_day_number(2057, 1, 1) as f64 - 0.5;
1427 let aliased_1957_jd =
1428 crate::astro::time::scales::julian_day_number(1957, 1, 1) as f64 - 0.5;
1429
1430 assert!(
1431 (actual_jd - expected_jd).abs() < 1.0e-9,
1432 "OMM epoch JD {actual_jd} must match the true 2057 epoch {expected_jd}",
1433 );
1434 assert!(
1435 (actual_jd - aliased_1957_jd).abs() > 36_000.0,
1436 "OMM epoch JD {actual_jd} must not alias to 1957 {aliased_1957_jd}",
1437 );
1438 }
1439
1440 #[test]
1441 fn from_omm_preserves_sub_microsecond_year_end_epoch_directly() {
1442 for (epoch, expected_year) in [
1443 ("2021-12-31T23:59:59.9999995", 2021),
1444 ("2020-12-31T23:59:59.9999995", 2020),
1445 ] {
1446 let omm = parse_kvn(&kvn_with_field("EPOCH", epoch)).expect("year-end OMM epoch");
1447 assert_eq!(omm.epoch.year, expected_year);
1448 assert_eq!(omm.epoch.month, 12);
1449 assert_eq!(omm.epoch.day, 31);
1450 assert_eq!(omm.epoch.hour, 23);
1451 assert_eq!(omm.epoch.minute, 59);
1452 assert_eq!(omm.epoch.second, 59);
1453 assert_eq!(omm.epoch.microsecond, 999_999);
1454 assert_eq!(omm.epoch.femtosecond, 500_000_000);
1455
1456 let sat =
1457 Satellite::from_omm(&omm).expect("sub-microsecond year-end OMM must initialize");
1458 let epoch_jd = sat.epoch_jd();
1459 let actual_jd = epoch_jd.0 + epoch_jd.1;
1460 let expected_jd =
1461 crate::astro::time::scales::julian_day_number(expected_year, 12, 31) as f64 - 0.5
1462 + (86_399.999_999_5 / 86_400.0);
1463
1464 assert!(
1465 (actual_jd - expected_jd).abs() < 1.0e-9,
1466 "{epoch} converted to JD {actual_jd}, expected {expected_jd}",
1467 );
1468 }
1469 }
1470
1471 #[test]
1472 fn from_sgp4_julian_date_normalizes_split_fraction_carry() {
1473 let (jd_midnight, _) =
1474 crate::astro::time::civil::split_julian_date(2026, 12, 31, 0, 0, 0.0);
1475 let epoch = OmmEpoch::from_sgp4_julian_date(JulianDate(jd_midnight, 1.0));
1476
1477 assert_eq!(
1478 epoch,
1479 OmmEpoch {
1480 year: 2027,
1481 month: 1,
1482 day: 1,
1483 hour: 0,
1484 minute: 0,
1485 second: 0,
1486 microsecond: 0,
1487 femtosecond: 0,
1488 }
1489 );
1490 }
1491
1492 #[test]
1493 fn from_omm_rejects_invalid_sgp4_element_fields() {
1494 let mut omm = parse_kvn(ISS_KVN).unwrap();
1495 omm.mean_motion = f64::NAN;
1496 let err = Satellite::from_omm(&omm).expect_err("non-finite mean motion must error");
1497 assert_eq!(
1498 err,
1499 Sgp4Error::InvalidInput {
1500 field: "mean_motion",
1501 kind: crate::astro::sgp4::Sgp4InputErrorKind::NonFinite,
1502 }
1503 );
1504
1505 let mut omm = parse_kvn(ISS_KVN).unwrap();
1506 omm.eccentricity = 1.0;
1507 let err = Satellite::from_omm(&omm).expect_err("eccentricity >= 1 must error");
1508 assert_eq!(
1509 err,
1510 Sgp4Error::InvalidInput {
1511 field: "eccentricity",
1512 kind: crate::astro::sgp4::Sgp4InputErrorKind::OutOfRange,
1513 }
1514 );
1515 }
1516}