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