Skip to main content

sidereon_core/astro/
omm.rs

1//! CCSDS Orbit Mean-Elements Message (OMM) parser, encoder, and SGP4 bridge.
2//!
3//! OMM (CCSDS 502.0-B) is the modern replacement for the TLE: it carries the
4//! same SGP4/SDP4 mean elements (mean motion, eccentricity, inclination, RAAN,
5//! argument of perigee, mean anomaly, B\*, epoch, ...) plus richer metadata
6//! (object name, NORAD id, reference frame, time system, element-set theory).
7//! CelesTrak and Space-Track serve general-perturbations (GP) data as OMM in
8//! three interchangeable encodings: KVN (`KEY = VALUE` lines), XML, and JSON.
9//!
10//! This module follows the format-agnostic design used on the Elixir side: a
11//! single canonical container ([`Omm`]) holds the CCSDS field set as plain data
12//! with documented units, and the per-encoding readers/writers map onto it. The
13//! element values flow into the validated SGP4 path through [`Omm::to_element_set`]
14//! (consumed by [`Satellite::from_elements`]) so an OMM drives SGP4 without
15//! downgrading its full epoch into the legacy TLE year/day representation.
16//!
17//! ## TLE-derived field quantization
18//!
19//! An OMM carries a full UTC calendar `EPOCH`, which is converted directly to
20//! SGP4's split Julian date. B\* and the second mean-motion derivative are still
21//! TLE-derived GP parameters:
22//!
23//! - **B\* and the second mean-motion derivative.** A TLE stores these in its
24//!   "assumed decimal" field (five significant mantissa digits and a power-of-ten
25//!   exponent), and that quantized value is what SGP4 actually receives. OMM
26//!   prints the same quantities as plain decimals, so the bridge re-quantizes
27//!   them onto the assumed-decimal grid via [`crate::astro::tle`].
28
29use 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
36/// Leaf CCSDS field element names carried in an OMM XML message (the format-set
37/// version, `CCSDS_OMM_VERS`, is an attribute on `<omm>` and is handled
38/// separately). Decoding maps each onto the shared `(key, value)` field set, the
39/// same one the KVN tokenizer produces.
40const 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/// UTC calendar epoch as carried by an OMM, split into the components a KVN/XML
67/// `EPOCH` (or JSON `EPOCH`) string spells out. Stored as integers so the epoch
68/// re-encodes losslessly and converts directly to the SGP4 epoch.
69#[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    /// Fractional second expressed in whole microseconds (0..=999_999).
78    pub microsecond: u32,
79}
80
81/// Canonical, format-agnostic OMM container.
82///
83/// Pure data: it knows nothing about KVN/XML/JSON serialization. The numeric
84/// element values use standard astrodynamic units (angles in degrees, mean
85/// motion in revolutions/day, its derivatives in rev/day^2 and rev/day^3, B\*
86/// in inverse earth-radii) and are stored as directly parsed `f64`s, so every
87/// encoding decodes to the same value.
88#[derive(Debug, Clone, PartialEq)]
89pub struct Omm {
90    // -- Header / metadata --
91    pub ccsds_omm_vers: String,
92    pub creation_date: Option<String>,
93    pub originator: Option<String>,
94    pub object_name: Option<String>,
95    /// International designator, CCSDS form (e.g. `"1998-067A"`).
96    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    // -- Mean elements --
103    pub epoch: OmmEpoch,
104    /// Mean motion, revolutions per day.
105    pub mean_motion: f64,
106    /// Eccentricity, dimensionless, in [0, 1).
107    pub eccentricity: f64,
108    /// Inclination, degrees.
109    pub inclination_deg: f64,
110    /// Right ascension of the ascending node, degrees.
111    pub ra_of_asc_node_deg: f64,
112    /// Argument of pericenter, degrees.
113    pub arg_of_pericenter_deg: f64,
114    /// Mean anomaly, degrees.
115    pub mean_anomaly_deg: f64,
116
117    // -- TLE-derived parameters --
118    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    /// SGP4 drag term B\*, inverse earth-radii.
124    pub bstar: f64,
125    /// First derivative of mean motion, rev/day^2.
126    pub mean_motion_dot: f64,
127    /// Second derivative of mean motion, rev/day^3.
128    pub mean_motion_ddot: f64,
129}
130
131/// Failure modes of the OMM readers.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum OmmError {
134    /// A required field was absent from the message.
135    MissingField(&'static str),
136    /// A decoded scalar field failed boundary validation.
137    InvalidField {
138        field: &'static str,
139        kind: OmmInputErrorKind,
140    },
141    /// A numeric/integer field could not be parsed.
142    Field(String),
143    /// The `EPOCH` value was malformed.
144    Epoch(String),
145}
146
147/// OMM boundary-validation failure category.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum OmmInputErrorKind {
150    /// A required field was absent.
151    Missing,
152    /// A floating-point field was NaN or infinite.
153    NonFinite,
154    /// A floating-point field could not be parsed.
155    FloatParse,
156    /// An integer field could not be parsed.
157    IntParse,
158    /// A positive physical field was zero or negative.
159    NotPositive,
160    /// A non-negative physical field was negative.
161    Negative,
162    /// A finite numeric field was outside its accepted range.
163    OutOfRange,
164    /// A civil date field was out of range.
165    InvalidCivilDate,
166    /// A civil time field was out of range.
167    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
218// ── KVN ──────────────────────────────────────────────────────────────
219
220/// Parse a CCSDS OMM in KVN (`KEY = VALUE`) encoding into an [`Omm`].
221///
222/// Blank lines and lines without an `=` are ignored; keys and values are
223/// trimmed. Numeric values accept the CelesTrak forms, including a leading
224/// decimal point (`.0004737`) and scientific notation (`.17172E-3`).
225pub 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
239/// Encode an [`Omm`] as a CCSDS OMM KVN message.
240///
241/// Numeric values use their shortest round-tripping decimal form, so parsing the
242/// output reproduces the same `f64`s. The epoch is emitted to microseconds.
243pub 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
282// ── XML ──────────────────────────────────────────────────────────────
283
284/// Parse a CCSDS OMM in XML encoding into an [`Omm`].
285///
286/// Uses the `roxmltree` DOM reader (which handles the `<?xml?>` declaration,
287/// namespaces, comments, and entity decoding) and then reads the known CCSDS
288/// leaf elements by name into the shared `(key, value)` field set, exactly the
289/// representation the KVN tokenizer produces, so both encodings flow through the
290/// single field mapping. The format-set version is taken from the `version`
291/// attribute on `<omm>`.
292pub 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
316/// Encode an [`Omm`] as a CCSDS OMM XML message, following the CelesTrak/`ndm`
317/// document layout. Numeric values use their shortest round-tripping form and
318/// text values are XML-escaped, so parsing the output reproduces the same [`Omm`].
319pub 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// ── JSON ─────────────────────────────────────────────────────────────
375
376/// Parse a CCSDS/CelesTrak OMM in JSON encoding into an [`Omm`].
377///
378/// Accepts either a single object or an array of objects (CelesTrak GP queries
379/// return an array); the first record is taken. Each member is mapped onto the
380/// shared `(key, value)` field set - numbers stringified, strings taken verbatim
381/// (so the Space-Track quirk of quoting numeric values is handled) - then flows
382/// through the single field mapping. Requires the `json` feature.
383#[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/// Parse a CelesTrak OMM JSON array into every contained [`Omm`].
412///
413/// CelesTrak GP queries return a JSON array of OMM objects; this reads all of
414/// them (a lone object is accepted as a one-element array) through the same
415/// field mapping [`parse_json`] uses. Requires the `json` feature.
416#[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/// Encode an [`Omm`] as a CCSDS/CelesTrak OMM JSON object.
448///
449/// Numeric element values are emitted as JSON numbers (round-tripping the exact
450/// `f64`), strings as JSON strings, and the epoch as an ISO-8601 string, so
451/// parsing the output reproduces the same [`Omm`]. Requires the `json` feature.
452#[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/// Render a JSON scalar as the string the shared field mapping consumes. Numbers
510/// use their canonical decimal form; strings pass through; null becomes empty.
511#[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
523// ── Encoding auto-detect ─────────────────────────────────────────────
524
525/// Parse an OMM in any supported encoding, detecting it from the leading
526/// non-whitespace character: `<` is XML, `{` or `[` is JSON, anything else is
527/// KVN. JSON requires the `json` feature; without it a JSON document returns an
528/// error rather than being misread.
529pub 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
549// ── Field mapping (shared by every encoding) ─────────────────────────
550
551impl Omm {
552    /// Build an [`Omm`] from the decoded CCSDS field set: a list of
553    /// `(key, value)` string pairs as produced by any of the encodings. This is
554    /// the single place the CCSDS field names map onto the canonical container.
555    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
627// ── SGP4 bridge ──────────────────────────────────────────────────────
628
629impl Omm {
630    /// Convert the canonical OMM elements into the SGP4 [`ElementSet`] consumed
631    /// by [`Satellite::from_elements`].
632    ///
633    /// The epoch is converted directly from the OMM calendar timestamp into
634    /// SGP4's split Julian date, preserving years outside the TLE pivot range.
635    /// B\* and the second mean-motion derivative are quantized onto the TLE
636    /// assumed-decimal grid because those GP parameters originate in that field
637    /// format.
638    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    /// Build a propagation-ready [`Satellite`] from an [`Omm`].
658    ///
659    /// Bridges the OMM mean elements into the validated SGP4 element path via
660    /// [`Omm::to_element_set`].
661    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
702// ── Epoch ────────────────────────────────────────────────────────────
703
704impl OmmEpoch {
705    /// Parse a CCSDS `EPOCH` value (`YYYY-MM-DDThh:mm:ss[.ffffff][Z]`, UTC).
706    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    /// Convert directly to the SGP4 split Julian date from the full OMM
748    /// calendar timestamp.
749    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    /// Format as a CCSDS `EPOCH` string with microsecond precision.
761    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
769// ── Numeric helpers ──────────────────────────────────────────────────
770
771fn 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
844/// Shortest decimal form of a value that round-trips back to the same `f64`.
845fn 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    /// Reduce an OMM to its canonical orbital + catalog content (the fields the
857    /// Elixir `Sidereon.Elements` struct carries), blanking the free-text header
858    /// metadata that CelesTrak emits inconsistently across encodings: it labels
859    /// the element theory `SGP/SGP4` in KVN but `SGP4` in XML/JSON, and its JSON
860    /// omits `CENTER_NAME`/`REF_FRAME`/`TIME_SYSTEM` entirely. Cross-encoding
861    /// identity is asserted on this canonical content, which must match exactly.
862    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("&#xD;"));
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        // CelesTrak JSON is a top-level array.
1236        assert_eq!(parse(ISS_JSON).unwrap(), parse_json(ISS_JSON).unwrap());
1237    }
1238
1239    #[test]
1240    fn bstar_quantizes_onto_assumed_decimal_grid() {
1241        // OMM B* is the plain-decimal 0.00017172; the SGP4 element set must carry
1242        // the assumed-decimal value 0.17172e-3 the TLE actually feeds SGP4.
1243        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}