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
68const GP_CSV_FIELDS: &[&str] = &[
70 "OBJECT_NAME",
71 "OBJECT_ID",
72 "EPOCH",
73 "MEAN_MOTION",
74 "ECCENTRICITY",
75 "INCLINATION",
76 "RA_OF_ASC_NODE",
77 "ARG_OF_PERICENTER",
78 "MEAN_ANOMALY",
79 "EPHEMERIS_TYPE",
80 "CLASSIFICATION_TYPE",
81 "NORAD_CAT_ID",
82 "ELEMENT_SET_NO",
83 "REV_AT_EPOCH",
84 "BSTAR",
85 "MEAN_MOTION_DOT",
86 "MEAN_MOTION_DDOT",
87];
88
89#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93pub struct OmmEpoch {
94 pub year: i32,
95 pub month: u32,
96 pub day: u32,
97 pub hour: u32,
98 pub minute: u32,
99 pub second: u32,
100 pub microsecond: u32,
102 #[serde(default, skip_serializing_if = "is_zero_u32")]
106 pub femtosecond: u32,
107}
108
109#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
117pub struct Omm {
118 pub ccsds_omm_vers: String,
120 pub creation_date: Option<String>,
121 pub originator: Option<String>,
122 pub object_name: Option<String>,
123 pub object_id: Option<String>,
125 pub center_name: Option<String>,
126 pub ref_frame: Option<String>,
127 pub time_system: Option<String>,
128 pub mean_element_theory: Option<String>,
129
130 pub epoch: OmmEpoch,
132 pub mean_motion: f64,
134 pub eccentricity: f64,
136 pub inclination_deg: f64,
138 pub ra_of_asc_node_deg: f64,
140 pub arg_of_pericenter_deg: f64,
142 pub mean_anomaly_deg: f64,
144
145 pub ephemeris_type: i32,
147 pub classification_type: String,
148 pub norad_cat_id: u32,
149 pub element_set_no: i32,
150 pub rev_at_epoch: i64,
151 pub bstar: f64,
153 pub mean_motion_dot: f64,
155 pub mean_motion_ddot: f64,
157 #[serde(default, skip)]
169 pub exact_sgp4_epoch: Option<JulianDate>,
170 #[serde(default = "default_quantize_tle_derived_fields", skip_serializing)]
179 pub quantize_tle_derived_fields: bool,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum OmmError {
185 MissingField(&'static str),
187 InvalidField {
189 field: &'static str,
190 kind: OmmInputErrorKind,
191 },
192 Field(String),
194 Epoch(String),
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum OmmInputErrorKind {
201 Missing,
203 NonFinite,
205 FloatParse,
207 IntParse,
209 NotPositive,
211 Negative,
213 OutOfRange,
215 InvalidCivilDate,
217 InvalidCivilTime,
219}
220
221impl fmt::Display for OmmInputErrorKind {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 let label = match self {
224 Self::Missing => "missing",
225 Self::NonFinite => "not finite",
226 Self::FloatParse => "invalid float",
227 Self::IntParse => "invalid integer",
228 Self::NotPositive => "not positive",
229 Self::Negative => "negative",
230 Self::OutOfRange => "out of range",
231 Self::InvalidCivilDate => "invalid civil date",
232 Self::InvalidCivilTime => "invalid civil time",
233 };
234 f.write_str(label)
235 }
236}
237
238impl From<&validate::FieldError> for OmmInputErrorKind {
239 fn from(error: &validate::FieldError) -> Self {
240 match error {
241 validate::FieldError::Missing { .. } => Self::Missing,
242 validate::FieldError::NonFinite { .. } => Self::NonFinite,
243 validate::FieldError::FloatParse { .. } => Self::FloatParse,
244 validate::FieldError::IntParse { .. } => Self::IntParse,
245 validate::FieldError::NotPositive { .. } => Self::NotPositive,
246 validate::FieldError::Negative { .. } => Self::Negative,
247 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
248 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
249 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
250 }
251 }
252}
253
254impl fmt::Display for OmmError {
255 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256 match self {
257 OmmError::MissingField(name) => write!(f, "OMM missing required field {name}"),
258 OmmError::InvalidField { field, kind } => {
259 write!(f, "invalid OMM field {field}: {kind}")
260 }
261 OmmError::Field(msg) => write!(f, "OMM field error: {msg}"),
262 OmmError::Epoch(msg) => write!(f, "OMM epoch error: {msg}"),
263 }
264 }
265}
266
267impl std::error::Error for OmmError {}
268
269pub fn parse_kvn(text: &str) -> Result<Omm, OmmError> {
277 let map = crate::format::kvn::FieldMap::parse(text);
278 Omm::from_field_map(&map)
279}
280
281pub fn encode_kvn(omm: &Omm) -> String {
287 let mut out = String::new();
288 let header = crate::astro::ndm::NdmHeader {
289 vers: omm.ccsds_omm_vers.clone(),
290 creation_date: omm.creation_date.clone(),
291 originator: omm.originator.clone(),
292 };
293 for line in header.write_kvn("CCSDS_OMM_VERS") {
294 out.push_str(&line);
295 out.push('\n');
296 }
297
298 let mut kv = |key: &str, value: &str| {
299 out.push_str(key);
300 out.push_str(" = ");
301 out.push_str(value);
302 out.push('\n');
303 };
304
305 kv("OBJECT_NAME", omm.object_name.as_deref().unwrap_or(""));
306 kv("OBJECT_ID", omm.object_id.as_deref().unwrap_or(""));
307 kv("CENTER_NAME", omm.center_name.as_deref().unwrap_or(""));
308 kv("REF_FRAME", omm.ref_frame.as_deref().unwrap_or(""));
309 kv("TIME_SYSTEM", omm.time_system.as_deref().unwrap_or(""));
310 kv(
311 "MEAN_ELEMENT_THEORY",
312 omm.mean_element_theory.as_deref().unwrap_or(""),
313 );
314 kv("EPOCH", &omm.epoch.to_iso8601());
315 kv("MEAN_MOTION", &fmt_num(omm.mean_motion));
316 kv("ECCENTRICITY", &fmt_num(omm.eccentricity));
317 kv("INCLINATION", &fmt_num(omm.inclination_deg));
318 kv("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg));
319 kv("ARG_OF_PERICENTER", &fmt_num(omm.arg_of_pericenter_deg));
320 kv("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg));
321 kv("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string());
322 kv("CLASSIFICATION_TYPE", &omm.classification_type);
323 kv("NORAD_CAT_ID", &omm.norad_cat_id.to_string());
324 kv("ELEMENT_SET_NO", &omm.element_set_no.to_string());
325 kv("REV_AT_EPOCH", &omm.rev_at_epoch.to_string());
326 kv("BSTAR", &fmt_num(omm.bstar));
327 kv("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot));
328 kv("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot));
329 out
330}
331
332pub fn parse_xml(text: &str) -> Result<Omm, OmmError> {
343 let doc = Document::parse(text).map_err(|e| OmmError::Field(format!("malformed XML: {e}")))?;
344 let mut fields: Vec<(String, String)> = Vec::new();
345
346 if let Some(omm_el) = doc
347 .descendants()
348 .find(|n| n.is_element() && n.tag_name().name() == "omm")
349 {
350 if let Some(version) = omm_el.attribute("version") {
351 fields.push(("CCSDS_OMM_VERS".to_string(), version.trim().to_string()));
352 }
353 }
354
355 for node in doc.descendants().filter(roxmltree::Node::is_element) {
356 let name = node.tag_name().name();
357 if FIELD_TAGS.contains(&name) {
358 let value = node.text().unwrap_or("").trim().to_string();
359 fields.push((name.to_string(), value));
360 }
361 }
362
363 let map = crate::format::kvn::FieldMap::from_pairs(fields);
364 Omm::from_field_map(&map)
365}
366
367pub fn encode_xml(omm: &Omm) -> String {
371 fn elem(name: &str, value: &str) -> String {
372 format!("<{name}>{value}</{name}>")
373 }
374 let opt = |value: &Option<String>| xml::escape_opt(value);
375
376 let mut s = String::new();
377 s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
378 s.push_str("<ndm>\n");
379 let _ = writeln!(
380 s,
381 "<omm id=\"CCSDS_OMM_VERS\" version=\"{}\">",
382 xml::escape(&omm.ccsds_omm_vers)
383 );
384
385 s.push_str("<header>");
386 s.push_str(&elem("CREATION_DATE", &opt(&omm.creation_date)));
387 s.push_str(&elem("ORIGINATOR", &opt(&omm.originator)));
388 s.push_str("</header>\n");
389
390 s.push_str("<body><segment>\n<metadata>");
391 s.push_str(&elem("OBJECT_NAME", &opt(&omm.object_name)));
392 s.push_str(&elem("OBJECT_ID", &opt(&omm.object_id)));
393 s.push_str(&elem("CENTER_NAME", &opt(&omm.center_name)));
394 s.push_str(&elem("REF_FRAME", &opt(&omm.ref_frame)));
395 s.push_str(&elem("TIME_SYSTEM", &opt(&omm.time_system)));
396 s.push_str(&elem("MEAN_ELEMENT_THEORY", &opt(&omm.mean_element_theory)));
397 s.push_str("</metadata>\n<data>\n<meanElements>");
398
399 s.push_str(&elem("EPOCH", &omm.epoch.to_iso8601()));
400 s.push_str(&elem("MEAN_MOTION", &fmt_num(omm.mean_motion)));
401 s.push_str(&elem("ECCENTRICITY", &fmt_num(omm.eccentricity)));
402 s.push_str(&elem("INCLINATION", &fmt_num(omm.inclination_deg)));
403 s.push_str(&elem("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg)));
404 s.push_str(&elem(
405 "ARG_OF_PERICENTER",
406 &fmt_num(omm.arg_of_pericenter_deg),
407 ));
408 s.push_str(&elem("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg)));
409 s.push_str("</meanElements>\n<tleParameters>");
410
411 s.push_str(&elem("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string()));
412 s.push_str(&elem(
413 "CLASSIFICATION_TYPE",
414 &xml::escape(&omm.classification_type),
415 ));
416 s.push_str(&elem("NORAD_CAT_ID", &omm.norad_cat_id.to_string()));
417 s.push_str(&elem("ELEMENT_SET_NO", &omm.element_set_no.to_string()));
418 s.push_str(&elem("REV_AT_EPOCH", &omm.rev_at_epoch.to_string()));
419 s.push_str(&elem("BSTAR", &fmt_num(omm.bstar)));
420 s.push_str(&elem("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot)));
421 s.push_str(&elem("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot)));
422 s.push_str("</tleParameters>\n</data>\n</segment></body>\n</omm>\n</ndm>\n");
423 s
424}
425
426pub fn parse_json(text: &str) -> Result<Omm, OmmError> {
436 use serde_json::Value;
437
438 let value: Value =
439 serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
440 let object = match &value {
441 Value::Array(items) => items
442 .first()
443 .ok_or_else(|| OmmError::Field("empty JSON array".to_string()))?,
444 Value::Object(_) => &value,
445 _ => {
446 return Err(OmmError::Field(
447 "expected a JSON object or array".to_string(),
448 ))
449 }
450 };
451 omm_from_json_value(object)
452}
453
454fn omm_from_json_value(object: &serde_json::Value) -> Result<Omm, OmmError> {
457 let map = object
458 .as_object()
459 .ok_or_else(|| OmmError::Field("expected a JSON object".to_string()))?;
460 let fields: Vec<(String, String)> = map
461 .iter()
462 .map(|(key, value)| (key.clone(), json_scalar_to_string(value)))
463 .collect();
464 let map = crate::format::kvn::FieldMap::from_pairs(fields);
465 Omm::from_field_map(&map)
466}
467
468#[derive(Debug, Clone, PartialEq)]
471pub struct OmmArray {
472 pub omms: Vec<Omm>,
474 pub skipped: usize,
481}
482
483pub fn parse_json_array(text: &str) -> Result<OmmArray, OmmError> {
493 use serde_json::Value;
494
495 let value: Value =
496 serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
497 let items: &[Value] = match &value {
498 Value::Array(items) => items.as_slice(),
499 Value::Object(_) => std::slice::from_ref(&value),
500 _ => {
501 return Err(OmmError::Field(
502 "expected a JSON object or array".to_string(),
503 ))
504 }
505 };
506
507 let mut omms = Vec::with_capacity(items.len());
508 let mut skipped = 0usize;
509 for object in items {
510 match omm_from_json_value(object) {
511 Ok(omm) => omms.push(omm),
512 Err(_) => skipped += 1,
513 }
514 }
515 Ok(OmmArray { omms, skipped })
516}
517
518pub fn encode_json(omm: &Omm) -> String {
524 use serde_json::{Map, Number, Value};
525
526 let num = |x: f64| Number::from_f64(x).map_or(Value::Null, Value::Number);
527 let opt = |value: &Option<String>| value.clone().map_or(Value::Null, Value::String);
528
529 let mut map = Map::new();
530 map.insert(
531 "CCSDS_OMM_VERS".into(),
532 Value::String(omm.ccsds_omm_vers.clone()),
533 );
534 map.insert("CREATION_DATE".into(), opt(&omm.creation_date));
535 map.insert("ORIGINATOR".into(), opt(&omm.originator));
536 map.insert("OBJECT_NAME".into(), opt(&omm.object_name));
537 map.insert("OBJECT_ID".into(), opt(&omm.object_id));
538 map.insert("CENTER_NAME".into(), opt(&omm.center_name));
539 map.insert("REF_FRAME".into(), opt(&omm.ref_frame));
540 map.insert("TIME_SYSTEM".into(), opt(&omm.time_system));
541 map.insert("MEAN_ELEMENT_THEORY".into(), opt(&omm.mean_element_theory));
542 map.insert("EPOCH".into(), Value::String(omm.epoch.to_iso8601()));
543 map.insert("MEAN_MOTION".into(), num(omm.mean_motion));
544 map.insert("ECCENTRICITY".into(), num(omm.eccentricity));
545 map.insert("INCLINATION".into(), num(omm.inclination_deg));
546 map.insert("RA_OF_ASC_NODE".into(), num(omm.ra_of_asc_node_deg));
547 map.insert("ARG_OF_PERICENTER".into(), num(omm.arg_of_pericenter_deg));
548 map.insert("MEAN_ANOMALY".into(), num(omm.mean_anomaly_deg));
549 map.insert(
550 "EPHEMERIS_TYPE".into(),
551 Value::Number(omm.ephemeris_type.into()),
552 );
553 map.insert(
554 "CLASSIFICATION_TYPE".into(),
555 Value::String(omm.classification_type.clone()),
556 );
557 map.insert(
558 "NORAD_CAT_ID".into(),
559 Value::Number(omm.norad_cat_id.into()),
560 );
561 map.insert(
562 "ELEMENT_SET_NO".into(),
563 Value::Number(omm.element_set_no.into()),
564 );
565 map.insert(
566 "REV_AT_EPOCH".into(),
567 Value::Number(omm.rev_at_epoch.into()),
568 );
569 map.insert("BSTAR".into(), num(omm.bstar));
570 map.insert("MEAN_MOTION_DOT".into(), num(omm.mean_motion_dot));
571 map.insert("MEAN_MOTION_DDOT".into(), num(omm.mean_motion_ddot));
572 Value::Object(map).to_string()
573}
574
575pub fn encode_json_array(omms: &[Omm]) -> String {
580 use serde_json::Value;
581
582 let values: Vec<Value> = omms
583 .iter()
584 .map(|omm| serde_json::from_str(&encode_json(omm)).expect("encoded OMM JSON object"))
585 .collect();
586 Value::Array(values).to_string()
587}
588
589fn json_scalar_to_string(value: &serde_json::Value) -> String {
592 use serde_json::Value;
593 match value {
594 Value::String(s) => s.clone(),
595 Value::Number(n) => n.to_string(),
596 Value::Bool(b) => b.to_string(),
597 Value::Null => String::new(),
598 other => other.to_string(),
599 }
600}
601
602pub fn parse_csv(text: &str) -> Result<Omm, OmmError> {
610 let parsed = parse_csv_array(text)?;
611 parsed
612 .omms
613 .into_iter()
614 .next()
615 .ok_or_else(|| OmmError::Field("empty GP CSV".to_string()))
616}
617
618pub fn parse_csv_array(text: &str) -> Result<OmmArray, OmmError> {
625 let records = parse_csv_records(text)?;
626 let Some((header, rows)) = records.split_first() else {
627 return Err(OmmError::Field("missing GP CSV header".to_string()));
628 };
629 let header: Vec<String> = header.iter().map(|key| key.trim().to_string()).collect();
630 if header.is_empty() || header.iter().all(String::is_empty) {
631 return Err(OmmError::Field("missing GP CSV header".to_string()));
632 }
633
634 let mut omms = Vec::with_capacity(rows.len());
635 let mut skipped = 0usize;
636 for row in rows {
637 if row.len() != header.len() {
638 skipped += 1;
639 continue;
640 }
641 let fields = header
642 .iter()
643 .zip(row.iter())
644 .map(|(key, value)| (key.clone(), value.trim().to_string()))
645 .collect();
646 let map = crate::format::kvn::FieldMap::from_pairs(fields);
647 match Omm::from_field_map(&map) {
648 Ok(omm) => omms.push(omm),
649 Err(_) => skipped += 1,
650 }
651 }
652
653 Ok(OmmArray { omms, skipped })
654}
655
656pub fn encode_csv(omms: &[Omm]) -> String {
662 let mut out = String::new();
663 write_csv_record(&mut out, GP_CSV_FIELDS.iter().copied());
664 for omm in omms {
665 out.push('\n');
666 write_csv_record(
667 &mut out,
668 GP_CSV_FIELDS
669 .iter()
670 .map(|key| omm_csv_field_value(omm, key)),
671 );
672 }
673 out
674}
675
676fn parse_csv_records(text: &str) -> Result<Vec<Vec<String>>, OmmError> {
677 let mut records = Vec::new();
678 let mut record = Vec::new();
679 let mut field = String::new();
680 let mut chars = text.chars().peekable();
681 let mut in_quotes = false;
682 let mut quoted_field = false;
683
684 while let Some(ch) = chars.next() {
685 if in_quotes {
686 match ch {
687 '"' if chars.peek() == Some(&'"') => {
688 field.push('"');
689 chars.next();
690 }
691 '"' => in_quotes = false,
692 _ => field.push(ch),
693 }
694 continue;
695 }
696
697 match ch {
698 '"' if field.is_empty() && !quoted_field => {
699 in_quotes = true;
700 quoted_field = true;
701 }
702 ',' => {
703 record.push(std::mem::take(&mut field));
704 quoted_field = false;
705 }
706 '\n' => {
707 record.push(std::mem::take(&mut field));
708 push_csv_record(&mut records, &mut record);
709 quoted_field = false;
710 }
711 '\r' if chars.peek() == Some(&'\n') => {}
712 '\r' => {
713 record.push(std::mem::take(&mut field));
714 push_csv_record(&mut records, &mut record);
715 quoted_field = false;
716 }
717 _ => field.push(ch),
718 }
719 }
720
721 if in_quotes {
722 return Err(OmmError::Field(
723 "malformed GP CSV: unclosed quoted field".to_string(),
724 ));
725 }
726 if !field.is_empty() || !record.is_empty() || quoted_field {
727 record.push(field);
728 push_csv_record(&mut records, &mut record);
729 }
730
731 Ok(records)
732}
733
734fn push_csv_record(records: &mut Vec<Vec<String>>, record: &mut Vec<String>) {
735 if record.len() == 1 && record[0].is_empty() {
736 record.clear();
737 return;
738 }
739 records.push(std::mem::take(record));
740}
741
742fn write_csv_record<I, S>(out: &mut String, fields: I)
743where
744 I: IntoIterator<Item = S>,
745 S: AsRef<str>,
746{
747 for (index, field) in fields.into_iter().enumerate() {
748 if index > 0 {
749 out.push(',');
750 }
751 write_csv_field(out, field.as_ref());
752 }
753}
754
755fn write_csv_field(out: &mut String, field: &str) {
756 if field.contains([',', '"', '\n', '\r']) {
757 out.push('"');
758 for ch in field.chars() {
759 if ch == '"' {
760 out.push('"');
761 }
762 out.push(ch);
763 }
764 out.push('"');
765 } else {
766 out.push_str(field);
767 }
768}
769
770fn omm_csv_field_value(omm: &Omm, key: &str) -> String {
771 match key {
772 "OBJECT_NAME" => omm.object_name.clone().unwrap_or_default(),
773 "OBJECT_ID" => omm.object_id.clone().unwrap_or_default(),
774 "EPOCH" => omm.epoch.to_iso8601(),
775 "MEAN_MOTION" => fmt_num(omm.mean_motion),
776 "ECCENTRICITY" => fmt_num(omm.eccentricity),
777 "INCLINATION" => fmt_num(omm.inclination_deg),
778 "RA_OF_ASC_NODE" => fmt_num(omm.ra_of_asc_node_deg),
779 "ARG_OF_PERICENTER" => fmt_num(omm.arg_of_pericenter_deg),
780 "MEAN_ANOMALY" => fmt_num(omm.mean_anomaly_deg),
781 "EPHEMERIS_TYPE" => omm.ephemeris_type.to_string(),
782 "CLASSIFICATION_TYPE" => omm.classification_type.clone(),
783 "NORAD_CAT_ID" => omm.norad_cat_id.to_string(),
784 "ELEMENT_SET_NO" => omm.element_set_no.to_string(),
785 "REV_AT_EPOCH" => omm.rev_at_epoch.to_string(),
786 "BSTAR" => fmt_num(omm.bstar),
787 "MEAN_MOTION_DOT" => fmt_num(omm.mean_motion_dot),
788 "MEAN_MOTION_DDOT" => fmt_num(omm.mean_motion_ddot),
789 _ => String::new(),
790 }
791}
792
793pub fn parse(text: &str) -> Result<Omm, OmmError> {
799 match text.trim_start().chars().next() {
800 Some('<') => parse_xml(text),
801 Some('{') | Some('[') => parse_json_detected(text),
802 _ if looks_like_csv(text) => parse_csv(text),
803 _ => parse_kvn(text),
804 }
805}
806
807pub fn parse_epoch(text: &str) -> Result<OmmEpoch, OmmError> {
818 OmmEpoch::parse(text, validate::CivilSecondPolicy::UtcLike)
819}
820
821fn parse_json_detected(text: &str) -> Result<Omm, OmmError> {
822 parse_json(text)
823}
824
825fn looks_like_csv(text: &str) -> bool {
826 text.lines()
827 .map(str::trim)
828 .find(|line| !line.is_empty())
829 .is_some_and(|line| line.contains(',') && !line.contains('='))
830}
831
832impl Omm {
835 pub(crate) fn from_field_map(map: &crate::format::kvn::FieldMap) -> Result<Omm, OmmError> {
839 let get = |key: &str| map.get(key);
840
841 let time_system = xml_text(get("TIME_SYSTEM"), "TIME_SYSTEM")?;
842 let epoch = OmmEpoch::parse(
843 get("EPOCH").ok_or(OmmError::MissingField("EPOCH"))?,
844 omm_civil_second_policy(time_system.as_deref()),
845 )?;
846
847 Ok(Omm {
848 ccsds_omm_vers: xml_text_or_default(get("CCSDS_OMM_VERS"), "CCSDS_OMM_VERS", "2.0")?,
849 creation_date: xml_text(get("CREATION_DATE"), "CREATION_DATE")?,
850 originator: xml_text(get("ORIGINATOR"), "ORIGINATOR")?,
851 object_name: xml_text(get("OBJECT_NAME"), "OBJECT_NAME")?,
852 object_id: xml_text(get("OBJECT_ID"), "OBJECT_ID")?,
853 center_name: xml_text(get("CENTER_NAME"), "CENTER_NAME")?,
854 ref_frame: xml_text(get("REF_FRAME"), "REF_FRAME")?,
855 time_system,
856 mean_element_theory: xml_text(get("MEAN_ELEMENT_THEORY"), "MEAN_ELEMENT_THEORY")?,
857 epoch,
858 mean_motion: req_num(get("MEAN_MOTION"), "MEAN_MOTION")?,
859 eccentricity: req_num(get("ECCENTRICITY"), "ECCENTRICITY")?,
860 inclination_deg: req_num(get("INCLINATION"), "INCLINATION")?,
861 ra_of_asc_node_deg: req_num(get("RA_OF_ASC_NODE"), "RA_OF_ASC_NODE")?,
862 arg_of_pericenter_deg: req_num(get("ARG_OF_PERICENTER"), "ARG_OF_PERICENTER")?,
863 mean_anomaly_deg: req_num(get("MEAN_ANOMALY"), "MEAN_ANOMALY")?,
864 ephemeris_type: opt_int(get("EPHEMERIS_TYPE"), "EPHEMERIS_TYPE")?.unwrap_or(0),
865 classification_type: xml_text_or_default(
866 get("CLASSIFICATION_TYPE"),
867 "CLASSIFICATION_TYPE",
868 "U",
869 )?,
870 norad_cat_id: req_int(get("NORAD_CAT_ID"), "NORAD_CAT_ID")?,
871 element_set_no: opt_int(get("ELEMENT_SET_NO"), "ELEMENT_SET_NO")?.unwrap_or(999),
872 rev_at_epoch: opt_int(get("REV_AT_EPOCH"), "REV_AT_EPOCH")?.unwrap_or(0),
873 bstar: req_num(get("BSTAR"), "BSTAR")?,
874 mean_motion_dot: req_num(get("MEAN_MOTION_DOT"), "MEAN_MOTION_DOT")?,
875 mean_motion_ddot: req_num(get("MEAN_MOTION_DDOT"), "MEAN_MOTION_DDOT")?,
876 exact_sgp4_epoch: None,
877 quantize_tle_derived_fields: true,
878 })
879 }
880}
881
882fn xml_text(value: Option<&str>, field: &'static str) -> Result<Option<String>, OmmError> {
883 value
884 .map(|value| xml_text_value(value, field).map(str::to_string))
885 .transpose()
886}
887
888fn xml_text_or_default(
889 value: Option<&str>,
890 field: &'static str,
891 default: &'static str,
892) -> Result<String, OmmError> {
893 xml_text_value(value.unwrap_or(default), field).map(str::to_string)
894}
895
896fn xml_text_value<'a>(value: &'a str, field: &'static str) -> Result<&'a str, OmmError> {
897 if let Some(ch) = xml::first_illegal_xml_1_0_char(value) {
898 return Err(OmmError::Field(format!(
899 "field {field} contains XML-illegal character U+{:04X}",
900 ch as u32
901 )));
902 }
903 Ok(value)
904}
905
906impl Omm {
909 pub fn to_element_set(&self) -> Result<ElementSet, OmmError> {
918 validate_omm_bridge(self)?;
919 let bstar = if self.quantize_tle_derived_fields {
920 tle::assumed_decimal_quantize(self.bstar)
921 } else {
922 self.bstar
923 };
924 let mean_motion_double_dot = if self.quantize_tle_derived_fields {
925 tle::assumed_decimal_quantize(self.mean_motion_ddot)
926 } else {
927 self.mean_motion_ddot
928 };
929 Ok(ElementSet {
930 epoch: self
931 .exact_sgp4_epoch
932 .unwrap_or_else(|| self.epoch.sgp4_julian_date()),
933 bstar,
934 mean_motion_dot: self.mean_motion_dot,
935 mean_motion_double_dot,
936 eccentricity: self.eccentricity,
937 argument_of_perigee_deg: self.arg_of_pericenter_deg,
938 inclination_deg: self.inclination_deg,
939 mean_anomaly_deg: self.mean_anomaly_deg,
940 mean_motion_rev_per_day: self.mean_motion,
941 right_ascension_deg: self.ra_of_asc_node_deg,
942 catalog_number: self.norad_cat_id,
943 })
944 }
945}
946
947impl Satellite {
948 pub fn from_omm(omm: &Omm) -> Result<Self, Sgp4Error> {
953 let elements = omm.to_element_set().map_err(map_omm_bridge_to_sgp4)?;
954 Self::from_elements(&elements)
955 }
956}
957
958fn validate_omm_bridge(omm: &Omm) -> Result<(), OmmError> {
959 if omm.epoch.microsecond >= 1_000_000 {
960 return Err(OmmError::InvalidField {
961 field: "epoch.microsecond",
962 kind: OmmInputErrorKind::OutOfRange,
963 });
964 }
965 if omm.epoch.femtosecond >= 1_000_000_000 {
966 return Err(OmmError::InvalidField {
967 field: "epoch.femtosecond",
968 kind: OmmInputErrorKind::OutOfRange,
969 });
970 }
971 validate::finite_positive(omm.mean_motion, "mean_motion").map_err(map_omm_field_error)?;
972 validate::finite_in_range_exclusive_upper(omm.eccentricity, 0.0, 1.0, "eccentricity")
973 .map_err(map_omm_field_error)?;
974 validate::finite(omm.inclination_deg, "inclination_deg").map_err(map_omm_field_error)?;
975 validate::finite(omm.ra_of_asc_node_deg, "ra_of_asc_node_deg").map_err(map_omm_field_error)?;
976 validate::finite(omm.arg_of_pericenter_deg, "arg_of_pericenter_deg")
977 .map_err(map_omm_field_error)?;
978 validate::finite(omm.mean_anomaly_deg, "mean_anomaly_deg").map_err(map_omm_field_error)?;
979 validate::finite(omm.bstar, "bstar").map_err(map_omm_field_error)?;
980 validate::finite(omm.mean_motion_dot, "mean_motion_dot").map_err(map_omm_field_error)?;
981 validate::finite(omm.mean_motion_ddot, "mean_motion_ddot").map_err(map_omm_field_error)?;
982 Ok(())
983}
984
985fn map_omm_bridge_to_sgp4(error: OmmError) -> Sgp4Error {
986 match error {
987 OmmError::InvalidField { field, kind } => Sgp4Error::InvalidInput {
988 field,
989 kind: match kind {
990 OmmInputErrorKind::NonFinite => Sgp4InputErrorKind::NonFinite,
991 OmmInputErrorKind::NotPositive => Sgp4InputErrorKind::NotPositive,
992 OmmInputErrorKind::Negative => Sgp4InputErrorKind::Negative,
993 OmmInputErrorKind::OutOfRange => Sgp4InputErrorKind::OutOfRange,
994 OmmInputErrorKind::Missing => Sgp4InputErrorKind::Missing,
995 OmmInputErrorKind::FloatParse => Sgp4InputErrorKind::FloatParse,
996 OmmInputErrorKind::IntParse => Sgp4InputErrorKind::IntParse,
997 OmmInputErrorKind::InvalidCivilDate => Sgp4InputErrorKind::InvalidCivilDate,
998 OmmInputErrorKind::InvalidCivilTime => Sgp4InputErrorKind::InvalidCivilTime,
999 },
1000 },
1001 other => Sgp4Error::InvalidTle(other.to_string()),
1002 }
1003}
1004
1005impl OmmEpoch {
1008 fn parse(text: &str, second_policy: validate::CivilSecondPolicy) -> Result<OmmEpoch, OmmError> {
1011 let e = crate::astro::ndm::NdmEpoch::parse(text, second_policy)
1012 .map_err(|err| map_omm_epoch_field_error(err, text.trim()))?;
1013 Ok(OmmEpoch {
1014 year: e.year,
1015 month: e.month,
1016 day: e.day,
1017 hour: e.hour,
1018 minute: e.minute,
1019 second: e.second,
1020 microsecond: e.microsecond,
1021 femtosecond: e.femtosecond,
1022 })
1023 }
1024
1025 fn sgp4_julian_date(&self) -> sgp4::JulianDate {
1028 sgp4::sgp4_julian_date_from_calendar(
1029 self.year,
1030 self.month as i32,
1031 self.day as i32,
1032 self.hour as i32,
1033 self.minute as i32,
1034 self.second as f64
1035 + self.microsecond as f64 / 1_000_000.0
1036 + self.femtosecond as f64 / 1_000_000_000_000_000.0,
1037 )
1038 }
1039
1040 pub(crate) fn from_sgp4_julian_date(epoch: JulianDate) -> Self {
1041 let (mut jd_midnight, mut day_fraction) = if (epoch.0.fract().abs() - 0.5).abs() < 1.0e-9 {
1042 (epoch.0, epoch.1)
1043 } else if epoch.1 >= 0.5 {
1044 (epoch.0 + 0.5, epoch.1 - 0.5)
1045 } else {
1046 (epoch.0 - 0.5, epoch.1 + 0.5)
1047 };
1048 let day_carry = day_fraction.floor();
1049 jd_midnight += day_carry;
1050 day_fraction -= day_carry;
1051 let (year, month, day, hour, minute, second) =
1052 crate::astro::time::civil::civil_from_split_julian_date(jd_midnight, day_fraction);
1053 let whole_second = second.floor();
1054 let subsecond = second - whole_second;
1055 let mut femtoseconds = (subsecond * FEMTOSECONDS_PER_SECOND as f64).round() as i128;
1056 let mut second = whole_second as u32;
1057 if femtoseconds == FEMTOSECONDS_PER_SECOND {
1058 second += 1;
1059 femtoseconds = 0;
1060 }
1061 OmmEpoch {
1062 year: year as i32,
1063 month: month as u32,
1064 day: day as u32,
1065 hour: hour as u32,
1066 minute: minute as u32,
1067 second,
1068 microsecond: (femtoseconds / FEMTOSECONDS_PER_MICROSECOND) as u32,
1069 femtosecond: (femtoseconds % FEMTOSECONDS_PER_MICROSECOND) as u32,
1070 }
1071 }
1072
1073 fn to_iso8601(&self) -> String {
1077 crate::astro::ndm::NdmEpoch {
1078 year: self.year,
1079 month: self.month,
1080 day: self.day,
1081 hour: self.hour,
1082 minute: self.minute,
1083 second: self.second,
1084 microsecond: self.microsecond,
1085 femtosecond: self.femtosecond,
1086 }
1087 .to_iso8601()
1088 }
1089}
1090
1091const FEMTOSECONDS_PER_SECOND: i128 = 1_000_000_000_000_000;
1092const FEMTOSECONDS_PER_MICROSECOND: i128 = 1_000_000_000;
1093
1094fn is_zero_u32(value: &u32) -> bool {
1095 *value == 0
1096}
1097
1098fn default_quantize_tle_derived_fields() -> bool {
1099 true
1100}
1101
1102fn omm_civil_second_policy(time_system: Option<&str>) -> validate::CivilSecondPolicy {
1105 let Some(label) = time_system.map(str::trim).filter(|label| !label.is_empty()) else {
1106 return validate::CivilSecondPolicy::UtcLike;
1107 };
1108 if label.eq_ignore_ascii_case("UTC")
1109 || label.eq_ignore_ascii_case("GLO")
1110 || label.eq_ignore_ascii_case("GLONASS")
1111 {
1112 validate::CivilSecondPolicy::UtcLike
1113 } else {
1114 validate::CivilSecondPolicy::Continuous
1115 }
1116}
1117
1118fn req_num(value: Option<&str>, field: &'static str) -> Result<f64, OmmError> {
1119 let value = value.ok_or(OmmError::MissingField(field))?;
1120 parse_num(value, field)
1121}
1122
1123fn parse_num(value: &str, field: &'static str) -> Result<f64, OmmError> {
1124 validate::strict_f64(value, field).map_err(map_omm_field_error)
1125}
1126
1127fn req_int<T>(value: Option<&str>, field: &'static str) -> Result<T, OmmError>
1128where
1129 T: std::str::FromStr,
1130{
1131 let value = value.ok_or(OmmError::MissingField(field))?;
1132 parse_int(value, field)
1133}
1134
1135fn opt_int<T>(value: Option<&str>, field: &'static str) -> Result<Option<T>, OmmError>
1136where
1137 T: std::str::FromStr,
1138{
1139 value.map(|v| parse_int(v, field)).transpose()
1140}
1141
1142fn parse_int<T>(value: &str, field: &'static str) -> Result<T, OmmError>
1143where
1144 T: std::str::FromStr,
1145{
1146 validate::strict_int::<T>(value, field).map_err(map_omm_field_error)
1147}
1148
1149fn map_omm_field_error(error: validate::FieldError) -> OmmError {
1150 OmmError::InvalidField {
1151 field: error.field(),
1152 kind: OmmInputErrorKind::from(&error),
1153 }
1154}
1155
1156fn map_omm_epoch_field_error(error: validate::FieldError, full: &str) -> OmmError {
1157 match error {
1158 validate::FieldError::Missing { .. }
1159 | validate::FieldError::FloatParse { .. }
1160 | validate::FieldError::IntParse { .. } => {
1161 OmmError::Epoch(format!("invalid seconds in {full:?}"))
1162 }
1163 _ => map_omm_field_error(error),
1164 }
1165}
1166
1167fn fmt_num(value: f64) -> String {
1169 format!("{value}")
1170}
1171
1172#[cfg(all(test, sidereon_repo_tests))]
1173mod tests {
1174 use super::*;
1175
1176 const ISS_KVN: &str = include_str!("../../tests/fixtures/omm/25544.kvn");
1177 const ISS_XML: &str = include_str!("../../tests/fixtures/omm/25544.xml");
1178 const ISS_CSV: &str = "OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT\n\
1179ISS (ZARYA),1998-067A,2026-06-17T04:32:52.099296,15.49273435,0.0004737,51.6332,300.0813,195.1146,164.9702,0,U,25544,999,57175,0.00017172,9.113e-5,0";
1180
1181 fn canonical(omm: &Omm) -> Omm {
1188 Omm {
1189 ccsds_omm_vers: String::new(),
1190 creation_date: None,
1191 originator: None,
1192 center_name: None,
1193 ref_frame: None,
1194 time_system: None,
1195 mean_element_theory: None,
1196 ..omm.clone()
1197 }
1198 }
1199
1200 fn kvn_with_field(field: &str, value: &str) -> String {
1201 kvn_with_fields(&[(field, value)])
1202 }
1203
1204 fn kvn_with_fields(fields: &[(&str, &str)]) -> String {
1205 ISS_KVN
1206 .lines()
1207 .map(|line| match line.split_once('=') {
1208 Some((key, _)) => fields
1209 .iter()
1210 .find(|(field, _)| key.trim() == *field)
1211 .map_or_else(
1212 || line.to_string(),
1213 |(field, value)| format!("{field} = {value}"),
1214 ),
1215 _ => line.to_string(),
1216 })
1217 .collect::<Vec<_>>()
1218 .join("\n")
1219 }
1220
1221 fn kvn_without_field(field: &str) -> String {
1222 ISS_KVN
1223 .lines()
1224 .filter(|line| match line.split_once('=') {
1225 Some((key, _)) => key.trim() != field,
1226 None => true,
1227 })
1228 .collect::<Vec<_>>()
1229 .join("\n")
1230 }
1231
1232 #[test]
1233 fn parses_iss_kvn_fields() {
1234 let omm = parse_kvn(ISS_KVN).unwrap();
1235 assert_eq!(omm.ccsds_omm_vers, "2.0");
1236 assert_eq!(omm.object_name.as_deref(), Some("ISS (ZARYA)"));
1237 assert_eq!(omm.object_id.as_deref(), Some("1998-067A"));
1238 assert_eq!(omm.norad_cat_id, 25544);
1239 assert_eq!(omm.mean_motion, 15.49273435);
1240 assert_eq!(omm.eccentricity, 0.0004737);
1241 assert_eq!(omm.inclination_deg, 51.6332);
1242 assert_eq!(omm.bstar, 0.00017172);
1243 assert_eq!(omm.mean_motion_dot, 9.113e-5);
1244 assert_eq!(omm.mean_motion_ddot, 0.0);
1245 assert_eq!(
1246 omm.epoch,
1247 OmmEpoch {
1248 year: 2026,
1249 month: 6,
1250 day: 17,
1251 hour: 4,
1252 minute: 32,
1253 second: 52,
1254 microsecond: 99296,
1255 femtosecond: 0,
1256 }
1257 );
1258 }
1259
1260 #[test]
1261 fn parse_kvn_requires_drag_terms() {
1262 for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
1263 assert_eq!(
1264 parse_kvn(&kvn_without_field(field)),
1265 Err(OmmError::MissingField(field))
1266 );
1267 }
1268 }
1269
1270 #[test]
1271 fn parse_kvn_rejects_non_finite_drag_terms() {
1272 for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
1273 assert_eq!(
1274 parse_kvn(&kvn_with_field(field, "NaN")),
1275 Err(OmmError::InvalidField {
1276 field,
1277 kind: OmmInputErrorKind::NonFinite,
1278 })
1279 );
1280 }
1281 }
1282
1283 #[test]
1284 fn parse_kvn_rejects_negative_norad_catalog_id() {
1285 assert_eq!(
1286 parse_kvn(&kvn_with_field("NORAD_CAT_ID", "-1")),
1287 Err(OmmError::InvalidField {
1288 field: "NORAD_CAT_ID",
1289 kind: OmmInputErrorKind::IntParse,
1290 })
1291 );
1292 }
1293
1294 #[test]
1295 fn parse_kvn_rejects_oversized_norad_catalog_id() {
1296 assert_eq!(
1297 parse_kvn(&kvn_with_field("NORAD_CAT_ID", "4294967296")),
1298 Err(OmmError::InvalidField {
1299 field: "NORAD_CAT_ID",
1300 kind: OmmInputErrorKind::IntParse,
1301 })
1302 );
1303 }
1304
1305 #[test]
1306 fn parse_kvn_rejects_invalid_civil_epoch() {
1307 assert_eq!(
1308 parse_kvn(&kvn_with_field("EPOCH", "2026-02-30T04:32:52.099296")),
1309 Err(OmmError::InvalidField {
1310 field: "civil datetime",
1311 kind: OmmInputErrorKind::InvalidCivilDate,
1312 })
1313 );
1314 assert_eq!(
1315 parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T24:00:00.000000")),
1316 Err(OmmError::InvalidField {
1317 field: "civil datetime",
1318 kind: OmmInputErrorKind::InvalidCivilTime,
1319 })
1320 );
1321 }
1322
1323 #[test]
1324 fn parse_kvn_accepts_utc_leap_second_epoch() {
1325 let omm = parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:60.000000Z"))
1326 .expect("OMM leap-second epoch");
1327 assert_eq!(
1328 omm.epoch,
1329 OmmEpoch {
1330 year: 2016,
1331 month: 12,
1332 day: 31,
1333 hour: 23,
1334 minute: 59,
1335 second: 60,
1336 microsecond: 0,
1337 femtosecond: 0,
1338 }
1339 );
1340 }
1341
1342 #[test]
1343 fn parse_kvn_rejects_gps_time_leap_second_epoch() {
1344 assert_eq!(
1345 parse_kvn(&kvn_with_fields(&[
1346 ("TIME_SYSTEM", "GPS"),
1347 ("EPOCH", "2016-12-31T23:59:60.000000Z"),
1348 ])),
1349 Err(OmmError::InvalidField {
1350 field: "civil datetime",
1351 kind: OmmInputErrorKind::InvalidCivilTime,
1352 })
1353 );
1354 }
1355
1356 #[test]
1357 fn parse_kvn_rejects_invalid_leap_second_range() {
1358 assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:61.000000Z")).is_err());
1359 assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:-1.000000Z")).is_err());
1360 }
1361
1362 #[test]
1363 fn parse_kvn_requires_fractional_epoch_digits() {
1364 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.500"))
1365 .expect("fractional epoch");
1366 assert_eq!(omm.epoch.microsecond, 500_000);
1367
1368 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.5Z"))
1369 .expect("fractional epoch with UTC suffix");
1370 assert_eq!(omm.epoch.microsecond, 500_000);
1371
1372 for epoch in [
1373 "2026-06-17T04:32:52.abc",
1374 "2026-06-17T04:32:52.abcZ",
1375 "2026-06-17T04:32:52.5x",
1376 "2026-06-17T04:32:52.5xZ",
1377 "2026-06-17T04:32:52.",
1378 ] {
1379 assert!(
1380 matches!(
1381 parse_kvn(&kvn_with_field("EPOCH", epoch)),
1382 Err(OmmError::Epoch(_))
1383 ),
1384 "{epoch} must be rejected"
1385 );
1386 }
1387 }
1388
1389 #[test]
1390 fn parse_kvn_preserves_sub_microsecond_epoch_seconds() {
1391 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.9999995"))
1392 .expect("fractional epoch");
1393 assert_eq!(
1394 omm.epoch,
1395 OmmEpoch {
1396 year: 2026,
1397 month: 6,
1398 day: 17,
1399 hour: 4,
1400 minute: 32,
1401 second: 52,
1402 microsecond: 999_999,
1403 femtosecond: 500_000_000,
1404 }
1405 );
1406 assert!(
1407 encode_kvn(&omm).contains("EPOCH = 2026-06-17T04:32:52.999999500000000"),
1408 "sub-microsecond epoch must encode with high fractional precision"
1409 );
1410 }
1411
1412 #[test]
1413 fn parse_kvn_preserves_continuous_time_sub_microsecond_epoch_near_day() {
1414 let omm = parse_kvn(&kvn_with_fields(&[
1415 ("TIME_SYSTEM", "GPS"),
1416 ("EPOCH", "2026-06-17T23:59:59.9999995"),
1417 ]))
1418 .expect("continuous-time fractional epoch");
1419 assert_eq!(
1420 omm.epoch,
1421 OmmEpoch {
1422 year: 2026,
1423 month: 6,
1424 day: 17,
1425 hour: 23,
1426 minute: 59,
1427 second: 59,
1428 microsecond: 999_999,
1429 femtosecond: 500_000_000,
1430 }
1431 );
1432 }
1433
1434 #[test]
1435 fn parse_kvn_preserves_continuous_time_sub_microsecond_epoch_near_year() {
1436 let ordinary = parse_kvn(&kvn_with_fields(&[
1437 ("TIME_SYSTEM", "GPS"),
1438 ("EPOCH", "2026-12-31T23:59:58.123456"),
1439 ]))
1440 .expect("ordinary continuous-time epoch");
1441 assert_eq!(
1442 ordinary.epoch,
1443 OmmEpoch {
1444 year: 2026,
1445 month: 12,
1446 day: 31,
1447 hour: 23,
1448 minute: 59,
1449 second: 58,
1450 microsecond: 123_456,
1451 femtosecond: 0,
1452 }
1453 );
1454 assert!(
1455 encode_kvn(&ordinary).contains("EPOCH = 2026-12-31T23:59:58.123456"),
1456 "ordinary epoch must encode unchanged"
1457 );
1458
1459 let carried = parse_kvn(&kvn_with_fields(&[
1460 ("TIME_SYSTEM", "GPS"),
1461 ("EPOCH", "2026-12-31T23:59:59.9999995"),
1462 ]))
1463 .expect("continuous-time fractional epoch near year boundary");
1464 assert_eq!(
1465 carried.epoch,
1466 OmmEpoch {
1467 year: 2026,
1468 month: 12,
1469 day: 31,
1470 hour: 23,
1471 minute: 59,
1472 second: 59,
1473 microsecond: 999_999,
1474 femtosecond: 500_000_000,
1475 }
1476 );
1477 assert!(
1478 encode_kvn(&carried).contains("EPOCH = 2026-12-31T23:59:59.999999500000000"),
1479 "sub-microsecond year-end epoch must encode unchanged"
1480 );
1481 }
1482
1483 #[test]
1484 fn kvn_round_trips_through_struct() {
1485 let omm = parse_kvn(ISS_KVN).unwrap();
1486 let reparsed = parse_kvn(&encode_kvn(&omm)).unwrap();
1487 assert_eq!(omm, reparsed);
1488 }
1489
1490 #[test]
1491 fn kvn_re_encodes_catalog_epoch_byte_faithfully() {
1492 let source_epoch = ISS_KVN
1496 .lines()
1497 .find_map(|line| match line.split_once('=') {
1498 Some((key, value)) if key.trim() == "EPOCH" => Some(value.trim()),
1499 _ => None,
1500 })
1501 .expect("fixture EPOCH");
1502 let encoded = encode_kvn(&parse_kvn(ISS_KVN).unwrap());
1503 assert!(
1504 encoded.contains(&format!("EPOCH = {source_epoch}\n")),
1505 "catalog epoch {source_epoch} must re-encode byte-faithfully"
1506 );
1507 assert_eq!(source_epoch.len(), "2026-06-17T04:32:52.099296".len());
1508 }
1509
1510 #[test]
1511 fn kvn_round_trips_femtosecond_epoch_through_struct() {
1512 let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.9999995")).unwrap();
1514 assert_eq!(omm.epoch.femtosecond, 500_000_000);
1515 let reparsed = parse_kvn(&encode_kvn(&omm)).unwrap();
1516 assert_eq!(omm, reparsed);
1517 assert_eq!(reparsed.epoch.femtosecond, 500_000_000);
1518 }
1519
1520 #[test]
1521 fn xml_matches_kvn_orbital_content() {
1522 let kvn = parse_kvn(ISS_KVN).unwrap();
1523 let xml = parse_xml(ISS_XML).unwrap();
1524 assert_eq!(canonical(&kvn), canonical(&xml));
1525 }
1526
1527 #[test]
1528 fn xml_round_trips_through_struct() {
1529 let omm = parse_xml(ISS_XML).unwrap();
1530 let reparsed = parse_xml(&encode_xml(&omm)).unwrap();
1531 assert_eq!(omm, reparsed);
1532 }
1533
1534 #[test]
1535 fn parse_kvn_rejects_xml_illegal_text_controls() {
1536 let err = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\u{0005}SGP4"))
1537 .expect_err("XML-illegal control characters must not enter OMM text fields");
1538 assert_eq!(
1539 err,
1540 OmmError::Field(
1541 "field MEAN_ELEMENT_THEORY contains XML-illegal character U+0005".to_string()
1542 )
1543 );
1544
1545 let omm = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\tSGP4"))
1546 .expect("XML-legal text control must remain valid");
1547 let reparsed = parse_xml(&encode_xml(&omm)).expect("encoded OMM must remain valid XML");
1548 assert_eq!(omm, reparsed);
1549 }
1550
1551 #[test]
1552 fn xml_round_trip_preserves_carriage_returns_in_text_values() {
1553 for value in ["SGP\rSGP4", "SGP\r\nSGP4"] {
1554 let mut omm = parse_kvn(ISS_KVN).expect("base OMM must parse");
1555 omm.mean_element_theory = Some(value.to_string());
1556 let encoded = encode_xml(&omm);
1557 assert!(encoded.contains("
"));
1558 assert!(!encoded.contains('\r'));
1559 let reparsed = parse_xml(&encoded).expect("encoded OMM must remain valid XML");
1560 assert_eq!(omm.mean_element_theory, reparsed.mean_element_theory);
1561 assert_eq!(omm, reparsed);
1562 }
1563 }
1564
1565 #[test]
1566 fn json_matches_kvn_orbital_content() {
1567 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1568 let kvn = parse_kvn(ISS_KVN).unwrap();
1569 let json = parse_json(ISS_JSON).unwrap();
1570 assert_eq!(canonical(&kvn), canonical(&json));
1571 }
1572
1573 #[test]
1574 fn json_round_trips_through_struct() {
1575 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1576 let omm = parse_json(ISS_JSON).unwrap();
1577 let reparsed = parse_json(&encode_json(&omm)).unwrap();
1578 assert_eq!(omm, reparsed);
1579 }
1580
1581 #[test]
1582 fn json_array_round_trips_through_struct() {
1583 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1584 let omm = parse_json(ISS_JSON).unwrap();
1585 let encoded = encode_json_array(std::slice::from_ref(&omm));
1586 let reparsed = parse_json_array(&encoded).unwrap();
1587 assert_eq!(reparsed.skipped, 0);
1588 assert_eq!(reparsed.omms, vec![omm]);
1589 }
1590
1591 #[test]
1592 fn csv_matches_json_orbital_content() {
1593 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1594 let csv = parse_csv(ISS_CSV).unwrap();
1595 let json = parse_json(ISS_JSON).unwrap();
1596 assert_eq!(canonical(&csv), canonical(&json));
1597 }
1598
1599 #[test]
1600 fn csv_round_trips_through_struct() {
1601 let omm = parse_csv(ISS_CSV).unwrap();
1602 let reparsed = parse_csv(&encode_csv(std::slice::from_ref(&omm))).unwrap();
1603 assert_eq!(omm, reparsed);
1604 }
1605
1606 #[test]
1607 fn csv_preserves_sub_microsecond_epoch() {
1608 let text = ISS_CSV.replace(
1609 "2026-06-17T04:32:52.099296",
1610 "2026-06-17T04:32:52.099296123456789",
1611 );
1612 let omm = parse_csv(&text).expect("high-precision CSV epoch");
1613 assert_eq!(omm.epoch.microsecond, 99_296);
1614 assert_eq!(omm.epoch.femtosecond, 123_456_789);
1615 }
1616
1617 #[test]
1618 fn parse_csv_array_skips_malformed_rows_and_counts_them() {
1619 let mut text = String::from(ISS_CSV);
1620 text.push('\n');
1621 text.push_str("BROKEN,ROW\n");
1622 text.push_str(ISS_CSV.lines().nth(1).expect("CSV data row"));
1623 let parsed = parse_csv_array(&text).expect("CSV with bad row still parses");
1624 assert_eq!(parsed.skipped, 1);
1625 assert_eq!(parsed.omms.len(), 2);
1626 assert_eq!(
1627 parsed
1628 .omms
1629 .iter()
1630 .map(|omm| omm.norad_cat_id)
1631 .collect::<Vec<_>>(),
1632 vec![25544, 25544]
1633 );
1634 }
1635
1636 #[test]
1637 fn csv_quotes_delimiters() {
1638 let mut omm = parse_csv(ISS_CSV).unwrap();
1639 omm.object_name = Some("SAT, \"A\"".to_string());
1640 let encoded = encode_csv(std::slice::from_ref(&omm));
1641 assert!(encoded.contains("\"SAT, \"\"A\"\"\""));
1642 let reparsed = parse_csv(&encoded).unwrap();
1643 assert_eq!(reparsed.object_name.as_deref(), Some("SAT, \"A\""));
1644 }
1645
1646 #[test]
1647 fn parse_auto_detects_encoding() {
1648 let from_kvn = parse(ISS_KVN).unwrap();
1649 let from_xml = parse(ISS_XML).unwrap();
1650 assert_eq!(parse_kvn(ISS_KVN).unwrap(), from_kvn);
1651 assert_eq!(parse_xml(ISS_XML).unwrap(), from_xml);
1652 assert_eq!(canonical(&from_kvn), canonical(&from_xml));
1653 }
1654
1655 #[test]
1656 fn parse_auto_detects_json_array() {
1657 const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1658 assert_eq!(parse(ISS_JSON).unwrap(), parse_json(ISS_JSON).unwrap());
1660 }
1661
1662 #[test]
1663 fn parse_auto_detects_csv() {
1664 assert_eq!(parse(ISS_CSV).unwrap(), parse_csv(ISS_CSV).unwrap());
1665 }
1666
1667 #[test]
1668 fn parse_json_array_skips_malformed_objects_and_counts_them() {
1669 let good = |norad: u32, id: &str| {
1674 format!(
1675 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}}"#
1676 )
1677 };
1678 let text = format!(
1679 "[{}, \"not an object\", {{\"OBJECT_NAME\":\"BROKEN\",\"NORAD_CAT_ID\":99999}}, {}]",
1680 good(25544, "1998-067A"),
1681 good(25545, "1998-067B"),
1682 );
1683
1684 let result = parse_json_array(&text).expect("array with bad entries must still parse");
1685 assert_eq!(result.skipped, 2, "the string and the malformed object");
1686 let norads: Vec<u32> = result.omms.iter().map(|o| o.norad_cat_id).collect();
1687 assert_eq!(norads, vec![25544, 25545], "both good OMMs must survive");
1688 }
1689
1690 #[test]
1691 fn bstar_quantizes_onto_assumed_decimal_grid() {
1692 let omm = parse_kvn(ISS_KVN).unwrap();
1695 let es = omm.to_element_set().expect("valid OMM bridge");
1696 assert_eq!(es.bstar, 0.17172 * 10.0_f64.powi(-3));
1697 assert_ne!(es.bstar, omm.bstar);
1698 }
1699
1700 #[test]
1701 fn to_element_set_rejects_invalid_bridge_fields() {
1702 let mut omm = parse_kvn(ISS_KVN).unwrap();
1703 omm.mean_motion = f64::NAN;
1704 assert_eq!(
1705 omm.to_element_set(),
1706 Err(OmmError::InvalidField {
1707 field: "mean_motion",
1708 kind: OmmInputErrorKind::NonFinite
1709 })
1710 );
1711
1712 let mut omm = parse_kvn(ISS_KVN).unwrap();
1713 omm.eccentricity = 1.0;
1714 assert_eq!(
1715 omm.to_element_set(),
1716 Err(OmmError::InvalidField {
1717 field: "eccentricity",
1718 kind: OmmInputErrorKind::OutOfRange
1719 })
1720 );
1721 }
1722
1723 #[test]
1724 fn from_omm_preserves_epoch_year_outside_tle_pivot_range() {
1725 let omm = parse_kvn(&kvn_with_field("EPOCH", "2057-01-01T00:00:00.000000"))
1726 .expect("future OMM epoch");
1727 let sat = Satellite::from_omm(&omm).expect("OMM with full-year epoch must initialize");
1728
1729 let epoch = sat.epoch_jd();
1730 let actual_jd = epoch.0 + epoch.1;
1731 let expected_jd = crate::astro::time::scales::julian_day_number(2057, 1, 1) as f64 - 0.5;
1732 let aliased_1957_jd =
1733 crate::astro::time::scales::julian_day_number(1957, 1, 1) as f64 - 0.5;
1734
1735 assert!(
1736 (actual_jd - expected_jd).abs() < 1.0e-9,
1737 "OMM epoch JD {actual_jd} must match the true 2057 epoch {expected_jd}",
1738 );
1739 assert!(
1740 (actual_jd - aliased_1957_jd).abs() > 36_000.0,
1741 "OMM epoch JD {actual_jd} must not alias to 1957 {aliased_1957_jd}",
1742 );
1743 }
1744
1745 #[test]
1746 fn from_omm_preserves_sub_microsecond_year_end_epoch_directly() {
1747 for (epoch, expected_year) in [
1748 ("2021-12-31T23:59:59.9999995", 2021),
1749 ("2020-12-31T23:59:59.9999995", 2020),
1750 ] {
1751 let omm = parse_kvn(&kvn_with_field("EPOCH", epoch)).expect("year-end OMM epoch");
1752 assert_eq!(omm.epoch.year, expected_year);
1753 assert_eq!(omm.epoch.month, 12);
1754 assert_eq!(omm.epoch.day, 31);
1755 assert_eq!(omm.epoch.hour, 23);
1756 assert_eq!(omm.epoch.minute, 59);
1757 assert_eq!(omm.epoch.second, 59);
1758 assert_eq!(omm.epoch.microsecond, 999_999);
1759 assert_eq!(omm.epoch.femtosecond, 500_000_000);
1760
1761 let sat =
1762 Satellite::from_omm(&omm).expect("sub-microsecond year-end OMM must initialize");
1763 let epoch_jd = sat.epoch_jd();
1764 let actual_jd = epoch_jd.0 + epoch_jd.1;
1765 let expected_jd =
1766 crate::astro::time::scales::julian_day_number(expected_year, 12, 31) as f64 - 0.5
1767 + (86_399.999_999_5 / 86_400.0);
1768
1769 assert!(
1770 (actual_jd - expected_jd).abs() < 1.0e-9,
1771 "{epoch} converted to JD {actual_jd}, expected {expected_jd}",
1772 );
1773 }
1774 }
1775
1776 #[test]
1777 fn from_sgp4_julian_date_normalizes_split_fraction_carry() {
1778 let (jd_midnight, _) =
1779 crate::astro::time::civil::split_julian_date(2026, 12, 31, 0, 0, 0.0);
1780 let epoch = OmmEpoch::from_sgp4_julian_date(JulianDate(jd_midnight, 1.0));
1781
1782 assert_eq!(
1783 epoch,
1784 OmmEpoch {
1785 year: 2027,
1786 month: 1,
1787 day: 1,
1788 hour: 0,
1789 minute: 0,
1790 second: 0,
1791 microsecond: 0,
1792 femtosecond: 0,
1793 }
1794 );
1795 }
1796
1797 #[test]
1798 fn from_omm_rejects_invalid_sgp4_element_fields() {
1799 let mut omm = parse_kvn(ISS_KVN).unwrap();
1800 omm.mean_motion = f64::NAN;
1801 let err = Satellite::from_omm(&omm).expect_err("non-finite mean motion must error");
1802 assert_eq!(
1803 err,
1804 Sgp4Error::InvalidInput {
1805 field: "mean_motion",
1806 kind: crate::astro::sgp4::Sgp4InputErrorKind::NonFinite,
1807 }
1808 );
1809
1810 let mut omm = parse_kvn(ISS_KVN).unwrap();
1811 omm.eccentricity = 1.0;
1812 let err = Satellite::from_omm(&omm).expect_err("eccentricity >= 1 must error");
1813 assert_eq!(
1814 err,
1815 Sgp4Error::InvalidInput {
1816 field: "eccentricity",
1817 kind: crate::astro::sgp4::Sgp4InputErrorKind::OutOfRange,
1818 }
1819 );
1820 }
1821}